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

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

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

1200 lines
36 KiB
Python

"""Pydantic response models — the OpenAPI schema for the read endpoints.
These are **documentation-only**: they are attached to the routes via
``responses={200: {"model": …}}`` (see ``main.py``), never as ``response_model``.
FastAPI therefore serves them in ``/openapi.json`` but does **not** validate or
filter the actual returned dicts through them — so a model that drifts from the
real payload can only produce a wrong TypeScript type (caught by ``tsc``), never
a silent runtime regression.
They mirror ``frontend/src/types.ts`` field-for-field; fields are intentionally
**camelCase** (matching the JSON the handlers already emit) so Orval generates
property names identical to the hand-written types they replace. Class names
match the TS interface names so the generated model names line up 1:1.
"""
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict
FileKind = Literal["claude-md", "skill-md", "markdown", "script", "config", "other"]
class Schema(BaseModel):
# ``model`` is a legit field name on several of these; opt out of Pydantic's
# protected ``model_`` namespace so it doesn't warn.
model_config = ConfigDict(protected_namespaces=())
# ── files / bundle ────────────────────────────────────────────────────────────
class FileEntry(Schema):
path: str
name: str
ext: str
kind: FileKind
isMarkdown: bool
bytes: int
words: int
chars: int
tokens: int
cost: float
estimated: bool
editable: bool
content: Optional[str] = None
class Pricing(Schema):
model: str
inputPerMTok: float
class Totals(Schema):
files: int
mdFiles: int
words: int
chars: int
tokens: int
cost: float
estimated: int
class Bundle(Schema):
workspace: str
totals: Totals
files: list[FileEntry]
pricing: Pricing
# ── dashboard ─────────────────────────────────────────────────────────────────
class SkillStat(Schema):
name: str
count: int
lastUsed: Optional[str] = None
contextTokens: Optional[int] = None
contextCost: Optional[float] = None
estSpend: Optional[float] = None
class DashboardTotals(Schema):
files: int
tokens: int
cost: float
estimated: int
skills: int
invocations: int
skillSpend: float
class TopFile(Schema):
path: str
tokens: int
cost: float
estimated: bool
class Dashboard(Schema):
pricing: Pricing
totals: DashboardTotals
skills: list[SkillStat]
topFiles: list[TopFile]
# ── conversations ─────────────────────────────────────────────────────────────
class ToolUsage(Schema):
tokens: int
cost: float
output: int
class ConvNotification(Schema):
title: Optional[str] = None
body: Optional[str] = None
type: Optional[str] = None
url: Optional[str] = None
at: Optional[str] = None
class WorktreeInfo(Schema):
name: str
dir: Optional[str] = None
createdAt: Optional[str] = None
removedAt: Optional[str] = None
class ForkedFrom(Schema):
"""Where a forked conversation branched off: the source conversation and
the user-message ordinal (``mi``) whose edit created the fork."""
id: str # source conversation id (<slug>/<sid>.jsonl)
sessionId: Optional[str] = None # source session id
mi: int # message ordinal the fork cut before
class CronRef(Schema):
"""The cron job that spawned a conversation (see cron.py) — rendered as a
tag on the card/detail linking back to Settings → Cron."""
id: str
name: Optional[str] = None
class ConvMeta(Schema):
state: str
# Title the session chose for itself (`conv-meta title "…"`); the backend
# already prefers it over the parsed one on every card, so the UI reads
# `title` — this is here for the raw-sidecar endpoint.
title: Optional[str] = None
projects: list[str]
services: list[str]
worktrees: Optional[list[WorktreeInfo]] = None
committed: Optional[str] = None
pushed: Optional[str] = None
merged: Optional[str] = None
deployed: Optional[str] = None
notified: Optional[str] = None
notifications: list[ConvNotification]
archived: bool = False
# Agent harness the session runs on: "claude" | "pi" (None = pre-field).
harness: Optional[str] = None
# Whether the session runs with thinking on. False ⇒ it was spawned (or last
# resumed) with thinking disabled. None = pre-field, i.e. on.
thinking: Optional[bool] = None
# The claude `--effort` level the session was spawned (or last resumed) with:
# "low" | "medium" | "high" | "xhigh" | "max". Claude-only, and None when the
# run never picked one (the CLI's own default) — pi runs never set it.
effort: Optional[str] = None
# Set when this conversation was forked off another one (edit & resubmit).
forkedFrom: Optional[ForkedFrom] = None
# Set when a cron job spawned this conversation.
cron: Optional[CronRef] = None
class Usage(Schema):
input: int
output: int
cacheRead: int
cacheWriteTokens: int
cacheWriteUnits: int
class SubagentRef(Schema):
id: str
agentType: Optional[str] = None
description: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
tokens: Optional[int] = None
cost: Optional[float] = None
messages: Optional[int] = None
# Lifecycle of the subagent itself: ``running`` while the parent's Task call
# is still open (no tool_result yet), else ``finished``.
state: Optional[str] = None
startedAt: Optional[str] = None
endedAt: Optional[str] = None
class SubagentListItem(Schema):
"""A flat subagent (sidechain) ref with its parent conversation id — the
graph view's node source (the top-level conversation list hides these)."""
id: str
parentId: str
agentType: Optional[str] = None
description: Optional[str] = None
title: Optional[str] = None
tokens: Optional[int] = None
cost: Optional[float] = None
state: Optional[str] = None
class SubagentsResponse(Schema):
subagents: list[SubagentListItem]
class RunningAgent(Schema):
"""A Task/Agent call that hasn't returned — i.e. a subagent still running."""
toolUseId: str
agentType: Optional[str] = None
description: Optional[str] = None
startedAt: Optional[str] = None
class ParentConvRef(Schema):
id: str
title: Optional[str] = None
toolUseId: Optional[str] = None
agentType: Optional[str] = None
description: Optional[str] = None
class Task(Schema):
"""One task from the conversation's task list (TaskCreate/TaskUpdate, or the
older TodoWrite). ``status`` is one of pending / in_progress / completed /
cancelled."""
id: str
subject: str
status: str
class ConversationSummary(Schema):
id: str
sessionId: Optional[str] = None
project: Optional[str] = None
cwd: Optional[str] = None
gitBranch: Optional[str] = None
model: str
# Every model that produced a turn, busiest first (a session can switch
# models mid-thread). Rendered as the conversation's model tags.
models: Optional[list[str]] = None
# Every `--effort` level the turns actually ran at, busiest first — the read
# side of the composer's effort select, mined from the transcript. Empty for
# pi runs and for transcripts predating the CLI flag.
efforts: Optional[list[str]] = None
title: str
userTurns: int
assistantTurns: int
messages: int
startedAt: Optional[str] = None
endedAt: Optional[str] = None
tokens: int
cost: float
usage: Optional[Usage] = None
byTool: Optional[dict[str, ToolUsage]] = None
# Second-level breakdown of each activity bucket: bucket -> sub-label ->
# usage. bash by program, read/edit by file extension, msg by prompt/replies,
# skill as "skills read", other tool buckets by tool name. Powers the
# click-to-drill-down on the conversations dashboard chart.
byToolSub: Optional[dict[str, dict[str, ToolUsage]]] = None
subagentCount: Optional[int] = None
subagentTokens: Optional[int] = None
subagentCost: Optional[float] = None
# Subagents still in flight (their Task call never came back). The list card
# shows one "running" chip per entry while the conversation itself is running.
runningAgents: Optional[list[RunningAgent]] = None
# Current context size: the last assistant API call's full prompt+reply
# (input + cache read/write + output) — what the next request replays.
# `contextModel` is the model that made that call, so the UI can pick the
# right context-window size (see ModelInfo.contextWindow).
contextTokens: Optional[int] = None
contextModel: Optional[str] = None
# The session's first API call's input side, split in two: the injected
# context (system prompt, CLAUDE.md, memories, other <system-reminder>
# blocks) vs the user's actual first message. The API reports only the
# combined total, so the message share is estimated from its length
# (~4 chars/token) and the remainder is attributed to context.
firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None
tasks: Optional[list[Task]] = None
meta: Optional[ConvMeta] = None
class ThreadItem(Schema):
role: Literal["user", "assistant", "tool"]
# "turn" is a content-less carrier: an API call that rendered nothing (a
# redacted-thinking turn) but was still billed. It exists so the cost chart
# sees every priced turn; the viewer never draws it.
kind: Literal[
"text", "thinking", "tool_use", "tool_result", "interrupted", "paused",
"turn"]
name: Optional[str] = None
input: Optional[Any] = None
text: Optional[str] = None
isError: Optional[bool] = None
result: Optional[str] = None
out: Optional[int] = None
turnTokens: Optional[int] = None
turnCost: Optional[float] = None
# The cached-prompt share of this turn: tokens replayed from the prompt cache
# and what they cost (10% of the input rate). The rest of `turnTokens` /
# `turnCost` is fresh input, cache writes and output.
turnCacheTokens: Optional[int] = None
turnCacheCost: Optional[float] = None
# The activity bucket this turn's cost was charged to in the `byTool`
# breakdown (msg / read / bash / …), so the cost chart colours and totals a
# turn exactly as "cost by activity" does.
turnBucket: Optional[str] = None
# On the first API call only: the share booked to the "context" bucket
# (injected system prompt, CLAUDE.md, memories), split off from this turn's
# own activity just as the breakdown does.
turnContextCost: Optional[float] = None
turnContextTokens: Optional[int] = None
# Only on the user's *first* message: how much of the session's first API
# call was injected context (system prompt, CLAUDE.md, memories, …) vs the
# message's own text (length-estimated split of the API-reported total).
firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None
mi: Optional[int] = None
ts: Optional[str] = None
toolUseId: Optional[str] = None
subagent: Optional[SubagentRef] = None
# On a Task/Agent card: whether the agent it spawned is still running. Set
# even before the subagent's transcript exists, so a just-spawned agent's
# card reads "running" straight away.
agentRunning: Optional[bool] = None
# Wall-clock duration of this step: for a tool call, how long it ran (call →
# its result); for a message/thinking block, the assistant API call's latency
# (previous transcript record → this one).
durationMs: Optional[int] = None
# The model that produced this assistant step.
model: Optional[str] = None
# Size of a tool call's result payload (before clipping).
resultChars: Optional[int] = None
resultLines: Optional[int] = None
# On a `Skill` tool card: the injected SKILL.md body (shown collapsible in the
# card) and a repo-relative path to it (a link into the editor), so a skill
# invocation renders as one self-contained action instead of a huge "You" bubble.
skillBody: Optional[str] = None
skillPath: Optional[str] = None
# On a deploy tool call (npm run deploy / deploy*.sh / zipgo deploy / the
# deploy-html skill): the URL it reported, scraped from the raw — unclipped —
# output. Drives the deploy preview widget and the "open the deploy" gizmo.
deployedUrl: Optional[str] = None
class TurnUsage(Usage):
model: Optional[str] = None
class MemorySummary(Schema):
slug: str
name: str
description: str
type: Optional[str] = None
originSessionId: Optional[str] = None
projects: list[str]
services: list[str]
links: list[str]
words: int
bytes: int
updatedAt: Optional[str] = None
class ArtefactItem(Schema):
"""One generated artefact (screenshot, render, export) found under a
project/service's ``.ai/artefacts/<date>/<sessionId>/`` folder."""
kind: str # "project" | "service"
slug: str
name: str
date: str # the YYYY-MM-DD folder it landed in
path: str # <date>/<sessionId>/<file>, for /api/artefact
url: str # ready-to-use /api/artefact serve URL
bytes: int
mtime: Optional[str] = None
video: Optional[bool] = None
class ConversationDetail(ConversationSummary):
usage: Usage
thread: list[ThreadItem]
turns: list[TurnUsage]
memoriesRead: Optional[list[MemorySummary]] = None
memoriesCreated: Optional[list[MemorySummary]] = None
subagents: Optional[list[SubagentRef]] = None
parentConversation: Optional[ParentConvRef] = None
# Plans (data/plans/*.md) whose planning session is this conversation — the
# `plan` skill stamps its sessionId/conversationIds, so we join them back.
plans: Optional[list[PlanSummary]] = None
# Generated artefacts collected from the conversation's projects/services
# (.ai/artefacts/<date>/<sessionId>/), oldest first.
artefacts: Optional[list[ArtefactItem]] = None
class ConversationsResponse(Schema):
conversations: list[ConversationSummary]
# Total conversations matching the filter (pre-pagination).
count: Optional[int] = None
# Cursor for the next page (`?before=`); None when this page ends the list.
nextBefore: Optional[str] = None
pricing: Pricing
# ── notification feed (light source for the history + "new" badges) ──────────
class NotifConversation(Schema):
"""A conversation that recorded notify-done pushes, feed-relevant bits only."""
id: str
title: str
endedAt: Optional[str] = None
projects: list[str]
services: list[str]
notifications: list[ConvNotification]
class NotificationsResponse(Schema):
conversations: list[NotifConversation]
count: Optional[int] = None
class FeedNotification(Schema):
"""A flat notification feed item (the server-side mirror of the frontend's
FeedNotif), as returned by ``/api/notifications/unread``."""
id: str
convId: str
convTitle: str
title: str
body: str
type: str
url: str
at: Optional[str] = None
projects: list[str]
services: list[str]
kind: Optional[str] = None
class UnreadNotificationsResponse(Schema):
notifications: list[FeedNotification]
count: int # items returned (after the limit)
total: int # total unread (before the limit)
# ── first-class notifications: webhooks + notify/ask log (notify.py) ─────────
class Webhook(Schema):
id: str
name: str
url: str
events: list[Literal["notify", "ask"]]
enabled: bool
class WebhooksResponse(Schema):
webhooks: list[Webhook]
class DeliveryStatus(Schema):
"""One webhook's delivery outcome for a forwarded notification."""
webhook: str
ok: bool
status: int
class NotifyResult(Schema):
ok: bool
id: str
delivered: list[DeliveryStatus]
class AskRecord(Schema):
"""An ask (choice question) with its lifecycle state."""
id: str
kind: Literal["ask"]
title: str
body: str
type: str
url: str
at: str
sessionId: str
delivered: list[DeliveryStatus]
options: list[str]
status: Literal["pending", "answered", "expired"]
answer: Optional[str] = None
answerIndex: Optional[int] = None
answeredAt: Optional[str] = None
expiresAt: Optional[str] = None
class NotifyLogEntry(Schema):
"""One recorded notification (kind "notify") or ask (kind "ask")."""
id: str
kind: Literal["notify", "ask"]
title: str
body: str
type: str
url: str
at: str
sessionId: str
delivered: list[DeliveryStatus]
# ask-only lifecycle fields (absent on plain pushes)
options: Optional[list[str]] = None
status: Optional[Literal["pending", "answered", "expired"]] = None
answer: Optional[str] = None
answerIndex: Optional[int] = None
answeredAt: Optional[str] = None
expiresAt: Optional[str] = None
class NotifyLogResponse(Schema):
notifications: list[NotifyLogEntry]
count: Optional[int] = None
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
class FormFieldOption(Schema):
value: str
label: str
class FormField(Schema):
"""One field of a form spec (see forms.FIELD_TYPES)."""
key: str
label: str
type: Literal["text", "textarea", "number", "select", "multiselect",
"radio", "checkbox", "slider", "file", "date"]
required: bool
placeholder: Optional[str] = None
help: Optional[str] = None
accept: Optional[str] = None # file: input accept filter
options: Optional[list[FormFieldOption]] = None
min: Optional[float] = None
max: Optional[float] = None
step: Optional[float] = None
multiple: Optional[bool] = None # file: allow several
default: Optional[Any] = None
class FormRecord(Schema):
"""A form with its lifecycle state; ``answers`` keyed by field key."""
id: str
title: str
description: str
type: str
url: str
sessionId: str
at: str
fields: list[FormField]
status: Literal["pending", "submitted", "cancelled"]
answers: Optional[dict[str, Any]] = None
submittedAt: Optional[str] = None
cancelledAt: Optional[str] = None
class FormsResponse(Schema):
forms: list[FormRecord]
count: Optional[int] = None
# ── full-text search index ────────────────────────────────────────────────────
class SearchMsg(Schema):
i: int
role: Literal["user", "assistant"]
text: str
class SearchConv(Schema):
id: str
title: str
project: Optional[str] = None
model: str
endedAt: Optional[str] = None
msgs: list[SearchMsg]
class SearchIndexResponse(Schema):
conversations: list[SearchConv]
count: Optional[int] = None
# ── memories ──────────────────────────────────────────────────────────────────
class MemoryConvRef(Schema):
id: str
sessionId: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
endedAt: Optional[str] = None
cost: Optional[float] = None
tokens: Optional[int] = None
meta: Optional[ConvMeta] = None
class MemoryDetail(MemorySummary):
content: str
createdIn: list[MemoryConvRef]
readIn: list[MemoryConvRef]
class MemoriesResponse(Schema):
memories: list[MemorySummary]
# ── plans (data/plans/, written by the `plan` skill) ──────────────────────────
class PlanSummary(Schema):
slug: str
title: str
status: str
created: Optional[str] = None
updatedAt: Optional[str] = None
sessionId: Optional[str] = None
conversationIds: list[str]
projects: list[str]
services: list[str]
estCost: Optional[float] = None
estTimeMinutes: Optional[int] = None
estTokens: Optional[int] = None
files: int
steps: int
class PlanFile(Schema):
file: str
change: str
class PlanStep(Schema):
title: str
detail: str
class PlanActual(Schema):
cost: float
tokens: int
durationMs: Optional[int] = None
class PlanConvRef(Schema):
id: str
sessionId: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
startedAt: Optional[str] = None
endedAt: Optional[str] = None
cost: Optional[float] = None
tokens: Optional[int] = None
durationMs: Optional[int] = None
meta: Optional[ConvMeta] = None
class PlanDetail(PlanSummary):
content: str
fileList: list[PlanFile]
stepList: list[PlanStep]
conversations: list[PlanConvRef]
actual: Optional[PlanActual] = None
class PlansResponse(Schema):
plans: list[PlanSummary]
# ── projects ──────────────────────────────────────────────────────────────────
class ProjectCostSummary(Schema):
cost: float
tokens: int
conversations: int
class TokenTypeSpend(Schema):
tokens: int
cost: float
class ProjectByType(Schema):
input: TokenTypeSpend
output: TokenTypeSpend
cacheRead: TokenTypeSpend
cacheWrite: TokenTypeSpend
class ProjectCostDetail(ProjectCostSummary):
meanCost: float
medianCost: float
meanTokens: float
medianTokens: float
byType: ProjectByType
loc: int
meanCostPerLoc: float
# A directory's GOAL.md: the checklist rollup that tags a card, and — on a
# detail response — the markdown itself. Absent file ⇒ the field is None.
class GoalSummary(Schema):
done: int
total: int
# One checklist item on a detail page's Goal checklist widget: the raw markdown
# after its checkbox (rendered), a plain single-line summary (the Work prompt +
# tooltip), and whether it's ticked.
class GoalChecklistItem(Schema):
text: str
plain: str
checked: bool
# A goal-keeper's in-flight claim under "## Being worked on" — an item some agent
# is on right now. Surfaced on the detail (so the checklist can flag a claimed
# item) and on the board. (Defined here so GoalDetail can reference it.)
class GoalClaim(Schema):
text: str
since: Optional[str] = None # ISO — parsed off a trailing "@<iso>"
stale: bool # older than the goal-keeper's 5h cadence
class GoalDetail(GoalSummary):
content: str
items: list[GoalChecklistItem]
claims: list[GoalClaim]
# ── the goals board (/api/goals) ─────────────────────────────────────────────
# One card per project/service that keeps a GOAL.md: its progress, the version
# milestones off its "## Horizons", the themed wishlist groups, the goal-keeper
# claims in "## Being worked on", and how many agents are on it right now.
class GoalMilestone(Schema):
title: str
version: Optional[str] = None # "0.3" — absent on an unversioned horizon
current: bool # the horizon marked "(now)"
done: int
total: int
hasTasks: bool # False ⇒ prose horizon, don't render "0/0"
class GoalTheme(Schema):
title: str
done: int
total: int
class RunningConversation(Schema):
id: str
title: str
startedAt: Optional[str] = None
class GoalBoardEntry(GoalSummary):
kind: Literal["project", "service"]
dir: str
name: str
description: str
url: Optional[str] = None
updatedAt: Optional[str] = None
milestones: list[GoalMilestone]
themes: list[GoalTheme]
claims: list[GoalClaim]
# The goal's checklist, so a card can expand into its individual items (and
# put a Work button on each) without a round-trip to the detail page. Capped
# server-side — see `goal.BOARD_ITEMS` — since the board renders many goals.
items: list[GoalChecklistItem]
itemsTruncated: int # items beyond the cap, for a "+N more" hint
agents: int # sessions running on this unit right now
runningConversations: list[RunningConversation]
conversations: int
lastConversationAt: Optional[str] = None
class GoalsResponse(Schema):
goals: list[GoalBoardEntry]
class ProjectSummary(Schema):
dir: str
name: str
description: str
version: Optional[str] = None
url: Optional[str] = None
stack: list[str]
keywords: list[str]
hasPackageJson: bool
goal: Optional[GoalSummary] = None
updatedAt: Optional[str] = None
costs: Optional[ProjectCostSummary] = None
class IndexMeta(Schema):
title: Optional[str] = None
description: Optional[str] = None
ogImage: Optional[str] = None
ogUrl: Optional[str] = None
themeColor: Optional[str] = None
class OgImage(Schema):
kind: Literal["url", "asset"]
src: str
class KeyFile(Schema):
name: str
bytes: int
class ProjectCommit(Schema):
hash: str
date: str
subject: str
class ProjectDetail(ProjectSummary):
readme: Optional[str] = None
claudeMd: Optional[str] = None
goal: Optional[GoalDetail] = None
indexMeta: Optional[IndexMeta] = None
ogImage: Optional[OgImage] = None
scripts: list[str]
keyFiles: list[KeyFile]
commits: list[ProjectCommit]
costs: Optional[ProjectCostDetail] = None
class ProjectsResponse(Schema):
projects: list[ProjectSummary]
class ProjectEnvVar(Schema):
key: str
# Secrets never travel to the UI: value is None for them, hasValue says
# whether a non-empty value is set on disk.
value: Optional[str] = None
secret: bool
hasValue: bool
class ProjectEnvResponse(Schema):
slug: str
exists: bool
vars: list[ProjectEnvVar]
# ── templates ─────────────────────────────────────────────────────────────────
class TemplateSummary(Schema):
name: str
description: str
stack: list[str]
fileCount: int
sizeBytes: int
hasReadme: bool
class TemplateTreeNode(Schema):
name: str
path: str
type: Literal["dir", "file"]
size: Optional[int] = None
text: Optional[bool] = None
children: Optional[list["TemplateTreeNode"]] = None
class TemplateDetail(Schema):
name: str
description: str
stack: list[str]
fileCount: int
sizeBytes: int
readme: Optional[str] = None
scripts: dict[str, str]
dependencies: list[str]
tree: list[TemplateTreeNode]
class TemplatesResponse(Schema):
templates: list[TemplateSummary]
# ── services catalog ──────────────────────────────────────────────────────────
class ServiceSummary(Schema):
dir: str
name: str
description: str
icon: Optional[str] = None
group: Optional[str] = None
urls: list[str]
containers: list[str]
containerCount: int
images: list[str]
profiles: list[str]
auth: Optional[Literal["auth", "public", "api", "mixed"]] = None
hasCompose: bool
hasTraefik: bool
goal: Optional[GoalSummary] = None
updatedAt: Optional[str] = None
class ServiceVolume(Schema):
source: Optional[str] = None
target: Optional[str] = None
mode: Optional[str] = None
readOnly: bool
class ServiceContainer(Schema):
key: str
containerName: str
image: Optional[str] = None
build: Optional[str] = None
profiles: list[str]
restart: Optional[str] = None
networkMode: Optional[str] = None
networks: list[str]
ports: list[str]
dependsOn: list[str]
envKeys: list[str]
envFiles: list[str]
volumes: list[ServiceVolume]
hasHealthcheck: bool
composeFile: str
class ServiceRouter(Schema):
name: str
kind: Literal["http", "tcp"]
rule: str
hosts: list[str]
url: Optional[str] = None
entryPoints: list[str]
service: Optional[str] = None
middlewares: list[str]
tls: bool
priority: Optional[int] = None
class TraefikService(Schema):
name: str
servers: list[str]
class ServiceTreeNode(Schema):
name: str
path: str
type: Literal["dir", "file"]
size: Optional[int] = None
text: Optional[bool] = None
secret: Optional[bool] = None
children: Optional[list["ServiceTreeNode"]] = None
class HomepageMeta(Schema):
label: str
description: Optional[str] = None
icon: Optional[str] = None
href: Optional[str] = None
group: Optional[str] = None
class ServiceDetail(ServiceSummary):
containersDetail: list[ServiceContainer]
composeFiles: list[str]
topNetworks: list[str]
routers: list[ServiceRouter]
traefikServices: list[TraefikService]
middlewares: list[str]
envKeys: list[str]
homepage: Optional[HomepageMeta] = None
readme: Optional[str] = None
claudeMd: Optional[str] = None
goal: Optional[GoalDetail] = None
keyFiles: list[KeyFile]
commits: list[ProjectCommit]
tree: list[ServiceTreeNode]
class ServicesResponse(Schema):
services: list[ServiceSummary]
# ── small mutation / misc envelopes ───────────────────────────────────────────
class UiStateValue(Schema):
value: Optional[str] = None
class OkResponse(Schema):
ok: bool
class ModelInfo(Schema):
"""One pickable model (a model tag in the composer)."""
id: str # the exact `--model` argument
displayName: str
family: str # opus | sonnet | haiku | fable | qwen
version: str # "4.8", "5", … (within the family)
alias: Optional[str] = None # CLI shorthand, only on a family's newest
latest: bool = False
# Which agent harness runs sessions on this model: "claude" (the claude
# CLI) or "pi" (the pi.dev runner). Picking the chip picks the harness.
harness: Optional[str] = None
# pi models only: "openrouter" (hosted) or "evox2" (local LM Studio).
provider: Optional[str] = None
# Optional short chip label ("qwen 3.6 local"); the frontend derives one
# from the id when absent.
label: Optional[str] = None
# Context-window size in tokens (`max_input_tokens` from the Anthropic
# Models API, with a static per-family fallback). Drives the conversation
# page's context-fill gizmo.
contextWindow: Optional[int] = None
class ModelsResponse(Schema):
models: list[ModelInfo]
default: str # model a run uses when nothing is picked
class SpawnResult(Schema):
sessionId: str
pid: Optional[int] = None
class InterruptResult(Schema):
sessionId: str
ok: bool
class UploadedFile(Schema):
name: str
size: int
contentType: str
# Repo-relative path the composer injects into the prompt (read by the session).
repoPath: str
# Backend serve route the thread viewer renders the file from.
url: str
class UploadResult(Schema):
files: list[UploadedFile]
class ConversationMetaResponse(Schema):
meta: dict[str, ConvMeta]
class MetaUpdateResult(Schema):
id: str
meta: ConvMeta
class ArchiveOldResult(Schema):
archived: int # how many conversations were newly archived
cutoff: str # UTC start-of-yesterday used as the age threshold
# ── diff view (commit + conversation diffs) ───────────────────────────────────
class CommitRef(Schema):
sha: str
short: str
date: str
author: str
subject: str
additions: int
deletions: int
files: int
repo: str # repo token: project:<slug> | service:<slug> | super:_
class ConversationCommits(Schema):
id: str
commits: list[CommitRef]
repos: list[str]
filesChanged: int
additions: int
deletions: int
class DiffLine(Schema):
type: Literal["add", "del", "ctx"]
oldNo: Optional[int] = None
newNo: Optional[int] = None
text: str
class DiffHunk(Schema):
header: str
oldStart: int
oldLines: int
newStart: int
newLines: int
lines: list[DiffLine]
class FileDiff(Schema):
path: str
oldPath: Optional[str] = None
status: Literal["added", "deleted", "modified", "renamed"]
additions: int
deletions: int
binary: bool
truncated: bool
hunks: list[DiffHunk]
repo: Optional[str] = None
class DiffResult(Schema):
title: str
subtitle: Optional[str] = None
files: list[FileDiff]
additions: int
deletions: int
commits: Optional[list[CommitRef]] = None
approx: Optional[bool] = None
# ── cron jobs ────────────────────────────────────────────────────────────────
class CronRun(Schema):
"""One recorded firing of a cron job — the history mapping's entry enriched
with the conversation id the session landed on (once its transcript
synced), so the UI can link straight to it."""
at: str
sessionId: str
conversationId: Optional[str] = None
class CronRunConfig(Schema):
"""How a job's firings are launched, read out of the prompt file's YAML
frontmatter (``cron.run_config``). Every field is optional: None means the
key isn't set and the spawn path's own default applies."""
# "claude" (the claude CLI) or "pi" (the pi.dev runner).
harness: Optional[str] = None
# A `--model` value: a family alias ("sonnet") or a full model id.
model: Optional[str] = None
# `--effort` level (low|medium|high|xhigh|max); "" = explicitly no flag.
effort: Optional[str] = None
# False ⇒ run with extended thinking off; None ⇒ the CLI's own default.
thinking: Optional[bool] = None
class CronJob(Schema):
id: str
name: str
# 5-field cron expression (min hour dom mon dow), container-local time.
schedule: str
# Markdown prompt file under .claude/agents/ — its content is the prompt
# each firing spawns a session with (leading YAML frontmatter stripped),
# and its frontmatter is what `runConfig` is read from.
promptFile: str
enabled: bool
# Legacy per-job model, kept as the fallback when the frontmatter sets none.
model: Optional[str] = None
# The resolved run config from the prompt file's frontmatter.
runConfig: Optional[CronRunConfig] = None
createdAt: Optional[str] = None
lastStatus: Optional[str] = None
# Next fire time (local ISO minute); None when disabled or never.
nextRun: Optional[str] = None
# The stored history: {ISO timestamp: sessionId}.
history: dict[str, str]
# `history` enriched + sorted newest-first for rendering.
runs: list[CronRun]
class CronJobsResponse(Schema):
jobs: list[CronJob]
class CronPromptFile(Schema):
path: str
content: str
exists: bool
# ── deploy status ────────────────────────────────────────────────────────────
class DeployStep(Schema):
"""One phase of the blue-green deploy, with its rendered dot state."""
n: int
label: str
# pending (grey) · active (loading) · done (green) · failed (red)
state: Literal["pending", "active", "done", "failed"]
class DeployStatus(Schema):
"""Snapshot of the in-flight ai-agent deploy (``deploy.sh``), or ``null``.
Written by the host-side deploy script into the data dir and polled by the
backend, which pings a ``deploy`` SSE event on change. Drives the sticky
in-app deploy banner (worktree label + a dot-per-phase timeline)."""
# Worktree / branch label the deploy was kicked off from.
label: Optional[str] = None
status: Literal["running", "done", "failed"]
phaseNum: int
totalPhases: int
# Human description of the current phase.
phase: Optional[str] = None
steps: list[DeployStep]
startedAt: Optional[int] = None
updatedAt: Optional[int] = None
# The conversation that triggered the deploy, for the expanded panel's link.
sessionId: Optional[str] = None
convUrl: Optional[str] = None
convTitle: Optional[str] = None