Skip to content

Instantly share code, notes, and snippets.

@darwin
Last active February 24, 2026 10:49
Show Gist options
  • Select an option

  • Save darwin/9ac0c2aa50e1777e12067065faff51cc to your computer and use it in GitHub Desktop.

Select an option

Save darwin/9ac0c2aa50e1777e12067065faff51cc to your computer and use it in GitHub Desktop.
Supex Radar — Single-host log aggregator with TUI (implementation plan)
❯ ~/.claude/skills/plan-runner/plan-runner.sh .tmp/plans/radar-plan.md --model opus --retry 2
Found 11 phases in radar-plan.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase scaffold: Project skeleton + models + CLI entry point
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase scaffold is complete. Here's what was created:
**Files created (9):**
- `radar` — bash launch script (executable, follows `./mcp` and `./supex` patterns)
- `devtools/radar/pyproject.toml` — uv project with hatchling build, textual/rich/typer deps
- `devtools/radar/src/radar/__init__.py` — package init
- `devtools/radar/src/radar/models.py` — `LogEvent` dataclass, `Level` IntEnum with ordering, `FilterSpec` with `matches()` method
- `devtools/radar/src/radar/cli.py` — Typer CLI with `watch` and `parse` subcommands, ad-hoc vs config mode detection, mutual exclusion validation
- `devtools/radar/tests/` — conftest + test_models (16 tests) + test_cli_modes (10 tests)
**Verification results:**
- `./radar --help` — works from supex root
- 16/16 model tests pass
- 10/10 CLI mode tests pass
Phase scaffold: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase log-unify: Standardize supex log output for Radar consumption
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase `log-unify` complete. Here's a summary of the changes:
**Python driver** (`cli/main.py`, `mcp/mcp_server.py`):
- Format changed from `asctime - name - levelname - message` to `asctime|LEVEL|name|message`
- Timestamps now ISO-8601 with milliseconds: `2026-02-24T02:14:44.227`
**Ruby ConsoleCapture** (`console_capture.rb`):
- Timestamps changed from `HH:MM:SS.LLL` to `YYYY-MM-DDTHH:MM:SS.LLL`
- Source field now uses actual source (`Runtime`, `STDOUT`, `STDERR`) instead of always `SketchUp`
**Rust sidecar** (`main.rs`):
- Custom `PipeFormat` replaces default tracing_subscriber format
- Output: `2026-02-24T02:14:44.227|INFO|target|message`
All verification tests pass, all 696 existing tests pass, Rust sidecar builds cleanly.
Phase log-unify: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase log-layout-unify: Single workspace-relative log root (hard cutover)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Done. Here's a summary of what was changed:
**Phase log-layout-unify complete** — all log producers now write to `$SUPEX_WORKSPACE/.tmp/logs/` with flat canonical filenames.
### Changes (9 files)
| File | Change |
|------|--------|
| `mcp` | `supex-mcp-protocol.jsonl` → `mcp-protocol.jsonl`, `supex-mcp-stderr.log` → `mcp-stderr.log` |
| `supex` | `supex-cli-stdout.log` → `cli-stdout.log`, `supex-cli-stderr.log` → `cli-stderr.log` |
| `scripts/launch-sketchup.sh` | Moved from `$PROJECT_ROOT/.tmp/sketchup_*.{txt,log}` to `$SUPEX_WORKSPACE/.tmp/logs/runtime-{stdout,stderr,console}.log` |
| `driver/.../cli/main.py` | `supex-cli.log` → `cli-driver.log` |
| `runtime/.../bridge_server.rb` | Uses `SUPEX_WORKSPACE` env var, writes to `runtime-console.log` under canonical root |
| `docs/configuration.md` | Updated log file list |
| `docs/agents/guide/troubleshooting.md` | Updated all log path references |
| `tests/README.md` | Updated 4 console log path references |
| `runtime/README.md` | Updated console log path reference |
**Verification:** All 6 mandatory canonical files created, flat layout confirmed, no legacy paths written. All 696 driver tests + 26 radar tests pass.
Phase log-layout-unify: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase tailer: File watcher with rotate/truncate detection
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Done. Phase tailer is complete with:
**`src/radar/tailer.py`** — Three components:
- **`FileTailer`** — Watches a single file, tracking position by offset and detecting rotation (inode change) and truncation (size < offset). Holds back incomplete lines (no trailing newline).
- **`TailSource`** — Dataclass pairing a logical source ID with a file path.
- **`MultiTailer`** — Manages multiple `FileTailer` instances, yielding `(source_id, raw_line, source_path)` tuples via an async generator. Sleeps on idle polls.
**`tests/test_tailer.py`** — 13 tests covering:
- Basic tailing (append, incremental reads, incomplete line holdback)
- Rotation detection (rename + new file, full read after rotate)
- Truncation detection (copytruncate style, truncate-to-empty)
- Missing files (graceful handling, late appearance, disappear/reappear)
- Multi-file streaming (interleaved sources, correct metadata, sleep on idle)
Phase tailer: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase parsers: Log format parsers + normalizer
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase complete. Here's what was implemented:
**New files (6):**
- `src/radar/parsers/__init__.py` — `BaseParser` ABC, `ParsedRecord` dataclass, parser registry (`register_parser`/`get_parser`), `detect_format` auto-detection
- `src/radar/parsers/pipe.py` — `PipeParser` for `timestamp|level|source|message|optional_json` format
- `src/radar/parsers/jsonl.py` — `JsonlParser` for JSON lines, with message derivation from known keys (`method`, `event`, `error`, `result`)
- `src/radar/parsers/plain.py` — `PlainParser` fallback with timestamp/level extraction from common patterns (ISO-8601, syslog, space-separated) and multiline continuation detection (stacktraces, tracebacks)
- `src/radar/normalizer.py` — `Normalizer` class that buffers continuation lines, assembles `LogEvent` instances, and generates deterministic EIDs via `sha256(source + timestamp_iso + first_raw_line)[:6]`
- `tests/test_parsers.py` — 57 tests covering all parsers, ANSI handling, auto-detection, registry, multiline assembly, normalizer behavior, and EID determinism
All 96 tests pass (39 existing + 57 new). Committed as `a90a6dc`.
Phase parsers: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase buffer: Ring buffer + ingest pipeline
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase buffer complete. Here's what was implemented:
**`src/radar/buffer.py`** — `RingBuffer` class:
- Fixed-size `deque`-backed buffer with configurable capacity (default 10,000)
- `append()` with silent oldest-eviction on overflow
- `get_recent(n)` returning the most recent n events (oldest first)
- `filter(spec)` using `FilterSpec.matches()` for source/level/pattern/time filtering
- `search(pattern)` with regex over message + raw fields (graceful fallback to literal substring on invalid regex)
- `count` and `sources` properties
**`src/radar/ingest.py`** — `IngestPipeline` class:
- Connects `MultiTailer` -> `Normalizer` -> `RingBuffer`
- Parser selection: explicit override > `detect_format` auto-detect > `plain` fallback
- Lazy normalizer creation per (source, source_path) pair
- `flush_all()` for draining buffered multiline records
- Clean cancellation handling
**`tests/test_buffer.py`** — 31 tests covering:
- RingBuffer basics, overflow/eviction, filtering (including logical source ID independence from path), search (regex + literal fallback)
- IngestPipeline end-to-end (pipe, JSONL, multiple sources, live append)
- Parser selection precedence (explicit, auto-detect, plain fallback)
- Glob expansion (deterministic order, deduplication)
Phase buffer: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase tui: Textual TUI with live stream, filters, and summary/raw toggle
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase TUI is complete. Here's what was implemented:
**New files (4):**
- `src/radar/tui/__init__.py` — package marker
- `src/radar/tui/renderers.py` — pluggable summary renderers (`SummaryRenderer` ABC, `DefaultSummaryRenderer`, `MCPProtocolRenderer`, `ConsoleLogRenderer`), registry, `render_raw()` for detail panel, `render_plain_line()` for `--plain` mode
- `src/radar/tui/views.py` — `LogListView` (DataTable with virtual scrolling, streaming/browsing modes), `DetailPanel` (summary/raw toggle), `FilterBar` (source, level, regex with 200ms debounce), `RadarStatusBar` (event counts, sources, rate)
- `src/radar/tui/app.py` — `RadarApp` with keyboard bindings (q/f/tab///j/k/g/G/Enter/Escape), streaming/browsing mode, IngestPipeline worker, periodic poll, tmux pane title, filter change handling
**Modified files (3):**
- `src/radar/buffer.py` — added `ObservableBuffer` with `drain_pending()` for live event notifications
- `src/radar/cli.py` — wired `watch` command: config loading (built-in defaults/TOML/ad-hoc), source resolution, `_do_watch()` launch, `--pane-name` flag, plain stdout mode via `_run_plain()`, TUI via `_run_tui()`
- `tests/conftest.py` — `_no_launch` autouse fixture to prevent pipeline/TUI launch in tests
All 127 tests pass, smoke test (`from radar.tui.app import RadarApp`) succeeds.
Phase tui: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase render-hot-reload: Live renderer reload without restart
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase `render-hot-reload` is complete. Here's what was implemented:
**`renderers.py`** — Three new components:
- **`RendererRegistry`** — Replaces the plain `RENDERERS` dict with a class that supports loading/reloading renderer modules from external Python files. Dict-like interface for backward compatibility. Tracks watched file paths for hot-reload.
- **`RendererLoadResult`** — Dataclass for structured success/error reporting from load/reload operations.
- **`RendererReloader`** — Polls watched renderer files by mtime. On change, reloads the module and swaps renderers atomically. On failure (syntax error, bad imports), keeps the last known-good renderer and records a warning.
**`app.py`** — Wired the reloader into the TUI:
- Creates a `RendererReloader` instance on init
- Snapshots mtimes on mount
- Polls for renderer changes every 1 second via `set_interval`
- Shows Textual notifications on reload failures
**`test_renderers.py`** — 13 tests covering:
- Registry basics (register, get, fallback, contains, bool, len)
- Module loading (success, syntax error, missing dict, invalid type)
- Hot-reload success (file change updates output)
- Error resilience (syntax error keeps old renderer, warning recorded)
- Recovery (fix after error reloads successfully)
- No-op when files unchanged
Phase render-hot-reload: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase cli-polish: CLI UX and startup ergonomics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Done. Here's a summary of what was implemented:
**CLI options added:**
- `--level/-l` — pre-set minimum log level filter (DEBUG/INFO/WARN/ERROR/FATAL), case-insensitive
- `--source/-s` — filter by logical source ID, repeatable and comma-separated (e.g. `-s cli-driver,mcp-stderr`)
- `--capacity` — ring buffer size (default 10000, minimum 100)
**Glob and dedup support:**
- Config paths with globs (`path = "*.log"`) are expanded internally
- Ad-hoc files are deduplicated by resolved path
**Filter integration:**
- Pre-set filters apply in both `--plain` mode and TUI mode
- TUI pre-populates the FilterBar inputs and shows the bar when CLI filters are active
**Help text:**
- Main help includes usage examples
- `watch --help` shows organized sections for modes, filtering, and output with inline examples
- All option descriptions improved with practical guidance
**Tests:** 24 tests (up from 11), covering level/source/capacity flags, glob expansion, dedup, and help output.
Phase cli-polish: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase perf-polish: Throughput and large-buffer behavior
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase perf-polish complete. Here's a summary of what was implemented:
## Changes
### 1. RingBuffer.sources — O(1) incremental tracking (`buffer.py`)
- Replaced O(n) set comprehension with incremental `_source_counts` dict
- Tracks evictions from the deque to decrement/remove source counts
- **Result**: `buf.sources` x10k calls: 0.002s (0.2 us/call) vs previously O(n) per call
### 2. Batch row insertion (`views.py`)
- Extracted `_make_row()` static method for row tuple construction
- Added `add_events_batch()` using DataTable's `add_rows()` for bulk insertion
- Avoids per-row DOM updates when adding many events at once
### 3. Lazy DetailPanel rendering (`views.py`)
- DetailPanel now defers rendering until the panel is actually visible
- `set_event()` while hidden just stores the reference and sets a dirty flag
- Rendering happens on `on_show()` or when panel is already visible
### 4. Batched event polling and table rebuild (`app.py`)
- `_poll_events()` filters into a batch list, then calls `add_events_batch()`
- `_rebuild_table()` uses batch insertion instead of per-event `add_event()`
### 5. `radar bench` CLI command (`cli.py`)
- Generates synthetic events and measures ingest, filter, search, and source-tracking performance
- Configurable `--count`, `--capacity`, `--sources`
### 6. Performance regression tests (`test_perf.py`)
- 100k ingest under 2s, with and without eviction
- Sources property cached and fast (10k calls < 1s)
- Filter operations under 500ms-1s at 100k scale
- drain_pending under 100ms
## Benchmark Results (100k events)
| Operation | Time | Throughput |
|-----------|------|-----------|
| Buffer ingest | 0.015s | 6.6M evt/s |
| drain_pending | <0.001s | — |
| Filter (pass-all) | 0.007s | — |
| Filter (single source) | 0.007s | — |
| Filter (level>=ERROR) | 0.006s | — |
| Filter (regex pattern) | 0.024s | — |
| Search (literal) | 0.026s | — |
| buf.sources x10k | 0.002s | 0.2 us/call |
Phase perf-polish: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Phase ssh-tmux-polish: Remote terminal and agent ergonomics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Attempt 1/2...
Phase ssh-tmux-polish is complete. Here's what was implemented:
**Terminal capability detection** (`app.py`):
- `detect_color_system()` — detects truecolor/256/standard/none from `COLORTERM` and `TERM` env vars
- `detect_terminal_info()` — gathers TERM, COLORTERM, tmux, SSH session info
- Terminal info shown in app subtitle (e.g. "tmux | ssh | colors=256")
**Mouse toggle** (`cli.py`, `app.py`):
- `--no-mouse` CLI flag for SSH environments where mouse events interfere with copy/paste
- Passed through to Textual's `App.run(mouse=...)`
**tmux integration** (`app.py`):
- Pane naming via `--pane-name` (already existed, verified working)
- `_check_tmux_escape_time()` — warns if escape-time >100ms (causes sluggish Escape key in TUI)
- Keybindings documented as tmux-prefix-safe (no Ctrl combinations)
**Capture-pane friendly rendering** (verified existing):
- Status bar format: `Total: N Shown: N Sources: N Rate: N/s [streaming]` — parseable plain text
- DataTable columns: fixed widths, clean separation
- CSS uses theme tokens (`$primary`, `$accent`) — degrades gracefully across color systems
- All Rich styles use standard names (dim, blue, yellow, etc.) — work with 256-color and 16-color
**Help documentation** (`cli.py`):
- tmux agent workflow examples (`capture-pane`, `send-keys`)
- Complete keybinding reference in `--help` output
All 162 tests pass, verification command succeeds.
Phase ssh-tmux-polish: PASS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Summary: 11 passed, 0 failed, 0 skipped
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Supex Radar — Single-host log aggregator with TUI

Format: Plan for plan-runner skill execution. Each ## Phase <code-name> section is one plan-runner invocation. Phases with ### Verify sections ending in test commands are automatically verifiable.

Project Overview

Supex Radar — custom Python tool for single-host log file aggregation with a TUI (over SSH), live streaming, and agent-friendly observability. Launcher script at ./radar in the supex root, project code in ./devtools/radar/.

Scope

  • In: single-host, file-based ingest, live tail streaming, TUI (Textual), agent interaction via tmux
  • Out: multi-host aggregation, network ingests (OTLP/syslog/kafka), enterprise SIEM integration, dedicated agent API

Architecture

Log Files (rotate/truncate safe)
         |
   File Watch/Tail Layer
         |
   Parser Layer (pipe | JSONL | plain)
         |
   Normalizer -> LogEvent model
         |
   Ring Buffer (in-memory, fixed capacity)
         |
   +-----------+--------------+
   |                          |
   Plain mode              TUI mode
   (stdout stream)         (Textual, interactive)
   ./radar watch --plain   ./radar watch
   |                          |
   Agent / pipe            tmux pane
                              / \
                          Human    Agent
                          (eyes)   (capture-pane + send-keys)

Note: TUI can run outside tmux; tmux is the recommended mode for agent control.

Key Design Decisions

Decision Choice Rationale
Language Python 3.14+ Rapid development, rich ecosystem, user preference
Package manager uv Workspace convention
TUI framework Textual + Rich Mature SSH-friendly TUI, async-native
Storage In-memory ring buffer No persistence needed; debugging/observing tool only
File watching Custom inode-aware tailer Handles rotate/truncate without restart
Two run modes Plain (stdout stream) and TUI (interactive) Plain for quick observing; TUI for detailed inspection
Agent interaction tmux (capture-pane + send-keys) in TUI mode Agent uses the same TUI as human; no separate API
Runtime tmux optional Plain mode works anywhere; TUI is tmux-friendly and recommended in tmux for agent operation
Multi-instance Multiple radars in parallel Each independent; read-only file tailing, no shared state
Parsers Pluggable registry Each log source gets its own parser; easy to add new ones
Summary renderers Pluggable per-source Human+agent friendly one-liners; raw detail on demand

Extensibility Model

Three plugin points, all following the same pattern — register by source name, fall back to defaults:

  1. Log sources — adding a new source = one config entry (file path + optional parser/renderer name). No code changes needed for sources that work with default parser/renderer.
  2. Parsers — each source can have a custom parser (BaseParser subclass) that extracts structured data from raw lines. Registry keyed by name. Default: auto-detect (pipe/JSONL/plain).
  3. Summary renderers — each source can have a custom summary renderer that produces a concise one-liner from LogEvent. The summary is what both humans and agents see in the log list. Raw detail is always available via toggle. Default: timestamp level source message. Hot-reloadable: renderer files are watched for changes and re-imported on save — no restart needed. This lets both humans and agents tweak the summary view while radar is running.

source is the logical source ID from config ([[source]].name), not a path basename.

This means: when supex adds a new component with a new log format, adding support to Radar is just writing a parser + renderer and registering them.

Configuration Model

Radar uses TOML config files. Zero-config default covers standard supex development; config files override for tests or custom setups.

Resolution rules and precedence: Effective workspace root is $SUPEX_WORKSPACE when set, otherwise current working directory.

  1. watch [files...] accepts zero or more positional files.
  2. If one or more files are provided, Radar runs in ad-hoc file mode (config/default source discovery is skipped).
  3. Else if --config is provided, it is the active source list (built-in defaults and radar.toml are not loaded).
  4. If no ad-hoc files and no --config are provided:
    • load <workspace-root>/radar.toml when present,
    • otherwise use built-in defaults.
  5. Workspace root for relative paths is defined by workspace in the active config; without config, built-ins resolve from the effective workspace root.

Default config (built-in, no file needed):

Built-in defaults below describe the target post-log-unify + log-layout-unify assumptions.

# Implicit when no config provided — resolves relative to effective workspace root
workspace = "."

[[source]]
name = "mcp-protocol"
path = ".tmp/logs/mcp-protocol.jsonl"
parser = "jsonl"

[[source]]
name = "mcp-stderr"
path = ".tmp/logs/mcp-stderr.log"
parser = "pipe"

[[source]]
name = "cli-driver"
path = ".tmp/logs/cli-driver.log"
parser = "pipe"

[[source]]
name = "cli-stdout"
path = ".tmp/logs/cli-stdout.log"
parser = "plain"

[[source]]
name = "cli-stderr"
path = ".tmp/logs/cli-stderr.log"
parser = "pipe"

[[source]]
name = "runtime-console"
path = ".tmp/logs/runtime-console.log"
parser = "pipe"

[[source]]
name = "runtime-stdout"
path = ".tmp/logs/runtime-stdout.log"
parser = "plain"

[[source]]
name = "runtime-stderr"
path = ".tmp/logs/runtime-stderr.log"
parser = "plain"

[[source]]
name = "vcad-sidecar"
path = ".tmp/logs/vcad-sidecar-stderr.log"
parser = "pipe"

[[source]]
name = "vcad-events"
path = ".tmp/logs/vcad-events.jsonl"
parser = "jsonl"

Note: cli-stdout, runtime-stdout, and runtime-stderr intentionally use plain because these streams can contain mixed unstructured process output.

Test config example (tests/fixtures/radar-test.toml):

# Point radar at test-isolated log directory
workspace = "/tmp/supex-test-abc123"

[[source]]
name = "test-driver"
path = "logs/driver.log"
parser = "pipe"

[[source]]
name = "test-sidecar"
path = "logs/sidecar.log"
parser = "pipe"

Usage:

# Default — watches standard supex logs
./radar watch

# Explicit config file
./radar watch --config tests/fixtures/radar-test.toml

# Non-default workspace: pass a config file that sets `workspace = "/path/to/workspace"`
./radar watch --config /tmp/radar-alt-workspace.toml

# Ad-hoc files mode (skips config/default source discovery)
./radar watch .tmp/logs/*.{log,jsonl}

# Invalid: ad-hoc files mode and --config together (must fail with usage error)
./radar watch .tmp/logs/*.{log,jsonl} --config tests/fixtures/radar-test.toml

Key design points:

  • workspace sets the root for relative paths in [[source]] entries
  • --config fully overrides defaults (explicit sources only)
  • Non-default workspace selection is done via config file (workspace = ...), not CLI flags
  • poll_interval is a global runtime setting (CLI/config), not per-source
  • Paths support globs (path = ".tmp/logs/*")
  • watch [files...] accepts shell-expanded file lists; config paths support internal glob expansion
  • Each [[source]] can optionally specify parser and renderer by name
  • Parser selection precedence: explicit [[source]].parser > auto-detect (detect_format) > plain fallback
  • radar watch [files...] and --config are mutually exclusive only when one or more positional files are provided
  • radar watch (with zero positional files) uses config/default discovery; radar watch [files...] with files is ad-hoc mode

LogEvent Data Model (reference)

@dataclass
class LogEvent:
    eid: str                     # Short event ID (e.g. "a3f2b7") for cross-reference
    timestamp: datetime          # Parsed or inferred
    level: str                   # DEBUG, INFO, WARN, ERROR, FATAL
    source: str                  # Logical source ID (from config `[[source]].name`)
    source_path: str             # Physical origin path for diagnostics/detail view
    message: str                 # Human-readable summary line
    structured: dict | None      # Parsed key-value / JSON payload
    raw: str                     # Original untouched line(s)
    multiline: bool              # Whether this spans multiple raw lines

Project Structure (target)

supex/
├── radar                    # Convenience launch script (bash, executable)
├── devtools/radar/          # Python project
│   ├── pyproject.toml
│   ├── src/radar/
│   │   ├── __init__.py
│   │   ├── models.py            # LogEvent dataclass + enums
│   │   ├── tailer.py            # File watch/tail with rotate detection
│   │   ├── parsers/
│   │   │   ├── __init__.py      # Parser registry + base class
│   │   │   ├── pipe.py          # Pipe-separated format
│   │   │   ├── jsonl.py         # JSON lines format
│   │   │   └── plain.py         # Plain text fallback
│   │   ├── normalizer.py        # Raw parsed data -> LogEvent
│   │   ├── buffer.py            # In-memory ring buffer
│   │   ├── tui/
│   │   │   ├── __init__.py
│   │   │   ├── app.py           # Textual App main class
│   │   │   ├── views.py         # Live list, detail panel, filter bar
│   │   │   └── renderers.py     # summary_renderer, raw_renderer
│   │   └── cli.py               # CLI entry point (typer)
│   └── tests/
│       ├── conftest.py
│       ├── test_models.py
│       ├── test_cli_modes.py
│       ├── test_tailer.py
│       ├── test_parsers.py
│       ├── test_buffer.py
│       └── test_renderers.py

Conventions

  • Project lives in devtools/radar/ (part of supex repo)
  • Convenience launch script ./radar in supex root (same pattern as ./mcp and ./supex)
  • Python: type hints, dataclasses, async where needed
  • Tests: pytest

Risks and Mitigations

Risk Mitigation
Inconsistent timestamps across files Fallback to receive-time; warn in TUI
Large JSON payloads slow rendering Truncate in summary view; full in raw view
Multiline exception block mis-assembly Indentation/pattern heuristics + configurable continuation regex
Ring buffer overflow loses old events Expected behavior for debugging tool; capacity configurable
Polling lag under bursty load Keep --plain stream running in parallel for lossless observation; tune polling interval and batching

Known Log Sources (current state)

Current state uses multiple locations and is a candidate for cleanup in a dedicated log-layout phase.

Current roots in practice:

  • $SUPEX_WORKSPACE/.tmp/logs/ for wrapper/driver-side logs
  • <repo>/.tmp/ for selected runtime launcher outputs
File Source Format
supex-mcp-protocol.jsonl ./mcp wrapper (tee) JSONL (MCP request/response)
supex-mcp-stderr.log ./mcp wrapper (tee) Plain text (driver stderr)
supex-cli-stdout.log ./supex wrapper (tee) Plain text
supex-cli-stderr.log ./supex wrapper (tee) Plain text
supex-cli.log Python driver CLI Plain text (asctime - name - level - message)
sketchup_console.log Ruby ConsoleCapture Pipe-separated (timestamp|level|source|message)
sketchup_out.txt launch-sketchup.sh Plain text (SketchUp stdout)
sketchup_err.txt launch-sketchup.sh Plain text (SketchUp stderr)
vcad-sidecar-stderr.log ./vcad-sidecar wrapper Plain text (Rust tracing)

Typical active session: multiple files simultaneously (exact count depends on enabled actors).

Note: ./vcad-sidecar wrapper redirects stderr from scripts/launch-vcad-sidecar.sh process into vcad-sidecar-stderr.log.

Python driver also emits structured JSON events on stderr (vcad_logging.py) with fields: timestamp, level, request_id, node_id, event, error_code.

Target Log Layout (post-unification)

Single root: $SUPEX_WORKSPACE/.tmp/logs/

Flat per-actor filenames under the root (no subdirectories), e.g.:

  • $SUPEX_WORKSPACE/.tmp/logs/mcp-protocol.jsonl
  • $SUPEX_WORKSPACE/.tmp/logs/mcp-stderr.log
  • $SUPEX_WORKSPACE/.tmp/logs/cli-stdout.log
  • $SUPEX_WORKSPACE/.tmp/logs/cli-stderr.log
  • $SUPEX_WORKSPACE/.tmp/logs/cli-driver.log
  • $SUPEX_WORKSPACE/.tmp/logs/runtime-console.log
  • $SUPEX_WORKSPACE/.tmp/logs/runtime-stdout.log
  • $SUPEX_WORKSPACE/.tmp/logs/runtime-stderr.log
  • $SUPEX_WORKSPACE/.tmp/logs/vcad-sidecar-stderr.log
  • $SUPEX_WORKSPACE/.tmp/logs/vcad-events.jsonl

Target source-to-file map:

Source ID Target file Parser
mcp-protocol mcp-protocol.jsonl jsonl
mcp-stderr mcp-stderr.log pipe
cli-stdout cli-stdout.log plain
cli-stderr cli-stderr.log pipe
cli-driver cli-driver.log pipe
runtime-console runtime-console.log pipe
runtime-stdout runtime-stdout.log plain
runtime-stderr runtime-stderr.log plain
vcad-sidecar vcad-sidecar-stderr.log pipe
vcad-events vcad-events.jsonl jsonl

Note: built-in default config remains the executable source of truth; this map is a quick reference.

Operational Notes

  • tmux control (capture-pane + send-keys) is considered proven in practice; primary remaining risk is polling cadence under burst load.

Phase Ordering Note

Recommended execution order:

  1. Phase scaffold
  2. Phase log-unify
  3. Phase log-layout-unify
  4. Phase tailer
  5. Phase parsers
  6. Phase buffer
  7. Phase tui
  8. Phase render-hot-reload
  9. Phase cli-polish
  10. Phase perf-polish
  11. Phase ssh-tmux-polish

Rationale: implementing log format/layout unification early keeps downstream ingest/parser/TUI phases aligned to a single source-of-truth log structure.


Phase scaffold: Project skeleton + models + CLI entry point

Objective

Bootstrap the radar project in devtools/radar with uv, define the LogEvent data model and enums, create a minimal CLI entry point, and add the ./radar convenience launch script in the supex root. After this phase, ./radar --help works from the supex root.

Specification

./radar launch script (supex root)

Convenience bash wrapper following the same pattern as ./mcp and ./supex:

#!/usr/bin/env bash
# Supex Radar — log aggregator TUI
set -e

SCRIPT_PATH="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT_PATH" ]; do
  SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
  SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
  [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
done
SUPEX_ROOT="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"

exec uv run --project "$SUPEX_ROOT/devtools/radar" radar "$@"

Mark executable (chmod +x).

pyproject.toml

[project]
name = "radar"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
    "textual>=0.80",
    "rich>=13",
    "typer>=0.12",
]

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]

[project.scripts]
radar = "radar.cli:main"

src/radar/models.py

Define LogEvent dataclass as specified in the overview. Add Level enum (DEBUG, INFO, WARN, ERROR, FATAL) with comparison ordering. Add FilterSpec dataclass for holding active filter state (source, level, time range, regex pattern).

src/radar/cli.py

Typer-based CLI with subcommands:

  • radar watch [files...] — main watch entrypoint (minimal scaffold behavior in this phase)
  • radar parse <file> — minimal parse/dump command for parser pipeline smoke checks
  • --config option for future TOML config file
  • early validation: reject watch [files...] (with one or more files) combined with --config

Behavior note:

  • radar watch with zero positional files runs in config/default discovery mode.
  • radar watch [files...] with one or more files runs in ad-hoc mode and does not combine with --config source lists.
  • If ad-hoc files and --config are both provided, CLI exits with a clear usage error (non-zero) explaining the conflict.

Tests

  • test_models.py: LogEvent creation, Level ordering, FilterSpec defaults
    • verifies source is logical source ID and source_path stores physical origin path
  • test_cli_modes.py: CLI mode selection and conflict handling
    • radar watch (zero files) succeeds in config/default mode
    • radar watch <file> (one or more files) enters ad-hoc mode
    • radar watch <file> --config ... fails with usage error

Verify

# Run verify commands from repository root

# Launch script works from supex root
./radar --help

# Tests pass
cd devtools/radar && uv run pytest tests/test_models.py -v
cd devtools/radar && uv run pytest tests/test_cli_modes.py -v

Phase log-unify: Standardize supex log output for Radar consumption

Objective

Adjust logging across supex subsystems to make log files easier for Radar to parse. Not a forced single format — each subsystem keeps its character — but consistent enough that parsers can be simple and reliable. Changes are in the supex repo (driver, runtime, sidecar, scripts).

Specification

Current state

Subsystem Format Timestamp Problem for Radar
Python driver asctime - name - levelname - message 2026-02-23 14:30:45,123 Non-ISO timestamp and inconsistent separator for Radar parser
Python vcad events JSON embedded in log line Unix float in JSON Double-wrapped: log format wraps JSON string
Ruby ConsoleCapture HH:MM:SS.LLL|severity|SketchUp|message Time only, no date Missing date; source always "SketchUp"
Rust sidecar tracing_subscriber default ISO-8601 with tz Good as-is, but target format differs from Python
Launch scripts [ERROR]/[CONSOLE] prefix via sed None (from source) ANSI color codes in output; no structured fields

Changes by subsystem

Python driver (driver/src/supex_driver/):

  • Unify format to: %(asctime)s|%(levelname)s|%(name)s|%(message)s
  • Set asctime datefmt to ISO-8601: %Y-%m-%dT%H:%M:%S.%f (or use logging.Formatter with default_msec_format)
  • Apply to both MCP server (stderr) and CLI (file) logging
  • vcad structured events: log JSON lines directly (no outer pipe wrapper) via dedicated vcad-events stream (%(message)s-only logger), targeting vcad-events.jsonl in the unified layout

Ruby ConsoleCapture (runtime/src/supex_runtime/console_capture.rb):

  • Add date to timestamp: %Y-%m-%dT%H:%M:%S.%L instead of %H:%M:%S.%L
  • Use actual source where available (e.g. "Bridge", "Runtime") instead of always "SketchUp"
  • Keep pipe-separated format — it's already good

Rust sidecar (vcad/sidecar/src/main.rs):

  • Switch tracing_subscriber to pipe-separated format: timestamp|level|target|message
  • Use tracing_subscriber::fmt().with_timer(...) for ISO-8601 timestamps
  • Or: use fmt::format::Format customization to output pipe-separated fields
  • Minimal change — just format, not the content of log messages

Launch scripts (scripts/):

  • launch-sketchup.sh: no required changes for Radar compatibility
  • launch-vcad-sidecar.sh: no changes needed (sidecar format handled above)

Result

After this phase, structured supex logs use either:

  • Pipe-separated: ISO-8601-timestamp|level|source|message (Python, Ruby, Rust)
  • JSONL: one JSON object per line (MCP protocol, vcad structured events)

runtime-stdout and runtime-stderr are treated as plain process streams in Radar defaults.

This phase aligns runtime log formats with the built-in default config parser mapping.

Radar ships with three built-in parsers (pipe/jsonl/plain) that handle all supex sources out of the box.

Verify

# Python driver format
(cd driver && uv run python -c '
import logging, os, re, tempfile
from pathlib import Path
from supex_driver.cli.main import _setup_logging

workspace = tempfile.mkdtemp(prefix="radar-log-unify-")
os.environ["SUPEX_WORKSPACE"] = workspace
_setup_logging()

logger = logging.getLogger("radar.verify")
logger.info("format-check")

line = Path(workspace, ".tmp", "logs", "supex-cli.log").read_text().strip().splitlines()[-1]
assert "|INFO|radar.verify|format-check" in line, line
assert re.match(r"^\d{4}-\d{2}-\d{2}T", line), line
')

# Ruby console format includes date
# Uses test-only introspection of private formatter helper (not a public runtime API contract)
(cd runtime && ruby -e '
require_relative "src/supex_runtime/console_capture"
capture = SupexRuntime::ConsoleCapture.allocate
line = capture.send(:idea_log_line, "I", "verify").strip
abort("bad format: #{line}") unless line.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\|I\|[^|]+\|verify$/)
')

# Rust sidecar builds
(cd vcad/sidecar && cargo build 2>&1 | tail -1)
rg -n 'tracing_subscriber::fmt|with_timer|Format' vcad/sidecar/src/main.rs

# Launch script behavior: terminal coloring is allowed, raw log files must remain parseable
test -f scripts/launch-sketchup.sh

Phase log-layout-unify: Single workspace-relative log root (hard cutover)

Objective

Move all supex log producers to one workspace-relative root with flat actor-prefixed filenames: $SUPEX_WORKSPACE/.tmp/logs/[actor]-[name].(log|jsonl)

Precondition

Phase log-unify completed (format unification first, path unification second).

Specification

  • Canonical root: $SUPEX_WORKSPACE/.tmp/logs/
  • Canonical filenames:
    • mcp-protocol.jsonl
    • mcp-stderr.log
    • cli-stdout.log
    • cli-stderr.log
    • cli-driver.log
    • runtime-console.log
    • runtime-stdout.log
    • runtime-stderr.log
    • vcad-sidecar-stderr.log
    • vcad-events.jsonl
  • Extension convention:
    • *.jsonl for structured JSON line streams
    • *.log for text/pipe streams
  • Hard cutover (no backward compatibility):
    • no dual-write
    • no legacy path fallback
    • no compatibility shims
  • Update wrappers/runtime/sidecar launch paths to write only under canonical root.
  • Update Radar built-in default sources to canonical flat filenames.
  • New log producers must follow canonical flat filename conventions.

Verify

# Run from repo root
: "${SUPEX_WORKSPACE:=$(pwd)}"

# Warning: cleanup below is destructive for listed log files.
# Use only in a disposable dev/test workspace.

# 0) Clean previous artifacts so checks validate fresh output from this run
mkdir -p "$SUPEX_WORKSPACE/.tmp/logs"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/mcp-protocol.jsonl"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/mcp-stderr.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/cli-stdout.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/cli-stderr.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/cli-driver.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/vcad-sidecar-stderr.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/vcad-events.jsonl"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/runtime-console.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/runtime-stdout.log"
rm -f "$SUPEX_WORKSPACE/.tmp/logs/runtime-stderr.log"
rm -f "$SUPEX_WORKSPACE/.tmp/sketchup_console.log"
rm -f "$SUPEX_WORKSPACE/.tmp/sketchup_out.txt"
rm -f "$SUPEX_WORKSPACE/.tmp/sketchup_err.txt"

# 1) Spawn representative producers (commands/examples may vary by environment)
# ./mcp ...
# ./supex ...
# ./vcad-sidecar ...
# Example minimal invocations (environment-dependent):
# ./mcp <<< '{"jsonrpc":"2.0","id":1,"method":"ping"}' >/dev/null 2>&1 || true
# ./supex --help >/dev/null 2>&1 || true
# ./vcad-sidecar --help >/dev/null 2>&1 || true
# These three are the mandatory subset for PASS in this verify scenario.
# Ensure commands actually emit protocol/stdout/stderr so required files are created.

# 2) Confirm only canonical tree is used
test -d "$SUPEX_WORKSPACE/.tmp/logs"
find "$SUPEX_WORKSPACE/.tmp/logs" -maxdepth 1 -type f | head
! find "$SUPEX_WORKSPACE/.tmp/logs" -mindepth 1 -type d | grep -q .

# Additional files are allowed (future producers), but layout must stay flat
# and mandatory canonical subset below must exist.

# 2b) Mandatory canonical files for the spawned producer subset (PASS criteria)
test -f "$SUPEX_WORKSPACE/.tmp/logs/mcp-protocol.jsonl"
test -f "$SUPEX_WORKSPACE/.tmp/logs/mcp-stderr.log"
test -f "$SUPEX_WORKSPACE/.tmp/logs/cli-stdout.log"
test -f "$SUPEX_WORKSPACE/.tmp/logs/cli-stderr.log"
test -f "$SUPEX_WORKSPACE/.tmp/logs/cli-driver.log"
test -f "$SUPEX_WORKSPACE/.tmp/logs/vcad-sidecar-stderr.log"

# 2c) Optional runtime/structured files (environment-dependent; informational only)
test -f "$SUPEX_WORKSPACE/.tmp/logs/runtime-console.log" || true
test -f "$SUPEX_WORKSPACE/.tmp/logs/runtime-stdout.log" || true
test -f "$SUPEX_WORKSPACE/.tmp/logs/runtime-stderr.log" || true
test -f "$SUPEX_WORKSPACE/.tmp/logs/vcad-events.jsonl" || true

# 3) Ensure no new radar-tracked logs are written outside canonical root
! test -f "$SUPEX_WORKSPACE/.tmp/sketchup_console.log"
! test -f "$SUPEX_WORKSPACE/.tmp/sketchup_out.txt"
! test -f "$SUPEX_WORKSPACE/.tmp/sketchup_err.txt"

Phase tailer: File watcher with rotate/truncate detection

Objective

Build the file watch/tail layer that reliably streams new lines from multiple log files, handling inode changes on rotate and file truncation without data loss or crash.

Specification

src/radar/tailer.py

class FileTailer:
    """Watches one file for new content. Handles rotate and truncate."""

    def __init__(self, path: Path, poll_interval: float = 0.2):
        self.path = path
        self.offset: int = 0
        self.inode: int | None = None

    async def read_new_lines(self) -> list[str]:
        """Return new lines since last read. Detect rotate/truncate."""

    def _detect_rotate(self) -> bool:
        """Inode changed or file smaller than offset -> rotated."""

    def _detect_truncate(self) -> bool:
        """Same inode but size < offset -> truncated (e.g. logrotate copytruncate)."""


@dataclass
class TailSource:
    source: str   # logical source ID from config
    source_path: Path


class MultiTailer:
    """Manages multiple FileTailers, yields (source, line, source_path) tuples."""

    def __init__(self, sources: list[TailSource], poll_interval: float = 0.2): ...

    async def stream(self) -> AsyncIterator[tuple[str, str, str]]:
        """Infinite async generator of (source_id, raw_line, source_path)."""

Key behaviors:

  • Accepts source descriptors with logical source ID + path (not raw paths only)
  • TailSource entries are produced by config resolution after glob expansion, sorting, deduplication, and path validation
  • Track file by inode (not just path) — detect rotation when inode changes
  • On rotate: re-open path, reset offset to 0, read from start of new file
  • On truncate: reset offset to 0, continue reading
  • Poll-based with globally configurable interval (default 200ms)
  • Graceful handling of missing files (warn, retry on next poll)
  • Multiline assembly is NOT done here — tailer emits raw lines only

Tests

  • test_tailer.py:
    • Basic tail: append lines, verify they appear
    • Rotate: rename file + create new, verify new lines are read
    • Truncate: write, truncate, write again — verify no crash, new lines appear
    • Missing file: verify warning, no crash
    • Multiple files: verify interleaved output

Verify

cd devtools/radar && uv run pytest tests/test_tailer.py -v

Phase parsers: Log format parsers + normalizer

Objective

Implement pluggable parsers for pipe-separated, JSONL, and plain text formats, plus a normalizer that converts parsed output into LogEvent instances. Support multiline continuation (stacktraces).

Specification

src/radar/parsers/__init__.py

class BaseParser(ABC):
    @abstractmethod
    def parse_line(self, raw: str) -> ParsedRecord | None:
        """Parse a single line. Return None if not recognized."""

    def is_continuation(self, line: str) -> bool:
        """Return True if line is a continuation of previous record (multiline)."""
        return False

@dataclass
class ParsedRecord:
    timestamp: datetime | None
    level: str | None
    message: str
    structured: dict | None


def detect_format(sample_lines: list[str]) -> BaseParser:
    """Auto-detect log format from first N lines."""

src/radar/parsers/pipe.py

Pipe-separated format: timestamp|level|source|message|optional_json_payload

This is the canonical order for unified pipe logs; emitters should follow it consistently.

src/radar/parsers/jsonl.py

JSON lines: each line is a JSON object. message is optional.

  • Preferred fields: timestamp, level, message.
  • For protocol/event payloads without message, parser derives a summary from known keys (method, event, error, result) and preserves full payload in structured.
  • Malformed JSON records are routed to plain-parser handling by ingest/normalizer fallback logic.

src/radar/parsers/plain.py

Fallback: attempt to extract timestamp and level from common patterns (syslog, ISO-8601 prefix, etc.). Rest is message. Multiline continuation: lines starting with whitespace or common stacktrace patterns ( at ..., Caused by:, Traceback).

Input normalization should tolerate ANSI escape sequences (e.g. by stripping ANSI before parsing), while preserving original content in raw when needed.

src/radar/normalizer.py

class Normalizer:
    def __init__(self, parser: BaseParser, source: str): ...

    def feed_line(self, raw: str) -> LogEvent | None:
        """Feed a raw line. Returns LogEvent when a complete record is ready.
        Buffers continuation lines internally."""

    def flush(self) -> LogEvent | None:
        """Flush any buffered multiline record."""

eid generation:

  • Generated by Normalizer when finalizing a LogEvent.
  • Mandatory algorithm: first 6 lowercase hex chars of sha256(source + timestamp_iso + first_raw_line).
  • No alternative algorithm is allowed in v1 (deterministic cross-reference contract).
  • eid is for cross-reference/search convenience (not a globally unique ID); rare collisions are acceptable.

Multiline strategy:

  • Normalizer holds a buffer of continuation lines
  • When a new non-continuation line arrives, flush the buffer as one LogEvent (with multiline=True), start new buffer
  • raw field contains all original lines joined with \n
  • message field contains the first line's parsed message

Tests

  • test_parsers.py:
    • Pipe parser: standard line, missing fields, extra payload
    • JSONL parser: valid JSON, malformed JSON (fallback to plain)
    • Plain parser: syslog format, ISO-8601, no timestamp
    • ANSI handling: lines containing ANSI escape sequences are parsed correctly
    • Auto-detection: pipe vs JSONL vs plain
    • Multiline: Java stacktrace, Python traceback
    • Normalizer: feed sequence of lines, verify correct LogEvent assembly
    • EID: present, stable for same input, changes when source/timestamp/first_raw_line changes

Verify

cd devtools/radar && uv run pytest tests/test_parsers.py -v

Phase buffer: Ring buffer + ingest pipeline

Objective

Implement in-memory ring buffer for live event storage and the ingest pipeline that connects tailer -> normalizer -> buffer. No persistence — radar is a debugging/observing tool.

Specification

src/radar/buffer.py

class RingBuffer:
    """Fixed-size in-memory buffer of LogEvents."""

    def __init__(self, capacity: int = 10_000): ...

    def append(self, event: LogEvent) -> None: ...
    def get_recent(self, n: int) -> list[LogEvent]: ...
    def filter(self, spec: FilterSpec) -> list[LogEvent]: ...
    def search(self, pattern: str) -> list[LogEvent]:
        """Regex/substring search over message + raw fields."""

    @property
    def count(self) -> int: ...

    @property
    def sources(self) -> set[str]: ...

When buffer is full, oldest events are silently evicted (standard ring buffer behavior). Capacity is configurable via CLI flag.

src/radar/ingest.py

Pipeline orchestrator that connects tailer -> normalizer -> buffer:

class IngestPipeline:
    """Connects MultiTailer -> Normalizer -> RingBuffer."""

    def __init__(self, tailer: MultiTailer, buffer: RingBuffer): ...

    async def run(self) -> None:
        """Main ingest loop. Runs until cancelled."""

Auto-detects parser per source file (via detect_format from parsers module). Creates one Normalizer per source.

Parser and renderer selection is keyed by logical source (config name), not by file basename. MultiTailer is constructed from resolved source config entries (source + source_path).

Parser selection behavior in ingest:

  • If source config specifies parser, use it (no auto-detect).
  • Else run detect_format on source samples.
  • If detect fails or parser errors for all samples, fallback to plain parser and emit a warning with source and source_path.

Glob handling in ingest/config loading:

  • Expand glob paths before tailer construction.
  • Sort expanded paths lexicographically for deterministic startup order.
  • Deduplicate expanded paths.
  • Keep logical source ID from config; each matched file gets its own source_path.

Tests

  • test_buffer.py:
    • RingBuffer: append, overflow/eviction, get_recent, filter, search
    • IngestPipeline: end-to-end from file lines to buffered events
    • source filtering uses logical source ID ([[source]].name), independent of file basename
    • parser selection precedence: explicit parser override, auto-detect path, and plain fallback path
    • glob expansion: path = ".tmp/logs/*" expands deterministically and ingests all matched files

Verify

cd devtools/radar && uv run pytest tests/test_buffer.py -v

Phase tui: Textual TUI with live stream, filters, and summary/raw toggle

Objective

Build the Textual-based TUI application with live log streaming, filter controls (source, level, time, regex), detail panel, and summary/raw view toggle. Must work over SSH; tmux is recommended for agent operation.

Specification

src/radar/tui/app.py

class RadarApp(App):
    """Main Textual application."""

    BINDINGS = [
        ("q", "quit", "Quit"),
        ("f", "toggle_filter", "Filter"),
        ("tab", "toggle_view", "Summary/Raw"),
        ("/", "search", "Search"),
        ("j", "scroll_down", "Down"),
        ("k", "scroll_up", "Up"),
        ("g", "scroll_top", "Top"),
        ("G", "scroll_bottom", "Bottom"),
    ]

src/radar/tui/views.py

Components:

  • LogListView: scrollable list of log events (virtual list for performance)
    • Each row shows: timestamp | level (colored) | source | message (truncated)
    • Selected row highlights; press Enter to open detail
  • DetailPanel: expanded view of single event
    • Toggle between summary (rendered) and raw (original text)
  • FilterBar: input fields for source, level dropdown, time range, regex
    • Live filtering (debounced, ~200ms)
  • StatusBar: total events, filtered count, active sources, stream rate

src/radar/tui/renderers.py

Pluggable summary renderers — each source can register a custom one-liner renderer. Summary is what both humans and agents see in the list view. Raw is always available via detail toggle.

class SummaryRenderer(ABC):
    """Base class for source-specific summary renderers."""
    @abstractmethod
    def render(self, event: LogEvent) -> RenderableType: ...

class DefaultSummaryRenderer(SummaryRenderer):
    """Default: timestamp | level (colored) | source | message (truncated)."""

class MCPProtocolRenderer(SummaryRenderer):
    """MCP JSONL: method name, tool, direction (req/res), duration."""

class ConsoleLogRenderer(SummaryRenderer):
    """Pipe-separated SketchUp console: level + source + message."""

# Registry: source name -> renderer instance
RENDERERS: dict[str, SummaryRenderer] = {}

def raw_renderer(event: LogEvent) -> RenderableType:
    """Original raw text with syntax highlighting. No truncation.
    Always available via detail panel toggle — not pluggable."""

Renderer lookup uses `event.source` (logical source ID from config).

Design constraints:

  • Virtual scrolling for large datasets (don't load all events into DOM)
  • Level color convention: DEBUG=dim, INFO=blue, WARN=yellow, ERROR=red, FATAL=red bold — applied by default, but summary renderers own their coloring (can highlight individual words, paths, durations, etc.)
  • SSH-safe: no mouse-only interactions, all actions have keyboard shortcuts
  • Responsive: works in 80x24 minimum terminal size

Two modes: streaming vs browsing

Streaming mode (default):

  • New events appear at bottom, view auto-scrolls to follow
  • No cursor visible in the list
  • Filter bar still works — only matching events are shown

Browsing mode (activated by pressing j/k or arrow keys):

  • Streaming pauses — view stays fixed so the selected row doesn't escape
  • Cursor visible, moves with j/k/arrows
  • Enter opens detail panel for selected event (summary/raw toggle via Tab); cursor navigation (j/k) and filtering still work with panel open — detail updates to follow selection
  • New events still arrive into the buffer but the view doesn't scroll
  • Esc exits browsing mode: cursor disappears, view jumps to end of list, streaming resumes

Integration with IngestPipeline

TUI receives events via async queue from IngestPipeline. RingBuffer serves both the live view and filtered/search results.

Agent interaction via tmux

When Radar runs in tmux, both humans and agents interact with the same TUI pane:

Agent reads the screen:

tmux capture-pane -t radar -p          # plain text snapshot of current view

Agent sends keystrokes:

tmux send-keys -t radar 'f'            # open filter bar
tmux send-keys -t radar 'ERROR' Enter  # type filter, confirm
tmux send-keys -t radar 'q'            # quit

Agent workflow examples:

Quick observing (plain mode):

  1. Agent runs ./radar watch --plain — summary lines stream to stdout
  2. Agent pipes/reads stdout, spots interesting event
  3. Each line includes a short event ID (e.g. [e:a3f2b7]) for cross-reference

Detailed inspection (TUI mode):

  1. Agent runs ./radar watch --pane-name radar-agent in a tmux pane
  2. Uses tmux capture-pane / tmux send-keys to interact
  3. To find an event seen in plain mode: press / and search by event ID (a3f2b7) or timestamp
  4. Cursor jumps to matching event, Enter opens detail

Recommended operational pattern (agent-safe):

  • Keep ./radar watch --plain running in a background pane/window as a lossless stream.
  • Use ./radar watch --pane-name radar-agent in a second pane for interactive filtering and inspection.
  • If TUI polling falls behind during bursts, plain mode still preserves visibility of all incoming lines.

Event IDs for cross-reference:

  • Every LogEvent gets a short ID (e.g. first 6 hex chars of sha256(source+timestamp_iso+first_raw_line))
  • Shown in both plain mode output and TUI list view
  • Searchable in TUI via / — enables quick jump from plain mode observation to TUI detail

TUI design for agent-friendliness:

  • Status bar with consistent format: total count, filtered count, active sources, rate
  • Level tags always in same column position (easy to regex from capture-pane output)
  • Timestamps in ISO-8601 (machine-parseable)
  • No animations or transient decorations that interfere with capture-pane reads
  • Keybindings are single characters (easy to send via send-keys)

./radar launcher:

  • --plain flag: stream summary lines to stdout (no TUI, no tmux needed)
  • --pane-name NAME flag: customize tmux pane name (default: radar)
  • If TUI mode inside tmux: optionally rename pane
  • If TUI mode outside tmux: run directly in the current terminal (no auto-launch behavior in v1)
  • Multiple instances supported: each runs independently with its own ring buffer and file tailers

Verify

Manual verification — launch TUI with sample log files and verify:

  • Live streaming works
  • Filter by level/source/regex works
  • Summary/raw toggle works
  • Keyboard navigation works
  • tmux capture-pane -t radar -p returns parseable text
  • tmux send-keys -t radar controls the TUI correctly
  • --plain and TUI can run in parallel over the same files without crash; both continue receiving new events
# Smoke test: app imports and basic widget construction
cd devtools/radar && uv run python -c "from radar.tui.app import RadarApp; print('OK')"

Phase render-hot-reload: Live renderer reload without restart

Objective

Implement hot-reload for summary renderer modules so renderer output updates on file save without restarting Radar.

Precondition

Phase tui completed.

Specification

Scope

  • Watches renderer module files used by source renderer registry.
  • On file change, reloads the module and swaps renderer instance in registry.
  • Applies to summary renderers only (raw renderer remains non-pluggable).

Behavior

  • Successful reload updates subsequent rows/detail summaries immediately.
  • Reload failures (syntax/import/runtime during init) do not crash app:
    • Keep last known-good renderer for affected source.
    • Emit warning in status/log stream with source + error summary.
  • After fixing file and saving again, reload retries and recovers automatically.

Suggested implementation components

  • RendererReloader service in TUI layer:
    • tracks watched files and mtime/hash
    • performs debounced reload
    • updates renderer registry atomically
  • RendererLoadResult for structured success/error reporting

Tests

  • test_renderers.py:
    • reload success path: file update changes rendered output without restart
    • syntax error path: keeps last known-good renderer and records warning
    • recovery path: after fix, renderer reloads and new output is used

Verify

Manual verification:

  • Run Radar TUI.
  • Edit a renderer file while app is running; verify output changes without restart.
  • Introduce a syntax error; verify app keeps running with warning and old renderer behavior.
  • Fix syntax error; verify renderer recovers after next save.
cd devtools/radar && uv run python -c "from radar.tui.renderers import RENDERERS; print(type(RENDERERS).__name__)"
cd devtools/radar && uv run python -c "import radar.tui.renderers as r; print(bool(getattr(r, 'RENDERERS', None)))"

Phase cli-polish: CLI UX and startup ergonomics

Objective

Polish CLI ergonomics for day-to-day use: file selection, pre-set filters, and capacity tuning.

Specification

  • radar watch .tmp/logs/* — ad-hoc glob expansion across *.log and *.jsonl
  • Supports both shell-expanded CLI files and config-based glob paths (no duplicate source entries)
  • radar watch --level ERROR --source cli-driver — pre-set filters
  • --source expects logical source IDs ([[source]].name), not filenames/paths
  • Pre-set filters apply in both TUI mode and --plain mode
  • --capacity N flag for ring buffer size
  • Improve help text/examples for common startup patterns (--plain, --config)

Verify

cd devtools/radar && uv run radar --help
cd devtools/radar && uv run radar watch --help

Phase perf-polish: Throughput and large-buffer behavior

Objective

Tune TUI responsiveness under high event volume and large in-memory buffers.

Precondition

Phase tui completed.

Specification

  • Profile with large log corpus (100k+ events)
  • Virtual scrolling in TUI: only render visible rows
  • Debounce filter input (200ms)
  • Lazy loading for detail panel
  • Record measured outcomes for stream/filter responsiveness

Verify

Manual verification:

  • Run with large corpus (100k+ events)
  • Verify scroll responsiveness remains usable
  • Verify filter interactions remain responsive under load
  • Record benchmark command lines used and observed timings in phase notes/output
  • Include at least one ingest/startup timing and one interactive filter timing
cd devtools/radar && uv run python -c "print('perf-polish smoke')"

Phase ssh-tmux-polish: Remote terminal and agent ergonomics

Objective

Finalize SSH/tmux usability and ensure robust agent interaction in terminal environments.

Precondition

Phase tui completed.

Specification

  • Verify all keybindings work in tmux (no conflicts with tmux prefix)
  • Test with common terminal emulators (iTerm2, Terminal.app, alacritty)
  • Ensure 256-color fallback (detect truecolor support, degrade gracefully)
  • Mouse scroll support where available (but never required)
  • Verify tmux capture-pane -t radar -p produces clean, parseable output
  • No ANSI artifacts in captured text (Textual must render cleanly for capture)
  • Pane naming works correctly (--pane-name)
  • No tmux auto-launch in v1; tmux usage remains explicit by operator choice

Verify

Manual verification:

  • Run in tmux over SSH with real log files
  • Verify agent workflow: capture-pane -> send-keys -> capture-pane
  • Verify all keybindings
cd devtools/radar && uv run radar --help
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment