Files
ai-agent/backend/main.py

3268 lines
138 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ai-agent backend — a viewer/editor + analytics dashboard for the homelab's
Claude context.
Exposes the repo's CLAUDE.md files and the `.claude/` tree (skills, hooks,
settings) as a flat list of files with **real** Claude token counts and dollar
costs, lets them be edited, and surfaces skill-usage analytics mined from the
Claude Code transcripts.
Token counts and skill stats are maintained in a small SQLite store by a
background indexer (see indexer.py): token counts come from the count_tokens
endpoint and are only refreshed for files whose content changed, after a debounce.
The built React PWA is served from STATIC_DIR at the web root.
"""
import copy
import datetime
import io
import json
import os
import pathlib
import queue
import re
import shutil
import socket
import threading
import time
import urllib.error
import urllib.request
import uuid as uuidlib
import zipfile
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi.responses import (FileResponse, JSONResponse, PlainTextResponse,
Response, StreamingResponse)
from pydantic import BaseModel
import artefacts as artefacts_mod
import conversations
import editsdiff
import envfile as envfile_mod
import gitdiff
import goal as goalmd
import memories as memories_mod
import models as models_mod
import cron as cron_mod
import forms as forms_mod
import notify as notify_mod
import notif_read as notif_read_mod
import plans as plans_mod
import project_costs as project_costs_mod
import projects as projects_mod
import schemas
import skills as skills_mod
import svc as svc_mod
import templates as templates_mod
from db import Store
from deploy_status import DeployWatcher, read_status as read_deploy_status
from events import Hub, Watcher
from indexer import (INPUT_PRICE_PER_MTOK, MODEL, Indexer, _estimate_tokens,
_price)
from meta import MetaStore
from ui_state import UiStateStore, valid_key
WORKSPACE = pathlib.Path(os.environ.get("WORKSPACE", "/workspace")).resolve()
STATIC_DIR = pathlib.Path(os.environ.get("STATIC_DIR", "/app/static")).resolve()
# Screenshots captured by the `screenshot` skill (docs/screenshots/, mounted
# read-only). Served by /api/screenshot and shown inline in the thread viewer.
SCREENSHOTS_DIR = pathlib.Path(
os.environ.get("SCREENSHOTS_DIR", "/screenshots")).resolve()
SCREENSHOT_EXTS = {".jpeg", ".jpg", ".png", ".webp", ".gif"}
# The repo's root `data/` tree (mounted read-only at /workspace/data). Generated
# artifacts (images, renders, charts) land here and are served by /api/data-asset
# so a `Read` of an image under data/ can be previewed inline in the thread.
DATA_DIR = (WORKSPACE / "data")
# Claude Code's per-session scratchpad root (mounted read-only). A session's
# temp dir is <root>/<encoded-cwd>/<sessionId>/scratchpad/…, which is where a
# run's throwaway renders and screenshots land when they aren't worth committing
# to a project's .ai/artefacts/. Served by /api/scratchpad-asset so those still
# preview inline in the thread instead of showing "not previewable".
SCRATCHPAD_DIR = pathlib.Path(
os.environ.get("SCRATCHPAD_DIR", "/scratchpads")).resolve()
IMAGE_EXTS = {".jpeg", ".jpg", ".png", ".webp", ".gif", ".svg", ".bmp", ".avif"}
# Files uploaded from the composer (SpawnBox/ResumeBox). Stored inside the
# service's own writable data dir; served back by /api/upload-file and read by
# the spawned `claude -p` session. UPLOADS_DIR is the container path; the repo
# prefix is what the host-side session (cwd = repo root) sees, so the composer
# injects "<prefix>/<batch>/<name>" into the prompt for Claude to Read.
UPLOADS_DIR = pathlib.Path(
os.environ.get("UPLOADS_DIR", "/data/uploads")).resolve()
UPLOADS_REPO_PREFIX = os.environ.get(
"UPLOADS_REPO_PREFIX", "services/ai-agent/data/uploads").strip("/")
MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_BYTES", 50 * 1024 * 1024))
# Custom avatar sets imported as a zip (sprite editor's "Export set"). Unpacked
# into the writable data volume and served at /avatars/custom/ so the roaming
# avatar can play a full custom emote set (not just a single strip URL). One
# active set at a time.
AVATARS_DIR = pathlib.Path(
os.environ.get("AVATARS_DIR", "/data/avatars")).resolve()
CUSTOM_AVATAR_DIR = AVATARS_DIR / "custom"
MAX_AVATAR_ZIP_BYTES = int(os.environ.get("MAX_AVATAR_ZIP_BYTES", 40 * 1024 * 1024))
_UNSAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
DB_PATH = os.environ.get("DB_PATH", "/data/ai-agent.db")
_transcripts = os.environ.get("TRANSCRIPTS_DIR", "/transcripts")
# Live (read-only) Claude Code transcripts that get imported into the archive.
SOURCE_DIR = pathlib.Path(_transcripts).resolve() if _transcripts else None
# Second live source: transcripts mirrored by the pi-harness runner
# (services/ai-agent/runner writes Claude-Code-schema JSONL there).
_pi_transcripts = os.environ.get("PI_TRANSCRIPTS_DIR", "/pi-transcripts")
PI_SOURCE_DIR = (pathlib.Path(_pi_transcripts).resolve()
if _pi_transcripts else None)
# Third live source: the in-container runner (RUNNER_IN_CONTAINER=1, see
# docker-entrypoint.sh). The Claude Code CLI baked into the image writes its
# transcripts under the runner's own $HOME, inside the container — watching that
# dir is what makes an in-container session stream into the viewer like any other.
# Unset on the homelab, where the runner is the host sidecar and its transcripts
# arrive through the read-only /transcripts mount instead.
_runner_transcripts = os.environ.get("RUNNER_TRANSCRIPTS_DIR", "")
RUNNER_SOURCE_DIR = (pathlib.Path(_runner_transcripts).resolve()
if _runner_transcripts else None)
# dict.fromkeys: order-preserving dedupe — pointing two of these at the same dir
# would otherwise import every transcript in it twice.
SOURCE_DIRS = list(dict.fromkeys(
d for d in (SOURCE_DIR, PI_SOURCE_DIR, RUNNER_SOURCE_DIR) if d))
# Persistent archive: imported transcripts live here and are indexed/read from
# it (so conversations Claude later prunes from the source survive).
ARCHIVE_DIR = pathlib.Path(
os.environ.get("ARCHIVE_DIR", "/data/transcripts")).resolve()
TRANSCRIPTS_DIR = ARCHIVE_DIR # the detail endpoint reads from the archive
# Sidecar of per-conversation action metadata edited by the skill scripts.
META_PATH = os.environ.get("META_PATH", "/data/conversations-meta.json")
# Cron jobs: scheduled agent sessions spawned from prompt files (see cron.py).
CRON_PATH = os.environ.get("CRON_PATH", "/data/cron-jobs.json")
# First-class notifications: webhook config + notification/ask log (notify.py).
NOTIFY_PATH = os.environ.get("NOTIFY_PATH", "/data/notifications.json")
FORMS_PATH = os.environ.get("FORMS_PATH", "/data/forms.json")
# Server-side store for the PWA's small client state (Settings config + the
# notification feed's seen/new bookkeeping) — moved off browser localStorage so
# it follows the user across browsers/devices. See ui_state.py.
UI_STATE_PATH = os.environ.get("UI_STATE_PATH", "/data/ui-state.json")
# A conversation whose last activity is within this window counts as "running".
RUNNING_WINDOW_SECS = float(os.environ.get("RUNNING_WINDOW_SECS", "600"))
# Host-side sidecar that actually launches `claude -p` (see services/ai-agent/
# sidecar/). The container reaches it over host.docker.internal.
SIDECAR_URL = os.environ.get("SIDECAR_URL", "http://host.docker.internal:8790")
SIDECAR_TOKEN = os.environ.get("SIDECAR_TOKEN", "")
# Directories never worth scanning (huge / generated / vendored).
IGNORE_DIRS = {
".git", "node_modules", ".forge", "dist", "build", "__pycache__",
".venv", "venv", ".cache", "history", "tmp", ".next", "coverage",
}
MD_EXTS = {".md", ".markdown"}
# Text-ish files worth surfacing from the repo's root `data/` tree (plans, logs,
# notes, kanban, widget templates). Binary/image files (screenshots, thumbnails)
# are skipped — the viewer is a text editor and can't render them.
DATA_TEXT_EXTS = {
".md", ".markdown", ".txt", ".json", ".yml", ".yaml", ".toml", ".ini",
".cfg", ".py", ".sh", ".js", ".ts", ".jinja", ".j2", ".log", ".csv",
}
app = FastAPI(title="ai-agent", docs_url=None, redoc_url=None)
# ── trusted-caller gate ──────────────────────────────────────────────────────
# The backend has no auth of its own: the browser reaches it through Traefik
# (behind Authelia) and host tooling through the 127.0.0.1-published port. But
# the container also sits on the shared `main` docker network, where any other
# container could hit it directly — and a PUT into `.claude/` (hooks) or a
# /api/spawn is host-level code execution. So every /api request must come from
# a trusted peer: Traefik (the Authelia-guarded edge), the docker gateway (how
# host-originated connections to the published port appear), localhost, or a
# caller presenting the shared INTERNAL_API_TOKEN. Only /api/health stays open
# (deploy probes). Static assets are public-shell only, so they stay open too.
INTERNAL_API_TOKEN = os.environ.get("INTERNAL_API_TOKEN", "")
# A read-ONLY API key: a caller presenting it (X-API-Key) may hit **safe**
# (GET/HEAD/OPTIONS) /api/* routes only — every mutating method is refused. This
# is what the desk phone holds to fetch unread notifications: it must never be
# able to spawn a session or PUT into .claude/ (that's host-level code exec).
# Comma-separated to allow more than one key. Empty ⇒ feature off.
READONLY_API_KEYS = frozenset(
k.strip() for k in os.environ.get("READONLY_API_KEY", "").split(",")
if k.strip())
# Methods a read-only key is allowed to use.
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
TRAEFIK_HOST = os.environ.get("TRAEFIK_HOST", "traefik")
_TRUSTED_TTL_S = 30.0
_trusted_lock = threading.Lock()
_trusted_cache: tuple[float, frozenset[str]] = (0.0, frozenset())
def _default_gateway_ips() -> set[str]:
"""The container's default-gateway IP(s) (/proc/net/route, IPv4)."""
ips: set[str] = set()
try:
for line in pathlib.Path("/proc/net/route").read_text().splitlines()[1:]:
f = line.split()
if len(f) >= 3 and f[1] == "00000000": # default route
ips.add(socket.inet_ntoa(bytes.fromhex(f[2])[::-1]))
except (OSError, ValueError):
pass
return ips
def _trusted_ips() -> frozenset[str]:
"""Gateway + Traefik IPs, re-resolved at most every _TRUSTED_TTL_S."""
global _trusted_cache
now = time.monotonic()
with _trusted_lock:
ts, ips = _trusted_cache
if now - ts < _TRUSTED_TTL_S and ips:
return ips
fresh = {"127.0.0.1", "::1"} | _default_gateway_ips()
try:
for info in socket.getaddrinfo(TRAEFIK_HOST, None):
fresh.add(info[4][0])
except OSError:
pass
out = frozenset(fresh)
with _trusted_lock:
_trusted_cache = (now, out)
return out
@app.middleware("http")
async def _trusted_caller_gate(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and path != "/api/health":
# A read-only API key downgrades the caller to safe methods, whatever the
# source IP — the phone reaches us over a "trusted" host loopback but is
# only allowed to read (fetch notifications), never to spawn/mutate. So
# this check comes FIRST and, when the key matches, it decides the request
# outright (a read-only key on a POST is refused, not silently upgraded by
# a trusted IP).
api_key = request.headers.get("x-api-key", "")
if api_key and api_key in READONLY_API_KEYS:
if request.method not in _SAFE_METHODS:
return JSONResponse(
{"detail": "read-only api key: method not allowed"},
status_code=403)
return await call_next(request)
client = request.client.host if request.client else ""
if client not in _trusted_ips():
tok = request.headers.get("x-internal-auth", "")
if not (INTERNAL_API_TOKEN and tok == INTERNAL_API_TOKEN):
return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request)
def _r(model) -> dict:
"""Attach a response schema to a route for OpenAPI/codegen **only**.
Passed as ``responses=`` (not ``response_model=``) so FastAPI documents the
200 body in ``/openapi.json`` — which Orval turns into the frontend's types —
without validating or filtering the handler's actual return value. See
``schemas.py``."""
return {200: {"model": model}}
store = Store(DB_PATH)
meta_store = MetaStore(META_PATH)
ui_state = UiStateStore(UI_STATE_PATH)
cron_store = cron_mod.CronStore(CRON_PATH)
notify_store = notify_mod.NotifyStore(NOTIFY_PATH)
form_store = forms_mod.FormStore(FORMS_PATH)
# Server-side "read" ledger for the notification feed — lets the phone fetch
# *unread* notifications (listening ≠ reading; only the UI marks them read).
NOTIF_READ_PATH = os.environ.get("NOTIF_READ_PATH", "/data/notif-read.json")
notif_read_store = notif_read_mod.NotifReadStore(NOTIF_READ_PATH)
hub = Hub()
# ── file discovery ──────────────────────────────────────────────────────────
def _is_data_file(rel: pathlib.PurePosixPath) -> bool:
"""A text file under the repo's root `data/` tree (plans/logs/notes/etc.).
Surfaced read-only for visibility; binaries and the ignored `data/tmp/`
(scratch + secrets) are filtered out via DATA_TEXT_EXTS + IGNORE_DIRS."""
return (
len(rel.parts) > 1
and rel.parts[0] == "data"
and rel.suffix.lower() in DATA_TEXT_EXTS
)
def _is_context_file(rel: pathlib.PurePosixPath) -> bool:
"""A file we expose: any CLAUDE.md, anything under a `.claude/` tree, or a
text file under the repo's root `data/` tree."""
if rel.name == "CLAUDE.md":
return True
if ".claude" in rel.parts:
return True
return _is_data_file(rel)
def _is_editable(rel: pathlib.PurePosixPath) -> bool:
"""Text files we allow editing. Binary/lock files stay read-only, and the
`data/` tree is read-only (mounted :ro — surfaced for viewing only)."""
if _is_data_file(rel):
return False
return rel.suffix.lower() in {
".md", ".markdown", ".sh", ".py", ".js", ".mjs", ".ts", ".tsx",
".json", ".yml", ".yaml", ".txt", ".toml", ".cfg", ".ini", "",
}
def _kind(rel: pathlib.PurePosixPath) -> str:
name = rel.name
if name == "CLAUDE.md":
return "claude-md"
if name == "SKILL.md":
return "skill-md"
if rel.suffix in MD_EXTS:
return "markdown"
if rel.suffix in {".sh", ".py", ".js", ".mjs", ".ts", ".tsx"}:
return "script"
if rel.suffix in {".json", ".yml", ".yaml", ".toml", ".ini", ".cfg"}:
return "config"
return "other"
def _iter_files():
"""Yield (abs_path, rel_posix) for every exposed context file."""
for dirpath, dirnames, filenames in os.walk(WORKSPACE):
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS]
for fn in filenames:
ap = pathlib.Path(dirpath) / fn
rel = pathlib.PurePosixPath(ap.relative_to(WORKSPACE).as_posix())
if _is_context_file(rel):
yield ap, rel
def _read_text(ap: pathlib.Path) -> str:
try:
return ap.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return ""
indexer = Indexer(store, WORKSPACE, SOURCE_DIRS, ARCHIVE_DIR,
_iter_files, _read_text)
watcher = Watcher(SOURCE_DIRS, meta_store, indexer, hub)
# Surface the blue-green deploy script's progress snapshot as a live SSE banner.
deploy_watcher = DeployWatcher(hub)
# ── entry assembly ──────────────────────────────────────────────────────────
def _tokens_for(path: str, text: str) -> tuple[int, float, bool]:
"""Pull the stored real token count/cost; fall back to a cheap estimate."""
row = store.get_file(path)
if row and row["tokens"] is not None:
return row["tokens"], row["cost"] or 0.0, bool(row["estimated"])
est = _estimate_tokens(text)
return est, _price(est), True
def _file_entry(ap: pathlib.Path, rel: pathlib.PurePosixPath, with_content: bool):
content = _read_text(ap)
is_md = rel.suffix.lower() in MD_EXTS
words = len(content.split())
chars = len(content)
tokens, cost, estimated = _tokens_for(str(rel), content)
try:
bytes_ = ap.stat().st_size
except OSError:
bytes_ = chars
entry = {
"path": str(rel),
"name": rel.name,
"ext": rel.suffix,
"kind": _kind(rel),
"isMarkdown": is_md,
"bytes": bytes_,
"words": words,
"chars": chars,
"tokens": tokens,
"cost": cost,
"estimated": estimated,
"editable": _is_editable(rel),
}
if with_content:
entry["content"] = content
return entry
# ── path safety ─────────────────────────────────────────────────────────────
def _resolve(rel_path: str) -> tuple[pathlib.Path, pathlib.PurePosixPath]:
rel = pathlib.PurePosixPath(rel_path)
if rel.is_absolute() or ".." in rel.parts:
raise HTTPException(400, "invalid path")
ap = (WORKSPACE / rel).resolve()
try:
ap.relative_to(WORKSPACE)
except ValueError:
raise HTTPException(400, "path escapes workspace")
if not _is_context_file(rel):
raise HTTPException(403, "not an exposed context file")
return ap, rel
# ── API ─────────────────────────────────────────────────────────────────────
@app.get("/api/bundle", responses=_r(schemas.Bundle))
def bundle():
"""The whole file catalog — metadata only, served from the index DB.
This used to read every context file's content per request (a 16 MB,
12-second response the app shell fetched on every load). File *content* now
loads lazily through ``GET /api/file`` when a file is opened; the words /
chars / token stats come from the ``files`` table, which the indexer keeps
fresh (recomputed only when a file's content actually changes)."""
files = []
for path, r in store.all_files().items():
rel = pathlib.PurePosixPath(path)
tokens = r["tokens"]
estimated = bool(r["estimated"])
if tokens is None:
# not counted yet — estimate off the stored char count (~chars/4)
tokens = ((r["chars"] or 0) + 3) // 4
estimated = True
files.append({
"path": path,
"name": rel.name,
"ext": rel.suffix,
"kind": _kind(rel),
"isMarkdown": bool(r["is_markdown"]),
"bytes": r["size"] or 0,
"words": r["words"] or 0,
"chars": r["chars"] or 0,
"tokens": tokens,
"cost": r["cost"] or _price(tokens),
"estimated": estimated,
"editable": _is_editable(rel),
})
files.sort(key=lambda f: f["path"])
totals = {
"files": len(files),
"mdFiles": sum(1 for f in files if f["isMarkdown"]),
"words": sum(f["words"] for f in files),
"chars": sum(f["chars"] for f in files),
"tokens": sum(f["tokens"] for f in files),
"cost": sum(f["cost"] for f in files),
"estimated": sum(1 for f in files if f["estimated"]),
}
return {"workspace": str(WORKSPACE.name), "totals": totals, "files": files,
"pricing": {"model": MODEL, "inputPerMTok": INPUT_PRICE_PER_MTOK}}
@app.get("/api/file", responses=_r(schemas.FileEntry))
def get_file(path: str):
ap, rel = _resolve(path)
if not ap.is_file():
raise HTTPException(404, "not found")
return _file_entry(ap, rel, with_content=True)
class SaveBody(BaseModel):
path: str
content: str
@app.put("/api/file", responses=_r(schemas.FileEntry))
def put_file(body: SaveBody):
ap, rel = _resolve(body.path)
if not _is_editable(rel):
raise HTTPException(403, "not editable")
if not ap.exists():
raise HTTPException(404, "not found")
ap.write_text(body.content, encoding="utf-8")
# Refresh on-disk metadata immediately (force the discovery walk so a
# brand-new file lands in the catalog at once); the real token count
# refreshes after the debounce.
indexer.scan_files(force_walk=True)
return _file_entry(ap, rel, with_content=True)
# ── UI state (Settings config + notification bookkeeping) ────────────────────
# The PWA persists its small Zustand `persist` stores here instead of the
# browser's localStorage, so config and the notification feed's seen/new state
# follow the user across browsers/devices. The value is the store's opaque
# serialized JSON blob; the backend just holds the string (see ui_state.py).
class UiStateBody(BaseModel):
value: str
class NotifSeenBody(BaseModel):
# Notification ids to mark read/seed; `all` marks/seeds the whole feed.
ids: list[str] | None = None
all: bool = False
@app.get("/api/ui-state/{key}", responses=_r(schemas.UiStateValue))
def get_ui_state(key: str):
if not valid_key(key):
raise HTTPException(400, "bad key")
return {"value": ui_state.get(key)}
@app.put("/api/ui-state/{key}", responses=_r(schemas.OkResponse))
def put_ui_state(key: str, body: UiStateBody):
if not valid_key(key):
raise HTTPException(400, "bad key")
ui_state.set(key, body.value)
return {"ok": True}
@app.delete("/api/ui-state/{key}", responses=_r(schemas.OkResponse))
def delete_ui_state(key: str):
if not valid_key(key):
raise HTTPException(400, "bad key")
ui_state.delete(key)
return {"ok": True}
def _context_by_skill(files: dict) -> dict[str, dict]:
"""Group the indexed files' tokens/cost by the skill that owns them.
A skill's *context cost* is what loading its whole dir
(``.claude/skills/<name>/…``) into a session costs once. Shared by the
dashboard rollup and the skills catalog."""
by_skill: dict[str, dict] = {}
for path, r in files.items():
parts = path.split("/")
if ".claude" in parts:
i = parts.index(".claude")
if len(parts) > i + 2 and parts[i + 1] == "skills":
name = parts[i + 2]
e = by_skill.setdefault(name, {"tokens": 0, "cost": 0.0})
e["tokens"] += r["tokens"] or 0
e["cost"] += r["cost"] or 0.0
return by_skill
@app.get("/api/dashboard", responses=_r(schemas.Dashboard))
def dashboard():
"""Skill-usage analytics + cost rollups for the homepage."""
files = store.all_files()
total_tokens = sum((r["tokens"] or 0) for r in files.values())
total_cost = sum((r["cost"] or 0.0) for r in files.values())
estimated = sum(1 for r in files.values() if r["estimated"])
by_skill = _context_by_skill(files)
skills = []
for row in store.all_skills():
ctx = by_skill.get(row["name"])
ctx_tokens = ctx["tokens"] if ctx else None
ctx_cost = ctx["cost"] if ctx else None
skills.append({
"name": row["name"],
"count": row["count"],
"lastUsed": row["last_used"],
"contextTokens": ctx_tokens,
"contextCost": ctx_cost,
# estimated total spend = cost of loading the skill once × times used
"estSpend": (ctx_cost * row["count"]) if ctx_cost is not None else None,
})
total_invocations = sum(s["count"] for s in skills)
skill_spend = sum(s["estSpend"] for s in skills if s["estSpend"] is not None)
top_files = sorted(
({"path": p, "tokens": r["tokens"] or 0, "cost": r["cost"] or 0.0,
"estimated": bool(r["estimated"])}
for p, r in files.items()),
key=lambda f: f["cost"], reverse=True,
)[:12]
return {
"pricing": {"model": MODEL, "inputPerMTok": INPUT_PRICE_PER_MTOK},
"totals": {
"files": len(files),
"tokens": total_tokens,
"cost": total_cost,
"estimated": estimated,
"skills": len(skills),
"invocations": total_invocations,
"skillSpend": skill_spend,
},
"skills": skills,
"topFiles": top_files,
}
# ── skills catalog ──────────────────────────────────────────────────────────
# The per-skill usage rollup walks every transcript's skills blob and joins it to
# the conversation summaries; memoized on the summaries version so repeat
# requests between changes are a dict lookup (same pattern as _project_costs).
_skill_usage_cache: tuple[int, dict] | None = None
_skill_usage_lock = threading.Lock()
def _skill_usage() -> dict:
global _skill_usage_cache
key = store.summaries_version
with _skill_usage_lock:
if _skill_usage_cache and _skill_usage_cache[0] == key:
return _skill_usage_cache[1]
usage = skills_mod.usage_by_skill(
store.all_summaries(), store.all_transcript_skill_rows())
with _skill_usage_lock:
_skill_usage_cache = (key, usage)
return usage
@app.get("/api/skills", responses=_r(schemas.SkillsResponse))
def skills_list():
"""Card metadata + usage/cost stats for every skill, last-used first.
The list is the **union** of the skills on disk and the ones the transcripts
saw invoked: a built-in or plugin skill (``code-review``, ``artifact-design``)
has no ``SKILL.md`` in this repo but still earned its calls, so it appears
with ``sourceKind: "builtin"`` and no editor link.
"""
usage = _skill_usage()
ctx = _context_by_skill(store.all_files())
def stats(name: str) -> dict:
u = usage.get(name) or {}
c = ctx.get(name)
count = u.get("count", 0)
ctx_cost = c["cost"] if c else None
return {
"count": count,
"lastUsed": u.get("lastUsed"),
"conversations": u.get("conversations", 0),
"contextTokens": c["tokens"] if c else None,
"contextCost": ctx_cost,
# what invoking it has plausibly cost: loading its context, per call
"estSpend": (ctx_cost * count) if ctx_cost is not None else None,
# total spend of the conversations that used it (coarse — a session
# is credited in full to every skill it invoked)
"sessionCost": u.get("sessionCost", 0.0),
"sessionTokens": u.get("sessionTokens", 0),
}
items = [{**s, **stats(s["name"])} for s in skills_mod.catalog()]
known = {s["name"] for s in items}
for name in sorted(usage.keys() - known):
items.append({
"name": name, "title": name, "description": "",
"dir": None, "path": None,
"source": "builtin", "sourceKind": "builtin",
"files": 0, "updatedAt": None,
**stats(name),
})
# Last-used first (never-used skills sink to the bottom, alphabetical) — the
# frontend re-sorts by the same field, this just makes the payload sane.
items.sort(key=lambda s: (s["lastUsed"] or "", s["name"]), reverse=True)
return {
"skills": items,
"totals": {
"skills": len(items),
"invocations": sum(s["count"] for s in items),
"used": sum(1 for s in items if s["count"] > 0),
"contextCost": sum(s["contextCost"] or 0.0 for s in items),
"estSpend": sum(s["estSpend"] or 0.0 for s in items),
},
}
def _auto_state(ended_at: str | None) -> str:
"""``running`` if the last recorded activity is recent, else ``finished``."""
if not ended_at:
return "finished"
try:
dt = datetime.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
except ValueError:
return "finished"
now = datetime.datetime.now(datetime.timezone.utc)
age = (now - dt).total_seconds()
return "running" if 0 <= age < RUNNING_WINDOW_SECS else "finished"
def _outlived_exit(exited_at: str | None, ended_at: str | None) -> bool:
"""True when the transcript's last activity is clearly *after* the recorded
process exit (>10s slack) — the session kept going in a run the sidecar
never saw (a terminal or remote-control turn), so the exit-time "finished"
stamp is stale and must not be trusted."""
if not exited_at or not ended_at:
return False
try:
exited = datetime.datetime.fromisoformat(exited_at.replace("Z", "+00:00"))
ended = datetime.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
except ValueError:
return False
return (ended - exited).total_seconds() > 10
def _resolve_state(summary: dict, m: dict, notified: str | None) -> str:
"""Decide a conversation's lifecycle state.
The ``worktree``/``select-project`` skills stamp ``state=running`` but
nothing ever clears it, so finished conversations get stuck "running". We
treat an explicit ``running`` as trustworthy only while activity is recent;
once a conversation has both sent a notification and ended its last message
with the ``DONE`` marker, it is finished regardless of the recency window.
The exit watcher (``_watch_sidecar_runs``) stamps ``finished`` the moment a
sidecar-launched run's process dies, so the badge doesn't sit on "running"
for the rest of the recency window when the prompt never said DONE.
A run whose transcript ends on a ``[Request interrupted by user]`` marker is
reported as ``interrupted`` (a terminal state, so it wins over recency and
over a stale ``running`` stamp); one ending on a ``No response requested.``
reply is reported as ``paused``. A run that parked itself waiting on a
trigger (a trailing ``ScheduleWakeup``/``Monitor`` — see the parser's
``waiting_trailing``) is ``paused`` too, instead of sitting on a "running"
badge while nothing happens. Other explicit states (e.g. ``review``) are
always honoured.
"""
auto = _auto_state(summary.get("endedAt")) # running if recent, else finished
# A trailing interrupt marker in the transcript is authoritative: the run
# was stopped by the user (Esc, or the interrupt button in this viewer).
if summary.get("interruptedByUser"):
return "interrupted"
# A trailing "No response requested." reply (a queued/resume prompt that had
# nothing to answer) leaves the run paused rather than finished.
if summary.get("pausedByUser"):
return "paused"
if summary.get("doneMarker") and notified:
return "finished"
explicit = m.get("state")
# An exit-time "finished" is trusted only while the transcript hasn't
# moved past that exit; later activity means the session continued outside
# the sidecar — fall back to the recency heuristic instead.
if explicit == "finished" and _outlived_exit(m.get("exitedAt"),
summary.get("endedAt")):
explicit = None
if explicit in (None, "running"):
# Claude scheduled a wake-up / blocked on a Monitor and the turn ended:
# the run is idle until the trigger fires, however recent its activity.
if summary.get("waitingForTrigger"):
return "paused"
return auto # stale/absent "running" → fall back to recency
return explicit
def _merge_worktrees(*sources) -> list[dict]:
"""Union worktree records from several sources, keyed by directory basename.
Each source is a list of ``{name, dir, createdAt, removedAt}`` (from the
transcript parse and/or the live sidecar). Same ``dir`` → one entry: earliest
``createdAt`` and latest ``removedAt`` win, and a real short ``name`` beats a
fallback that equals the dir. Ordered by creation (unknown-created last)."""
by_dir: dict[str, dict] = {}
for src in sources:
for e in src or []:
d = (e.get("dir") or e.get("name") or "").strip()
if not d:
continue
cur = by_dir.get(d)
if cur is None:
by_dir[d] = {"name": e.get("name") or d, "dir": d,
"createdAt": e.get("createdAt"),
"removedAt": e.get("removedAt")}
continue
nm = e.get("name")
if nm and nm != d and (not cur["name"] or cur["name"] == d):
cur["name"] = nm
for k, pick in (("createdAt", min), ("removedAt", max)):
vals = [v for v in (cur.get(k), e.get(k)) if v]
cur[k] = pick(vals) if vals else None
return sorted(by_dir.values(), key=lambda w: (w.get("createdAt") is None,
w.get("createdAt") or ""))
def _conv_title(summary: dict) -> str | None:
"""The conversation's display title.
A session can rename itself (``conv-meta title ""``) once it knows what it
is actually doing — the goal-keeper does this after picking its item, so its
card reads "Add a Work button to the Goals page" rather than the generic
spawn prompt it was launched with. That sidecar title wins over the parsed
one (Claude's ``ai-title``, else the first user message)."""
sid = summary.get("sessionId") or ""
return meta_store.peek(sid).get("title") or summary.get("title")
def _conv_meta(summary: dict) -> dict:
"""Merge a conversation's parsed auto fields with its action sidecar."""
sid = summary.get("sessionId") or ""
m = meta_store.peek(sid) # read-only — never mutated below
projects = list(summary.get("projectsAuto") or [])
for p in (m.get("projects") or []):
if p not in projects:
projects.append(p)
# Homelab services touched by the conversation (mirrors ``projects``).
services = list(summary.get("servicesAuto") or [])
for s in (m.get("services") or []):
if s not in services:
services.append(s)
# Lifecycle stamps: the live sidecar (written by the skills) is authoritative;
# where it's empty we backfill from evidence mined out of the transcript.
auto = summary.get("lifecycleAuto") or {}
stamp = lambda k: m.get(k) or auto.get(k) # noqa: E731
notified = stamp("notified")
return {
"state": _resolve_state(summary, m, notified),
# The custom sidecar title override, when set (the resolved top-level
# `title` already prefers it) — lets the UI tell a renamed card from an
# auto-titled one and offer "reset to auto title" in the rename dialog.
"title": m.get("title") or None,
"projects": projects,
"services": services,
# Git worktrees the conversation created/removed (with created/removed
# timestamps), merging the transcript parse with anything the worktree
# skill recorded live in the sidecar.
"worktrees": _merge_worktrees(summary.get("worktreesAuto"),
m.get("worktrees")),
"committed": stamp("committed"),
"pushed": stamp("pushed"),
"merged": stamp("merged"),
"deployed": stamp("deployed"),
"notified": notified,
"notifications": m.get("notifications") or [],
# Hidden-from-list flag, toggled from the conversation card context menu.
"archived": bool(m.get("archived")),
# Which agent harness runs this session ("claude"/"pi"). Stamped by
# /api/spawn; conversations predating the field (or spawned from a
# terminal) fall back to "claude" client-side.
"harness": m.get("harness"),
# Source conversation + message ordinal when this one is a fork.
"forkedFrom": m.get("forkedFrom"),
# The cron job that spawned this session ({id, name}), if any — the
# viewer tags the conversation and links back to Settings → Cron.
"cron": m.get("cron"),
}
def _conv_id(path: str) -> str:
"""Transcript path → the rel id the detail endpoint / UI use."""
try:
return str(pathlib.Path(path).relative_to(TRANSCRIPTS_DIR)) \
if TRANSCRIPTS_DIR else pathlib.Path(path).name
except ValueError:
return pathlib.Path(path).name
# ── subagent (sidechain) linkage ─────────────────────────────────────────────
# Subagent transcripts live at `<convId>/subagents/agent-<id>.jsonl` next to the
# parent `<convId>.jsonl`. They are imported + parsed like any transcript (flagged
# `isSidechain` by the parser) but hidden from the top-level lists; instead each is
# linked from the parent's Task/Agent card and its usage is rolled into the parent.
def _is_sidechain_path(abs_path: str) -> bool:
return "subagents" in pathlib.Path(abs_path).parts
def _parent_path_of(abs_path: str) -> str | None:
"""Given a subagent transcript path, the parent `<convId>.jsonl` archive path."""
parts = pathlib.Path(abs_path).parts
if "subagents" not in parts:
return None
idx = parts.index("subagents")
conv_dir = pathlib.Path(*parts[:idx]) # …/<convId>
return str(conv_dir.with_suffix(".jsonl")) # …/<convId>.jsonl
def _rollup_children(summary: dict, children: list[dict]) -> dict:
"""Fold each subagent's aggregate usage into the parent's totals and its
`agent` activity bucket, so a conversation's cost includes the sub-agents it
spawned. Returns a shallow copy; leaves the stored summary untouched."""
if not children:
return summary
s = dict(summary)
usage = dict(summary.get("usage") or conversations.zero_usage())
by_tool = {k: dict(v) for k, v in (summary.get("byTool") or {}).items()}
by_tool_sub = {k: {sk: dict(sv) for sk, sv in v.items()}
for k, v in (summary.get("byToolSub") or {}).items()}
add_tokens = add_output = 0
add_cost = 0.0
for c in children:
cu = c.get("usage") or {}
for k in usage:
usage[k] = (usage.get(k) or 0) + (cu.get(k) or 0)
add_tokens += c.get("tokens") or 0
add_cost += c.get("cost") or 0.0
add_output += cu.get("output") or 0
agent = by_tool.get("agent") or {"tokens": 0, "cost": 0.0, "output": 0}
by_tool["agent"] = {"tokens": (agent.get("tokens") or 0) + add_tokens,
"cost": (agent.get("cost") or 0.0) + add_cost,
"output": (agent.get("output") or 0) + add_output}
# Mirror the rollup into the drill-down: a "subagents" sub of the agent bucket.
asub = by_tool_sub.setdefault("agent", {})
sa = asub.get("subagents") or {"tokens": 0, "cost": 0.0, "output": 0}
asub["subagents"] = {"tokens": (sa.get("tokens") or 0) + add_tokens,
"cost": (sa.get("cost") or 0.0) + add_cost,
"output": (sa.get("output") or 0) + add_output}
s["usage"] = usage
s["byTool"] = by_tool
s["byToolSub"] = by_tool_sub
s["tokens"] = (summary.get("tokens") or 0) + add_tokens
s["cost"] = (summary.get("cost") or 0.0) + add_cost
s["subagentCount"] = len(children)
s["subagentTokens"] = add_tokens
s["subagentCost"] = add_cost
return s
def _subagent_meta(sub_jsonl: pathlib.Path) -> dict:
"""Read the `agent-<id>.meta.json` sitting next to a subagent transcript."""
meta_f = sub_jsonl.with_name(sub_jsonl.stem + ".meta.json")
try:
return json.loads(meta_f.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
# meta.json files are written once at spawn and never change, so list endpoints
# may cache them (only non-empty reads — an unwritten file may appear later).
_SUBAGENT_META_CACHE: dict[str, dict] = {}
def _subagent_meta_cached(sub_jsonl: pathlib.Path) -> dict:
key = str(sub_jsonl)
hit = _SUBAGENT_META_CACHE.get(key)
if hit is not None:
return hit
mj = _subagent_meta(sub_jsonl)
if mj:
_SUBAGENT_META_CACHE[key] = mj
return mj
def _running_agents(summary: dict, state: str | None = None) -> list[dict]:
"""The subagents a conversation still has in flight.
An open Task call only means "running" while the conversation itself is: a
run that was interrupted (or died) mid-agent leaves its call open forever,
and so does every transcript written before the parser learned to close a
background agent on its `<task-notification>`. Nothing is running there."""
ra = summary.get("runningAgents") or []
if not ra:
return []
st = state if state is not None else _conv_meta(summary).get("state")
return ra if st == "running" else []
def _sidechain_state(ap: pathlib.Path) -> str | None:
"""A subagent's own lifecycle state: it runs exactly as long as the parent's
Task call is still open. `None` if `ap` isn't a subagent transcript.
Its `_conv_meta` state can't be trusted: a sidechain's sessionId is the
*parent's*, so the sidecar lookup returns the parent's (possibly stale)
state, and the recency fallback would keep a just-finished subagent "running"
for the whole recency window."""
pp = _is_sidechain_path(str(ap)) and _parent_path_of(str(ap))
if not pp:
return None
psum = store.get_summary(pp) or {}
tuid = _subagent_meta(ap).get("toolUseId")
open_calls = {a.get("toolUseId") for a in _running_agents(psum)}
return "running" if tuid and tuid in open_calls else "finished"
def _attach_subagents(ap: pathlib.Path, data: dict) -> dict:
"""Enrich a parsed parent thread with its subagents: link each Task/Agent card
to the child transcript it spawned (via meta.json `toolUseId`) and roll the
children's usage into the parent totals. If `ap` is itself a subagent, attach a
`parentConversation` back-link instead. Returns the (mutated) data."""
# A subagent detail view: link back up to the parent + the exact Task card.
if _is_sidechain_path(str(ap)):
pp = _parent_path_of(str(ap))
if pp:
psum = store.get_summary(pp) or {}
mj = _subagent_meta(ap)
data["parentConversation"] = {
"id": _conv_id(pp),
"title": _conv_title(psum),
"toolUseId": mj.get("toolUseId"),
"agentType": mj.get("agentType"),
"description": mj.get("description"),
}
return data
# A parent view. Mark every Task/Agent card with whether its agent is still
# running (the parser tracks the open calls as `runningAgents`) — including
# the ones whose subagent transcript hasn't been written yet, which is
# exactly the moment the card most needs to say "running".
open_calls = {a.get("toolUseId") for a in (data.get("runningAgents") or [])}
for it in data.get("thread") or []:
if it.get("name") in ("Task", "Agent") and it.get("toolUseId"):
it["agentRunning"] = it["toolUseId"] in open_calls
# Gather the children from the sibling `subagents/` dir.
sub_dir = ap.with_suffix("") / "subagents"
if not sub_dir.is_dir():
return data
link_by_tooluse: dict[str, dict] = {}
infos_by_id: dict[str, dict] = {}
child_summaries: list[dict] = []
for meta_f in sorted(sub_dir.glob("*.meta.json")):
stem = meta_f.name[:-len(".meta.json")] # agent-<id>
child = sub_dir / f"{stem}.jsonl"
csum = store.get_summary(str(child)) or {}
mj = _subagent_meta(child)
tuid = mj.get("toolUseId")
info = {"id": _conv_id(str(child)),
"agentType": mj.get("agentType"),
"description": mj.get("description"),
"title": csum.get("title"), "model": csum.get("model"),
"tokens": csum.get("tokens"), "cost": csum.get("cost"),
"messages": csum.get("messages"),
"startedAt": csum.get("startedAt"),
"endedAt": csum.get("endedAt"),
"state": "running" if tuid in open_calls else "finished"}
infos_by_id[info["id"]] = info
if mj.get("toolUseId"):
link_by_tooluse[mj["toolUseId"]] = info
if csum:
child_summaries.append(csum)
# Link each Task/Agent card to its child and collect the subagents in the
# order they were spawned (thread order), so the detail view can list them.
ordered: list[dict] = []
seen: set[str] = set()
for it in data.get("thread") or []:
tuid = it.get("toolUseId")
info = tuid and link_by_tooluse.get(tuid)
if info:
it["subagent"] = info
# A subagent's spend is rolled into the parent's total and its `agent`
# bucket below, but it was billed to the *child's* transcript — so no
# parent thread item carries it, and the cost-growth chart (which sums
# thread items) would report less than the total. Credit it to the card
# that spawned it: the turn that actually caused the spend.
child_cost = info.get("cost") or 0.0
if child_cost > 0:
it["turnCost"] = (it.get("turnCost") or 0.0) + child_cost
it["turnTokens"] = (it.get("turnTokens") or 0) + (info.get("tokens") or 0)
# It's all downstream agent work, whatever the card's own turn drove.
it["turnBucket"] = "agent"
if info["id"] not in seen:
ordered.append(info)
seen.add(info["id"])
for cid, info in infos_by_id.items(): # any orphans (no matching card)
if cid not in seen:
ordered.append(info)
seen.add(cid)
# No card claimed this child's cost above, but the rollup still counts
# it — park it on an invisible carrier so the chart stays whole.
if (info.get("cost") or 0.0) > 0 and data.get("thread") is not None:
data["thread"].append({
"role": "assistant", "kind": "turn", "first": False, "out": 0,
"turnCost": info["cost"], "turnTokens": info.get("tokens") or 0,
"turnBucket": "agent",
})
data["subagents"] = ordered
# Roll subagent usage into the parent totals / `agent` bucket.
rolled = _rollup_children(data, child_summaries)
for k in ("usage", "byTool", "byToolSub", "tokens", "cost",
"subagentCount", "subagentTokens", "subagentCost"):
if k in rolled:
data[k] = rolled[k]
return data
def _conv_card(path: str, s: dict) -> dict:
"""Compact conversation reference used when cross-linking from a memory."""
return {
"id": _conv_id(path),
"sessionId": s.get("sessionId"),
"title": _conv_title(s),
"model": s.get("model"),
"endedAt": s.get("endedAt"),
"cost": s.get("cost"),
"tokens": s.get("tokens"),
"meta": _conv_meta(s),
}
# Card fields every list view needs. `usage`/`byToolSub` are heavy per-card and
# only the analytics dashboard reads them — it asks with ``full=1``.
_CARD_KEYS = ("sessionId", "project", "gitBranch", "model", "models", "efforts",
"title", "userTurns", "assistantTurns", "messages", "startedAt", "endedAt",
"tokens", "cost", "byTool", "subagentCount",
"subagentTokens", "subagentCost", "tasks")
_CARD_KEYS_FULL = _CARD_KEYS + ("usage", "byToolSub")
def _dedup_by_session(summaries: list[tuple[str, dict]]) -> list[tuple[str, dict]]:
"""Collapse transcript files that share a ``sessionId`` into one.
Resuming a session in a different working directory (a worktree, or
``claude --resume`` from another dir) makes Claude Code write the
continuation to a *new* cwd-encoded folder under the SAME sessionId — so
one logical conversation lands on disk as several ``<cwd>/<sid>.jsonl``
files. Keyed on the file path, each became its own card and the
conversation showed up two (or more) times. Keep only the file with the
most recent activity (latest ``endedAt``, then most messages, then longest
path as a stable tiebreak) — that's the fully-resumed transcript. Files
without a sessionId are never collapsed (nothing links them)."""
best: dict[str, tuple[str, dict]] = {}
passthrough: list[tuple[str, dict]] = []
for path, s in summaries:
sid = s.get("sessionId")
if not sid:
passthrough.append((path, s))
continue
cur = best.get(sid)
if cur is None or (
(s.get("endedAt") or "", s.get("messages") or 0, path)
> (cur[1].get("endedAt") or "", cur[1].get("messages") or 0, cur[0])
):
best[sid] = (path, s)
return list(best.values()) + passthrough
def _conversation_cards(full: bool) -> list[dict]:
"""Every non-sidechain conversation as a card dict, unsorted.
Summaries come from the store's shared decode cache and are **never
mutated** here — rollups copy, and per-card extras land on the card."""
summaries = store.all_summaries()
# Group subagent summaries under their parent so we can roll their usage up.
children: dict[str, list[dict]] = {}
for path, s in summaries:
if s.get("isSidechain"):
pp = _parent_path_of(path)
if pp:
children.setdefault(pp, []).append(s)
keys = _CARD_KEYS_FULL if full else _CARD_KEYS
# A session resumed across working dirs has one transcript file per cwd —
# collapse those so the conversation shows up once, not once per copy.
out = []
for path, s in _dedup_by_session(
[(p, s) for p, s in summaries if not s.get("isSidechain")]
):
if not s.get("messages"):
continue # skip empty transcripts
s = _rollup_children(s, children.get(path, []))
m = _conv_meta(s)
out.append({"id": _conv_id(path), **{k: s.get(k) for k in keys},
"title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")),
"meta": m})
return out
@app.get("/api/conversations", responses=_r(schemas.ConversationsResponse))
def conversations_list(limit: int = 50, before: str | None = None,
archived: str = "false", full: bool = False):
"""List parsed transcripts (newest first) for the conversation viewer.
Paginated: ``limit`` caps the page (0 = everything), ``before`` is the
``endedAt`` cursor of the previous page's last card, and ``archived``
filters hidden conversations server-side (``false`` — the default view —
excludes them; ``all`` includes them). ``full=1`` adds the per-card
``usage``/``byToolSub`` blocks the analytics dashboard aggregates."""
cards = _conversation_cards(full)
if archived != "all":
cards = [c for c in cards if not c["meta"].get("archived")]
cards.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
total = len(cards)
if before:
cards = [c for c in cards if (c.get("endedAt") or "") < before]
next_before = None
if limit and limit > 0 and len(cards) > limit:
cards = cards[:limit]
next_before = cards[-1].get("endedAt")
return {"conversations": cards, "count": total, "nextBefore": next_before,
"pricing": {"model": MODEL, "inputPerMTok": INPUT_PRICE_PER_MTOK}}
@app.get("/api/subagents", responses=_r(schemas.SubagentsResponse))
def subagents_list():
"""Every subagent (sidechain) transcript as a flat lite ref carrying its
parent conversation id. The top-level conversation list hides sidechains;
this is the one place they're enumerated — the graph view's node source.
All from the in-process summaries cache (+ cached meta.json reads)."""
out = []
open_by_parent: dict[str, set] = {}
for path, s in store.all_summaries():
if not s.get("isSidechain") or not s.get("messages"):
continue
pp = _parent_path_of(path)
if not pp:
continue
# One open-Task lookup per parent, shared by all its children.
if pp not in open_by_parent:
psum = store.get_summary(pp) or {}
open_by_parent[pp] = {a.get("toolUseId")
for a in _running_agents(psum)}
mj = _subagent_meta_cached(pathlib.Path(path))
tuid = mj.get("toolUseId")
out.append({
"id": _conv_id(path),
"parentId": _conv_id(pp),
"agentType": mj.get("agentType"),
"description": mj.get("description"),
"title": s.get("title"),
"tokens": s.get("tokens"),
"cost": s.get("cost"),
"state": "running" if tuid and tuid in open_by_parent[pp]
else "finished",
})
return {"subagents": out}
@app.get("/api/conversation-summary",
responses=_r(schemas.ConversationSummary))
def conversation_summary(id: str):
"""One conversation's list card — what an SSE `transcript` ping patches
into the cached list, so a live turn costs a few KB instead of a full
list refetch."""
ap = _resolve_transcript(id)
s = store.get_summary(str(ap))
if not s:
raise HTTPException(404, "summary not indexed yet")
children: list[dict] = []
sub_dir = ap.with_suffix("") / "subagents"
if sub_dir.is_dir():
for child in sorted(sub_dir.glob("*.jsonl")):
cs = store.get_summary(str(child))
if cs:
children.append(cs)
s = _rollup_children(s, children)
m = _conv_meta(s)
return {"id": id, **{k: s.get(k) for k in _CARD_KEYS},
"title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")), "meta": m}
@app.get("/api/notifications", responses=_r(schemas.NotificationsResponse))
def notifications_feed():
"""Feed source for the notification history: every conversation that
recorded a push, with just the fields the feed needs. Replaces walking the
whole conversation list client-side."""
out = []
for path, s in store.all_summaries():
if not s.get("messages") or s.get("isSidechain"):
continue
sid = s.get("sessionId") or ""
notes = meta_store.peek(sid).get("notifications") or []
if not notes:
continue
m = _conv_meta(s)
out.append({"id": _conv_id(path), "title": _conv_title(s),
"endedAt": s.get("endedAt"),
"projects": m.get("projects") or [],
"services": m.get("services") or [],
"notifications": notes})
out.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
return {"conversations": out, "count": len(out)}
# ── flat notification feed (server-side mirror of the frontend's buildFeed) ──
# The PWA flattens /api/notifications + /api/notify-log into one list of feed
# items (lib/notifications.ts). The phone can't run that, so we do the same
# flattening here — same stable ids — for the unread endpoint and the read
# ledger. Keep the two in sync: id scheme is `<convId>#<at>#<index>` for
# conv-linked pushes and `log#<id>` for hub-only entries.
_DEDUP_WINDOW_MS = 120_000
def _parse_iso_ms(at: str | None) -> float | None:
if not at:
return None
try:
s = at.replace("Z", "+00:00")
return datetime.datetime.fromisoformat(s).timestamp() * 1000.0
except (ValueError, AttributeError):
return None
def _flat_notifications() -> list[dict]:
"""Every notification as a flat, de-duplicated, newest-first feed item —
the exact set the PWA shows, so "unread" means the same on the phone."""
feed: list[dict] = []
for path, s in store.all_summaries():
if not s.get("messages") or s.get("isSidechain"):
continue
sid = s.get("sessionId") or ""
notes = meta_store.peek(sid).get("notifications") or []
if not notes:
continue
m = _conv_meta(s)
cid = _conv_id(path)
title = _conv_title(s)
last_seen: dict[str, float] = {}
for i, n in enumerate(notes):
key = (f"{n.get('title') or ''} {n.get('body') or ''} "
f"{n.get('type') or ''} {n.get('url') or ''}")
t = _parse_iso_ms(n.get("at"))
prev = last_seen.get(key)
if prev is not None and (t is None or abs(t - prev) <= _DEDUP_WINDOW_MS):
continue # duplicate push within the conversation
if t is not None:
last_seen[key] = t
feed.append({
"id": f"{cid}#{n.get('at') or ''}#{i}",
"convId": cid, "convTitle": title,
"title": n.get("title") or "", "body": n.get("body") or "",
"type": n.get("type") or "", "url": n.get("url") or "",
"at": n.get("at"),
"projects": m.get("projects") or [],
"services": m.get("services") or [],
"kind": "notify",
})
# Merge the hub log: a log entry that matches a conv push (title+body within
# the dedup window) is a duplicate and skipped; hub-only entries are added.
for e in notify_store.log(limit=500):
t = _parse_iso_ms(e.get("at"))
dup = False
for n in feed:
if n["title"] == (e.get("title") or "") and \
n["body"] == (e.get("body") or ""):
nt = _parse_iso_ms(n.get("at"))
if t is None or nt is None or abs(nt - t) <= _DEDUP_WINDOW_MS:
dup = True
break
if dup:
continue
feed.append({
"id": f"log#{e.get('id')}",
"convId": "", "convTitle": "",
"title": e.get("title") or "", "body": e.get("body") or "",
"type": e.get("type") or "", "url": e.get("url") or "",
"at": e.get("at"),
"projects": [], "services": [],
"kind": e.get("kind") or "notify",
})
feed.sort(key=lambda n: n.get("at") or "", reverse=True)
return feed
@app.get("/api/notifications/unread",
responses=_r(schemas.UnreadNotificationsResponse))
def notifications_unread(limit: int = 20):
"""Unread notifications (not yet opened in the UI), newest first.
Pure read: fetching this NEVER marks anything read — the phone can list and
read these aloud without "consuming" them; only the UI (or an explicit
``POST /api/notifications/seen``) advances the read ledger. This is the
endpoint the desk phone hits with its read-only API key.
"""
feed = _flat_notifications()
unread_ids = set(notif_read_store.unread([n["id"] for n in feed]))
items = [n for n in feed if n["id"] in unread_ids]
total = len(items)
if limit and limit > 0:
items = items[:limit]
return {"notifications": items, "count": len(items), "total": total}
@app.post("/api/notifications/seen", responses=_r(schemas.OkResponse))
def notifications_mark_seen(body: NotifSeenBody):
"""Mark notification ids as read (opened/dismissed in the UI). Mutating —
refused to a read-only API-key caller by the gate, so the phone can't use
it. Passing no ids with ``all=true`` marks the whole current feed read."""
ids = list(body.ids or [])
if body.all:
ids += [n["id"] for n in _flat_notifications()]
count = notif_read_store.mark_seen(ids)
return {"ok": True, "seen": count}
@app.post("/api/notifications/seed", responses=_r(schemas.OkResponse))
def notifications_seed(body: NotifSeenBody):
"""Adopt the given ids (or the whole current feed) as the read baseline —
the first-sync op so history isn't reported as unread."""
ids = list(body.ids or [])
if body.all or not ids:
ids += [n["id"] for n in _flat_notifications()]
count = notif_read_store.seed(ids)
return {"ok": True, "seen": count}
@app.get("/api/search-index", responses=_r(schemas.SearchIndexResponse))
def search_index():
"""Full-text search corpus: every conversation's user/assistant text blocks.
Shipped once to the client, which runs a fuzzy search (Fuse.js) over it. Each
message carries its ordinal `i`, which matches the `mi` on the detail thread's
text items, so a hit can deep-link to `/conversation/<id>?m=<i>` and scroll to
the exact message."""
out = []
for path, s in store.all_summaries():
if not s.get("messages"):
continue
if s.get("isSidechain"):
continue # subagent messages are reachable via the parent, not listed
msgs = s.get("searchMsgs") or []
if not msgs:
continue
out.append({
"id": _conv_id(path),
"title": _conv_title(s),
"project": s.get("project"),
"model": s.get("model"),
"endedAt": s.get("endedAt"),
# serve-time clip: search + snippets don't need more, and this
# corpus ships to the client in one payload
"msgs": [{**m, "text": (m.get("text") or "")[:500]} for m in msgs],
})
out.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
return {"conversations": out, "count": len(out)}
# Recently parsed full threads, keyed by path and validated on (mtime, size).
# A parse of a large transcript costs hundreds of ms; re-opening a conversation
# (or a meta-only SSE ping refetching the open one) shouldn't pay it again.
# Served as a deep copy — the endpoint decorates the dict in place.
_detail_cache: dict[str, tuple[tuple[float, int], dict]] = {}
_DETAIL_CACHE_CAP = 4
_detail_lock = threading.Lock()
def _parse_full_cached(ap: pathlib.Path) -> dict:
key = str(ap)
try:
st = ap.stat()
sig = (st.st_mtime, st.st_size)
except OSError:
sig = None
with _detail_lock:
hit = _detail_cache.get(key)
if hit and sig and hit[0] == sig:
_detail_cache[key] = _detail_cache.pop(key) # LRU bump
return copy.deepcopy(hit[1])
data = conversations.parse_conversation(ap, full=True)
if sig:
with _detail_lock:
_detail_cache[key] = (sig, copy.deepcopy(data))
while len(_detail_cache) > _DETAIL_CACHE_CAP:
_detail_cache.pop(next(iter(_detail_cache)))
return data
@app.get("/api/conversation", responses=_r(schemas.ConversationDetail))
def conversation_detail(id: str):
"""Full parsed thread (tools collapsible client-side) + per-turn usage."""
ap = _resolve_transcript(id)
data = _parse_full_cached(ap)
data.pop("skills", None)
data["id"] = id
data["title"] = _conv_title(data)
data["meta"] = _conv_meta(data)
sub_state = _sidechain_state(ap)
if sub_state:
data["meta"]["state"] = sub_state
# Resolve the state first: which Task calls count as running depends on it.
data["runningAgents"] = _running_agents(data, data["meta"].get("state"))
_attach_subagents(ap, data)
# Cross-link the memories this conversation read (recalled) and created.
read_slugs = set(data.get("memoriesRead") or [])
data["memoriesRead"] = [m for m in memories_mod.list_memories()
if m["slug"] in read_slugs]
data["memoriesCreated"] = memories_mod.by_origin(data.get("sessionId"))
# Plans this conversation authored (the `plan` skill stamps the planning
# session id / conversationIds into each plan's frontmatter).
sid = data.get("sessionId")
data["plans"] = ([p for p in plans_mod.list_plans()
if sid in plans_mod.linked_session_ids(p)] if sid else [])
# Generated artefacts (screenshots, renders…) the conversation's projects/
# services hold for this session — <dir>/.ai/artefacts/<date>/<sid>/<file>.
data["artefacts"] = artefacts_mod.list_for_conversation(
sid or "", data["meta"].get("projects") or [],
data["meta"].get("services") or [])
return data
# ── diff view: per-conversation commits + commit / conversation diffs ────────
def _resolve_transcript(id: str) -> pathlib.Path:
"""Validate a conversation id and return its archived transcript path."""
if not TRANSCRIPTS_DIR:
raise HTTPException(404, "transcripts not mounted")
rel = pathlib.PurePosixPath(id)
if rel.is_absolute() or ".." in rel.parts or rel.suffix != ".jsonl":
raise HTTPException(400, "invalid id")
ap = (TRANSCRIPTS_DIR / id).resolve()
try:
ap.relative_to(TRANSCRIPTS_DIR)
except ValueError:
raise HTTPException(400, "id escapes transcripts")
if not ap.is_file():
raise HTTPException(404, "not found")
return ap
def _summary_and_meta(id: str) -> tuple[str, dict, dict]:
"""(sessionId, summary, meta sidecar) for a conversation id."""
ap = _resolve_transcript(id)
summary = store.get_summary(str(ap)) or conversations.parse_conversation(ap, full=False)
sid = summary.get("sessionId") or ""
return sid, summary, meta_store.get(sid)
@app.get("/api/conversation-commits", responses=_r(schemas.ConversationCommits))
def conversation_commits(id: str):
"""The commits a conversation produced (cached), tagged with their repo."""
sid, summary, meta = _summary_and_meta(id)
data = gitdiff.conversation_commits(store, summary, meta, sid)
return {"id": id, **data}
@app.get("/api/commit-diff", responses=_r(schemas.DiffResult))
def commit_diff(repo: str, sha: str):
"""Structured per-file hunk diff of a single commit within a repo token."""
out = gitdiff.commit_diff(repo, sha)
if out is None:
raise HTTPException(404, "commit not found")
return out
# Content-type for image blobs served in the diff view (the only kind the diff
# viewer requests). Anything else falls back to octet-stream.
_IMAGE_CT = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
".avif": "image/avif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
}
@app.get("/api/diff-blob")
def diff_blob(repo: str, path: str, sha: str | None = None):
"""Raw bytes of a file inside a diff — the image side rendered in the viewer.
``repo`` is a diff repo token (``project:`` / ``service:`` / ``super:_``),
``path`` is repo-relative. With ``sha`` the file is read at that rev (e.g. the
commit's tip for an added image, or ``<sha>^`` for a deleted one); without it
the current working-tree copy is served (conversation / uncommitted diffs)."""
data = gitdiff.blob_bytes(repo, path, sha=sha)
if data is None:
raise HTTPException(404, "blob not found")
ext = pathlib.PurePosixPath(path).suffix.lower()
ct = _IMAGE_CT.get(ext, "application/octet-stream")
# SVG can carry scripts; serving it inline as image/svg+xml is fine for <img>
# but nosniff + no inline-disposition keeps it from being treated as a page.
return Response(content=data, media_type=ct,
headers={"X-Content-Type-Options": "nosniff",
"Cache-Control": "public, max-age=3600"})
# Replaying a transcript's edits re-reads the whole file; during a live run the
# panel refetches on every SSE ping, so cache per (path, mtime, size, commits).
_udiff_cache: dict[str, tuple[tuple, dict]] = {}
_UDIFF_CACHE_CAP = 8
_udiff_lock = threading.Lock()
@app.get("/api/conversation-uncommitted-diff", responses=_r(schemas.DiffResult))
def conversation_uncommitted_diff(id: str, stats: bool = False):
"""The conversation's current not-yet-committed changes, replayed from the
transcript's accumulated edits/creates/deletes (see editsdiff). ``stats``
strips the hunks — the detail panel only needs paths and +/ counts."""
sid, summary, meta = _summary_and_meta(id)
ap = _resolve_transcript(id)
data = gitdiff.conversation_commits(store, summary, meta, sid)
try:
st = ap.stat()
sig = (st.st_mtime, st.st_size,
tuple(c.get("sha") for c in data["commits"]))
except OSError:
sig = None
out = None
with _udiff_lock:
hit = _udiff_cache.get(str(ap))
if hit and sig and hit[0] == sig:
out = hit[1]
if out is None:
out = editsdiff.uncommitted_diff(ap, summary.get("title") or "",
data["commits"])
if sig:
with _udiff_lock:
_udiff_cache[str(ap)] = (sig, out)
while len(_udiff_cache) > _UDIFF_CACHE_CAP:
_udiff_cache.pop(next(iter(_udiff_cache)))
if stats:
return {**out, "files": [{**f, "hunks": []} for f in out["files"]]}
return out
@app.get("/api/conversation-diff", responses=_r(schemas.DiffResult))
def conversation_diff(id: str):
"""Aggregated net diff across all of a conversation's commits."""
sid, summary, meta = _summary_and_meta(id)
data = gitdiff.conversation_commits(store, summary, meta, sid)
by_repo: dict[str, list[str]] = {}
for c in data["commits"]:
by_repo.setdefault(c["repo"], []).append(c["sha"])
files: list[dict] = []
add = dele = 0
for token, shas in by_repo.items():
agg = gitdiff.aggregate_diff(token, shas, title="", subtitle="")
if agg:
files += agg["files"]
add += agg["additions"]
dele += agg["deletions"]
title = summary.get("title") or "Conversation changes"
n = len(data["commits"])
return {
"title": title,
"subtitle": f"{n} commit{'s' if n != 1 else ''} · {len(files)} file{'s' if len(files) != 1 else ''}",
"files": files, "additions": add, "deletions": dele,
"commits": data["commits"], "approx": True,
}
# ── conversation action metadata (edited by skills, streamed to the UI) ──────
class MetaPatch(BaseModel):
id: str # Claude Code session id (or "<proj>/<sid>.jsonl")
# Display title the session chose for itself (`conv-meta title "…"`), used
# instead of the parsed one. Lets an agent spawned with a generic prompt
# rename its card once it knows what it's actually building.
title: str | None = None
projects: list[str] | str | None = None
services: list[str] | str | None = None
state: str | None = None
committed: str | None = None
pushed: str | None = None
merged: str | None = None
deployed: str | None = None
notified: str | None = None
notification: dict | None = None
worktree: dict | None = None # a single worktree create/remove event to fold in
archived: bool | None = None # hide/unhide from the default conversation list
def _meta_key(raw: str) -> str:
"""Accept a bare session id or a transcript id; store under the session id."""
name = pathlib.PurePosixPath(raw).name
return name[:-6] if name.endswith(".jsonl") else name
@app.get("/api/conversation-meta", responses=_r(schemas.ConversationMetaResponse))
def conversation_meta():
"""The raw action-metadata sidecar (keyed by session id)."""
return {"meta": meta_store.all()}
@app.post("/api/conversation-meta", responses=_r(schemas.MetaUpdateResult))
def update_conversation_meta(patch: MetaPatch):
body = patch.model_dump(exclude_none=True)
cid = _meta_key(body.pop("id"))
if not cid:
raise HTTPException(400, "missing id")
merged = meta_store.update(cid, body)
hub.publish({"type": "meta", "id": cid})
return {"id": cid, "meta": merged}
@app.post("/api/conversations/archive-old",
responses=_r(schemas.ArchiveOldResult))
def archive_old_conversations():
"""Archive (hide) every conversation that ended before the start of yesterday.
Bulk cleanup for the drawer: keeps today's and yesterday's transcripts
visible and folds everything older behind the "show archived" toggle."""
# endedAt is a UTC ISO timestamp; compare against a UTC start-of-yesterday so
# the day boundary lines up (lexicographic works at day granularity).
today = datetime.datetime.now(datetime.timezone.utc).date()
cutoff = datetime.datetime.combine(
today - datetime.timedelta(days=1), datetime.time.min).isoformat()
n = 0
for path, s in store.all_summaries():
if not s.get("messages") or s.get("isSidechain"):
continue
ended = s.get("endedAt") or ""
if ended and ended < cutoff:
sid = s.get("sessionId") or _meta_key(_conv_id(path))
if sid and not meta_store.get(sid).get("archived"):
meta_store.update(sid, {"archived": True})
n += 1
hub.publish({"type": "meta"})
return {"archived": n, "cutoff": cutoff}
# ── spawn / resume / interrupt `claude -p` sessions via the host sidecar ──────
def _sidecar_call(path: str, payload: dict) -> dict:
"""POST to the host sidecar (bearer auth) and return its JSON reply.
Raises 503 when the sidecar isn't configured, or 502 when it's unreachable
or answers with an error — so every proxy endpoint reports failures the
same way."""
if not SIDECAR_TOKEN:
raise HTTPException(503, "sidecar not configured (no SIDECAR_TOKEN)")
data = json.dumps(payload).encode()
req = urllib.request.Request(
f"{SIDECAR_URL}{path}", data=data, method="POST",
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {SIDECAR_TOKEN}"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:500]
raise HTTPException(502, f"sidecar error {e.code}: {detail}")
except (urllib.error.URLError, OSError, ValueError) as e:
raise HTTPException(502, f"sidecar unreachable: {e}")
def _valid_sid(raw: str) -> str:
sid = (raw or "").strip()
try:
uuidlib.UUID(sid)
except ValueError:
raise HTTPException(400, "sessionId must be a valid UUID")
return sid
# ── run-exit detection ────────────────────────────────────────────────────────
# The sidecar knows exactly when a run's process dies (its pidfiles), so the
# viewer doesn't have to guess "finished" from the DONE marker or wait out the
# recency window. A background thread polls the sidecar's live-session list and
# stamps a conversation finished the moment its run vanishes from it.
# Sessions with an in-flight /resume or /interrupt: those deliberately stop or
# replace the run, so the exit watcher must not race the vanish and stamp
# "finished" over the state the endpoint is about to write.
_sidecar_busy: set[str] = set()
def _sidecar_live_sids() -> set[str] | None:
"""Session ids with a live process behind them, per the sidecar's
``/sessions`` (which also reaps finished runs' pidfiles as it answers).
None = sidecar unconfigured/unreachable — the caller must treat that as
"unknown", never as "nothing is running"."""
if not SIDECAR_TOKEN:
return None
req = urllib.request.Request(
f"{SIDECAR_URL}/sessions",
headers={"Authorization": f"Bearer {SIDECAR_TOKEN}"})
try:
with urllib.request.urlopen(req, timeout=3) as r:
out = json.loads(r.read().decode())
return {s["sessionId"] for s in out.get("sessions", [])
if s.get("sessionId")}
except (urllib.error.URLError, OSError, ValueError, KeyError):
return None
def _mark_run_exited(sid: str) -> None:
"""A sidecar-launched run's process is gone: stamp the conversation
``finished`` (with the exit time) unless an endpoint/skill already moved it
to another state. The transcript's own trailing markers still outrank the
stamp in ``_resolve_state`` (interrupted, DONE+notified, …), and
``exitedAt`` lets it be distrusted if the session later continues outside
the sidecar (see ``_outlived_exit``)."""
if meta_store.get(sid).get("state") not in (None, "running"):
return
now = datetime.datetime.now(datetime.timezone.utc) \
.strftime("%Y-%m-%dT%H:%M:%SZ")
meta_store.update(sid, {"state": "finished", "exitedAt": now})
hub.publish({"type": "meta", "id": sid})
def _watch_sidecar_runs(interval: float = 2.0) -> None:
"""Poll the sidecar's live-session list; a session id vanishing from it
means its process exited — flip the conversation to ``finished`` within a
tick instead of after the recency window.
The first successful poll only sets the baseline (a fresh backend can't
tell "exited while we were down" from "never sidecar-launched" — those
stay on the recency fallback). Ids with an in-flight resume/interrupt are
deferred a tick so the endpoint's own stamp wins; if the resume then
failed, the id is still absent next tick and gets flipped then."""
prev: set[str] | None = None
while True:
time.sleep(interval)
try:
cur = _sidecar_live_sids()
if cur is None:
continue # unknown — keep the baseline, flip nothing
if prev is not None:
for sid in prev - cur:
if sid in _sidecar_busy:
cur.add(sid) # decide next tick
continue
_mark_run_exited(sid)
prev = cur
except Exception:
pass
@app.get("/api/models", responses=_r(schemas.ModelsResponse))
def list_models():
"""The models a session can be spawned/resumed on (a tag each in the composer).
Fetched from the Anthropic API and cached in-process (see models.py), so the
picker follows new releases without a redeploy. `default` is what a run uses
when no model tag is picked."""
return {"models": models_mod.list_models(),
"default": models_mod.DEFAULT_MODEL}
class SpawnBody(BaseModel):
prompt: str
model: str | None = None
# Which agent harness runs the session: "claude" (the claude CLI, default)
# or "pi" (the pi.dev runner — see services/ai-agent/runner/).
harness: str | None = None
# Whether the session thinks. False ⇒ the sidecar runs the harness with
# extended thinking off (`--thinking disabled` / `--thinking off`); unset ⇒
# each CLI keeps its own default. Both harnesses support it.
thinking: bool | None = None
# How hard a claude run works a turn (`--effort`: low|medium|high|xhigh|max).
# Claude-only — it's what the composer shows instead of the thinking toggle
# for claude models; unset ⇒ the CLI's own default.
effort: str | None = None
class ResumeBody(BaseModel):
sessionId: str
prompt: str
model: str | None = None
cwd: str | None = None
harness: str | None = None # unset ⇒ the harness the session started on
thinking: bool | None = None # unset ⇒ what the session last ran with
effort: str | None = None # unset ⇒ what the session last ran with
def _send_message(prompt: str, *, session_id: str | None = None,
model: str | None = None, cwd: str | None = None,
harness: str | None = None,
thinking: bool | None = None,
effort: str | None = None) -> dict:
"""The one send path behind both composers.
With no ``session_id`` this starts a new conversation: we mint the UUID here
so the frontend can start watching for the transcript to sync (~2s) and
redirect to it without waiting for the run. With one, it continues that
session — and a continue *always* takes the prompt: if a run is still live
(or parked on a ScheduleWakeup/Monitor), the sidecar stops it first (SIGINT
the group, wait for it to die) and then launches the ``--resume`` with the
new turn, so sending into a "running"/"paused" conversation restarts it on
the new message. Either way the actual agent process (`claude -p` or the pi
runner, per ``harness``) is launched by the host sidecar (the container
can't reach the host's authenticated CLIs), and we stamp ``running``
immediately so the UI flips before the first turn has synced."""
prompt = (prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
if session_id:
sid = _valid_sid(session_id)
# A conversation resumes on the harness it started on — and with the
# thinking setting it last ran with — unless the caller overrides. Both
# were stamped into the metadata sidecar by the spawn.
prev = meta_store.get(sid)
harness = (harness or prev.get("harness") or "claude").lower()
if thinking is None:
thinking = prev.get("thinking")
if effort is None:
effort = prev.get("effort")
# A force-resume swaps the session's process; shield the swap from the
# exit watcher so the old run's vanish isn't stamped "finished" over
# the "running" written below.
_sidecar_busy.add(sid)
try:
out = _sidecar_call("/resume", {"sessionId": sid, "prompt": prompt,
"model": model, "cwd": cwd,
"harness": harness,
"thinking": thinking,
"effort": effort, "force": True})
if out.get("reused"):
# force=True makes the sidecar stop a live run instead of
# answering `reused`; only a sidecar predating the flag still
# can. The turn was dropped either way — a 409, not a success,
# so the composer says so instead of waiting on an answer that
# isn't coming.
meta_store.update(sid, {"state": "running"})
hub.publish({"type": "meta", "id": sid})
raise HTTPException(409, "this session is still running and "
"the sidecar is too old to stop it — "
"restart the claude-sidecar service")
# Stamp running (and re-stamp harness + thinking) before lifting the
# shield — the UI flips before the transcript syncs. A None thinking
# is dropped by the merge, so the session keeps what it had.
meta_store.update(sid, {"state": "running", "harness": harness,
"thinking": thinking, "effort": effort})
finally:
_sidecar_busy.discard(sid)
else:
sid = str(uuidlib.uuid4())
harness = (harness or "claude").lower()
out = _sidecar_call("/spawn", {"prompt": prompt, "sessionId": sid,
"model": model, "harness": harness,
"thinking": thinking, "effort": effort})
# A fresh id can't be in the exit watcher's baseline — no shield needed.
meta_store.update(sid, {"state": "running", "harness": harness,
"thinking": thinking, "effort": effort})
hub.publish({"type": "meta", "id": sid})
return {"sessionId": sid, "pid": out.get("pid")}
@app.post("/api/spawn", responses=_r(schemas.SpawnResult))
def spawn_conversation(body: SpawnBody):
"""Kick off a new headless agent session on the host (see _send_message)."""
return _send_message(body.prompt, model=body.model, harness=body.harness,
thinking=body.thinking, effort=body.effort)
@app.post("/api/resume", responses=_r(schemas.SpawnResult))
def resume_conversation(body: ResumeBody):
"""Continue an existing conversation with a new user turn (see _send_message).
Runs ``claude -p <prompt> --resume <sessionId>`` in the session's original
cwd; resume reuses the session id, so Claude appends the new turns to the
same transcript the viewer is already watching and the open conversation
streams the continuation live. A still-running session is stopped first —
sending a message always wins."""
return _send_message(body.prompt, session_id=body.sessionId,
model=body.model, cwd=body.cwd, harness=body.harness,
thinking=body.thinking, effort=body.effort)
class ForkBody(BaseModel):
id: str # source conversation id (<slug>/<sid>.jsonl)
mi: int # ordinal of the user message being edited (fork point)
prompt: str # the edited message — becomes the fork's next turn
model: str | None = None
harness: str | None = None # unset ⇒ the harness the source ran on
@app.post("/api/fork", responses=_r(schemas.SpawnResult))
def fork_conversation(body: ForkBody):
"""Branch a new conversation off an existing one at a user message.
The new session's history is the source transcript up to (excluding) user
message ``mi``; ``prompt`` (the edited message) is then sent as its next
turn. Neither CLI can truncate at a message (`claude --fork-session` only
clones whole sessions), so the host sidecar does transcript surgery: it
copies the first ``cutLine`` lines of the source session file under a
fresh session id and resumes that — see sidecar ``/fork``. ``cutText`` and
``cutUserOrd`` let the sidecar/runner verify they're slicing the same
message this viewer showed (the archive is an append-only copy of the
live file, so the line indices match; the canary catches drift)."""
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
ap = _resolve_transcript(body.id)
src_sid, summary, src_meta = _summary_and_meta(body.id)
if not src_sid:
raise HTTPException(409, "source conversation has no session id")
cut = conversations.find_cut_line(ap, body.mi)
if cut is None:
raise HTTPException(404, "fork point not found — message ordinal "
"missing or not a user message")
if cut["line"] == 0:
raise HTTPException(400, "nothing before the first message — start a "
"new conversation instead")
harness = (body.harness or src_meta.get("harness") or "claude").lower()
sid = str(uuidlib.uuid4())
out = _sidecar_call("/fork", {
"sessionId": sid, "srcSessionId": src_sid,
"cwd": summary.get("cwd"),
"cutLine": cut["line"], "cutText": cut["text"][:120],
"cutUserOrd": cut["userOrd"],
"prompt": prompt, "model": body.model, "harness": harness})
meta_store.update(sid, {
"state": "running", "harness": harness,
"forkedFrom": {"id": body.id, "sessionId": src_sid, "mi": body.mi}})
hub.publish({"type": "meta", "id": sid})
return {"sessionId": sid, "pid": out.get("pid")}
class InterruptBody(BaseModel):
sessionId: str
@app.post("/api/interrupt", responses=_r(schemas.InterruptResult))
def interrupt_conversation(body: InterruptBody):
"""Stop a running Claude session this viewer spawned.
Proxies to the host sidecar, which sends the run's process group a SIGINT —
exactly like pressing Esc in a terminal. Claude aborts the turn and records a
``[Request interrupted by user]`` marker in the transcript, which the parser
then surfaces as the ``interrupted`` state. We also stamp the metadata
immediately so the UI flips before that marker has synced."""
sid = _valid_sid(body.sessionId)
# Shield the kill from the exit watcher: the vanish it causes must resolve
# to the "interrupted" stamped below, not a generic "finished".
_sidecar_busy.add(sid)
try:
try:
out = _sidecar_call("/interrupt", {"sessionId": sid})
except HTTPException as e:
# 404 from the sidecar = no live process for this session (it
# already exited, or was started from a terminal) — surfaced as a
# 502 by _sidecar_call. A stale "running" badge over a dead run is
# exactly what the user is clearing here, so stamp it interrupted
# anyway.
if "sidecar error 404" not in str(e.detail):
raise
out = {"note": "no live process — marked interrupted"}
meta_store.update(sid, {"state": "interrupted"})
finally:
_sidecar_busy.discard(sid)
hub.publish({"type": "meta", "id": sid})
return {"sessionId": sid, "ok": True, **out}
# ── cron jobs: scheduled agent sessions (see cron.py) ────────────────────────
# sessionId → conversation id, for linking a job's run history to the viewer.
# Memoized on the summaries version (rebuilding walks ~900 summaries).
_sid_map_cache: tuple[int, dict] | None = None
_sid_map_lock = threading.Lock()
def _session_conv_map() -> dict:
global _sid_map_cache
v = store.summaries_version
with _sid_map_lock:
if _sid_map_cache and _sid_map_cache[0] == v:
return _sid_map_cache[1]
m: dict[str, str] = {}
for path, s in store.all_summaries():
sid = s.get("sessionId")
if sid and not _is_sidechain_path(path):
m[sid] = _conv_id(path)
with _sid_map_lock:
_sid_map_cache = (v, m)
return m
def _cron_job_out(job: dict) -> dict:
"""A stored job + the computed fields the UI shows: the next fire time, the
run config read out of its prompt file's frontmatter, and the run history
enriched with conversation ids (newest first)."""
out = dict(job)
out["nextRun"] = (cron_mod.next_run(job.get("schedule") or "")
if job.get("enabled") else None)
out["runConfig"] = cron_mod.run_config(_cron_prompt_text(job))
sid2conv = _session_conv_map()
out["runs"] = [{"at": at, "sessionId": sid,
"conversationId": sid2conv.get(sid)}
for at, sid in sorted((job.get("history") or {}).items(),
reverse=True)]
return out
def _cron_prompt_path(job: dict) -> tuple[pathlib.Path, str]:
rel = cron_mod.valid_prompt_file(job.get("promptFile") or "")
return (WORKSPACE / rel), rel
def _cron_prompt_text(job: dict) -> str:
"""The job's prompt file verbatim (frontmatter included), or ``""`` if it
isn't readable — a missing file is reported when the job fires, not here."""
try:
ap, _ = _cron_prompt_path(job)
return ap.read_text(encoding="utf-8")
except (OSError, ValueError):
return ""
def _fire_cron_job(job: dict) -> dict:
"""Run a cron job once: read its prompt file, spawn a session on the
harness/model/parameters its frontmatter declares, record the run in the
job's history and tag the conversation with the job."""
_, rel = _cron_prompt_path(job)
raw = _cron_prompt_text(job)
cfg = cron_mod.run_config(raw)
prompt = cron_mod.strip_frontmatter(raw).strip()
if not prompt:
cron_store.record_run(job["id"], "",
status=f"error: prompt file {rel} missing/empty")
hub.publish({"type": "cron"})
raise HTTPException(409, f"prompt file {rel} is missing or empty")
# Frontmatter wins; the store's `model` is the pre-frontmatter fallback.
out = _send_message(prompt, model=cfg["model"] or job.get("model"),
harness=cfg["harness"], thinking=cfg["thinking"],
effort=cfg["effort"])
sid = out["sessionId"]
meta_store.update(sid, {"cron": {"id": job["id"],
"name": job.get("name")}})
cron_store.record_run(job["id"], sid, status="ok")
hub.publish({"type": "cron"})
hub.publish({"type": "meta", "id": sid})
return out
def _scheduler_fire(job: dict) -> None:
"""The scheduler-thread wrapper: outcomes land in the store (surfaced as
the job's lastStatus), never raise into the loop."""
try:
_fire_cron_job(job)
except HTTPException as e:
cron_store.record_run(job["id"], "", status=f"error: {e.detail}")
hub.publish({"type": "cron"})
cron_scheduler = cron_mod.CronScheduler(cron_store, _scheduler_fire)
class CronCreateBody(BaseModel):
name: str
schedule: str
promptFile: str | None = None # default: .claude/agents/<slug>.md
enabled: bool = True
model: str | None = None
class CronPatchBody(BaseModel):
name: str | None = None
schedule: str | None = None
promptFile: str | None = None
enabled: bool | None = None
model: str | None = None
class CronPromptBody(BaseModel):
content: str
@app.get("/api/cron", responses=_r(schemas.CronJobsResponse))
def cron_jobs():
"""Every cron job, with its computed next fire time and run history."""
return {"jobs": [_cron_job_out(j) for j in cron_store.list()]}
@app.post("/api/cron", responses=_r(schemas.CronJob))
def cron_create(body: CronCreateBody):
"""Create a job. The prompt file is created (a stub carrying the default
run-config frontmatter) if it doesn't exist yet, so the editor always opens
on something and the harness/model/effort keys are there to edit."""
name = (body.name or "").strip()
if not name:
raise HTTPException(400, "name is required")
prompt_file = body.promptFile or \
f"{cron_mod.PROMPT_DIR}/{cron_mod.slugify(name)}.md"
try:
job = cron_store.create(name, body.schedule, prompt_file,
enabled=body.enabled, model=body.model)
except ValueError as e:
raise HTTPException(400, str(e))
ap, _ = _cron_prompt_path(job)
if not ap.exists():
try:
ap.parent.mkdir(parents=True, exist_ok=True)
ap.write_text(
cron_mod.frontmatter_block(cron_mod.DEFAULT_RUN_CONFIG)
+ f"\n# {name}\n\n", encoding="utf-8")
indexer.scan_files(force_walk=True)
except OSError:
pass # surfaced as missing/empty when the job fires
hub.publish({"type": "cron"})
return _cron_job_out(job)
@app.put("/api/cron/{job_id}", responses=_r(schemas.CronJob))
def cron_update(job_id: str, body: CronPatchBody):
try:
job = cron_store.update(job_id, body.model_dump())
except ValueError as e:
raise HTTPException(400, str(e))
if not job:
raise HTTPException(404, "cron job not found")
hub.publish({"type": "cron"})
return _cron_job_out(job)
@app.delete("/api/cron/{job_id}", responses=_r(schemas.OkResponse))
def cron_delete(job_id: str):
"""Remove a job. Its prompt file is left in place (it's repo content)."""
if not cron_store.delete(job_id):
raise HTTPException(404, "cron job not found")
hub.publish({"type": "cron"})
return {"ok": True}
@app.post("/api/cron/{job_id}/run", responses=_r(schemas.SpawnResult))
def cron_run_now(job_id: str):
"""Fire a job immediately (manual run — works even when disabled)."""
job = cron_store.get(job_id)
if not job:
raise HTTPException(404, "cron job not found")
return _fire_cron_job(job)
@app.get("/api/cron/{job_id}/prompt", responses=_r(schemas.CronPromptFile))
def cron_prompt(job_id: str):
"""The job's prompt file content (the editable text in Settings → Cron)."""
job = cron_store.get(job_id)
if not job:
raise HTTPException(404, "cron job not found")
ap, rel = _cron_prompt_path(job)
try:
return {"path": rel, "content": ap.read_text(encoding="utf-8"),
"exists": True}
except OSError:
return {"path": rel, "content": "", "exists": False}
@app.put("/api/cron/{job_id}/prompt", responses=_r(schemas.CronPromptFile))
def cron_prompt_save(job_id: str, body: CronPromptBody):
job = cron_store.get(job_id)
if not job:
raise HTTPException(404, "cron job not found")
ap, rel = _cron_prompt_path(job)
try:
ap.parent.mkdir(parents=True, exist_ok=True)
ap.write_text(body.content, encoding="utf-8")
except OSError as e:
raise HTTPException(500, f"could not write {rel}: {e}")
indexer.scan_files(force_walk=True)
hub.publish({"type": "cron"})
return {"path": rel, "content": body.content, "exists": True}
# ── first-class notifications: webhooks + notify/ask (see notify.py) ─────────
# This backend is the notification source of truth: the notify-done skill
# POSTs pushes and asks here; they are recorded in /data/notifications.json and
# forwarded to the configured webhooks — normally the Home Assistant webhook
# registered by the `ai_agent` custom integration (integrations/home-assistant/),
# which renders the phone notification and POSTs ask answers back.
class WebhooksBody(BaseModel):
webhooks: list[dict]
class NotifyBody(BaseModel):
title: str
message: str
type: str | None = None
url: str | None = None
image: str | None = None
icon: str | None = None
color: str | None = None
channel: str | None = None
importance: str | None = None
vibrationPattern: str | None = None
tag: str | None = None
persistent: bool | None = None
actions: list[dict] | None = None
# A phone-only line to read aloud (the phone webhook's TTS prefers it over
# title/message). Forwarded verbatim to webhooks; ignored by the HA screen
# notification. See services/phone/bridge/app.py.
spoken: str | None = None
sessionId: str | None = None
class AskBody(BaseModel):
question: str
options: list[str]
type: str | None = None
url: str | None = None
image: str | None = None
icon: str | None = None
color: str | None = None
timeoutSecs: int | None = None
sessionId: str | None = None
class AskAnswerBody(BaseModel):
index: int | None = None
label: str | None = None
@app.get("/api/webhooks", responses=_r(schemas.WebhooksResponse))
def webhooks_get():
return {"webhooks": notify_store.webhooks()}
@app.put("/api/webhooks", responses=_r(schemas.WebhooksResponse))
def webhooks_put(body: WebhooksBody):
try:
saved = notify_store.set_webhooks(body.webhooks)
except ValueError as e:
raise HTTPException(400, str(e))
hub.publish({"type": "webhooks"})
return {"webhooks": saved}
@app.post("/api/webhooks/{webhook_id}/test", responses=_r(schemas.NotifyResult))
def webhook_test(webhook_id: str):
"""Send a test push through one webhook (regardless of enabled/events)."""
target = next((w for w in notify_store.webhooks()
if w.get("id") == webhook_id), None)
if not target:
raise HTTPException(404, "unknown webhook")
nid = "ntf_" + uuidlib.uuid4().hex[:12]
delivered = notify_mod.forward([target], {
"event": "notify", "id": nid, "title": "ai-agent test",
"message": f"Webhook '{target['name']}' is wired up.",
"type": "success", "url": "https://ai-agent.lab.gabvdl.xyz"})
return {"ok": all(d["ok"] for d in delivered), "id": nid,
"delivered": delivered}
@app.post("/api/notify", responses=_r(schemas.NotifyResult))
def notify_push(body: NotifyBody):
"""Record a push notification and forward it to the subscribed webhooks."""
nid = "ntf_" + uuidlib.uuid4().hex[:12]
payload = {"event": "notify", "id": nid,
**{k: v for k, v in body.model_dump().items()
if v not in (None, "")}}
payload.pop("sessionId", None)
targets = notify_store.targets("notify")
delivered = notify_mod.forward(targets, payload)
notify_store.record({
"id": nid, "kind": "notify", "title": body.title,
"body": body.message, "type": body.type or "", "url": body.url or "",
"at": notify_mod._now_iso(), "sessionId": body.sessionId or "",
"delivered": delivered,
})
hub.publish({"type": "notification", "id": nid})
return {"ok": bool(delivered) and all(d["ok"] for d in delivered),
"id": nid, "delivered": delivered}
@app.post("/api/ask", responses=_r(schemas.NotifyResult))
def ask_push(body: AskBody):
"""Create an ask (choice question), forward it to the webhooks, return its
id. The HA integration renders the tappable notification and POSTs the
answer back to /api/ask/{id}/answer; callers long-poll GET /api/ask/{id}."""
options = [o.strip() for o in (body.options or []) if o and o.strip()]
if not body.question.strip() or not 2 <= len(options) <= 3:
raise HTTPException(400, "need a question and 2-3 options")
ask = notify_store.create_ask(
body.question.strip(), options, type_=body.type or "",
url=body.url or "", session_id=body.sessionId or "",
timeout_s=body.timeoutSecs or notify_mod.DEFAULT_ASK_TIMEOUT_S)
payload = {"event": "ask", "id": ask["id"], "question": ask["body"],
"options": options,
**{k: v for k, v in body.model_dump().items()
if k in ("type", "url", "image", "icon", "color")
and v not in (None, "")}}
targets = notify_store.targets("ask")
delivered = notify_mod.forward(targets, payload)
notify_store.set_delivered(ask["id"], delivered)
hub.publish({"type": "notification", "id": ask["id"]})
return {"ok": bool(delivered) and all(d["ok"] for d in delivered),
"id": ask["id"], "delivered": delivered}
@app.get("/api/ask/{ask_id}", responses=_r(schemas.AskRecord))
def ask_get(ask_id: str, waitSecs: float = 0):
"""The ask's current state; ``waitSecs`` long-polls for the answer."""
ask = (notify_store.wait_for_answer(ask_id, waitSecs) if waitSecs > 0
else notify_store.ask_status(ask_id))
if not ask:
raise HTTPException(404, "unknown ask")
return ask
@app.post("/api/ask/{ask_id}/answer", responses=_r(schemas.AskRecord))
def ask_answer(ask_id: str, body: AskAnswerBody):
"""Callback for the HA integration: record which button was tapped."""
ask = notify_store.answer_ask(ask_id, body.index, body.label)
if not ask:
raise HTTPException(404, "unknown ask")
hub.publish({"type": "notification", "id": ask_id})
return ask
@app.get("/api/notify-log", responses=_r(schemas.NotifyLogResponse))
def notify_log(limit: int = 200):
"""The first-class notification/ask log, newest first."""
entries = notify_store.log(limit=limit)
return {"notifications": entries, "count": len(entries)}
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
class FormCreateBody(BaseModel):
title: str
fields: list[dict]
description: str | None = None
type: str | None = None
url: str | None = None
sessionId: str | None = None
class FormSubmitBody(BaseModel):
answers: dict
@app.post("/api/forms", responses=_r(schemas.FormRecord))
def form_create(body: FormCreateBody):
"""Create a form (the ask-form skill's entry point) and return it.
The caller then sends its own push notification linking to the
conversation with ``?form=<id>`` and long-polls GET /api/forms/{id}."""
if not body.title.strip():
raise HTTPException(400, "need a title")
try:
fields = forms_mod.valid_fields(body.fields)
except ValueError as e:
raise HTTPException(400, str(e))
form = form_store.create(
body.title.strip(), fields, description=(body.description or "").strip(),
type_=body.type or "", url=body.url or "",
session_id=body.sessionId or "")
hub.publish({"type": "form", "id": form["id"],
"sessionId": form["sessionId"]})
return form
@app.get("/api/forms", responses=_r(schemas.FormsResponse))
def forms_list(session: str = "", limit: int = 100):
"""Forms, newest first; ``session`` filters to one conversation."""
items = form_store.list(session_id=session or None, limit=limit)
return {"forms": items, "count": len(items)}
@app.get("/api/forms/{form_id}", responses=_r(schemas.FormRecord))
def form_get(form_id: str, waitSecs: float = 0):
"""The form's current state; ``waitSecs`` long-polls for a submit."""
form = (form_store.wait(form_id, waitSecs) if waitSecs > 0
else form_store.get(form_id))
if not form:
raise HTTPException(404, "unknown form")
return form
@app.post("/api/forms/{form_id}/submit", responses=_r(schemas.FormRecord))
def form_submit(form_id: str, body: FormSubmitBody):
"""Record the user's answers (from the PWA's form card / modal)."""
try:
form = form_store.submit(form_id, body.answers)
except ValueError as e:
raise HTTPException(400, str(e))
if not form:
raise HTTPException(404, "unknown form")
hub.publish({"type": "form", "id": form_id,
"sessionId": form.get("sessionId") or ""})
return form
@app.post("/api/forms/{form_id}/cancel", responses=_r(schemas.FormRecord))
def form_cancel(form_id: str):
"""Dismiss a form unanswered (the card's Cancel button, or the agent
giving up on the wait)."""
form = form_store.cancel(form_id)
if not form:
raise HTTPException(404, "unknown form")
hub.publish({"type": "form", "id": form_id,
"sessionId": form.get("sessionId") or ""})
return form
# ── projects gallery (the repo's projects/ dir) ──────────────────────────────
# Cost rollups walk every summary; memoized on the (summaries, sidecar)
# versions so repeat requests between changes are a dict lookup.
_costs_cache: tuple[tuple[int, int], dict] | None = None
_costs_lock = threading.Lock()
def _project_costs() -> dict:
global _costs_cache
key = (store.summaries_version, meta_store.version)
with _costs_lock:
if _costs_cache and _costs_cache[0] == key:
return _costs_cache[1]
costs = project_costs_mod.costs_by_project(
store.all_summaries(), meta_store.all())
with _costs_lock:
_costs_cache = (key, costs)
return costs
@app.get("/api/projects", responses=_r(schemas.ProjectsResponse))
def projects_list():
"""Card metadata for every directory under projects/ (newest commit first).
Each card carries a compact ``costs`` rollup (total $/tokens + conversation
count) aggregated from every conversation attributed to the project."""
costs = _project_costs()
items = projects_mod.list_projects()
for it in items:
agg = costs.get(it["dir"])
it["costs"] = {
"cost": agg["cost"] if agg else 0.0,
"tokens": agg["tokens"] if agg else 0,
"conversations": agg["conversations"] if agg else 0,
}
return {"projects": items}
@app.get("/api/project", responses=_r(schemas.ProjectDetail))
def project_detail(slug: str):
"""Full metadata + key-file contents (README/CLAUDE.md/index meta/og) for one project.
Also attaches a ``costs`` block: total/mean/median spend across the project's
conversations, a per-token-type breakdown, and mean cost per line of code."""
detail = projects_mod.project_detail(slug)
if detail is None:
raise HTTPException(404, "project not found")
agg = _project_costs().get(slug) or project_costs_mod._aggregate([])
loc = projects_mod.count_loc(slug)
agg = {**agg, "loc": loc,
"meanCostPerLoc": (agg["cost"] / loc) if loc else 0.0}
detail["costs"] = agg
return detail
def _project_env_payload(slug: str, entry: pathlib.Path) -> dict:
env_path = entry / ".env"
ef = envfile_mod.ProjectEnvFile.load(str(env_path))
return {
"slug": slug,
"exists": env_path.is_file(),
"vars": [
{
"key": ent["key"],
"value": None if ent["secret"] else ent["value"],
"secret": ent["secret"],
"hasValue": bool(ent["value"]),
}
for ent in ef.entries()
],
}
@app.get("/api/project-env", responses=_r(schemas.ProjectEnvResponse))
def project_env(slug: str):
"""Key/value view of a project's `.env`. Vars below a `# --- secrets ---`
comment are secrets: their keys are listed but values never leave disk."""
entry = projects_mod._safe_entry(slug)
if entry is None:
raise HTTPException(404, "project not found")
return _project_env_payload(slug, entry)
class EnvSetItem(BaseModel):
key: str
# None = keep the value already on disk (used to move a var between
# sections without the UI ever having seen a secret's value).
value: str | None = None
secret: bool = False
class EnvSaveBody(BaseModel):
slug: str
set: list[EnvSetItem] = []
unset: list[str] = []
@app.put("/api/project-env", responses=_r(schemas.ProjectEnvResponse))
def project_env_save(body: EnvSaveBody):
"""Apply key/value edits to a project's `.env`, preserving comments, order
and the secrets section. Unsets run first, then sets."""
entry = projects_mod._safe_entry(body.slug)
if entry is None:
raise HTTPException(404, "project not found")
for item in body.set:
if not envfile_mod.KEY_RE.match(item.key):
raise HTTPException(400, f"invalid env key: {item.key!r}")
ef = envfile_mod.ProjectEnvFile.load(str(entry / ".env"))
for key in body.unset:
ef.unset(key)
current = {e["key"]: e["value"] for e in ef.entries()}
for item in body.set:
value = item.value if item.value is not None else current.get(item.key, "")
ef.set_var(item.key, value, secret=item.secret)
ef.save()
return _project_env_payload(body.slug, entry)
# Raster formats that are safe to render inline on the app origin. Anything
# else served from user/repo-supplied bytes (SVG can carry scripts, HTML is
# HTML) goes out as a download so it can never script against the API's cookies.
_INLINE_IMAGE_EXTS = {".jpeg", ".jpg", ".png", ".webp", ".gif", ".bmp", ".avif"}
# Video containers safe to play inline on the app origin: a media file can't
# script against the API's cookies the way an SVG/HTML can, so it need not go
# out as an attachment. FileResponse honours Range requests (Starlette), so the
# <video> element can seek.
_INLINE_VIDEO_EXTS = {".mp4", ".webm", ".mov", ".m4v", ".ogv"}
def _safe_file_response(p: pathlib.Path) -> FileResponse:
headers = {"X-Content-Type-Options": "nosniff"}
ext = p.suffix.lower()
if ext not in _INLINE_IMAGE_EXTS and ext not in _INLINE_VIDEO_EXTS:
headers["Content-Disposition"] = f'attachment; filename="{p.name}"'
return FileResponse(p, headers=headers)
@app.get("/api/project-asset")
def project_asset(slug: str, path: str):
"""Serve a whitelisted image asset (e.g. og-image) from a project dir."""
p = projects_mod.project_asset_path(slug, path)
if p is None:
raise HTTPException(404, "asset not found")
return _safe_file_response(p)
@app.get("/api/artefact")
def artefact_asset(kind: str, slug: str, path: str):
"""Serve one generated artefact from a project/service's `.ai/artefacts/`
tree (``path`` is ``<date>/<sessionId>/<file>``, relative to that tree)."""
p = artefacts_mod.artefact_path(kind, slug, path)
if p is None:
raise HTTPException(404, "artefact not found")
return _safe_file_response(p)
@app.get("/api/screenshot")
def screenshot_asset(path: str):
"""Serve a captured screenshot image from docs/screenshots/ (read-only).
``path`` is the image's location relative to the screenshots root
(``<project>/<name>_<ts>.jpeg``). A leading ``docs/screenshots/`` prefix,
if present, is stripped so the raw path printed by the skill also works.
"""
rel = path.replace("\\", "/").lstrip("/")
for pref in ("docs/screenshots/", "screenshots/"):
if rel.startswith(pref):
rel = rel[len(pref):]
rel_p = pathlib.Path(rel)
if not rel or rel_p.is_absolute() or ".." in rel_p.parts:
raise HTTPException(400, "bad path")
p = (SCREENSHOTS_DIR / rel_p).resolve()
try:
p.relative_to(SCREENSHOTS_DIR)
except ValueError:
raise HTTPException(400, "bad path")
if not (p.is_file() and p.suffix.lower() in SCREENSHOT_EXTS):
raise HTTPException(404, "screenshot not found")
return _safe_file_response(p)
@app.get("/api/data-asset")
def data_asset(path: str):
"""Serve an image or video artifact from the repo's root `data/` tree (read-only).
``path`` is the file's location relative to ``data/`` (a leading ``data/``
prefix, if present, is stripped so the raw repo-relative path also works).
Used to preview media the agent ``Read`` from under ``data/`` inline in the
conversation viewer — see the CLAUDE.md convention to write artifacts there.
"""
rel = path.replace("\\", "/").lstrip("/")
if rel.startswith("data/"):
rel = rel[len("data/"):]
rel_p = pathlib.Path(rel)
if not rel or rel_p.is_absolute() or ".." in rel_p.parts:
raise HTTPException(400, "bad path")
p = (DATA_DIR / rel_p).resolve()
try:
p.relative_to(DATA_DIR)
except ValueError:
raise HTTPException(400, "bad path")
if not (p.is_file() and p.suffix.lower() in (IMAGE_EXTS | _INLINE_VIDEO_EXTS)):
raise HTTPException(404, "data asset not found")
return _safe_file_response(p)
@app.get("/api/scratchpad-asset")
def scratchpad_asset(path: str):
"""Serve an image or video from Claude Code's session scratchpad tree.
``path`` is relative to the scratchpad root — ``<encoded-cwd>/<sessionId>/
scratchpad/<file>`` — so a run's throwaway media previews inline in the
thread without having to be copied into a project's ``.ai/artefacts/``.
The mount is read-only and only media extensions are served.
"""
rel = path.replace("\\", "/").lstrip("/")
rel_p = pathlib.Path(rel)
if not rel or rel_p.is_absolute() or ".." in rel_p.parts:
raise HTTPException(400, "bad path")
p = (SCRATCHPAD_DIR / rel_p).resolve()
try:
p.relative_to(SCRATCHPAD_DIR)
except ValueError:
raise HTTPException(400, "bad path")
if not (p.is_file() and p.suffix.lower() in (IMAGE_EXTS | _INLINE_VIDEO_EXTS)):
raise HTTPException(404, "scratchpad asset not found")
return _safe_file_response(p)
# ── composer file uploads ────────────────────────────────────────────────────
# The composer (SpawnBox / ResumeBox) uploads files here; each batch lands in a
# random subdir under UPLOADS_DIR. The endpoint returns, per file, the
# repo-relative path to inject into the prompt (so the host-side `claude -p`
# session can Read it) and a serve URL the frontend renders inline in the thread.
def _safe_upload_name(name: str) -> str:
base = pathlib.PurePosixPath((name or "").replace("\\", "/")).name
base = _UNSAFE_NAME_RE.sub("_", base).strip("._")
return (base or "file")[:120]
def _upload_rel(path: str) -> pathlib.Path:
"""Validate a client-supplied uploads path and return it relative to
UPLOADS_DIR, tolerating a leading repo prefix (the injected prompt form)."""
rel = path.replace("\\", "/").lstrip("/")
prefix = UPLOADS_REPO_PREFIX + "/"
if rel.startswith(prefix):
rel = rel[len(prefix):]
rel_p = pathlib.Path(rel)
if not rel or rel_p.is_absolute() or ".." in rel_p.parts:
raise HTTPException(400, "bad path")
p = (UPLOADS_DIR / rel_p).resolve()
try:
p.relative_to(UPLOADS_DIR)
except ValueError:
raise HTTPException(400, "bad path")
return p
@app.post("/api/upload", responses=_r(schemas.UploadResult))
async def upload_files(files: list[UploadFile] = File(...)):
"""Store one or more composer attachments and describe where they landed.
Each upload is written to ``UPLOADS_DIR/<batch>/<safe-name>``. The returned
``repoPath`` is what the composer injects into the prompt (the session reads
it from the repo working dir); ``url`` is the backend serve route the thread
viewer renders the file from."""
if not files:
raise HTTPException(400, "no files")
batch = uuidlib.uuid4().hex[:12]
dest = UPLOADS_DIR / batch
dest.mkdir(parents=True, exist_ok=True)
out = []
for f in files:
name = _safe_upload_name(f.filename or "file")
target = dest / name
i = 1
while target.exists():
target = dest / f"{target.stem}-{i}{pathlib.Path(name).suffix}"
i += 1
size = 0
try:
with open(target, "wb") as w:
while chunk := await f.read(1 << 20):
size += len(chunk)
if size > MAX_UPLOAD_BYTES:
raise HTTPException(
413, f"{name} exceeds {MAX_UPLOAD_BYTES // (1<<20)} MB")
w.write(chunk)
except HTTPException:
target.unlink(missing_ok=True)
raise
rel = f"{batch}/{target.name}"
out.append({
"name": target.name,
"size": size,
"contentType": f.content_type or "application/octet-stream",
"repoPath": f"{UPLOADS_REPO_PREFIX}/{rel}",
"url": f"/api/upload-file?path={rel}",
})
return {"files": out}
@app.get("/api/upload-file")
def upload_file_asset(path: str):
"""Serve a previously uploaded composer attachment (raw bytes, inline)."""
p = _upload_rel(path)
if not p.is_file():
raise HTTPException(404, "upload not found")
return _safe_file_response(p)
# ── custom avatar set (a sprite-editor "Export set" zip) ──────────────────────
# The zip is one square-cell strip per clip plus a manifest.json in the Hemp
# Henry shape ({clip: {cell, fps, frames, loop, src}}). We validate it, unpack a
# clean copy into the writable data volume, and serve the strips inline so the
# roaming avatar plays a full custom emote set. Only strips referenced by the
# manifest are kept, and only as safe raster images — the zip never lands as-is.
_AVATAR_STRIP_EXTS = {".webp", ".png", ".gif"}
def _read_custom_manifest() -> dict:
"""The stored custom manifest with each src rewritten to its serve URL, or
{} when no set is imported."""
mf = CUSTOM_AVATAR_DIR / "manifest.json"
if not mf.is_file():
return {}
try:
raw = json.loads(mf.read_text())
except Exception:
return {}
out: dict = {}
for clip, meta in (raw or {}).items():
if not isinstance(meta, dict):
continue
src = str(meta.get("src") or f"{clip}.webp")
name = pathlib.PurePosixPath(src.replace("\\", "/")).name
if not (CUSTOM_AVATAR_DIR / name).is_file():
continue
out[clip] = {**meta, "src": f"/api/avatar-asset?path={name}"}
return out
@app.get("/api/avatar/manifest")
def avatar_manifest():
"""The imported custom avatar set's manifest (empty {} if none), with each
clip src pointing at /api/avatar-asset so the frontend can render it."""
return _read_custom_manifest()
@app.get("/api/avatar-asset")
def avatar_asset(path: str):
"""Serve one strip from the imported custom avatar set (inline image)."""
name = pathlib.PurePosixPath((path or "").replace("\\", "/")).name
ext = pathlib.Path(name).suffix.lower()
if not name or ext not in _AVATAR_STRIP_EXTS:
raise HTTPException(400, "bad path")
p = (CUSTOM_AVATAR_DIR / name).resolve()
try:
p.relative_to(CUSTOM_AVATAR_DIR)
except ValueError:
raise HTTPException(400, "bad path")
if not p.is_file():
raise HTTPException(404, "avatar asset not found")
# Pin the image content-type: the slim container's mimetypes DB can lack the
# .webp mapping, and with nosniff a text/plain response won't render as an
# image (breaking the sprite background). All _AVATAR_STRIP_EXTS are rasters.
media = {".webp": "image/webp", ".png": "image/png", ".gif": "image/gif"}[ext]
return FileResponse(p, media_type=media, headers={"X-Content-Type-Options": "nosniff"})
@app.post("/api/avatar/import")
async def avatar_import(file: UploadFile = File(...)):
"""Import a custom avatar set from a sprite-editor "Export set" zip.
Validates the zip has a manifest.json plus one strip per referenced clip,
then unpacks a clean copy into CUSTOM_AVATAR_DIR (replacing any previous
set) and returns the served manifest. Rejects anything that isn't a small
zip of a manifest + raster strips — no path traversal, no oversized members,
nothing executable ever written."""
raw = b""
while chunk := await file.read(1 << 20):
raw += chunk
if len(raw) > MAX_AVATAR_ZIP_BYTES:
raise HTTPException(413, f"zip exceeds {MAX_AVATAR_ZIP_BYTES // (1 << 20)} MB")
if not raw:
raise HTTPException(400, "empty upload")
try:
zf = zipfile.ZipFile(io.BytesIO(raw))
except zipfile.BadZipFile:
raise HTTPException(400, "not a valid zip")
# Index members by basename (tolerate a wrapping top-level folder).
members: dict[str, zipfile.ZipInfo] = {}
total = 0
for info in zf.infolist():
if info.is_dir():
continue
name = pathlib.PurePosixPath(info.filename.replace("\\", "/")).name
if not name or name.startswith("."):
continue
total += info.file_size
if total > MAX_AVATAR_ZIP_BYTES:
raise HTTPException(413, "zip contents too large")
members[name] = info
if "manifest.json" not in members:
raise HTTPException(400, "zip is missing manifest.json")
try:
manifest = json.loads(zf.read(members["manifest.json"].filename))
except Exception:
raise HTTPException(400, "manifest.json is not valid JSON")
if not isinstance(manifest, dict) or not manifest:
raise HTTPException(400, "manifest.json must be a non-empty object of clips")
# Validate every referenced strip is present and a safe raster image.
clean: dict = {}
strips: dict[str, bytes] = {}
for clip, meta in manifest.items():
if not isinstance(clip, str) or not _UNSAFE_NAME_RE.sub("", clip):
raise HTTPException(400, f"bad clip name: {clip!r}")
if not isinstance(meta, dict):
raise HTTPException(400, f"clip {clip!r}: entry must be an object")
src = str(meta.get("src") or f"{clip}.webp")
name = pathlib.PurePosixPath(src.replace("\\", "/")).name
if pathlib.Path(name).suffix.lower() not in _AVATAR_STRIP_EXTS:
raise HTTPException(400, f"clip {clip!r}: src must be a webp/png/gif strip")
if name not in members:
raise HTTPException(400, f"clip {clip!r}: strip {name} not in zip")
try:
frames = int(meta.get("frames") or 0)
cell = int(meta.get("cell") or 0)
except (TypeError, ValueError):
raise HTTPException(400, f"clip {clip!r}: frames/cell must be numbers")
strips[name] = zf.read(members[name].filename)
clean[clip] = {
"cell": cell,
"fps": int(meta.get("fps") or 6) or 6,
"frames": max(1, frames),
"loop": bool(meta.get("loop", True)),
"src": name,
}
# Write a clean set atomically-ish: build in a temp dir, then swap.
AVATARS_DIR.mkdir(parents=True, exist_ok=True)
tmp = AVATARS_DIR / f".custom-{uuidlib.uuid4().hex[:8]}"
tmp.mkdir(parents=True, exist_ok=True)
try:
for name, data in strips.items():
(tmp / name).write_bytes(data)
(tmp / "manifest.json").write_text(json.dumps(clean, indent=2, sort_keys=True) + "\n")
if CUSTOM_AVATAR_DIR.exists():
shutil.rmtree(CUSTOM_AVATAR_DIR)
tmp.rename(CUSTOM_AVATAR_DIR)
except Exception:
shutil.rmtree(tmp, ignore_errors=True)
raise
return {"manifest": _read_custom_manifest(), "clips": sorted(clean.keys())}
@app.delete("/api/avatar/custom")
def avatar_clear():
"""Remove the imported custom avatar set."""
if CUSTOM_AVATAR_DIR.exists():
shutil.rmtree(CUSTOM_AVATAR_DIR, ignore_errors=True)
return {"ok": True}
# ── services catalog (the repo's services/ dir — read-only, no Docker) ────────
@app.get("/api/services", responses=_r(schemas.ServicesResponse))
def services_list():
"""Card metadata for every service dir (containers/urls/auth, newest first)."""
return {"services": svc_mod.list_services()}
@app.get("/api/service", responses=_r(schemas.ServiceDetail))
def service_detail(slug: str):
"""Full parse of one service: containers, Traefik routers, env keys, tree."""
detail = svc_mod.service_detail(slug)
if detail is None:
raise HTTPException(404, "service not found")
return detail
@app.get("/api/service-file")
def service_file(slug: str, path: str):
"""A single whitelisted text file's contents from a service dir (512 KB cap)."""
txt = svc_mod.service_file(slug, path)
if txt is None:
raise HTTPException(404, "file not found or not previewable")
return PlainTextResponse(txt)
# ── goals board (every GOAL.md in the repo, with who's working on what) ───────
def _goal_activity() -> dict[tuple[str, str], dict]:
"""Per-unit conversation activity, keyed by ``(kind, dir)``.
``agents`` counts the sessions *currently running* on that unit — the badge
the Goals page shows so you can see, at a glance, that three agents are
already on `brain` and none on `mail`. ``lastAt`` is the newest
conversation's end time; the board sorts on it, so the units you last
worked on come first.
A conversation is attributed exactly as everywhere else in this app: the
union of the transcript-mined ``projectsAuto``/``servicesAuto`` and the
manual sidecar lists (see ``_conv_meta``). One conversation can therefore
count toward several units, which is correct — an agent editing both a
project and its service really is working on both.
"""
out: dict[tuple[str, str], dict] = {}
for card in _conversation_cards(full=False):
m = card.get("meta") or {}
ended = card.get("endedAt") or ""
running = m.get("state") == "running"
for kind, key in (("project", "projects"), ("service", "services")):
for d in (m.get(key) or []):
a = out.setdefault((kind, d),
{"agents": 0, "conversations": 0,
"lastAt": None, "running": []})
a["conversations"] += 1
if not a["lastAt"] or ended > a["lastAt"]:
a["lastAt"] = ended
if running:
a["agents"] += 1
a["running"].append({"id": card["id"],
"title": card.get("title") or "",
"startedAt": card.get("startedAt")})
return out
@app.get("/api/goals", responses=_r(schemas.GoalsResponse))
def goals_list():
"""Every project/service that keeps a GOAL.md, as a board.
One card per goal-bearing unit: its checklist progress, the version
milestones off its ``## Horizons``, the themed wishlist groups, the
goal-keeper's in-flight ``## Being worked on`` claims, and how many agent
sessions are running on it right now.
Sorted by most-recent conversation (the unit you last touched is the one
you most likely want to push), with never-worked-on goals last.
"""
activity = _goal_activity()
goals = []
for kind, root, items in (
("project", projects_mod.PROJECTS_DIR, projects_mod.list_projects()),
("service", svc_mod.SERVICES_DIR, svc_mod.list_services()),
):
for it in items:
board = goalmd.goal_board(root / it["dir"])
if board is None:
continue # no GOAL.md — not on the board
act = activity.get((kind, it["dir"])) or {}
goals.append({
"kind": kind,
"dir": it["dir"],
"name": it["name"],
"description": it.get("description") or "",
"url": (it.get("url") if kind == "project"
else (it.get("urls") or [None])[0]),
"updatedAt": it.get("updatedAt"),
**board,
"agents": act.get("agents", 0),
"runningConversations": act.get("running", []),
"conversations": act.get("conversations", 0),
"lastConversationAt": act.get("lastAt"),
})
goals.sort(key=lambda g: g["lastConversationAt"] or "", reverse=True)
return {"goals": goals}
class GoalWorkBody(BaseModel):
kind: str # "project" | "service"
dir: str # the unit's directory name
item: str | None = None # a specific checklist item to work on (the detail
# page's per-item Work button); None ⇒ let the
# agent pick one (the Goals board's Work button)
@app.post("/api/goal-work", responses=_r(schemas.SpawnResult))
def goal_work(body: GoalWorkBody):
"""Spawn a goal-keeper session pointed at one unit's GOAL.md.
The "Work" button on the Goals page (no ``item``) and the per-item Work
button on a detail page's checklist (``item`` set). Same machinery as any
other spawn (`_send_message` → host sidecar), the difference being the
prompt: it invokes the `goal-keeper` agent scoped to a single directory
instead of letting it choose among every GOAL.md in the repo, and it tells
the agent to claim its item in ``## Being worked on`` first — which is what
stops two agents launched from this page landing on the same checkbox. When
``item`` is given the agent is told to work *that* item rather than pick one.
"""
if body.kind not in ("project", "service"):
raise HTTPException(400, "kind must be 'project' or 'service'")
resolve = (projects_mod._safe_entry if body.kind == "project"
else svc_mod._safe_entry)
unit = resolve(body.dir)
if unit is None or not (unit / goalmd.GOAL_FILE).is_file():
raise HTTPException(404, "no GOAL.md for that unit")
rel = f"{'projects' if body.kind == 'project' else 'services'}/{body.dir}"
item = (body.item or "").strip()
if item:
# The per-item Work button: the user already chose the checkbox, so the
# agent works exactly that one instead of surveying the wishlist.
pick = (
f"Use the goal-keeper agent to push `{rel}` forward by completing "
f"this specific item from its GOAL.md:\n\n {item}\n\n"
f"Read `{rel}/GOAL.md` for the full context around it. If that exact "
f"item is already listed under its `## Being worked on` section, "
f"stop — another agent has it — and say so.\n\n"
)
else:
pick = (
f"Use the goal-keeper agent to push `{rel}` forward by one item.\n\n"
f"Read `{rel}/GOAL.md` and pick ONE unchecked wishlist item — the "
f"smallest high-leverage step you can finish end-to-end this "
f"session. Skip anything already listed under its `## Being worked "
f"on` section: another agent has claimed it.\n\n"
)
prompt = (
pick +
f"Before you start, claim your item by appending a bullet to that "
f"`## Being worked on` section (create it after the North star if it "
f"doesn't exist), in the "
f"form `- [{body.dir}] <the item> — @<UTC ISO timestamp>`. Commit that "
f"claim on its own so other agents see it immediately. When the item "
f"lands, tick its checkbox and remove your claim line in the same "
f"commit.\n\n"
f"Then — still before you create a worktree — rename this conversation "
f"to the item you claimed, so its card says what you're building "
f"instead of repeating this prompt: `conv-meta title \"<the feature, a "
f"handful of words, imperative>\"`. Leave the project out of the title; "
f"the card is already tagged with it.\n\n"
f"Follow the repo conventions (worktree, commit skill, notify-done)."
)
return _send_message(prompt)
# ── memories (the assistant's persistent file-based memory for this repo) ─────
@app.get("/api/memories", responses=_r(schemas.MemoriesResponse))
def memories_list():
"""All memory summaries (projects + links derived), newest-updated first."""
return {"memories": memories_mod.list_memories()}
@app.get("/api/memory", responses=_r(schemas.MemoryDetail))
def memory_detail(slug: str):
"""One memory + its body, with the conversations that created / read it."""
mem = memories_mod.memory_detail(slug)
if mem is None:
raise HTTPException(404, "memory not found")
origin = mem.get("originSessionId")
created_in, read_in = [], []
created_sids: set[str] = set()
for path, s in store.all_summaries():
if not s.get("messages"):
continue
sid = s.get("sessionId")
if sid and sid == origin:
created_in.append(_conv_card(path, s))
created_sids.add(sid)
for path, s in store.all_summaries():
if not s.get("messages"):
continue
# don't repeat the originating conversation under "read"
if s.get("sessionId") in created_sids:
continue
if slug in (s.get("memoriesRead") or []):
read_in.append(_conv_card(path, s))
created_in.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
read_in.sort(key=lambda c: c.get("endedAt") or "", reverse=True)
mem["createdIn"] = created_in
mem["readIn"] = read_in
return mem
# ── plans (data/plans/, written by the `plan` skill — on-machine, not tracked) ─
def _duration_ms(started: str | None, ended: str | None) -> int | None:
if not started or not ended:
return None
try:
a = datetime.datetime.fromisoformat(started.replace("Z", "+00:00"))
b = datetime.datetime.fromisoformat(ended.replace("Z", "+00:00"))
return max(0, int((b - a).total_seconds() * 1000))
except ValueError:
return None
@app.get("/api/plans", responses=_r(schemas.PlansResponse))
def plans_list():
"""All plan summaries (data/plans/*.md), newest-updated first."""
return {"plans": plans_mod.list_plans()}
@app.get("/api/plan", responses=_r(schemas.PlanDetail))
def plan_detail(slug: str):
"""One plan + body, with the ACTUAL cost/time/tokens joined from the linked
conversation transcripts (shown next to the plan's own estimate)."""
plan = plans_mod.plan_detail(slug)
if plan is None:
raise HTTPException(404, "plan not found")
# sessionId → newest transcript summary for it.
by_sid: dict[str, tuple[str, dict]] = {}
for path, s in store.all_summaries():
sid = s.get("sessionId")
if not sid or not s.get("messages"):
continue
prev = by_sid.get(sid)
if prev is None or (s.get("endedAt") or "") > (prev[1].get("endedAt") or ""):
by_sid[sid] = (path, s)
convs, cost, tokens, dur = [], 0.0, 0, 0
for sid in plans_mod.linked_session_ids(plan):
hit = by_sid.get(sid)
if not hit:
continue
path, s = hit
card = _conv_card(path, s)
card["startedAt"] = s.get("startedAt")
card["durationMs"] = _duration_ms(s.get("startedAt"), s.get("endedAt"))
convs.append(card)
cost += s.get("cost") or 0.0
tokens += s.get("tokens") or 0
dur += card["durationMs"] or 0
plan["conversations"] = convs
plan["actual"] = {"cost": cost, "tokens": tokens,
"durationMs": dur or None} if convs else None
return plan
# ── project scaffolding templates (projects/templates/) ──────────────────────
@app.get("/api/templates", responses=_r(schemas.TemplatesResponse))
def templates_list():
"""List every scaffold under projects/templates/ with stack tags + stats."""
return {"templates": templates_mod.list_templates()}
@app.get("/api/template", responses=_r(schemas.TemplateDetail))
def template_detail(name: str):
"""One template: README, npm scripts, deps and a nested file tree."""
return templates_mod.template_detail(name)
@app.get("/api/template-file")
def template_file(name: str, path: str):
"""A single text file's contents from within a template (512 KB cap)."""
return PlainTextResponse(templates_mod.template_file(name, path))
@app.get("/api/events")
def events():
"""Server-Sent Events: ``meta`` / ``transcript`` change pings for the UI."""
q = hub.subscribe()
def gen():
try:
yield "retry: 3000\n\n"
while True:
try:
data = q.get(timeout=15)
yield f"data: {data}\n\n"
except queue.Empty:
yield ": ping\n\n" # keep proxies from closing the stream
finally:
hub.unsubscribe(q)
return StreamingResponse(
gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no",
"Connection": "keep-alive"})
@app.get("/api/deploy-status", responses=_r(schemas.DeployStatus))
def deploy_status():
"""Current blue-green deploy snapshot (or ``null`` when none is in flight).
Written by ``services/ai-agent/deploy.sh`` into the data dir; polled by the
:class:`DeployWatcher`, which pings a ``deploy`` SSE event on any change.
"""
return read_deploy_status()
@app.get("/api/health")
def health():
return {"ok": True}
@app.on_event("startup")
def _startup():
# seed file metadata so the first request has data, then index in background
try:
indexer.scan_files()
except Exception:
pass
indexer.start()
watcher.start()
deploy_watcher.start()
# Run-exit detection: flip "running" conversations to "finished" the
# moment their sidecar-launched process dies (see _watch_sidecar_runs).
threading.Thread(target=_watch_sidecar_runs, daemon=True).start()
# Cron jobs: fire scheduled agent sessions (see cron.py).
cron_scheduler.start()
# ── static PWA + SPA fallback (declared last so /api/* wins) ─────────────────
# Everything under /assets/ carries a content hash in its name, so it can never
# go stale and is cached forever. The rest of the shell — index.html, the
# service worker, the manifest — keeps its name across deploys, so it must be
# revalidated on every request or a client can pin itself to an old bundle.
_IMMUTABLE = "public, max-age=31536000, immutable"
_REVALIDATE = "no-cache"
def _static_response(path: pathlib.Path) -> FileResponse:
hashed = path.parent.name == "assets" and path.parent.parent == STATIC_DIR
cache = _IMMUTABLE if hashed else _REVALIDATE
return FileResponse(path, headers={"Cache-Control": cache})
# Serve built assets when they exist, otherwise hand back index.html so the
# client-side router can resolve deep links like /_/.claude/skills/...
@app.get("/{full_path:path}")
def spa(full_path: str):
index = STATIC_DIR / "index.html"
if not index.is_file():
return JSONResponse({"detail": "frontend not built"}, status_code=200)
if full_path:
candidate = (STATIC_DIR / full_path).resolve()
try:
candidate.relative_to(STATIC_DIR)
except ValueError:
candidate = index # path traversal attempt → fall back
if candidate.is_dir() and (candidate / "index.html").is_file():
# Static sub-sites shipped in public/ (e.g. /avatars/hemp-henry/)
# get their own index instead of the SPA shell.
candidate = candidate / "index.html"
if candidate.is_file():
return _static_response(candidate)
return _static_response(index)