Adds assistants.

This commit is contained in:
2026-07-01 15:11:45 -07:00
parent 6929bd89d3
commit ba2001df49
18 changed files with 1720 additions and 4 deletions

48
AGENTS.md Normal file
View File

@@ -0,0 +1,48 @@
# AGENTS.md — Castle
You are working in the **Castle** repository — Paul's personal software platform,
a monorepo of independent programs (services, tools, libraries, frontends) managed
by the `castle` CLI.
## What Castle does
Castle turns a source repo into something running on this node. The two halves:
- **programs/** — the software catalog: what software exists (`source`, `stack`,
`build`, `system_dependencies`).
- **deployments/** — how a program is realized here, discriminated on its
**`manager`**: `systemd` (a service, or a job with a `schedule`), `caddy` (a
static frontend), `path` (a CLI on your PATH), or `none` (an external
reference). The human-facing **kind** (service/job/tool/static/reference) is
*derived* from the manager, never stored.
## Using the `castle` CLI
It's resource-first — operations live under the resource they act on:
```bash
castle program create <name> --stack <python-cli|python-fastapi|react-vite>
castle program add <path|git-url> # adopt an existing repo
castle service create <name> --program <p> --port <n>
castle service deploy <name> && castle service enable <name>
castle gateway reload # update reverse-proxy routes
castle deploy && castle start # apply config, then start everything
castle status # unified status
castle list --json # all deployments, machine-readable
```
Expose a service on the network by setting `proxy: true` (→
`<name>.<gateway.domain>`); add `public: true` for the Cloudflare tunnel.
## Read this first
- **@CLAUDE.md** — full architecture, the complete `castle` verb list, registry
model, DNS/TLS, and code-style conventions. This is the authoritative guide.
- `docs/registry.md`, `docs/dns-and-tls.md`, and `docs/stacks/*.md` — deep dives.
**Key principle:** regular programs must never depend on castle. They take
standard config (data dir, port, URLs) via env vars; only castle's own programs
(CLI, gateway, api) know castle internals.
Broader context about this machine and Paul's other projects lives at
`/data/.lakehouse/AGENTS.md`.

View File

@@ -13,6 +13,8 @@
"dependencies": {
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",

37
app/pnpm-lock.yaml generated
View File

@@ -14,6 +14,12 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -427,66 +433,79 @@ packages:
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
@@ -556,24 +575,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.0':
resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.0':
resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.0':
resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.0':
resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==}
@@ -710,6 +733,12 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1050,24 +1079,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -1914,6 +1947,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react"
import { Maximize2, Minimize2 } from "lucide-react"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import "@xterm/xterm/css/xterm.css"
import { apiClient } from "@/services/api/client"
type Status = "connecting" | "connected" | "exited" | "closed" | "error"
interface AgentTerminalProps {
// Agent name to launch, or "terminal" for the plain login shell.
agent: string
// When set, resume this existing session instead of creating a new one.
resumeId?: string
// Fired once the backend confirms the session id (new or resumed).
onSession?: (id: string) => void
// Fill the parent container's height instead of using a fixed height.
fill?: boolean
// "continue": launch the agent with its resume_args (reopen its last convo).
mode?: "continue"
// Resume one of the agent's OWN past sessions by its id (agent-native).
resumeSessionId?: string
}
export function AgentTerminal({
agent,
resumeId,
onSession,
fill,
mode,
resumeSessionId,
}: AgentTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [status, setStatus] = useState<Status>("connecting")
const [detail, setDetail] = useState<string>("")
const [maximized, setMaximized] = useState(false)
useEffect(() => {
const term = new Terminal({
convertEol: false,
cursorBlink: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 13,
theme: { background: "#0a0a0a", foreground: "#d1d5db" },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(containerRef.current!)
try {
fit.fit()
} catch {
/* container not laid out yet */
}
const path = resumeId
? `/agents/${agent}/session?session=${encodeURIComponent(resumeId)}`
: resumeSessionId
? `/agents/${agent}/session?resume_session=${encodeURIComponent(resumeSessionId)}`
: mode === "continue"
? `/agents/${agent}/session?mode=continue`
: `/agents/${agent}/session`
const ws = new WebSocket(apiClient.wsUrl(path))
ws.binaryType = "arraybuffer"
const enc = new TextEncoder()
const sendResize = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }))
}
}
ws.onopen = () => {
setStatus("connected")
sendResize()
// On resume, full-screen TUIs (opencode, claude) don't know a new client
// attached and won't repaint. A size change forces SIGWINCH → a full
// redraw of the current frame, so the prior session becomes visible.
if (resumeId) {
setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) return
ws.send(
JSON.stringify({ type: "resize", cols: Math.max(1, term.cols - 1), rows: term.rows }),
)
setTimeout(sendResize, 60)
}, 80)
}
term.focus()
}
ws.onmessage = (e) => {
if (typeof e.data === "string") {
let msg: { type?: string; id?: string; code?: number; error?: string }
try {
msg = JSON.parse(e.data)
} catch {
return
}
if (msg.type === "session" && msg.id) {
onSession?.(msg.id)
} else if (msg.type === "exit") {
setStatus("exited")
setDetail(`exit ${msg.code ?? "?"}`)
term.write(`\r\n\x1b[90m[process exited: ${msg.code ?? "?"}]\x1b[0m\r\n`)
} else if (msg.type === "error") {
setStatus("error")
setDetail(msg.error ?? "error")
term.write(`\r\n\x1b[31m[error: ${msg.error ?? "unknown"}]\x1b[0m\r\n`)
}
} else {
term.write(new Uint8Array(e.data as ArrayBuffer))
}
}
ws.onclose = () => setStatus((s) => (s === "exited" || s === "error" ? s : "closed"))
ws.onerror = () => setStatus((s) => (s === "connecting" ? "error" : s))
const dataDisp = term.onData((d) => {
if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(d))
})
const ro = new ResizeObserver(() => {
try {
fit.fit()
} catch {
/* ignore */
}
sendResize()
})
if (containerRef.current) ro.observe(containerRef.current)
return () => {
ro.disconnect()
dataDisp.dispose()
ws.close()
term.dispose()
}
// Remounting (new agent / resume target / mode) tears this down and restarts.
}, [agent, resumeId, mode, resumeSessionId, onSession])
const statusColor =
status === "connected"
? "text-green-400"
: status === "connecting"
? "text-amber-400"
: status === "error"
? "text-red-400"
: "text-[var(--muted)]"
const btn =
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
return (
<div
className={
maximized
? "fixed inset-0 z-50 bg-[var(--background)] flex flex-col"
: fill
? "h-full flex flex-col bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
: "bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] text-xs text-[var(--muted)]">
<span className="flex items-center gap-2">
<span>terminal: {agent}</span>
<span className={statusColor}>
{status}
{detail ? ` · ${detail}` : ""}
</span>
</span>
{/* In dock (fill) mode the surrounding panel owns sizing/expansion, so
the inner maximize is hidden (a fixed-inset child can't escape the
panel's transformed containing block anyway). */}
{!fill && (
<button
onClick={() => setMaximized((m) => !m)}
className={btn}
title={maximized ? "Restore" : "Maximize"}
>
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
</div>
<div
ref={containerRef}
className={maximized || fill ? "flex-1 min-h-0 p-2" : "h-[560px] p-2"}
/>
</div>
)
}

View File

@@ -0,0 +1,301 @@
import { useCallback, useState } from "react"
import {
Bot,
ChevronDown,
History,
Maximize2,
Minimize2,
Play,
RotateCw,
Terminal as TerminalIcon,
Trash2,
} from "lucide-react"
import { AgentTerminal } from "@/components/AgentTerminal"
import {
useAgents,
useAgentHistory,
useAgentSessions,
useDeleteAgentSession,
} from "@/services/api/hooks"
import { cn } from "@/lib/utils"
// The reserved name that launches the plain login shell (backend TERMINAL_AGENT).
const SHELL = "terminal"
interface Active {
agent: string
resumeId?: string
mode?: "continue"
resumeSessionId?: string
key: number // bump to force a fresh terminal mount (relaunch / resume)
}
// A global, persistent assistant surface: a floating action button that opens an
// overlay panel. It lives in Layout (which does not remount on navigation) and
// keeps the terminal mounted while minimized, so the agent session and its
// WebSocket survive as you click around the rest of the dashboard.
export function AssistantDock() {
const { data: agents } = useAgents()
const { data: sessions } = useAgentSessions()
const deleteSession = useDeleteAgentSession()
const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [showSessions, setShowSessions] = useState(false)
const [active, setActive] = useState<Active | null>(null)
const [seq, setSeq] = useState(0)
const { data: history } = useAgentHistory(showSessions)
const launch = (
agent: string,
opts?: { resumeId?: string; mode?: "continue"; resumeSessionId?: string },
) => {
const key = seq + 1
setSeq(key)
setActive({
agent,
resumeId: opts?.resumeId,
mode: opts?.mode,
resumeSessionId: opts?.resumeSessionId,
key,
})
setOpen(true)
setShowSessions(false)
}
const relaunch = () => active && launch(active.agent)
const onSession = useCallback(() => {
/* session id confirmed; list auto-refreshes via its 5s poll */
}, [])
const runningCount = sessions?.filter((s) => s.running).length ?? 0
const pill =
"text-xs px-2.5 py-1 rounded-full border transition-colors inline-flex items-center gap-1.5"
const pillIdle =
"border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]"
const iconBtn =
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
return (
<>
{/* FAB — hidden while the panel is open */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-4 right-4 z-40 w-14 h-14 rounded-full bg-[var(--primary)] text-black shadow-lg flex items-center justify-center hover:opacity-90 transition-opacity"
title="Assistant"
aria-label="Open assistant"
>
<Bot size={24} />
{runningCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-green-400 border-2 border-[var(--background)]" />
)}
</button>
)}
{/* Overlay panel — kept mounted (translated off-screen when closed) so the
terminal's WebSocket survives minimize. */}
<div
className={cn(
"fixed z-40 flex flex-col",
expanded
? "inset-4"
: "bottom-4 right-4 w-[760px] max-w-[calc(100vw-2rem)] h-[600px] max-h-[calc(100vh-2rem)]",
"bg-[var(--background)] border border-[var(--border)] rounded-xl shadow-2xl",
"transition-all duration-200",
open
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-[calc(100%+2rem)] pointer-events-none",
)}
aria-hidden={!open}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--border)]">
<Bot size={16} className="text-[var(--primary)] shrink-0" />
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<button
onClick={() => launch(SHELL)}
className={cn(
pill,
active?.agent === SHELL && !active.resumeId
? "border-[var(--primary)] text-[var(--foreground)]"
: pillIdle,
)}
title="Plain login shell in the castle repo"
>
<TerminalIcon size={13} /> Shell
</button>
{agents?.map((a) => (
<button
key={a.name}
onClick={() => a.available && launch(a.name)}
disabled={!a.available}
className={cn(
pill,
!a.available && "opacity-40 cursor-not-allowed",
active?.agent === a.name && !active.resumeId
? "border-[var(--primary)] text-[var(--foreground)]"
: pillIdle,
)}
title={a.available ? a.description ?? a.name : `${a.command} not installed`}
>
<Bot size={13} /> {a.name}
</button>
))}
{/* resume: chip = open the agent's own in-terminal picker. Agents
that expose a machine-readable session list appear in the History
popover instead (no chip needed). */}
{agents?.some((a) => a.available && a.can_continue && !a.can_list_sessions) && (
<span className="text-[10px] text-[var(--muted)] pl-1">resume:</span>
)}
{agents
?.filter((a) => a.available && a.can_continue && !a.can_list_sessions)
.map((a) => (
<button
key={`cont-${a.name}`}
onClick={() => launch(a.name, { mode: "continue" })}
className={cn(pill, pillIdle)}
title={`Open ${a.name}'s session picker in this directory`}
>
<RotateCw size={12} /> {a.name}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{active && (
<button onClick={relaunch} className={iconBtn} title="Restart session">
<RotateCw size={14} />
</button>
)}
<button
onClick={() => setShowSessions((s) => !s)}
className={cn(iconBtn, showSessions && "text-[var(--foreground)] bg-white/10")}
title="Sessions"
>
<History size={15} />
</button>
<button
onClick={() => setExpanded((e) => !e)}
className={iconBtn}
title={expanded ? "Shrink" : "Expand"}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
<button onClick={() => setOpen(false)} className={iconBtn} title="Minimize">
<ChevronDown size={16} />
</button>
</div>
</div>
{/* Body */}
<div className="relative flex-1 min-h-0 p-2">
{active ? (
<AgentTerminal
key={active.key}
agent={active.agent}
resumeId={active.resumeId}
mode={active.mode}
resumeSessionId={active.resumeSessionId}
onSession={onSession}
fill
/>
) : (
<div className="h-full flex items-center justify-center text-sm text-[var(--muted)]">
Pick an agent above to start a session.
</div>
)}
{/* Sessions popover */}
{showSessions && (
<div className="absolute inset-x-2 top-2 max-h-[calc(100%-1rem)] overflow-auto bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl">
<div className="px-3 py-2 text-xs font-semibold border-b border-[var(--border)]">
Sessions
</div>
{sessions && sessions.length > 0 ? (
<div className="divide-y divide-[var(--border)]">
{sessions.map((s) => (
<div key={s.id} className="flex items-center gap-2 px-3 py-2 text-xs">
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
s.running ? "bg-green-400" : "bg-[var(--muted)]",
)}
title={s.running ? "running" : "exited"}
/>
<span className="font-medium">{s.agent}</span>
<span className="text-[var(--muted)] font-mono">{s.id}</span>
<span className="text-[var(--muted)] ml-auto">
{s.running ? `${s.clients} client${s.clients === 1 ? "" : "s"}` : `exit ${s.exit_code ?? "?"}`}
</span>
<button
onClick={() => launch(s.agent, { resumeId: s.id })}
disabled={!s.running}
className={cn(
"p-1 rounded hover:bg-white/10 transition-colors",
s.running
? "text-[var(--muted)] hover:text-[var(--foreground)]"
: "opacity-30 cursor-not-allowed",
)}
title="Resume"
>
<Play size={13} />
</button>
<button
onClick={() => deleteSession.mutate(s.id)}
className="p-1 rounded text-[var(--muted)] hover:text-red-400 hover:bg-white/10 transition-colors"
title="Kill session"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
) : (
<div className="px-3 py-4 text-xs text-[var(--muted)]">
No live sessions.
</div>
)}
{/* Agent-native history — past conversations each agent stored for
this directory (from its declared session-list command). */}
{history && history.length > 0 && (
<>
<div className="px-3 py-2 text-xs font-semibold border-y border-[var(--border)]">
History
</div>
<div className="divide-y divide-[var(--border)]">
{history.map((h) => (
<button
key={`${h.agent}-${h.id}`}
onClick={() =>
launch(h.agent, { resumeSessionId: h.id })
}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-white/5 transition-colors"
title={`Resume in ${h.agent}`}
>
<RotateCw size={12} className="text-[var(--muted)] shrink-0" />
<span className="text-[var(--muted)]">{h.agent}</span>
<span className="truncate">{h.title || h.id}</span>
<span className="text-[var(--muted)] ml-auto shrink-0">
{formatTime(h.time)}
</span>
</button>
))}
</div>
</>
)}
</div>
)}
</div>
</div>
</>
)
}
function formatTime(t: number | string | null): string {
if (t == null) return ""
if (typeof t === "number") return new Date(t).toLocaleString()
return t
}

View File

@@ -18,6 +18,7 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useEventStream } from "@/services/api/hooks"
import { AssistantDock } from "@/components/AssistantDock"
type NavLeaf = { to: string; label: string; icon: LucideIcon; end?: boolean }
type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
@@ -228,6 +229,9 @@ export function Layout() {
>
<Outlet />
</main>
{/* Global assistant — persists across navigation (Layout doesn't remount). */}
<AssistantDock />
</div>
)
}

