Files
ai-agent/sidecar/sidecar.py
Gabriel Vidal a698f5b929 feat(composer): guideline chips and skill mentions follow "Runs on"
Prompt shortcuts are now shared across runners or scoped to one: `shared` +
`runner` ("local" = the lab, or a worker id). The composer resolves the
runner its prompt lands on (the pick, else the account's online worker, else
the lab) and offers the shared chips first, then that runner's; switching
runner seeds the newly revealed chips' default-on cascade. The resume box
uses the session's own runner. Settings → Prompt shortcuts becomes a Shared
section plus one tab per runner (Lab, each paired worker), with a Shared
checkbox per card and reordering kept within a section. Settings v3
migrates existing configs: worktree, commit, pr, notify, ask, clarify and
tasks become shared; everything else stays the lab's.

A worker run also lists that machine's skills instead of the lab catalog:
the sidecar's new GET /skills reports the default cwd's and the user-level
skills, the hub proxies it at /api/workers/{id}/skills (60 s cache, empty
when offline), and the composer swaps its mentions when the runner is a
worker (no lab agents there either).

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

1132 lines
49 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 hmac
import json
import logging
import os
import pathlib
import re
import signal
import subprocess
import time
import uuid as uuidlib
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
import claude_cli
import feed
import outbox
import pairing
import skills_list
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())
TOKEN = os.environ.get("SIDECAR_TOKEN", "")
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
# The pi-harness runner (runner/cli.mjs): wraps `pi --mode
# json` and mirrors the session as a Claude-Code-schema transcript the viewer
# watches. Launched with the exact same flag shape as `claude`.
RUNNER_BIN = os.environ.get(
"RUNNER_BIN",
str(pathlib.Path(__file__).resolve().parent.parent / "runner" / "cli.mjs"))
DEFAULT_CWD = os.environ.get("SIDECAR_CWD", str(pathlib.Path.home() / "homelab"))
DEFAULT_MODEL = os.environ.get("SIDECAR_MODEL", "opus")
# What a pi-harness run uses when no model tag was picked (an OpenRouter id;
# the runner resolves each id's provider from ~/.pi/agent/models.json, so a
# locally-served id may carry a vendor prefix too — see runner/cli.mjs).
PI_DEFAULT_MODEL = os.environ.get("SIDECAR_PI_MODEL", "qwen/qwen3.8-27b")
# What `claude --model` accepts: a family alias (`opus`) or a full model id
# (`claude-opus-4-8`, `claude-haiku-4-5-20251001`). The viewer picks from the
# live Anthropic model list, so we don't pin an allow-list here — we only reject
# shapes that aren't a model at all, since the value goes onto a command line.
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
# pi model ids additionally carry a vendor prefix and optional thinking suffix
# (`qwen/qwen3.6-35b-a3b`, `…:thinking`).
PI_MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:-]{0,127}$")
# Turning thinking off is the single thinking control the composer exposes, and
# each harness spells it its own way: the claude CLI takes `--thinking disabled`
# (its scale is enabled|adaptive|disabled), pi takes `--thinking off` (its scale
# is off|minimal|low|medium|high|xhigh|max). Thinking *on* emits no flag at all,
# so each CLI keeps whatever its own default level is — we only ever say "off".
THINKING_OFF = {"claude": "disabled", "pi": "off"}
# The claude CLI's `--effort <level>` — how hard the model works a turn. It is
# the *claude-only* replacement for the thinking toggle: the composer shows an
# effort select for claude runs and keeps the on/off toggle for pi, which has no
# equivalent flag. Unset emits nothing, so the CLI keeps its own default.
EFFORT_LEVELS = ("low", "medium", "high", "xhigh", "max")
# Headless sessions can't answer permission prompts, so they run with
# permissions bypassed (same as `--dangerously-skip-permissions`). This is the
# owner's own box acting on its own repo; keep it behind the bearer token.
PERMISSION_MODE = os.environ.get("SIDECAR_PERMISSION_MODE", "bypassPermissions")
# Tools a sidecar-spawned claude run must not use. AskUserQuestion is the
# interactive terminal chooser — headless runs have no terminal to answer it
# on, and the ai-agent's own ask channel is the `ask-form` skill (a form the
# PWA renders inline in the conversation). Space-separated env to override;
# set SIDECAR_DISALLOWED_TOOLS="" to disable.
DISALLOWED_TOOLS = os.environ.get(
"SIDECAR_DISALLOWED_TOOLS", "AskUserQuestion").split()
# Enable Claude Code Remote Control by default so every sidecar-spawned run
# shows up in the Claude app and can be driven from the phone. `claude
# --remote-control` with no name auto-generates one from the hostname prefix.
# Set SIDECAR_REMOTE_CONTROL=0 to opt out.
REMOTE_CONTROL = os.environ.get("SIDECAR_REMOTE_CONTROL", "1") not in ("0", "false", "")
LOG_DIR = pathlib.Path(
os.environ.get("SIDECAR_LOG_DIR", pathlib.Path(__file__).parent / "logs"))
# Where the claude CLI keeps its session transcripts (one dir per cwd slug).
# /fork does its transcript surgery here — it's the host user's own tree.
# This is the *default* account's tree; other accounts keep theirs under their
# own config dir (see ACCOUNTS).
CLAUDE_PROJECTS = pathlib.Path(os.environ.get(
"CLAUDE_PROJECTS_DIR", pathlib.Path.home() / ".claude" / "projects"))
def _parse_accounts(raw: str) -> dict[str, pathlib.Path | None]:
"""``SIDECAR_ACCOUNTS`` → {account id: config dir}.
Format: comma-separated ``id=dir`` pairs (``work=~/.claude-work``). The
default account (``SIDECAR_DEFAULT_ACCOUNT``, ``personal``) is always
present and maps to None: it runs on the CLI's own default config — never
on ``CLAUDE_CONFIG_DIR=~/.claude``, which would move ``.claude.json`` into
the dir and make the logged-in account look brand new."""
out: dict[str, pathlib.Path | None] = {}
for part in (raw or "").split(","):
if "=" not in part:
continue
acct, _, d = part.partition("=")
acct, d = acct.strip().lower(), d.strip()
if re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", acct) and d:
out[acct] = pathlib.Path(os.path.expanduser(d))
return out
# Claude accounts a run can be launched on. An account *is* a config dir:
# CLAUDE_CONFIG_DIR moves the login, .claude.json, settings and the projects/
# transcripts together, so exporting it per run is the whole switch.
DEFAULT_ACCOUNT = os.environ.get("SIDECAR_DEFAULT_ACCOUNT", "personal")
_ACCOUNTS_RAW = os.environ.get("SIDECAR_ACCOUNTS", "")
ACCOUNTS: dict[str, pathlib.Path | None] = {
DEFAULT_ACCOUNT: None, **_parse_accounts(_ACCOUNTS_RAW)}
ACCOUNTS[DEFAULT_ACCOUNT] = None
# With accounts declared, every run authenticates with a subscription login —
# so an ANTHROPIC_API_KEY that leaks into the sidecar's env would silently
# override BOTH accounts and bill the API key instead. Strip it from every run.
# (Unset — the standalone in-container runner — the key may be the only auth,
# so it passes through unchanged.)
STRIP_API_KEY = bool(_ACCOUNTS_RAW.strip())
# `claude auth status` per account for GET /accounts — cached, it spawns a CLI.
ACCOUNT_STATUS_TTL_S = float(os.environ.get("SIDECAR_ACCOUNT_STATUS_TTL_S", "60"))
# How long a freshly launched run is watched before we answer the caller. A
# `claude` that can't start at all (bad `--resume` id, bad cwd, broken install)
# exits non-zero within ~2.5s; anything still alive at the end of the window is
# a real run. Without this the launch is fire-and-forget and a dead-on-arrival
# run looks exactly like a healthy one — the viewer then waits forever for turns
# that never come ("sending…" with no error).
LAUNCH_PROBE_S = float(os.environ.get("SIDECAR_LAUNCH_PROBE_S", "3.5"))
# How long a force-resume waits for the SIGINT'd run to die before escalating
# to SIGKILL. `claude` handles SIGINT by writing the interrupt marker and
# exiting — usually well under a second. The total force-resume worst case
# (STOP_WAIT_S + 2s kill wait + LAUNCH_PROBE_S) must stay under the backend's
# 15s sidecar call timeout.
STOP_WAIT_S = float(os.environ.get("SIDECAR_STOP_WAIT_S", "6"))
app = FastAPI(title="claude-sidecar", docs_url=None, redoc_url=None)
# ---- pid tracking ---------------------------------------------------------
# Each running session is persisted to `logs/<sid>.pid` as a small JSON record
# so the sidecar can re-discover, list and interrupt runs that outlived it. The
# `/proc` start-time pins the record to *this* process, so a recycled PID (a new
# unrelated process that happens to reuse the number) is never mistaken for a
# live session — critical before we send it a signal.
# The runs we launched ourselves, kept only so we can *reap* them. A `claude`
# started with Popen stays our child even though it leads its own session, so
# when it exits it lingers as a zombie until the parent waits on it — and a
# zombie still answers `os.kill(pid, 0)` and still has a `/proc/<pid>/stat`. So
# a finished run kept looking "alive", `/resume` took the idempotency shortcut
# below, launched nothing, and the prompt was silently dropped. We poll (reap)
# them before every liveness check, and treat state `Z` as dead as a belt-and-
# braces guard (a survivor of a previous sidecar instance is reparented to init,
# which reaps it for us).
_procs: dict[str, subprocess.Popen] = {}
def _reap() -> None:
"""Wait on any of our children that have exited, so no zombie lingers."""
for sid, proc in list(_procs.items()):
if proc.poll() is not None: # returncode set ⇒ child reaped
_procs.pop(sid, None)
# Linux reads /proc directly (no dependency — the host sidecar's venv has only
# fastapi + uvicorn); anywhere else (the macOS worker) falls back to psutil,
# which the worker installer puts in its venv. Both answer the same two
# questions: is the process a zombie, and what is its start-time fingerprint.
_HAVE_PROC = pathlib.Path("/proc/self/stat").exists()
def _psutil_proc(pid: int):
try:
import psutil # noqa: PLC0415 — optional, macOS worker only
return psutil.Process(pid)
except Exception: # ImportError, NoSuchProcess, AccessDenied
return None
def _proc_state(pid: int) -> str | None:
"""Process state letter ('Z' = zombie, 'S'/'R' = live), from /proc/pid/stat
on Linux, psutil elsewhere."""
if not _HAVE_PROC:
p = _psutil_proc(pid)
try:
return None if p is None else (
"Z" if p.status() == "zombie" else "R")
except Exception:
return None
try:
stat = pathlib.Path(f"/proc/{pid}/stat").read_text()
return stat[stat.rindex(")") + 1:].split()[0]
except (OSError, ValueError, IndexError):
return None
def _proc_starttime(pid: int) -> int | float | None:
"""Process start-time fingerprint: clock ticks since boot (field 22 of
/proc/pid/stat) on Linux, psutil's ``create_time()`` (epoch seconds,
rounded) elsewhere.
Unique per (pid, boot), so it fingerprints the exact process and lets us
detect PID reuse. The `comm` field (field 2) can contain spaces and parens,
so we parse everything after the final ')'."""
if not _HAVE_PROC:
p = _psutil_proc(pid)
try:
return None if p is None else round(p.create_time(), 2)
except Exception:
return None
try:
stat = pathlib.Path(f"/proc/{pid}/stat").read_text()
after = stat[stat.rindex(")") + 1:].split()
return int(after[19]) # field 22 overall (fields 3.. after comm)
except (OSError, ValueError, IndexError):
return None
def _pidfile(sid: str) -> pathlib.Path:
return LOG_DIR / f"{sid}.pid"
def _read_record(sid: str) -> dict | None:
"""Load a session's pid record, tolerating the legacy plain-integer format."""
try:
raw = _pidfile(sid).read_text().strip()
except OSError:
return None
if not raw:
return None
try:
rec = json.loads(raw)
if isinstance(rec, dict) and rec.get("pid"):
return rec
except ValueError:
pass
try: # legacy pidfile: bare pid, no start-time guard
return {"pid": int(raw)}
except ValueError:
return None
def _alive(rec: dict) -> bool:
"""True if the recorded process is still *running* — same process, not a zombie.
Reaps our finished children first: an exited-but-unwaited child would
otherwise still pass every check below and be mistaken for a live run."""
pid = rec.get("pid")
if not pid:
return False
_reap()
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True # exists but owned by someone else — still 'alive'
except OSError:
return False
if _proc_state(pid) == "Z":
return False # exited, just not reaped yet
want = rec.get("starttime")
if want is not None:
now = _proc_starttime(pid)
if now is not None and now != want:
return False # PID was recycled into a different process
return True
def _live_record(sid: str) -> dict | None:
"""The session's pid record if its run is still going; else None (and the
stale pidfile is dropped, so finished runs don't accumulate on disk)."""
rec = _read_record(sid)
if rec is None:
return None
if _alive(rec):
return rec
_pidfile(sid).unlink(missing_ok=True)
return None
@app.on_event("startup")
def _reconcile_pidfiles() -> None:
"""On (re)start, GC pidfiles whose process is gone and count the survivors.
The spawned runs are daemons, so after a sidecar restart the disk pidfiles
are our only record of what's still running — reconcile them into a clean
view and drop the dead ones so /interrupt never signals a stale PID."""
if not LOG_DIR.is_dir():
return
survived = 0
for p in sorted(LOG_DIR.glob("*.pid")):
rec = _read_record(p.stem)
if rec is None or not _alive(rec):
p.unlink(missing_ok=True)
else:
survived += 1
print(f"[sidecar] startup reconcile: {survived} session(s) survived restart",
flush=True)
class SpawnBody(BaseModel):
prompt: str
sessionId: str | None = None # caller may pre-pick the id (ai-agent does)
model: str | None = None
cwd: str | None = None
harness: str | None = None # "claude" (default) | "pi"
thinking: bool | None = None # False ⇒ run with thinking off; None = on
effort: str | None = None # claude only; None ⇒ the CLI's default
account: str | None = None # claude only; None ⇒ DEFAULT_ACCOUNT
class ResumeBody(BaseModel):
sessionId: str # the existing session to continue
prompt: str # the new user turn to send
model: str | None = None
cwd: str | None = None # must match the session's original cwd
harness: str | None = None # "claude" (default) | "pi"
thinking: bool | None = None # False ⇒ run with thinking off; None = on
effort: str | None = None # claude only; None ⇒ what it last ran with
# The account the session started on: its transcript only exists in that
# account's projects/, so a resume on any other one can't find it.
account: str | None = None
# force=True: a run that's still live doesn't answer `reused` — it is
# stopped first (SIGINT its group, wait for it to die, escalate to SIGKILL)
# and the resume then launches with the new prompt. This is how "send a
# message to a running conversation" restarts the session on the new turn.
force: bool = False
class ForkBody(BaseModel):
sessionId: str # fresh uuid for the fork (backend-minted)
srcSessionId: str # the session to branch off
prompt: str # the edited message — the fork's next turn
cutLine: int # raw JSONL line index of the fork point
cutText: str | None = None # collapsed prefix of that message (canary)
cutUserOrd: int | None = None # its 0-based ordinal among user msgs (pi)
model: str | None = None
cwd: str | None = None # must match the source session's cwd
harness: str | None = None # "claude" (default) | "pi"
account: str | None = None # the source session's account
def _auth(authorization: str | None) -> None:
"""Bearer check. The token is SIDECAR_TOKEN (the host sidecar, the
in-container runner) or, on a paired worker, the one minted at pairing."""
token = TOKEN or pairing.token()
if not token:
raise HTTPException(500, "sidecar not configured (no SIDECAR_TOKEN, "
"not paired)")
if not hmac.compare_digest(authorization or "", f"Bearer {token}"):
raise HTTPException(401, "unauthorized")
def _valid_uuid(sid: str) -> str:
sid = (sid or "").strip()
try:
uuidlib.UUID(sid) # claude requires a valid UUID for --session-id/--resume
except ValueError:
raise HTTPException(400, "sessionId must be a valid UUID")
return sid
def _valid_model(model: str | None, harness: str = "claude") -> str | None:
"""Sanity-check a `--model` value (an alias or a model id). None = unset."""
model = (model or "").strip()
if not model:
return None
rx = PI_MODEL_RE if harness == "pi" else MODEL_RE
if not rx.match(model):
raise HTTPException(400, f"invalid model: {model!r}")
return model
def _valid_harness(harness: str | None) -> str:
h = (harness or "claude").strip().lower()
if h not in ("claude", "pi"):
raise HTTPException(400, f"invalid harness: {harness!r}")
return h
def _thinking_args(thinking: bool | None, harness: str) -> list[str]:
"""The `--thinking` flag for a run, in the harness's own vocabulary.
Only *off* is expressible: thinking on (or unset) emits nothing, leaving each
CLI on its own default level. Both harnesses take the same flag name, so this
is a straight append onto either argv."""
if thinking is False:
return ["--thinking", THINKING_OFF[harness]]
return []
def _effort_args(effort: str | None, harness: str) -> list[str]:
"""The `--effort` flag for a run. Claude-only, and validated as an
allow-list (unlike `--model`, the CLI's scale is a closed set), so a bogus
value is a 400 here rather than a run that dies on an unknown flag value.
Unset — or any pi run — emits nothing and the CLI keeps its default."""
effort = (effort or "").strip().lower()
if not effort or harness != "claude":
return []
if effort not in EFFORT_LEVELS:
raise HTTPException(400, f"invalid effort: {effort!r}")
return ["--effort", effort]
def _valid_account(account: str | None) -> str:
"""The account a claude run launches on (unset ⇒ DEFAULT_ACCOUNT). An
unknown id is a 400 — never a quiet fallback onto the default account,
which would bill the wrong subscription."""
a = (account or "").strip().lower() or DEFAULT_ACCOUNT
if a not in ACCOUNTS:
raise HTTPException(400, f"unknown account: {account!r} "
f"(known: {', '.join(ACCOUNTS)})")
return a
def _require_login(account: str) -> None:
"""Fail loudly when a non-default account's config dir has no login.
Without a `.credentials.json` there, `claude -p` would stop on "not logged
in" — or worse, fall back to an API key. Refusing up front puts the fix in
the error the composer shows."""
cfg = ACCOUNTS.get(account)
if cfg is None:
return # the default account — its login is the host user's own
if not (cfg / ".credentials.json").is_file():
raise HTTPException(409, claude_cli.error_detail(
f"account {account!r} is not logged in", kind="auth",
account=account))
# The `notify` CLI (cli/notify.py — push / ask / form through the hub) ships
# with this repo so every runner has it: appended to each run's PATH (the lab
# host's own skill aliases come first and resolve to the same file).
CLI_DIR = pathlib.Path(__file__).resolve().parent.parent / "cli"
# 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.
IS_WORKER = not TOKEN
OUTBOX = outbox.Outbox(pairing.state_dir() / "outbox.json")
def _outbox_url() -> str:
bind = os.environ.get("SIDECAR_BIND", "127.0.0.1")
return f"http://{bind}:{os.environ.get('SIDECAR_PORT', '8790')}"
def _run_env(sid: str, account: str | None) -> dict[str, str]:
"""``_account_env`` plus what the notify CLI needs: the session id (so a
push links the right conversation without a resolver) and, on a worker,
the outbox transport."""
env = _account_env(account)
env["CLAUDE_SESSION_ID"] = sid
env["PATH"] = f"{env.get('PATH', '')}:{CLI_DIR}" if env.get("PATH") else str(CLI_DIR)
if IS_WORKER:
env["AI_AGENT_OUTBOX_URL"] = _outbox_url()
env["AI_AGENT_OUTBOX_TOKEN"] = outbox.run_token(pairing.state_dir())
return env
def _account_env(account: str | None) -> dict[str, str]:
"""The environment a run launches with: the account's config dir exported
as CLAUDE_CONFIG_DIR (the default account gets it *removed*, so a stray
value in the unit can't redirect it), and ANTHROPIC_API_KEY stripped when
accounts are declared (see STRIP_API_KEY). ``account`` None = a pi run,
which isn't a Claude login at all: the host env passes through as-is."""
env = {**os.environ, "CLAUDE_CODE_ENTRYPOINT": "ai-agent-sidecar"}
if account is None:
return env
env.pop("CLAUDE_CONFIG_DIR", None)
if STRIP_API_KEY:
env.pop("ANTHROPIC_API_KEY", None)
cfg = ACCOUNTS.get(account)
if cfg is not None:
env["CLAUDE_CONFIG_DIR"] = str(cfg)
return env
def _account_projects(account: str) -> pathlib.Path:
"""Where an account's transcripts live (one dir per cwd slug)."""
cfg = ACCOUNTS.get(account)
return CLAUDE_PROJECTS if cfg is None else cfg / "projects"
def _remote_control(account: str) -> list[str]:
"""`--remote-control` for default-account runs only: a work run would list
itself in the employer org's claude.ai session list."""
return ["--remote-control"] if REMOTE_CONTROL and account == DEFAULT_ACCOUNT \
else []
def _run_cwd(cwd: str | None) -> str:
"""The run's working dir (``~`` expanded — the backend sends an account's
default cwd host-relative), which must exist."""
cwd = os.path.expanduser(cwd) if cwd else DEFAULT_CWD
if not pathlib.Path(cwd).is_dir():
raise HTTPException(400, f"cwd does not exist: {cwd}")
return cwd
def _launch(args: list[str], sid: str, cwd: str, *,
model: str | None = None, append: bool = False,
account: str | None = None) -> dict:
"""Fire a detached ``claude -p`` run (a daemon) and persist its pid record.
The child leads its own session/process group (``start_new_session``) so the
run survives past this request *and* past the sidecar itself; the systemd
unit's ``KillMode=process`` keeps it alive across a sidecar restart. We
persist its identity (pid + ``/proc`` start-time) to ``logs/<sid>.pid`` so a
restarted sidecar can re-discover, list and ``/interrupt`` it, and never
signals a recycled PID. ``append`` keeps a resumed run's output alongside the
original spawn log instead of clobbering it.
The launch is *checked*, not fire-and-forget: we watch the child for
``LAUNCH_PROBE_S`` and raise 502 with the tail of its log if it exits
non-zero in that window (``No conversation found with session ID: …`` when a
``--resume`` can't find its transcript, a broken CLI, …). The caller then
sees a real error instead of a run that never produces a turn."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{sid}.log"
err_from = log_path.stat().st_size if append and log_path.exists() else 0
with open(log_path, "ab" if append else "wb") as log:
proc = subprocess.Popen(
args, cwd=cwd, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
start_new_session=True,
env=_run_env(sid, account),
)
_procs[sid] = proc # kept so we reap it when it exits (see _reap)
# The child leads its own process group (start_new_session), so pid == pgid.
try:
_pidfile(sid).write_text(json.dumps({
"pid": proc.pid,
"startedAt": int(time.time()),
"starttime": _proc_starttime(proc.pid),
"model": model or DEFAULT_MODEL,
"cwd": cwd,
"account": account,
}))
except OSError:
pass
rc = _probe_launch(proc)
if rc is not None and rc != 0:
_procs.pop(sid, None)
_pidfile(sid).unlink(missing_ok=True)
msg, kind = claude_cli.run_failure(_log_text(log_path, err_from))
raise HTTPException(502, claude_cli.error_detail(
msg, kind=kind, account=account, exitCode=rc))
return {"sessionId": sid, "pid": proc.pid, "log": str(log_path)}
def _probe_launch(proc: subprocess.Popen) -> int | None:
"""Poll a just-started run for LAUNCH_PROBE_S. Its exit code if it died in
that window (0 = a legitimately quick, successful run), else None."""
deadline = time.monotonic() + LAUNCH_PROBE_S
while time.monotonic() < deadline:
rc = proc.poll()
if rc is not None:
return rc
time.sleep(0.1)
return None
def _stop_run(sid: str, rec: dict) -> None:
"""Stop a live run so a forced resume can take over its session.
SIGINT the process group (like the /interrupt endpoint — claude writes its
``[Request interrupted by user]`` marker and exits), wait up to STOP_WAIT_S
for it to actually die, and escalate to SIGKILL if it won't. Launching the
``--resume`` while the old run still lives would append duplicate turns to
the same transcript, so a run that survives even SIGKILL is a hard error."""
pid = rec["pid"]
try:
os.killpg(pid, signal.SIGINT)
except ProcessLookupError:
pass # died between the liveness check and the signal — that's fine
except OSError as e:
raise HTTPException(500, f"could not signal process: {e}")
deadline = time.monotonic() + STOP_WAIT_S
while time.monotonic() < deadline and _alive(rec):
time.sleep(0.15)
if _alive(rec):
try:
os.killpg(pid, signal.SIGKILL)
except OSError:
pass
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and _alive(rec):
time.sleep(0.15)
if _alive(rec):
raise HTTPException(409, "could not stop the running session")
_pidfile(sid).unlink(missing_ok=True)
def _log_text(log_path: pathlib.Path, since: int = 0) -> str:
"""What this run wrote to its log (from ``since``) — parsed by
``claude_cli.run_failure`` into the message to show the user."""
try:
with open(log_path, "rb") as f:
f.seek(since)
return f.read().decode(errors="replace")
except OSError:
return ""
@app.get("/health")
def health() -> dict:
"""Open (no bearer): the deploy probe and the hub's worker poller read it.
The worker fields say who this runner is — ``paired`` is whether a hub
holds its token (SIDECAR_TOKEN counts, so the host sidecar reads paired)."""
return {"ok": True, "cwd": DEFAULT_CWD, "claude": CLAUDE_BIN,
"defaultModel": DEFAULT_MODEL, "accounts": list(ACCOUNTS),
"defaultAccount": DEFAULT_ACCOUNT,
"permissionMode": PERMISSION_MODE,
**pairing.identity(), "paired": bool(TOKEN or pairing.token())}
_account_status_cache: dict[str, tuple[float, dict]] = {}
def _auth_status(account: str) -> dict:
"""``claude auth status`` run under the account's config dir, reduced to
the fields the Settings health card shows. Cached ACCOUNT_STATUS_TTL_S."""
hit = _account_status_cache.get(account)
if hit and time.monotonic() - hit[0] < ACCOUNT_STATUS_TTL_S:
return hit[1]
cfg = ACCOUNTS.get(account)
out: dict = {"id": account, "configDir": str(cfg) if cfg else None,
"default": account == DEFAULT_ACCOUNT, "loggedIn": False}
try:
r = subprocess.run([CLAUDE_BIN, "auth", "status"], capture_output=True,
text=True, timeout=20, stdin=subprocess.DEVNULL,
env=_account_env(account))
st = json.loads(r.stdout or "{}")
out.update({k: st.get(k) for k in (
"loggedIn", "authMethod", "email", "orgName", "subscriptionType",
"projectsDirectory")})
out["loggedIn"] = bool(st.get("loggedIn"))
except (OSError, ValueError, subprocess.TimeoutExpired) as e:
out["error"] = str(e)[:200]
_account_status_cache[account] = (time.monotonic(), out)
return out
@app.get("/accounts")
def accounts(authorization: str | None = Header(default=None)) -> dict:
"""Each account's login health (who it is, which org, which plan)."""
_auth(authorization)
return {"accounts": [{**_auth_status(a),
"pendingLogin": claude_cli.pending_login(a)}
for a in ACCOUNTS],
"default": DEFAULT_ACCOUNT}
# ---- login / logout ---------------------------------------------------------
# `claude auth login` driven headlessly — see claude_cli.py for the flow. Each
# account logs in under its own env (_account_env), so the credentials land in
# that account's config dir; the default account writes the host user's own
# ~/.claude login.
class LoginBody(BaseModel):
email: str | None = None # pre-fills the sign-in page (--email)
class LoginCodeBody(BaseModel):
code: str # the `code#state` the sign-in page shows
def _login_account(account: str) -> str:
a = (account or "").strip().lower()
if a not in ACCOUNTS:
raise HTTPException(404, f"unknown account: {account!r}")
return a
def _login_error(e: "claude_cli.LoginError", account: str) -> HTTPException:
return HTTPException(e.status, claude_cli.error_detail(
e.message, kind=e.kind, account=account))
@app.post("/accounts/{account}/login")
def login_start(account: str, body: LoginBody | None = None,
authorization: str | None = Header(default=None)) -> dict:
"""Start a subscription login: returns the sign-in URL to open (on any
device); the code it ends with goes to ``/login/code``."""
_auth(authorization)
account = _login_account(account)
email = ((body.email if body else None) or "").strip() or None
if email and not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
raise HTTPException(400, "invalid email")
try:
return claude_cli.start_login(account, [CLAUDE_BIN],
_account_env(account), email)
except claude_cli.LoginError as e:
raise _login_error(e, account)
@app.post("/accounts/{account}/login/code")
def login_code(account: str, body: LoginCodeBody,
authorization: str | None = Header(default=None)) -> dict:
"""Finish the pending login with the pasted code; answers the fresh
``auth status`` of the account."""
_auth(authorization)
account = _login_account(account)
try:
claude_cli.submit_code(account, body.code)
except claude_cli.LoginError as e:
raise _login_error(e, account)
_account_status_cache.pop(account, None)
return _auth_status(account)
@app.delete("/accounts/{account}/login")
def login_cancel(account: str,
authorization: str | None = Header(default=None)) -> dict:
_auth(authorization)
return {"cancelled": claude_cli.cancel_login(_login_account(account))}
@app.post("/accounts/{account}/logout")
def logout(account: str,
authorization: str | None = Header(default=None)) -> dict:
"""`claude auth logout` for the account. New launches on it fail with a
typed ``auth`` error until it logs back in; runs already in flight may
keep going on the access token they hold in memory."""
_auth(authorization)
account = _login_account(account)
claude_cli.cancel_login(account)
try:
msg = claude_cli.logout([CLAUDE_BIN], _account_env(account))
except claude_cli.LoginError as e:
raise _login_error(e, account)
_account_status_cache.pop(account, None)
return {**_auth_status(account), "message": msg}
@app.get("/sessions")
def sessions(authorization: str | None = Header(default=None)) -> dict:
"""List sessions still running (survivors of any sidecar restart included)."""
_auth(authorization)
out = []
if LOG_DIR.is_dir():
for p in sorted(LOG_DIR.glob("*.pid")):
sid = p.stem
rec = _live_record(sid)
if rec:
out.append({
"sessionId": sid,
"pid": rec.get("pid"),
"startedAt": rec.get("startedAt"),
"model": rec.get("model"),
"cwd": rec.get("cwd"),
"account": rec.get("account"),
})
return {"sessions": out, "count": len(out)}
@app.post("/spawn")
def spawn(body: SpawnBody, authorization: str | None = Header(default=None)) -> dict:
_auth(authorization)
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
sid = _valid_uuid(body.sessionId or str(uuidlib.uuid4()))
cwd = _run_cwd(body.cwd)
harness = _valid_harness(body.harness)
if harness == "pi":
# pi harness: same flag shape, different binary. The runner wraps
# `pi --mode json` and mirrors the session into the watched pi
# transcripts dir (permission scope = the run's cwd by default).
model = _valid_model(body.model, "pi") or PI_DEFAULT_MODEL
args = [RUNNER_BIN, "-p", prompt, "--session-id", sid, "--model", model]
args += _thinking_args(body.thinking, harness)
return _launch(args, sid, cwd, model=model)
# The viewer's composer sends the model tag the user picked (a CLI alias like
# `opus`, or a pinned id like `claude-opus-4-5-20251101`); with none picked we
# fall back to DEFAULT_MODEL. Either way `claude --model` resolves it.
model = _valid_model(body.model) or DEFAULT_MODEL
account = _valid_account(body.account)
_require_login(account)
args = [
CLAUDE_BIN, "-p", prompt,
"--session-id", sid,
"--output-format", "json",
"--permission-mode", PERMISSION_MODE,
"--model", model,
]
if DISALLOWED_TOOLS:
args += ["--disallowed-tools", " ".join(DISALLOWED_TOOLS)]
args += _thinking_args(body.thinking, harness)
args += _effort_args(body.effort, harness)
args += _remote_control(account)
return _launch(args, sid, cwd, model=model, account=account)
@app.post("/resume")
def resume(body: ResumeBody, authorization: str | None = Header(default=None)) -> dict:
"""Continue an existing conversation with a new user turn.
Runs ``claude -p <prompt> --resume <sessionId>`` (default: reuses the
original session id, so it appends to the same
``~/.claude/projects/<proj>/<sid>.jsonl`` the viewer already watches — the
new turns stream straight into the open conversation). ``--resume`` is scoped
to the current directory, so the caller must pass the session's original
``cwd``.
``model`` switches the model the continuation runs on (the composer sends the
conversation's current model by default, so nothing changes unless the user
picks another tag). Omitted ⇒ no ``--model`` flag ⇒ the session keeps its own."""
_auth(authorization)
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
sid = _valid_uuid(body.sessionId)
harness = _valid_harness(body.harness)
model = _valid_model(body.model, harness)
cwd = _run_cwd(body.cwd)
account = _valid_account(body.account) if harness == "claude" else None
if account:
_require_login(account)
# Idempotency: if a run for this session is *really* still going, a rapid
# double-submit would launch a SECOND `claude -p --resume` appending duplicate
# turns to the same transcript (and clobbering the pidfile so only one stays
# interruptible). We take no new prompt in that case — and say so
# (`reused`), because the turn was NOT accepted: the caller has to surface
# that rather than show a message that will never be answered.
# With force=True the caller wants the new prompt to win instead: stop the
# live run (it lands its interrupt marker in the transcript), then resume.
rec = _live_record(sid)
if rec:
if not body.force:
return {"sessionId": sid, "pid": rec.get("pid"),
"log": str(LOG_DIR / f"{sid}.log"), "reused": True}
_stop_run(sid, rec)
if harness == "pi":
# The composer sends the conversation's current model by default; with
# nothing picked the runner's own default keeps the session coherent.
model = model or PI_DEFAULT_MODEL
args = [RUNNER_BIN, "-p", prompt, "--resume", sid, "--model", model]
args += _thinking_args(body.thinking, harness)
return _launch(args, sid, cwd, model=model, append=True)
args = [
CLAUDE_BIN, "-p", prompt,
"--resume", sid,
"--output-format", "json",
"--permission-mode", PERMISSION_MODE,
]
if DISALLOWED_TOOLS:
args += ["--disallowed-tools", " ".join(DISALLOWED_TOOLS)]
if model:
args += ["--model", model]
args += _thinking_args(body.thinking, harness)
args += _effort_args(body.effort, harness)
args += _remote_control(account)
return _launch(args, sid, cwd, model=model, append=True, account=account)
def _collapse(s: str) -> str:
return " ".join((s or "").split())
def _record_texts(line: str) -> list[str]:
"""Every text payload in one transcript record (message content as a plain
string, or the text blocks of a content list) — what the fork-point canary
compares against."""
try:
o = json.loads(line)
except ValueError:
return []
c = (o.get("message") or {}).get("content")
if isinstance(c, str):
return [c]
if isinstance(c, list):
return [b.get("text", "") for b in c
if isinstance(b, dict) and b.get("type") == "text"]
return []
def _fork_transcript(src: pathlib.Path, dst: pathlib.Path, new_sid: str,
cut_line: int, cut_text: str | None) -> None:
"""Write the first ``cut_line`` lines of ``src`` as a new session file.
This is the same shape ``claude --resume --fork-session`` produces — the
identical records under a new ``sessionId`` (record uuids stay; they're
only unique within a file's parent chain) — just cut short at the fork
point instead of cloning the whole session. ``cut_text`` is the message
the viewer showed at that ordinal: if the record at ``cut_line`` doesn't
contain it, the archive the backend counted lines in has diverged from
the live file (it never should — the import is append-only) and we refuse
rather than fork at the wrong message."""
try:
lines = src.read_text(encoding="utf-8", errors="ignore") \
.splitlines(keepends=True)
except OSError as e:
raise HTTPException(502, f"cannot read source transcript: {e}")
if not 0 < cut_line < len(lines):
raise HTTPException(409, f"fork point out of range: line {cut_line} "
f"of {len(lines)}")
if cut_text:
want = _collapse(cut_text)
if not any(_collapse(t)[:len(want)] == want
for t in _record_texts(lines[cut_line])):
raise HTTPException(409, "fork point mismatch — the transcript "
"changed since the viewer loaded it; "
"reload the conversation and retry")
keep = lines[:cut_line]
# The records just before a user message are that turn's bookkeeping
# (queue-operation enqueue/dequeue carrying the message text, last-prompt).
# They belong to the message being edited away, so a cut at the user
# record would otherwise leak its text into the fork — trim them.
def _rec_type(ln: str) -> str | None:
try:
return json.loads(ln).get("type")
except ValueError:
return None
while keep and _rec_type(keep[-1]) in ("queue-operation", "last-prompt"):
keep.pop()
if not keep:
raise HTTPException(400, "nothing before the fork point")
out = []
for ln in keep:
s = ln.strip()
if s.startswith("{"):
try:
o = json.loads(s)
if "sessionId" in o:
o["sessionId"] = new_sid
out.append(json.dumps(o, ensure_ascii=False) + "\n")
continue
except ValueError:
pass
out.append(ln if ln.endswith("\n") else ln + "\n")
try:
dst.write_text("".join(out), encoding="utf-8")
except OSError as e:
raise HTTPException(502, f"cannot write forked transcript: {e}")
@app.post("/fork")
def fork(body: ForkBody, authorization: str | None = Header(default=None)) -> dict:
"""Branch a new session off an existing one at a message boundary.
Claude Code has no message-level fork (``--fork-session`` clones whole
sessions), so: copy the source session file truncated at the fork point
under a fresh session id (see ``_fork_transcript``), then ``claude -p
<prompt> --resume <newSid>`` — claude loads the truncated history and
appends the edited turn. The pi harness forks natively: pi sessions are
trees, so the runner branches pi's own session file (``--fork-from``) and
mirrors the truncated prefix itself."""
_auth(authorization)
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
new_sid = _valid_uuid(body.sessionId)
src_sid = _valid_uuid(body.srcSessionId)
harness = _valid_harness(body.harness)
model = _valid_model(body.model, harness)
cwd = _run_cwd(body.cwd)
if harness == "pi":
model = model or PI_DEFAULT_MODEL
args = [RUNNER_BIN, "-p", prompt, "--session-id", new_sid,
"--fork-from", src_sid, "--cut-line", str(body.cutLine),
"--cut-user-ord", str(body.cutUserOrd or 0), "--model", model]
if body.cutText:
args += ["--cut-text", body.cutText]
return _launch(args, new_sid, cwd, model=model)
# A fork stays on the source's account: the source transcript lives only
# in that account's projects/, and so must the fork for --resume to find it.
account = _valid_account(body.account)
_require_login(account)
projects = _account_projects(account)
src = next(projects.glob(f"*/{src_sid}.jsonl"), None)
if src is None:
raise HTTPException(404, f"source session {src_sid} not found under "
f"{projects}")
dst = src.parent / f"{new_sid}.jsonl"
_fork_transcript(src, dst, new_sid, body.cutLine, body.cutText)
args = [
CLAUDE_BIN, "-p", prompt,
"--resume", new_sid,
"--output-format", "json",
"--permission-mode", PERMISSION_MODE,
]
if DISALLOWED_TOOLS:
args += ["--disallowed-tools", " ".join(DISALLOWED_TOOLS)]
if model:
args += ["--model", model]
args += _remote_control(account)
try:
return _launch(args, new_sid, cwd, model=model, account=account)
except HTTPException:
dst.unlink(missing_ok=True) # no headless truncated session left behind
raise
class InterruptBody(BaseModel):
sessionId: str
@app.post("/interrupt")
def interrupt(body: InterruptBody,
authorization: str | None = Header(default=None)) -> dict:
"""Stop a running session by sending its process group a SIGINT.
SIGINT is what Ctrl+C delivers to a terminal's foreground process group, so
a headless ``claude -p`` run handles it the same way: it aborts the current
turn, writes a ``[Request interrupted by user]`` marker to its transcript,
and exits. We signal the whole process group (pid == pgid, since the run was
started with ``start_new_session``) so in-flight tool subprocesses stop too.
Reads the run's identity from its on-disk pidfile, so this works even for a
session spawned by a *previous* sidecar instance that has since restarted."""
_auth(authorization)
sid = _valid_uuid(body.sessionId)
# Verify it's still our live process before signalling — never fire a signal
# at a PID that has already exited (possibly still a zombie), or been
# recycled into something else. _live_record drops the stale pidfile for us.
rec = _live_record(sid)
if rec is None:
raise HTTPException(404, "no running process for this session")
pid = rec["pid"]
pid_path = _pidfile(sid)
try:
os.killpg(pid, signal.SIGINT)
except ProcessLookupError:
# Raced us to exit between the liveness check and the signal.
pid_path.unlink(missing_ok=True)
raise HTTPException(404, "process already exited")
except OSError as e:
raise HTTPException(500, f"could not signal process: {e}")
pid_path.unlink(missing_ok=True)
return {"sessionId": sid, "pid": pid, "signal": "SIGINT", "ok": True}
# ---- 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: _account_projects(DEFAULT_ACCOUNT)))
app.include_router(pairing.make_router(
_auth, lambda: _auth_status(DEFAULT_ACCOUNT),
lambda: {"cwd": DEFAULT_CWD, "defaultAccount": DEFAULT_ACCOUNT,
"permissionMode": PERMISSION_MODE,
"projectsDir": str(_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)