← Qnative (provisional) · c3

guide/04-monitor.md

Monitor: the contract

Standing: "Where it comes from today" is read from qcontrol main (2026-11-10). The shapes and routes are proposals; the names are ours to change.

The Monitor page replicates the monitor TUI (qcontrol start --tui, cli/src/commands/monitor/ui/terminal/). The TUI can show what it shows because it is the monitor process. The app is another process, so it can show only what the sensor serves. This page lists what that is, view by view.

What each view needs, and whether it can be read today

ViewShowsWhere the TUI gets itReadable from another process today
Overviewlive workers, throughput per worker, latest events, health, the event mix of the last 60 s, targetsthe MonitorView snapshot and the event routeevents: yes. Workers, health, last error: no
Qcontrolthe monitor's process tree with CPU and memory, history, averages over 1, 5, 10 and 30 minsysinfo, sampled on the TUI's own thread at 1 Hz, 1,800 samples per processno; nothing produces it outside the TUI
Workerslifecycle timeline, process facts (signing, inject verdicts, capture), behavior, plugins, process treeWorkers::view() (crates/qmonitor/src/workers/telemetry.rs), compiled only with the telemetry featureno. Plugin loads arrive as events, at Debug severity
Eventsthe raw event tablethe tui event routeyes; this is the Live Activity page
Configtargets, behaviors with own and inherited values, YAML as written and effectivethe loaded configurationno. The file on disk can be read, but it is not what the monitor has loaded
Inventoryharnesses, other matched processesinventory stateyes: installation.snapshot, process.snapshot, or /api/installations
Loglog lines and the monitor's stderra tracing layer in processno; only the log file

What exists today for another process to read: the event stream, the two snapshots, the dashboard's REST routes, and two lock files (<data dir>/start.lock, <data dir>/dash/qdash.sock.lock). The control socket (<data dir>/monitor.control.sock) takes Snapshot, Shutdown, CheckUpdate and ApplyUpdate; none of them returns state.

The mechanism

Design c131 D17, ruled 2026-11-11: shapes that only the endpoint knows become dashboard routes, typed in TypeScript in ui/app/types/api/ and generated into Rust for this app, under the same drift test as the other 38. Never hand-written in the app, never the monitor's internal structs. D17 names six shapes on three routes: SensorState, Resources and LogLine on /api/sensor; EffectiveConfig and ConfigFile on /api/config; PlaneConnection on /api/plane.

One ask per route (reconciled 2026-11-12, design c143). The Monitor adds its fields to two of D17's routes rather than asking for a second shape of the same facts, and it needs two routes of its own. That makes four routes to ask for, two of them new, plus the event stream that is served today:

RouteD17's shape, as V1's Sensor page reads itWhat the Monitor addsViews that read it
/api/sensor (D17)SensorState, the Resources totals, the last LogLinesthe monitor's pid; a row per process in its tree, with 1 Hz samples; health and the last error; the log's target and a sequence numberOverview (health), Qcontrol, Log; the pid and uptime in the page's header
/api/config (D17)EffectiveConfig, ConfigFilethe targets, the behaviors (each value own or inherited) and the include globs, as the monitor has loaded themConfig, Overview (targets)
/api/monitor/workers (new)one row per worker, with its lifecycleOverview (workers), Workers
/api/monitor/processes (new)every process the monitor tracksWorkers (the process tree); Live's attribution to a worker
/api/events/stream (served)Overview (throughput, latest events, the event mix), Live

Inventory reads what is served today: installation.snapshot and process.snapshot, or /api/installations.

Until the routes exist, the Monitor's additions to D17's shapes live with the rest of those shapes in design's bridge (app/data/worlds/bridge/types.ts, mirrored by hand in crates/types/src/bridge.rs), and its two routes in types/monitor.ts, generated into crates/types/src/monitor.rs. Every Monitor field on D17's routes is optional, so V1's Sensor page and the bridge files are unchanged, and an absent field draws as "not served yet". When the routes land, all of it moves into qdash's TypeScript.

What the Monitor adds to /api/sensor

export interface SensorState {
  // V1's fields: state, version, uptime_s, started_at, last_seen_by_plane, degraded
  /** The monitor's pid; null when off. */
  pid?: number | null
}

/** Totals over the monitor's own tree: CPU as percent of one core (a 5 s mean), resident memory, live workers. */
export interface Resources {
  // V1's fields: cpu_pct, rss_bytes, workers
  processes?: {
    pid: number
    ppid: number
    /** `qcontrol monitor`, `worker W3 · claude-cli`, or the executable's name */
    label: string
    /** The worker this process is, when it is one. */
    worker_id: number | null
    cpu_pct: number
    rss_bytes: number
    /** 1 Hz samples after ?samples_since=, oldest first. */
    samples?: { at: string; cpu_pct: number; rss_bytes: number }[]
  }[]
}

export interface LogLine {
  // V1's fields: at, level (now trace · debug · info · warn · error), line
  /** The tracing target: `qcontrol::monitor`, `stderr`. */
  target?: string
  /** Rises by one per line; follow the tail with ?log_after=. */
  seq?: number
}

