diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 1bc4fa1..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,695 +0,0 @@
-# Building yeet dashboards
-
-This is a **yeet** script: a reactive JSX TUI that runs in the daemon's V8
-isolate, fed by live kernel data (eBPF + a process/system graph). This file
-is the API contract and gotcha list for editing it. For build/run mechanics,
-layout, and the `@/`/`#/` aliases, see `README.md` — don't duplicate that here.
-
-## Mental model
-
-It reads like React but it is **signals, not a vdom**. No hooks, no
-reconciliation, no `useState`. A node re-renders exactly when a signal it
-*read* changes — and the only way to "read inside a node" is to pass a
-**thunk** (`() => …`) as a prop or child. A plain value is static forever; a
-thunk is reactive.
-
-```jsx
-{() => `load ${load.get().toFixed(2)}`} // re-renders on load change
-{`load ${load.get()}`} // snapshot, never updates
-```
-
-Three layers, composed:
-
-```
-probes/ (BPF-aware) → signals → components/ (pure UI, read signals)
- ↑
- graph queries / timers
-```
-
-`probes/` is the *only* code that touches `yeet:bpf`; it exposes plain
-signals. Components never see BPF — they read signals. `lib/` is pure helpers.
-
-## Build bottom-up: data → component → layout
-
-Build a dashboard from the inside out. Each layer is verifiable on its own, so
-mistakes surface where they're cheap — at the data, not three layers up where a
-blank panel could mean anything.
-
-### 1. Get the data right first, in isolation
-
-Before any JSX, confirm the kernel actually gives you the fields and types you
-think it does. Guard a self-test with `import.meta.main` — it's `true` **only**
-when this module is the run entry, so the block runs when you point `yeet run`
-at the module and stays dormant once `main.jsx` imports it.
-
-Verify the **raw source**, not a `from()` signal (a `from()` producer doesn't
-run until something watches it — there's no UI here):
-
-```js
-// probes/conns.js
-import { BpfObject, RingBuf } from "yeet:bpf";
-import { from } from "yeet:tui";
-
-const ctl = await new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname })
- .bind("events", { kind: "ringbuf", btf_struct: "conn_event" })
- .start();
-const events = new RingBuf(ctl, "events");
-
-export const conns = from((state) => { /* …wrap events into a signal… */ }, []);
-
-// Standalone correctness probe — dumps real records so you can eyeball field
-// names, the btf_struct envelope, and which numbers came back as BigInt.
-if (import.meta.main) {
- await events.subscribe((w) => console.log(JSON.stringify(w, (_k, v) =>
- typeof v === "bigint" ? `${v}n` : v))); // JSON.stringify chokes on BigInt
-}
-```
-
-For a graph probe the self-test is a one-shot dump:
-
-```js
-if (import.meta.main) {
- const { data } = await yeet.graph.query(QUERY);
- console.log(JSON.stringify(data, null, 2));
- yeet.exit();
-}
-```
-
-Run it directly — `yeet run src/probes/conns.js`. **Caveat:** `@/` and `#/` are
-bundle-time aliases, so a standalone module must reach its siblings by relative
-path (`./probe.js`), or be bundled as its own entry. Switching `JSON.stringify`
-to flag BigInt up front saves you the "why does math give NaN" detour — wrap
-64-bit values with `Number(...)` once you've seen them.
-
-### 2. Build each component against a fake signal
-
-A component is a pure function of signals, so prove it in isolation with a
-hand-fed signal before any real data exists. Mount just the one:
-
-```jsx
-// scratch entry while developing components/gauge.jsx
-import { mount, signal } from "yeet:tui";
-import Gauge from "@/components/gauge.jsx";
-
-const fake = signal(0.3);
-setInterval(() => fake.set(Math.random()), 700); // exercise the reactive path
-mount(() => );
-await new Promise(() => {});
-```
-
-You're checking one thing: does it repaint when the signal changes, and does it
-fit its box? Get sizing and the thunk wiring right here, with data you control,
-before it has to share the screen.
-
-### 3. Layout and routing last
-
-Only once the pieces work do you compose them. The layout is a single thunk
-that reads the size signal (reflow on resize) and a view signal (which panel is
-showing) — responsive breakpoints and "routing" are the same branch:
-
-```jsx
-const view = signal("cpu");
-tty.on("keydown", (e) => {
- if (e.key === "1") view.set("cpu");
- else if (e.key === "2") view.set("net");
-});
-
-const Root = (size) => (
-
-
-
- {() => {
- const { cols } = size.get();
- if (cols < 80) return ; // responsive
- switch (view.get()) { // routing
- case "cpu": return ;
- case "net": return ;
- }
- }}
-
-
-
-);
-```
-
-By now each panel is already known-good, so if the screen looks wrong it's the
-layout math — `1fr`/`fit`/fixed and `overflow`, nothing deeper.
-
-## Entry shape
-
-JSX is the **automatic runtime** (`jsxImportSource: yeet:tui` in tsconfig +
-esbuild) — write JSX directly, no pragma import. The entry mounts a root that
-receives the terminal's reactive size signal, then parks forever:
-
-```jsx
-import { Box, Text, mount, signal } from "yeet:tui";
-
-const Root = (size) => (
- {/* default direction is COLUMN, not row */}
-
-
- {() => renderBody(size.get())} {/* reading size.get() reflows on resize */}
-
-
-
-);
-
-mount(Root);
-await new Promise(() => {}); // keep the script alive; the TUI owns the screen
-```
-
-## Signals (state) — from `yeet:tui`
-
-```js
-import { signal, computed, from } from "yeet:tui";
-
-const n = signal(0); n.get(); n.set(v); n.update(x => x + 1);
-const doubled = computed(() => n.get() * 2);
-```
-
-`from(producer, initial)` is **the** idiom for turning a subscription or poll
-into a signal — the producer runs when the signal is first watched and its
-cleanup runs when no one watches, so the kernel work is tied to the UI:
-
-```js
-export const cpus = from((state) => {
- const sub = events.subscribe(w => { /* accumulate */ });
- const h = setInterval(() => state.set(snapshot()), 500); // publish a window
- return () => { clearInterval(h); sub.then(s => s.unsubscribe()); };
-}, initialValue);
-```
-
-**Never `.set()` during a render/computed eval** — defer with `setInterval`,
-a subscription callback, or `Promise.resolve().then(() => sig.set(…))`.
-
-## Components — `yeet:tui`
-
-- `Box(opts, ...kids)` — flow container. `direction="row"|"column"` (**column
- default**). `width/height/left/top/right/bottom`, `border`, `padding`,
- `overflow="hidden"|"visible"`, `z`, `bg`.
-- `Layer(opts, ...kids)` — z-stack; child insets are absolute in the rect.
-- `Text(opts, content)` — `break="word"|"anywhere"|"none"`,
- `overflow="hidden"|"ellipsis"|"visible"`.
-- `CellBuffer({rows, cols})` — raster surface: `.blit(x,y,str)`,
- `.tint(x,y,w,h,color)`, `.clear()` for pixel/game drawing.
-- `Effect(fn)` — invisible lifecycle leaf; `fn` runs on mount, returns teardown.
-
-**Sizing** — every dimension is a `Size`, accepted as a string or via the
-`Size` helper: `"1fr"` (flex weight), `"10"`/`Size.fixed(10)`, `"50%"`,
-`"fit"`, `"50vw"`, `Size.min/max/clamp/add/sub(...)`. **Frame the root with
-`1fr` or a fixed size or the tree collapses to 0.**
-
-**Color & faces** — a ``'s bare attributes *are* its face: `fg`, `bg`,
-and the boolean SGR flags `bold`/`dim`/`italic`/`underline`/`reverse`/`strike`.
-Colors: a hex string anywhere (`"#ff0080"`, `"#f08"`, `"#ff0080cc"`), or
-`idx(0..255)`, `rgb(0xRRGGBB)` / `rgb(r,g,b)`, `rgba(...,a)`, `DEFAULT`.
-
-```jsx
-{() => pct(frac.get())}
-```
-
-**Uniform style → bare attrs. Per-span → nest. Runtime-computed → `face()`.**
-Bare attrs face the *whole* Text, so a line with per-span colors nests ``
-runs as children — the inner face merges over the outer, so it wins:
-
-```jsx
-
- ↑
- {n}
- {name}
-
-```
-
-When the face itself is computed at runtime, `face(patch)` applies a patch
-object to content — the programmatic form behind ``, and the escape hatch
-when bare attrs can't carry a dynamic value:
-
-```jsx
-import { face } from "yeet:tui";
-{() => face({ fg: heat(frac.get()), bold: frac.get() > 0.9 })(label)}
-```
-
-(There's also a separate `style.red(s)` / `style.bold(s)` global — that's for
-raw `tty.write` line-mode tools, *not* the JSX tree.)
-
-> The named combinators `fg(c)(s)` / `bold(s)` / `dim(s)` … are **deprecated**,
-> kept only for back-compat. Reach for bare attrs, nested ``, or
-> `face(patch)` instead.
-
-## Data sources
-
-**Graph** — process/system state as GraphQL:
-
-```js
-const { data } = await yeet.graph.query(`{ procs { stat { pid comm rss_bytes } } }`);
-// streaming: import { subscribe } from "yeet:graph"
-```
-
-⚠️ **Race big queries against a timeout.** A pathological query (e.g. full
-memory maps of a huge process) can wedge the daemon for *all* runs until it's
-restarted.
-
-**BPF** — bind maps on the shared object, then read them (`yeet:bpf`):
-
-```js
-import { BpfObject, RingBuf, ArrayMap, HashMap, DataSec } from "yeet:bpf";
-
-const ctl = await new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname })
- .bind("events", { kind: "ringbuf", btf_struct: "sched_event" })
- .bind("probe.data", { kind: "data" }) // .data/.rodata/.bss section
- .bind("runq_hist", { kind: "array" })
- .start(); // probes auto-attach
-
-const events = new RingBuf(ctl, "events");
-await events.subscribe(w => {
- const e = w?.sched_event ?? w; // ⚠️ event is WRAPPED under btf_struct name
- // e.cpu, e.prev_comm, e.slice_ns, …
-});
-
-const hist = new ArrayMap(ctl, "runq_hist"); // poll: await hist.lookup(i)
-const knobs = new DataSec(ctl, "probe.data"); // write: knobs.patch({ field: … })
-```
-
-`kind` values: `ringbuf`, `hash-map`, `lru-hash-map`, `array`, `percpu-*`,
-`lpm-trie`, `bloom-filter`, `data`. In `.bind()`, **every key except `kind` is
-a top-level option** (`btf_struct`, `capacity`, …) — nesting under `opts`
-fails silently. Map methods: `lookup/update/delete/entries/lookupBatch` (hash),
-`lookup/update` (array), `read/patch` (data-sec), per-CPU lookups return an
-array per CPU.
-
-## Input — global `tty`
-
-```js
-tty.enableMouse();
-tty.on("keydown", e => { // {code, key, ctrlKey, shiftKey, altKey, repeat, preventDefault()}
- const k = (e.key ?? "").toLowerCase();
- if (e.code === "Escape" || k === "q") return yeet.exit();
- if (e.code === "ArrowDown" || k === "j") move(1);
-});
-tty.on("wheel", e => move(e.deltaY > 0 ? 3 : -3)); // {deltaX, deltaY, clientX, clientY}
-tty.on("mousedown", e => { if (e.button === 0) select(e.clientY); }); // {button, clientX, clientY}
-tty.on("resize", s => viewport.set(s)); // {rows, cols}
-```
-
-Coordinates are 0-indexed. `tty.size()` → `{rows, cols}`. `e.preventDefault()`
-suppresses the Ctrl-C kill / Ctrl-D detach defaults. `tty.frame(cb)` batches
-writes atomically. `yeet.exit()` tears the script down.
-
-## Composition patterns
-
-- **Responsive layout** — derive breakpoints from a size computed, branch in a
- thunk: `{() => columns.get() === 1 ? : }`.
-- **Sparkline / bars** — `"▁▂▃▄▅▆▇█"[Math.min(7, Math.floor(v/peak*7.99))]`.
-- **Fill / background** — a Box's `bg` prop tints its whole rect (color or
- `(x,y,w,h) => color` shader). Don't paint spaces — `wrap` trims them; if you
- must fill with *text*, use non-breaking spaces.
-- **Proportional gauge** — two `Box`es with computed `1fr` widths that sum to a
- constant, each `bg`-filled (see the worked example).
-- **Color-by-value** — `const heat = f => f < 0.6 ? GREEN : f < 0.85 ? AMBER : RED`.
-- **Table** — fixed `Text` header + `{() => rows.get().slice(0, h).map(r => {cells(r)})}`.
-- **Rate** — accumulate in a window, `setInterval(1000)` pushes to a bounded
- history array signal and resets the window.
-
-## Gotchas that bite
-
-1. **No `Intl`, no `TextDecoder`/`TextEncoder`** — `localeCompare`,
- `toLocaleString`, `Intl.*` all throw. Hand-roll formatting; decode `comm`
- byte arrays with a `String.fromCharCode` loop, stopping at the first `\0`.
-2. **64-bit map fields need `BigInt`** — `knobs.patch({ x: BigInt(n) })`;
- smaller ints take plain numbers. Ring-buffer `__u64` fields arrive as
- `BigInt` — `Number(e.slice_ns)` to use them in math.
-3. **Set-during-render throws** — defer signal writes out of the render path.
-4. **`column` is the default Box direction** (Yoga, not CSS) — set
- `direction="row"` explicitly for horizontal.
-5. **Style with bare `` attrs** (`fg`/`bg`/`bold`/…) — never
- `color`/`style`/`backgroundColor`, and not the deprecated `fg(c)(s)`/`bold(s)`
- combinators (use `face(patch)` when the style is computed at runtime).
-6. **Ring-buffer events are wrapped** under the `btf_struct` name — unwrap with
- `w?. ?? w`.
-7. **`@/` and `#/` are bundle-time only** — the runtime resolver doesn't know
- them, which is why the BPF object is located with `import.meta.dirname`.
-8. **`console.log` goes to the daemon log, not the screen** (and strips ANSI) —
- render in-pane via the JSX tree or `tty.write`.
-9. **No Node builtins** (`fs`, `net`, …) — only packages that run in bare V8
- bundle cleanly.
-10. **Don't `.set()` per high-rate event** — a busy ring buffer fires thousands
- of times a second; accumulate in a plain variable and publish a snapshot on
- a `setInterval` window (250–1000 ms). One re-render per frame, not per event.
-11. **Guard the pre-data state** — signals start at their initial value (often
- `null`/`[]`), so every render thunk runs once before data arrives. Use
- `x?.field` / `if (!data) return …` or the first frame throws.
-12. **Uncaught errors get dumped over your UI** — there's no `unhandledrejection`
- hook; the daemon renders the exception to the screen. Catch at the
- boundaries (see *Crash handling*) so a failing probe degrades to a status
- line instead of wrecking the display.
-13. **`yeet.args` is minimist-parsed** — positionals in `yeet.args._`, flags as
- named keys (`yeet run . -- --pid 42 eth0` → `{_: ["eth0"], pid: 42}`). Use
- it to parameterize a dashboard (target pid, interface, refresh rate).
-
-## Worked examples
-
-### A complete component (pure UI, reads a signal)
-
-A horizontal gauge. It takes a `frac` signal and paints a heat-colored fill
-against a rail, with a percentage on the right. A Box's `bg` prop **tints its
-whole rect** — that's how you fill (don't paint a string of spaces; `wrap`
-trims trailing ones, and filling with *text* would need non-breaking spaces).
-Two boxes whose `fr` weights sum to a constant make a proportional bar; both
-widths read `frac`, so each fill is a thunk child that re-mints its Box when
-`frac` changes — only those parts re-render.
-
-```jsx
-// components/gauge.jsx
-import { Box, Text, idx } from "yeet:tui";
-
-const RAIL = idx(238);
-const heat = (f) => (f < 0.6 ? idx(2) : f < 0.85 ? idx(3) : idx(1));
-const pct = (f) => `${Math.round(f * 100)}%`;
-const lpad = (s, n) => `${s}`.padStart(n);
-
-export default function Gauge({ frac, label }) {
- return (
-
- {label}
- {() => }
- {() => }
- {() => lpad(pct(frac.get()), 5)}
-
- );
-}
-```
-
-### A complete probe (kernel → signal, polled graph)
-
-No BPF needed — poll the system graph on a timer and expose a `cpu` fraction
-signal. `from()` ties the timer's lifecycle to the UI watching it.
-
-```js
-// probes/sysload.js
-import { computed, from } from "yeet:tui";
-
-// Whole-host CPU busy fraction, sampled once a second from the kernel graph.
-const QUERY = `{ kernel_stats { total { user nice system irq softirq idle iowait } } }`;
-const busy = (t) => t.user + t.nice + t.system + t.irq + t.softirq;
-const total = (t) => busy(t) + t.idle + t.iowait;
-
-export const cpu = from((state) => {
- let prev = null;
- const tick = async () => {
- const { data } = await yeet.graph.query(QUERY);
- const t = data.kernel_stats.total;
- if (prev) {
- const db = busy(t) - busy(prev);
- const dt = total(t) - total(prev);
- state.set(dt > 0 ? db / dt : 0); // delta between samples, not absolute
- }
- prev = t;
- };
- const h = setInterval(() => tick().catch(() => {}), 1000);
- tick().catch(() => {});
- return () => clearInterval(h);
-}, 0);
-
-export const cpuPct = computed(() => Math.round(cpu.get() * 100));
-```
-
-### Wiring it together
-
-```jsx
-// main.jsx
-import { Box, Text, mount } from "yeet:tui";
-import { cpu } from "@/probes/sysload.js";
-import Gauge from "@/components/gauge.jsx";
-
-tty.on("keydown", (e) => {
- if (e.code === "Escape" || (e.key ?? "").toLowerCase() === "q") yeet.exit();
-});
-
-const Root = () => (
-
- {" sysload — q to quit"}
-
-
-
-
-);
-
-mount(Root);
-await new Promise(() => {});
-```
-
-### Live BPF feed → scrolling list
-
-The starter's `cpusched` is the full version; this is the minimal shape — a
-ring-buffer subscription pushed into a bounded list signal that a component
-renders.
-
-```js
-// probes/conns.js
-import { BpfObject, RingBuf } from "yeet:bpf";
-import { from } from "yeet:tui";
-
-const MAX = 50;
-
-export const conns = from((state) => {
- const rows = [];
- const ctl = new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname })
- .bind("events", { kind: "ringbuf", btf_struct: "conn_event" });
- const sub = ctl.start().then((c) =>
- new RingBuf(c, "events").subscribe((w) => {
- const e = w?.conn_event ?? w; // unwrap the btf_struct envelope
- rows.unshift({ comm: e.comm, port: e.dport });
- if (rows.length > MAX) rows.pop();
- state.set(rows.slice()); // publish a fresh array → re-render
- }),
- );
- return () => sub.then((s) => s.unsubscribe());
-}, []);
-```
-
-```jsx
-// components/conns.jsx — reads the signal in a thunk, one Text per row
-import { Box, Text } from "yeet:tui";
-
-export default function Conns({ conns }) {
- return (
-
- {() => conns.get().map((r) => {`${r.comm.padEnd(16)} :${r.port}`})}
-
- );
-}
-```
-
-## More BPF patterns
-
-### Effect-scoped subscription (a "BPF effect")
-
-`from()` ties a subscription to *a signal* being watched. `Effect` ties one to
-*a subtree being mounted* — so an expensive probe runs only while its panel is
-on screen and tears down when you navigate away. The `Effect` re-runs whenever
-a signal it reads changes, so it also re-targets cleanly.
-
-```jsx
-// components/detail.jsx — subscribe only while this panel is visible
-import { Box, Effect, Text, signal } from "yeet:tui";
-import { RingBuf } from "yeet:bpf";
-import { control } from "@/probes/probe.js";
-
-export default function Detail({ pid }) {
- const lines = signal([]);
- return (
-
-
- {() => {
- const target = pid.get(); // read → re-runs when pid changes
- const rb = new RingBuf(control, "syscalls");
- const sub = rb.subscribe((w) => {
- const e = w?.syscall_event ?? w;
- if (e.pid !== target) return;
- lines.set([e.name, ...lines.get()].slice(0, 200));
- });
- return () => sub.then((s) => s.unsubscribe()); // teardown on unmount / re-run
- }}
-
- {() => lines.get().map((l) => {l})}
-
- );
-}
-```
-
-`Effect`'s teardown accepts a function or a `{ unsubscribe }`. Its reads do
-**not** become render dependencies of the surrounding tree — it has its own
-lifecycle. An `Effect` leaf is invisible and zero-sized, so drop it anywhere.
-
-### user → kernel: a live knob (DataSec.patch)
-
-JS only sees events the kernel emits — push a filter *into* the program by
-patching a global in its `.data` section. 64-bit fields want a `BigInt`.
-
-```js
-// probes/knob.js
-import { DataSec } from "yeet:bpf";
-import { signal } from "yeet:tui";
-import { control } from "@/probes/probe.js";
-
-const knobs = new DataSec(control, "probe.data");
-
-export const minSliceUs = signal(1000); // mirror the compiled default
-
-export function setMinSlice(us) {
- us = Math.max(0, us);
- minSliceUs.set(us); // UI reads this
- knobs.patch({ min_slice_ns: BigInt(us * 1000) }); // kernel re-filters live
-}
-// read the whole section back with knobs.read(), or one field: knobs.read("min_slice_ns")
-```
-
-```js
-// in main.jsx input handler
-if (k === "+") setMinSlice(minSliceUs.get() + 100);
-if (k === "-") setMinSlice(minSliceUs.get() - 100);
-```
-
-### HashMap aggregation → top-N table
-
-Poll a hash map on a timer, iterate, sort, publish. `entries()` pages
-transparently; iteration order is unstable under churn, so collect-then-act.
-
-```js
-// probes/syscount.js — map keyed by comm[16], value is a __u64 counter
-import { HashMap } from "yeet:bpf";
-import { from } from "yeet:tui";
-import { control } from "@/probes/probe.js";
-
-const counts = new HashMap(control, "counts");
-const comm = (u8) => { let s = ""; for (const b of u8) { if (!b) break; s += String.fromCharCode(b); } return s; };
-
-export const top = from((state) => {
- const h = setInterval(async () => {
- const rows = [];
- for await (const [k, v] of counts.entries()) rows.push({ comm: comm(k.comm), n: Number(v) });
- rows.sort((a, b) => b.n - a.n);
- state.set(rows.slice(0, 20));
- }, 1000);
- return () => clearInterval(h);
-}, []);
-
-// per-CPU counter map? lookup → array per CPU; sum with BigInt:
-// const total = (await pcMap.lookup(key)).reduce((a, b) => a + b, 0n);
-```
-
-### Polled histogram → log2 bar chart
-
-The other egress: the kernel aggregates into an array map, JS just reads slots.
-
-```jsx
-// components/histogram.jsx — `latency` is a signal of per-bucket counts
-import { Box, Text, idx } from "yeet:tui";
-
-const BARS = "▁▂▃▄▅▆▇█";
-const lo = (i) => (i === 0 ? 0 : 1 << (i - 1)); // log2 bucket lower bound (ns)
-
-export default function Histogram({ latency }) {
- return (
-
- {() => {
- const slots = latency.get();
- const peak = Math.max(...slots, 1);
- return slots.map((n, i) => (
-
- {`${String(lo(i)).padStart(12)}ns `}
- {BARS[Math.min(7, Math.floor((n / peak) * 7.99))].repeat(Math.ceil((n / peak) * 40))}
- {` ${n}`}
-
- ));
- }}
-
- );
-}
-```
-
-(The probe side is `runqlat.js` in the starter: `ArrayMap.lookup(i)` per slot
-on a timer, published through `from()`.)
-
-## Crash handling (a BSOD)
-
-There's no global `unhandledrejection`/`onerror` hook in JS — when something
-throws uncaught, the daemon paints the raw exception over your screen. That's
-ugly and loses the alt-screen/cursor state. Better to catch at the two
-boundaries you control and show your own crash screen.
-
-**Boundary 1 — async probe failures degrade to a status line.** A probe that
-can't load (missing BTF, no root, bad bind) should set an error signal, not
-reject into the void:
-
-```js
-export const status = signal("starting…");
-
-export const start = async () => {
- try {
- const ctl = await new BpfObject({ exe: "../bin/probe.bpf.o", base: import.meta.dirname })
- .bind("events", { kind: "ringbuf", btf_struct: "conn_event" })
- .start();
- /* … wire maps … */
- status.set("tracing");
- } catch (e) {
- status.set(`probe failed: ${e.message ?? e}`); // UI shows this, app stays up
- }
-};
-```
-
-**Boundary 2 — wrap `mount` so a setup throw shows a BSOD instead of a stack
-dump.** `mount` owns the alt screen and cursor; re-mounting a crash component
-keeps that lifecycle clean:
-
-```jsx
-// main.jsx
-import { mount } from "yeet:tui";
-import App from "@/components/app.jsx";
-import Bsod from "@/components/bsod.jsx";
-
-tty.on("keydown", (e) => {
- if (e.code === "Escape" || (e.key ?? "").toLowerCase() === "q") yeet.exit();
-});
-
-try {
- mount(App);
-} catch (e) {
- mount(() => ); // any key still quits via the handler above
-}
-await new Promise(() => {});
-```
-
-The crash screen itself is just a `bg`-filled box — no special API:
-
-```jsx
-// components/bsod.jsx
-import { Box, Text, idx } from "yeet:tui";
-
-const BLUE = idx(20);
-const WHITE = idx(15);
-
-export default function Bsod({ error }) {
- const lines = String(error?.stack ?? error?.message ?? error).split("\n");
- return (
-
- {":( your dashboard hit an error"}
- {" "}
- {lines.map((l) => {l})}
- {" "}
- {"press q to quit"}
-
- );
-}
-```
-
-Note this only catches *synchronous* setup errors. Errors thrown later inside a
-render thunk (e.g. reading a field off a `null` signal — gotcha 11) happen
-during a re-render that `try/catch` can't wrap, which is exactly why you guard
-the pre-data state at the source instead.
-
-## System info
-
-`system.numCpus`, `system.arch`, `system.os`, `system.kernel`
-(`{major, minor, patch}`), `system.endianness`. `setTimeout`/`setInterval`/
-`clearInterval`/`queueMicrotask` are available.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..df67128
--- /dev/null
+++ b/README.md
@@ -0,0 +1,351 @@
+
+# `exectop`
+
+> **`top` for the programs your build launches.** Hundreds of execs fold into a handful of rows, with anything that looks odd ranked on top.
+
+
+
+
+
+
+
+
+
+
+
+
+
+**`exectop` is a scoped process-launch monitor for Linux: it shows every program one application starts, folds the repetition into one row per kind, and ranks anything that doesn't look like ordinary work above the rest.**
+
+## Quick start
+
+```sh
+curl -fsSL https://yeet.cx | sh # install yeet, once
+yeet run gh:yeet-src/exectop -- --pid $$ # watch this shell and everything it starts
+```
+
+The classic `execsnoop` from bcc prints every exec on the machine as a flat line-per-event stream. That is the right shape for `grep`, and the wrong shape for reading: a single `npm install` with native modules emits a few hundred lines, a busy host emits thousands, and none of it is scoped to the thing you actually care about.
+
+This one asks a narrower question. You name one application, by launching it, by container, or by pid, and it follows that process tree through `fork` in the kernel. Then it collapses repetition, so a nine-file C build reads as three rows of `×9` rather than 27 lines of compiler invocation, and puts the handful of commands that don't fit the pattern on top.
+
+> [!TIP]
+> **Folding is what makes the unusual visible.** A loop that runs `echo`, `date` and `head` a few thousand times is 7,745 lines of exec log, or eight rows once repetition is grouped. At eight rows you can see the shape of what ran; at 7,745 lines a single unexpected `curl` is just one more line going past. The compression is not a convenience, it is the thing that makes an outlier legible as one.
+
+## Contents
+
+**Run it** — [Get started](#get-started) · [Choosing what to watch](#choosing-what-to-watch) · [Have an agent set it up](#have-an-agent-set-it-up) · [Reading it without a TTY](#reading-it-without-a-tty)
+**Understand it** — [A 30-second primer on exec](#a-30-second-primer-on-exec) · [Questions this tool answers](#questions-this-tool-answers) · [What you're looking at](#what-youre-looking-at) · [Navigation](#navigation) · [How it works](#how-it-works)
+**Reference** — [Requirements](#requirements) · [What it can't see](#what-it-cant-see) · [FAQ](#faq)
+**Contribute** — [Building from source](#building-from-source) · [Testing across kernels](#testing-across-kernels) · [Try it without real traffic](#try-it-without-real-traffic)
+
+## Get started
+
+```sh
+curl -fsSL https://yeet.cx | sh
+make # clang + bpftool → bin/probe.bpf.o ; esbuild → the JS bundle
+./bin/exectop -- npm ci # run npm ci and watch every process it starts
+```
+[Manual install guide](https://yeet.cx/docs/manual-installation?utm_source=github&utm_medium=readme&utm_campaign=exectop) | Linux only
+
+`bin/exectop` is a small wrapper around `yeet run`. It exists for one reason: launching a command under the probe requires spawning a process, and a yeet isolate deliberately cannot do that. The wrapper starts your command stopped, hands its pid to the script, waits for the probe to attach, then lets it go. Attach modes need no wrapper and take the plain runtime form, with flags after `--` so they reach the script rather than `yeet` itself:
+
+```sh
+yeet run . -- --container api # everything a running container starts
+yeet run . -- --pid 4242 # a process and its descendants
+yeet run . # whole host, unscoped
+```
+
+It runs until you `Ctrl-C`, reflows on resize, and needs a real terminal. Don't pipe or redirect it; see [Reading it without a TTY](#reading-it-without-a-tty) for the text path.
+
+## Choosing what to watch
+
+Three modes, and the difference between them is what they can promise, not just what they cover.
+
+| mode | invocation | what it covers |
+| --- | --- | --- |
+| Launch | `./bin/exectop -- ` | The command and every descendant, from its first instruction. Nothing is missed. |
+| Container | `yeet run . -- --container ` | Everything the container's process tree starts, scoped by cgroup when one resolves. |
+| Pid | `yeet run . -- --pid ` | A running process and everything it starts **from now on**. |
+| Host | `yeet run .` | Every exec on the machine. The bcc-shaped firehose, folded. |
+
+Launch mode is the one to reach for when you have the choice. Because the target is held stopped until the probe is attached, there is no window in which it can fork unobserved, and the status bar says `complete tree` to mean exactly that. The attach modes carry an unavoidable gap: a process that forked its children before you attached is invisible until it forks again, and the status bar says `pre-existing children not tracked` rather than implying otherwise.
+
+Container mode is the one you want during an incident. Attach to a container you suspect is doing something it shouldn't and you get one of two useful answers: a list of what it is launching, or silence, which tells you the problem is inside the application rather than in something it shells out to.
+
+## Have an agent set it up
+
+```
+Set up exectop, a yeet script that shows every process an application launches.
+
+1. git clone https://github.com/yeet-src/exectop && cd exectop
+ (or: cd into an existing clone and `git pull`)
+2. Read AGENTS.md for the runtime API and the gotcha list.
+3. Run `make`. It fetches its own clang/bpftool/esbuild; no system toolchain needed.
+4. Verify the probe works headlessly, before touching the TUI:
+ yeet run src/probes/capture.js -- $$ 15
+ Then, in another shell, run something that starts processes (`ls; git status; make`).
+ You should see a report with a fold count and a bucket breakdown.
+5. Run the real thing with a workload under it:
+ ./bin/exectop -- bash -c 'for i in 1 2 3; do /bin/echo hi >/dev/null; done'
+ Rows should appear and the count should climb.
+
+Platform trap: this is Linux-only and needs a BTF-capable kernel. On macOS use a
+Lima VM; `demo/live.sh` does the sync-and-build for you.
+
+"It compiled" is not the same as "it works". Step 4 is the one that proves the
+probe attached and events are arriving.
+```
+
+Prefer to drive it yourself? [Get started](#get-started) is the three-line version.
+
+## A 30-second primer on exec
+
+A process starts another program in two steps. `fork` makes a copy of the current process, and `exec` replaces that copy's memory with a new program. Almost everything you think of as "running a command" is a fork followed by an exec: your shell forks, the child execs `/bin/ls`, and the program you asked for is now running.
+
+That split is why this tool hooks both. `sched_process_exec` is the event you want to see, because it carries the program and its arguments. But an exec alone doesn't tell you whose process it was, and "this application" means a tree, not a pid. So `sched_process_fork` propagates membership: when a process being watched forks, its child joins the set, in the kernel, before it has a chance to exec anything. `sched_process_exit` removes it again.
+
+Two things follow from this, and both shape what the tool can tell you. The arguments come from the new program's own memory, so they are what the kernel was actually asked to run rather than what a script said it would run. And a long-running service that starts up and then serves traffic execs almost nothing: the interesting activity is concentrated in builds, installs, deploys and scripts.
+
+## Questions this tool answers
+
+**I'm about to add an npm package I don't fully trust. How do I see what its install scripts actually do?**
+Run the install under it: `./bin/exectop -- npm install`. Every process the install starts appears, and anything unusual is ranked at the top with a reason. A postinstall that fetches from the network, decodes a blob, widens permissions, or reads `~/.ssh` shows up as a labelled row rather than as something you would have to find by reading the package's scripts.
+
+**My build is slow and I can't tell what it's spending time on. How do I see what it's actually running?**
+Watch it run. Repetition is folded, so a row reading `×2420` (a real count, from a loop calling `echo` in the demo workload) tells you at a glance what is happening thousands of times. The usual finding is something running per-file that should run once, or a step running twice. The `fork→exec` column gives the median time between a process being created and the program starting, which separates a slow program from a slow *launch*.
+
+**Something's wrong with this container and the logs don't say what. How do I see if it's shelling out to something?**
+`yeet run . -- --container `. You get one of two answers, and both are useful: a list of what it launches, or silence. Silence is a real result, and the screen says so rather than sitting on a spinner: it means nothing in that container is starting processes, so the problem is inside the application.
+
+**How do I check what a process is launching on a box where I can't install anything and there's no Docker?**
+Install yeet once and `yeet run . -- --pid `. It's a terminal program over SSH; there's no agent to deploy, no sidecar, and nothing added to the process you're watching. Note the honest limit: attaching to something already running only sees what it starts from that point on. If you can launch the thing yourself, launch mode has no such gap.
+
+**My CI job passes locally and fails in the pipeline. How do I see what the pipeline actually ran?**
+Wrap the job's command in `./bin/exectop -- ` and compare the fold list between the two environments. Differences in what got launched, a different compiler, a fallback path taken, a tool that wasn't found, show up as rows that exist in one run and not the other.
+
+**Can I hand developers something that checks a dependency before it gets merged, without setting up a security platform?**
+Yes, within limits worth knowing. It's a single binary run and the output is readable in a terminal or as text, so it fits a pre-merge check on one machine. It ranks what looks unusual, and it is not a scanner or a policy engine: it has no rules to configure, no database of known-bad packages, and no way to block anything. Treat it as a look at what happened, not as a gate.
+
+**Is this a replacement for Snyk, Socket.dev, or a supply-chain security scanner?**
+No. Those analyze packages before you run them, keep a database of known-bad releases, and integrate with a pipeline to block a merge. `exectop` watches one run on one host, keeps nothing after you quit, and never blocks anything. It also only sees processes, so a package that does its damage inside Node without launching anything is invisible to it. What it gives you that a scanner doesn't is what *this* install did on *this* machine just now, including the parts nobody has catalogued yet. Use both.
+
+**When should I use this instead of bcc's `execsnoop`, `strace`, or reading the install scripts?**
+Reach for this one when the question is "what did this application launch", especially when the answer is hundreds of events and you need it grouped. Reach for bcc's `execsnoop` when you want a flat, greppable, host-wide line stream to pipe somewhere. Reach for `strace -f` when you need every syscall for one process rather than every process launch for one tree; it sees far more, at a much higher cost, and it is painful across a process tree. Reading the scripts is worth doing and answers a different question: what the author intended, rather than what ran. For CPU profiling of a single process rather than what it spawns, [`hotspot`](https://github.com/yeet-src/hotspot) is the sibling; it deliberately excludes forked children, which is exactly what this covers.
+
+## What you're looking at
+
+```
+ ● exectop ▏ scope launched pid 1111222 ▏ complete tree — target was parked until the probe attached
+ 541 execs in 52s · 8.2/s ▁▁▁▁▁█▂▁▁▁▅▄▄█▄█▄█▄▄█▄█▄▄█▄█▄▄█▄▁▁▁▁
+ mostly text plumbing (77% of execs) · 4 things that don't fit
+── doing ────────────────────────────────────────────────────────────────────
+ text plumbing ███████████████████████······· 416 76.9%
+ compiling █████························· 84 15.5%
+ other ██···························· 30 5.5%
+ moving files ······························ 8 1.5%
+ network ······························ 2 0.4%
+ shelling out ······························ 1 0.2%
+── doesn't fit ──────────────────────────────────────────────────────────────
+ ▲ curl -fsS -o ⟨2 args⟩ ran once, fetches from the network
+ ▲ base64 -d ran once, evaluates constructed input
+ ▲ ls ⟨1 arg⟩ ran once, touches credential paths
+ ▲ chmod ⟨2 args⟩ ran once, widens permissions
+── every exec, repetition folded ▸ ──────────────────────────────────────────
+ command count share fork→exec parent
+ ▸ echo ⟨1 arg⟩ ×147 ██······ 27.2% 174µs bash
+ ▸ date ⟨1 arg⟩ ×90 █······· 16.7% 166µs bash
+ ▸ as -EL -mabi=lp64 -o ⟨2 args⟩ ×27 ········ 5.2% 122µs gcc
+```
+
+The screen reads top to bottom, general to specific. The **status bar** names what you're scoped to and what that scope can promise. The **verdict** is two lines: totals with a rate sparkline, then the dominant kind of work and whether anything looks out of place. **doing** groups every exec into behavior buckets, which is the fastest way to see that a build is mostly compiling or mostly shell. **doesn't fit** ranks the unusual. **every exec, repetition folded** is the full picture, one row per kind of command.
+
+| column | meaning |
+| --- | --- |
+| `▸` / `▾` | the row can be expanded to show individual commands; `▾` means it is |
+| command | the program plus its flags, with positional paths elided as `⟨2 args⟩` |
+| count | how many times this kind of command ran, `×1` when it ran once |
+| share | that count as a proportion of every exec in the window |
+| `fork→exec` | median time between the process being created and the program starting |
+| parent | the program that started it, comma-separated when there was more than one |
+
+Row color carries the behavior bucket, so compilers, shells and network commands are distinguishable without a badge column. A command that has just appeared flashes white and fades over about 700ms, which is what makes a process that lived three milliseconds visible at all.
+
+### What gets flagged
+
+The `doesn't fit` panel ranks rare commands that also did something a build step has no business doing. Rarity alone is not enough, and that boundary is the whole design: measured against a real `npm install`, 11 of 34 distinct commands ran exactly once, so "ran once" on its own would flag a third of an ordinary build.
+
+A row appears only if it ran three times or fewer **and** matched one of: fetching from the network, piping a download into a shell, evaluating constructed input, widening permissions, changing privileges, touching credential paths, or unpacking into `/tmp`. Every reason names something observed in the arguments, never a guess about intent. A build that legitimately fetches six times stays silent, because six is that build's normal.
+
+## Navigation
+
+| key | action |
+| --- | --- |
+| `↑` `↓` or `k` `j` | move the cursor |
+| `Enter` | expand a fold to its individual commands, or jump from a finding to its row |
+| `Tab` | switch focus between the findings panel and the tree |
+| `PgUp` `PgDn` | move ten rows |
+| `g` | jump back to the top |
+| `p` | pause the feed |
+| `q` or `Esc` | quit |
+
+Expanding a row shows the last six actual command lines behind that fold, with their pids, which is where the elided paths come back. Selecting a finding with `Enter` moves the cursor to that command in the tree and expands it, so the finding and its evidence are one keystroke apart.
+
+## Reading it without a TTY
+
+A TUI is unreadable to an agent, a CI job, or an SSH session in a hurry. The data layer runs standalone and prints a plain-text report:
+
+```sh
+yeet run src/probes/capture.js -- 30
+```
+
+It seeds the same traced set, aggregates for the given number of seconds, then prints the totals, the bucket breakdown, the findings and the top folds as text before exiting. That makes it the right thing for verifying the probe works (it is step 4 of [the agent prompt](#have-an-agent-set-it-up)), for a CI check, and for piping somewhere.
+
+There is no `--json` mode. The `RingBuf.subscribe` callback in [`src/probes/exec.js`](src/probes/exec.js) sees every normalized record before aggregation, so a JSON or HTTP sink is a branch there rather than a rewrite.
+
+## How it works
+
+Three layers, dependencies pointing downward. `src/probes/` is the only BPF-aware code and exposes plain signals; `src/components/` is pure presentation and never sees BPF; `src/lib/` is pure helpers with no I/O, which is why the aggregation can be tested against recorded captures with no kernel involved.
+
+```
+src/bpf/exectop.bpf.c three sched tracepoints; the traced set and the exec stream
+src/probes/probe.js loads bin/probe.bpf.o, binds the maps, starts the tracepoints
+src/probes/exec.js seeds the root, subscribes to the ring buffer, exposes signals
+src/probes/capture.js headless: aggregate for N seconds and print a text report
+src/lib/argv.js splits the raw argv blob; normalizes a record for the model
+src/lib/model.js folding, behavior buckets, the outlier scoring
+src/lib/scope.js resolves a container name or pid to a root, via the system graph
+src/lib/format.js palette, string helpers, bars and sparklines
+src/components/*.jsx verdict, buckets, findings, tree, chrome
+bin/exectop the launcher; owns launch mode's SIGSTOP handoff
+```
+
+### The BPF side
+
+| program | hook | what it captures |
+| --- | --- | --- |
+| `on_exec` | `tracepoint/sched/sched_process_exec` | one record per exec: pid, ppid, comm, argv, depth, fork→exec time |
+| `on_fork` | `tracepoint/sched/sched_process_fork` | adds the child to the traced set, one generation deeper |
+| `on_exit` | `tracepoint/sched/sched_process_exit` | removes a task from the traced set |
+
+Four maps. `events` is a 4 MiB `RINGBUF`, sized for the fact that exec storms are bursty rather than steady. `traced` is a `HASH` of tgid to depth: userspace seeds it with one root pid and the kernel grows it at every fork, which is what makes the scope a tree rather than a pid. `fork_ts` is a `HASH` holding a fork timestamp per pid so exec can report the gap. `scratch` is a `PERCPU_ARRAY` of one element, because an event carrying a 1 KiB argv buffer is far past the 512-byte BPF stack limit.
+
+The in-kernel filter is one lookup in `traced` before anything is copied. An exec by a process outside the scope costs a hash lookup and a return, so the cost tracks the traced application rather than total activity on the host.
+
+### Reading argv without fighting the verifier
+
+The obvious way to capture arguments is to walk the userspace `argv` pointer array. The verifier hates it: a bounded loop over indexed userspace pointers, each read fallible, is the shape it is designed to reject, and bcc's version carries the complexity to prove it.
+
+There is a simpler path. The kernel already stores the arguments contiguously at `mm->arg_start..arg_end` as a NUL-separated blob, so one bounded `bpf_probe_read_user` copies the lot and JavaScript splits it. No loop, no pointer chasing. The object compiles and passes the verifier with no complaints on 6.12 arm64.
+
+
+Two envelope traps that cost real time
+
+Both fail silently, which is what made them expensive.
+
+**A `char[]` field arrives truncated at the first NUL.** For a NUL-separated argv blob that means you only ever see `argv[0]`, while the length field still reports the true byte count. Declaring the field `__u8[]` makes it arrive as a full byte array.
+
+**A map declared with scalar `__type(key, __u32)` silently drops writes from userspace.** The update returns without error and the entry never lands, because the JS map API serializes through BTF and a scalar has no struct to name. Wrapping key and value in named structs (`struct traced_key { __u32 tgid; }`) fixes it. This one presented as a fork-propagation bug and sent the investigation in the wrong direction for a while.
+
+
+
+### The JS side
+
+| module | responsibility |
+| --- | --- |
+| `probes/exec.js` | one ring-buffer subscription, coalesced into a 100ms tick so a burst of hundreds doesn't cost a render each |
+| `lib/argv.js` | splits the blob on NUL, marks truncation, normalizes to the record shape the model consumes |
+| `lib/model.js` | the fold key, the behavior buckets, the outlier scoring, the rate history |
+| `lib/scope.js` | resolves a container to its root pid and cgroup through the system graph |
+
+The kernel stays dumb on purpose. It copies bytes and maintains a set; every decision about what counts as "the same command", what kind of work it is, and whether it is unusual happens in JavaScript, where it can be changed without a verifier round trip and tested against recorded captures.
+
+The fold key is the interesting part. It is the command plus its flag names, with positional paths dropped and repeated flags deduplicated, so `cc1 -quiet a.c` and `cc1 -quiet b.c` fold together while `cc1 -O2` stays separate. Two carve-outs came from real data: subcommands count as part of the identity for tools that have them, so `git add` and `git gc` don't merge, and `sh -c` folds on the first command *inside* the script rather than the script text, because a generated Makefile emits a different script per target and folding on the text shatters one recipe into dozens of one-off rows.
+
+### Why a tracepoint, not a syscall hook
+
+Hooking `sys_enter_execve` is the obvious alternative and it is worse in two specific ways. It fires on the *attempt*, so a failed exec looks identical to a successful one, and its arguments are still userspace pointers in the calling process. `sched_process_exec` fires after the new program is installed, which means the exec succeeded and the arguments are readable from the new `mm`. It is also a stable tracepoint rather than a syscall ABI, so the same object works across architectures without a per-arch entry point.
+
+## Building from source
+
+```sh
+make # both compilers: BPF object + JS bundle
+make veristat # load every program through the verifier on this kernel
+make clean
+```
+
+`make` runs two independent toolchains. clang and bpftool compile `src/bpf/exectop.bpf.c` into the loadable object `bin/probe.bpf.o`; esbuild bundles `src/main.jsx` into `src/index.jsx` with the `yeet:*` builtins left external. Both come from a checksum-pinned toolchain fetched into a per-machine cache, so the build needs no system clang and no Node or npm. `bin/probe.bpf.o`, `src/index.jsx` and `.build/` are generated.
+
+The `@/` and `#/` aliases are bundle-time only, resolved by esbuild through the tsconfig `paths`. That is why the BPF object is located at runtime with `import.meta.dirname` rather than through an alias, and it surprises everyone once: a module run directly with `yeet run src/probes/foo.js` has to reach its siblings by relative path.
+
+## Testing across kernels
+
+A BPF program that loads on your laptop can be rejected by an older kernel's verifier, and that failure surfaces on a user's machine rather than yours.
+
+`make veristat` loads every program through the verifier on your own kernel and reports per-program complexity. [`.github/workflows/kernel-matrix.yml`](.github/workflows/kernel-matrix.yml) builds the object and boots each kernel in its matrix in a VM, failing if any verifier rejects it. Run the same matrix locally on Linux with KVM using `make veristat-matrix`.
+
+The aggregation has its own suite, which needs no kernel:
+
+```sh
+node test/heuristics.test.mjs
+```
+
+It runs the folding and the outlier scoring against five recorded captures in `test/` and asserts both halves of the claim: three benign builds produce no findings, and the adversarial ones produce the expected findings. The captures are real probe output rather than synthesized fixtures, deliberately: an earlier synthetic version of this suite passed while the heuristics were badly wrong.
+
+## Try it without real traffic
+
+```sh
+demo/live.sh # menu: build, sketchy, noisy, container, or watch a shell
+demo/record.sh # the 60-second showcase used for the GIF above
+demo/run.sh # replay recorded captures; needs only node, no Linux
+```
+
+`demo/live.sh` is the real tool on real kernel events. On macOS it syncs to a Lima VM, builds there, starts a workload and hands you the TUI. `demo/record.sh` runs the paced 60-second workload in `demo/showcase.sh` at 100×30 and is how the hero GIF is reproduced; `--cast` records an asciinema file. Everything the demos run is genuine work that stays inside `/tmp`, and the network fetches use `file://` URLs, so nothing leaves the machine.
+
+`demo/run.sh` is the fallback for a machine with no VM. It replays the checked-in captures through the same aggregation, so the folding and the findings are real even though the events are recorded.
+
+## Requirements
+
+> [!IMPORTANT]
+> - **A Linux kernel with BTF** (`CONFIG_DEBUG_INFO_BTF=y`) for CO-RE, which `bpftool` reads to generate `src/bpf/include/vmlinux.h`. Default on current Arch, Fedora, Ubuntu, and Debian. Verified on 6.1, 6.6, 6.12 and bpf-next; CO-RE means no per-kernel recompile.
+> - **The `sched_process_exec`, `_fork` and `_exit` tracepoints**, which are long-standing and present on every kernel in that range.
+> - **The yeet daemon**, which handles the privileged load. `yeet run` is not run with `sudo`.
+
+Container mode additionally needs Docker reachable from the host running the probe, since the container's root pid is resolved through the system graph.
+
+## What it can't see
+
+> [!NOTE]
+> `exectop` is observability, not enforcement. It tells you what was launched; it does not stop, delay, or modify anything. For a kernel-enforced boundary around what a process can touch, [`agent-lock`](https://github.com/yeet-src/agent-lock) is the sibling that blocks rather than reports.
+
+- **Anything that doesn't exec.** A dependency that does its damage inside Node, Python, or the JVM without launching a program is invisible here. Fetching a URL with `fetch()` looks like nothing; fetching it with `curl` is a row. This is the boundary that matters most when reasoning about what the findings panel can and cannot catch.
+- **Arguments past 1 KiB.** The kernel copies a fixed 1024-byte window of the argument blob and marks the record truncated. A very long compiler invocation is cut off; the program, its flags and the timing stay correct.
+- **Children that already existed** when you attach with `--pid` or `--container`. Membership propagates at `fork`, so a process that forked before you attached is outside the set until it forks again. Launch mode has no such gap, which is the reason to prefer it.
+- **Which files a process touched, or what it sent.** This is process launches only. For file access see [`agent-lock`](https://github.com/yeet-src/agent-lock), for HTTP see [`container-traffic`](https://github.com/yeet-src/container-traffic), for raw packets [`pktscope`](https://github.com/yeet-src/pktscope).
+- **A determined adversary.** The findings are pattern matches on observed command lines. Anything that renames a binary, builds its argument string at runtime, or avoids launching a process at all will not be flagged. It is a way to see what happened, not a control that something can be prevented from evading.
+- **Anything after you quit.** No retention, no aggregation across machines, no alerting. One host, one session.
+- **`comm` is 16 bytes.** Long process names are truncated by the kernel, not by `exectop`.
+
+## FAQ
+
+**Why is the screen empty?**
+Most likely nothing in your scope is launching processes, and that is a real answer rather than a failure. A service that has finished starting up and is serving traffic execs almost nothing. After a few seconds the verdict line says so explicitly, with how long it has been quiet. If you expected activity, check that you scoped to the right thing: `--pid` on a supervisor whose children predate the attach shows nothing until it forks again.
+
+**Why does one command appear as several rows?**
+Different flags mean different folds, by design, because `gcc -c` and `gcc -o` are different operations. Subcommands split too, so `git add` and `git commit` are separate rows. If a split looks wrong, expand the rows to see the actual command lines.
+
+**Does watching a busy host slow it down?**
+The in-kernel filter is a single hash lookup before any data is copied, so an exec outside your scope costs almost nothing and the work tracks the application you're watching rather than the host. Exec is also a comparatively rare event: a busy build is hundreds per second, where the network path this technique is usually applied to is tens of thousands.
+
+**Can I run it in CI?**
+Yes, through `yeet run src/probes/capture.js -- `, which prints a text report and exits. The TUI itself needs a real terminal and will refuse to start without one.
+
+**Why is `fork→exec` sometimes 0?**
+The timestamp is recorded at `fork`, so a process that was already alive when its exec was captured has nothing to measure from. This shows up on the first process in an attach-mode scope and resolves for everything forked afterward.
+
+## License
+
+Apache-2.0.
+
+---
+
+Built with [yeet](https://yeet.cx/docs/?utm_source=github&utm_medium=readme&utm_campaign=exectop&utm_content=footer), a JS runtime for writing eBPF programs on Linux machines. Join us on [discord](https://discord.gg/JxVseaAVAU).
diff --git a/assets/exectop.gif b/assets/exectop.gif
new file mode 100644
index 0000000..80eb548
Binary files /dev/null and b/assets/exectop.gif differ
diff --git a/bin/exectop b/bin/exectop
new file mode 100755
index 0000000..d3afd4d
--- /dev/null
+++ b/bin/exectop
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# execsnoop launcher — the `--` form.
+#
+# execsnoop -- npm install # launch and watch, tree complete from byte 0
+# execsnoop --container my-app # attach to a running container
+# execsnoop --pid 4242 # attach to a pid subtree
+#
+# Launch mode exists here, in the shell, rather than in the script: a yeet
+# isolate has no way to spawn a process (no yeet.spawn, no child_process — the
+# sandbox deliberately doesn't reach the host that way). So the wrapper starts
+# the target STOPPED, hands its pid to the script, waits for the probe to
+# attach, then lets it run. That ordering is the whole point of launch mode:
+# there is no window in which the target can fork unobserved.
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+YEET="${YEET:-yeet}"
+
+# Split our flags from the target command at `--`.
+ARGS=()
+CMD=()
+seen_sep=0
+for a in "$@"; do
+ if [[ $seen_sep == 0 && "$a" == "--" ]]; then seen_sep=1; continue; fi
+ if [[ $seen_sep == 1 ]]; then CMD+=("$a"); else ARGS+=("$a"); fi
+done
+
+if [[ ${#CMD[@]} -eq 0 ]]; then
+ # Attach mode: no target command, just pass the flags through.
+ exec sudo "$YEET" run -t "$HERE" -- "${ARGS[@]+"${ARGS[@]}"}"
+fi
+
+# Launch mode. Start the target with SIGSTOP raised before it execs, so it is
+# parked at the very beginning of its own life.
+#
+# The target's own output is redirected to a log rather than the terminal: it
+# shares the TUI's screen otherwise, and a chatty build (npm's progress spinner)
+# scribbles over the panels. The log path is printed on exit.
+LOG="${EXECSNOOP_LOG:-$(mktemp -t execsnoop-target.XXXXXX.log)}"
+setsid bash -c 'kill -STOP $$; exec "$@"' _ "${CMD[@]}" >"$LOG" 2>&1 /dev/null || true
+ kill -TERM "$TARGET" 2>/dev/null || true
+ printf '\n[execsnoop] target output: %s\n' "$LOG" >&2
+}
+trap cleanup EXIT INT TERM
+
+# Wait until it is actually stopped, so we know it hasn't run anything yet.
+for _ in $(seq 1 100); do
+ state=$(ps -o state= -p "$TARGET" 2>/dev/null | tr -d ' ' || true)
+ [[ "$state" == T* ]] && break
+ sleep 0.05
+done
+
+# Hand the parked pid to the TUI, and let the target go once the probe is up.
+( sleep 2; kill -CONT "$TARGET" 2>/dev/null || true ) &
+exec sudo "$YEET" run -t "$HERE" -- --pid "$TARGET" --launched "${ARGS[@]+"${ARGS[@]}"}"
diff --git a/demo/live.sh b/demo/live.sh
new file mode 100755
index 0000000..8423e8d
--- /dev/null
+++ b/demo/live.sh
@@ -0,0 +1,182 @@
+#!/usr/bin/env bash
+# exectop live demo — the REAL tool, real eBPF, in your Lima VM.
+#
+# Runs the actual probe against actual kernel exec events, with a workload
+# generator driving traffic. Nothing is replayed and nothing is mocked.
+#
+# demo/live.sh # menu
+# demo/live.sh build # a real npm install with native compilation
+# demo/live.sh sketchy # a postinstall that does suspicious things
+# demo/live.sh container # attach to a running container
+# demo/live.sh noisy # a busy loop, to watch folding work
+# demo/live.sh shell # your own shell, watched live
+#
+# Host is macOS and eBPF is Linux, so it works in the Lima VM. The host repo is
+# mounted read-only there, so this syncs to a writable copy and builds in the
+# VM (the playbook's rule).
+set -euo pipefail
+
+VM="${EXECTOP_VM:-yeet.debian-13}"
+REMOTE="\$HOME/exectop-demo"
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+die() { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; }
+
+# yeet:tui needs a real terminal. `limactl shell` only allocates a PTY when its
+# own stdin IS one, so bail early with a useful message rather than failing deep
+# inside the VM with "Terminal IO failed: No such device or address".
+[[ -t 0 && -t 1 ]] || die "demo/live.sh needs an interactive terminal (it renders a TUI).
+Run it directly in your terminal, not through a pipe or redirect."
+say() { printf '\033[2m%s\033[0m\n' "$*"; }
+
+command -v limactl >/dev/null || die "limactl not found — this demo needs Lima (brew install lima)"
+limactl list "$VM" --format '{{.Status}}' 2>/dev/null | grep -q Running \
+ || die "VM '$VM' is not running. Start it with: limactl start $VM"
+
+MODE="${1:-}"
+if [[ -z "$MODE" ]]; then
+ cat <