← Design · c138

research/live-viewer.md

The live viewer: inventory, contract and weaknesses

Surveyed: 2026-11-11, by a read-only agent, for this cycle's plan. Nothing was modified, built or run. Source: the UI is qdash's Live Activity page in ../qcontrol/crates/qdash/ui/app/ on feat/new-pages (28182424c); the backend is qcontrol origin/main at 9e6cd55a0 (2026-11-10). See the index for how to read the backend again. Standing: a reading at those commits. Line numbers drift. Everything is read from code unless marked (inferred). qsinks and qconfig were not in the backend snapshot. Checked afterwards: the data directory on macOS is ~/Library/Application Support/com.qpoint.qcontrol (default_data_dir_for_home in crates/qconfig/src/lib.rs on main), as section 2.2 says. Everything below is read from code unless marked (inferred).

Roots

  • UI: = ../qcontrol/crates/qdash/ui/app/ (branch feat/new-pages, HEAD 28182424c; merge-base with main is 8e98cdd8e, 2026-10-04).
  • BE: = the root of qcontrol at origin/main, 9e6cd55a0 (2026-11-10).
  • crates/qdash/src/hub.rs and live.rs are byte-identical between the UI's tree and the snapshot, and the events_stream handler is unchanged. Only qevents drifted (section 2.5).

1. Feature inventory

Shell (UI:pages/live.vue, 111 lines)

  • Route /live. The sidebar entry is flagged experimental (UI:components/shell/AppSidebar.vue:143), so the page only shows with the Experimental toggle on.
  • Composition: DropZone → header + StreamStatus → file-error banner → Presence → WorkspaceTabs | RetentionControls → SplitPaneFilterBar + EventStream | TallyPanel + EventDetail.
  • Tab, details width (560), filters, search and sort direction are page-local and reset on navigation. Buffer, tally and presence are globalThis singletons: they survive navigation and are lost on reload (UI:composables/live/stream.ts:65-105).

Stream status (UI:components/live/StreamStatus.vue:54-73)

  • SseStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' (UI:lib/live/sse-protocol.ts:8).
  • Badges: connecting (neutral, pulsing), connected shown as "live" (green), reconnecting (amber, pulsing), disconnected (red), file mode shown as file · <name>.
  • Buttons: "Open JSONL" always; "Return to live" in file mode.
  • disconnected is only reached by stop() or a worker runtime error (UI:lib/live/sse-worker-client.ts:56-65, 90-93). A dead or static backend therefore shows "reconnecting" forever.
  • There is no error text, last-event time, rate, or dropped count.

Presence row / mini agent cards (UI:lib/live/agent-presence.ts, UI:components/live/{Presence,MiniAgentCards}.vue, layer components/agent/MiniCard.vue)

  • Hidden by default. A "Show/Hide harnesses" toggle appears only when cards exist; persisted in localStorage qdash.live.agentsVisible.v1.
  • One card per process entity_id (legacy fallback run.id), not per agent or session. Cards sort by firstSeenAtMs so they never swap slots.
  • A card shows:
    • pawn state: working | idle | errored | needs-input;
    • folder (basename of cwd), clickable to reveal;
    • agent logo and label;
    • Requests = count of llm.response;
    • Files total plus Read/Write/Edit = agent.tool_call whose tool name is Read/Write/Edit (:108-112);
    • Tools & Skills: first 3, then "+N more". Skills are dug from a builtin Skill call's arguments.skill.
  • Status precedence (:405-411): errored > needs-input > working > idle.
    • errored: lastErrorAtMs within ERROR_WINDOW_MS 60 s. Set by a non-completed llm.response, mcp.error, or run.adapter_error; cleared by a later completed response.
    • needs-input: last signal was a completed response and the last tool was in QUESTION_TOOLS = {AskUserQuestion, ExitPlanMode} (:100).
    • working: turn in flight, or activity within WORKING_WINDOW_MS 10 s.
  • A card is dropped when quiet for PRESENCE_WINDOW_MS 5 min, when it has no agent attribution, or when ended. End signals: process.stopped by entity or pid, absence from a process.snapshot, run.ended, or root-pid exec.exit.
  • A 1 Hz ticker drives transitions (UI:composables/live/useAgentPresence.ts:28-34).
  • Actions:
    • "View Details" toggles the card's agentId into the stream filter (selected ring).
    • "Go to Agent" (also the needs-input bubble) calls POST /api/agent/focus; on any rejection it navigates to /inventory/agents/detail?agentId=<entityId>&from=live (live.vue:94).
    • Folder click calls POST /api/agent/reveal; failure is a silent no-op.

