Files
ai-agent/sidecar/config.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

156 lines
9.2 KiB
Python

"""
Sidecar configuration: every environment variable and constant, in one place.
Each value keeps the comment that documents it — read these before changing a
default. The other modules import what they need from here; nothing in this
file is reassigned at runtime.
"""
import os
import pathlib
import re
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"))
# ---- background work in a headless run ---------------------------------------
# A `claude -p` process ends when the model ends its turn. Before exiting it
# waits for the run's background *subagents and Monitors* — but only up to
# `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` (the CLI's default is 600000: 10 min,
# which is what silently killed every hour-long subagent, with "Background tasks
# still running after 600s; terminating" in the run log). Exported into every
# run; "" leaves the CLI's default. 0 = wait indefinitely (the Stop button
# still kills the run).
BG_WAIT_CEILING_MS = os.environ.get("SIDECAR_BG_WAIT_CEILING_MS", "7200000")
# Plain `Bash run_in_background` tasks are *excluded* from that wait (the CLI's
# `local_bash` type) and die the instant the turn ends. stop_guard.py — a Stop
# hook injected via `--settings` — holds the turn until such a task has exited
# (its output file ends with "[exited with code N]"), without any API call.
STOP_GUARD = pathlib.Path(__file__).resolve().parent / "stop_guard.py"
STOP_GUARD_ENABLED = os.environ.get("SIDECAR_STOP_GUARD", "1") not in ("0", "false", "")
# The hook's own timeout (what the CLI enforces); the guard hands the question
# back to the model well before it (STOP_GUARD_MAX_WAIT_S in stop_guard.py).
STOP_GUARD_TIMEOUT_S = int(os.environ.get("SIDECAR_STOP_GUARD_TIMEOUT_S", "3600"))
# A follow-up sent into a *live* run is delivered through the CLI's per-session
# inbox socket (`/message`) instead of killing the run; the session only takes
# such messages with `crossSessionInbound: accept`. Claude Code wraps them as
# "Another Claude session sent a message … not typed by your user", so the text
# is prefixed to say whose it really is — the backend's parser strips both.
INBOX_PREFIX = ("[Follow-up from your user, sent from the ai-agent composer — "
"this IS your user's own message, not a peer agent's.]\n\n")
# 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