breakglass UI v2: attachable sessions (tmux model) + mobile-first redesign

Full audit-driven rework. Keeps the proven SSE-translation + verb logic; everything else upgraded for phone-primary use.

Backend — server owns the session, clients attach (Viktor's tmux idea):
- session.py: SessionManager + Session with an event log, subscriber pub/sub, and turns that run DETACHED (keep going if the client disconnects).
- GET /api/session/{id}/stream = attach (SSE): replays the transcript then tails live; per-event id: lines so an EventSource auto-reconnect resumes from Last-Event-ID (free re-attach). POST /{id}/prompt starts a detached turn; POST /{id}/cancel = Stop. Replaces the old one-shot /api/chat.
- agent_session trimmed to the argv + translate_event helpers; 21 new/updated tests (replay, Last-Event-ID resume, broadcast, detached turn, resume, cancel, routes) — 53 green.

Frontend — mobile-first via the frontend-design skill (emergency-console aesthetic):
- EventSource attach (native auto-reconnect, zero client reconnect logic); transcript.js folds events->messages with id-dedupe so replays never double-render (30 unit assertions).
- Installable PWA: manifest + icons (wrench/break-glass mark) + apple-mobile-web-app meta + theme-color; viewport-fit=cover + safe-area; 100dvh; 16px composer (no iOS zoom).
- One-tap diagnosis presets (Triage / Memory-OOM / Disk / Services / QEMU-wedged) mapped to the devvm's real failure modes; Stop button while a turn runs.
- Foldable VM-control sheet, cycle the dominant recovery action w/ confirm, output capped 46vh.
- a11y: fixed --ink-faint contrast 3.6:1 -> 6.1:1 (WCAG AA); >=44px tap targets. Deleted the obsolete fetch-reader sse.js (EventSource replaces it).

Verified: 53 backend tests + 30 transcript assertions; Playwright @390x844 (input on-screen y=721-821, presets/sheet/fold/cap); local integration smoke vs the real backend (attach->caught-up, 404, verbs, PWA served).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-14 19:19:03 +00:00
parent 9d8afdd884
commit 5b5daa4bea
30 changed files with 1961 additions and 968 deletions

View file

@ -1,26 +1,13 @@
"""Drive the breakglass Claude agent and stream its work to the browser.
"""Claude CLI argv + stream-json → UI-event translation for the breakglass agent.
Each chat turn runs ``claude -p --output-format stream-json`` in the session's
persistent workspace; the first turn opens the session with ``--session-id`` and
later turns ``--resume`` it, so the conversation has memory across turns. The
CLI's JSON events are translated to a small, stable SSE vocabulary the UI
renders (``session`` / ``text`` / ``tool`` / ``result`` / ``error``) we do not
leak the raw event firehose to the client.
Subprocesses use ``asyncio.create_subprocess_exec`` (list argv, no shell): the
prompt and ids are argv elements, never interpreted by a shell.
The session lifecycle (running turns, attaching clients) lives in ``session.py``;
this module is just the two helpers it builds on:
* ``_turn_argv`` the no-shell list argv for one ``claude -p`` turn.
* ``translate_event`` map a raw stream-json event to the small UI vocabulary
(session / text / tool / result), dropping the hook/thinking-token noise.
"""
import asyncio
import json
import os
from subprocess import PIPE
from typing import AsyncIterator
from . import config
# Sessions we've already opened (so the next turn resumes instead of re-creating).
_started: set[str] = set()
def _turn_argv(session_id: str, prompt: str, resume: bool, model: str) -> list[str]:
argv = [
@ -66,7 +53,7 @@ def translate_event(obj: dict) -> dict | None:
})
if not events:
return None
# The server flattens a "batch" into individual SSE frames.
# The session log flattens a "batch" into individual events.
return events[0] if len(events) == 1 else {"kind": "batch", "events": events}
if etype == "result":
@ -78,68 +65,3 @@ def translate_event(obj: dict) -> dict | None:
}
return None
async def run_turn(
session_id: str, prompt: str, model: str | None = None
) -> AsyncIterator[dict]:
"""Run one chat turn, yielding translated UI events as they arrive."""
resume = session_id in _started
model = model or config.DEFAULT_MODEL
workspace = os.path.join(config.SESSIONS_DIR, session_id)
os.makedirs(workspace, exist_ok=True)
argv = _turn_argv(session_id, prompt, resume, model)
proc = await asyncio.create_subprocess_exec(
*argv, cwd=workspace, stdout=PIPE, stderr=PIPE,
)
_started.add(session_id)
assert proc.stdout is not None and proc.stderr is not None
try:
async def _pump() -> AsyncIterator[dict]:
async for raw in proc.stdout:
line = raw.decode(errors="replace").strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
ev = translate_event(obj)
if ev is None:
continue
if ev.get("kind") == "batch":
for sub in ev["events"]:
yield sub
else:
yield ev
async for ev in _with_timeout(_pump(), config.TURN_TIMEOUT_SECONDS):
yield ev
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
yield {"kind": "error", "error": f"turn timed out after {config.TURN_TIMEOUT_SECONDS}s"}
return
await proc.wait()
if proc.returncode not in (0, None):
err = (await proc.stderr.read()).decode(errors="replace")
yield {"kind": "error", "error": err.strip()[:500] or f"exit {proc.returncode}"}
async def _with_timeout(agen: AsyncIterator[dict], timeout: float) -> AsyncIterator[dict]:
"""Yield from an async generator but raise TimeoutError if the WHOLE turn
exceeds ``timeout`` seconds (a wedged agent shouldn't stream forever)."""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
it = agen.__aiter__()
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise asyncio.TimeoutError
try:
yield await asyncio.wait_for(it.__anext__(), timeout=remaining)
except StopAsyncIteration:
return

View file

@ -25,6 +25,9 @@ MAX_CONCURRENT_TURNS = int(os.environ.get("BREAKGLASS_MAX_CONCURRENT_TURNS", "2"
TURN_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_TURN_TIMEOUT_SECONDS", "1800"))
# A single PVE power verb must return fast; a wedged host shouldn't hang the UI.
PVE_VERB_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_PVE_VERB_TIMEOUT_SECONDS", "120"))
# How long an idle attach stream waits before emitting an SSE keepalive comment
# (keeps proxies/CDN from closing the long-lived connection).
SSE_KEEPALIVE_SECONDS = int(os.environ.get("BREAKGLASS_SSE_KEEPALIVE_SECONDS", "20"))
# Auth. The app sits behind the ingress `auth = "required"` resilience proxy
# (Authentik SSO, basic-auth fallback when Authentik is down). We additionally

View file

@ -1,38 +1,44 @@
"""Breakglass FastAPI app — the in-cluster emergency recovery UI.
The chat uses the tmux/attach model (see session.py): the server owns the
conversation; clients attach over SSE and the turn keeps running if they
disconnect.
Routes:
GET /health liveness (no auth)
GET / the single-page UI (static)
POST /api/session open a chat session, returns {session_id}
POST /api/chat run one turn, streams SSE events (text/tool/result)
POST /api/pve/{verb} LLM-independent PVE power verb (manual buttons)
GET /api/pve/verbs list allowed verbs + which mutate
GET /health liveness (no auth)
GET / the single-page UI (static)
POST /api/session create a session, returns {session_id}
GET /api/session/{id}/stream ATTACH (SSE): replay + live tail
POST /api/session/{id}/prompt run a turn (detached; survives disconnect)
POST /api/session/{id}/cancel stop the in-flight turn
GET /api/pve/verbs list allowed verbs + which mutate
POST /api/pve/{verb} LLM-independent PVE power verb (buttons)
Everything under /api requires auth (edge Authentik header or bearer token).
"""
import json
import os
import uuid
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from . import agent_session, config, pve
from . import config, pve
from .auth import require_auth
from .session import SessionManager, attach_stream
app = FastAPI(title="Claude Breakglass")
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
manager = SessionManager()
class SessionResponse(BaseModel):
session_id: str
class ChatRequest(BaseModel):
session_id: str
class PromptRequest(BaseModel):
prompt: str = Field(..., min_length=1)
model: str | None = None
@ -44,30 +50,53 @@ async def health():
@app.post("/api/session", response_model=SessionResponse)
async def open_session(_identity: str = Depends(require_auth)):
# Claude wants a UUID for --session-id.
return SessionResponse(session_id=str(uuid.uuid4()))
return SessionResponse(session_id=manager.create().id)
@app.post("/api/chat")
async def chat(req: ChatRequest, _identity: str = Depends(require_auth)):
"""Stream one chat turn as Server-Sent Events. The browser reads the
response body incrementally (fetch + ReadableStream)."""
async def _sse():
try:
async for ev in agent_session.run_turn(req.session_id, req.prompt, req.model):
yield f"data: {json.dumps(ev)}\n\n"
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
yield f"data: {json.dumps({'kind': 'error', 'error': str(exc)[:500]})}\n\n"
yield f"data: {json.dumps({'kind': 'done'})}\n\n"
@app.get("/api/session/{session_id}/stream")
async def attach(
session_id: str,
_identity: str = Depends(require_auth),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
):
"""Attach to a session (SSE). Replays the conversation so far, then tails
live. On an EventSource auto-reconnect the browser sends Last-Event-ID, so we
replay only what was missed."""
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="session not found")
try:
leid = int(last_event_id) if last_event_id is not None else None
except ValueError:
leid = None
return StreamingResponse(
_sse(),
attach_stream(session, leid),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
@app.post("/api/session/{session_id}/prompt")
async def prompt(session_id: str, req: PromptRequest, _identity: str = Depends(require_auth)):
"""Start a turn. It runs DETACHED (keeps going if the client disconnects);
output is delivered via the attach stream, not this response."""
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="session not found")
if not session.start_turn(req.prompt, req.model):
raise HTTPException(status_code=409, detail="a turn is already running")
return {"status": "started"}
@app.post("/api/session/{session_id}/cancel")
async def cancel(session_id: str, _identity: str = Depends(require_auth)):
session = manager.get(session_id)
if session is None:
raise HTTPException(status_code=404, detail="session not found")
cancelled = await session.cancel()
return {"cancelled": cancelled}
@app.get("/api/pve/verbs")
async def pve_verbs(_identity: str = Depends(require_auth)):
return {

201
app/breakglass/session.py Normal file
View file

@ -0,0 +1,201 @@
"""Attachable server-side sessions — the tmux model for the breakglass chat.
Instead of the client owning conversation state, the SERVER owns it and clients
*attach*. A turn runs as a detached task that keeps going if the client
disconnects (you can background the phone / hit a tunnel blip and the agent
keeps working); its output is appended to a per-session event log and broadcast
to every attached subscriber. A client attaches over SSE, gets the log replayed
(or only the part it missed, via Last-Event-ID), then tails live exactly like
re-attaching to a tmux session. ``EventSource`` reconnects natively, so the
"re-attach" needs zero client logic.
This module owns the lifecycle; ``agent_session`` still provides the claude
argv + the stream-jsonUI-event translation (all subprocesses use the no-shell
list-argv form), and ``config`` the knobs.
"""
import asyncio
import json
import os
import uuid
from subprocess import PIPE
from typing import AsyncIterator
from . import agent_session, config
class Session:
"""One conversation. Owns the replay log + live subscribers + the in-flight
turn. The claude ``session_id`` is reused with ``--resume`` so the agent
keeps its own context across turns."""
def __init__(self, session_id: str):
self.id = session_id
# The replay log: every UI event, in order. Index in the list IS the
# SSE event id, so a reconnecting client replays only what it missed.
self.events: list[dict] = []
self._subscribers: set[asyncio.Queue] = set()
self._turn: asyncio.Task | None = None
self._proc: asyncio.subprocess.Process | None = None
self._started = False # has claude opened this session id yet?
# ── event log + fan-out ────────────────────────────────────────────────
def add_event(self, event: dict) -> dict:
"""Append an event to the log and broadcast it to attached clients."""
stored = {**event, "id": len(self.events)}
self.events.append(stored)
for q in list(self._subscribers):
q.put_nowait(stored)
return stored
def subscribe(self) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue()
self._subscribers.add(q)
return q
def unsubscribe(self, q: asyncio.Queue) -> None:
self._subscribers.discard(q)
@property
def turn_active(self) -> bool:
return self._turn is not None and not self._turn.done()
# ── running a turn (detached from any client) ──────────────────────────
def start_turn(self, prompt: str, model: str | None = None) -> bool:
"""Kick off a turn as a background task. Returns False if one is already
running (one turn at a time per session)."""
if self.turn_active:
return False
self.add_event({"kind": "user", "text": prompt})
self._turn = asyncio.create_task(self._run_turn(prompt, model))
return True
async def _run_turn(self, prompt: str, model: str | None) -> None:
model = model or config.DEFAULT_MODEL
resume = self._started
argv = agent_session._turn_argv(self.id, prompt, resume, model)
try:
self._proc = await asyncio.create_subprocess_exec(
*argv, cwd=_workspace_for(self.id), stdout=PIPE, stderr=PIPE,
)
except Exception as exc: # noqa: BLE001
self.add_event({"kind": "error", "error": f"could not start agent: {exc}"})
self.add_event({"kind": "turn_end"})
return
self._started = True
assert self._proc.stdout is not None and self._proc.stderr is not None
try:
async def _pump():
async for raw in self._proc.stdout:
line = raw.decode(errors="replace").strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
ev = agent_session.translate_event(obj)
if ev is None:
continue
if ev.get("kind") == "batch":
for sub in ev["events"]:
self.add_event(sub)
else:
self.add_event(ev)
await asyncio.wait_for(_pump(), timeout=config.TURN_TIMEOUT_SECONDS)
await self._proc.wait()
if self._proc.returncode not in (0, None):
err = (await self._proc.stderr.read()).decode(errors="replace")
self.add_event({"kind": "error", "error": err.strip()[:500] or f"exit {self._proc.returncode}"})
except asyncio.TimeoutError:
await self._kill_proc()
self.add_event({"kind": "error", "error": f"turn timed out after {config.TURN_TIMEOUT_SECONDS}s"})
except asyncio.CancelledError:
await self._kill_proc()
self.add_event({"kind": "cancelled"})
raise
finally:
self._proc = None
self.add_event({"kind": "turn_end"})
async def _kill_proc(self) -> None:
if self._proc and self._proc.returncode is None:
try:
self._proc.kill()
await self._proc.wait()
except ProcessLookupError:
pass
async def cancel(self) -> bool:
"""Stop the in-flight turn. Returns True if a turn was cancelled."""
if not self.turn_active:
return False
await self._kill_proc()
if self._turn:
self._turn.cancel()
try:
await self._turn
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
return True
def _workspace_for(session_id: str) -> str:
path = os.path.join(config.SESSIONS_DIR, session_id)
os.makedirs(path, exist_ok=True)
return path
class SessionManager:
"""Holds all live sessions. The breakglass is single-operator, so callers
typically reuse one persistent session; multiple are still supported."""
def __init__(self):
self.sessions: dict[str, Session] = {}
def create(self) -> Session:
sid = str(uuid.uuid4())
s = Session(sid)
self.sessions[sid] = s
return s
def get(self, session_id: str) -> Session | None:
return self.sessions.get(session_id)
def get_or_create(self, session_id: str | None) -> Session:
if session_id and session_id in self.sessions:
return self.sessions[session_id]
return self.create()
async def attach_stream(session: Session, last_event_id: int | None) -> AsyncIterator[str]:
"""Yield SSE frames for an attached client: first the replay (everything, or
only events after ``last_event_id`` on a reconnect), then live events as they
arrive. Each frame carries an ``id:`` so EventSource resumes precisely."""
q = session.subscribe()
try:
start = 0 if last_event_id is None else last_event_id + 1
backlog = session.events[start:]
for ev in backlog:
yield _sse_frame(ev)
# Tell the client the replay is done and it's now live.
yield "event: caught-up\ndata: {}\n\n"
seen = backlog[-1]["id"] if backlog else (last_event_id if last_event_id is not None else -1)
while True:
try:
ev = await asyncio.wait_for(q.get(), timeout=config.SSE_KEEPALIVE_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n" # comment frame keeps the connection warm
continue
if ev["id"] <= seen:
continue
seen = ev["id"]
yield _sse_frame(ev)
finally:
session.unsubscribe(q)
def _sse_frame(event: dict) -> str:
return f"id: {event['id']}\ndata: {json.dumps(event)}\n\n"

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="devvm breakglass">
<defs>
<!-- layered near-black surface, matching the app theme -->
<radialGradient id="bg" cx="68%" cy="22%" r="92%">
<stop offset="0%" stop-color="#12303a"/>
<stop offset="42%" stop-color="#0b0f14"/>
<stop offset="100%" stop-color="#06080b"/>
</radialGradient>
<linearGradient id="steel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7df0f3"/>
<stop offset="55%" stop-color="#3dd1d6"/>
<stop offset="100%" stop-color="#1f6f72"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="7" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- rounded-square field (safe for maskable: art kept within central ~80%) -->
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<rect x="6" y="6" width="500" height="500" rx="108" fill="none" stroke="#1c2530" stroke-width="3"/>
<!-- faint scanline texture -->
<g opacity="0.05" stroke="#ffffff" stroke-width="2">
<line x1="0" y1="148" x2="512" y2="148"/>
<line x1="0" y1="220" x2="512" y2="220"/>
<line x1="0" y1="292" x2="512" y2="292"/>
<line x1="0" y1="364" x2="512" y2="364"/>
</g>
<!-- fracture burst (amber): the "break the glass" radiating cracks -->
<g stroke="#f5b657" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"
fill="none" opacity="0.92" filter="url(#glow)">
<path d="M256 256 L142 132"/>
<path d="M256 256 L120 250"/>
<path d="M256 256 L150 372"/>
<path d="M256 256 L372 380"/>
<path d="M256 256 L392 246"/>
<path d="M256 256 L360 138"/>
<!-- cross-cracks -->
<path d="M186 196 L150 250"/>
<path d="M210 320 L172 318" opacity="0.7"/>
<path d="M326 318 L356 350" opacity="0.7"/>
</g>
<!-- wrench, struck across the burst (cyan steel) -->
<g filter="url(#glow)">
<path fill="url(#steel)" stroke="#0e3133" stroke-width="6" stroke-linejoin="round"
d="M344 150
a62 62 0 0 0 -82 76
L150 338
a26 26 0 0 0 0 37
l11 11
a26 26 0 0 0 37 0
l112 -112
a62 62 0 0 0 76 -82
l-41 41
l-40 -11
l-11 -40
z"/>
<!-- handle highlight -->
<path d="M171 350 l128 -128" stroke="#bdf6f8" stroke-width="7" stroke-linecap="round" opacity="0.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -2,12 +2,31 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover so the app paints edge-to-edge and we can honour the
notch/home-indicator via env(safe-area-inset-*). maximum-scale + no
user-scaling keeps the cockpit layout stable under stress on mobile. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0"
/>
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex, nofollow" />
<!-- PWA / installable. theme-color tints the mobile status bar to the dark
theme; black-translucent lets the app draw under the iOS status bar. -->
<meta name="theme-color" content="#06080b" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="breakglass" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link rel="icon" type="image/svg+xml" href="./icon.svg" />
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
<title>devvm breakglass</title>
<script type="module" crossorigin src="./assets/index-DjaW81Sq.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DWHIP1Zw.css">
<script type="module" crossorigin src="./assets/index-CLbKo1Yx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BoWC1Onq.css">
</head>
<body>
<div id="app"></div>

View file

@ -0,0 +1,31 @@
{
"name": "devvm breakglass",
"short_name": "breakglass",
"description": "Emergency recovery console for the devvm — chat with a repair agent or power-cycle the VM directly.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#06080b",
"theme_color": "#06080b",
"icons": [
{
"src": "./icon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any maskable"
},
{
"src": "./icon-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "./icon-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
]
}

View file

@ -2,9 +2,28 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover so the app paints edge-to-edge and we can honour the
notch/home-indicator via env(safe-area-inset-*). maximum-scale + no
user-scaling keeps the cockpit layout stable under stress on mobile. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0"
/>
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex, nofollow" />
<!-- PWA / installable. theme-color tints the mobile status bar to the dark
theme; black-translucent lets the app draw under the iOS status bar. -->
<meta name="theme-color" content="#06080b" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="breakglass" />
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
<link rel="icon" type="image/svg+xml" href="./icon.svg" />
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
<title>devvm breakglass</title>
</head>
<body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

64
frontend/public/icon.svg Normal file
View file

@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="devvm breakglass">
<defs>
<!-- layered near-black surface, matching the app theme -->
<radialGradient id="bg" cx="68%" cy="22%" r="92%">
<stop offset="0%" stop-color="#12303a"/>
<stop offset="42%" stop-color="#0b0f14"/>
<stop offset="100%" stop-color="#06080b"/>
</radialGradient>
<linearGradient id="steel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7df0f3"/>
<stop offset="55%" stop-color="#3dd1d6"/>
<stop offset="100%" stop-color="#1f6f72"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="7" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- rounded-square field (safe for maskable: art kept within central ~80%) -->
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
<rect x="6" y="6" width="500" height="500" rx="108" fill="none" stroke="#1c2530" stroke-width="3"/>
<!-- faint scanline texture -->
<g opacity="0.05" stroke="#ffffff" stroke-width="2">
<line x1="0" y1="148" x2="512" y2="148"/>
<line x1="0" y1="220" x2="512" y2="220"/>
<line x1="0" y1="292" x2="512" y2="292"/>
<line x1="0" y1="364" x2="512" y2="364"/>
</g>
<!-- fracture burst (amber): the "break the glass" radiating cracks -->
<g stroke="#f5b657" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"
fill="none" opacity="0.92" filter="url(#glow)">
<path d="M256 256 L142 132"/>
<path d="M256 256 L120 250"/>
<path d="M256 256 L150 372"/>
<path d="M256 256 L372 380"/>
<path d="M256 256 L392 246"/>
<path d="M256 256 L360 138"/>
<!-- cross-cracks -->
<path d="M186 196 L150 250"/>
<path d="M210 320 L172 318" opacity="0.7"/>
<path d="M326 318 L356 350" opacity="0.7"/>
</g>
<!-- wrench, struck across the burst (cyan steel) -->
<g filter="url(#glow)">
<path fill="url(#steel)" stroke="#0e3133" stroke-width="6" stroke-linejoin="round"
d="M344 150
a62 62 0 0 0 -82 76
L150 338
a26 26 0 0 0 0 37
l11 11
a26 26 0 0 0 37 0
l112 -112
a62 62 0 0 0 76 -82
l-41 41
l-40 -11
l-11 -40
z"/>
<!-- handle highlight -->
<path d="M171 350 l128 -128" stroke="#bdf6f8" stroke-width="7" stroke-linecap="round" opacity="0.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,31 @@
{
"name": "devvm breakglass",
"short_name": "breakglass",
"description": "Emergency recovery console for the devvm — chat with a repair agent or power-cycle the VM directly.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#06080b",
"theme_color": "#06080b",
"icons": [
{
"src": "./icon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any maskable"
},
{
"src": "./icon-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "./icon-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
]
}

View file

@ -1,100 +1,294 @@
<script>
import { onMount } from 'svelte';
import { openSession } from './lib/api.js';
import { onMount, onDestroy } from 'svelte';
import {
openSession,
attachStream,
sendPrompt,
cancelTurn,
loadSessionId,
saveSessionId,
clearSessionId,
} from './lib/api.js';
import { createTranscript, reduceEvent } from './lib/transcript.js';
import Chat from './Chat.svelte';
import VmControls from './VmControls.svelte';
// ── session lifecycle ────────────────────────────────────────────────────
// ── lifecycle state ───────────────────────────────────────────────────────
// link: connecting | attached | error (the EventSource to the session)
let link = $state('connecting');
let linkError = $state('');
let sessionId = $state('');
let sessionState = $state('connecting'); // connecting | ready | error
let sessionError = $state('');
let streaming = $state(false);
let caughtUp = $state(false); // replay drained → live tailing
let turnActive = $state(false); // a turn is running (Stop shown, Send off)
let sending = $state(false); // a prompt POST is in flight
// Mobile: the VM controls live in a slide-up sheet. Desktop: a side column
// (CSS hides the toggle and pins the sheet open as a column ≥900px).
// The transcript is folded with a plain mutable object; we bump `rev` to
// notify the view of in-place mutations (cheaper than cloning the whole
// message list on every streamed token). `tx` is $state too, so REASSIGNING
// it (reset / new session) also propagates to the Chat prop. $state.raw keeps
// the object un-proxied so the hot per-token path stays a plain mutation.
let tx = $state.raw(createTranscript());
let rev = $state(0);
let es = null; // the live EventSource
// Mobile: VM controls live in a slide-up sheet. Desktop (≥900px): a column.
let showControls = $state(false);
async function newSession() {
sessionState = 'connecting';
sessionError = '';
try {
sessionId = await openSession();
sessionState = 'ready';
} catch (err) {
sessionState = 'error';
sessionError = err instanceof Error ? err.message : String(err);
function resetTranscript() {
tx = createTranscript();
rev++;
}
function onEvent(ev) {
if (reduceEvent(tx, ev)) {
// turn liveness tracks the folder's view of the stream, so a turn started
// in ANOTHER tab (or before a reload) still flips us into "active".
turnActive = tx.activeUserSeen;
rev++;
}
}
onMount(newSession);
function onLiveSession(id) {
if (id) sessionId = id;
function closeStream() {
if (es) {
es.close();
es = null;
}
}
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '────────');
const dotState = $derived(
sessionState === 'error' ? 'error' : streaming ? 'busy' : sessionState === 'ready' ? 'ready' : 'idle'
function attach(id) {
closeStream();
sessionId = id;
caughtUp = false;
link = 'connecting';
linkError = '';
es = attachStream(id, {
onOpen: () => {
// a successful (re)connection clears any prior transient error
if (link !== 'attached') link = 'attached';
linkError = '';
},
onCaughtUp: () => {
caughtUp = true;
link = 'attached';
},
onEvent,
onError: () => {
// EventSource auto-reconnects on a transient drop (readyState
// CONNECTING). Only a terminal CLOSED state is a hard failure. The
// server keeps the turn running regardless, so we surface a soft note
// and let the browser retry.
if (es && es.readyState === EventSource.CLOSED) {
link = 'error';
linkError = 'lost the connection to the session — retrying…';
// a closed source won't retry itself; re-attach to the same id.
setTimeout(() => {
if (sessionId === id) attach(id);
}, 1500);
} else {
link = 'connecting';
}
},
});
}
async function bootstrap() {
link = 'connecting';
linkError = '';
resetTranscript();
const existing = loadSessionId();
if (existing) {
// Reuse the persisted id and attach. If it's gone (pod restart → 404 on
// the stream), the EventSource errors; we detect the 404-shaped close and
// mint a fresh session below.
attach(existing);
// Probe liveness: if the attach can't open within a grace window AND the
// id is stale, create a new one. We rely on onError(CLOSED) for the 404.
return;
}
await createFresh();
}
async function createFresh() {
try {
link = 'connecting';
const id = await openSession();
saveSessionId(id);
attach(id);
} catch (err) {
link = 'error';
linkError = err instanceof Error ? err.message : String(err);
}
}
// "New session": archive the local id, mint a new one, re-attach.
async function newSession() {
if (turnActive || sending) return;
closeStream();
clearSessionId();
resetTranscript();
turnActive = false;
await createFresh();
}
// Send a prompt (typed or a preset). Output arrives via the attach stream.
async function submitPrompt(prompt) {
const text = (prompt || '').trim();
if (!text || turnActive || sending) return;
if (!sessionId) {
await createFresh();
if (!sessionId) return;
}
sending = true;
turnActive = true; // optimistic: the working indicator shows immediately
try {
const res = await sendPrompt({ session_id: sessionId, prompt: text });
if (res.status === 'busy') {
flash = 'A turn is already running.';
// turn really is active; keep the indicator, the stream will end it.
} else if (res.status === 'gone') {
// session evaporated (pod restart). Re-create and resend once.
clearSessionId();
await createFresh();
if (sessionId) await sendPrompt({ session_id: sessionId, prompt: text });
}
} catch (err) {
flash = err instanceof Error ? err.message : String(err);
turnActive = tx.activeUserSeen; // back off the optimistic flag on failure
} finally {
sending = false;
}
}
async function stopTurn() {
if (!sessionId) return;
try {
await cancelTurn(sessionId);
// turn_end / cancelled events arrive via the stream and flip turnActive.
} catch (err) {
flash = err instanceof Error ? err.message : String(err);
}
}
// a transient toast (409 / network blips), auto-cleared
let flash = $state('');
let flashTimer;
$effect(() => {
if (flash) {
clearTimeout(flashTimer);
flashTimer = setTimeout(() => (flash = ''), 4200);
}
});
onMount(bootstrap);
onDestroy(closeStream);
// ── header status lamp ──────────────────────────────────────────────────
// One quietly-living "system pulse": idle/connecting (cyan breathe),
// working (amber pulse), error (steady red — the ONLY non-power red, used
// sparingly for the lamp because connection loss IS the emergency here).
const lamp = $derived(
link === 'error'
? 'error'
: turnActive
? 'working'
: link === 'attached'
? 'live'
: 'connecting'
);
const lampLabel = $derived(
{
error: 'link down',
working: 'agent working',
live: 'attached',
connecting: 'connecting',
}[lamp]
);
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '········');
</script>
<div class="shell">
<header class="rail">
<header class="rail rise-in" style="--d:0ms">
<div class="rail-title">
<span class="glyph" aria-hidden="true">🔧</span>
<h1>devvm <span class="accent">breakglass</span></h1>
<span class="brand-mark" aria-hidden="true">
<!-- breakglass glyph: a wrench struck through a fracture line -->
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M15.5 5.5a3.6 3.6 0 0 0-4.7 4.4L4 16.7 7.3 20l6.8-6.8a3.6 3.6 0 0 0 4.4-4.7l-2.2 2.2-2.2-.6-.6-2.2 2-2.6Z" />
<path class="frac" d="M3 3l3.2 4.1L4.4 8.6 7 12" stroke-dasharray="2 2.4" />
</svg>
</span>
<h1>devvm<span class="accent"> breakglass</span></h1>
</div>
<div class="rail-right">
<span class="rail-status">
<span class="dot dot--{dotState}" aria-hidden="true"></span>
{#if sessionState === 'error'}
<span class="session-bad">offline</span>
{:else if sessionState === 'connecting'}
<span class="session-meta">connecting…</span>
{:else}
<code class="session-id" title={sessionId}>{shortId}</code>
{/if}
<span class="lamp-wrap" title={lampLabel}>
<span class="lamp lamp--{lamp}" aria-hidden="true"></span>
<span class="lamp-text lamp-text--{lamp}">
{#if lamp === 'error'}
link down
{:else if lamp === 'working'}
working
{:else if lamp === 'live'}
<code class="sid">{shortId}</code>
{:else}
connecting
{/if}
</span>
</span>
<!-- Mobile-only: open the VM control sheet. Hidden on desktop (column). -->
<button
class="controls-toggle"
class="rail-btn rail-btn--vm"
onclick={() => (showControls = true)}
aria-label="Open direct VM controls"
>
<span class="controls-toggle-label">VM</span>
<span class="bolt" aria-hidden="true"></span><span class="rail-btn-label">VM</span>
</button>
<button
class="new-session"
class="rail-btn"
onclick={newSession}
disabled={streaming || sessionState === 'connecting'}
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
disabled={turnActive || sending || link === 'connecting'}
title={turnActive ? 'wait for the current turn to finish' : 'archive this session and start fresh'}
>
New
</button>
</div>
</header>
{#if sessionState === 'error'}
<div class="rail-error" role="alert">
Can't reach the breakglass backend — {sessionError}. The cluster or network
may be down. The <strong>⚡ VM</strong> power controls still work without the chat.
{#if link === 'error'}
<div class="rail-note" role="alert">
<span>{linkError || "Can't reach the breakglass backend."}</span>
<span class="rail-note-aside">The <strong>⚡ VM</strong> power controls still work without the chat.</span>
<button class="rail-note-retry" onclick={bootstrap}>Reconnect</button>
</div>
{/if}
{#if flash}
<div class="toast" role="status">{flash}</div>
{/if}
<main class="stage">
<section class="chat-pane" aria-label="Recovery chat">
<section class="chat-pane rise-in" style="--d:80ms" aria-label="Recovery chat">
<Chat
{sessionId}
sessionReady={sessionState === 'ready'}
{onLiveSession}
onStreamingChange={(v) => (streaming = v)}
{tx}
{rev}
{caughtUp}
{turnActive}
sending={sending}
linkState={link}
onSubmit={submitPrompt}
onStop={stopTurn}
/>
</section>
<aside class="controls-pane" class:open={showControls} aria-label="Direct VM control">
<aside
class="controls-pane rise-in"
class:open={showControls}
style="--d:160ms"
aria-label="Direct VM control"
>
<div class="sheet-grip" aria-hidden="true"></div>
<div class="controls-head">
<span class="controls-head-title">Direct VM control</span>
@ -104,7 +298,6 @@
</aside>
</main>
<!-- backdrop behind the mobile sheet -->
<button
class="sheet-backdrop"
class:show={showControls}
@ -119,43 +312,51 @@
height: 100%;
display: flex;
flex-direction: column;
max-width: 1500px;
max-width: 1520px;
margin: 0 auto;
/* honour the notch on landscape / edge-to-edge */
padding-left: var(--safe-left);
padding-right: var(--safe-right);
}
/* ── status rail (compact, single row on mobile) ─────────────────────── */
/* ── status rail ───────────────────────────────────────────────────────── */
.rail {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px;
padding: max(10px, var(--safe-top)) 14px 10px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(61, 209, 214, 0.03), transparent 60%),
linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
flex: none;
}
.rail-title {
display: flex;
align-items: baseline;
gap: 9px;
align-items: center;
gap: 10px;
min-width: 0;
}
.glyph {
font-size: 17px;
transform: translateY(2px);
filter: saturate(0.85);
.brand-mark {
color: var(--cyan);
display: inline-flex;
filter: drop-shadow(0 0 10px rgba(61, 209, 214, 0.35));
flex: none;
}
.brand-mark .frac { color: var(--amber); stroke: var(--amber); opacity: 0.85; }
h1 {
margin: 0;
font-family: var(--mono);
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
letter-spacing: 0.04em;
color: var(--ink);
white-space: nowrap;
}
.accent {
color: var(--cyan);
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
text-shadow: 0 0 18px rgba(61, 209, 214, 0.4);
}
.rail-right {
@ -164,90 +365,158 @@
gap: 8px;
flex: none;
}
.rail-status {
/* the living system-pulse lamp */
.lamp-wrap {
display: inline-flex;
align-items: center;
gap: 7px;
gap: 8px;
padding: 0 4px;
font-family: var(--mono);
font-size: 12px;
}
.session-id {
color: var(--cyan);
letter-spacing: 0.04em;
}
.session-meta {
color: var(--amber);
}
.session-bad {
color: var(--danger-bright);
}
.dot {
width: 9px;
height: 9px;
.lamp {
position: relative;
width: 10px;
height: 10px;
border-radius: 50%;
flex: none;
background: var(--ink-faint);
}
.dot--ready {
/* a soft halo ring that pulses outward — the "instrument is powered" tell */
.lamp::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 1px solid currentColor;
opacity: 0;
}
.lamp--live {
background: var(--cyan);
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.6);
animation: breathe 3.4s ease-in-out infinite;
color: var(--cyan);
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.65);
animation: lamp-breathe 3.6s ease-in-out infinite;
}
.dot--busy {
.lamp--live::after { animation: lamp-ring 3.6s ease-out infinite; }
.lamp--connecting {
background: var(--cyan-dim);
color: var(--cyan);
animation: lamp-blink 1.4s ease-in-out infinite;
}
.lamp--working {
background: var(--amber);
color: var(--amber);
box-shadow: 0 0 10px 1px rgba(245, 182, 87, 0.7);
animation: pulse 1s ease-in-out infinite;
animation: lamp-pulse 1s ease-in-out infinite;
}
.dot--error {
.lamp--working::after { animation: lamp-ring 1s ease-out infinite; }
.lamp--error {
background: var(--danger);
color: var(--danger);
box-shadow: 0 0 10px 1px var(--danger-glow);
animation: lamp-pulse 1.2s ease-in-out infinite;
}
@keyframes breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
@keyframes pulse {
0%, 100% { transform: scale(0.82); opacity: 0.7; }
50% { transform: scale(1.15); opacity: 1; }
@keyframes lamp-breathe { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
@keyframes lamp-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.9; } }
@keyframes lamp-pulse {
0%, 100% { transform: scale(0.82); opacity: 0.75; }
50% { transform: scale(1.12); opacity: 1; }
}
@keyframes lamp-ring {
0% { opacity: 0.5; transform: scale(0.6); }
70% { opacity: 0; transform: scale(1.8); }
100% { opacity: 0; transform: scale(1.8); }
}
.lamp-text {
letter-spacing: 0.04em;
color: var(--ink-dim);
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lamp-text--live .sid { color: var(--cyan); letter-spacing: 0.06em; }
.lamp-text--working { color: var(--amber); }
.lamp-text--error { color: var(--danger-bright); }
.lamp-text--connecting { color: var(--ink-faint); }
.sid { font-family: var(--mono); }
/* On the tightest phones the title + lamp text + two buttons crowd; keep the
living dot (the system pulse) and drop the text label until there's room. */
@media (max-width: 439px) {
.lamp-text { display: none; }
.lamp-wrap { padding: 0; }
}
/* touch-friendly buttons */
.controls-toggle,
.new-session {
min-height: 40px;
padding: 0 13px;
/* rail buttons — touch-first (≥44px tall via padding + line height) */
.rail-btn {
min-height: 44px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
font-size: 13px;
letter-spacing: 0.02em;
letter-spacing: 0.03em;
display: inline-flex;
align-items: center;
gap: 5px;
gap: 6px;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.controls-toggle {
border-color: #5a4a2a;
.rail-btn:hover:not(:disabled) { border-color: var(--line-bright); color: var(--ink); }
.rail-btn:active:not(:disabled) { background: var(--bg-3); }
.rail-btn:disabled { opacity: 0.42; }
.rail-btn--vm {
border-color: var(--amber-dim);
color: var(--amber);
}
.controls-toggle:active,
.new-session:active {
background: var(--bg-3);
}
.new-session:disabled {
opacity: 0.45;
}
.rail-btn--vm:hover:not(:disabled) { border-color: var(--amber); color: var(--amber); }
.bolt { font-size: 13px; line-height: 1; }
.rail-error {
.rail-note {
margin: 10px 12px 0;
padding: 11px 14px;
padding: 10px 13px;
border: 1px solid var(--danger-deep);
border-left-width: 3px;
background: rgba(255, 77, 77, 0.07);
color: #ffd5d5;
color: #ffd9d9;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 12px;
flex: none;
}
.rail-note-aside { color: #f0b8b8; }
.rail-note-aside strong { color: #fff; font-family: var(--mono); }
.rail-note-retry {
margin-left: auto;
border: 1px solid var(--danger-deep);
background: transparent;
color: var(--danger-bright);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
min-height: 36px;
}
.rail-note-retry:hover { background: rgba(255, 77, 77, 0.12); }
.toast {
margin: 10px 12px 0;
padding: 9px 13px;
border: 1px solid var(--line-strong);
border-left: 3px solid var(--amber);
background: var(--bg-2);
color: var(--amber);
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.45;
flex: none;
animation: rise-in 0.28s ease-out both;
}
/* ── stage ───────────────────────────────────────────────────────────── */
.stage {
@ -271,31 +540,37 @@
right: 0;
bottom: 0;
z-index: 40;
max-height: 86dvh;
overflow-y: auto;
max-height: 88dvh;
display: flex;
flex-direction: column;
background: var(--bg-1);
border-top: 1px solid var(--line-strong);
border-radius: 16px 16px 0 0;
box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.55);
padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
transform: translateY(101%);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-sheet);
padding: 8px 14px calc(14px + var(--safe-bottom));
transform: translateY(102%);
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
/* the rise-in entrance is for the desktop column; the sheet is transform-
controlled, so cancel the shared keyframe here. */
animation: none !important;
}
.controls-pane.open {
transform: translateY(0);
}
.sheet-grip {
width: 38px;
width: 40px;
height: 4px;
border-radius: 99px;
background: var(--line-strong);
background: var(--line-bright);
margin: 4px auto 10px;
flex: none;
}
.controls-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
flex: none;
}
.controls-head-title {
font-family: var(--mono);
@ -305,14 +580,15 @@
color: var(--amber);
}
.sheet-close {
width: 34px;
height: 34px;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
font-size: 14px;
}
.sheet-close:active { background: var(--bg-3); }
.sheet-backdrop {
position: fixed;
@ -320,40 +596,40 @@
z-index: 30;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.55);
background: rgba(2, 4, 7, 0.62);
backdrop-filter: blur(1.5px);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s;
transition: opacity 0.24s;
}
.sheet-backdrop.show {
opacity: 1;
pointer-events: auto;
}
/* ── desktop: controls become a static side column, sheet chrome gone ── */
/* ── desktop: controls become a static side column ─────────────────────── */
@media (min-width: 900px) {
.rail {
padding: 14px 18px;
}
.rail { padding: 14px 18px; }
h1 { font-size: 19px; }
.stage {
display: grid;
grid-template-columns: minmax(0, 1fr) 372px;
grid-template-columns: minmax(0, 1fr) 384px;
gap: 16px;
padding: 16px 18px 18px;
}
.chat-pane { display: flex; }
.controls-toggle { display: none; }
.rail-btn--vm { display: none; }
.controls-pane {
position: static;
max-height: none;
overflow: visible;
transform: none;
box-shadow: none;
border: none;
border-radius: 0;
padding: 0;
z-index: auto;
animation: rise-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both !important;
animation-delay: var(--d, 0ms) !important;
}
.sheet-grip,
.controls-head,

View file

@ -1,128 +1,105 @@
<script>
import { tick } from 'svelte';
import { streamChat } from './lib/api.js';
import ToolChip from './ToolChip.svelte';
let {
sessionId = '',
sessionReady = false,
onLiveSession = (/** @type {string} */ _id) => {},
onStreamingChange = (/** @type {boolean} */ _v) => {},
tx, // the folded transcript state (plain object, see lib/transcript.js)
rev = 0, // bumped on every in-place mutation to retrigger reactivity
caughtUp = false, // replay drained → staggered reveal may run
turnActive = false, // a turn is running: show Stop, hide Send
sending = false, // a prompt POST is in flight (brief)
linkState = 'connecting', // connecting | attached | error
onSubmit = (/** @type {string} */ _p) => {},
onStop = () => {},
} = $props();
/**
* Message model. A user message is plain text. An assistant message is an
* ordered list of parts so streamed prose and tool chips interleave in the
* exact order the agent emitted them:
* { role:'assistant', parts:[{type:'text',text}|{type:'tool',name,command}],
* result?: {is_error, text, duration_ms}, error?: string }
* @type {Array<any>}
*/
let messages = $state([]);
// The five quick-action presets — the mobile win: one tap, no typing.
const PRESETS = [
{
label: 'Triage',
icon: '◑',
prompt:
'Triage the devvm: uptime, load, memory, swap, disk usage, failed systemd units, and the last 30 lines of dmesg. Summarize what\'s wrong.',
},
{
label: 'Memory / OOM',
icon: '▦',
prompt:
'Check devvm memory pressure: free -h, top memory consumers, any recent OOM-kills in dmesg/journal, and swap usage. Is it OOMing?',
},
{
label: 'Disk',
icon: '▤',
prompt:
'What\'s filling the devvm disk? df -h, then the biggest directories/files under the fullest mount. Anything safe to clear?',
},
{
label: 'Services',
icon: '⚙',
prompt:
'List failed or stuck systemd units on the devvm (systemctl --failed) and show the status + recent journal lines for any that are down.',
},
{
label: 'QEMU wedged?',
icon: '◫',
prompt:
'Is the devvm\'s QEMU wedged (I/O stall)? Check guest responsiveness over SSH, then ssh pve forensics for VM 102\'s qm status/QMP/guest-agent. Tell me if a cycle is needed.',
},
];
let draft = $state('');
let streaming = $state(false);
let scroller; // the scroll viewport
let scroller;
let inputEl;
let pinnedToBottom = true; // auto-scroll only while the user is at the bottom
let pinnedToBottom = true;
const canSend = $derived(sessionReady && !streaming && draft.trim().length > 0);
// re-derive the message list whenever the folder mutates (rev bump). The
// transcript is folded with in-place mutation on a $state.raw object, so no
// reference changes on its own — we depend on `rev` explicitly and rebuild
// fresh objects (message + its parts array) so Svelte's keyed {#each} re-
// renders streamed prose/chips on every token. Transcripts are small; the
// per-token copy is cheap and keeps the hot streaming path bug-free.
const messages = $derived(
rev >= 0 && tx
? tx.messages.map((m) =>
m.role === 'assistant' ? { ...m, parts: m.parts.slice() } : { ...m }
)
: []
);
const isEmpty = $derived(messages.length === 0);
const canSend = $derived(linkState !== 'error' && !turnActive && draft.trim().length > 0);
const inputReady = $derived(!turnActive);
// ── scrolling ─────────────────────────────────────────────────────────────
// ── auto-scroll (only while pinned to the bottom) ─────────────────────────
function onScroll() {
if (!scroller) return;
const gap = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
pinnedToBottom = gap < 60;
pinnedToBottom = gap < 64;
}
async function scrollToBottom(force = false) {
if (!force && !pinnedToBottom) return;
await tick();
if (scroller) scroller.scrollTop = scroller.scrollHeight;
}
// ── streaming a turn ────────────────────────────────────────────────────────
function lastAssistant() {
return messages[messages.length - 1];
}
function appendText(text) {
const msg = lastAssistant();
const parts = msg.parts;
const tail = parts[parts.length - 1];
if (tail && tail.type === 'text') {
tail.text += text;
} else {
parts.push({ type: 'text', text });
}
messages = messages; // notify Svelte of the in-place mutation
}
function handleEvent(ev) {
switch (ev?.kind) {
case 'session':
onLiveSession(ev.session_id);
break;
case 'text':
if (ev.text) appendText(ev.text);
break;
case 'tool': {
// Bash carries a `command`; other tools just show their name.
const command =
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
lastAssistant().parts.push({ type: 'tool', name: ev.name || 'tool', command });
messages = messages;
break;
}
case 'result':
lastAssistant().result = {
is_error: Boolean(ev.is_error),
text: typeof ev.result === 'string' ? ev.result : '',
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
};
messages = messages;
break;
case 'error':
lastAssistant().error = ev.error || 'unknown error';
messages = messages;
break;
case 'done':
// handled by the stream completing; nothing to render
break;
default:
break;
}
// any transcript change → keep the view pinned if the user is at the bottom
$effect(() => {
rev; // track
scrollToBottom();
});
function fire(prompt) {
if (turnActive) return;
pinnedToBottom = true;
onSubmit(prompt);
scrollToBottom(true);
}
async function send() {
const prompt = draft.trim();
if (!prompt || streaming || !sessionReady) return;
messages.push({ role: 'user', text: prompt });
messages.push({ role: 'assistant', parts: [] });
messages = messages;
function send() {
const text = draft.trim();
if (!text || turnActive) return;
draft = '';
streaming = true;
onStreamingChange(true);
pinnedToBottom = true;
await scrollToBottom(true);
try {
await streamChat({ session_id: sessionId, prompt }, handleEvent);
} catch (err) {
// Network/transport failure (backend down, connection dropped mid-stream).
const msg = lastAssistant();
if (msg && msg.role === 'assistant' && !msg.error) {
msg.error =
(err instanceof Error ? err.message : String(err)) +
' — the connection to the agent failed.';
messages = messages;
}
} finally {
streaming = false;
onStreamingChange(false);
await scrollToBottom();
inputEl?.focus();
}
fire(text);
// restore single-row height after clearing
tick().then(() => inputEl?.focus());
}
function onKeydown(e) {
@ -130,7 +107,7 @@
e.preventDefault();
send();
}
// Shift+Enter falls through to insert a newline.
// Shift+Enter → newline (default behaviour)
}
function fmtDuration(ms) {
@ -139,7 +116,12 @@
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
}
const isEmpty = $derived(messages.length === 0);
// a freshly-attached transcript reveals with a brief stagger; cap the delay
// so a long replay doesn't animate forever.
function revealDelay(i) {
if (!caughtUp) return 0;
return Math.min(i, 6) * 45;
}
</script>
<div class="chat">
@ -150,41 +132,58 @@
<div class="stream" bind:this={scroller} onscroll={onScroll}>
{#if isEmpty}
<div class="empty">
<div class="empty-mark"></div>
<p class="empty-title">The agent is standing by.</p>
<div class="empty" class:dim={linkState === 'connecting'}>
<div class="empty-mark" aria-hidden="true"></div>
<p class="empty-title">
{#if linkState === 'error'}
The agent is unreachable.
{:else if linkState === 'connecting'}
Attaching to the session…
{:else}
The agent is standing by.
{/if}
</p>
<p class="empty-sub">
Describe the symptom — "devvm is unreachable", "disk full", "ssh hangs"
— and it will connect over SSH, investigate, and stream its work here.
For a hard power action when the agent can't help, use
<strong>Direct VM control</strong>.
{#if linkState === 'error'}
The cluster or network may be down. You can still power-cycle the VM
with <strong>⚡ Direct VM control</strong> — it needs no agent.
{:else}
Tap a preset below or describe the symptom — "devvm unreachable",
"disk full", "ssh hangs" — and it will connect over SSH, investigate,
and stream its work here. For a hard power action, use
<strong>⚡ Direct VM control</strong>.
{/if}
</p>
</div>
{/if}
{#each messages as msg, i (i)}
{#each messages as msg (msg.key)}
{#if msg.role === 'user'}
<div class="row row--user">
<div class="row row--user rise-in" style="--d:{revealDelay(0)}ms">
<div class="bubble bubble--user">{msg.text}</div>
</div>
{:else}
<div class="row row--assistant">
<div class="row row--assistant rise-in" style="--d:{revealDelay(0)}ms">
<div class="bubble bubble--assistant">
{#if msg.parts.length === 0 && !msg.result && !msg.error}
{#if msg.parts.length === 0 && !msg.result && !msg.error && !msg.cancelled}
<span class="thinking" aria-label="working">
<span></span><span></span><span></span>
</span>
{/if}
{#each msg.parts as part, j (j)}
{#if part.type === 'text'}
<span class="prose">{part.text}</span>
{:else}
<ToolChip name={part.name} command={part.command} />
{/if}
{#if part.type === 'text'}<span class="prose">{part.text}</span>{:else}<ToolChip name={part.name} command={part.command} />{/if}
{/each}
{#if msg.error}
<div class="turn-note turn-note--error">{msg.error}</div>
<div class="turn-note turn-note--error">
<span class="turn-note-tag">error</span>
<span class="turn-note-body">{msg.error}</span>
</div>
{:else if msg.cancelled}
<div class="turn-note turn-note--muted">
<span class="turn-note-tag">stopped</span>
<span class="turn-note-body">turn cancelled</span>
</div>
{:else if msg.result}
<div class="turn-note {msg.result.is_error ? 'turn-note--error' : 'turn-note--ok'}">
<span class="turn-note-tag">{msg.result.is_error ? 'failed' : 'done'}</span>
@ -200,36 +199,61 @@
{/each}
</div>
<form
class="composer"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
{#if streaming}
<div class="working-bar" aria-live="polite">
<span class="working-dots"><span></span><span></span><span></span></span>
agent working — streaming live
</div>
{/if}
<div class="composer-row">
<textarea
bind:this={inputEl}
bind:value={draft}
onkeydown={onKeydown}
placeholder={sessionReady
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
: 'Waiting for a session…'}
rows="1"
disabled={!sessionReady || streaming}
spellcheck="false"
></textarea>
<button type="submit" class="send" disabled={!canSend}>
{streaming ? '…' : 'Send'}
</button>
<div class="dock">
<!-- quick-action preset bar: horizontally scrollable, one-tap prompts -->
<div class="presets" role="group" aria-label="Quick actions">
{#each PRESETS as p (p.label)}
<button
class="preset"
onclick={() => fire(p.prompt)}
disabled={turnActive || linkState === 'error'}
title={p.prompt}
>
<span class="preset-icon" aria-hidden="true">{p.icon}</span>
<span class="preset-label">{p.label}</span>
</button>
{/each}
</div>
</form>
<form
class="composer"
onsubmit={(e) => {
e.preventDefault();
send();
}}
>
{#if turnActive}
<div class="working-bar" aria-live="polite">
<span class="working-dots"><span></span><span></span><span></span></span>
<span>agent working — streaming live</span>
</div>
{/if}
<div class="composer-row">
<textarea
bind:this={inputEl}
bind:value={draft}
onkeydown={onKeydown}
placeholder={inputReady
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
: 'A turn is running — Stop it to type, or wait…'}
rows="1"
disabled={!inputReady}
spellcheck="false"
enterkeyhint="send"
></textarea>
{#if turnActive}
<button type="button" class="stop" onclick={onStop} title="Stop the running turn">
<span class="stop-glyph" aria-hidden="true"></span>
Stop
</button>
{:else}
<button type="submit" class="send" disabled={!canSend}>
{sending ? '···' : 'Send'}
</button>
{/if}
</div>
</form>
</div>
</div>
<style>
@ -249,9 +273,10 @@
display: flex;
align-items: baseline;
gap: 12px;
padding: 13px 18px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent);
flex: none;
}
.chat-head-label {
font-family: var(--mono);
@ -263,13 +288,16 @@
.chat-head-hint {
font-size: 12px;
color: var(--ink-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stream {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 18px 8px;
padding: 20px 16px 10px;
display: flex;
flex-direction: column;
gap: 14px;
@ -279,23 +307,27 @@
/* empty state */
.empty {
margin: auto;
max-width: 460px;
max-width: 470px;
text-align: center;
padding: 28px 12px;
padding: 24px 14px;
color: var(--ink-dim);
}
.empty.dim { opacity: 0.8; }
.empty-mark {
font-size: 40px;
font-size: 42px;
color: var(--cyan-dim);
line-height: 1;
margin-bottom: 14px;
text-shadow: 0 0 24px rgba(61, 209, 214, 0.25);
text-shadow: 0 0 26px rgba(61, 209, 214, 0.3);
animation: lamp-breathe 3.6s ease-in-out infinite;
}
@keyframes lamp-breathe { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
.empty-title {
font-family: var(--mono);
color: var(--ink);
font-size: 15px;
margin: 0 0 8px;
letter-spacing: 0.01em;
}
.empty-sub {
font-size: 13px;
@ -303,32 +335,23 @@
color: var(--ink-faint);
margin: 0;
}
.empty-sub strong {
color: var(--ink-dim);
font-weight: 600;
}
.empty-sub strong { color: var(--ink-dim); font-weight: 600; }
.row {
display: flex;
}
.row--user {
justify-content: flex-end;
}
.row--assistant {
justify-content: flex-start;
}
.row { display: flex; }
.row--user { justify-content: flex-end; }
.row--assistant { justify-content: flex-start; }
.bubble {
max-width: 86%;
max-width: 88%;
border-radius: 13px;
padding: 11px 14px;
font-size: 14px;
line-height: 1.6;
line-height: 1.62;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.bubble--user {
background: linear-gradient(180deg, #15333a, #0f262c);
background: linear-gradient(180deg, #123036, #0d2329);
border: 1px solid var(--cyan-dim);
color: #d8f6f7;
border-bottom-right-radius: 4px;
@ -341,12 +364,9 @@
border-bottom-left-radius: 4px;
color: var(--ink);
}
/* prose renders inline so text and tool chips share the same flow */
.prose {
white-space: pre-wrap;
}
.prose { white-space: pre-wrap; }
/* in-flight assistant "thinking" dots */
/* in-flight "thinking" dots */
.thinking,
.working-dots {
display: inline-flex;
@ -363,19 +383,15 @@
animation: blink 1.2s infinite ease-in-out;
}
.thinking span:nth-child(2),
.working-dots span:nth-child(2) {
animation-delay: 0.18s;
}
.working-dots span:nth-child(2) { animation-delay: 0.18s; }
.thinking span:nth-child(3),
.working-dots span:nth-child(3) {
animation-delay: 0.36s;
}
.working-dots span:nth-child(3) { animation-delay: 0.36s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
/* turn result / error footer inside the assistant bubble */
/* turn result / error / stopped footer inside the assistant bubble */
.turn-note {
margin-top: 10px;
padding: 7px 10px;
@ -396,9 +412,16 @@
color: #bff5d3;
}
.turn-note--error {
background: rgba(255, 77, 77, 0.08);
border: 1px solid var(--danger-deep);
color: #ffd5d5;
/* the error tint here is amber-leaning text on a faint warm wash, NOT the
reserved power-action red — a turn error is not a destructive action. */
background: rgba(245, 182, 87, 0.06);
border: 1px solid var(--amber-dim);
color: #f7d49a;
}
.turn-note--muted {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--line-strong);
color: var(--ink-faint);
}
.turn-note-tag {
text-transform: uppercase;
@ -409,20 +432,55 @@
border: 1px solid currentColor;
opacity: 0.85;
}
.turn-note-body {
flex: 1;
min-width: 0;
}
.turn-note-time {
margin-left: auto;
color: var(--ink-faint);
.turn-note-body { flex: 1; min-width: 0; }
.turn-note-time { margin-left: auto; color: var(--ink-faint); }
/* ── dock: presets + composer, pinned to the bottom ────────────────────── */
.dock {
flex: none;
border-top: 1px solid var(--line);
background: linear-gradient(0deg, rgba(255, 255, 255, 0.015), transparent);
}
/* ── composer ─────────────────────────────────────────────────────────── */
.presets {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 11px 12px 4px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
/* fade the right edge to hint there's more to scroll */
mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 18px), transparent 100%);
}
.presets::-webkit-scrollbar { display: none; }
.preset {
flex: none;
min-height: 38px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 13px;
border-radius: 999px;
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
font-family: var(--mono);
font-size: 12.5px;
letter-spacing: 0.02em;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s, background 0.15s, transform 0.06s;
}
.preset:hover:not(:disabled) {
border-color: var(--cyan-dim);
color: var(--ink);
background: var(--bg-3);
}
.preset:active:not(:disabled) { transform: translateY(1px); }
.preset:disabled { opacity: 0.4; }
.preset-icon { color: var(--cyan); font-size: 12px; }
.composer {
border-top: 1px solid var(--line);
padding: 12px;
background: linear-gradient(0deg, rgba(255, 255, 255, 0.012), transparent);
padding: 8px 12px calc(12px + var(--safe-bottom));
}
.working-bar {
display: flex;
@ -431,7 +489,7 @@
font-family: var(--mono);
font-size: 12px;
color: var(--amber);
padding: 0 4px 9px;
padding: 2px 4px 9px;
letter-spacing: 0.02em;
}
.composer-row {
@ -442,13 +500,13 @@
textarea {
flex: 1;
resize: none;
max-height: 168px;
max-height: 160px;
min-height: 48px;
background: var(--bg-2);
color: var(--ink);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 12px 13px;
padding: 13px 13px;
font-family: var(--sans);
/* 16px: anything smaller makes iOS Safari auto-zoom on focus (mobile is the
primary client) — the zoom then shifts the composer out of view. */
@ -458,39 +516,60 @@
transition: border-color 0.15s, box-shadow 0.15s;
field-sizing: content; /* progressive: auto-grows where supported */
}
textarea::placeholder {
color: var(--ink-faint);
}
textarea::placeholder { color: var(--ink-faint); }
textarea:focus {
border-color: var(--cyan-dim);
box-shadow: 0 0 0 3px rgba(61, 209, 214, 0.12);
}
textarea:disabled {
opacity: 0.55;
}
textarea:disabled { opacity: 0.55; }
.send {
.send,
.stop {
flex: none;
align-self: stretch;
min-width: 78px;
min-width: 82px;
min-height: 48px;
padding: 0 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--cyan-dim);
background: linear-gradient(180deg, #19474b, #103539);
color: #d8f6f7;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
transition: filter 0.15s, border-color 0.15s, opacity 0.15s;
letter-spacing: 0.05em;
transition: filter 0.15s, border-color 0.15s, opacity 0.15s, background 0.15s;
}
.send:hover:not(:disabled) {
filter: brightness(1.22);
border-color: var(--cyan);
.send {
border: 1px solid var(--cyan-dim);
background: linear-gradient(180deg, #16464a, #0e3438);
color: #d8f6f7;
}
.send:hover:not(:disabled) { filter: brightness(1.24); border-color: var(--cyan); }
.send:disabled {
opacity: 0.4;
background: var(--bg-2);
border-color: var(--line-strong);
color: var(--ink-faint);
}
/* Stop is NOT red — red is reserved for destructive VM power. Stop is a calm
neutral control with a square "halt" glyph. */
.stop {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--line-bright);
background: var(--bg-3);
color: var(--ink);
}
.stop:hover { border-color: var(--ink-faint); filter: brightness(1.1); }
.stop-glyph {
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--amber);
box-shadow: 0 0 8px rgba(245, 182, 87, 0.55);
animation: lamp-pulse 1s ease-in-out infinite;
}
@keyframes lamp-pulse {
0%, 100% { transform: scale(0.85); opacity: 0.8; }
50% { transform: scale(1.08); opacity: 1; }
}
</style>

View file

@ -293,7 +293,8 @@
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 15px;
min-height: 44px; /* touch target */
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
@ -408,7 +409,8 @@
}
.confirm-yes {
flex: 1;
padding: 9px;
min-height: 44px;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--danger-bright);
background: var(--danger);
@ -424,7 +426,8 @@
}
.confirm-no {
flex: 1;
padding: 9px;
min-height: 44px;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);

View file

@ -1,48 +1,70 @@
/*
devvm breakglass global theme
A recovery console: dark, high-contrast, terminal-adjacent. Calm by default;
danger is the only loud thing on the screen. No external fonts/CDNs system
monospace carries the identity, system sans carries readable prose.
Emergency recovery console / instrument panel. Dark, high-contrast, monospace
identity, calm by default. Danger (red) is reserved EXCLUSIVELY for the
destructive VM power actions nothing else on the screen is ever red. No
external fonts/CDNs (air-gapped cluster): a refined system-monospace stack
carries the identity, system-sans carries readable prose. Distinctiveness is
earned through composition, the living "system pulse" lamp, motion, hairlines,
and the reserved danger treatment not through a downloaded typeface.
*/
:root {
/* Surfaces — a near-black slate with cool undertone, layered for depth. */
--bg-0: #07090c; /* page base */
--bg-1: #0c1015; /* panel */
--bg-2: #11171e; /* raised panel / input */
--bg-3: #161d26; /* chips, hover */
--bg-term: #06080a; /* command-output panels */
/* Surfaces — a near-black slate with a cool undertone, layered for depth. */
--bg-0: #06080b; /* page base (darkened from #07090c for crisper AA) */
--bg-1: #0b0f14; /* panel */
--bg-2: #10161d; /* raised panel / input */
--bg-3: #161e27; /* chips, hover */
--bg-term: #05070a; /* command-output panels */
/* Hairlines & text */
--line: #1d2630;
--line: #1c2530;
--line-strong: #2a3744;
--ink: #e6edf3; /* primary text */
--ink-dim: #9bb0c0; /* secondary text */
--ink-faint: #5d7185; /* labels, meta */
--line-bright: #3a4a5a;
--ink: #e9eff5; /* primary text */
--ink-dim: #9bb0c0; /* secondary text — 8.0:1 on bg-2 */
/* labels/meta — was #5d7185 (3.6:1, fails AA). Lifted to 6.1:1 on bg-2. */
--ink-faint: #8499ab;
/* Accents */
--cyan: #3dd1d6; /* "system alive" — links, focus, session dot */
/* Accents — the "alive" cyan is the spine of the calm palette. */
--cyan: #3dd1d6; /* "system alive" — links, focus, session pulse */
--cyan-bright: #62e3e7;
--cyan-dim: #1f6f72;
--cyan-deep: #0e3133;
--amber: #f5b657; /* working / in-flight */
--amber-dim: #6a5226;
--green: #5ddb8e; /* healthy exit */
--green-dim: #1f5f3d;
/* Danger — reserved EXCLUSIVELY for mutating actions. Nothing else is red. */
/* Danger — reserved EXCLUSIVELY for mutating power actions. Nothing else red. */
--danger: #ff4d4d;
--danger-bright: #ff6363;
--danger-deep: #7a1717;
--danger-glow: rgba(255, 77, 77, 0.35);
--radius: 10px;
--radius-sm: 7px;
--radius: 11px;
--radius-sm: 8px;
--radius-lg: 16px;
--mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code",
"Fira Code", Menlo, Consolas, "Liberation Mono", monospace;
/* A refined, deliberately-ordered monospace stack. We lead with faces that
have real character (Berkeley Mono / JetBrains / Cascadia / SF Mono) and
fall back gracefully but ship nothing; whatever the device has carries
the cockpit-readout identity. */
--mono: "Berkeley Mono", ui-monospace, "JetBrains Mono", "SF Mono",
"Cascadia Code", "Fira Code", "Source Code Pro", Menlo, Consolas,
"Liberation Mono", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.02) inset,
0 16px 40px -24px rgba(0, 0, 0, 0.9);
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.025) inset,
0 18px 44px -26px rgba(0, 0, 0, 0.95);
--shadow-sheet: 0 -22px 48px -12px rgba(0, 0, 0, 0.7);
/* Safe-area shorthands (notch / home-indicator). 0px fallback off-device. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
color-scheme: dark;
}
@ -55,23 +77,24 @@ html,
body {
margin: 0;
height: 100%;
/* The page itself never scrolls the chat stream scrolls internally. This
keeps the composer pinned and stops iOS rubber-banding the whole UI. */
/* The page itself never scrolls only the chat stream scrolls internally.
This keeps the composer pinned and stops iOS rubber-banding the whole UI. */
overflow: hidden;
overscroll-behavior: none;
}
body {
background-color: var(--bg-0);
/* Atmosphere: a soft cyan corner-glow over a faint scanline weave, so the
surface reads like backlit equipment rather than flat #000. */
/* Atmosphere: a soft cyan corner-glow + a faint warm counter-glow over a
hairline scanline weave, so the surface reads as backlit equipment rather
than flat black. Fixed so it doesn't drift when the chat scrolls. */
background-image:
radial-gradient(120% 80% at 85% -10%, rgba(61, 209, 214, 0.07), transparent 55%),
radial-gradient(90% 70% at 10% 110%, rgba(245, 182, 87, 0.04), transparent 50%),
radial-gradient(120% 78% at 86% -12%, rgba(61, 209, 214, 0.08), transparent 55%),
radial-gradient(90% 70% at 8% 112%, rgba(245, 182, 87, 0.045), transparent 52%),
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.012) 0px,
rgba(255, 255, 255, 0.012) 1px,
rgba(255, 255, 255, 0.013) 0px,
rgba(255, 255, 255, 0.013) 1px,
transparent 1px,
transparent 3px
);
@ -84,8 +107,8 @@ body {
#app {
/* 100dvh (dynamic viewport height) NOT 100vh/100% so the composer at the
bottom is never hidden behind a mobile browser's address/tool bar. Mobile is
the primary client for this tool. 100vh is the fallback for old engines. */
bottom is never hidden behind a mobile browser's address/tool bar. 100vh is
the fallback for engines without dvh. Mobile is the primary client. */
height: 100vh;
height: 100dvh;
}
@ -94,7 +117,6 @@ button {
font-family: var(--mono);
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
@ -119,10 +141,26 @@ button:disabled {
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: #3a4a5a;
background: var(--line-bright);
background-clip: content-box;
}
/* Shared motion primitives
One well-orchestrated entrance beats scattered micro-interactions: panels
and rows rise a few px with a soft fade, staggered via --d on each element. */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.rise-in {
animation: rise-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--d, 0ms);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View file

@ -1,8 +1,41 @@
// Same-origin API client. Auth is handled entirely by the edge proxy
// (Authentik / basic-auth / bearer) — this UI never sends or stores a token.
import { readEventStream } from './sse.js';
// Same-origin API client for the breakglass UI.
//
// Auth is handled entirely by the edge proxy (Authentik / basic-auth / bearer):
// this UI never sends or stores a token, and builds no login screen.
//
// The chat uses the tmux/attach model. The conversation lives SERVER-SIDE; we
// only persist the session_id locally and ATTACH to it over an EventSource. The
// browser's native EventSource auto-reconnects and sends Last-Event-ID, and the
// server resumes from there — so there is ZERO reconnect logic here. We just
// render events idempotently by id (see transcript.js).
/** Open a fresh chat session. @returns {Promise<string>} session_id */
const SESSION_KEY = 'breakglass.session_id';
/** Read the persisted session id, or '' if none. */
export function loadSessionId() {
try {
return localStorage.getItem(SESSION_KEY) || '';
} catch {
return '';
}
}
/** Persist the session id (best-effort; private-mode storage may throw). */
export function saveSessionId(id) {
try {
if (id) localStorage.setItem(SESSION_KEY, id);
else localStorage.removeItem(SESSION_KEY);
} catch {
/* ignore — storage is a convenience, not a requirement */
}
}
/** Forget the persisted session id (the "New session" archive step). */
export function clearSessionId() {
saveSessionId('');
}
/** Open a fresh server-side session. @returns {Promise<string>} session_id */
export async function openSession() {
const res = await fetch('/api/session', {
method: 'POST',
@ -19,30 +52,89 @@ export async function openSession() {
}
/**
* Run one chat turn. Streams events to onEvent until the backend sends
* {kind:"done"} and the connection closes. Pass an AbortSignal to cancel.
* Attach to a session's event stream. Returns the live EventSource so the
* caller can close() it. Events arrive as:
* - default `message` events: .data is JSON {kind, id, ...}
* - a named `caught-up` event once the replay is drained (.data is {})
* - native `error` events while reconnecting (EventSource retries itself)
*
* @param {{session_id: string, prompt: string, model?: string, signal?: AbortSignal}} opts
* @param {(event: object) => void} onEvent
* @param {string} sessionId
* @param {{
* onEvent: (e: object) => void,
* onCaughtUp?: () => void,
* onOpen?: () => void,
* onError?: (e: Event) => void,
* }} handlers
* @returns {EventSource}
*/
export async function streamChat({ session_id, prompt, model, signal }, onEvent) {
const payload = { session_id, prompt };
if (model) payload.model = model;
export function attachStream(sessionId, { onEvent, onCaughtUp, onOpen, onError }) {
const es = new EventSource(`/api/session/${encodeURIComponent(sessionId)}/stream`);
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'text/event-stream',
},
body: JSON.stringify(payload),
signal,
});
await readEventStream(res, onEvent);
es.onopen = () => onOpen?.();
es.onmessage = (e) => {
if (!e || typeof e.data !== 'string' || e.data === '') return;
let obj;
try {
obj = JSON.parse(e.data);
} catch {
// A malformed frame must not abort an in-progress recovery stream.
return;
}
// EventSource exposes the SSE `id:` line as e.lastEventId. The server also
// embeds id in the JSON; prefer the JSON id, fall back to lastEventId.
if ((obj.id == null || obj.id === '') && e.lastEventId) obj.id = e.lastEventId;
onEvent(obj);
};
es.addEventListener('caught-up', () => onCaughtUp?.());
es.onerror = (e) => {
// EventSource auto-reconnects on a transient drop (readyState CONNECTING);
// we only surface a hard, terminal failure (readyState CLOSED).
onError?.(e);
};
return es;
}
/**
* List the PVE power verbs and which of them mutate VM state.
* Start a turn. Output arrives via the attach stream, NOT this response.
* @param {{session_id: string, prompt: string, model?: string}} opts
* @returns {Promise<{status:'started'|'busy'|'gone'}>}
* started accepted; busy 409 (a turn already runs); gone 404 (re-create).
*/
export async function sendPrompt({ session_id, prompt, model }) {
const payload = { prompt };
if (model) payload.model = model;
const res = await fetch(`/api/session/${encodeURIComponent(session_id)}/prompt`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.status === 409) return { status: 'busy' };
if (res.status === 404) return { status: 'gone' };
if (!res.ok) throw new Error(`could not start the turn (HTTP ${res.status})`);
return { status: 'started' };
}
/**
* Cancel the in-flight turn (the Stop button).
* @param {string} sessionId
* @returns {Promise<boolean>} whether a turn was cancelled
*/
export async function cancelTurn(sessionId) {
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}/cancel`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
});
if (!res.ok) throw new Error(`could not stop the turn (HTTP ${res.status})`);
const body = await res.json().catch(() => ({}));
return Boolean(body.cancelled);
}
/**
* List the PVE power verbs and which mutate VM state.
* @returns {Promise<{verbs: string[], mutating: string[]}>}
*/
export async function fetchVerbs() {
@ -58,27 +150,26 @@ export async function fetchVerbs() {
}
/**
* Run a PVE power verb directly (no AI in the path). The backend returns 200
* on success and 502 when the verb's exit code is non-zero, but the JSON body
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases so we
* read the body regardless of HTTP status and let the caller style on
* exit_code / rejected.
* Run a PVE power verb directly (no AI in the path). The backend returns 200 on
* success and 502 when the verb's exit code is non-zero, but the JSON body
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases so we read
* the body regardless of HTTP status and let the caller style on exit_code.
*
* @param {string} verb
* @returns {Promise<{verb: string, exit_code: number|null, stdout: string, stderr: string, rejected: boolean}>}
* @returns {Promise<{verb:string, exit_code:number|null, stdout:string, stderr:string, rejected:boolean}>}
*/
export async function runVerb(verb) {
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
});
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
let body;
try {
body = await res.json();
} catch {
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
}
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
if (res.status === 400) {
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
}

View file

@ -1,150 +0,0 @@
// SSE frame parsing — the load-bearing core of the breakglass UI.
//
// The /api/chat endpoint returns a text/event-stream that we read with
// fetch() + response.body.getReader() (NOT EventSource, which cannot POST).
// The backend emits one frame per event as:
//
// data: {json}\n\n
//
// getReader() hands us bytes at arbitrary boundaries: a single frame can be
// split across reads, and one read can contain several frames. So we keep a
// rolling text buffer, split it on the blank-line frame delimiter, and only
// hand back the JSON payload of *complete* frames. Per the SSE spec a frame may
// carry multiple `data:` lines (joined with "\n"); the backend emits single
// line JSON today, but we handle the general case so a future multi-line
// payload can't silently corrupt the stream.
/**
* Parse a single SSE event block (the text between blank lines) into its data
* payload string, or null if the block carries no `data:` field (e.g. a bare
* comment or a `:` heartbeat).
* @param {string} block
* @returns {string|null}
*/
export function dataFromEventBlock(block) {
const dataLines = [];
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/\r$/, '');
if (line.startsWith(':')) continue; // SSE comment / heartbeat
if (line === 'data:' || line === 'data') {
dataLines.push('');
} else if (line.startsWith('data:')) {
// Spec: a single leading space after the colon is stripped.
let v = line.slice('data:'.length);
if (v.startsWith(' ')) v = v.slice(1);
dataLines.push(v);
}
// field lines we don't care about (event:, id:, retry:) are ignored
}
if (dataLines.length === 0) return null;
return dataLines.join('\n');
}
/**
* A stateful splitter that turns an arbitrary sequence of decoded text chunks
* into a sequence of complete SSE event-block strings. Frames are delimited by
* a blank line; we tolerate both "\n\n" and "\r\n\r\n".
*/
export class SSEFrameSplitter {
constructor() {
this.buffer = '';
}
/**
* Feed a decoded text chunk; returns the event blocks that are now complete.
* Any trailing partial frame stays buffered for the next chunk.
* @param {string} chunk
* @returns {string[]} complete event blocks (text between delimiters)
*/
push(chunk) {
this.buffer += chunk;
const blocks = [];
// Normalise CRLF delimiters to LF so a single split rule covers both.
let idx;
// Process every complete frame currently in the buffer.
while ((idx = this._nextDelimiter()) !== -1) {
const block = this.buffer.slice(0, idx.start);
this.buffer = this.buffer.slice(idx.end);
if (block.length > 0) blocks.push(block);
}
return blocks;
}
/**
* On stream end, return whatever complete-looking content remains. A
* well-behaved backend always terminates the last frame with a blank line,
* so this is usually empty but if the connection closed mid-trailing-frame
* with a parseable block, surface it rather than dropping data.
* @returns {string[]}
*/
flush() {
const rest = this.buffer.trim();
this.buffer = '';
return rest ? [rest] : [];
}
_nextDelimiter() {
// Find the earliest of "\n\n", "\r\n\r\n", "\r\r".
const candidates = [
{ token: '\r\n\r\n', i: this.buffer.indexOf('\r\n\r\n') },
{ token: '\n\n', i: this.buffer.indexOf('\n\n') },
{ token: '\r\r', i: this.buffer.indexOf('\r\r') },
].filter((c) => c.i !== -1);
if (candidates.length === 0) return -1;
candidates.sort((a, b) => a.i - b.i);
const { token, i } = candidates[0];
return { start: i, end: i + token.length };
}
}
/**
* Read an SSE Response body to completion, invoking onEvent for every parsed
* JSON event object. Resolves when the stream ends. Throws if the response is
* not ok or has no readable body (caller shows the error inline).
*
* @param {Response} response a fetch() Response with a streaming body
* @param {(event: object) => void} onEvent called per parsed JSON event
*/
export async function readEventStream(response, onEvent) {
if (!response.ok) {
throw new Error(`server returned ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error('response has no readable body (streaming unsupported)');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const splitter = new SSEFrameSplitter();
const handleBlock = (block) => {
const payload = dataFromEventBlock(block);
if (payload == null || payload.trim() === '') return;
let obj;
try {
obj = JSON.parse(payload);
} catch {
// A malformed frame must not abort an in-progress recovery stream;
// skip it and keep reading.
return;
}
onEvent(obj);
};
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const block of splitter.push(text)) handleBlock(block);
}
} finally {
reader.releaseLock?.();
}
// Drain any trailing bytes the decoder held, then any final frame.
const tail = decoder.decode();
if (tail) {
for (const block of splitter.push(tail)) handleBlock(block);
}
for (const block of splitter.flush()) handleBlock(block);
}

View file

@ -1,152 +0,0 @@
// Standalone test of the SSE frame parser — no test framework, just node.
// Run: node src/lib/sse.test.mjs (exits non-zero on any failure)
//
// These pin the protocol described in the API contract: frames are
// `data: {json}\n\n`, the event `kind` is one of session/text/tool/result/
// error/done, and bytes arrive at arbitrary boundaries via getReader().
import { SSEFrameSplitter, dataFromEventBlock, readEventStream } from './sse.js';
let failures = 0;
function ok(name, cond) {
if (cond) {
console.log(` ok ${name}`);
} else {
failures++;
console.error(`FAIL ${name}`);
}
}
function eq(name, got, want) {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(`${name} (got ${g})`, g === w);
}
// --- dataFromEventBlock ---------------------------------------------------
eq(
'extracts JSON payload from a data: line',
dataFromEventBlock('data: {"kind":"text","text":"hi"}'),
'{"kind":"text","text":"hi"}'
);
eq(
'strips exactly one space after the colon',
dataFromEventBlock('data: leading-space-kept'),
' leading-space-kept'
);
eq('ignores comment/heartbeat lines', dataFromEventBlock(': keep-alive'), null);
eq(
'joins multi-line data fields with newline',
dataFromEventBlock('data: line1\ndata: line2'),
'line1\nline2'
);
// --- SSEFrameSplitter: whole frames --------------------------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push('data: {"kind":"session","session_id":"abc"}\n\n');
eq('one complete frame yields one block', blocks, [
'data: {"kind":"session","session_id":"abc"}',
]);
}
// --- SSEFrameSplitter: multiple frames in one chunk ----------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push(
'data: {"kind":"text","text":"a"}\n\ndata: {"kind":"text","text":"b"}\n\n'
);
eq('two frames in one chunk yield two blocks', blocks.length, 2);
eq('first block', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"a"}');
eq('second block', dataFromEventBlock(blocks[1]), '{"kind":"text","text":"b"}');
}
// --- SSEFrameSplitter: frame split across chunks -------------------------
{
const s = new SSEFrameSplitter();
let blocks = s.push('data: {"kind":"te');
eq('partial frame yields nothing yet', blocks, []);
blocks = s.push('xt","text":"split"}\n\n');
eq('completing the frame yields it whole', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"split"}');
}
// --- SSEFrameSplitter: delimiter split across chunks ---------------------
{
const s = new SSEFrameSplitter();
let blocks = s.push('data: {"kind":"done"}\n');
eq('frame held while delimiter incomplete', blocks, []);
blocks = s.push('\n');
eq('frame released once blank line completes', dataFromEventBlock(blocks[0]), '{"kind":"done"}');
}
// --- SSEFrameSplitter: CRLF delimiters -----------------------------------
{
const s = new SSEFrameSplitter();
const blocks = s.push('data: {"kind":"text","text":"crlf"}\r\n\r\n');
eq('CRLF-delimited frame parses', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"crlf"}');
}
// --- end-to-end via readEventStream over a mock streaming Response --------
function mockResponse(chunks) {
const enc = new TextEncoder();
let i = 0;
return {
ok: true,
status: 200,
body: {
getReader() {
return {
read() {
if (i < chunks.length) {
return Promise.resolve({ value: enc.encode(chunks[i++]), done: false });
}
return Promise.resolve({ value: undefined, done: true });
},
releaseLock() {},
};
},
},
};
}
await (async () => {
// A realistic turn, deliberately chopped at ugly boundaries:
// - the session frame split mid-JSON
// - two text frames glued together
// - a tool frame
// - a result frame and the terminal done frame in one chunk
const chunks = [
'data: {"kind":"sess',
'ion","session_id":"S1"}\n\n',
'data: {"kind":"text","text":"checking "}\n\ndata: {"kind":"text","text":"disk"}\n\n',
'data: {"kind":"tool","name":"Bash","input":{"command":"df -h"}}\n\n',
'data: {"kind":"result","is_error":false,"result":"ok","duration_ms":12}\n\ndata: {"kind":"done"}\n\n',
];
const events = [];
await readEventStream(mockResponse(chunks), (e) => events.push(e));
eq('event count', events.length, 6);
eq('1: session id', events[0], { kind: 'session', session_id: 'S1' });
eq('2: first text', events[1], { kind: 'text', text: 'checking ' });
eq('3: second text', events[2], { kind: 'text', text: 'disk' });
eq('4: tool kind+name', { kind: events[3].kind, name: events[3].name }, { kind: 'tool', name: 'Bash' });
eq('4: tool command', events[3].input.command, 'df -h');
eq('5: result', events[4], { kind: 'result', is_error: false, result: 'ok', duration_ms: 12 });
eq('6: done terminal', events[5], { kind: 'done' });
})();
// malformed frame in the middle must be skipped, not abort the stream
await (async () => {
const chunks = [
'data: {"kind":"text","text":"before"}\n\n',
'data: {this is not json}\n\n',
'data: {"kind":"done"}\n\n',
];
const events = [];
await readEventStream(mockResponse(chunks), (e) => events.push(e));
eq('malformed frame skipped, stream continues', events.map((e) => e.kind), ['text', 'done']);
})();
if (failures) {
console.error(`\n${failures} assertion(s) FAILED`);
process.exit(1);
}
console.log('\nall SSE parser assertions passed');

View file

@ -0,0 +1,196 @@
// transcript.js — the load-bearing core of the breakglass UI.
//
// The attach stream (EventSource) replays the conversation-so-far and then
// tails live. Replayed events are byte-identical to live ones, and on a
// reconnect the server re-replays from Last-Event-ID — so the SAME event id can
// arrive more than once. This module folds a flat, possibly-duplicated event
// sequence into an ordered list of render-ready messages, idempotently.
//
// Contract (every default `message` event's .data is one of these JSON shapes):
// {kind:"user", text, id} → opens a USER bubble
// {kind:"session", session_id, id} → informational (agent's session id)
// {kind:"text", text, id} → assistant prose; concatenated
// {kind:"tool", name, input, id} → inline tool chip (Bash → command)
// {kind:"result", is_error, result, duration_ms, id} → closes the bubble
// {kind:"error", error, id} → error note on the bubble
// {kind:"cancelled", id} → muted "stopped" note
// {kind:"turn_end", id} → the turn finished
//
// Grouping: a `user` event opens a user message; the session/text/tool events
// that follow build ONE assistant message; result/error/cancelled annotate it;
// turn_end ends it. Assistant events with no preceding user (e.g. a session
// banner on a fresh attach) still get an assistant message so nothing is lost.
//
// Idempotency: every event carries a monotonic integer-ish id. We track the
// max id folded so far and DROP any event whose id we've already passed — a
// reconnect replay therefore never double-renders. Ids are compared
// numerically when both parse as numbers, else as strings (defensive).
/** @typedef {{type:'text',text:string}|{type:'tool',name:string,command:string,raw:any}} Part */
/**
* @typedef {Object} Message
* @property {'user'|'assistant'} role
* @property {string} key stable key for keyed {#each}
* @property {string} [text] user text
* @property {Part[]} [parts] assistant parts, in emit order
* @property {{is_error:boolean,text:string,duration_ms:number|null}} [result]
* @property {string} [error]
* @property {boolean} [cancelled]
* @property {boolean} [ended] turn_end seen for this message
*/
/** Compare two ids; numeric when both look numeric, else lexicographic. */
export function idGreater(a, b) {
const na = Number(a);
const nb = Number(b);
if (Number.isFinite(na) && Number.isFinite(nb) && `${a}`.trim() !== '' && `${b}`.trim() !== '') {
return na > nb;
}
return String(a) > String(b);
}
/**
* Create an empty transcript-folding state.
* @returns {{messages: Message[], maxId: any, sawId: boolean, openAssistant: Message|null, activeUserSeen: boolean}}
*/
export function createTranscript() {
return {
messages: [],
maxId: null,
sawId: false,
openAssistant: null,
// a turn is "active" once a user event (or local prompt) has no following
// turn_end; the UI reads `active` from reduceEvent's return.
activeUserSeen: false,
};
}
function bubbleKey(prefix, id, fallbackIndex) {
if (id != null && `${id}`.trim() !== '') return `${prefix}:${id}`;
return `${prefix}:idx:${fallbackIndex}`;
}
/**
* Should this event be applied, given the max id folded so far? Updates and
* returns the new max. Events WITHOUT an id are always applied (and don't move
* the watermark) the protocol always carries ids, but we never drop data on a
* malformed frame.
* @returns {{apply:boolean, maxId:any}}
*/
export function admit(maxId, id) {
if (id == null || `${id}`.trim() === '') return { apply: true, maxId };
if (maxId == null) return { apply: true, maxId: id };
if (idGreater(id, maxId)) return { apply: true, maxId: id };
return { apply: false, maxId }; // already seen — dedupe
}
/**
* Fold one event into the transcript state, mutating `state` in place.
* Returns true if the state changed (so callers can trigger a re-render).
*
* @param {ReturnType<typeof createTranscript>} state
* @param {any} ev parsed event object ({kind, id, ...})
* @returns {boolean} changed
*/
export function reduceEvent(state, ev) {
if (!ev || typeof ev !== 'object') return false;
const { apply, maxId } = admit(state.maxId, ev.id);
state.maxId = maxId;
if (!apply) return false;
if (ev.id != null && `${ev.id}`.trim() !== '') state.sawId = true;
const ensureAssistant = () => {
if (!state.openAssistant) {
const msg = {
role: 'assistant',
key: bubbleKey('a', ev.id, state.messages.length),
parts: [],
ended: false,
};
state.messages.push(msg);
state.openAssistant = msg;
}
return state.openAssistant;
};
switch (ev.kind) {
case 'user': {
// A new user turn. Close any dangling assistant bubble first.
state.openAssistant = null;
state.messages.push({
role: 'user',
key: bubbleKey('u', ev.id, state.messages.length),
text: typeof ev.text === 'string' ? ev.text : '',
});
state.activeUserSeen = true;
return true;
}
case 'session': {
// Informational — does not itself render a part, but it does open the
// assistant bubble for the turn so subsequent text lands in one place.
ensureAssistant();
return true;
}
case 'text': {
if (typeof ev.text !== 'string' || ev.text === '') return false;
const msg = ensureAssistant();
const tail = msg.parts[msg.parts.length - 1];
if (tail && tail.type === 'text') {
tail.text += ev.text; // concatenate consecutive prose
} else {
msg.parts.push({ type: 'text', text: ev.text });
}
return true;
}
case 'tool': {
const msg = ensureAssistant();
const command =
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
msg.parts.push({
type: 'tool',
name: typeof ev.name === 'string' && ev.name ? ev.name : 'tool',
command,
raw: ev.input ?? null,
});
return true;
}
case 'result': {
const msg = ensureAssistant();
msg.result = {
is_error: Boolean(ev.is_error),
text: typeof ev.result === 'string' ? ev.result : '',
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
};
return true;
}
case 'error': {
const msg = ensureAssistant();
msg.error = typeof ev.error === 'string' && ev.error ? ev.error : 'unknown error';
return true;
}
case 'cancelled': {
const msg = ensureAssistant();
msg.cancelled = true;
return true;
}
case 'turn_end': {
if (state.openAssistant) state.openAssistant.ended = true;
state.openAssistant = null;
state.activeUserSeen = false;
return true;
}
default:
return false;
}
}
/**
* Convenience: fold an array of events into a fresh transcript (used by tests
* and by a from-scratch render). Returns the final state.
* @param {any[]} events
*/
export function foldAll(events) {
const state = createTranscript();
for (const ev of events) reduceEvent(state, ev);
return state;
}

View file

@ -0,0 +1,162 @@
// Standalone test of the transcript folder — no test framework, just node.
// Run: node src/lib/transcript.test.mjs (exits non-zero on any failure)
//
// These pin the attach-model contract: events carry monotonic ids, a reconnect
// re-replays already-seen ids (which MUST be deduped), and events group into
// user/assistant messages with consecutive prose concatenated.
import {
admit,
idGreater,
reduceEvent,
createTranscript,
foldAll,
} from './transcript.js';
let failures = 0;
function ok(name, cond) {
if (cond) {
console.log(` ok ${name}`);
} else {
failures++;
console.error(`FAIL ${name}`);
}
}
function eq(name, got, want) {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
ok(`${name} (got ${g})`, g === w);
}
// --- id comparison --------------------------------------------------------
ok('idGreater numeric', idGreater(10, 9) === true);
ok('idGreater numeric not', idGreater(2, 10) === false); // not string "2" > "10"
ok('idGreater string fallback', idGreater('b', 'a') === true);
// --- admit / dedupe watermark --------------------------------------------
{
let { apply, maxId } = admit(null, 1);
eq('first id admitted', { apply, maxId }, { apply: true, maxId: 1 });
({ apply, maxId } = admit(5, 5));
ok('equal id rejected (already seen)', apply === false && maxId === 5);
({ apply, maxId } = admit(5, 3));
ok('lower id rejected', apply === false && maxId === 5);
({ apply, maxId } = admit(5, 6));
ok('higher id admitted, watermark moves', apply === true && maxId === 6);
({ apply, maxId } = admit(5, undefined));
ok('id-less event always admitted, watermark held', apply === true && maxId === 5);
}
// --- a full turn groups into user + one assistant bubble ------------------
{
const events = [
{ kind: 'user', text: 'triage it', id: 1 },
{ kind: 'session', session_id: 'S1', id: 2 },
{ kind: 'text', text: 'Checking ', id: 3 },
{ kind: 'text', text: 'disk usage.', id: 4 },
{ kind: 'tool', name: 'Bash', input: { command: 'df -h' }, id: 5 },
{ kind: 'result', is_error: false, result: 'ok', duration_ms: 1200, id: 6 },
{ kind: 'turn_end', id: 7 },
];
const s = foldAll(events);
eq('two messages: user + assistant', s.messages.length, 2);
eq('first is user with text', { r: s.messages[0].role, t: s.messages[0].text }, { r: 'user', t: 'triage it' });
const a = s.messages[1];
eq('assistant role', a.role, 'assistant');
// consecutive text concatenated into ONE part; tool is a separate part
eq('parts: one concatenated text + one tool', a.parts.map((p) => p.type), ['text', 'tool']);
eq('prose concatenated in order', a.parts[0].text, 'Checking disk usage.');
eq('tool command captured', a.parts[1].command, 'df -h');
eq('result attached', { e: a.result.is_error, ms: a.result.duration_ms }, { e: false, ms: 1200 });
ok('turn ended', a.ended === true);
ok('no longer active after turn_end', s.activeUserSeen === false);
}
// --- reconnect replay: re-feeding the SAME events must NOT double-render --
{
const events = [
{ kind: 'user', text: 'hi', id: 1 },
{ kind: 'text', text: 'hello', id: 2 },
{ kind: 'turn_end', id: 3 },
];
const s = createTranscript();
for (const e of events) reduceEvent(s, e);
// simulate an EventSource reconnect that re-replays everything from the top
for (const e of events) reduceEvent(s, e);
eq('still exactly two messages after replay', s.messages.length, 2);
eq('assistant prose not doubled', s.messages[1].parts[0].text, 'hello');
}
// --- a partial replay (Last-Event-ID resume) continues the same bubble ----
{
const s = createTranscript();
reduceEvent(s, { kind: 'user', text: 'go', id: 1 });
reduceEvent(s, { kind: 'text', text: 'part-A ', id: 2 });
// reconnect: server resumes after id 2; we must drop id<=2 if re-sent and
// keep appending to the open assistant bubble.
reduceEvent(s, { kind: 'text', text: 'part-A ', id: 2 }); // dup, dropped
reduceEvent(s, { kind: 'text', text: 'part-B', id: 3 }); // new, appended
reduceEvent(s, { kind: 'turn_end', id: 4 });
eq('resume appended to same bubble', s.messages[1].parts[0].text, 'part-A part-B');
eq('still two messages', s.messages.length, 2);
}
// --- error / cancelled annotate the open bubble ---------------------------
{
const s = foldAll([
{ kind: 'user', text: 'x', id: 1 },
{ kind: 'text', text: 'working', id: 2 },
{ kind: 'error', error: 'ssh timeout', id: 3 },
{ kind: 'turn_end', id: 4 },
]);
eq('error note on assistant bubble', s.messages[1].error, 'ssh timeout');
}
{
const s = foldAll([
{ kind: 'user', text: 'x', id: 1 },
{ kind: 'cancelled', id: 2 },
{ kind: 'turn_end', id: 3 },
]);
ok('cancelled flag on assistant bubble', s.messages[1].cancelled === true);
}
// --- active state: a user event with no turn_end means a turn is running ---
{
const s = createTranscript();
reduceEvent(s, { kind: 'user', text: 'go', id: 1 });
reduceEvent(s, { kind: 'text', text: '...', id: 2 });
ok('active while no turn_end', s.activeUserSeen === true);
reduceEvent(s, { kind: 'turn_end', id: 3 });
ok('inactive after turn_end', s.activeUserSeen === false);
}
// --- assistant-only stream (session banner on a fresh attach) still renders -
{
const s = foldAll([
{ kind: 'session', session_id: 'S1', id: 1 },
{ kind: 'text', text: 'standing by', id: 2 },
{ kind: 'turn_end', id: 3 },
]);
eq('lone assistant message created', s.messages.length, 1);
eq('assistant prose present', s.messages[0].parts[0].text, 'standing by');
}
// --- two sequential turns produce two assistant bubbles -------------------
{
const s = foldAll([
{ kind: 'user', text: 'q1', id: 1 },
{ kind: 'text', text: 'a1', id: 2 },
{ kind: 'turn_end', id: 3 },
{ kind: 'user', text: 'q2', id: 4 },
{ kind: 'text', text: 'a2', id: 5 },
{ kind: 'turn_end', id: 6 },
]);
eq('four messages (u,a,u,a)', s.messages.map((m) => m.role), ['user', 'assistant', 'user', 'assistant']);
eq('second answer in its own bubble', s.messages[3].parts[0].text, 'a2');
ok('message keys are unique', new Set(s.messages.map((m) => m.key)).size === 4);
}
if (failures) {
console.error(`\n${failures} assertion(s) FAILED`);
process.exit(1);
}
console.log('\nall transcript assertions passed');

View file

@ -1,174 +1,251 @@
"""Tests for the breakglass app: verb whitelist, SSE translation, auth, routes."""
"""Tests for the breakglass app: session manager (attach model), verb whitelist,
SSE translation, auth, routes."""
import os
os.environ.setdefault("API_BEARER_TOKEN", "test-token")
# Turns chdir into a per-session workspace; point it somewhere writable for tests
# (prod uses the /workspace emptyDir). Must be set before the app imports config.
os.environ.setdefault("BREAKGLASS_SESSIONS_DIR", "/tmp/bg-test-sessions")
import pytest
from fastapi.testclient import TestClient
from app.breakglass import agent_session, pve
from app.breakglass import agent_session, pve, session as sessionmod
from app.breakglass.server import app
# --------------------------------------------------------------------------- #
# PVE verb whitelist — the security boundary mirrored client-side.
# Fakes for the claude subprocess a turn spawns.
# --------------------------------------------------------------------------- #
class _FakeStdout:
def __init__(self, lines):
self._lines = [(l + "\n").encode() for l in lines]
self._i = 0
def __aiter__(self):
return self
async def __anext__(self):
if self._i >= len(self._lines):
raise StopAsyncIteration
line = self._lines[self._i]
self._i += 1
return line
class _FakeStderr:
async def read(self):
return b""
class _FakeProc:
def __init__(self, lines, rc=0):
self.stdout = _FakeStdout(lines)
self.stderr = _FakeStderr()
self.returncode = None
self._rc = rc
async def wait(self):
self.returncode = self._rc
return self._rc
def kill(self):
self.returncode = -9
def _patch_proc(monkeypatch, lines, rc=0):
async def _fake_spawn(*argv, **kwargs):
return _FakeProc(lines, rc)
monkeypatch.setattr(sessionmod.asyncio, "create_subprocess_exec", _fake_spawn)
_TURN_LINES = [
'{"type":"system","subtype":"init","session_id":"s"}',
'{"type":"system","subtype":"thinking_tokens","estimated_tokens":5}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"checking disk"}]}}',
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"df -h"}}]}}',
'{"type":"result","is_error":false,"result":"done","duration_ms":12}',
]
# --------------------------------------------------------------------------- #
# Session: event log + broadcast + replay/Last-Event-ID.
# --------------------------------------------------------------------------- #
def test_add_event_assigns_sequential_ids():
s = sessionmod.Session("s1")
a = s.add_event({"kind": "user", "text": "hi"})
b = s.add_event({"kind": "text", "text": "yo"})
assert a["id"] == 0 and b["id"] == 1
assert [e["kind"] for e in s.events] == ["user", "text"]
def test_subscribe_receives_broadcast():
s = sessionmod.Session("s1")
q = s.subscribe()
s.add_event({"kind": "text", "text": "live"})
assert q.get_nowait()["text"] == "live"
s.unsubscribe(q)
s.add_event({"kind": "text", "text": "after"})
assert q.empty()
@pytest.mark.asyncio
async def test_attach_replays_then_signals_caught_up():
s = sessionmod.Session("s1")
s.add_event({"kind": "user", "text": "diagnose"})
s.add_event({"kind": "text", "text": "looking"})
frames = []
async for frame in sessionmod.attach_stream(s, last_event_id=None):
frames.append(frame)
if "caught-up" in frame:
break
body = "".join(frames)
assert "diagnose" in body and "looking" in body
assert "id: 0" in body and "id: 1" in body
assert "event: caught-up" in frames[-1]
@pytest.mark.asyncio
async def test_attach_reconnect_replays_only_missed():
s = sessionmod.Session("s1")
for i in range(3):
s.add_event({"kind": "text", "text": f"e{i}"}) # ids 0,1,2
frames = []
async for frame in sessionmod.attach_stream(s, last_event_id=0): # already saw id 0
frames.append(frame)
if "caught-up" in frame:
break
body = "".join(frames)
assert "e0" not in body # not re-sent
assert "e1" in body and "e2" in body
# --------------------------------------------------------------------------- #
# Session: running a detached turn (mocked subprocess).
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_turn_streams_events_into_log(monkeypatch):
_patch_proc(monkeypatch, _TURN_LINES)
s = sessionmod.Session("s1")
assert s.start_turn("diagnose the devvm") is True
await s._turn # wait for the detached turn to finish
kinds = [e["kind"] for e in s.events]
assert kinds[0] == "user"
assert "session" in kinds and "text" in kinds and "tool" in kinds
assert "result" in kinds and kinds[-1] == "turn_end"
assert "thinking_tokens" not in kinds
@pytest.mark.asyncio
async def test_one_turn_at_a_time(monkeypatch):
_patch_proc(monkeypatch, _TURN_LINES)
s = sessionmod.Session("s1")
assert s.start_turn("first") is True
assert s.start_turn("second") is False # task not done yet
await s._turn
@pytest.mark.asyncio
async def test_resume_after_first_turn(monkeypatch):
captured = {"argvs": []}
async def _fake_spawn(*argv, **kwargs):
captured["argvs"].append(argv)
return _FakeProc(_TURN_LINES)
monkeypatch.setattr(sessionmod.asyncio, "create_subprocess_exec", _fake_spawn)
s = sessionmod.Session("s1")
s.start_turn("first"); await s._turn
s.start_turn("second"); await s._turn
assert "--session-id" in captured["argvs"][0]
assert "--resume" in captured["argvs"][1]
# --------------------------------------------------------------------------- #
# SessionManager.
# --------------------------------------------------------------------------- #
def test_manager_create_get():
m = sessionmod.SessionManager()
s = m.create()
assert m.get(s.id) is s
assert m.get("nope") is None
assert m.get_or_create(s.id) is s
assert m.get_or_create(None).id != s.id
# --------------------------------------------------------------------------- #
# PVE verb whitelist (unchanged security boundary).
# --------------------------------------------------------------------------- #
def test_allowed_verbs_match_host_script():
assert pve.ALLOWED_VERBS == {
"status", "forensics", "reset", "stop", "start", "cycle"
}
assert pve.ALLOWED_VERBS == {"status", "forensics", "reset", "stop", "start", "cycle"}
assert pve.MUTATING_VERBS == {"reset", "stop", "start", "cycle"}
assert pve.MUTATING_VERBS < pve.ALLOWED_VERBS
@pytest.mark.parametrize("bad", [
"rm -rf /", "status; rm -rf /", "status 103", "shutdown", "", "STATUS",
"cycle 999", "$(reboot)", "../start",
])
@pytest.mark.parametrize("bad", ["rm -rf /", "status; reboot", "status 103", "", "STATUS"])
@pytest.mark.asyncio
async def test_run_verb_rejects_non_whitelisted_without_ssh(bad, monkeypatch):
"""A bad verb must be rejected locally — never spawning a subprocess."""
called = False
async def _boom(*a, **k):
nonlocal called
called = True
raise AssertionError("ssh must not run for a rejected verb")
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _boom)
result = await pve.run_verb(bad)
assert result["rejected"] is True
assert result["exit_code"] is None
assert called is False
@pytest.mark.asyncio
async def test_run_verb_allowed_invokes_ssh_with_bare_verb(monkeypatch):
captured = {}
class _FakeProc:
returncode = 0
async def communicate(self):
return (b"status: running\n", b"")
async def _fake_exec(*argv, **kwargs):
captured["argv"] = argv
return _FakeProc()
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _fake_exec)
result = await pve.run_verb("status")
assert result["rejected"] is False
assert result["exit_code"] == 0
assert "running" in result["stdout"]
# The verb is the LAST argv element, passed as a single token (no shell).
assert captured["argv"][-1] == "status"
assert captured["argv"][0] == "ssh"
# --------------------------------------------------------------------------- #
# stream-json -> UI event translation (pure function).
# translate_event (pure).
# --------------------------------------------------------------------------- #
def test_translate_init_to_session():
ev = agent_session.translate_event(
def test_translate_init_and_noise_and_blocks():
assert agent_session.translate_event(
{"type": "system", "subtype": "init", "session_id": "abc"}
) == {"kind": "session", "session_id": "abc"}
assert agent_session.translate_event({"type": "system", "subtype": "hook_started"}) is None
assert agent_session.translate_event(
{"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}
) == {"kind": "text", "text": "hi"}
tool = agent_session.translate_event(
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {"command": "df -h"}}]}}
)
assert ev == {"kind": "session", "session_id": "abc"}
@pytest.mark.parametrize("noise", [
{"type": "system", "subtype": "hook_started"},
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 5},
{"type": "user", "message": {"content": []}},
{"type": "unknown"},
])
def test_translate_drops_noise(noise):
assert agent_session.translate_event(noise) is None
def test_translate_assistant_text():
ev = agent_session.translate_event({
"type": "assistant",
"message": {"content": [{"type": "text", "text": "checking disk"}]},
})
assert ev == {"kind": "text", "text": "checking disk"}
def test_translate_assistant_tool_use():
ev = agent_session.translate_event({
"type": "assistant",
"message": {"content": [
{"type": "tool_use", "name": "Bash", "input": {"command": "df -h"}}
]},
})
assert ev["kind"] == "tool"
assert ev["name"] == "Bash"
assert ev["input"]["command"] == "df -h"
def test_translate_result():
ev = agent_session.translate_event({
"type": "result", "is_error": False, "result": "done", "duration_ms": 1234,
})
assert ev == {"kind": "result", "is_error": False, "result": "done", "duration_ms": 1234}
assert tool["kind"] == "tool" and tool["input"]["command"] == "df -h"
# --------------------------------------------------------------------------- #
# Routes + auth.
# --------------------------------------------------------------------------- #
client = TestClient(app)
AUTH = {"Authorization": "Bearer test-token"}
def test_health_no_auth():
r = client.get("/health")
assert r.status_code == 200
assert r.json()["service"] == "claude-breakglass"
assert client.get("/health").json()["service"] == "claude-breakglass"
def test_api_requires_auth():
assert client.post("/api/session").status_code == 401
assert client.get("/api/pve/verbs").status_code == 401
assert client.post("/api/session/x/prompt", json={"prompt": "hi"}).status_code == 401
def test_api_accepts_bearer():
def test_session_create_and_unknown_session_404():
r = client.post("/api/session", headers=AUTH)
assert r.status_code == 200
assert "session_id" in r.json()
assert r.status_code == 200 and "session_id" in r.json()
assert client.post("/api/session/nope/prompt", headers=AUTH, json={"prompt": "x"}).status_code == 404
assert client.post("/api/session/nope/cancel", headers=AUTH).status_code == 404
def test_api_accepts_authentik_header():
r = client.post("/api/session", headers={"X-authentik-username": "me@viktorbarzin.me"})
assert r.status_code == 200
def test_prompt_starts_turn(monkeypatch):
monkeypatch.setattr(sessionmod.Session, "start_turn", lambda self, *a, **k: True)
sid = client.post("/api/session", headers=AUTH).json()["session_id"]
r = client.post(f"/api/session/{sid}/prompt", headers=AUTH, json={"prompt": "diagnose"})
assert r.status_code == 200 and r.json()["status"] == "started"
def test_pve_verb_route_rejects_unknown():
r = client.post("/api/pve/destroy", headers=AUTH)
assert r.status_code == 400
def test_prompt_409_when_turn_active(monkeypatch):
monkeypatch.setattr(sessionmod.Session, "start_turn", lambda self, *a, **k: False)
sid = client.post("/api/session", headers=AUTH).json()["session_id"]
r = client.post(f"/api/session/{sid}/prompt", headers=AUTH, json={"prompt": "x"})
assert r.status_code == 409
def test_pve_verbs_listing():
r = client.get("/api/pve/verbs", headers=AUTH)
assert r.status_code == 200
body = r.json()
assert set(body["verbs"]) == pve.ALLOWED_VERBS
assert set(body["mutating"]) == pve.MUTATING_VERBS
def test_chat_streams_sse(monkeypatch):
async def _fake_turn(session_id, prompt, model=None):
yield {"kind": "session", "session_id": session_id}
yield {"kind": "text", "text": "hello"}
yield {"kind": "result", "is_error": False, "result": "ok"}
monkeypatch.setattr(agent_session, "run_turn", _fake_turn)
r = client.post("/api/chat", headers=AUTH,
json={"session_id": "s1", "prompt": "diagnose"})
assert r.status_code == 200
assert "text/event-stream" in r.headers["content-type"]
body = r.text
assert "hello" in body
assert '"kind": "done"' in body # terminal frame always emitted
def test_pve_verbs_listing_and_unknown_rejected():
assert set(client.get("/api/pve/verbs", headers=AUTH).json()["verbs"]) == pve.ALLOWED_VERBS
assert client.post("/api/pve/destroy", headers=AUTH).status_code == 400