Workspace tabs (UI:components/live/WorkspaceTabs.vue)

  • "Events N" is the buffer length after eviction; "Tally N" is the never-evicting total.

Filter bar and popover (UI:components/live/FilterBar.vue, FilterPopover.vue, UI:composables/live/eventList.ts:50-96, UI:lib/live/event-table-projections.ts)

  • Model: EventFilterSelection { agents: Set, families: Set, types: Set } plus a search string.
  • Semantics (filterTableEvents :128-144): the selected row always passes; agents are OR-ed; a non-empty type set overrides the family axis entirely; search is a case-insensitive substring.
  • Search covers 12 fields only (:221-239): type, family, summary, entityId, process.name/pid/installationId, run.id/agentId/runPid, resolved agent id, observedAt. It does not search the payload, and summary uses renamed keys (cost=, tool=), so wire keys miss.
  • Bar: search box ("Search visible fields"), "Filter · N active" popover, removable chips per agent/family/type, Reset.
  • Two rails with counts: Harnesses (agent ids) and Events (families). Counts are over the whole buffer, not faceted.
  • Popover: Harness checkboxes, then a tri-state family → type tree with counts (buildEventTreeNodes :95-114), "Clear all". It closes on outside pointerdown; there is no Esc.
  • Filter-from-row: clicking the Harness cell toggles that agent.
  • Missing dimensions: severity (parsed, never shown or filterable), entity, session, pid, time, negation, regex, saved filters.

Firehose table (UI:components/live/EventTable.vue, EventStream.vue)

  • Columns (grid at EventTable.vue:213):
ColumnContent
TimeHH:MM:SS local, no milliseconds; header toggles newest-top/bottom
Seqsequence ?? line
Eventtype chip toned by family
Harnessresolved agent id, plus (runPid/agentPid) or (pid)
Summarykey=value parts, truncated
  • Row projection is LiveEventRow = LogEvent & { agentId, summaryParts } (UI:lib/live/event-rows.ts).
  • Per-family preview extractors (UI:lib/live/events.ts:211-234):
    • have extractors: installation.discovered/snapshot, process.*, agent.*, llm.*, mcp.*, plugin.*, run.*, file.*, exec.*, connection.*, http.*, sse.*, websocket.*;
    • other installation.* and auto_tap.* get an empty preview, so Summary shows "-";
    • unknown types show the first four payload keys.
  • Display renames at :620-642: cost_usd→cost (as $0.0000), duration_ms→duration, tool_name→tool, shortened call_id.
  • Virtualisation: fixed 44 px rows, overscan 10, spacer divs (UI:lib/live/virtual-rows.ts, UI:composables/lib/useVirtualRows.ts).
  • Live-follow (EventStream.vue:61-151):
    • default is newest-top;
    • follows the live edge until the user scrolls more than 2 px away or selects a row;
    • then a banner shows "N new events · Paused" with "Jump to live". N comes from the monotonic ingestedCount.
    • "Paused" only stops auto-scroll; ingestion and eviction continue.
  • Selection: click, Enter or Space. It pushes #event=<id> through the raw history API (UI:composables/live/selection.ts). The selected event is protected from eviction and from filters.
  • There is no arrow-key navigation.

