diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..464e419 --- /dev/null +++ b/AGENTS.md @@ -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 --stack +castle program add # adopt an existing repo +castle service create --program

--port +castle service deploy && castle service enable +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` (→ +`.`); 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`. diff --git a/app/package.json b/app/package.json index 2e99647..1e27b6b 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 4b6c59d..9b64490 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -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 diff --git a/app/src/components/AgentTerminal.tsx b/app/src/components/AgentTerminal.tsx new file mode 100644 index 0000000..dc78931 --- /dev/null +++ b/app/src/components/AgentTerminal.tsx @@ -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(null) + const [status, setStatus] = useState("connecting") + const [detail, setDetail] = useState("") + 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 ( +

+
+ + terminal: {agent} + + {status} + {detail ? ` · ${detail}` : ""} + + + {/* 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 && ( + + )} +
+
+
+ ) +} diff --git a/app/src/components/AssistantDock.tsx b/app/src/components/AssistantDock.tsx new file mode 100644 index 0000000..ebd43bc --- /dev/null +++ b/app/src/components/AssistantDock.tsx @@ -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(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 && ( + + )} + + {/* Overlay panel — kept mounted (translated off-screen when closed) so the + terminal's WebSocket survives minimize. */} +
+ {/* Header */} +
+ +
+ + {agents?.map((a) => ( + + ))} + {/* 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) && ( + resume: + )} + {agents + ?.filter((a) => a.available && a.can_continue && !a.can_list_sessions) + .map((a) => ( + + ))} +
+
+ {active && ( + + )} + + + +
+
+ + {/* Body */} +
+ {active ? ( + + ) : ( +
+ Pick an agent above to start a session. +
+ )} + + {/* Sessions popover */} + {showSessions && ( +
+
+ Sessions +
+ {sessions && sessions.length > 0 ? ( +
+ {sessions.map((s) => ( +
+ + {s.agent} + {s.id} + + {s.running ? `${s.clients} client${s.clients === 1 ? "" : "s"}` : `exit ${s.exit_code ?? "?"}`} + + + +
+ ))} +
+ ) : ( +
+ No live sessions. +
+ )} + + {/* Agent-native history — past conversations each agent stored for + this directory (from its declared session-list command). */} + {history && history.length > 0 && ( + <> +
+ History +
+
+ {history.map((h) => ( + + ))} +
+ + )} +
+ )} +
+
+ + ) +} + +function formatTime(t: number | string | null): string { + if (t == null) return "" + if (typeof t === "number") return new Date(t).toLocaleString() + return t +} diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index e5d29e6..9c03f6c 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -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() { > + + {/* Global assistant — persists across navigation (Layout doesn't remount). */} +
) } diff --git a/app/src/services/api/client.ts b/app/src/services/api/client.ts index 9e758d0..5df715d 100644 --- a/app/src/services/api/client.ts +++ b/app/src/services/api/client.ts @@ -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.) 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() diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 01bd449..3f282c6 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -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("/agents"), + staleTime: Infinity, + }) +} + +export function useAgentSessions() { + return useQuery({ + queryKey: ["agent-sessions"], + queryFn: () => apiClient.get("/agents/sessions"), + refetchInterval: 5_000, + }) +} + +export function useAgentHistory(enabled: boolean) { + return useQuery({ + queryKey: ["agent-history"], + queryFn: () => apiClient.get("/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() diff --git a/app/src/types/index.ts b/app/src/types/index.ts index e45d62f..3740b91 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -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 diff --git a/castle-api/pyproject.toml b/castle-api/pyproject.toml index 85c5f1d..aff58e3 100644 --- a/castle-api/pyproject.toml +++ b/castle-api/pyproject.toml @@ -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", diff --git a/castle-api/src/castle_api/agent_registry.py b/castle-api/src/castle_api/agent_registry.py new file mode 100644 index 0000000..549b223 --- /dev/null +++ b/castle-api/src/castle_api/agent_registry.py @@ -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] diff --git a/castle-api/src/castle_api/agent_sessions.py b/castle-api/src/castle_api/agent_sessions.py new file mode 100644 index 0000000..101bafb --- /dev/null +++ b/castle-api/src/castle_api/agent_sessions.py @@ -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() diff --git a/castle-api/src/castle_api/agents.py b/castle-api/src/castle_api/agents.py new file mode 100644 index 0000000..806ce90 --- /dev/null +++ b/castle-api/src/castle_api/agents.py @@ -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=` 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. diff --git a/castle-api/src/castle_api/main.py b/castle-api/src/castle_api/main.py index 07b01e1..9487192 100644 --- a/castle-api/src/castle_api/main.py +++ b/castle-api/src/castle_api/main.py @@ -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") diff --git a/castle-api/src/castle_api/pty_session.py b/castle-api/src/castle_api/pty_session.py new file mode 100644 index 0000000..172bf41 --- /dev/null +++ b/castle-api/src/castle_api/pty_session.py @@ -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 diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 3d96892..9bc6eba 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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: diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 65240f1..e92c8fd 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 # --------------------- diff --git a/uv.lock b/uv.lock index 7c913eb..fb1eaf0 100644 --- a/uv.lock +++ b/uv.lock @@ -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"