Files
ai-agent/backend/schemas.py

1394 lines
44 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.
"""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]
# ── activity dashboard ────────────────────────────────────────────────────────
class ActivityDay(Schema):
"""One local calendar day of agent activity (the heatmap's cell)."""
date: str # YYYY-MM-DD, in the caller's timezone
conversations: int # top-level runs started this day (heatmap intensity)
subagents: int # sidechain runs started this day
agents: int # conversations + subagents
activeBins: int # distinct 5-min slots that saw work (× 5 = minutes)
tokens: int
cost: float
class ActivityModel(Schema):
"""Everything one model did, across the whole archive."""
model: str
runs: int # agent runs whose busiest model was this one
conversations: int
subagents: int
activeBins: int # distinct 5-min slots this model worked in
tokens: int
cost: float
class ActivityTotals(Schema):
agents: int
conversations: int
subagents: int
activeBins: int
tokens: int
cost: float
days: int
firstDay: Optional[str] = None
lastDay: Optional[str] = None
class ActivityResponse(Schema):
"""The activity dashboard's whole payload — see ``backend/activity.py``.
``binsByDay`` maps a local ``YYYY-MM-DD`` to that day's occupied histogram
cells as positional ``[slot, modelIndex, agents]`` triples: ``slot`` is the
5-minute slot of the local day (0…``binsPerDay``-1) and ``modelIndex``
indexes ``models``. Empty slots are simply absent.
"""
binMinutes: int
binsPerDay: int
tzOffset: int # the minutes-east-of-UTC the rollup was built for
days: list[ActivityDay]
models: list[ActivityModel]
binsByDay: dict[str, list[list[int]]]
totals: ActivityTotals
# ── skills catalog ────────────────────────────────────────────────────────────
class SkillSummary(Schema):
"""One skill card: what it is on disk + what its use has cost."""
name: str # the invocation name (its dir name)
title: str # frontmatter `name:`, falls back to `name`
description: str
# None for a built-in/plugin skill: nothing of it lives in this repo, so
# there is no file to link into the editor.
dir: Optional[str] = None
path: Optional[str] = None
source: str # "repo" | <project dir-name> | "builtin"
sourceKind: str # "repo" | "project" | "builtin"
files: int
updatedAt: Optional[str] = None
count: int # total invocations across all transcripts
lastUsed: Optional[str] = None
conversations: int # distinct conversations that invoked it
contextTokens: Optional[int] = None
contextCost: Optional[float] = None
estSpend: Optional[float] = None # contextCost × count
sessionCost: float # total spend of the conversations using it
sessionTokens: int
class SkillsTotals(Schema):
skills: int
used: int
invocations: int
contextCost: float
estSpend: float
class SkillsResponse(Schema):
skills: list[SkillSummary]
totals: SkillsTotals
# ── 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
# The French line the desk phone read aloud (notify-done --spoken), kept so
# the UI can replay the push's sound. Absent on pushes that weren't spoken.
spoken: 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
# Conversation hooks that already fired on this session, {hookId: iso}.
# This mapping *is* the once-per-hook-per-session loop guard (hooks.py) —
# the UI renders it as a pill linking to Settings → Hooks.
hooks: Optional[dict[str, str]] = 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
spoken: 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]
# The line the desk phone read aloud, kept so the UI can replay the sound.
spoken: Optional[str] = None
# 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
questions: int = 0
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 PlanQuestionForm(Schema):
"""The plan's open questions, as an ask-form field spec (the ``## Questions``
section's fenced JSON). Answered client-side on the plan page — answers live
in the browser and feed the "implement this plan" prompt."""
title: str
description: str
fields: list[FormField]
class PlanDetail(PlanSummary):
content: str
fileList: list[PlanFile]
stepList: list[PlanStep]
conversations: list[PlanConvRef]
actual: Optional[PlanActual] = None
questionForm: Optional[PlanQuestionForm] = 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
# ── conversation hooks ───────────────────────────────────────────────────────
class HookRun(Schema):
"""One recorded firing of a hook — when it fired, the session it resumed,
and that session's conversation id once its transcript synced."""
at: str
sessionId: str
conversationId: Optional[str] = None
class HookPending(Schema):
"""A queued firing: the conversation is waiting out the hook's delay (or a
precondition that hasn't come true yet — see ``retries``)."""
sessionId: str
conversationId: Optional[str] = None
# When the firing is due (local ISO with offset).
dueAt: Optional[str] = None
# How many times it was postponed (the run was still live, the transcript
# hadn't synced). Capped by hooks.MAX_RETRIES, then skipped.
retries: int = 0
class Hook(Schema):
"""A conversation hook: a follow-up prompt resumed into the conversation
that just finished, ``delaySeconds`` after it does (see hooks.py)."""
id: str
name: str
# The event it fires on. Only "conversation-done" exists in v1.
event: str
# Seconds between the conversation finishing and the follow-up resume.
delaySeconds: int
# Markdown prompt file under .claude/agents/hooks/ — its content (leading
# YAML frontmatter stripped) is the follow-up turn.
promptFile: str
enabled: bool
# Don't follow up sessions a cron job spawned (goal-keeper & friends also
# end with DONE).
skipCron: bool = True
# Don't follow up subagent (sidechain) conversations.
skipSubagents: bool = True
# Skip a conversation whose context exceeds this many *thousand* tokens
# (the resume would re-read it all). 0 = no cap.
maxContextK: int = 0
# The resolved run config from the prompt file's frontmatter. Unset keys
# mean "keep whatever the session already ran with".
runConfig: Optional[CronRunConfig] = None
createdAt: Optional[str] = None
lastStatus: Optional[str] = None
# The stored history: {ISO timestamp: sessionId}.
history: dict[str, str]
# `history` enriched + sorted newest-first for rendering.
runs: list[HookRun]
# Firings queued but not yet run.
pending: list[HookPending]
class HooksResponse(Schema):
hooks: list[Hook]
class HookPromptFile(Schema):
path: str
content: str
exists: bool
class ClaudeHookEntry(Schema):
"""One native Claude Code hook from the workspace's .claude/settings.json,
flattened to a single command."""
# PreToolUse / UserPromptSubmit / Stop / …
event: str
# The tool/name matcher, when the event supports one.
matcher: Optional[str] = None
type: str = "command"
command: str
class ClaudeHooksResponse(Schema):
"""The repo's native hooks — read-only: editing .claude/settings.json from
the container would be host-level code execution."""
path: str
exists: bool
entries: list[ClaudeHookEntry]
# ── 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