View File

@@ -76,6 +76,17 @@ class ApiClient {
streamUrl(path: string): string {
return `${this.baseUrl}${path}`
}
// Build a ws(s):// URL for a WebSocket endpoint. Handles both the cross-origin
// base (https://castle-api.<domain>) and the same-origin "/api" base.
wsUrl(path: string): string {
const absolute = this.baseUrl.startsWith("http")
? `${this.baseUrl}${path}`
: `${window.location.origin}${this.baseUrl}${path}`
const url = new URL(absolute)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
return url.toString()
}
}
export const apiClient = new ApiClient()

View File

@@ -17,6 +17,9 @@ import type {
MeshStatus,
NodeSummary,
NodeDetail,
AgentInfo,
AgentSessionInfo,
AgentHistoryEntry,
} from "@/types"
// Compat hook for the /deployments/{name} unified detail endpoint
@@ -208,6 +211,39 @@ export function useMeshStatus() {
})
}
export function useAgents() {
return useQuery({
queryKey: ["agents"],
queryFn: () => apiClient.get<AgentInfo[]>("/agents"),
staleTime: Infinity,
})
}
export function useAgentSessions() {
return useQuery({
queryKey: ["agent-sessions"],
queryFn: () => apiClient.get<AgentSessionInfo[]>("/agents/sessions"),
refetchInterval: 5_000,
})
}
export function useAgentHistory(enabled: boolean) {
return useQuery({
queryKey: ["agent-history"],
queryFn: () => apiClient.get<AgentHistoryEntry[]>("/agents/history"),
enabled,
staleTime: 10_000,
})
}
export function useDeleteAgentSession() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiClient.delete(`/agents/sessions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["agent-sessions"] }),
})
}
export function useEventStream() {
const qc = useQueryClient()

View File

@@ -156,6 +156,38 @@ export interface SSEHealthEvent {
timestamp: number
}
// Agent terminal UX
export interface AgentInfo {
name: string
command: string
available: boolean
cwd: string
description: string | null
can_continue: boolean
can_list_sessions: boolean
}
export interface AgentHistoryEntry {
agent: string
id: string
title: string
time: number | string | null
}
export interface AgentSessionInfo {
id: string
agent: string
command: string
cwd: string
created_at: number
running: boolean
exited: boolean
exit_code: number | null
cols: number | null
rows: number | null
clients: number
}
export interface SSEServiceActionEvent {
action: string
program: string

View File

@@ -5,7 +5,7 @@ description = "Castle API"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115.0",
"uvicorn>=0.34.0",
"uvicorn[standard]>=0.34.0",
"pydantic-settings>=2.0.0",
"httpx>=0.27.0",
"castle-core",

View File

@@ -0,0 +1,234 @@
"""Resolve launchable agents for the dashboard terminal UX.
Config (a `castle.yaml` `agents:` block) declares agents; this layer adds the
runtime view: is the binary present, and what's the absolute cwd. Castle stays
agnostic — it only ever runs `command args` in a pty.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from castle_core.config import USER_TOOL_PATH_DIRS
from castle_api.config import get_castle_root, get_config
# Zero-config fallback so the feature works out of the box. A castle.yaml
# `agents:` block overrides this entirely.
_DEFAULT_AGENTS: dict[str, dict] = {
"claude": {
"command": "claude",
"description": "Anthropic Claude Code",
# Bare --resume pops claude's interactive session-history picker (whereas
# --continue would silently reopen only the most recent conversation).
"resume_args": ["--resume"],
},
"opencode": {
"command": "opencode",
"description": "opencode",
# opencode has no CLI flag to pop a picker, but it lists sessions as JSON
# and resumes by id — so the dashboard renders the picker itself.
"sessions": {
"list_command": ["opencode", "session", "list", "--format", "json"],
"resume": ["--session", "{id}"],
"id_field": "id",
"title_field": "title",
"time_field": "updated",
},
},
"amplifier": {
"command": "amplifier",
"description": "Amplifier",
# `amplifier resume` is a subcommand that interactively selects a past
# session (like claude --resume). (`amplifier continue` = most recent.)
"resume_args": ["resume"],
},
}
# Extra dirs to look in beyond the process PATH — where user CLIs actually live.
# opencode installs to ~/.opencode/bin, which the systemd service PATH omits.
_EXTRA_PATH_DIRS = [
*USER_TOOL_PATH_DIRS,
Path.home() / ".opencode" / "bin",
]
def _augmented_path() -> str:
extra = [str(d) for d in _EXTRA_PATH_DIRS if d.exists()]
current = os.environ.get("PATH", "")
return os.pathsep.join([*extra, current]) if extra else current
@dataclass
class ResolvedAgent:
name: str
command: str
args: list[str] = field(default_factory=list)
cwd: str = ""
env: dict[str, str] = field(default_factory=dict)
description: str | None = None
resume_args: list[str] = field(default_factory=list)
sessions: dict | None = None # SessionsSpec-shaped, or None
resolved_command: str | None = None # absolute path if found on PATH
@property
def available(self) -> bool:
return self.resolved_command is not None
@property
def can_continue(self) -> bool:
return bool(self.resume_args)
@property
def can_list_sessions(self) -> bool:
return bool(self.sessions and self.sessions.get("list_command"))
def launch_env(self) -> dict[str, str]:
"""Env overrides for the pty: augmented PATH so the agent's own tool
lookups (node, etc.) resolve, plus any configured env."""
return {"PATH": _augmented_path(), **self.env}
def info(self) -> dict:
return {
"name": self.name,
"command": self.command,
"available": self.available,
"cwd": self.cwd,
"description": self.description,
"can_continue": self.can_continue,
"can_list_sessions": self.can_list_sessions,
}
def _agent_specs() -> dict[str, dict]:
"""Configured agents (as plain dicts), or the built-in defaults."""
try:
config = get_config()
except FileNotFoundError:
return _DEFAULT_AGENTS
if config.agents:
return {n: s.model_dump(exclude_none=True) for n, s in config.agents.items()}
return _DEFAULT_AGENTS
def default_cwd() -> str:
"""Where agents launch by default: the castle git repo (its CLAUDE.md /
AGENTS.md and `castle` sources), falling back to the config root, then home."""
try:
repo = get_config().repo
if repo:
return str(repo)
except FileNotFoundError:
pass
root = get_castle_root()
return str(root) if root else str(Path.home())
def _resolve(name: str, spec: dict) -> ResolvedAgent:
command = spec["command"]
cwd = spec.get("cwd") or default_cwd()
resolved = shutil.which(command, path=_augmented_path())
return ResolvedAgent(
name=name,
command=command,
args=list(spec.get("args") or []),
cwd=cwd,
env=dict(spec.get("env") or {}),
description=spec.get("description"),
resume_args=list(spec.get("resume_args") or []),
sessions=spec.get("sessions"),
resolved_command=resolved,
)
def list_agents() -> list[ResolvedAgent]:
return [_resolve(name, spec) for name, spec in _agent_specs().items()]
def resolve_agent(name: str) -> ResolvedAgent | None:
specs = _agent_specs()
spec = specs.get(name)
if spec is None:
return None
return _resolve(name, spec)
def _dig(obj: object, path: str) -> object:
"""Read a (possibly dotted) field path off a nested dict, else None."""
cur = obj
for part in path.split("."):
if isinstance(cur, dict):
cur = cur.get(part)
else:
return None
return cur
def resume_argv(agent: ResolvedAgent, session_id: str) -> list[str] | None:
"""Build the argv that resumes a specific past session by id, or None."""
if not agent.sessions:
return None
template = agent.sessions.get("resume") or []
if not template:
return None
base = agent.resolved_command or agent.command
return [base, *(part.replace("{id}", session_id) for part in template)]
async def list_agent_history(agent: ResolvedAgent, limit: int = 40) -> list[dict]:
"""Run the agent's declared list_command and normalize it to
[{id, title, time}] — newest first. Best-effort: any failure yields []."""
if not agent.can_list_sessions:
return []
assert agent.sessions is not None
argv = agent.sessions["list_command"]
env = {**os.environ, "PATH": _augmented_path()}
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
cwd=agent.cwd or None,
env=env,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
except (OSError, asyncio.TimeoutError):
return []
try:
data = json.loads(out.decode() or "[]")
except json.JSONDecodeError:
return []
items = (
data
if isinstance(data, list)
else (data.get("sessions") or data.get("data") or [])
)
id_f = agent.sessions.get("id_field", "id")
title_f = agent.sessions.get("title_field", "title")
time_f = agent.sessions.get("time_field", "updated")
out_rows: list[dict] = []
for item in items:
if not isinstance(item, dict):
continue
sid = _dig(item, id_f)
if not sid:
continue
out_rows.append(
{
"agent": agent.name,
"id": str(sid),
"title": str(_dig(item, title_f) or ""),
"time": _dig(item, time_f),
}
)
# Newest first when the time field is sortable; keep input order otherwise.
try:
out_rows.sort(key=lambda r: r["time"] or 0, reverse=True)
except TypeError:
pass
return out_rows[:limit]

View File

@@ -0,0 +1,169 @@
"""Live agent/terminal session registry.
A session is a running PTY (an agent CLI or a shell) whose lifetime is decoupled
from any WebSocket connection: closing the browser tab detaches, it does not
kill the process. Sessions can be listed, re-attached (resumed) by id, and
explicitly terminated. Each session keeps a bounded scrollback buffer so a
re-attaching client can repaint the terminal.
This is deliberately in-memory and single-node: sessions do not survive a
castle-api restart. That's the right scope for a personal dashboard — for true
cross-restart persistence you'd launch the agent under tmux, which breaks the
"castle just runs a command" agnosticism.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from castle_api.pty_session import PtySession
# Cap the per-session replay buffer. Raw terminal bytes; enough to repaint a
# full-screen TUI and some history without unbounded growth.
_SCROLLBACK_BYTES = 256 * 1024
# Reap sessions that have exited and sat idle longer than this (seconds).
_EXITED_TTL = 3600
@dataclass
class AgentSession:
"""One live PTY plus its scrollback and attached subscribers."""
id: str
agent: str # agent name, or "terminal" for the plain shell
command: str
cwd: str
created_at: float
pty: PtySession | None = None
scrollback: bytearray = field(default_factory=bytearray)
subscribers: set[asyncio.Queue[bytes | None]] = field(default_factory=set)
exited: bool = False
exit_code: int | None = None
exited_at: float | None = None
def _on_output(self, data: bytes) -> None:
self.scrollback.extend(data)
if len(self.scrollback) > _SCROLLBACK_BYTES:
del self.scrollback[: len(self.scrollback) - _SCROLLBACK_BYTES]
for q in list(self.subscribers):
try:
q.put_nowait(data)
except asyncio.QueueFull:
# Slow client: drop it; the WS side will notice on its next send.
self.subscribers.discard(q)
def _on_exit(self) -> None:
self.exited = True
self.exit_code = self.pty.proc.returncode if self.pty else None
self.exited_at = time.time()
for q in list(self.subscribers):
q.put_nowait(None) # EOF sentinel
def subscribe(self) -> asyncio.Queue[bytes | None]:
q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=256)
self.subscribers.add(q)
return q
def unsubscribe(self, q: asyncio.Queue[bytes | None]) -> None:
self.subscribers.discard(q)
@property
def running(self) -> bool:
return not self.exited and self.pty is not None and self.pty.running
def info(self) -> dict:
return {
"id": self.id,
"agent": self.agent,
"command": self.command,
"cwd": self.cwd,
"created_at": self.created_at,
"running": self.running,
"exited": self.exited,
"exit_code": self.exit_code,
"cols": self.pty.cols if self.pty else None,
"rows": self.pty.rows if self.pty else None,
"clients": len(self.subscribers),
}
class SessionManager:
"""In-memory registry of live agent sessions."""
def __init__(self) -> None:
self._sessions: dict[str, AgentSession] = {}
def _reap_exited(self) -> None:
now = time.time()
stale = [
sid
for sid, s in self._sessions.items()
if s.exited and s.exited_at and (now - s.exited_at) > _EXITED_TTL
]
for sid in stale:
self._sessions.pop(sid, None)
async def create(
self,
agent: str,
command: str,
args: list[str] | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
cols: int = 80,
rows: int = 24,
) -> AgentSession:
self._reap_exited()
session = AgentSession(
id=uuid.uuid4().hex[:12],
agent=agent,
command=command,
cwd=str(cwd) if cwd else "",
created_at=time.time(),
)
session.pty = await PtySession.start(
command,
args=args,
cwd=cwd,
env=env,
cols=cols,
rows=rows,
on_output=session._on_output,
on_exit=session._on_exit,
)
self._sessions[session.id] = session
return session
def get(self, session_id: str) -> AgentSession | None:
return self._sessions.get(session_id)
def list(self) -> list[dict]:
self._reap_exited()
return [
s.info()
for s in sorted(
self._sessions.values(), key=lambda s: s.created_at, reverse=True
)
]
async def close(self, session_id: str) -> bool:
session = self._sessions.pop(session_id, None)
if session is None:
return False
if session.pty is not None:
await session.pty.close()
for q in list(session.subscribers):
q.put_nowait(None)
return True
async def close_all(self) -> None:
for sid in list(self._sessions):
await self.close(sid)
# Module-level singleton (mirrors mesh_state / stream subscribers).
manager = SessionManager()

View File

@@ -0,0 +1,260 @@
"""Agent terminal UX — list agents, run/resume them in a pty over WebSocket.
Castle is assistant-agnostic: it launches a configured command (or a shell) in a
real tty and streams raw bytes to an xterm.js terminal in the browser. Sessions
outlive their WebSocket connection, so they can be listed, resumed, and killed.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shlex
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
from starlette.websockets import WebSocketState
from castle_api.agent_registry import (
default_cwd,
list_agent_history,
list_agents,
resolve_agent,
resume_argv,
)
from castle_api.agent_sessions import AgentSession, manager
from castle_api.config import get_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/agents", tags=["agents"])
# The reserved agent name that launches the user's login shell (Option A: plain
# terminal). Not a configured agent — Castle knows nothing about what you run in it.
TERMINAL_AGENT = "terminal"
# --- Origin allowlist (this endpoint is an interactive shell) ---------------
def _origin_allowed(origin: str | None) -> bool:
"""Allow same-node/local and configured-domain origins for the WS handshake.
CORS (`*`) governs XHR, not WebSocket upgrades, so we gate the handshake
here. Non-browser clients (no Origin) and localhost are allowed; browser
origins must match the gateway domain (or `CASTLE_API_TERMINAL_ORIGINS`).
Set `CASTLE_API_ALLOW_ALL_ORIGINS=1` to disable the check entirely.
"""
if os.environ.get("CASTLE_API_ALLOW_ALL_ORIGINS"):
return True
if not origin:
return True # curl/websocat and other non-browser clients
host = (urlparse(origin).hostname or "").lower()
if host in ("localhost", "127.0.0.1", "::1"):
return True
try:
domain = get_config().gateway.domain
except Exception:
domain = None
if domain and (host == domain or host.endswith("." + domain)):
return True
extra = os.environ.get("CASTLE_API_TERMINAL_ORIGINS", "")
allowed = {h.strip().lower() for h in extra.split(",") if h.strip()}
return host in allowed
# --- HTTP: agents + sessions ------------------------------------------------
@router.get("")
def get_agents() -> list[dict]:
"""List launchable agents with availability (binary present) + default cwd."""
return [a.info() for a in list_agents()]
@router.get("/sessions")
def get_sessions() -> list[dict]:
"""List live terminal sessions (running or recently exited)."""
return manager.list()
@router.get("/history")
async def get_history() -> list[dict]:
"""Unified list of agents' own past sessions (from each agent's declared
`sessions.list_command`), newest first. Agents without the capability
(or with none stored) simply contribute nothing."""
agents = [a for a in list_agents() if a.available and a.can_list_sessions]
results = await asyncio.gather(*(list_agent_history(a) for a in agents))
rows = [row for agent_rows in results for row in agent_rows]
try:
rows.sort(key=lambda r: r["time"] or 0, reverse=True)
except TypeError:
pass
return rows
@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str) -> dict:
"""Kill a session's process group and drop it from the registry."""
ok = await manager.close(session_id)
if not ok:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="session not found"
)
return {"status": "closed", "id": session_id}
# --- WebSocket: interactive session ----------------------------------------
def _login_shell() -> str:
return os.environ.get("SHELL") or "/bin/bash"
async def _new_session(
name: str, cont: bool = False, resume_session: str | None = None
) -> AgentSession:
"""Create a session for a named agent, or the reserved login shell.
Everything runs under the user's *interactive login* shell (`-lic`) so it
inherits exactly the environment a real terminal would — the systemd service
env is stripped down and would otherwise miss vars set in shell rc files
(API keys, etc.). Agents are `exec`'d so the pty's foreground process is the
agent itself (clean signals + exit).
``cont`` appends the agent's ``resume_args`` (open its own picker / continue).
``resume_session`` builds the agent's resume-by-id argv for a specific past
session. Either way castle only passes declared flags through — it never
reads the agent's session storage.
"""
shell = _login_shell()
if name == TERMINAL_AGENT:
return await manager.create(
TERMINAL_AGENT, shell, args=["-l"], cwd=default_cwd()
)
agent = resolve_agent(name)
if agent is None:
raise LookupError(f"unknown agent: {name}")
if not agent.available:
raise LookupError(f"agent not installed: {name}")
if resume_session:
argv = resume_argv(agent, resume_session)
if argv is None:
raise LookupError(f"agent cannot resume by id: {name}")
else:
argv = [agent.resolved_command or agent.command, *agent.args]
if cont and agent.resume_args:
argv += agent.resume_args
launch = shlex.join(argv)
return await manager.create(
name,
shell,
args=["-lic", f"exec {launch}"],
cwd=agent.cwd,
env=agent.env, # explicit per-agent overrides; the login shell sets PATH
)
@router.websocket("/{name}/session")
async def agent_session(ws: WebSocket, name: str) -> None:
"""Run (or resume) an agent in a pty; stream it to the browser terminal.
Query param `session=<id>` resumes an existing live session (replaying its
scrollback); otherwise a new session is created for `name` (or the reserved
`terminal` shell). Disconnecting detaches — it does NOT kill the session.
"""
if not _origin_allowed(ws.headers.get("origin")):
await ws.close(code=1008) # policy violation
return
resume_id = ws.query_params.get("session")
# Resolve the target session (before accept only for hard rejects).
session: AgentSession | None = None
if resume_id:
session = manager.get(resume_id)
if session is None or not session.running:
await ws.accept()
await ws.send_json({"type": "error", "error": "session not resumable"})
await ws.close()
return
else:
cont = ws.query_params.get("mode") == "continue"
resume_session = ws.query_params.get("resume_session")
try:
session = await _new_session(name, cont=cont, resume_session=resume_session)
except LookupError as e:
await ws.accept()
await ws.send_json({"type": "error", "error": str(e)})
await ws.close()
return
except Exception:
logger.exception("failed to launch agent %s", name)
await ws.accept()
await ws.send_json({"type": "error", "error": "failed to launch"})
await ws.close()
return
assert session is not None # narrowed by the branches above
await ws.accept()
# Subscribe + snapshot scrollback atomically (no await between → the output
# callback, which runs in this same event loop, cannot interleave), so a
# resumed client gets the buffer once with no duplication or gap.
q = session.subscribe()
snapshot = bytes(session.scrollback)
await ws.send_json(
{
"type": "session",
"id": session.id,
"agent": session.agent,
"resumed": bool(resume_id),
}
)
if snapshot:
await ws.send_bytes(snapshot)
async def pump() -> None:
try:
while True:
chunk = await q.get()
if chunk is None: # child exited
if ws.application_state == WebSocketState.CONNECTED:
await ws.send_json({"type": "exit", "code": session.exit_code})
break
if ws.application_state != WebSocketState.CONNECTED:
break
await ws.send_bytes(chunk)
except Exception:
pass
pump_task = asyncio.create_task(pump())
try:
while True:
msg = await ws.receive()
if msg["type"] == "websocket.disconnect":
break
data = msg.get("bytes")
text = msg.get("text")
if data is not None and session.pty is not None:
session.pty.write(data)
elif text is not None and session.pty is not None:
try:
ctrl = json.loads(text)
except json.JSONDecodeError:
continue
if ctrl.get("type") == "resize":
session.pty.resize(int(ctrl["cols"]), int(ctrl["rows"]))
elif ctrl.get("type") == "input":
session.pty.write(str(ctrl.get("data", "")).encode())
except WebSocketDisconnect:
pass
finally:
pump_task.cancel()
session.unsubscribe(q)
# Intentionally do NOT close the session here — it lives on for resume.
# Sessions are ended via DELETE /agents/sessions/{id}, when the child
# exits, or by idle reaping.

View File

@@ -12,6 +12,8 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse
from castle_api.agent_sessions import manager as agent_session_manager
from castle_api.agents import router as agents_router
from castle_api.config import get_registry, settings
from castle_api.config_editor import router as config_router
from castle_api.deploy_routes import router as deploy_router
@@ -98,6 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if mdns_service:
mdns_service.stop()
await agent_session_manager.close_all()
close_all_subscribers()
@@ -123,6 +126,7 @@ app.include_router(secrets_router)
app.include_router(services_router)
app.include_router(programs_router)
app.include_router(deploy_router)
app.include_router(agents_router)
@app.get("/health")

View File

@@ -0,0 +1,156 @@
"""Run a command inside a pseudo-terminal and stream it over asyncio.
Castle stays assistant-agnostic: it just launches a command in a real TTY and
shunts raw bytes both ways. The command's own TUI (an agent CLI, a shell, …)
renders exactly as it would in a terminal — Castle never parses its output.
The PTY master is a raw OS fd, not an asyncio stream, so we watch it with
``loop.add_reader`` (no extra thread, no busy-poll). Output is delivered to an
``on_output`` callback (a fan-out layer above can broadcast it to any number of
attached clients and buffer scrollback), so a session's lifetime is decoupled
from any one WebSocket connection. Keystroke writes are tiny, so a plain
``os.write`` is fine.
"""
from __future__ import annotations
import asyncio
import fcntl
import os
import pty
import signal
import struct
import termios
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
def _set_winsize(fd: int, rows: int, cols: int) -> None:
"""Apply a terminal window size via TIOCSWINSZ (struct winsize)."""
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
@dataclass
class PtySession:
"""A child process attached to a pty master.
Output is pushed to ``on_output(bytes)`` as it arrives and ``on_exit()`` is
fired once when the child closes the tty. Both run in the event-loop thread.
"""
proc: asyncio.subprocess.Process
master_fd: int
on_output: Callable[[bytes], None] | None = None
on_exit: Callable[[], None] | None = None
cols: int = 80
rows: int = 24
_closed: bool = field(default=False)
@classmethod
async def start(
cls,
command: str,
args: list[str] | None = None,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
cols: int = 80,
rows: int = 24,
on_output: Callable[[bytes], None] | None = None,
on_exit: Callable[[], None] | None = None,
) -> PtySession:
"""Spawn ``command args`` on a fresh pty, sized ``cols`` x ``rows``."""
master_fd, slave_fd = pty.openpty()
# Size the tty before exec so the first TUI render lays out correctly.
_set_winsize(master_fd, rows, cols)
proc_env = {**os.environ, "TERM": "xterm-256color"}
if env:
proc_env.update(env)
proc = await asyncio.create_subprocess_exec(
command,
*(args or []),
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=str(cwd) if cwd else None,
env=proc_env,
start_new_session=True, # own session/group → controlling tty + killpg
close_fds=True,
)
os.close(slave_fd) # parent keeps only the master
os.set_blocking(master_fd, False)
session = cls(proc=proc, master_fd=master_fd, cols=cols, rows=rows)
if on_output is not None:
session.on_output = on_output
if on_exit is not None:
session.on_exit = on_exit
asyncio.get_running_loop().add_reader(master_fd, session._drain_master)
return session
def _drain_master(self) -> None:
"""Reader callback: pull available bytes off the master and fan them out."""
try:
data = os.read(self.master_fd, 65536)
except (BlockingIOError, InterruptedError):
return
except OSError:
data = b"" # slave closed → child gone
if not data:
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except (ValueError, OSError):
pass
if self.on_exit is not None:
self.on_exit()
return
if self.on_output is not None:
self.on_output(data)
def write(self, data: bytes) -> None:
"""Forward keystroke bytes to the child's tty."""
try:
os.write(self.master_fd, data)
except OSError:
pass
def resize(self, cols: int, rows: int) -> None:
"""Resize the tty (browser terminal changed dimensions)."""
self.cols, self.rows = cols, rows
try:
_set_winsize(self.master_fd, rows, cols)
except OSError:
pass
@property
def running(self) -> bool:
return self.proc.returncode is None
async def close(self) -> None:
"""Kill the whole process group and release the master fd (idempotent)."""
if self._closed:
return
self._closed = True
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except (ValueError, OSError):
pass
if self.proc.returncode is None:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(self.proc.wait(), timeout=3)
except asyncio.TimeoutError:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
try:
os.close(self.master_fd)
except OSError:
pass

View File

@@ -4,13 +4,14 @@ from __future__ import annotations
import os
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from pydantic import BaseModel, TypeAdapter
from castle_core.manifest import (
AgentSpec,
DeploymentSpec,
ProgramSpec,
kind_for,
@@ -142,6 +143,9 @@ class CastleConfig:
# The one deployment concept (manager-discriminated). service/job/tool/static/
# reference are *derived views* over this, filtered by kind_for — see below.
deployments: dict[str, DeploymentSpec]
# Launchable agent CLIs for the dashboard terminal UX (assistant-agnostic).
# Optional; empty means the API falls back to a built-in default set.
agents: dict[str, AgentSpec] = field(default_factory=dict)
def deployments_of(self, name: str) -> list[tuple[str, str]]:
"""A program's deployments as (deployment-name, kind) pairs, name-sorted.
@@ -364,12 +368,18 @@ def load_config(root: Path | None = None) -> CastleConfig:
name: _parse_deployment(name, dep_data) for name, dep_data in raw.items()
}
agents: dict[str, AgentSpec] = {
name: AgentSpec.model_validate(spec or {})
for name, spec in (data.get("agents") or {}).items()
}
config = CastleConfig(
root=root,
repo=repo_path,
gateway=gateway,
programs=programs,
deployments=deployments,
agents=agents,
)
return config
@@ -495,6 +505,11 @@ def save_config(config: CastleConfig) -> None:
data: dict = {"gateway": gateway_data}
if config.repo:
data["repo"] = str(config.repo)
if config.agents:
data["agents"] = {
n: s.model_dump(exclude_none=True, exclude_defaults=True)
for n, s in config.agents.items()
}
config_path = config.root / "castle.yaml"
with open(config_path, "w") as f:

View File

@@ -197,6 +197,52 @@ class DefaultsSpec(BaseModel):
env: EnvMap = Field(default_factory=dict)
# ---------------------
# Agent spec — a launchable agent CLI (assistant-agnostic)
# ---------------------
class SessionsSpec(BaseModel):
"""Declarative session-history capability for an agent (optional).
Lets the dashboard show a unified picker of an agent's *own* past sessions
without castle knowing anything agent-specific in code: it runs
``list_command`` (which must print a JSON array of session objects), reads
three named fields off each object, and launches ``command`` + ``resume``
(with ``{id}`` substituted) to reopen one. Field names default to opencode's
shape and are overridable per agent (dotted paths allowed, e.g.
``config_summary.session_id``).
"""
list_command: list[str] # argv → JSON array of session objects
resume: list[str] # appended to the agent command; "{id}" is substituted
id_field: str = "id"
title_field: str = "title"
time_field: str = "updated"
class AgentSpec(BaseModel):
"""A launchable agent CLI for the dashboard's terminal UX.
Castle just runs ``command args`` inside a pty at ``cwd``; it never parses
the agent's output, so any interactive CLI works. This block only names the
launch — live sessions (list/resume/kill) are a runtime concern, not config.
"""
command: str
args: list[str] = Field(default_factory=list)
description: str | None = None
cwd: str | None = None # defaults to the castle repo root when unset
env: EnvMap = Field(default_factory=dict)
# Extra args that open the agent's own session browser / continue its last
# conversation (e.g. ["--resume"] or ["--continue"]). Optional and
# agent-specific — castle just passes them through. Empty = no such affordance.
resume_args: list[str] = Field(default_factory=list)
# Optional: declares how to list + resume the agent's own sessions, so the
# dashboard can render a unified session picker (see SessionsSpec).
sessions: SessionsSpec | None = None
# ---------------------
# Program spec — software identity
# ---------------------

178
uv.lock generated
View File

@@ -50,7 +50,7 @@ dependencies = [
{ name = "httpx" },
{ name = "paho-mqtt" },
{ name = "pydantic-settings" },
{ name = "uvicorn" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "zeroconf" },
]
@@ -69,7 +69,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "paho-mqtt", specifier = ">=2.0.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "uvicorn", specifier = ">=0.34.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
{ name = "zeroconf", specifier = ">=0.131.0" },
]
@@ -214,6 +214,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -604,6 +633,151 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zeroconf"
version = "0.148.0"