Adds assistants.
This commit is contained in:
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# AGENTS.md — Castle
|
||||
|
||||
You are working in the **Castle** repository — Paul's personal software platform,
|
||||
a monorepo of independent programs (services, tools, libraries, frontends) managed
|
||||
by the `castle` CLI.
|
||||
|
||||
## What Castle does
|
||||
|
||||
Castle turns a source repo into something running on this node. The two halves:
|
||||
|
||||
- **programs/** — the software catalog: what software exists (`source`, `stack`,
|
||||
`build`, `system_dependencies`).
|
||||
- **deployments/** — how a program is realized here, discriminated on its
|
||||
**`manager`**: `systemd` (a service, or a job with a `schedule`), `caddy` (a
|
||||
static frontend), `path` (a CLI on your PATH), or `none` (an external
|
||||
reference). The human-facing **kind** (service/job/tool/static/reference) is
|
||||
*derived* from the manager, never stored.
|
||||
|
||||
## Using the `castle` CLI
|
||||
|
||||
It's resource-first — operations live under the resource they act on:
|
||||
|
||||
```bash
|
||||
castle program create <name> --stack <python-cli|python-fastapi|react-vite>
|
||||
castle program add <path|git-url> # adopt an existing repo
|
||||
castle service create <name> --program <p> --port <n>
|
||||
castle service deploy <name> && castle service enable <name>
|
||||
castle gateway reload # update reverse-proxy routes
|
||||
castle deploy && castle start # apply config, then start everything
|
||||
castle status # unified status
|
||||
castle list --json # all deployments, machine-readable
|
||||
```
|
||||
|
||||
Expose a service on the network by setting `proxy: true` (→
|
||||
`<name>.<gateway.domain>`); add `public: true` for the Cloudflare tunnel.
|
||||
|
||||
## Read this first
|
||||
|
||||
- **@CLAUDE.md** — full architecture, the complete `castle` verb list, registry
|
||||
model, DNS/TLS, and code-style conventions. This is the authoritative guide.
|
||||
- `docs/registry.md`, `docs/dns-and-tls.md`, and `docs/stacks/*.md` — deep dives.
|
||||
|
||||
**Key principle:** regular programs must never depend on castle. They take
|
||||
standard config (data dir, port, URLs) via env vars; only castle's own programs
|
||||
(CLI, gateway, api) know castle internals.
|
||||
|
||||
Broader context about this machine and Paul's other projects lives at
|
||||
`/data/.lakehouse/AGENTS.md`.
|
||||
@@ -13,6 +13,8 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
37
app/pnpm-lock.yaml
generated
37
app/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
187
app/src/components/AgentTerminal.tsx
Normal file
187
app/src/components/AgentTerminal.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Maximize2, Minimize2 } from "lucide-react"
|
||||
import { Terminal } from "@xterm/xterm"
|
||||
import { FitAddon } from "@xterm/addon-fit"
|
||||
import "@xterm/xterm/css/xterm.css"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
|
||||
type Status = "connecting" | "connected" | "exited" | "closed" | "error"
|
||||
|
||||
interface AgentTerminalProps {
|
||||
// Agent name to launch, or "terminal" for the plain login shell.
|
||||
agent: string
|
||||
// When set, resume this existing session instead of creating a new one.
|
||||
resumeId?: string
|
||||
// Fired once the backend confirms the session id (new or resumed).
|
||||
onSession?: (id: string) => void
|
||||
// Fill the parent container's height instead of using a fixed height.
|
||||
fill?: boolean
|
||||
// "continue": launch the agent with its resume_args (reopen its last convo).
|
||||
mode?: "continue"
|
||||
// Resume one of the agent's OWN past sessions by its id (agent-native).
|
||||
resumeSessionId?: string
|
||||
}
|
||||
|
||||
export function AgentTerminal({
|
||||
agent,
|
||||
resumeId,
|
||||
onSession,
|
||||
fill,
|
||||
mode,
|
||||
resumeSessionId,
|
||||
}: AgentTerminalProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [status, setStatus] = useState<Status>("connecting")
|
||||
const [detail, setDetail] = useState<string>("")
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: 13,
|
||||
theme: { background: "#0a0a0a", foreground: "#d1d5db" },
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(containerRef.current!)
|
||||
try {
|
||||
fit.fit()
|
||||
} catch {
|
||||
/* container not laid out yet */
|
||||
}
|
||||
|
||||
const path = resumeId
|
||||
? `/agents/${agent}/session?session=${encodeURIComponent(resumeId)}`
|
||||
: resumeSessionId
|
||||
? `/agents/${agent}/session?resume_session=${encodeURIComponent(resumeSessionId)}`
|
||||
: mode === "continue"
|
||||
? `/agents/${agent}/session?mode=continue`
|
||||
: `/agents/${agent}/session`
|
||||
const ws = new WebSocket(apiClient.wsUrl(path))
|
||||
ws.binaryType = "arraybuffer"
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const sendResize = () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected")
|
||||
sendResize()
|
||||
// On resume, full-screen TUIs (opencode, claude) don't know a new client
|
||||
// attached and won't repaint. A size change forces SIGWINCH → a full
|
||||
// redraw of the current frame, so the prior session becomes visible.
|
||||
if (resumeId) {
|
||||
setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(
|
||||
JSON.stringify({ type: "resize", cols: Math.max(1, term.cols - 1), rows: term.rows }),
|
||||
)
|
||||
setTimeout(sendResize, 60)
|
||||
}, 80)
|
||||
}
|
||||
term.focus()
|
||||
}
|
||||
ws.onmessage = (e) => {
|
||||
if (typeof e.data === "string") {
|
||||
let msg: { type?: string; id?: string; code?: number; error?: string }
|
||||
try {
|
||||
msg = JSON.parse(e.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (msg.type === "session" && msg.id) {
|
||||
onSession?.(msg.id)
|
||||
} else if (msg.type === "exit") {
|
||||
setStatus("exited")
|
||||
setDetail(`exit ${msg.code ?? "?"}`)
|
||||
term.write(`\r\n\x1b[90m[process exited: ${msg.code ?? "?"}]\x1b[0m\r\n`)
|
||||
} else if (msg.type === "error") {
|
||||
setStatus("error")
|
||||
setDetail(msg.error ?? "error")
|
||||
term.write(`\r\n\x1b[31m[error: ${msg.error ?? "unknown"}]\x1b[0m\r\n`)
|
||||
}
|
||||
} else {
|
||||
term.write(new Uint8Array(e.data as ArrayBuffer))
|
||||
}
|
||||
}
|
||||
ws.onclose = () => setStatus((s) => (s === "exited" || s === "error" ? s : "closed"))
|
||||
ws.onerror = () => setStatus((s) => (s === "connecting" ? "error" : s))
|
||||
|
||||
const dataDisp = term.onData((d) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(d))
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
sendResize()
|
||||
})
|
||||
if (containerRef.current) ro.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
dataDisp.dispose()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
}
|
||||
// Remounting (new agent / resume target / mode) tears this down and restarts.
|
||||
}, [agent, resumeId, mode, resumeSessionId, onSession])
|
||||
|
||||
const statusColor =
|
||||
status === "connected"
|
||||
? "text-green-400"
|
||||
: status === "connecting"
|
||||
? "text-amber-400"
|
||||
: status === "error"
|
||||
? "text-red-400"
|
||||
: "text-[var(--muted)]"
|
||||
|
||||
const btn =
|
||||
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
maximized
|
||||
? "fixed inset-0 z-50 bg-[var(--background)] flex flex-col"
|
||||
: fill
|
||||
? "h-full flex flex-col bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
|
||||
: "bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] text-xs text-[var(--muted)]">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>terminal: {agent}</span>
|
||||
<span className={statusColor}>
|
||||
{status}
|
||||
{detail ? ` · ${detail}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{/* In dock (fill) mode the surrounding panel owns sizing/expansion, so
|
||||
the inner maximize is hidden (a fixed-inset child can't escape the
|
||||
panel's transformed containing block anyway). */}
|
||||
{!fill && (
|
||||
<button
|
||||
onClick={() => setMaximized((m) => !m)}
|
||||
className={btn}
|
||||
title={maximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={maximized || fill ? "flex-1 min-h-0 p-2" : "h-[560px] p-2"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
301
app/src/components/AssistantDock.tsx
Normal file
301
app/src/components/AssistantDock.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
History,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Play,
|
||||
RotateCw,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { AgentTerminal } from "@/components/AgentTerminal"
|
||||
import {
|
||||
useAgents,
|
||||
useAgentHistory,
|
||||
useAgentSessions,
|
||||
useDeleteAgentSession,
|
||||
} from "@/services/api/hooks"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// The reserved name that launches the plain login shell (backend TERMINAL_AGENT).
|
||||
const SHELL = "terminal"
|
||||
|
||||
interface Active {
|
||||
agent: string
|
||||
resumeId?: string
|
||||
mode?: "continue"
|
||||
resumeSessionId?: string
|
||||
key: number // bump to force a fresh terminal mount (relaunch / resume)
|
||||
}
|
||||
|
||||
// A global, persistent assistant surface: a floating action button that opens an
|
||||
// overlay panel. It lives in Layout (which does not remount on navigation) and
|
||||
// keeps the terminal mounted while minimized, so the agent session and its
|
||||
// WebSocket survive as you click around the rest of the dashboard.
|
||||
export function AssistantDock() {
|
||||
const { data: agents } = useAgents()
|
||||
const { data: sessions } = useAgentSessions()
|
||||
const deleteSession = useDeleteAgentSession()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [showSessions, setShowSessions] = useState(false)
|
||||
const [active, setActive] = useState<Active | null>(null)
|
||||
const [seq, setSeq] = useState(0)
|
||||
|
||||
const { data: history } = useAgentHistory(showSessions)
|
||||
|
||||
const launch = (
|
||||
agent: string,
|
||||
opts?: { resumeId?: string; mode?: "continue"; resumeSessionId?: string },
|
||||
) => {
|
||||
const key = seq + 1
|
||||
setSeq(key)
|
||||
setActive({
|
||||
agent,
|
||||
resumeId: opts?.resumeId,
|
||||
mode: opts?.mode,
|
||||
resumeSessionId: opts?.resumeSessionId,
|
||||
key,
|
||||
})
|
||||
setOpen(true)
|
||||
setShowSessions(false)
|
||||
}
|
||||
const relaunch = () => active && launch(active.agent)
|
||||
|
||||
const onSession = useCallback(() => {
|
||||
/* session id confirmed; list auto-refreshes via its 5s poll */
|
||||
}, [])
|
||||
|
||||
const runningCount = sessions?.filter((s) => s.running).length ?? 0
|
||||
|
||||
const pill =
|
||||
"text-xs px-2.5 py-1 rounded-full border transition-colors inline-flex items-center gap-1.5"
|
||||
const pillIdle =
|
||||
"border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]"
|
||||
const iconBtn =
|
||||
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB — hidden while the panel is open */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-4 right-4 z-40 w-14 h-14 rounded-full bg-[var(--primary)] text-black shadow-lg flex items-center justify-center hover:opacity-90 transition-opacity"
|
||||
title="Assistant"
|
||||
aria-label="Open assistant"
|
||||
>
|
||||
<Bot size={24} />
|
||||
{runningCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-green-400 border-2 border-[var(--background)]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Overlay panel — kept mounted (translated off-screen when closed) so the
|
||||
terminal's WebSocket survives minimize. */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-40 flex flex-col",
|
||||
expanded
|
||||
? "inset-4"
|
||||
: "bottom-4 right-4 w-[760px] max-w-[calc(100vw-2rem)] h-[600px] max-h-[calc(100vh-2rem)]",
|
||||
"bg-[var(--background)] border border-[var(--border)] rounded-xl shadow-2xl",
|
||||
"transition-all duration-200",
|
||||
open
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-[calc(100%+2rem)] pointer-events-none",
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--border)]">
|
||||
<Bot size={16} className="text-[var(--primary)] shrink-0" />
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
onClick={() => launch(SHELL)}
|
||||
className={cn(
|
||||
pill,
|
||||
active?.agent === SHELL && !active.resumeId
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: pillIdle,
|
||||
)}
|
||||
title="Plain login shell in the castle repo"
|
||||
>
|
||||
<TerminalIcon size={13} /> Shell
|
||||
</button>
|
||||
{agents?.map((a) => (
|
||||
<button
|
||||
key={a.name}
|
||||
onClick={() => a.available && launch(a.name)}
|
||||
disabled={!a.available}
|
||||
className={cn(
|
||||
pill,
|
||||
!a.available && "opacity-40 cursor-not-allowed",
|
||||
active?.agent === a.name && !active.resumeId
|
||||
? "border-[var(--primary)] text-[var(--foreground)]"
|
||||
: pillIdle,
|
||||
)}
|
||||
title={a.available ? a.description ?? a.name : `${a.command} not installed`}
|
||||
>
|
||||
<Bot size={13} /> {a.name}
|
||||
</button>
|
||||
))}
|
||||
{/* resume: chip = open the agent's own in-terminal picker. Agents
|
||||
that expose a machine-readable session list appear in the History
|
||||
popover instead (no chip needed). */}
|
||||
{agents?.some((a) => a.available && a.can_continue && !a.can_list_sessions) && (
|
||||
<span className="text-[10px] text-[var(--muted)] pl-1">resume:</span>
|
||||
)}
|
||||
{agents
|
||||
?.filter((a) => a.available && a.can_continue && !a.can_list_sessions)
|
||||
.map((a) => (
|
||||
<button
|
||||
key={`cont-${a.name}`}
|
||||
onClick={() => launch(a.name, { mode: "continue" })}
|
||||
className={cn(pill, pillIdle)}
|
||||
title={`Open ${a.name}'s session picker in this directory`}
|
||||
>
|
||||
<RotateCw size={12} /> {a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-0.5 shrink-0">
|
||||
{active && (
|
||||
<button onClick={relaunch} className={iconBtn} title="Restart session">
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowSessions((s) => !s)}
|
||||
className={cn(iconBtn, showSessions && "text-[var(--foreground)] bg-white/10")}
|
||||
title="Sessions"
|
||||
>
|
||||
<History size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className={iconBtn}
|
||||
title={expanded ? "Shrink" : "Expand"}
|
||||
>
|
||||
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)} className={iconBtn} title="Minimize">
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="relative flex-1 min-h-0 p-2">
|
||||
{active ? (
|
||||
<AgentTerminal
|
||||
key={active.key}
|
||||
agent={active.agent}
|
||||
resumeId={active.resumeId}
|
||||
mode={active.mode}
|
||||
resumeSessionId={active.resumeSessionId}
|
||||
onSession={onSession}
|
||||
fill
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm text-[var(--muted)]">
|
||||
Pick an agent above to start a session.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions popover */}
|
||||
{showSessions && (
|
||||
<div className="absolute inset-x-2 top-2 max-h-[calc(100%-1rem)] overflow-auto bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl">
|
||||
<div className="px-3 py-2 text-xs font-semibold border-b border-[var(--border)]">
|
||||
Sessions
|
||||
</div>
|
||||
{sessions && sessions.length > 0 ? (
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2 px-3 py-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
s.running ? "bg-green-400" : "bg-[var(--muted)]",
|
||||
)}
|
||||
title={s.running ? "running" : "exited"}
|
||||
/>
|
||||
<span className="font-medium">{s.agent}</span>
|
||||
<span className="text-[var(--muted)] font-mono">{s.id}</span>
|
||||
<span className="text-[var(--muted)] ml-auto">
|
||||
{s.running ? `${s.clients} client${s.clients === 1 ? "" : "s"}` : `exit ${s.exit_code ?? "?"}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => launch(s.agent, { resumeId: s.id })}
|
||||
disabled={!s.running}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-white/10 transition-colors",
|
||||
s.running
|
||||
? "text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
: "opacity-30 cursor-not-allowed",
|
||||
)}
|
||||
title="Resume"
|
||||
>
|
||||
<Play size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSession.mutate(s.id)}
|
||||
className="p-1 rounded text-[var(--muted)] hover:text-red-400 hover:bg-white/10 transition-colors"
|
||||
title="Kill session"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-4 text-xs text-[var(--muted)]">
|
||||
No live sessions.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent-native history — past conversations each agent stored for
|
||||
this directory (from its declared session-list command). */}
|
||||
{history && history.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-2 text-xs font-semibold border-y border-[var(--border)]">
|
||||
History
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border)]">
|
||||
{history.map((h) => (
|
||||
<button
|
||||
key={`${h.agent}-${h.id}`}
|
||||
onClick={() =>
|
||||
launch(h.agent, { resumeSessionId: h.id })
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-white/5 transition-colors"
|
||||
title={`Resume in ${h.agent}`}
|
||||
>
|
||||
<RotateCw size={12} className="text-[var(--muted)] shrink-0" />
|
||||
<span className="text-[var(--muted)]">{h.agent}</span>
|
||||
<span className="truncate">{h.title || h.id}</span>
|
||||
<span className="text-[var(--muted)] ml-auto shrink-0">
|
||||
{formatTime(h.time)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(t: number | string | null): string {
|
||||
if (t == null) return ""
|
||||
if (typeof t === "number") return new Date(t).toLocaleString()
|
||||
return t
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEventStream } from "@/services/api/hooks"
|
||||
import { AssistantDock } from "@/components/AssistantDock"
|
||||
|
||||
type NavLeaf = { to: string; label: string; icon: LucideIcon; end?: boolean }
|
||||
type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
|
||||
@@ -228,6 +229,9 @@ export function Layout() {
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Global assistant — persists across navigation (Layout doesn't remount). */}
|
||||
<AssistantDock />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,17 @@ class ApiClient {
|
||||
streamUrl(path: string): string {
|
||||
return `${this.baseUrl}${path}`
|
||||
}
|
||||
|
||||
// Build a ws(s):// URL for a WebSocket endpoint. Handles both the cross-origin
|
||||
// base (https://castle-api.<domain>) and the same-origin "/api" base.
|
||||
wsUrl(path: string): string {
|
||||
const absolute = this.baseUrl.startsWith("http")
|
||||
? `${this.baseUrl}${path}`
|
||||
: `${window.location.origin}${this.baseUrl}${path}`
|
||||
const url = new URL(absolute)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
return url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
|
||||
@@ -17,6 +17,9 @@ import type {
|
||||
MeshStatus,
|
||||
NodeSummary,
|
||||
NodeDetail,
|
||||
AgentInfo,
|
||||
AgentSessionInfo,
|
||||
AgentHistoryEntry,
|
||||
} from "@/types"
|
||||
|
||||
// Compat hook for the /deployments/{name} unified detail endpoint
|
||||
@@ -208,6 +211,39 @@ export function useMeshStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => apiClient.get<AgentInfo[]>("/agents"),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentSessions() {
|
||||
return useQuery({
|
||||
queryKey: ["agent-sessions"],
|
||||
queryFn: () => apiClient.get<AgentSessionInfo[]>("/agents/sessions"),
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentHistory(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["agent-history"],
|
||||
queryFn: () => apiClient.get<AgentHistoryEntry[]>("/agents/history"),
|
||||
enabled,
|
||||
staleTime: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAgentSession() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/agents/sessions/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["agent-sessions"] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useEventStream() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
234
castle-api/src/castle_api/agent_registry.py
Normal file
234
castle-api/src/castle_api/agent_registry.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Resolve launchable agents for the dashboard terminal UX.
|
||||
|
||||
Config (a `castle.yaml` `agents:` block) declares agents; this layer adds the
|
||||
runtime view: is the binary present, and what's the absolute cwd. Castle stays
|
||||
agnostic — it only ever runs `command args` in a pty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import USER_TOOL_PATH_DIRS
|
||||
|
||||
from castle_api.config import get_castle_root, get_config
|
||||
|
||||
# Zero-config fallback so the feature works out of the box. A castle.yaml
|
||||
# `agents:` block overrides this entirely.
|
||||
_DEFAULT_AGENTS: dict[str, dict] = {
|
||||
"claude": {
|
||||
"command": "claude",
|
||||
"description": "Anthropic Claude Code",
|
||||
# Bare --resume pops claude's interactive session-history picker (whereas
|
||||
# --continue would silently reopen only the most recent conversation).
|
||||
"resume_args": ["--resume"],
|
||||
},
|
||||
"opencode": {
|
||||
"command": "opencode",
|
||||
"description": "opencode",
|
||||
# opencode has no CLI flag to pop a picker, but it lists sessions as JSON
|
||||
# and resumes by id — so the dashboard renders the picker itself.
|
||||
"sessions": {
|
||||
"list_command": ["opencode", "session", "list", "--format", "json"],
|
||||
"resume": ["--session", "{id}"],
|
||||
"id_field": "id",
|
||||
"title_field": "title",
|
||||
"time_field": "updated",
|
||||
},
|
||||
},
|
||||
"amplifier": {
|
||||
"command": "amplifier",
|
||||
"description": "Amplifier",
|
||||
# `amplifier resume` is a subcommand that interactively selects a past
|
||||
# session (like claude --resume). (`amplifier continue` = most recent.)
|
||||
"resume_args": ["resume"],
|
||||
},
|
||||
}
|
||||
|
||||
# Extra dirs to look in beyond the process PATH — where user CLIs actually live.
|
||||
# opencode installs to ~/.opencode/bin, which the systemd service PATH omits.
|
||||
_EXTRA_PATH_DIRS = [
|
||||
*USER_TOOL_PATH_DIRS,
|
||||
Path.home() / ".opencode" / "bin",
|
||||
]
|
||||
|
||||
|
||||
def _augmented_path() -> str:
|
||||
extra = [str(d) for d in _EXTRA_PATH_DIRS if d.exists()]
|
||||
current = os.environ.get("PATH", "")
|
||||
return os.pathsep.join([*extra, current]) if extra else current
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedAgent:
|
||||
name: str
|
||||
command: str
|
||||
args: list[str] = field(default_factory=list)
|
||||
cwd: str = ""
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
description: str | None = None
|
||||
resume_args: list[str] = field(default_factory=list)
|
||||
sessions: dict | None = None # SessionsSpec-shaped, or None
|
||||
resolved_command: str | None = None # absolute path if found on PATH
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.resolved_command is not None
|
||||
|
||||
@property
|
||||
def can_continue(self) -> bool:
|
||||
return bool(self.resume_args)
|
||||
|
||||
@property
|
||||
def can_list_sessions(self) -> bool:
|
||||
return bool(self.sessions and self.sessions.get("list_command"))
|
||||
|
||||
def launch_env(self) -> dict[str, str]:
|
||||
"""Env overrides for the pty: augmented PATH so the agent's own tool
|
||||
lookups (node, etc.) resolve, plus any configured env."""
|
||||
return {"PATH": _augmented_path(), **self.env}
|
||||
|
||||
def info(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"command": self.command,
|
||||
"available": self.available,
|
||||
"cwd": self.cwd,
|
||||
"description": self.description,
|
||||
"can_continue": self.can_continue,
|
||||
"can_list_sessions": self.can_list_sessions,
|
||||
}
|
||||
|
||||
|
||||
def _agent_specs() -> dict[str, dict]:
|
||||
"""Configured agents (as plain dicts), or the built-in defaults."""
|
||||
try:
|
||||
config = get_config()
|
||||
except FileNotFoundError:
|
||||
return _DEFAULT_AGENTS
|
||||
if config.agents:
|
||||
return {n: s.model_dump(exclude_none=True) for n, s in config.agents.items()}
|
||||
return _DEFAULT_AGENTS
|
||||
|
||||
|
||||
def default_cwd() -> str:
|
||||
"""Where agents launch by default: the castle git repo (its CLAUDE.md /
|
||||
AGENTS.md and `castle` sources), falling back to the config root, then home."""
|
||||
try:
|
||||
repo = get_config().repo
|
||||
if repo:
|
||||
return str(repo)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
root = get_castle_root()
|
||||
return str(root) if root else str(Path.home())
|
||||
|
||||
|
||||
def _resolve(name: str, spec: dict) -> ResolvedAgent:
|
||||
command = spec["command"]
|
||||
cwd = spec.get("cwd") or default_cwd()
|
||||
resolved = shutil.which(command, path=_augmented_path())
|
||||
return ResolvedAgent(
|
||||
name=name,
|
||||
command=command,
|
||||
args=list(spec.get("args") or []),
|
||||
cwd=cwd,
|
||||
env=dict(spec.get("env") or {}),
|
||||
description=spec.get("description"),
|
||||
resume_args=list(spec.get("resume_args") or []),
|
||||
sessions=spec.get("sessions"),
|
||||
resolved_command=resolved,
|
||||
)
|
||||
|
||||
|
||||
def list_agents() -> list[ResolvedAgent]:
|
||||
return [_resolve(name, spec) for name, spec in _agent_specs().items()]
|
||||
|
||||
|
||||
def resolve_agent(name: str) -> ResolvedAgent | None:
|
||||
specs = _agent_specs()
|
||||
spec = specs.get(name)
|
||||
if spec is None:
|
||||
return None
|
||||
return _resolve(name, spec)
|
||||
|
||||
|
||||
def _dig(obj: object, path: str) -> object:
|
||||
"""Read a (possibly dotted) field path off a nested dict, else None."""
|
||||
cur = obj
|
||||
for part in path.split("."):
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(part)
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
def resume_argv(agent: ResolvedAgent, session_id: str) -> list[str] | None:
|
||||
"""Build the argv that resumes a specific past session by id, or None."""
|
||||
if not agent.sessions:
|
||||
return None
|
||||
template = agent.sessions.get("resume") or []
|
||||
if not template:
|
||||
return None
|
||||
base = agent.resolved_command or agent.command
|
||||
return [base, *(part.replace("{id}", session_id) for part in template)]
|
||||
|
||||
|
||||
async def list_agent_history(agent: ResolvedAgent, limit: int = 40) -> list[dict]:
|
||||
"""Run the agent's declared list_command and normalize it to
|
||||
[{id, title, time}] — newest first. Best-effort: any failure yields []."""
|
||||
if not agent.can_list_sessions:
|
||||
return []
|
||||
assert agent.sessions is not None
|
||||
argv = agent.sessions["list_command"]
|
||||
env = {**os.environ, "PATH": _augmented_path()}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
cwd=agent.cwd or None,
|
||||
env=env,
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
||||
except (OSError, asyncio.TimeoutError):
|
||||
return []
|
||||
try:
|
||||
data = json.loads(out.decode() or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
items = (
|
||||
data
|
||||
if isinstance(data, list)
|
||||
else (data.get("sessions") or data.get("data") or [])
|
||||
)
|
||||
id_f = agent.sessions.get("id_field", "id")
|
||||
title_f = agent.sessions.get("title_field", "title")
|
||||
time_f = agent.sessions.get("time_field", "updated")
|
||||
out_rows: list[dict] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
sid = _dig(item, id_f)
|
||||
if not sid:
|
||||
continue
|
||||
out_rows.append(
|
||||
{
|
||||
"agent": agent.name,
|
||||
"id": str(sid),
|
||||
"title": str(_dig(item, title_f) or ""),
|
||||
"time": _dig(item, time_f),
|
||||
}
|
||||
)
|
||||
# Newest first when the time field is sortable; keep input order otherwise.
|
||||
try:
|
||||
out_rows.sort(key=lambda r: r["time"] or 0, reverse=True)
|
||||
except TypeError:
|
||||
pass
|
||||
return out_rows[:limit]
|
||||
169
castle-api/src/castle_api/agent_sessions.py
Normal file
169
castle-api/src/castle_api/agent_sessions.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Live agent/terminal session registry.
|
||||
|
||||
A session is a running PTY (an agent CLI or a shell) whose lifetime is decoupled
|
||||
from any WebSocket connection: closing the browser tab detaches, it does not
|
||||
kill the process. Sessions can be listed, re-attached (resumed) by id, and
|
||||
explicitly terminated. Each session keeps a bounded scrollback buffer so a
|
||||
re-attaching client can repaint the terminal.
|
||||
|
||||
This is deliberately in-memory and single-node: sessions do not survive a
|
||||
castle-api restart. That's the right scope for a personal dashboard — for true
|
||||
cross-restart persistence you'd launch the agent under tmux, which breaks the
|
||||
"castle just runs a command" agnosticism.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from castle_api.pty_session import PtySession
|
||||
|
||||
# Cap the per-session replay buffer. Raw terminal bytes; enough to repaint a
|
||||
# full-screen TUI and some history without unbounded growth.
|
||||
_SCROLLBACK_BYTES = 256 * 1024
|
||||
# Reap sessions that have exited and sat idle longer than this (seconds).
|
||||
_EXITED_TTL = 3600
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSession:
|
||||
"""One live PTY plus its scrollback and attached subscribers."""
|
||||
|
||||
id: str
|
||||
agent: str # agent name, or "terminal" for the plain shell
|
||||
command: str
|
||||
cwd: str
|
||||
created_at: float
|
||||
pty: PtySession | None = None
|
||||
scrollback: bytearray = field(default_factory=bytearray)
|
||||
subscribers: set[asyncio.Queue[bytes | None]] = field(default_factory=set)
|
||||
exited: bool = False
|
||||
exit_code: int | None = None
|
||||
exited_at: float | None = None
|
||||
|
||||
def _on_output(self, data: bytes) -> None:
|
||||
self.scrollback.extend(data)
|
||||
if len(self.scrollback) > _SCROLLBACK_BYTES:
|
||||
del self.scrollback[: len(self.scrollback) - _SCROLLBACK_BYTES]
|
||||
for q in list(self.subscribers):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
# Slow client: drop it; the WS side will notice on its next send.
|
||||
self.subscribers.discard(q)
|
||||
|
||||
def _on_exit(self) -> None:
|
||||
self.exited = True
|
||||
self.exit_code = self.pty.proc.returncode if self.pty else None
|
||||
self.exited_at = time.time()
|
||||
for q in list(self.subscribers):
|
||||
q.put_nowait(None) # EOF sentinel
|
||||
|
||||
def subscribe(self) -> asyncio.Queue[bytes | None]:
|
||||
q: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=256)
|
||||
self.subscribers.add(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: asyncio.Queue[bytes | None]) -> None:
|
||||
self.subscribers.discard(q)
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return not self.exited and self.pty is not None and self.pty.running
|
||||
|
||||
def info(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"agent": self.agent,
|
||||
"command": self.command,
|
||||
"cwd": self.cwd,
|
||||
"created_at": self.created_at,
|
||||
"running": self.running,
|
||||
"exited": self.exited,
|
||||
"exit_code": self.exit_code,
|
||||
"cols": self.pty.cols if self.pty else None,
|
||||
"rows": self.pty.rows if self.pty else None,
|
||||
"clients": len(self.subscribers),
|
||||
}
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""In-memory registry of live agent sessions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
|
||||
def _reap_exited(self) -> None:
|
||||
now = time.time()
|
||||
stale = [
|
||||
sid
|
||||
for sid, s in self._sessions.items()
|
||||
if s.exited and s.exited_at and (now - s.exited_at) > _EXITED_TTL
|
||||
]
|
||||
for sid in stale:
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
agent: str,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
) -> AgentSession:
|
||||
self._reap_exited()
|
||||
session = AgentSession(
|
||||
id=uuid.uuid4().hex[:12],
|
||||
agent=agent,
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else "",
|
||||
created_at=time.time(),
|
||||
)
|
||||
session.pty = await PtySession.start(
|
||||
command,
|
||||
args=args,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
on_output=session._on_output,
|
||||
on_exit=session._on_exit,
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
return session
|
||||
|
||||
def get(self, session_id: str) -> AgentSession | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
self._reap_exited()
|
||||
return [
|
||||
s.info()
|
||||
for s in sorted(
|
||||
self._sessions.values(), key=lambda s: s.created_at, reverse=True
|
||||
)
|
||||
]
|
||||
|
||||
async def close(self, session_id: str) -> bool:
|
||||
session = self._sessions.pop(session_id, None)
|
||||
if session is None:
|
||||
return False
|
||||
if session.pty is not None:
|
||||
await session.pty.close()
|
||||
for q in list(session.subscribers):
|
||||
q.put_nowait(None)
|
||||
return True
|
||||
|
||||
async def close_all(self) -> None:
|
||||
for sid in list(self._sessions):
|
||||
await self.close(sid)
|
||||
|
||||
|
||||
# Module-level singleton (mirrors mesh_state / stream subscribers).
|
||||
manager = SessionManager()
|
||||
260
castle-api/src/castle_api/agents.py
Normal file
260
castle-api/src/castle_api/agents.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Agent terminal UX — list agents, run/resume them in a pty over WebSocket.
|
||||
|
||||
Castle is assistant-agnostic: it launches a configured command (or a shell) in a
|
||||
real tty and streams raw bytes to an xterm.js terminal in the browser. Sessions
|
||||
outlive their WebSocket connection, so they can be listed, resumed, and killed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from castle_api.agent_registry import (
|
||||
default_cwd,
|
||||
list_agent_history,
|
||||
list_agents,
|
||||
resolve_agent,
|
||||
resume_argv,
|
||||
)
|
||||
from castle_api.agent_sessions import AgentSession, manager
|
||||
from castle_api.config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
# The reserved agent name that launches the user's login shell (Option A: plain
|
||||
# terminal). Not a configured agent — Castle knows nothing about what you run in it.
|
||||
TERMINAL_AGENT = "terminal"
|
||||
|
||||
|
||||
# --- Origin allowlist (this endpoint is an interactive shell) ---------------
|
||||
|
||||
|
||||
def _origin_allowed(origin: str | None) -> bool:
|
||||
"""Allow same-node/local and configured-domain origins for the WS handshake.
|
||||
|
||||
CORS (`*`) governs XHR, not WebSocket upgrades, so we gate the handshake
|
||||
here. Non-browser clients (no Origin) and localhost are allowed; browser
|
||||
origins must match the gateway domain (or `CASTLE_API_TERMINAL_ORIGINS`).
|
||||
Set `CASTLE_API_ALLOW_ALL_ORIGINS=1` to disable the check entirely.
|
||||
"""
|
||||
if os.environ.get("CASTLE_API_ALLOW_ALL_ORIGINS"):
|
||||
return True
|
||||
if not origin:
|
||||
return True # curl/websocat and other non-browser clients
|
||||
host = (urlparse(origin).hostname or "").lower()
|
||||
if host in ("localhost", "127.0.0.1", "::1"):
|
||||
return True
|
||||
try:
|
||||
domain = get_config().gateway.domain
|
||||
except Exception:
|
||||
domain = None
|
||||
if domain and (host == domain or host.endswith("." + domain)):
|
||||
return True
|
||||
extra = os.environ.get("CASTLE_API_TERMINAL_ORIGINS", "")
|
||||
allowed = {h.strip().lower() for h in extra.split(",") if h.strip()}
|
||||
return host in allowed
|
||||
|
||||
|
||||
# --- HTTP: agents + sessions ------------------------------------------------
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_agents() -> list[dict]:
|
||||
"""List launchable agents with availability (binary present) + default cwd."""
|
||||
return [a.info() for a in list_agents()]
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
def get_sessions() -> list[dict]:
|
||||
"""List live terminal sessions (running or recently exited)."""
|
||||
return manager.list()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history() -> list[dict]:
|
||||
"""Unified list of agents' own past sessions (from each agent's declared
|
||||
`sessions.list_command`), newest first. Agents without the capability
|
||||
(or with none stored) simply contribute nothing."""
|
||||
agents = [a for a in list_agents() if a.available and a.can_list_sessions]
|
||||
results = await asyncio.gather(*(list_agent_history(a) for a in agents))
|
||||
rows = [row for agent_rows in results for row in agent_rows]
|
||||
try:
|
||||
rows.sort(key=lambda r: r["time"] or 0, reverse=True)
|
||||
except TypeError:
|
||||
pass
|
||||
return rows
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(session_id: str) -> dict:
|
||||
"""Kill a session's process group and drop it from the registry."""
|
||||
ok = await manager.close(session_id)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="session not found"
|
||||
)
|
||||
return {"status": "closed", "id": session_id}
|
||||
|
||||
|
||||
# --- WebSocket: interactive session ----------------------------------------
|
||||
|
||||
|
||||
def _login_shell() -> str:
|
||||
return os.environ.get("SHELL") or "/bin/bash"
|
||||
|
||||
|
||||
async def _new_session(
|
||||
name: str, cont: bool = False, resume_session: str | None = None
|
||||
) -> AgentSession:
|
||||
"""Create a session for a named agent, or the reserved login shell.
|
||||
|
||||
Everything runs under the user's *interactive login* shell (`-lic`) so it
|
||||
inherits exactly the environment a real terminal would — the systemd service
|
||||
env is stripped down and would otherwise miss vars set in shell rc files
|
||||
(API keys, etc.). Agents are `exec`'d so the pty's foreground process is the
|
||||
agent itself (clean signals + exit).
|
||||
|
||||
``cont`` appends the agent's ``resume_args`` (open its own picker / continue).
|
||||
``resume_session`` builds the agent's resume-by-id argv for a specific past
|
||||
session. Either way castle only passes declared flags through — it never
|
||||
reads the agent's session storage.
|
||||
"""
|
||||
shell = _login_shell()
|
||||
if name == TERMINAL_AGENT:
|
||||
return await manager.create(
|
||||
TERMINAL_AGENT, shell, args=["-l"], cwd=default_cwd()
|
||||
)
|
||||
agent = resolve_agent(name)
|
||||
if agent is None:
|
||||
raise LookupError(f"unknown agent: {name}")
|
||||
if not agent.available:
|
||||
raise LookupError(f"agent not installed: {name}")
|
||||
if resume_session:
|
||||
argv = resume_argv(agent, resume_session)
|
||||
if argv is None:
|
||||
raise LookupError(f"agent cannot resume by id: {name}")
|
||||
else:
|
||||
argv = [agent.resolved_command or agent.command, *agent.args]
|
||||
if cont and agent.resume_args:
|
||||
argv += agent.resume_args
|
||||
launch = shlex.join(argv)
|
||||
return await manager.create(
|
||||
name,
|
||||
shell,
|
||||
args=["-lic", f"exec {launch}"],
|
||||
cwd=agent.cwd,
|
||||
env=agent.env, # explicit per-agent overrides; the login shell sets PATH
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/{name}/session")
|
||||
async def agent_session(ws: WebSocket, name: str) -> None:
|
||||
"""Run (or resume) an agent in a pty; stream it to the browser terminal.
|
||||
|
||||
Query param `session=<id>` resumes an existing live session (replaying its
|
||||
scrollback); otherwise a new session is created for `name` (or the reserved
|
||||
`terminal` shell). Disconnecting detaches — it does NOT kill the session.
|
||||
"""
|
||||
if not _origin_allowed(ws.headers.get("origin")):
|
||||
await ws.close(code=1008) # policy violation
|
||||
return
|
||||
|
||||
resume_id = ws.query_params.get("session")
|
||||
|
||||
# Resolve the target session (before accept only for hard rejects).
|
||||
session: AgentSession | None = None
|
||||
if resume_id:
|
||||
session = manager.get(resume_id)
|
||||
if session is None or not session.running:
|
||||
await ws.accept()
|
||||
await ws.send_json({"type": "error", "error": "session not resumable"})
|
||||
await ws.close()
|
||||
return
|
||||
else:
|
||||
cont = ws.query_params.get("mode") == "continue"
|
||||
resume_session = ws.query_params.get("resume_session")
|
||||
try:
|
||||
session = await _new_session(name, cont=cont, resume_session=resume_session)
|
||||
except LookupError as e:
|
||||
await ws.accept()
|
||||
await ws.send_json({"type": "error", "error": str(e)})
|
||||
await ws.close()
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("failed to launch agent %s", name)
|
||||
await ws.accept()
|
||||
await ws.send_json({"type": "error", "error": "failed to launch"})
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
assert session is not None # narrowed by the branches above
|
||||
await ws.accept()
|
||||
|
||||
# Subscribe + snapshot scrollback atomically (no await between → the output
|
||||
# callback, which runs in this same event loop, cannot interleave), so a
|
||||
# resumed client gets the buffer once with no duplication or gap.
|
||||
q = session.subscribe()
|
||||
snapshot = bytes(session.scrollback)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "session",
|
||||
"id": session.id,
|
||||
"agent": session.agent,
|
||||
"resumed": bool(resume_id),
|
||||
}
|
||||
)
|
||||
if snapshot:
|
||||
await ws.send_bytes(snapshot)
|
||||
|
||||
async def pump() -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = await q.get()
|
||||
if chunk is None: # child exited
|
||||
if ws.application_state == WebSocketState.CONNECTED:
|
||||
await ws.send_json({"type": "exit", "code": session.exit_code})
|
||||
break
|
||||
if ws.application_state != WebSocketState.CONNECTED:
|
||||
break
|
||||
await ws.send_bytes(chunk)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pump_task = asyncio.create_task(pump())
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
data = msg.get("bytes")
|
||||
text = msg.get("text")
|
||||
if data is not None and session.pty is not None:
|
||||
session.pty.write(data)
|
||||
elif text is not None and session.pty is not None:
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if ctrl.get("type") == "resize":
|
||||
session.pty.resize(int(ctrl["cols"]), int(ctrl["rows"]))
|
||||
elif ctrl.get("type") == "input":
|
||||
session.pty.write(str(ctrl.get("data", "")).encode())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
pump_task.cancel()
|
||||
session.unsubscribe(q)
|
||||
# Intentionally do NOT close the session here — it lives on for resume.
|
||||
# Sessions are ended via DELETE /agents/sessions/{id}, when the child
|
||||
# exits, or by idle reaping.
|
||||
@@ -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")
|
||||
|
||||
156
castle-api/src/castle_api/pty_session.py
Normal file
156
castle-api/src/castle_api/pty_session.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Run a command inside a pseudo-terminal and stream it over asyncio.
|
||||
|
||||
Castle stays assistant-agnostic: it just launches a command in a real TTY and
|
||||
shunts raw bytes both ways. The command's own TUI (an agent CLI, a shell, …)
|
||||
renders exactly as it would in a terminal — Castle never parses its output.
|
||||
|
||||
The PTY master is a raw OS fd, not an asyncio stream, so we watch it with
|
||||
``loop.add_reader`` (no extra thread, no busy-poll). Output is delivered to an
|
||||
``on_output`` callback (a fan-out layer above can broadcast it to any number of
|
||||
attached clients and buffer scrollback), so a session's lifetime is decoupled
|
||||
from any one WebSocket connection. Keystroke writes are tiny, so a plain
|
||||
``os.write`` is fine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
||||
"""Apply a terminal window size via TIOCSWINSZ (struct winsize)."""
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PtySession:
|
||||
"""A child process attached to a pty master.
|
||||
|
||||
Output is pushed to ``on_output(bytes)`` as it arrives and ``on_exit()`` is
|
||||
fired once when the child closes the tty. Both run in the event-loop thread.
|
||||
"""
|
||||
|
||||
proc: asyncio.subprocess.Process
|
||||
master_fd: int
|
||||
on_output: Callable[[bytes], None] | None = None
|
||||
on_exit: Callable[[], None] | None = None
|
||||
cols: int = 80
|
||||
rows: int = 24
|
||||
_closed: bool = field(default=False)
|
||||
|
||||
@classmethod
|
||||
async def start(
|
||||
cls,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
on_output: Callable[[bytes], None] | None = None,
|
||||
on_exit: Callable[[], None] | None = None,
|
||||
) -> PtySession:
|
||||
"""Spawn ``command args`` on a fresh pty, sized ``cols`` x ``rows``."""
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
# Size the tty before exec so the first TUI render lays out correctly.
|
||||
_set_winsize(master_fd, rows, cols)
|
||||
|
||||
proc_env = {**os.environ, "TERM": "xterm-256color"}
|
||||
if env:
|
||||
proc_env.update(env)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*(args or []),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
env=proc_env,
|
||||
start_new_session=True, # own session/group → controlling tty + killpg
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave_fd) # parent keeps only the master
|
||||
os.set_blocking(master_fd, False)
|
||||
|
||||
session = cls(proc=proc, master_fd=master_fd, cols=cols, rows=rows)
|
||||
if on_output is not None:
|
||||
session.on_output = on_output
|
||||
if on_exit is not None:
|
||||
session.on_exit = on_exit
|
||||
asyncio.get_running_loop().add_reader(master_fd, session._drain_master)
|
||||
return session
|
||||
|
||||
def _drain_master(self) -> None:
|
||||
"""Reader callback: pull available bytes off the master and fan them out."""
|
||||
try:
|
||||
data = os.read(self.master_fd, 65536)
|
||||
except (BlockingIOError, InterruptedError):
|
||||
return
|
||||
except OSError:
|
||||
data = b"" # slave closed → child gone
|
||||
if not data:
|
||||
try:
|
||||
asyncio.get_running_loop().remove_reader(self.master_fd)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
if self.on_exit is not None:
|
||||
self.on_exit()
|
||||
return
|
||||
if self.on_output is not None:
|
||||
self.on_output(data)
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Forward keystroke bytes to the child's tty."""
|
||||
try:
|
||||
os.write(self.master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
"""Resize the tty (browser terminal changed dimensions)."""
|
||||
self.cols, self.rows = cols, rows
|
||||
try:
|
||||
_set_winsize(self.master_fd, rows, cols)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.proc.returncode is None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Kill the whole process group and release the master fd (idempotent)."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
asyncio.get_running_loop().remove_reader(self.master_fd)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
if self.proc.returncode is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(self.proc.wait(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
os.close(self.master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -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:
|
||||
|
||||
@@ -197,6 +197,52 @@ class DefaultsSpec(BaseModel):
|
||||
env: EnvMap = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Agent spec — a launchable agent CLI (assistant-agnostic)
|
||||
# ---------------------
|
||||
|
||||
|
||||
class SessionsSpec(BaseModel):
|
||||
"""Declarative session-history capability for an agent (optional).
|
||||
|
||||
Lets the dashboard show a unified picker of an agent's *own* past sessions
|
||||
without castle knowing anything agent-specific in code: it runs
|
||||
``list_command`` (which must print a JSON array of session objects), reads
|
||||
three named fields off each object, and launches ``command`` + ``resume``
|
||||
(with ``{id}`` substituted) to reopen one. Field names default to opencode's
|
||||
shape and are overridable per agent (dotted paths allowed, e.g.
|
||||
``config_summary.session_id``).
|
||||
"""
|
||||
|
||||
list_command: list[str] # argv → JSON array of session objects
|
||||
resume: list[str] # appended to the agent command; "{id}" is substituted
|
||||
id_field: str = "id"
|
||||
title_field: str = "title"
|
||||
time_field: str = "updated"
|
||||
|
||||
|
||||
class AgentSpec(BaseModel):
|
||||
"""A launchable agent CLI for the dashboard's terminal UX.
|
||||
|
||||
Castle just runs ``command args`` inside a pty at ``cwd``; it never parses
|
||||
the agent's output, so any interactive CLI works. This block only names the
|
||||
launch — live sessions (list/resume/kill) are a runtime concern, not config.
|
||||
"""
|
||||
|
||||
command: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
description: str | None = None
|
||||
cwd: str | None = None # defaults to the castle repo root when unset
|
||||
env: EnvMap = Field(default_factory=dict)
|
||||
# Extra args that open the agent's own session browser / continue its last
|
||||
# conversation (e.g. ["--resume"] or ["--continue"]). Optional and
|
||||
# agent-specific — castle just passes them through. Empty = no such affordance.
|
||||
resume_args: list[str] = Field(default_factory=list)
|
||||
# Optional: declares how to list + resume the agent's own sessions, so the
|
||||
# dashboard can render a unified session picker (see SessionsSpec).
|
||||
sessions: SessionsSpec | None = None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Program spec — software identity
|
||||
# ---------------------
|
||||
|
||||
178
uv.lock
generated
178
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user