Detail sidebar (UI:components/live/EventDetail.vue, UI:lib/live/event-context.ts)

  • Header: [seq] type in the family hue, Copy JSON (pretty-printed event.raw), close.
  • Context chips in order: Harness (filter agent), Entity, Install, Run, Run PID, Harness PID or PID (all set the search), Family (toggle), Type (drill: clears families, sets the type).
  • Preview: key/value table.
  • Payload: JsonTree over the whole raw envelope, expanded to depth 2. Above 200,000 characters it falls back to a <pre> (UI:components/lib/JsonTree.vue:22).
  • Width is draggable 360–920 px and not persisted. There is no search inside the payload, no prev/next, and no filter-by-arbitrary-field.

Tally panel (UI:components/live/TallyPanel.vue, UI:composables/live/tallyView.ts, UI:lib/live/event-tally.ts)

  • A pivot of runs × event types that never evicts.
  • Row key (runKeyFromEvent :108-112): run.id → pid:<run_pid> → __unattributed__.
  • Columns: the static EVENT_FAMILIES catalogue (12 families, :32-79) plus a discovered other family.
  • Controls: expand a family into per-type columns; sort by run, total, family or type; "Hide zero rows" (default on); "Hide zero columns"; expand/collapse all; Reset.
  • Shading is log-scaled and column-relative. A footer carries column totals and a grand total.
  • "Copy view" and "Copy full" write CSV to the clipboard.
  • It ignores the Events-tab filters and has no time dimension.

Retention controls (UI:components/live/RetentionControls.vue, UI:lib/live/retention-settings.ts)

  • Live mode only (live.vue:21).
  • Shows "N kept · X MiB · N evicted" (evicted only when above zero).
  • Inputs: events (step 500) and MiB (step 5), minimum 1, no maximum; Reset.
  • Defaults DEFAULT_MAX_EVENTS = 5_000, DEFAULT_MAX_BYTES = 25 MiB (:18-19); persisted in localStorage qdash.live.retention.v1.
  • Retention is by count and bytes only; there is no max age and no per-run cap.

File mode (UI:components/live/DropZone.vue, UI:lib/live/run-log.ts, stream.ts:126-150)

  • Drop anywhere or use "Open JSONL" (.jsonl, application/json, text/plain). Only the first file is taken.
  • Overlay copy: "Drop JSONL to load a run log / The file stays local in this browser."
  • The whole file is read with file.text(), parsed synchronously on the main thread, and sorted by time. A bad line becomes a parse.error row. An empty file raises the banner "The selected file is empty."
  • If events exist, window.confirm("Replace the current view with <name>?").
  • Loading stops the stream (the worker is terminated), replaces all three stores, and is uncapped.
  • "Return to live" clears everything and reconnects fresh; the earlier live buffer is gone.

Empty, loading, error and disconnected states

  • One table empty state covers never-connected, static backend, and filtered-to-nothing alike: "No events loaded — Run a wrapped command (qcontrol run -- …) or drop a run JSONL file onto this page." (EventTable.vue:124-131).
  • Popover: "No events yet."
  • Tally: "No tally data yet…" and "All columns hidden by filters…".
  • There are no skeletons and no distinct filtered-empty state.

2. Data contract

