Files
ai-agent/backend/main.py
Gabriel Vidal 2befaeca54 feat(conversations): show a "Deploying…" chip on cards mid-deploy
Track a deploy command (Bash matching the deploy regex, or the
deploy-html skill) as in-flight from tool_use to its tool_result, and
surface it as a gated `deploying` field (only true while the
conversation itself is running) — same shape as `runningAgents`. The
list card renders an amber spinner pill next to the subagent chips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 23:17:48 +02:00

4046 lines
170 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 activity as activity_mod
import agents as agents_mod
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 ctas as ctas_mod
import forms as forms_mod
import notify as notify_mod
import notify_audio as notify_audio_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 scaffold as scaffold_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")
# Conversation CTAs: the prompt buttons a finished conversation offers (ctas.py).
CTAS_PATH = os.environ.get("CTAS_PATH", "/data/ctas.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)
cta_store = ctas_mod.CtaStore(CTAS_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,
}
# ── activity dashboard ──────────────────────────────────────────────────────
# The rollup walks every summary's pre-computed `activeBins`, so it is cheap —
# but the whole archive's worth of it is still a few thousand cells, and the
# page refetches on every SSE list ping. Memoized on (summaries version,
# timezone), same pattern as _skill_usage.
_activity_cache: tuple[tuple[int, int], dict] | None = None
_activity_lock = threading.Lock()
@app.get("/api/activity", responses=_r(schemas.ActivityResponse))
def activity_rollup(tzOffset: int = 0):
"""When the agents ran: per day, per 5-minute bin per model, per model.
``tzOffset`` is the caller's minutes east of UTC (the browser's
``-new Date().getTimezoneOffset()``); days and histogram slots are bucketed
in that local time. Clamped to ±16 h — a nonsense offset would otherwise
shift every day silently.
"""
tz = max(-960, min(960, int(tzOffset)))
global _activity_cache
key = (store.summaries_version, tz)
with _activity_lock:
if _activity_cache and _activity_cache[0] == key:
return _activity_cache[1]
out = activity_mod.rollup(store.all_summaries(), tz)
with _activity_lock:
_activity_cache = (key, out)
return out
# ── 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 (or the Complete CTA's ``COMPLETED`` marker — see
``_COMPLETED_RE``; a CTA resumes the same session, so its own reply becomes
the transcript's last message and overwrites ``doneMarker``), 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") or summary.get("completedMarker")) 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"),
# The agent definition this session was launched from ({name, at}) by
# the agent page's "Run now" — the manual sibling of `cron`, and what
# puts the run in that agent's history.
"agentRun": m.get("agentRun"),
# CTAs already run on this session ({ctaId: iso}) — a record of which
# end-of-conversation buttons were pressed (see ctas.py).
"ctas": m.get("ctas") or None,
# The conversation's published summary, written by the Complete CTA
# ({markdown, at}) and rendered under the thread (see scaffold.py).
"summary": m.get("summary") or None,
}
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 _is_deploying(summary: dict, state: str | None = None) -> bool:
"""True while this conversation has a deploy command in flight (issued, no
tool_result yet) — gated the same way as `_running_agents`: a dangling call
left open by an interrupted/dead run isn't actually deploying anymore."""
if not summary.get("deploying"):
return False
st = state if state is not None else _conv_meta(summary).get("state")
return st == "running"
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
_skills_by_path_cache: tuple[int, dict[str, dict[str, int]]] | None = None
def _skills_by_path() -> dict[str, dict[str, int]]:
"""transcript path → ``{skill: calls}``, decoded once per store version.
The indexer keeps a transcript's skill map in its **own column** (it feeds
the catalog rollup), not inside the summary blob — so a stored summary has
no `skills` key at all, and this is the join that puts it back.
"""
global _skills_by_path_cache
hit = _skills_by_path_cache
if hit and hit[0] == store.summaries_version:
return hit[1]
out: dict[str, dict[str, int]] = {}
for path, raw in store.all_transcript_skill_rows():
try:
contrib = json.loads(raw or "{}")
except ValueError:
continue
counts = {name: d.get("count") or 0 for name, d in contrib.items()}
if counts:
out[path] = counts
_skills_by_path_cache = (store.summaries_version, out)
return out
def _skills_used(paths: list[str], extra: dict | None = None) -> list[str]:
"""Skill names used across `paths`, busiest first.
Callers pass a conversation *and its subagent transcripts*: a `Skill(...)`
run by a spawned Explore is work this conversation caused, and the child is
never listed on its own — so hiding its skills would lose them entirely.
`extra` folds in a freshly parsed ``{skill: {count, …}}`` map for a
transcript the store may not have re-read yet (the detail view parses live).
"""
by_path = _skills_by_path()
counts: dict[str, int] = {}
for p in paths:
for name, n in by_path.get(p, {}).items():
counts[name] = counts.get(name, 0) + n
for name, d in (extra or {}).items():
counts[name] = counts.get(name, 0) + (d.get("count") or 0)
return sorted(counts, key=lambda n: (-counts[n], n))
_agents_by_conv_cache: tuple[tuple[int, int], dict[str, list[str]]] | None = None
def _agents_by_conversation() -> dict[str, list[str]]:
"""conversation id → the agent types that ran in it, busiest first.
Built from the same run list the agents catalog counts (`_agent_runs`) —
not from a second pass over the `Task` calls — so a conversation card can
never disagree with the agent page. That list already resolves both origins:
a subagent run is keyed to its *parent* conversation (where its Task card
lives), and a cron/manual agent session to the conversation it *is*.
"""
global _agents_by_conv_cache
key = (store.summaries_version, meta_store.version)
hit = _agents_by_conv_cache
if hit and hit[0] == key:
return hit[1]
counts: dict[str, dict[str, int]] = {}
for r in _agent_runs():
cid, name = r.get("conversationId"), r.get("agent")
if not cid or not name:
continue
c = counts.setdefault(cid, {})
c[name] = c.get(name, 0) + 1
out = {cid: sorted(c, key=lambda n: (-c[n], n)) for cid, c in counts.items()}
_agents_by_conv_cache = (key, out)
return out
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]] = {}
child_paths: dict[str, list[str]] = {}
for path, s in summaries:
if s.get("isSidechain"):
pp = _parent_path_of(path)
if pp:
children.setdefault(pp, []).append(s)
child_paths.setdefault(pp, []).append(path)
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 = []
agents_by_conv = _agents_by_conversation()
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
skills_used = _skills_used([path, *child_paths.get(path, [])])
s = _rollup_children(s, children.get(path, []))
m = _conv_meta(s)
cid = _conv_id(path)
out.append({"id": cid, **{k: s.get(k) for k in keys},
"title": _conv_title(s),
"runningAgents": _running_agents(s, m.get("state")),
"deploying": _is_deploying(s, m.get("state")),
"skillsUsed": skills_used,
"agentsUsed": agents_by_conv.get(cid, []),
"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,
sessions: str = ""):
"""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.
``sessions`` (comma-separated session ids) resolves a specific handful of
conversations instead of a page — what the home page's "waiting for
feedback" cards use to join a pending form/ask back to the session that
posted it, without pulling the whole (~MB) list for one title. An explicit
id lookup ignores the archived filter and the cursor."""
cards = _conversation_cards(full)
if sessions:
want = {s.strip() for s in sessions.split(",") if s.strip()}
cards = [c for c in cards if c.get("sessionId") in want]
return {"conversations": cards, "count": len(cards),
"nextBefore": None,
"pricing": {"model": MODEL,
"inputPerMTok": INPUT_PRICE_PER_MTOK}}
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] = []
paths = [str(ap)]
sub_dir = ap.with_suffix("") / "subagents"
if sub_dir.is_dir():
for child in sorted(sub_dir.glob("*.jsonl")):
paths.append(str(child))
cs = store.get_summary(str(child))
if cs:
children.append(cs)
skills_used = _skills_used(paths)
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")),
"deploying": _is_deploying(s, m.get("state")),
"skillsUsed": skills_used,
"agentsUsed": _agents_by_conversation().get(id, []),
"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",
"spoken": n.get("spoken") or "",
})
# 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}
class NotifAudioBody(BaseModel):
"""What a notification sounded like: its type (→ jingle) and spoken line."""
type: str | None = None
spoken: str | None = None
# Read instead of `spoken` when the push carried none (most of the log —
# --spoken only became mandatory recently).
title: str | None = None
message: str | None = None
@app.post("/api/notifications/audio")
def notification_audio(body: NotifAudioBody):
"""Render a notification's sound as a WAV so the UI can replay it.
The desk phone plays a per-type jazz jingle and then reads the French
`spoken` line; the notification card's play button asks for those same
bytes. Rendering happens in the phone service (one synthesizer, one voice —
see notify_audio.py). A push with no spoken line still gets a voice: its own
title + message, minus the bits written only for a screen.
"""
spoken = (body.spoken or "").strip() or notify_audio_mod.fallback_speech(
body.title or "", body.message or "")
try:
wav = notify_audio_mod.render(spoken, body.type or "")
except notify_audio_mod.RenderError as e:
raise HTTPException(502, str(e))
return Response(content=wav, media_type="audio/wav",
headers={"Cache-Control": "private, max-age=86400"})
@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)
# The raw `{skill: {count, last}}` map is indexer-internal; the thread only
# needs the names it used, and those roll the subagents' calls in. This
# parse is fresher than the store (a live turn re-parses here first), so the
# conversation's own map comes off `data` and only the children are joined.
sub_dir = ap.with_suffix("") / "subagents"
kid_paths = ([str(c) for c in sorted(sub_dir.glob("*.jsonl"))]
if sub_dir.is_dir() else [])
data["skillsUsed"] = _skills_used(kid_paths, extra=data.get("skills"))
data["agentsUsed"] = _agents_by_conversation().get(id, [])
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"))
data["deploying"] = _is_deploying(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 scaffold + published summary (see scaffold.py) ─────────────
# The algorithmic half of the Complete CTA: everything about a conversation that
# can be aggregated rather than reasoned about, handed to the model as markdown
# with two sections left blank. `conv-scaffold` (scripts/) is the CLI wrapper.
@app.get("/api/conversations/{id:path}/scaffold",
response_class=PlainTextResponse)
def conversation_scaffold(id: str):
"""The completion scaffold for one conversation, as markdown."""
sid, _, meta = _summary_and_meta(id)
path = _resolve_transcript(id)
# A full parse: the merged tool sections need the thread, which the cached
# rollup summary deliberately doesn't carry.
full = conversations.parse_conversation(path, full=True)
return scaffold_mod.build(_conv_id(str(path)), sid, full, meta, store)
class SummaryBody(BaseModel):
markdown: str
@app.put("/api/conversations/{id:path}/summary", responses=_r(schemas.OkResponse))
def conversation_summary_put(id: str, body: SummaryBody):
"""Publish a filled-in scaffold as the conversation's summary. Rejects an
untouched one — an unedited scaffold is a report of nothing."""
sid, _, _ = _summary_and_meta(id)
md = (body.markdown or "").strip()
if not md:
raise HTTPException(400, "markdown is required")
if not scaffold_mod.is_filled(md):
raise HTTPException(422, "the Summary section is still the scaffold's "
"placeholder — fill it in before publishing")
now = datetime.datetime.now(datetime.timezone.utc) \
.strftime("%Y-%m-%dT%H:%M:%SZ")
meta_store.update(sid, {"summary": {
"markdown": scaffold_mod.clean_for_publish(md), "at": now}})
hub.publish({"type": "meta", "id": sid})
return {"ok": 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}
# ── agents catalog ──────────────────────────────────────────────────────────
# `agents.py` owns the disk scan and the arithmetic; assembling the run list
# needs the summary store, the metadata sidecar and the cron store, so it lives
# here — the same split the skills catalog uses. Sits after the cron section
# because a scheduled agent's runs come out of `cron_store`.
_agent_runs_cache: tuple[tuple[int, int], list[dict]] | None = None
_agent_runs_lock = threading.Lock()
def _build_agent_runs() -> list[dict]:
"""Every recorded run of an agent, newest first, in one flat shape.
Two very different things are being unified here (see `agents.py`): a
**subagent** run, which is a sidechain transcript whose `agent-*.meta.json`
names the agent type, and a **session** run, which is an ordinary top-level
conversation that happened to be spawned *from* an agent file by cron or by
the agent page's "Run now". Both carry a real cost, because both are whole
transcripts — that's the whole point of measuring agents rather than skills.
A subagent run's `conversationId` is its **parent** (where its Task card
lives, and the unit "used in N conversations" counts); `subConversationId`
opens the child transcript itself. For a session run the two are the same
conversation, so only the former is set.
"""
by_file = {a["path"]: a["name"] for a in agents_mod.catalog()}
runs: list[dict] = []
sessions: dict[str, tuple[str, dict]] = {}
for path, s in store.all_summaries():
if _is_sidechain_path(path):
name = _subagent_meta_cached(pathlib.Path(path)).get("agentType")
if not name:
continue # a Task call with no resolved type (pre-meta.json)
parent = _parent_path_of(path)
runs.append({
"agent": name,
"origin": "task",
"at": s.get("endedAt") or s.get("startedAt"),
"cost": s.get("cost") or 0.0,
"tokens": s.get("tokens") or 0,
"model": s.get("model"),
"title": s.get("title"),
"conversationId": _conv_id(parent) if parent else None,
"subConversationId": _conv_id(path),
})
elif s.get("sessionId"):
sessions[s["sessionId"]] = (path, s)
def session_run(name: str, origin: str, sid: str, at: str | None) -> dict:
"""A top-level run, filled in from its transcript when one has synced —
a just-spawned session has none yet, so it shows up on the recorded
timestamp with zero spend rather than not at all."""
path, s = sessions.get(sid, (None, {}))
return {
"agent": name,
"origin": origin,
"at": s.get("endedAt") or s.get("startedAt") or at,
"cost": s.get("cost") or 0.0,
"tokens": s.get("tokens") or 0,
"model": s.get("model"),
"title": s.get("title"),
"conversationId": _conv_id(path) if path else None,
"subConversationId": None,
}
for job in cron_store.list():
name = by_file.get((job.get("promptFile") or "").lstrip("/"))
if not name:
continue # a job whose prompt file isn't an agent definition
for at, sid in (job.get("history") or {}).items():
if sid:
runs.append(session_run(name, "cron", sid, at))
for sid, m in meta_store.all().items():
ar = m.get("agentRun") or {}
if ar.get("name"):
runs.append(session_run(ar["name"], "manual", sid, ar.get("at")))
runs.sort(key=lambda r: r["at"] or "", reverse=True)
return runs
def _agent_runs() -> list[dict]:
"""Memoized `_build_agent_runs` — a full pass walks every summary, so the
list/detail pages (which the SSE bus refetches on every transcript write)
share one build per store version."""
global _agent_runs_cache
key = (store.summaries_version, meta_store.version)
with _agent_runs_lock:
if _agent_runs_cache and _agent_runs_cache[0] == key:
return _agent_runs_cache[1]
out = _build_agent_runs()
with _agent_runs_lock:
_agent_runs_cache = (key, out)
return out
def _agent_card(entry: dict | None, name: str, usage: dict, files: dict) -> dict:
"""One agent card: its disk record (or a built-in stub) + its run stats.
A built-in type (`Explore`, `general-purpose`, `Plan`, …) has no file in
this repo but has still earned its runs, so — exactly like a built-in skill
— it appears with `sourceKind: "builtin"` and no editor link.
"""
base = entry or {
"name": name, "title": name, "description": "",
"path": None, "dir": None,
"source": "builtin", "sourceKind": "builtin",
"tools": [], "bytes": 0, "updatedAt": None,
"harness": None, "model": None, "effort": None, "thinking": None,
}
u = usage.get(name) or {}
row = files.get(base.get("path") or "")
return {
**base,
"runs": u.get("runs", 0),
"taskRuns": u.get("taskRuns", 0),
"sessionRuns": u.get("sessionRuns", 0),
"lastRun": u.get("lastRun"),
"conversations": u.get("conversations", 0),
# Real spend: an agent run is a whole conversation, so unlike a skill's
# `estSpend` this is measured, not context-size × calls.
"cost": u.get("cost", 0.0),
"tokens": u.get("tokens", 0),
"avgCost": u.get("avgCost", 0.0),
# What the definition itself costs to load into a session.
"contextTokens": row["tokens"] if row else None,
"contextCost": row["cost"] if row else None,
}
@app.get("/api/agents", responses=_r(schemas.AgentsResponse))
def agents_list():
"""Card metadata + run/cost stats for every agent, last-run first.
The **union** of the agent definitions on disk and the types the transcripts
saw run — same shape as `/api/skills`, but the money here is real spend.
"""
runs = _agent_runs()
usage = agents_mod.rollup(runs)
files = store.all_files()
items = [_agent_card(a, a["name"], usage, files)
for a in agents_mod.catalog()]
known = {a["name"] for a in items}
for name in sorted(usage.keys() - known):
items.append(_agent_card(None, name, usage, files))
items.sort(key=lambda a: (a["lastRun"] or "", a["name"]), reverse=True)
return {
"agents": items,
"totals": {
"agents": len(items),
"used": sum(1 for a in items if a["runs"] > 0),
"runs": sum(a["runs"] for a in items),
"cost": sum(a["cost"] for a in items),
"tokens": sum(a["tokens"] for a in items),
},
}
def _agent_entry(name: str) -> dict | None:
return next((a for a in agents_mod.catalog() if a["name"] == name), None)
def _agent_cron_job(path: str | None) -> dict | None:
"""The cron job scheduled on this agent's file, if one exists — what turns
the detail page's "Schedule" CTA into a "next run in 3h" line."""
if not path:
return None
for job in cron_store.list():
if (job.get("promptFile") or "").lstrip("/") == path:
return _cron_job_out(job)
return None
# How many runs the detail page's history list shows. The rollup counts them
# all; only the tail is worth shipping.
AGENT_RUN_LIMIT = 40
@app.get("/api/agents/{name}", responses=_r(schemas.AgentDetail))
def agent_detail(name: str):
"""One agent: its definition, its stats, its recent runs and its schedule.
404s only when the name is neither on disk nor in any transcript — an agent
that exists but has never run is a perfectly good (empty) page.
"""
entry = _agent_entry(name)
runs = [r for r in _agent_runs() if r["agent"] == name]
if entry is None and not runs:
raise HTTPException(404, "agent not found")
usage = agents_mod.rollup(runs)
card = _agent_card(entry, name, usage, store.all_files())
content = ""
if entry:
try:
content = (WORKSPACE / entry["path"]).read_text(
encoding="utf-8", errors="replace")
except OSError:
content = ""
# Schedulable = cron would accept this path as a prompt file. Nested
# definitions (`.claude/agents/hooks/post-task.md`) and project-local ones
# aren't, so the UI can say why instead of offering a button that 400s.
can_schedule = False
if entry:
try:
cron_mod.valid_prompt_file(entry["path"])
can_schedule = True
except ValueError:
can_schedule = False
return {
"agent": card,
"content": content,
"runs": runs[:AGENT_RUN_LIMIT],
"daily": agents_mod.daily(runs),
"cron": _agent_cron_job(entry["path"] if entry else None),
"canSchedule": can_schedule,
}
class AgentRunBody(BaseModel):
# An extra instruction appended under the definition — the "what should it
# do this time?" box on the agent page. Empty ⇒ run the definition as-is,
# exactly like a cron firing does.
prompt: str | None = None
@app.post("/api/agents/{name}/run", responses=_r(schemas.SpawnResult))
def agent_run(name: str, body: AgentRunBody):
"""Run an agent now, as a top-level session — the agent page's CTA.
Same machinery as a cron firing (`_fire_cron_job`): the definition's body is
the prompt and its frontmatter picks the harness/model/effort, so a "Run
now" and a scheduled run are the same run. The session is stamped
`agentRun` in the metadata sidecar, which is both what puts it in this
agent's history and what makes the conversation card say where it came from.
"""
entry = _agent_entry(name)
if entry is None:
raise HTTPException(404, "agent not found")
try:
raw = (WORKSPACE / entry["path"]).read_text(encoding="utf-8")
except OSError:
raise HTTPException(409, f"could not read {entry['path']}")
cfg = cron_mod.run_config(raw)
prompt = cron_mod.strip_frontmatter(raw).strip()
if not prompt:
raise HTTPException(409, f"{entry['path']} has no prompt body")
extra = (body.prompt or "").strip()
if extra:
prompt = f"{prompt}\n\n---\n\n**This run's task:** {extra}"
out = _send_message(prompt, model=cfg["model"], harness=cfg["harness"],
thinking=cfg["thinking"], effort=cfg["effort"])
sid = out["sessionId"]
meta_store.update(sid, {"agentRun": {"name": name,
"at": notify_mod._now_iso()}})
hub.publish({"type": "meta", "id": sid})
return out
# ── conversation CTAs: the prompt buttons a finished conversation offers ─────
# The replacement for the auto-firing conversation hooks (see ctas.py). A CTA is
# a prompt file plus a button the viewer renders under the last message of a
# finished run: a click loads the prompt into the resume box (which is why
# ``_cta_out`` ships the prompt text itself), a long-press fires it into the
# background through ``/run`` below. No scheduler, no once-per-session ledger —
# a CTA only ever runs because someone pressed it.
def _cta_prompt_path(cta: dict) -> tuple[pathlib.Path, str]:
rel = ctas_mod.valid_prompt_file(cta.get("promptFile") or "")
return (WORKSPACE / rel).resolve(), rel
def _cta_prompt_text(cta: dict) -> str:
try:
ap, _ = _cta_prompt_path(cta)
return ap.read_text(encoding="utf-8")
except (OSError, ValueError):
return ""
def _cta_out(cta: dict) -> dict:
"""A stored CTA + the computed fields the UI needs: the run config and the
prompt body itself (frontmatter stripped), so pressing the button — or
picking its tag in a composer — needs no second round-trip."""
raw = _cta_prompt_text(cta)
out = dict(cta)
out["runConfig"] = ctas_mod.run_config(raw)
out["prompt"] = ctas_mod.strip_frontmatter(raw).strip()
out["promptExists"] = bool(raw)
out["history"] = [{"at": at, "sessionId": sid}
for at, sid in sorted((cta.get("history") or {}).items(),
reverse=True)][:20]
return out
def _run_cta(cta: dict, sid: str) -> dict:
"""Send the CTA's prompt at ``sid`` as a background resume — the long-press
path, and what ``POST /api/ctas/{id}/run`` does."""
_, rel = _cta_prompt_path(cta)
raw = _cta_prompt_text(cta)
cfg = ctas_mod.run_config(raw)
prompt = ctas_mod.strip_frontmatter(raw).strip()
if not prompt:
raise HTTPException(409, f"prompt file {rel} is missing or empty")
conv_id = _session_conv_map().get(sid)
summary = _summary_and_meta(conv_id)[1] if conv_id else {}
now = datetime.datetime.now(datetime.timezone.utc) \
.strftime("%Y-%m-%dT%H:%M:%SZ")
# Newest press wins — unlike a hook's ledger this is a record, not a guard.
meta_store.update(sid, {"ctas": {cta["id"]: now}})
# model/effort/thinking default to None = "keep whatever the session ran
# with"; a CTA prompt's frontmatter can still override.
out = _send_message(prompt, session_id=sid, cwd=summary.get("cwd"),
model=cfg["model"], harness=cfg["harness"],
thinking=cfg["thinking"], effort=cfg["effort"])
cta_store.record_run(cta["id"], sid, status="ok")
hub.publish({"type": "ctas"})
hub.publish({"type": "meta", "id": sid})
return out
class CtaCreateBody(BaseModel):
name: str
label: str | None = None
description: str | None = None
icon: str | None = None
promptFile: str | None = None # default: .claude/agents/cta/<slug>.md
enabled: bool = True
class CtaPatchBody(BaseModel):
name: str | None = None
label: str | None = None
description: str | None = None
icon: str | None = None
promptFile: str | None = None
enabled: bool | None = None
order: int | None = None
class CtaPromptBody(BaseModel):
content: str
class CtaRunBody(BaseModel):
# Which conversation to fire the CTA at. Required — a CTA is always about
# one conversation (there is no "most recent" default like a hook had).
sessionId: str
class CtaReorderBody(BaseModel):
ids: list[str]
@app.get("/api/ctas", responses=_r(schemas.CtasResponse))
def ctas_list():
"""Every CTA, in button order, each with its prompt text and run history."""
return {"ctas": [_cta_out(c) for c in cta_store.list()]}
@app.post("/api/ctas", responses=_r(schemas.Cta))
def ctas_create(body: CtaCreateBody):
"""Create a CTA. Its prompt file is created (carrying an agent-style
frontmatter stub) if absent, so the editor always opens on something."""
name = (body.name or "").strip()
if not name:
raise HTTPException(400, "name is required")
prompt_file = body.promptFile or \
f"{ctas_mod.PROMPT_DIR}/{ctas_mod.slugify(name)}.md"
try:
cta = cta_store.create(
name, label=(body.label or "").strip(),
description=(body.description or "").strip(),
icon=(body.icon or "").strip(),
prompt_file=prompt_file, enabled=body.enabled)
except ValueError as e:
raise HTTPException(400, str(e))
ap, _ = _cta_prompt_path(cta)
try:
if not ap.exists():
ap.parent.mkdir(parents=True, exist_ok=True)
ap.write_text(
f"---\nname: {cta['id']}\ndescription: Prompt offered as a "
f"button when a conversation finishes.\n---\n\n",
encoding="utf-8")
except OSError:
pass # surfaced as missing/empty when the CTA runs
hub.publish({"type": "ctas"})
return _cta_out(cta)
@app.put("/api/ctas/{cta_id}", responses=_r(schemas.Cta))
def ctas_update(cta_id: str, body: CtaPatchBody):
try:
cta = cta_store.update(cta_id, body.model_dump())
except ValueError as e:
raise HTTPException(400, str(e))
if not cta:
raise HTTPException(404, "cta not found")
hub.publish({"type": "ctas"})
return _cta_out(cta)
@app.post("/api/ctas/reorder", responses=_r(schemas.CtasResponse))
def ctas_reorder(body: CtaReorderBody):
"""Set the button order (the settings list is drag-reorderable)."""
return {"ctas": [_cta_out(c) for c in cta_store.reorder(body.ids)]}
@app.delete("/api/ctas/{cta_id}", responses=_r(schemas.OkResponse))
def ctas_delete(cta_id: str):
"""Remove a CTA. Its prompt file stays — it is repo content."""
if not cta_store.delete(cta_id):
raise HTTPException(404, "cta not found")
hub.publish({"type": "ctas"})
return {"ok": True}
@app.post("/api/ctas/{cta_id}/run", responses=_r(schemas.SpawnResult))
def ctas_run(cta_id: str, body: CtaRunBody):
"""Fire a CTA at one conversation in the background (the long-press)."""
cta = cta_store.get(cta_id)
if not cta:
raise HTTPException(404, "cta not found")
sid = (body.sessionId or "").strip()
if not sid:
raise HTTPException(400, "sessionId is required")
try:
return _run_cta(cta, sid)
except HTTPException as e:
cta_store.record_run(cta_id, "", status=f"error: {e.detail}")
hub.publish({"type": "ctas"})
raise
@app.get("/api/ctas/{cta_id}/prompt", responses=_r(schemas.CtaPromptFile))
def ctas_prompt(cta_id: str):
"""The CTA's prompt file content (the editable text in Settings → CTAs)."""
cta = cta_store.get(cta_id)
if not cta:
raise HTTPException(404, "cta not found")
ap, rel = _cta_prompt_path(cta)
try:
return {"path": rel, "content": ap.read_text(encoding="utf-8"),
"exists": True}
except OSError:
return {"path": rel, "content": "", "exists": False}
@app.put("/api/ctas/{cta_id}/prompt", responses=_r(schemas.CtaPromptFile))
def ctas_prompt_save(cta_id: str, body: CtaPromptBody):
cta = cta_store.get(cta_id)
if not cta:
raise HTTPException(404, "cta not found")
ap, rel = _cta_prompt_path(cta)
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}")
hub.publish({"type": "ctas"})
return {"path": rel, "content": body.content, "exists": True}
# Claude Code's own hooks, read out of the workspace's .claude/settings.json.
# Read-only on purpose: writing that file from the container is host-level code
# execution (see the trusted-caller note at the top of this module), so the
# page shows them and points at the repo for edits.
CLAUDE_SETTINGS_REL = ".claude/settings.json"
@app.get("/api/claude-hooks", responses=_r(schemas.ClaudeHooksResponse))
def claude_hooks():
"""The native Claude Code hooks registered in the workspace repo, flattened
to one entry per (event, matcher, command). A missing or unparseable
settings file is an empty list, not an error (standalone has no repo)."""
ap = WORKSPACE / CLAUDE_SETTINGS_REL
try:
data = json.loads(ap.read_text(encoding="utf-8")) or {}
except (OSError, ValueError):
return {"path": CLAUDE_SETTINGS_REL, "exists": ap.exists(),
"entries": []}
entries = []
blocks = data.get("hooks")
for event, matchers in (blocks or {}).items():
for m in (matchers if isinstance(matchers, list) else []):
if not isinstance(m, dict):
continue
for h in (m.get("hooks") or []):
if not isinstance(h, dict):
continue
entries.append({"event": str(event),
"matcher": m.get("matcher") or None,
"type": h.get("type") or "command",
"command": h.get("command") or ""})
return {"path": CLAUDE_SETTINGS_REL, "exists": True, "entries": entries}
def _seed_cta_prompts() -> None:
"""Write the seeded CTA prompts into the workspace on first run, so a fresh
install has a working Complete button with no setup. Never overwrites an
existing file — the prompt is repo content the user edits."""
seeds = {"complete": ctas_mod.DEFAULT_COMPLETE_PROMPT}
for cta_id, text in seeds.items():
cta = cta_store.get(cta_id)
if not cta:
continue
try:
ap, _ = _cta_prompt_path(cta)
if ap.exists():
continue
ap.parent.mkdir(parents=True, exist_ok=True)
ap.write_text(text, encoding="utf-8")
except (OSError, ValueError):
pass # read-only workspace: surfaced as missing when the CTA runs
# ── 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,
# Kept (not just forwarded) so the UI can replay what the phone said.
"spoken": body.spoken or "",
})
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()
# Conversation CTAs: the buttons a finished conversation offers (ctas.py).
# The seeded Complete prompt is written into the workspace on first run;
# there is no scheduler — a CTA runs when it is pressed.
_seed_cta_prompts()
# ── 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)