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>
1200 lines
36 KiB
Python
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
|