2.1 SSE endpoint

  • Route: GET /api/events/stream (BE:crates/qdash/src/web.rs:62, handler :295-317).
  • How the UI finds it: the relative constant DEFAULT_SSE_URL (stream.ts:45), made absolute against window.location for the blob worker (sse-worker-client.ts:112-118).
    • Production: same origin as the embedded SPA.
    • Dev: Nuxt devProxy['/api'] → QDASH_API_BASE || http://127.0.0.1:7878.
  • Auth: none. The server binds loopback only.
  • Host guard: a router-wide require_local_host returns 403 "qdash accepts local-host requests only" unless Host / :authority is localhost, 127.0.0.1 or [::1] (web.rs:159-200). A native client must send a local Host.
  • Frames: default-message SSE only, data: <complete event JSON> per record. There is no event:, id:, retry:, hello frame or wrapper (asserted by the test at BE:cli/src/commands/monitor/ui/dash.rs:575-584).
  • Keep-alive: KeepAlive::default(), which in axum 0.8.9 (local cargo registry) is a :\n\n comment every 15 s.
  • Replay and cursor: none. "There is deliberately no cursor or replay" (web.rs:286-290). GET /api/events is retired and must 404 (dash.rs:609-631).
  • Late join:
    • hub.subscribe() signals LiveSubscribeNotify (BE:crates/qdash/src/hub.rs:151-157).
    • The monitor then runs publish_inventory_resync: a fresh installation scan pass, installation.snapshot, and a requested process.snapshot (BE:cli/src/commands/monitor/inventory/state.rs:96-119).
    • It is broadcast to all subscribers, not just the new one.
    • It is throttled by ResyncStorm: burst 2 per 500 ms (state.rs:15,20).
    • It is wired only when dash is embedded in the monitor (dash.rs:695-696). Standalone qdash run uses EventHub::new() and gets no resync.
  • Lag: BROADCAST_BUFFER_SIZE = 1024 per subscriber (hub.rs:20). A lagging subscriber's response is terminated (web.rs:310-311); the client sees only onerror. The lost interval is not marked.
  • Static qdash serve: hub = None gives 503 with body "live stream unavailable: static qdash serve has no live hub" (web.rs:299-305, BE:crates/qdash/src/main.rs:121-126). Your belief is correct.
  • Client reconnect: the worker closes on onerror and retries on BACKOFF_SCHEDULE_MS = [500, 1000, 2000, 4000, 8000], capped at 8 s, reset on open, with no retry limit (UI:lib/live/sse.worker.ts:59-107).
  • Malformed JSON frames are dropped silently in the worker (:86-92).

