research/monitor-tui.md
The monitor TUI: inventory and data provenance
Surveyed: 2026-11-11, by a read-only agent, for this cycle's plan. Nothing was modified, built or run.
Source: qcontrol origin/main at 9e6cd55a0 (2026-11-10). The TUI is not on the feat/new-pages checkout; see the index for how to read it again.
Standing: a reading at one commit. Line numbers drift. Everything is read from code unless marked (inferred). The snapshot read held cli, crates/qdash/src, crates/qevents, crates/qmonitor, crates/qsupervisor and crates/qworker; qsinks, qconfig, qprocess and qinject were not in it, so what is said about them comes from call sites.
Checked afterwards: the TUI was added by Kamal Nasser on 2026-10-16 as qcontrol #782 (2b2051a95, "Add monitor TUI (qcontrol start --tui)"), which the survey could not verify from a snapshot without git history.
Conventions: $Q is the root of qcontrol at that commit, and $T is $Q/cli/src/commands/monitor/ui/terminal.
0. Headline
- The TUI has no single central feed. It has three in-process inputs, plus two things the UI thread produces itself and disk reads of
config.yaml. - Only the raw event stream and the installation / matched-process inventory are reachable from another process today.
- The worker table, full process index, health rows, last error, resource samples, live log stream and event-to-worker attribution have no out-of-process surface.
- The natural seam is
Hooks { view: Publisher, events: qsinks::BoxSink }($Q/cli/src/commands/monitor/ui/shared/mod.rs:26-29), passed tohost::run(stop, Some(hooks)). The plan callsshared/free of terminal dependencies (plan.md:14-16), andui/mod.rs:15-17says both modules are "currently" compiled withtui. - None of the snapshot types derive serde, so a wire feed needs new types or derives.
1. Screen inventory
Tabs in key order are Overview, Qcontrol, Workers, Events, Config, Inventory, Log ($T/app/mod.rs:32-50). There is no Resources, Processes or Targets tab:
resources/is the "Qcontrol" tab (its heading reads "Resources").targets.rsis the "Config" tab.config/builds YAML documents for the pager.processes.rsholds tree helpers shared by two tabs.
Chrome ($T/app/chrome.rs, footer.rs, overlay.rs, input.rs)
- Minimum size: 100×28, otherwise a one-line "Terminal too small…" message (
chrome.rs:21-28). - Brand bar:
qcontrolbadge,monitor,DEV. Right side shows● liveor◐ paused · N new(click toggles pause) andpid {monitor_pid} · up {uptime}(chrome.rs:58-106). - Tab strip:
1 Overview … 7 Log, clickable, with an accent rule under the active tab. - Footer: context-sensitive key hints that list only keys that do something now; pause, time, help, quit, clear-filters and close are clickable (
footer.rs:17-137). - Global keys (
input.rs:16-58):1-7, Tab / Shift-Tab cycle tabs.qand Ctrl-C stop the monitor.?help,tclock ↔ ages (Go-style durations),spacepause.- Esc: Log returns to live tail; Events with filters clears them; otherwise go to Overview.
- Mouse: click, double-click (400 ms), right-click context menu, wheel (3 rows), hover tint. Hit regions are rebuilt every frame.
- Overlays:
- Right-click menu.
- Help, three groups: Navigate, View, Mouse (
overlay.rs:156-185). - Pager for event JSON or config YAML: line numbers, scrollbar, syntect highlighting. Keys: ↑↓/jk, PgUp/PgDn/space/b, g/G,
ycopy, esc/q/v close (input.rs:108-130). - Toast, bottom right, 2.6 s; "Stopping monitor…" persists.
- Copy is OSC 52 through the terminal (
$T/term.rs:43-45). Open-URL launches the browser as the sudo-invoking user (term.rs:50-107). - Pause freezes only the event list at a sequence number (
app/mod.rs:158-161). Snapshot-driven panels keep updating.
1. Overview ($T/overview/)
Two columns.
Left column:
- Workers: live only, two-line items.
- Line 1: name,
W{id} · launch|attach,{rate}/s, status glyph and word. - Line 2: optional
no harness ·,pid Nornot spawned,· proxy :port. - Click selects, double-click opens the Workers tab, menu has "Open details" and "Show events".
- Empty: "No workers. Run
qcontrol run -- <harness>or add a target."
- Line 1: name,
- Throughput: one sparkline per live worker plus
monitor, events/s over the last 40 s, with a trailing 10 s rate. Click filters Events by that source (workers.rs:148-241). - Latest events:
time kind summary. Click selects and pauses, double-click opens the pager, "All events ›" links to Events.
Right column:
- Health (
health.rs):- Header counts
N failing · N degraded · N ok. ● N healthy ›opens a "Subsystems" menu listing every row.- Then each non-OK row, a
last errorrow (time and first line), and adash <url>row (click opens the browser; menu has Open and Copy URL).
- Header counts
- Event types (last 60 s): horizontal bar chart of the top kinds with a total. Click a bar to filter Events. Empty: "No events yet".
- Targets:
title → behaviors | discover only, right-alignedN installs · N attachedor—. "Targets ›" links to Config.
Keys: ↑↓ select worker, enter goes to the Workers tab.
2. Qcontrol ($T/resources/)
- Title:
Resources N processes · X% CPU · Y resident, with the note "CPU is percent of one core". - Tree rooted at the monitor pid over its descendants. Columns:
- Process:
qcontrol monitor,worker W{id} · {name}, or exe basename. - PID.
- CPU: 5 s mean.
- Memory: latest RSS.
- CPU history: inline 24 s sparkline on a fixed scale of at least 10%.
- Process:
- Detail for the selected process:
pid N · graphs show the last {2×width}s.CPU X% now.- Averages over 1m / 5m / 10m / 30m; a partial window is shown muted with
(Ns). - Braille CPU line chart (5 s rolling mean) and memory line chart.
- Empty: "No samples yet".
- Keys: ↑↓/jk, ←→/hl collapse or expand, enter toggles. Wheel scrolls.
- Covered harnesses are deliberately not in this view (
resources/mod.rs:1-5).
3. Workers ($T/workers/)
- List: live workers plus up to 8 departed ones, dimmed with
gone {ts}. Each shows name, glyph,W{id} · kind [· no harness] [· rate/s]. Menu: Open details, Copy pid, Show events. - Detail (
detail.rs:22-249):- Title with
since {first transition}and status. - Owner line:
qcontrol run · pid N,target X, orexplicit opt-in. - Lifecycle timeline (
timeline.rs): launch is prepare → ready → running → exited; attach is prepare → ready → detached. Each phase shows its duration, and the current setup phase counts up. - Process facts:
harness|processas pid and basename;signingchips (signed / ad-hoc / unsigned, hardened, library validation, no DYLD env);preloadandinjectverdict chips (tryor✗ code);workerpid;cwd;capture(enrolled → addrornot enrolled). - Behavior facts:
chosen by,proxy(off, or mitm / no mitm with address),inject(on · file · execor off). - Plugins:
✓ loadedor× reasonfrom load events; otherwise the requested list; otherwise "recipe only; no load events seen yet". - Events (last 60 s): bar chart for this worker, with "Show all ›".
- Process tree rooted at the target pid: name with
W{id}/no harness/unattachedbadges, pid, 60 s event count. Empty: "Not spawned yet".
- Title with
- Empty state: "No workers" with a hint.
- Keys: only ↑↓ select a worker. The process tree is mouse-only (
workers/mod.rs:242-261).
4. Events ($T/events/)
- Filter sidebar:
- Source chips: each worker,
monitor,other. - Types grouped by family with 60 s counts. Click toggles a kind, double-click solos it, clicking a family toggles the group, wheel scrolls.
- "Clear" link.
- Source chips: each worker,
- Table, newest first: Time/Age, Source, Type, Summary, with scrollbar.
- Title
Raw events N matching. - State shows
● newest firstor◐ paused · N new above Resume ›.
- Title
- Inspect pane: event, summary, when, process (
pid basename args, a link when a worker covers it), size in bytes, and "View payload v". Empty: "Select an event. Double-click or press v…". - Keys: ↑↓/jk select (this pauses), enter/v pager,
ycopy JSON, esc clear filters. - Wheel scrolls and pauses; reaching the top resumes.
- Menu: View payload, Only {kind}, Only {source}, Open worker.
- There is no text search.
5. Config ($T/targets.rs)
- Targets table: #, Target, Match (rulekit source, quoted strings coloured), Behaviors, Harness. Header
N · first match wins. - Behaviors table: Name, Network, File, Exec, Inject, Env, Plugins. Bright values are the behavior's own, muted come from the base. Behaviors of the selected target are highlighted.
- Selected target detail:
title → behaviors id X, matching Installations (name, path), and Attached workers (links). - Empty states: "No targets configured.", "none match…", "none right now", "none · this target never attaches".
- Keys: ↑↓ target, enter/v opens the target YAML pager. Clicking a behavior opens a pager with "As written" plus "Effective" YAML. Menu: View config, Copy match.
6. Inventory ($T/inventory.rs)
- Title:
N harnesses · M matched processes. - Harnesses table: Harness, Path, Target, Behaviors, Running (count), Worker (
W{id}link). - Other matched processes table: Binary, PID, Target, Behaviors, Worker.
- One selection walks both tables. Enter or double-click opens the worker. Menu: Open worker, Copy path / Copy pid. Empty: "None".
7. Log ($T/log.rs)
tui-loggerwidget showingHH:MM:SS LEVEL target message.- Captured stderr appears under target
stderr. - ←/→ level, PgUp/PgDn history, esc live tail, wheel pages.
- No text search and no target filter.
2. Data provenance
Feed A: MonitorView snapshot
- Transport:
tokio::sync::watch<Arc<MonitorView>>. - Producer:
Publisher::build, on the monitor's host loop (shared/build.rs:10-97). - Call sites: once per loop pass (
$Q/cli/src/commands/monitor/host.rs:436-449), and only when the TUI hooks are present. - Rate limit:
PUBLISH_INTERVAL = 250 ms, with an "owed" wake-up (shared/mod.rs:32, 239-264). - UI side: adopts it in
ui_loop($T/mod.rs:107-115).
Field (shared/mod.rs:35-53) | Type | Source in the monitor |
|---|---|---|
monitor_pid, started_at | u32, SystemTime | std::process::id(); time the Publisher was created |
workers | Vec<WorkerRow { view: WorkerView, target: Option<String>, capture: Option<SocketAddr> }> | Workers::view(); matched_target(attach_key(id)); capture.enrollment(key).destination |
processes | Arc<BTreeMap<u32, ProcessRow { pid, ppid, exe, args }>> | Publisher::observe folds every qprocess::Event (host.rs:478-481); exists only because of the TUI |
live | Vec<LiveRow { pid, exe, harness, target }> | ProcessMonitorState::live(), classified(), matched_target() |
installations | Vec<InstallationRow { name, path, target }> | InventoryState.installations (agent.name, app_path or executable_path, tap.rule) |
targets | Vec<TargetRow { id, title, matcher, behaviors, harness }> | file_config().targets |
behaviors | Vec<BehaviorRow { name, network, file, exec, inject, plugins, env: Setting { value, set }, effective: qconfig::ResolvedBehavior }> | each behavior resolved alone onto the base (build.rs:102-153) |
health | Vec<HealthRow { name: &'static str, status: Ok/Degraded/Failing, detail }> | see below |
last_error | Option<(SystemTime, String)> | the loop's sticky last_error, stamped on first sight |
dash_url, config_include | Option<String>, Vec<String> | dash subsystem handle; config.include |
Health rows (build.rs:156-215):
process tracker: Ok with{N} processes, or Failing.containers: appears only when admission is degraded.tap watcher: always Ok; the detail says watching or idle.dash: appears only when there is a URL.- One
routerow per router destination, with its drop count.
WorkerView ($Q/crates/qmonitor/src/workers/telemetry.rs:152-174):
- Fields:
id: LaunchId(u64),kind: Launch|Attach,worker_pid,target_pid,client_pid,adapter_id(__nonefor non-harness),program,cwd,behavior: WorkerBehavior { plugins, proxy, inject },proxy_addr,container_id,transitions: Vec<Transition { phase: Prepare|Ready|Started|Exited, at: SystemTime }>. - Reaped launches are omitted from the view.
- Departed workers and the final "exited" / "detached" step are synthesised by the TUI by diffing snapshots (
$T/app/mod.rs:126-142,timeline.rs:51-53).
Feed B: public events
- Transport: router route
tui(Route::unfiltered,$Q/cli/src/commands/monitor/events.rs:33-35) intoTuiSink, astd::sync::mpsc::sync_channelthat drops withtry_sendwhen full. - Buffers (
$T/feed.rs:17-19):CHANNEL_DEPTH = 10_000;EventStorekeepsHISTORY = 5_000rows. FeedEntry:{ kind, at, pid, raw: Arc<[u8]> }.EventRow:{ seq, at, kind, pid, summary, raw }.pidis parsed fromentity_id(pid:<pid>:start:<secs>).summaryjoins a fixed list of payload keys (feed.rs:142-157).
- It is the same record stream the
dashroute receives.
Everything below is derived on the UI thread from those 5,000 rows:
- Throughput: 40 one-second buckets.
- Rates: 10 s.
- Event mix and per-pid counts: 60 s.
- Plugin status: scanning
run.plugin_load_success|failurepayloads. - Source attribution:
source_ofwalksppidthroughview.processesto a worker'starget_pidorworker_pid, at most 64 hops, cached per snapshot (app/mod.rs:202-231).
Feed C: log
- A
tui_logger::TuiTracingSubscriberLayeris added to the tracing registry under the sameEnvFilter($Q/cli/src/logging.rs:30-36, 74-75). - Process stderr is swapped for a pipe whose lines are re-logged as
tracing::warn!(target: "stderr")($T/term.rs:117-135). - The filter defaults to Info when
QCONTROL_LOGis unset ($T/mod.rs:45-49). - Ring size is the
tui-loggerdefault; the code sets none.
UI-thread local
- Resources:
sysinforefresh_pids_specifics(cpu + memory)for the monitor pid and its descendants inview.processes.SAMPLE_EVERY = 1 s,HISTORY = 1800samples per process ($T/resources/sample.rs:13-14, 39-64).- The table shows a 5 s mean.
- Eligibility chips:
qinject::check_eligibility(exe), read once per binary path and cached ($T/workers/mod.rs:46-52). - Config pager: reads
config.yaml, itsincludefiles or the stock document from disk on demand, and re-emits YAML ($T/config/mod.rs:74-143).
Cadences
- Input poll: 50 ms.
- Redraw: on change, or every
TICK = 250 ms. - Host loop: install pass every 5 min, maintenance every 3 s, macOS recovery every 5 s (
host.rs:41-45).
3. In-process vs out-of-process
Out-of-process surfaces that exist today:
- Control socket:
<data_dir>/monitor.control.sock(Windows\\.\pipe\qcontrol-monitor-<user>).- Owner-only, one JSON request per line.
MonitorRequesthas onlySnapshot,Shutdown,CheckUpdate,ApplyUpdate { force }($Q/cli/src/commands/monitor/control.rs:87-105).Snapshotis{"service":"monitor","payload":{"op":"snapshot"}}. It returns a bare ack and republishesinstallation.snapshotplusprocess.snapshoton the bus (dispatch.rs:475-490). Floods are limited to 2 per 500 ms.- The
sessionservice is forqcontrol runand MCP. - No message returns state.
- Lock files:
<data_dir>/start.lock:<pid>\n[manager=<id>\n]<sink>….<data_dir>/dash/qdash.sock.lock:<pid>\n<dashboard url>\n.
- qdash loopback HTTP: default
127.0.0.1:7878, falling back to an ephemeral port.GET /api/events/streamis SSE of every public event ($Q/crates/qdash/src/web.rs:62, 295-317). It has no replay, a 1024-record per-subscriber buffer, and lag disconnects. Attaching triggers the same inventory resync.- REST over SQLite projections includes
/api/installations,/api/agents,/api/sessions,/api/harnesses,/api/statsand others.
- macOS tray feed:
<data_dir>/monitor.ui.sock, NDJSON ofScanEventonly (installation., process.), broadcast capacity 256. It is documented as private to the tray worker (ui/feed.rs:1-9). - Configured
sinks(file or socket URLs), and the log file.
| TUI datum | Reachable out of process today? |
|---|---|
| Raw event stream (Events tab, all derived rates and mixes) | Yes: SSE or sinks. Live only; the 5,000-row history is the TUI's own. |
| Installations with matched target and behaviors | Yes: installation.snapshot / discovered (tap.rule, tap.policies, tap.status), or REST. |
| Matched live processes with harness and target | Yes: process.snapshot / started / stopped (agent, rule, ppid, argv, cwd). |
| Monitor pid, sink URLs, dash URL | Yes: lock files. |
| Targets and behaviors | No wire. Rebuildable from config.yaml with qconfig, but that is the disk view, not what the monitor has loaded. |
| Plugin load status | Partly: run.plugin_load_* on the bus. Success is Debug severity ($Q/crates/qevents/src/run_diagnostic.rs:49), so it is probably filtered at default level (inferred). |
Worker table (WorkerView, target, capture, proxy address, lifecycle timestamps) | No. Workers::view() is compiled only under telemetry. |
| Full process index (pid → ppid, exe, args for every tracked process) | No. Public process events cover only classified or matched processes. |
| Health rows (tracker, container admission, tap watcher, per-route drops) | No. |
last_error | No. |
| Monitor start time / uptime | No. Only derivable from the pid. |
| Event-to-worker attribution | No. The envelope carries only entity_id; plan.md defers a run or worker id as a schema change. |
| Resource samples | No producer exists. A user-level app could sample itself, but the monitor runs elevated, and on macOS reading another user's task info needs privilege (inferred, verify). |
| Live log lines and stderr | No stream. Only the log file (QCONTROL_LOG_FILE, else temp_dir()/qcontrol.log). Outside TUI mode its default filter is qcontrol=error and eprintln! output does not reach it. |
| Eligibility chips | No feed. Computable by any process that links qinject and can read the binary. |
| Departed workers and the final lifecycle step | No. Synthesised by the TUI. |
4. Cost
Neither plan.md nor the code gives a CPU or RAM figure for the TUI. The rationale is qualitative: dev tooling, no compatibility promise, never blocks the loop.
Work that happens only when --tui runs:
MonitorViewbuilds on the host loop at up to 4 Hz. Each build resolves every behavior and clones workers, live processes and installations.- The full process index is maintained.
- The
tuiroute is added to the router. - The UI thread samples
sysinfoat 1 Hz. - The log layer and stderr pipe thread are installed.
- Each event is JSON-parsed once on arrival, and all aggregates are recomputed from the 5,000 rows every frame.
Compiled in but trivial: the telemetry trace fields in qmonitor are recorded in any tui build, whether or not --tui is passed.
5. Gate mechanics
- Declaration (
$Q/cli/Cargo.toml:9-21):tuienables optional depsratatui,tui-tree-widget,tui-logger,serde-saphyr,crossterm(osc52),go-duration,bytesize,ratatui-cheese,tui-syntax-highlight,syntect, plusqmonitor/telemetry.sysinfoandchronoare already unconditional. - Build (
$Q/Makefile:126-138, 161-162):make build-devrunscargo build -p qcontrol --features "tui …"(debug profile).make buildis--releasewithout it. - Cuts:
ui/mod.rs:22-25gates thesharedandterminalmodules.cli.rs:155gates the--tuiflag itself.lifecycle/mod.rs:36-39dispatches toterminal::run().logging.rsgates the extra layer.monitor/mod.rs:165gatesstart_with_tui.host.rs:47-51aliasesTuiHookstoInfalliblewhen the feature is off; publish, owed-wake and observe are gated at:436,:456,:478.qmonitorgatestelemetry.rsand every trace field.
- Non-TUI path:
host::run(stop, None).
6. Keep, drop, improve
Keep:
- The seven-view split and cross-links (worker ↔ events ↔ target ↔ inventory).
- Lifecycle timeline with per-phase durations.
- Bright-versus-inherited behavior values.
- Problems-first health.
- Pause with a "N new" count.
- Clock / age toggle.
- Payload and YAML viewers with copy.
Drop:
- Minimum-size screen, hover painting, OSC 52, stderr capture.
- Footer key legend and numeric tab keys as primary navigation.
- "Quit stops the monitor".
Improve:
- Real charts with hover, zoom and a time-range choice.
- Sortable, resizable tables with text search on Events and Log.
- Persistent split panes in place of modal pagers.
- A keyboard-navigable process tree.
- Longer or disk-backed history.
- An explicit "worker gone" record instead of snapshot diffing.
7. Surprises and contradictions
ui/feed.rsis not the TUI feed. It is the macOS tray feed. The TUI's event feed isterminal/feed.rs.ui/dash.rsis not an alternative UI path. It is the embedded qdash server and runs alongside the TUI.- The mockups are stale.
mockups.html:496has six tabs (Overview, Workers, Processes, Events, Taps, Log) and an Activity / Raw toggle. The Activity view is deferred (plan.md:69-72). - The TUI is the monitor process. The plan's rule is "in-process and read-only", so an attached native app is a different architecture, not a port.
- The Qcontrol tab is not sensor telemetry about harnesses. It covers only the monitor's own process tree.
- Minor code oddities:
- The Events pid filter is rendered but nothing ever sets it.
WorkerView.container_idis never shown.- A launch worker in
Readydisplays "running". plan.md:126says stderr goes to a file; the code pipes it into tracing.
- Not verifiable: the PR number and the 2026-10-16 date, because the snapshot has no git metadata. The snapshot also lacks
qsinks,qconfig,qprocessandqinject, so those APIs are known only from call sites.
Key files
$Q/docs/plan/2026-10-12-monitor-tui/plan.md$Q/cli/src/commands/monitor/ui/shared/mod.rs$Q/cli/src/commands/monitor/ui/shared/build.rs$Q/cli/src/commands/monitor/ui/terminal/mod.rs$Q/cli/src/commands/monitor/ui/terminal/feed.rs$Q/cli/src/commands/monitor/host.rs$Q/cli/src/commands/monitor/control.rs$Q/crates/qmonitor/src/workers/telemetry.rs$Q/crates/qdash/src/web.rs$Q/crates/qevents/src/scan.rs