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>
156 lines
9.2 KiB
Python
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
|