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/(branchfeat/new-pages, HEAD28182424c; merge-base with main is8e98cdd8e, 2026-10-04).BE:= the root of qcontrol atorigin/main,9e6cd55a0(2026-11-10).crates/qdash/src/hub.rsandlive.rsare byte-identical between the UI's tree and the snapshot, and theevents_streamhandler is unchanged. Onlyqeventsdrifted (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
globalThissingletons: 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.
disconnectedis only reached bystop()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 fallbackrun.id), not per agent or session. Cards sort byfirstSeenAtMsso 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_callwhose tool name isRead/Write/Edit(:108-112); - Tools & Skills: first 3, then "+N more". Skills are dug from a builtin
Skillcall'sarguments.skill.
- pawn state:
- Status precedence (
:405-411): errored > needs-input > working > idle.- errored:
lastErrorAtMswithinERROR_WINDOW_MS60 s. Set by a non-completedllm.response,mcp.error, orrun.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_MS10 s.
- errored:
- A card is dropped when quiet for
PRESENCE_WINDOW_MS5 min, when it has no agent attribution, or when ended. End signals:process.stoppedby entity or pid, absence from aprocess.snapshot,run.ended, or root-pidexec.exit. - A 1 Hz ticker drives transitions (
UI:composables/live/useAgentPresence.ts:28-34). - Actions:
- "View Details" toggles the card's
agentIdinto 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.
- "View Details" toggles the card's
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 asearchstring. - 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, andsummaryuses 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):
| Column | Content |
|---|---|
| Time | HH:MM:SS local, no milliseconds; header toggles newest-top/bottom |
| Seq | sequence ?? line |
| Event | type chip toned by family |
| Harness | resolved agent id, plus (runPid/agentPid) or (pid) |
| Summary | key=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.*andauto_tap.*get an empty preview, so Summary shows "-"; - unknown types show the first four payload keys.
- have extractors:
- Display renames at
:620-642:cost_usd→cost(as$0.0000),duration_ms→duration,tool_name→tool, shortenedcall_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] typein the family hue, Copy JSON (pretty-printedevent.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:
JsonTreeover 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_FAMILIEScatalogue (12 families,:32-79) plus a discoveredotherfamily. - 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 localStorageqdash.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 aparse.errorrow. 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 againstwindow.locationfor 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_hostreturns 403 "qdash accepts local-host requests only" unless Host /:authorityislocalhost,127.0.0.1or[::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 noevent:,id:,retry:, hello frame or wrapper (asserted by the test atBE: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\ncomment every 15 s. - Replay and cursor: none. "There is deliberately no cursor or replay" (
web.rs:286-290).GET /api/eventsis retired and must 404 (dash.rs:609-631). - Late join:
hub.subscribe()signalsLiveSubscribeNotify(BE:crates/qdash/src/hub.rs:151-157).- The monitor then runs
publish_inventory_resync: a fresh installation scan pass,installation.snapshot, and a requestedprocess.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). Standaloneqdash runusesEventHub::new()and gets no resync.
- Lag:
BROADCAST_BUFFER_SIZE = 1024per subscriber (hub.rs:20). A lagging subscriber's response is terminated (web.rs:310-311); the client sees onlyonerror. The lost interval is not marked. - Static
qdash serve:hub = Nonegives 503 with body "live stream unavailable: staticqdash servehas 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
onerrorand retries onBACKOFF_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:
- monitor router (
qsinks) sends to configuredsinksfirst (each with an optionalifrule), then unfiltered product routesdashandtui(BE:cli/src/commands/monitor/events.rs:23-41); DashSinkis aSocketForwarder::spawn_to_ui(<socket>): "quiet when no dashboard is listening, drop-oldest when the consumer lags" (dash.rs:51-65);- the dash telemetry accept loop reads NDJSON lines and calls
publish_line(BE:crates/qdash/src/live.rs:149-163), thenDurableProjector.project_lineinto SQLite (:186-194).
- monitor router (
publish_linedoes a strict typed parse:EventRecord, elseRunRecord, elseScanEvent. A line the dash'sqeventscannot parse is dropped with a warning, and anything that passes is re-serialized from typed structs.- Socket:
<data_dir>/dash/qdash.sock, overrideQCONTROL_DASH_SOCKET_PATH; Windows uses\\.\pipe\qcontrol-dash-<user>(dash.rs:22-49). - Data dir (from the working-tree
crates/qdash/README.mdandcrates/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>.lockas<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 frameinternal.process_observed(BE:cli/src/commands/monitor/ingress.rs). - Other hooks:
- a configured sink.
file://is confirmed in code (SinkTarget::File);unix://appears only inBE:docs/plan/clean-monitor-surface.md:157and a lock test.qsinks/qconfigare not in the snapshot, sounix://and theifgrammar are unverified; qcontrol monitor --snapshot, a user-level forced resync (clean-monitor-surface.md:191-210).
- a configured sink.
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), withtypeandpayloadflattened in:EntityRecord { timestamp, severity, entity_id, type, payload };HostRecord { timestamp, severity, type, payload }.
timestampis RFC3339Nano.severityisdebug|info|warn|error, defaultinfo.entity_idispid:<pid>:start:<unix_secs>(BE:crates/qevents/src/entity.rs:11-13).- Legacy
RunRecordaddsrun { 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);lineis the batcher's arrival counter.familyis the type prefix (:777-781).
2.4 Event types in current qevents
| Family | Types |
|---|---|
| process | started, stopped, snapshot |
| installation | discovered, details, snapshot, tap_result, tap_error |
| agent | message, tool_call, tool_decision, tool_result |
| llm | request, response, usage, rate_limit, provider_matched, provider_unmatched |
| mcp | request, response, error, notification, session_close, diagnostic, oauth |
| file | open, read, write, close |
| exec | spawn, exit |
| connection | open, update, close, mitm_success, mitm_failure, proxy_error, intake_fallback |
| http | request, response, exchange_close |
| sse | open, event, close |
| websocket | open, send, recv, close, trace |
| run | adapter_error, plugin_load_success, plugin_load_failure (run.started is legacy RunRecord only) |
| netstat | snapshot |
| plugin | plugin.event |
Correlation keys on the wire that the viewer does not use:
payload.context.session_id(agent/llm) andpayload.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-levelprocess: not on any current envelope. The Seq column and row ids are therefore client arrival ordinals.run.cwd: not inRunContext; it came from the retired backfill.- Types the UI knows that no producer emits:
installation.tap_skipped(removed perCHANGELOG.md:137);run.ended,run.agent_injection_success/failure,agent.classified,agent.api_request/response;- the
auto_tap.*family. - A
run.endedline cannot pass the typedpublish_lineon main. The unified end signal isprocess.stopped.
- Types missing from the UI catalogue:
- the whole
processfamily,installation.detailsandinstallation.snapshotland under Tally "other"; netstat.snapshot(new,CHANGELOG.md:29) has no tone and no preview.
- the whole
- Payload shape:
plugin.event:payload.payloadbecamepayload.data(CHANGELOG.md:11-27);pluginPreviewstill readspayload.payload(events.ts:475).exec.exit.statusis internally tagged{kind:"exited",code}(BE:crates/qevents/src/io.rs:299-307); the UI takesObject.keys(status)[0], so it showsstatus=kind.toolis{kind, name, server?|path?};Tool::Unknown({kind:"unknown"}) renderstool=kind; MCP tools loseserver.- A future
Tool::Skillwould 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_instancesin SQLite, then runsopen <bundle>oropen -a <bundle> <cwd>for workspace editors.
- 200
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>.
- 200
- Both depend on the durable projection: an entity seen only on a standalone
qdash rungets 404. - The page calls nothing else and does no polling.
3. Client pipeline and cost
Path
- Worker:
EventSourceplusJSON.parse, onepostMessage(structured clone) per event. - Batcher on the main thread (
UI:lib/live/live-ingest.ts): normalises each event eagerly withingestEvent; flushes onrequestAnimationFramewith a 32 ms timer fallback; at most 250 events per flush (:31-32). onBatchfeedseventsStore,tallyStoreand presence (stream.ts:79-84); onetriggerRefeach.
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(withpayloadaliasingraw.payload), plussummaryandpreview[]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 becauserawcontainspayload.- Tally increment; presence update.
Per flush, O(N) over the retained buffer
- copy the entity index (
:98); - re-stamp
agentIdson 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 bywatch(sorted)inselection.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
previewPartscall as deferred perf items (…/ui/cycles/c8.review-response-hardening/review.md, Bugs 7). The first two are still present; the third was moved intoevent-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
runCountevents arerun.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.
- The first
- It is used only by the load-smoke test; no page imports it. The dev-only
?sim=1belongs to/activity(UI:lib/session-sim.ts), not/live. - It is off-schema for the current wire:
- legacy
runenvelope and a top-levelsequence; - flat
tool_name;http.request.urlandhttp.response.status;agent.evidence; - no
entity_id,process.*, snapshots,llm.*, errors, or session context.
- legacy
- It therefore never exercises the entity-meta join, the presence state machine, or tally attribution.
- qnative needs a new generator that emits unified
EventRecordlines through the same parser.qeventshas constructors:EventRecord::entity(ts, entity_id, EntityEvent::…)andEventRecord::host(…). - Replay corpus:
../qcontrol/scripts/qdash-seed/out/runs.ndjson(gitignored output ofgenerate.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
| # | Weakness | Needs |
|---|---|---|
| 1 | No 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 |
| 2 | Silent 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 |
| 3 | No server-side filter; the client receives the whole firehose. | Backend (?families=, min_severity=, entity_id=), or client-side coalescing of file.* |
| 4 | O(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 |
| 6 | Flat rows with no correlation: sessions, tool call → decision → result, request → response latency, HTTP exchange grouping. All keys are on the wire. | Client |
| 7 | Tally is effectively one "Unattributed" row on the unified stream (no run envelope). No time axis. | Client: key by entity, agent or session; add rate |
| 8 | Filter model: no severity, entity, session, pid or time; no negation; no payload search; type overrides family globally; counts not faceted; nothing persisted. | Client |
| 9 | Dishonest 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 |
| 10 | No time tools: no histogram, range, scrub, milliseconds or relative time (formatRelativeTime exists unused). | Client within the buffer; backend (#1) beyond it |
| 11 | Presence 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 |
| 12 | Export: only one event's JSON and a tally CSV. | Client: JSONL of the buffer or filtered set |
| 13 | Sensitive 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 |
| 14 | Keyboard 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 |
| 15 | Hand-maintained type catalogue and extractors (section 2.5). | Client: derive from the qevents crate |
| 16 | File mode: main-thread parse, no progress, replaces live, single file, no tail of a growing file. | Client |
| 17 | Late-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
| Module | Lines |
|---|---|
events.ts | 800 |
agent-presence.ts | 476 |
event-tally.ts | 409 |
entity-meta.ts | 269 |
live-ingest.ts (scheduler injected) | 184 |
retention-settings.ts (Storage injected) | 133 |
synthetic-events.ts | 120 |
virtual-rows.ts | 93 |
event-display.ts | 89 |
event-context.ts | 54 |
event-rows.ts | 27 |
run-log.ts | 23 |
sse-protocol.ts | 17 |
event-table-projections.ts(267) has pure functions (filterTableEvents,build*Counts,buildEventTreeNodes, toggles) but also importsvueforuseEventTableProjections.- The retention algorithm in
composables/live/useLiveEvents.ts(255) is pure logic wrapped in refs.
Does not port
sse.worker.tsandsse-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
familyToneclasses;Intltime 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/rowsToCsvandfilterTableEventsover 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.tscases cover retired types.
Rust prior art already in the backend
- The dev TUI Events tab (
qcontrol start --tui, cargo featuretui):BE:cli/src/commands/monitor/ui/terminal/feed.rsandterminal/events/{mod,filters,table}.rs. TuiSinkis an in-process router route oversync_channel(10_000), dropping on full.EventStoreis aVecDeque<EventRow>withHISTORY = 5_000;EventRow { seq, at, kind, pid, summary, raw: Arc<[u8]> }.- It has a generic
SUMMARY_KEYSsummariser, 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. qeventsis a public Rust crate (event_type(),default_severity(),session_id(),installation_id()). Its tagged struct enums such asToolare 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
eventsunder the embedded monitor.prune_events(db.rs:498) has no caller in the snapshot'scli/andcrates/. 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,
applyProcessSnapshotPresencesetslastActivityAtMs(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-typehash can select a different event after reload, becausewatch(sorted)re-resolves by the same id (inferred). - The header of
types/api/live.tssays a malformed frame becomes aparse.errorrow. In live mode the worker drops it; only file mode and non-object JSON produce such rows. - The worker protocol defines an
errormessage that the worker never posts. - The Nuxt dev proxy sets
timeout: 1000, proxyTimeout: 1000on/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
TallyRowcarries vestigial fields from qglass's process-tree tally:descendants,pids,isAi,aiSystemId. - qdash
normalize.rs:192still handlesrun.ended, andweb.rs:886countsrun.agent_injection_failure; neither type exists in current qevents.