Files
ai-agent/sidecar/sidecar.py
Gabriel Vidal 6c793d2dca feat(forms): structured ask-form questions answered in the PWA
Rich forms the agent asks the user to fill, replacing Claude Code's
interactive AskUserQuestion for spawned sessions:

- backend/forms.py + /api/forms REST (create / list / get with waitSecs
  long-poll / submit / cancel), JSON sidecar store at /data/forms.json,
  'form' SSE events; field types: text textarea number select multiselect
  radio checkbox slider file date, answers validated server-side
- conversation viewer renders the ask-form Bash call as a live inline form
  card (submit with confirm recap, cancel, file uploads via /api/upload);
  answers stay in the thread read-only after submit; wait/cancel subcommands
  render as compact status strips; new 'Form cards' visibility switch
- ?form=<id> query param (the notification deep link) opens the form
  focused in a full-screen modal
- sidecar disallows AskUserQuestion on spawned claude runs
  (SIDECAR_DISALLOWED_TOOLS to override)
- mock backend: /api/forms routes + seeded pending/submitted/cancelled
  forms and kitchen-sink thread cards (SEED_VERSION 7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 16:18:34 +02:00

777 lines
34 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.
"""
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
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;
# bare LM Studio ids route to the local EVOX2 provider — see runner/cli.mjs).
PI_DEFAULT_MODEL = os.environ.get("SIDECAR_PI_MODEL", "qwen/qwen3.6-35b-a3b")
# 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.
CLAUDE_PROJECTS = pathlib.Path(os.environ.get(
"CLAUDE_PROJECTS_DIR", pathlib.Path.home() / ".claude" / "projects"))
# 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)
def _proc_state(pid: int) -> str | None:
"""Process state letter from /proc/pid/stat ('Z' = zombie, 'S'/'R' = live)."""
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 | None:
"""Process start-time in clock ticks since boot (field 22 of /proc/pid/stat).
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 ')'."""
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
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
# 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"
def _auth(authorization: str | None) -> None:
if not TOKEN:
raise HTTPException(500, "sidecar not configured (no SIDECAR_TOKEN)")
expected = f"Bearer {TOKEN}"
if authorization != expected:
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 _launch(args: list[str], sid: str, cwd: str, *,
model: str | None = None, append: bool = False) -> 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={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "ai-agent-sidecar"},
)
_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,
}))
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)
raise HTTPException(502, f"claude exited immediately (code {rc}): "
f"{_log_tail(log_path, err_from)}")
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_tail(log_path: pathlib.Path, since: int = 0, limit: int = 400) -> str:
"""The last of what this run wrote to its log — the message to show the user."""
try:
with open(log_path, "rb") as f:
f.seek(since)
text = f.read().decode(errors="replace")
except OSError:
return "(no output)"
lines = [ln for ln in text.strip().splitlines() if ln.strip()]
return (lines[-1][:limit] if lines else "(no output)")
@app.get("/health")
def health() -> dict:
return {"ok": True, "cwd": DEFAULT_CWD, "claude": CLAUDE_BIN,
"defaultModel": DEFAULT_MODEL}
@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"),
})
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 = body.cwd or DEFAULT_CWD
if not pathlib.Path(cwd).is_dir():
raise HTTPException(400, f"cwd does not exist: {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
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)
if REMOTE_CONTROL:
args.append("--remote-control")
return _launch(args, sid, cwd, model=model)
@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 = body.cwd or DEFAULT_CWD
if not pathlib.Path(cwd).is_dir():
raise HTTPException(400, f"cwd does not exist: {cwd}")
# 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)
if REMOTE_CONTROL:
args.append("--remote-control")
return _launch(args, sid, cwd, model=model, append=True)
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 = body.cwd or DEFAULT_CWD
if not pathlib.Path(cwd).is_dir():
raise HTTPException(400, f"cwd does not exist: {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)
src = next(CLAUDE_PROJECTS.glob(f"*/{src_sid}.jsonl"), None)
if src is None:
raise HTTPException(404, f"source session {src_sid} not found under "
f"{CLAUDE_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]
if REMOTE_CONTROL:
args.append("--remote-control")
try:
return _launch(args, new_sid, cwd, model=model)
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}