2.2 How the sensor feeds the hub (for Kamal's document)

  • Path:
    1. monitor router (qsinks) sends to configured sinks first (each with an optional if rule), then unfiltered product routes dash and tui (BE:cli/src/commands/monitor/events.rs:23-41);
    2. DashSink is a SocketForwarder::spawn_to_ui(<socket>): "quiet when no dashboard is listening, drop-oldest when the consumer lags" (dash.rs:51-65);
    3. the dash telemetry accept loop reads NDJSON lines and calls publish_line (BE:crates/qdash/src/live.rs:149-163), then DurableProjector.project_line into SQLite (:186-194).
  • publish_line does a strict typed parse: EventRecord, else RunRecord, else ScanEvent. A line the dash's qevents cannot parse is dropped with a warning, and anything that passes is re-serialized from typed structs.
  • Socket: <data_dir>/dash/qdash.sock, override QCONTROL_DASH_SOCKET_PATH; Windows uses \\.\pipe\qcontrol-dash-<user> (dash.rs:22-49).
  • Data dir (from the working-tree crates/qdash/README.md and crates/qconfig/src/lib.rs:95-118, not in the snapshot): macOS ~/Library/Application Support/com.qpoint.qcontrol, Linux ~/.local/share/qcontrol.
  • HTTP port: DEFAULT_PORT = 7878, with an ephemeral fallback when taken (dash.rs:20, 372-387).
  • Port discovery: the bound URL is written to <socket>.lock as <pid>\n<url>\n (BE:cli/src/commands/monitor/ui/lock.rs:26-30, 84-90). Read it rather than assuming 7878.
  • Producer ingress, which the native app should not use: <data_dir>/monitor.event-ingress.sock, mode 0600, peer-uid checked, first frame internal.process_observed (BE:cli/src/commands/monitor/ingress.rs).
  • Other hooks:
    • a configured sink. file:// is confirmed in code (SinkTarget::File); unix:// appears only in BE:docs/plan/clean-monitor-surface.md:157 and a lock test. qsinks/qconfig are not in the snapshot, so unix:// and the if grammar are unverified;
    • qcontrol monitor --snapshot, a user-level forced resync (clean-monitor-surface.md:191-210).

2.3 Envelope

  • What the UI reads: StreamEvent { type; sequence?; observed_at?; timestamp?; severity?; entity_id?; run?{id,run_pid,agent_pid,agent_id,cwd}; process?{pid,name,command_name,command,cmd,installation_id}; payload? } (UI:types/api/live.ts:11-25).
  • What main emits (BE:crates/qevents/src/public.rs:29-62), with type and payload flattened in:
    • EntityRecord { timestamp, severity, entity_id, type, payload };
    • HostRecord { timestamp, severity, type, payload }.
  • timestamp is RFC3339Nano. severity is debug|info|warn|error, default info. entity_id is pid:<pid>:start:<unix_secs> (BE:crates/qevents/src/entity.rs:11-13).
  • Legacy RunRecord adds run { id, run_pid, agent_pid?, started_at, version, plugins, agent_id?, installation_id? } (BE:crates/qevents/src/run.rs:23-66). It only reaches the hub when a producer writes straight to the dash socket.
  • Client normal form: LogEvent { id, line, sequence, observedAt, observedAtMs, type, family, severity, entityId, agentIds[], run, process, payload, raw, summary, preview[], parseError } (UI:lib/live/events.ts:22-53).
    • id = ${sequence ?? 'line'}-${line}-${type} (:128); line is the batcher's arrival counter.
    • family is the type prefix (:777-781).

2.4 Event types in current qevents

FamilyTypes
processstarted, stopped, snapshot
installationdiscovered, details, snapshot, tap_result, tap_error
agentmessage, tool_call, tool_decision, tool_result
llmrequest, response, usage, rate_limit, provider_matched, provider_unmatched
mcprequest, response, error, notification, session_close, diagnostic, oauth
fileopen, read, write, close
execspawn, exit
connectionopen, update, close, mitm_success, mitm_failure, proxy_error, intake_fallback
httprequest, response, exchange_close
sseopen, event, close
websocketopen, send, recv, close, trace
runadapter_error, plugin_load_success, plugin_load_failure (run.started is legacy RunRecord only)
netstatsnapshot
pluginplugin.event

Correlation keys on the wire that the viewer does not use:

  • payload.context.session_id (agent/llm) and payload.session.session_id (mcp);
  • call_id, request_id, exchange_id;
  • exchange.id, stream.http_exchange_id, connection.id.

2.5 Drift: UI versus current qevents

  • sequence, observed_at, top-level process: not on any current envelope. The Seq column and row ids are therefore client arrival ordinals.
  • run.cwd: not in RunContext; it came from the retired backfill.
  • Types the UI knows that no producer emits:
    • installation.tap_skipped (removed per CHANGELOG.md:137);
    • run.ended, run.agent_injection_success/failure, agent.classified, agent.api_request/response;
    • the auto_tap.* family.
    • A run.ended line cannot pass the typed publish_line on main. The unified end signal is process.stopped.
  • Types missing from the UI catalogue:
    • the whole process family, installation.details and installation.snapshot land under Tally "other";
    • netstat.snapshot (new, CHANGELOG.md:29) has no tone and no preview.
  • Payload shape:
    • plugin.event: payload.payload became payload.data (CHANGELOG.md:11-27); pluginPreview still reads payload.payload (events.ts:475).
    • exec.exit.status is internally tagged {kind:"exited",code} (BE:crates/qevents/src/io.rs:299-307); the UI takes Object.keys(status)[0], so it shows status=kind.
    • tool is {kind, name, server?|path?}; Tool::Unknown ({kind:"unknown"}) renders tool=kind; MCP tools lose server.
    • A future Tool::Skill would be filed under tools, not skills, by presence.
  • Unused fields: rule, tap.status/rule/policies, exchange_id, ppid, matches.

2.6 Side endpoints

  • POST /api/agent/focus {entity_id} (web.rs:393-482):
    • 200 {focused:true, app, window_targeted};
    • 404 unknown agent or no focusable app; 409 another host; 501 non-macOS.
    • It looks up agent_instances in SQLite, then runs open <bundle> or open -a <bundle> <cwd> for workspace editors.
  • POST /api/agent/reveal {entity_id} (:486-556):
    • 200 {revealed:true};
    • 404 unknown agent, no cwd, or cwd gone; 409; 501; 500.
    • It runs open -R <cwd>.
  • Both depend on the durable projection: an entity seen only on a standalone qdash run gets 404.
  • The page calls nothing else and does no polling.

3. Client pipeline and cost

Path

  1. Worker: EventSource plus JSON.parse, one postMessage (structured clone) per event.
  2. Batcher on the main thread (UI:lib/live/live-ingest.ts): normalises each event eagerly with ingestEvent; flushes on requestAnimationFrame with a 32 ms timer fallback; at most 250 events per flush (:31-32).
  3. onBatch feeds eventsStore, tallyStore and presence (stream.ts:79-84); one triggerRef each.

Bounds

  • Table buffer: 5,000 events / 25 MiB by default, user-set with no ceiling; always keeps at least one event and the protected selection.
  • Tally and presence maps: never evict.
  • Entity-meta index: never pruned.

Kept per event

  • The full parsed envelope: raw (with payload aliasing raw.payload), plus summary and preview[] strings. There is no per-event size cap.

Per event

  • ingestEvent: preview extraction and string formatting.
  • estimateEventBytes = new Blob([JSON.stringify(event)]).size (UI:composables/live/useLiveEvents.ts:229-235). It double-counts because raw contains payload.
  • Tally increment; presence update.

Per flush, O(N) over the retained buffer

  • copy the entity index (:98);
  • re-stamp agentIds on every retained event (:116-118), allocating a Set and an array each;
  • evict with splice(index, 1) on two parallel arrays per evicted event (:188-192);
  • sortedEvents: full copy and sort (eventList.ts:112), forced even on the Tally tab by watch(sorted) in selection.ts:115;
  • buildEventTreeNodes, filterTableEvents, buildAgentCounts, buildFamilyCounts;
  • liveEventRows: one object spread per filtered event;
  • the reversed display copy.
  • Only about 30 DOM rows render.

Stated limits and measurements

  • The only load test is UI:lib/live/qglass-load-smoke.test.ts: 1,200 synthetic events, batch 90, giving 14 flushes; retain 360, evict 840; at most 18 virtual rows for a 440 px viewport.
  • The c6 outcome records "~23 DOM rows for a 5,000-event buffer".
  • I found no events/s, frame-time or heap measurements in code, tests or cycle docs.
  • The c8 review flagged the Blob sizing, the full re-sort, and a double previewParts call as deferred perf items (…/ui/cycles/c8.review-response-hardening/review.md, Bugs 7). The first two are still present; the third was moved into event-rows.ts.
  • Backend side: file.* is the volume family ("tens of thousands of syscalls", BE:crates/qdash/src/db.rs:62-66). The dev TUI uses a 10,000-deep channel and a 5,000-row history.

4. Synthetic path

  • generateSyntheticRunEvents({count, runCount=4, startTimeMs}) (UI:lib/live/synthetic-events.ts:35-87) is deterministic.
    • The first runCount events are run.started.
    • It then round-robins six types: agent.message, agent.tool_call, http.request, http.response, mcp.request, file.open.
    • Timestamps step +50 ms; sequence = index+1.
  • It is used only by the load-smoke test; no page imports it. The dev-only ?sim=1 belongs to /activity (UI:lib/session-sim.ts), not /live.
  • It is off-schema for the current wire:
    • legacy run envelope and a top-level sequence;
    • flat tool_name; http.request.url and http.response.status; agent.evidence;
    • no entity_id, process.*, snapshots, llm.*, errors, or session context.
  • It therefore never exercises the entity-meta join, the presence state machine, or tally attribution.
  • qnative needs a new generator that emits unified EventRecord lines through the same parser. qevents has constructors: EventRecord::entity(ts, entity_id, EntityEvent::…) and EventRecord::host(…).
  • Replay corpus: ../qcontrol/scripts/qdash-seed/out/runs.ndjson (gitignored output of generate.py). It has 49,996 lines, 31.5 MB, max line 1,160 bytes, legacy run-envelope shape, about 58% file.*.

5. Weaknesses and improvement candidates, ranked

#WeaknessNeeds
1No replay, cursor or stable ids. Reload shows nothing; lag loses an unmarked interval; deep links are client-local. c6 built a backfill and it was retired. The SQLite events table is still written (file.* folded into counters), so a cursor is cheap to restore.Backend: id: / ?since=, a hub ring buffer, or a gap frame
2Silent loss at three points: forwarder drop-oldest, the 1024-record hub buffer, worker-dropped malformed frames. Nothing reaches the user.Backend for drop counts; client for surfacing
3No server-side filter; the client receives the whole firehose.Backend (?families=, min_severity=, entity_id=), or client-side coalescing of file.*
4O(N)-per-flush pipeline (section 3) holds the buffer at 5,000.Client: ring buffer, raw bytes with lazy parse, incremental facet counts, wire-length byte accounting
5"Paused" is not a pause; at scroll offset 0 a selected row is pushed down by new rows (inferred). No scroll-anchor compensation in useVirtualRows.Client: freeze the view at a sequence number, as the TUI does
6Flat rows with no correlation: sessions, tool call → decision → result, request → response latency, HTTP exchange grouping. All keys are on the wire.Client
7Tally is effectively one "Unattributed" row on the unified stream (no run envelope). No time axis.Client: key by entity, agent or session; add rate
8Filter model: no severity, entity, session, pid or time; no negation; no payload search; type overrides family globally; counts not faceted; nothing persisted.Client
9Dishonest states: 503 looks like a blip; one empty-state copy for four situations; no last-event age or rate; focus/reveal fail silently.Client, plus a small backend hub-health or status frame
10No time tools: no histogram, range, scrub, milliseconds or relative time (formatRelativeTime exists unused).Client within the buffer; backend (#1) beyond it
11Presence is heuristic: Claude-Code tool-name literals; one card per process; wall-clock windows mean an old file shows no cards (inferred); state never pruned.Backend for harness-emitted session state; client for the rest
12Export: only one event's JSON and a tally CSV.Client: JSONL of the buffer or filtered set
13Sensitive payloads shown and copied verbatim. installation.details is documented as secrets-bearing (BE:crates/qevents/src/scan.rs, mcp_servers doc).Client masking; backend policy
14Keyboard and accessibility: no arrow navigation, Esc, or search shortcut; popover has no focus management; no aria-rowcount. Roles and live regions are partly present.Client
15Hand-maintained type catalogue and extractors (section 2.5).Client: derive from the qevents crate
16File mode: main-thread parse, no progress, replaces live, single file, no tail of a growing file.Client
17Late-join resync is broadcast to all subscribers and re-runs an installation scan on every connect.Backend: per-subscriber snapshot

6. What ports to Rust

Pure logic (no Vue or DOM); port as-is

ModuleLines
events.ts800
agent-presence.ts476
event-tally.ts409
entity-meta.ts269
live-ingest.ts (scheduler injected)184
retention-settings.ts (Storage injected)133
synthetic-events.ts120
virtual-rows.ts93
event-display.ts89
event-context.ts54
event-rows.ts27
run-log.ts23
sse-protocol.ts17
  • event-table-projections.ts (267) has pure functions (filterTableEvents, build*Counts, buildEventTreeNodes, toggles) but also imports vue for useEventTableProjections.
  • The retention algorithm in composables/live/useLiveEvents.ts (255) is pure logic wrapped in refs.

Does not port

  • sse.worker.ts and sse-worker-client.ts (Worker + EventSource): replace with an async SSE or socket reader.
  • The rAF batcher: drain a channel per frame.
  • All Vue composables, the history-API hash selection, localStorage, clipboard, drag-drop, window.confirm.
  • Tailwind familyTone classes; Intl time formatting.

Tests and golden files

  • 107 specs in about 2,380 lines across 15 files. All fixtures are inline TypeScript object literals; the repo has no JSONL fixtures.
  • Goldens would have to be generated: run ingestEvent, buildPresenceCards, buildTallyRows/rowsToCsv and filterTableEvents over a corpus and snapshot the output. The seed NDJSON works for the legacy shape; a new unified corpus is needed for the current one.
  • Best candidates for cross-language vectors: events.test.ts (19 preview cases), agent-presence.test.ts (20, time injected), entity-meta.test.ts, event-tally.test.ts (CSV is byte-comparable).
  • Several events.test.ts cases cover retired types.

Rust prior art already in the backend

  • The dev TUI Events tab (qcontrol start --tui, cargo feature tui): BE:cli/src/commands/monitor/ui/terminal/feed.rs and terminal/events/{mod,filters,table}.rs.
  • TuiSink is an in-process router route over sync_channel(10_000), dropping on full.
  • EventStore is a VecDeque<EventRow> with HISTORY = 5_000; EventRow { seq, at, kind, pid, summary, raw: Arc<[u8]> }.
  • It has a generic SUMMARY_KEYS summariser, source/type/pid filters, pause-on-scroll with a new-event count, and a JSON pager.
  • Scope is in BE:docs/plan/2026-10-12-monitor-tui/plan.md.
  • qevents is a public Rust crate (event_type(), default_severity(), session_id(), installation_id()). Its tagged struct enums such as Tool are strict on unknown variants (BE:crates/qevents/README.md), so a tolerant envelope with lazy typed payload decode is the safer choice against version skew.

7. Surprises

  • A replay cursor existed and was removed. c6 shipped a hello frame with last_id, GET /api/events?after&upto&limit&order, and wrapped frames; the "Unify monitor event delivery" cutover (#639) retired it, and a test now asserts the 404.
  • The same lines are still written to SQLite events under the embedded monitor. prune_events (db.rs:498) has no caller in the snapshot's cli/ and crates/. A qdash-ui cycle doc cites a real 2.9 GB / 4M-event db.
  • Every SSE connect makes the monitor rescan installations and rebroadcast both snapshots to everyone. On the client, applyProcessSnapshotPresence sets lastActivityAtMs (agent-presence.ts:185), so each snapshot flips every card to "working" for 10 s (inferred).
  • "Seq" looks like a wire sequence but is the client arrival counter.
  • A stale #event=line-N-type hash can select a different event after reload, because watch(sorted) re-resolves by the same id (inferred).
  • The header of types/api/live.ts says a malformed frame becomes a parse.error row. In live mode the worker drops it; only file mode and non-object JSON produce such rows.
  • The worker protocol defines an error message that the worker never posts.
  • The Nuxt dev proxy sets timeout: 1000, proxyTimeout: 1000 on /api (nuxt.config.ts:83-84). This may cut an idle SSE stream in dev and trigger resyncs (inferred, not tested). The embedded build is unaffected.
  • The tally TallyRow carries vestigial fields from qglass's process-tree tally: descendants, pids, isAi, aiSystemId.
  • qdash normalize.rs:192 still handles run.ended, and web.rs:886 counts run.agent_injection_failure; neither type exists in current qevents.

Qpoint Brand Style Guide