← Qnative (provisional) · c3

guide/03-live-activity.md

Live Activity: connection, data, pipeline, states, asks

Standing: Phases 1–3 are built (2026-11-11): the connection, the data model, the ladder and Dev › Source, the stream core held to qdash by test vectors, and the page, Dev › Live Activity, with its seven improvements. The connection is crates/nozzle (sse.rs, stream.rs, scenario.rs). The data model is crates/types (live.rs, events.rs, monitor.rs). The stream core is crates/stream (ingest.rs). Phase 2 adds the ladder on the Sensor page and the doctor; Phase 3 builds the page. Each section says which parts are built. Facts about qcontrol are read from origin/main 9e6cd55a0 (2026-11-10) and name the file; anything guessed is marked guess.

Live Activity is the firehose: every event the sensor sends, raw, with filters, a tally and the payload. It sits in the Dev group. Events reads the same stream and shows it as activities: a turn, a tool call with its decision and result, a finding. Events is an ordinary page that everyone has. One stream, two altitudes. (Not to be confused with Activity under This machine, c5's sessions over time.)

1. How the app reaches a sensor

This machine (the default)

The app reads one file, then speaks HTTP over loopback.

  1. The lock file. <data dir>/dash/qdash.sock.lock holds two lines: the dashboard's pid, then its URL (read: cli/src/commands/monitor/ui/lock.rs:26-30, 84-90). Read the URL rather than assuming port 7878; the dashboard falls back to an ephemeral port when 7878 is taken.
    • The data dir on macOS is ~/Library/Application Support/com.qpoint.qcontrol; on Linux, ~/.local/share/qcontrol (read: crates/qconfig/src/lib.rs).
    • QCONTROL_DATA_DIR moves the data dir, and QCONTROL_DASH_SOCKET_PATH moves the socket. The lock file sits beside the socket.
    • Phase 2 fixed the app's copy of the rule on macOS. It had looked in …/Application Support/qcontrol. Windows still differs; see page 2.
  2. The stream. GET /api/events/stream on that URL (read: crates/qdash/src/web.rs:62, 295-317).

At an address (a Linux box, a VM, a container)

The sensor answers only on loopback, and it has no login. So an address means the near end of an SSH tunnel, never a port opened to the network:

ssh -N -L 7878:127.0.0.1:<the sensor's port> <host>

Then attach to http://127.0.0.1:7878. The sensor's port is the second line of the lock file on that host. The dashboard's host guard refuses any request whose Host is not localhost, 127.0.0.1 or [::1] with a 403 (read: web.rs:159-200). A tunnel passes that guard. An opened port does not, and the app says so.

What the app sends and does (built: nozzle::stream::connect)

  • GET /api/events/stream HTTP/1.1 with Host: <the address's host:port> and Accept: text/event-stream. HTTP/1.1 is spoken directly over the socket, so the read timeout, the Host header and the refusal bodies are the app's to handle. The body is decoded from chunked transfer encoding.
  • Frames are parsed to the WHATWG event-stream rules (nozzle::sse): CR, LF or CR LF line ends, including a CR LF split across reads; data: lines joined; a comment is a keep-alive. qdash sends one JSON record per data: line and a : comment every 15 s.
  • Reconnecting follows the web viewer's schedule: 0.5, 1, 2, 4, 8 s, then every 8 s for ever. A stream that was open starts the schedule over. Each attempt says why the last one ended.
  • Leaving the page closes the connection. The reader wakes every 250 ms. When the page has dropped its subscription, the reader closes the socket and its thread ends (tested: closed within one tick). A feed exists only while its page is open.

The ladder (each rung is a state the Sensor page draws)

RungThe checkSaysBuilt
Not foundno lock file, or a stale one (this machine only)no sensor on this machinebuilt (Phase 2): nozzle::ladder, on Dev › Source and --check-sensor
Foundthe lock file's pid is alive (this machine only)installed and runningbuilt (Phase 2)
AnsweringGET /api/stats returns 200connected; the events it has seen (no version is served)built (Phase 2)
Streamingthe stream is open, keep-alive under 15 s oldlive; events a second, the last event's ageOpen and the keep-alive are built; the rate and age are Phase 3
Lostwas open, now retryingreconnecting, with the attempt and the reasonbuilt: Retrying { attempt, reason, retry_in }
Refused403 (host guard) or 503 (no live hub)reachable but not serving; what to changebuilt: Refused { status, reason }, the reason taken from the sensor's own body

2. The data

The envelope (read from code)

The source is types/live.ts, generated into crates/types/src/live.rs by scripts/ts-to-rust.py --from, the same mechanism qdash's routes will use (c131 D17). Each field's doc says where it was read.

export interface StreamRecord {
  timestamp: string          // RFC 3339, 9 fractional digits, Z (qevents timestamp.rs:16)
  severity?: Severity        // always written; absent reads as info
  entity_id?: string         // "pid:<pid>:start:<unix secs>"; absent on a host record
  type: string               // "<family>.<name>"
  payload?: unknown          // shaped per type; decoded on demand
  run?: RunContext           // legacy run envelope, tolerated, never required
}
  • Entity or host. A record without entity_id is a host record. The host records are installation.* (five types) and process.snapshot, whose entries each carry their own entity_id. Everything else belongs to one process lifetime (read: crates/qevents/src/public.rs:30-60).
  • Severity. It is written on every record. Each type has a default (public.rs:224): most are info; connection.mitm_success, websocket.trace, llm.provider_* and run.plugin_load_success are debug; connection.intake_fallback and run.adapter_error are warn; connection.mitm_failure, connection.proxy_error, run.plugin_load_failure and installation.tap_error are error. Debug records are not emitted at all unless the sensor's log level is debug.
  • Tolerant. The app reads an unknown type, an unknown field, and a payload it cannot decode, and each is still a row. The sensor and the app will not always be the same version, and qevents' own enums are strict about unknown variants.

The families (read from code)

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
netstatsnapshot
pluginplugin.event

types::events::FAMILIES carries this table. file.* is the volume family.

The payloads (read from code)

Every type's payload, field by field, with optional versus always-present-but-null, the tagged enums, and file:line, is in ../research/qevents-payloads.md. The synthetic stream scripts are written to it.

Correlation keys (what Events joins on; read from code)

ActivityJoined by
a process and everything it didentity_id (and each process.snapshot.payload.processes[].entity_id)
an installationinstallation.*.payload.id = process.started.payload.installation_id
a session of a harnesspayload.context.session_id (agent, llm), payload.session.session_id (mcp)
a tool call, its decision and its resultcall_id
a model request and its responserequest_id
an HTTP exchange and its streamexchange.id = sse/websocket.*.stream.http_exchange_id
a connectionconnection.id = http.*.exchange.connection_id
one file sessionfile.*.payload.file.id
a child processexec.spawn.payload.id = exec.exit.payload.id

The monitor's shapes (proposed)

The Monitor page's two routes of its own, workers with their lifecycle and the process tree, are in types/monitor.ts, generated into crates/types/src/monitor.rs. Its health, resources, log and loaded targets and behaviors ride on c131 D17's /api/sensor and /api/config (reconciled 2026-11-12, so each route is asked for once). None is served today. Each field marks whether the monitor already holds that data in its in-process view (read) or not (proposed). Page 4 has the rest.

What is read, and what is guessed

Read from codeGuessed
The envelope, families, payloads, severities, correlation keysqeventsnone
Framing, keep-alive, 403/503, the 1,024 buffer, the resync on connectqdash web.rs, hub.rs, the monitor's inventory statenone
That mcp exchange_id shares its id space with http.*.exchange.idnot stated in codeguess
That a tap skip is written as warna doc comment says so; no code sets itguess
The synthetic streams' values, mix and rates (fixtures::streams)the shapes are readguess: the content is invented. A recording of a real session replaces it (page 6).
The monitor's four routesthe data behind each field is readguess: the shapes are ours to propose

3. The pipeline

Built in Phase 1:

sensor ── SSE ──▶ nozzle::sse ──▶ Subscription ──▶ the page ──▶ stream::ingest
                  (frames)        (a channel of    (awaits it; no   (qdash's LogEvent,
                                  1,024 items:     timer, so idle   held to qdash by
synthetic ── fixtures::streams ─▶ play()           costs nothing)   test vectors)
  • One subscription per open page, through Repo::subscribe(). A page never knows whether the sensor or a script is behind it (c131 R1, R2). The plane serves no stream of this machine's events and says so (ruling 3).
  • Backpressure, not loss, inside the app. When the page falls behind, the producer waits. Against the sensor, this means qdash's 1,024-record buffer fills and qdash cuts the app, exactly as it cuts a browser. That is the "Lost" rung, and ask 2 below would let the app say how much was lost.
  • Records stay text until they are needed. The subscription carries each frame's JSON as sent. Phase 3 keeps a ring of raw frames and parses on demand.
  • Held to the web by test. scripts/live-vectors.sh generates a corpus from the stream scripts plus edge lines (not JSON, not an object, no type, a legacy run envelope, observed_at and sequence, an unknown severity). It runs qdash's own ingestEvent and parseRunLog over the corpus in node, bundled read-only from the qcontrol checkout, and writes crates/stream/vectors/ingest.json. The Rust port agrees with all 615 vectors and with the run log's sort order. The vectors already carry qdash's previews, summaries, run and process summaries, ready for Phase 3's port.

Built in Phase 3:

  • The buffer (stream::buffer): a ring of raw frames, bounded by count and by the bytes on the wire. The default is 20,000 events and 32 MiB, and the person can choose 5k, 20k or 100k.
    • A light head per frame (type, family, severity, entity, session, time) skips the payload. Over every corpus line it agrees with ingest.
    • Facets (family, type, severity, process, session) and the filtered view are kept as frames arrive and leave.
    • Full rows are built only for the rows on screen.
  • Parity with qdash's lib/live (crates/stream), held by cross-language vectors that scripts/live-vectors.sh regenerates byte-identical:
    • ingest and previews (614 records: summary, preview, run, process);
    • presence (99 checkpoints, 256 cards, all four statuses);
    • the tally (63 views; 126 CSVs, byte for byte);
    • entity-meta and the table projections (filters, counts, trees, chips, rows).
    • Web quirks are kept for parity and marked web quirk, kept for parity: in the code.
  • Rows: the page also builds a full row for every arriving record, because presence, the entity index and qdash's tally read every one, as the web's do.

4. The states (what the person sees; drawn on Live Activity since Phase 3)

StateFromSays
ConnectingConnecting { attempt }connecting, and the attempt after the first
LiveOpen, records or keep-aliveslive; events a second; the last event's age
ReconnectingRetrying { attempt, reason, retry_in }why it closed, the attempt, when the next one is
RefusedRefused { status, reason }the sensor's own sentence (403: the host guard; 503: a static qdash serve) and what to change
Not runningNotRunning { reason }no sensor on this machine, and where the app looked
EndedEndeda finite recording played to its end
Filtered to nothingthe page's filtersthe filters, and a way to clear them

5. What the app does not need

The web page this replicates still expects fields that the sensor stopped sending. The native pages are written against the envelope above and need none of these:

  • sequence and observed_at. The app numbers frames as they arrive and says so. Both are read if present, as the web reads them, so the normal form agrees.
  • The run envelope. Attribution is by entity_id.
  • Retired types: run.ended, agent.classified, auto_tap.* and others. process.stopped is the end signal.
  • POST /api/agent/focus and /api/agent/reveal. A native app can bring a window forward or reveal a folder itself.

6. What is shown, and what is masked (built in Phase 3: stream::mask)

installation.details is documented as secrets-bearing (scan.rs:236-238). Live Activity masks its mcp_servers[].env and headers values before showing or copying them, and offers a deliberate reveal.

The code does not document these as secret, but they carry user content and are masked the same way until the sensor's engineer says otherwise:

  • HTTP headers;
  • MCP params, result and error.data;
  • tool arguments and output;
  • llm.request.system_instructions;
  • exec.spawn.argv and process.started.argv.

A masked value reads •••• masked (N chars) everywhere a row shows it: summary, preview and JSON. Reveal shows them on that page until it is masked again, and Copy JSON copies what is shown.

Ids that are u64 on the wire (exchange_id, connection.id) can exceed 2^53. The web page loses precision on them; the app parses them as 64-bit integers and does not.

7. Asks of the sensor

The app builds on today's stream as it is. These are recorded, not waited on. Each has why it matters and what we would accept if the answer is "not now".

#AskWhyWe would accept
1A cursor. An id: on each frame, and a way to resume from one (Last-Event-ID or ?since=), from a short ring in the hub.A reload, a restart or a lag currently loses events silently. Events cannot be trusted as a record without it. The app's parser already keeps id:.No cursor, with ask 2.
2A gap marker. When a subscriber lags, a frame that says how many records were dropped, in place of a bare disconnect.The person should see "312 events missed", not a clean-looking list with a hole in it.The count on the next connect.
3A filter on the server. ?families=, ?min_severity=, ?entity_id=.Events needs a fraction of the firehose. Sending all of it costs the sensor serialisation and the app parsing.None; the app filters and coalesces.
4A snapshot for the new subscriber only.Today every connect makes the monitor re-scan installations and broadcast two snapshots to everyone. An app that opens and closes a page pays that each time, and so do the other subscribers.The broadcast, kept throttled.
5The id spaces, stated. Does mcp.*.exchange_id share its space with http.*.exchange.id?Events would join an MCP call to its HTTP exchange.A "no", and the app keeps them apart.
6A key that forbids developer mode on a company's machines, whatever the build (review stop 0, call 8: proposed). Where would it live in qconfig, and may the app read it?A company that ships the full build can still say no.The lean build, which carries no developer mode at all.

Asks 1 and 4 touch the sensor's own cost, which is the point of Footprint.

Qpoint Brand Style Guide