/** New on the response, beside sensor, resources and log: every health row, problems and all, and the last error. */
health?: {
  rows: { name: string; status: 'ok' | 'degraded' | 'failing'; detail: string }[]
  last_error: { at: string; message: string } | null
}
  • state and degraded are the rollup of health: a failing or degraded row makes the state degraded, and degraded lists those rows' details.
  • Two cursors, both optional. ?log_after=<seq> returns only the lines after that one. ?samples_since=<time> adds each process's samples after that time. Without them the response is V1's: the last lines, newest first, and no samples.
  • The page works out the 1, 5, 10 and 30 minute means and the charts from the samples, and draws a window it has not filled yet muted, as the TUI does.
  • No dashboard URL. The app reaches the sensor through the dashboard, so it already holds the address.

What the Monitor adds to /api/config

export interface EffectiveConfig {
  // V1's fields: file_found, mode, plane, won, plugins, it_contact
  /** As the monitor has loaded them, first match wins; not config.yaml as it is on disk. */
  targets?: { id: string; title: string; matcher: string; behaviors: string[]; harness: string | null }[]
  /** Each behavior resolved alone onto the base. */
  behaviors?: {
    name: string
    network: Setting; file: Setting; exec: Setting
    inject: Setting; env: Setting; plugins: Setting
  }[]
  /** The include globs in force. */
  include?: string[]
}
/** Whether this behavior set the value (drawn bright) or inherited it from the base (muted). */
export interface Setting { value: string; set: boolean }

The Monitor's two routes

/** GET /api/monitor/workers. One row per worker the monitor holds. */
export interface MonitorWorker {
  /** The launch id. Stable for the worker's life. */
  id: number
  kind: 'launch' | 'attach'
  worker_pid: number | null
  /** The process being observed; null until spawned. */
  target_pid: number | null
  client_pid: number | null
  /** The harness adapter; null when the target is not a harness. */
  adapter_id: string | null
  program: string
  cwd: string | null
  /** The config target that matched, if any. */
  target: string | null
  /** Where capture is enrolled to, if it is. */
  capture: string | null
  proxy_addr: string | null
  container_id: string | null
  behavior: { plugins: string[]; proxy: string; inject: string }
  /** In order. The page draws the timeline and the duration of each phase. */
  transitions: { phase: 'prepare' | 'ready' | 'started' | 'exited'; at: string }[]
  /** New: set when the worker is gone, so no client has to diff snapshots. */
  ended_at: string | null
}

/** GET /api/monitor/processes. Every process the monitor tracks, not only matched ones. */
export interface MonitorProcess {
  pid: number
  ppid: number
  exe: string
  args: string[]
}

The reference for every field is what the TUI's snapshot holds today: MonitorView, WorkerRow, HealthRow, ProcessRow, TargetRow, BehaviorRow in cli/src/commands/monitor/ui/shared/mod.rs, and WorkerView in qmonitor. Three things are new: ended_at (the TUI works out that a worker has gone by comparing snapshots, and a served shape should say so), the log's seq, and the per-process samples, which nothing outside the TUI produces.

Cadence and cost

  • The TUI's snapshot is built at most four times a second, and only when --tui is passed. Nothing is built for nobody.
  • While the Monitor page is open, the app would poll /api/sensor (with both cursors) and /api/monitor/workers once a second, /api/monitor/processes once a second while a view that draws the tree is shown, and /api/config when the page opens and when asked. Nothing while the page is closed. A stream in place of polling is welcome and not required.
  • Resources once a second, as the TUI samples, kept for 30 minutes.
  • The rule we would keep: no subscriber, no work. Then the samples begin when a reader arrives, and the 30-minute mean fills in over 30 minutes, drawn muted until it does, as in the TUI (question 3).

What the page does until a route exists

  • On synthetic data: every view, fully, from fixtures in the shapes above (Monitor's fixtures come with its build, Phase 5; the bridge files carry none of its fields yet).
  • On a real sensor: Inventory and the event-derived parts of Overview work today. Every other region is drawn with its name and one line: this sensor does not serve this yet, with the route it is waiting for. When a route lands, the region fills in. No page change is needed.

Hooking it up

Either the sensor serves a shape at the route above, or one transport function in crates/nozzle reads it from wherever it is served. The doctor (--check-sensor) lists each feed as served, missing or mismatched.

Questions

  1. Which of these can the dashboard serve: the Monitor's fields on /api/sensor and /api/config, and its two routes? And does its loopback API stay as the sensor's local read surface once the dashboard's own UI leaves qcontrol?
  2. The monitor may run elevated. Can a user-level app read the lock files and reach the loopback API in that case?
  3. Resources: can the monitor produce the samples? A user-level app may not be allowed to read an elevated process, and should not be the one sampling the sensor. And should it sample always (a few processes at 1 Hz), so the 30-minute mean is there when the page opens, or only while a reader is attached?
  4. LogLine: a tail with a sequence number (or as a stream), and at what default level? Outside TUI mode the log file's default filter is qcontrol=error.
  5. Serving these means the dashboard subsystem is on, with its SQLite projection. Is there a lighter way to serve four small routes to one local reader?
  6. A worker or run id on the event envelope, so an event attributes to a worker without walking the process tree. The TUI's plan defers it as a schema change.
  7. Is anything in these shapes something you would rather not expose outside the process?

Qpoint Brand Style Guide