@warlock.js/ai-panoptic 5.19.0 → 5.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,221 +1,221 @@
1
- ---
2
- name: use-local-dashboard
3
- description: "The local Panoptic dashboard — zero-setup trace viewing in @warlock.js/ai-panoptic; use when you need to use local dashboard."
4
- ---
5
-
6
- # The local Panoptic dashboard — zero-setup trace viewing
7
-
8
- A loopback-only `node:http` server that serves one self-contained HTML page polling a read-only JSON API over a trace store. No external backend and no build step; it binds `127.0.0.1` with no auth by default, and gains optional bearer-token auth + a Host allowlist (`authToken` / `allowedHosts`, **required** off-loopback). The store is the live object the collector fills, so each poll reflects the latest completed traces.
9
-
10
- ## The documented path — `ai.config({ panoptic: { dashboard } })`
11
-
12
- You almost never call `dashboard(...)` directly. Set it declaratively and the side-effect import wires the store, the collector, and the server for you:
13
-
14
- ```ts
15
- import { ai } from "@warlock.js/ai";
16
- import "@warlock.js/ai-panoptic"; // side-effect: subscribes panoptic to the config seam
17
-
18
- ai.config({
19
- panoptic: {
20
- dashboard: true, // serve on http://127.0.0.1:4319/
21
- observeAll: true, // route every workflow/supervisor/team run to the collector
22
- },
23
- });
24
- ```
25
-
26
- `dashboard: true` uses the defaults. Pass an object to override:
27
-
28
- ```ts
29
- ai.config({
30
- panoptic: {
31
- exporters: [createInMemoryTraceStore({ capacity: 5000 })],
32
- dashboard: { port: 4319, open: true, title: "My App — AI traces" },
33
- observeAll: true,
34
- },
35
- });
36
- ```
37
-
38
- **Where the dashboard reads its traces from.** It needs a queryable store (a `TraceStoreContract`). Panoptic resolves one in this order:
39
-
40
- 1. The first **store-shaped exporter** in `config.exporters` — `createInMemoryTraceStore()` doubles as an `ExporterContract`, so a store passed in `exporters` is reused (captured AND served from the same object).
41
- 2. If none was supplied but `dashboard` is set, panoptic creates a fresh in-memory store and registers it as an exporter so the collector fills it.
42
-
43
- So `dashboard: true` with no exporters Just Works — you get a store for free. Supply your own when you want a capacity cap or want to `query` / `aggregate` it from app code too.
44
-
45
- **Idempotent.** Applying the config is safe on every `ai.config(...)` merge: the collector is registered as a core `Observer` exactly once and the dashboard is started at most once. Repeat calls only refresh the `observeAll` flag (latest config wins). `panoptic` undefined in the config is a no-op.
46
-
47
- ## What the UI shows
48
-
49
- The page is one self-contained, dependency-free vanilla-JS file (no bundler, no framework, works offline). It polls the JSON API every 2s and renders:
50
-
51
- - **Header** — the Warlock logo + your `title`, the `TraceAggregate` headline counts (traces / completed / failed / cancelled), tokens as **`↓input · ↑output · total`**, total cost, an **errors-only** toggle, a **search** box, and a **light / dark / system** theme switch (persisted to `localStorage`, so the choice survives a reload; `system` follows the OS `prefers-color-scheme`).
52
- - **Trace list** — newest-first clickable master rows, each led by a **colour-coded Title-case type label** (`Supervisor` / `Team` / `Workflow` / `Agent` / `Tool` / …; `ai.team` reports its own `team` type rather than `supervisor`, so it groups, filters, and labels separately). An optional **group-by-session** view collapses runs under collapsible session headers; a **group-by-prompt** view does the same over the `name@version` prompt key (see *Prompt-version linkage* below); a **group-by-type** view buckets the list by root type under collapsible `Type (N)` headers (`Agent (12)`, `Team (3)`, …). The three groupings are mutually exclusive.
53
- - **Two-pane drawer** (click a row) — a collapsible **nested call tree** on the left and the **selected node's detail** on the right: rich structured input/output (chat bubbles / key-value / Markdown), a per-node **token breakdown** (`↓in · ↑out · total`), the node's **rollup cost** (its own cost, else the sum of its children — so wrapper nodes that carry tokens but no cost don't double-count), and a **metadata panel** with humanized keys (`Supervisor Terminated By`, `Span ID` — not raw `supervisor.terminatedBy`), and **started** / **ended** wall-clock timestamps (`28 Jun 2026 03:16 PM` — named month, 12-hour, minute precision) alongside the elapsed duration. Zero-valued token/cost rows are hidden so the panel stays readable.
54
- - **Cost heatmap** — every tree node and trace row carries a left accent tinted by its rollup cost relative to the trace's most expensive node, with a small legend, so the spend hot-spots jump out at a glance.
55
- - **Timeline view** — the drawer's left pane toggles between the nested call **Tree** and a Gantt **Timeline** (each span drawn at its offset from the root start, width = duration, critical path highlighted).
56
- - **Stats panel** — a header **Stats** toggle shows a per-type aggregate table above the trace list, one row per root type (`Agent` / `Team` / `Workflow` / …) with Count, Failed (rate), p50 / p95 latency, total Tokens, and total Cost, computed over the active filtered set. Distinct from the headline counts in the header.
57
-
58
- All UI state (selected trace, selected span, collapsed nodes, theme, filters) lives in JS, **not** the DOM, so the 2s poll never disturbs an open drawer.
59
-
60
- ### Filtering & grouping (all client-side over the polled list)
61
-
62
- The list narrows entirely in the browser — no server round-trip per keystroke:
63
-
64
- - **Search** — free-text, case-insensitive, matched against root span name + session id.
65
- - **Status / type chips** — restrict to selected terminal statuses or executable types. Chip, badge, drawer, and group-header labels are **Title-cased** everywhere (`Completed`, `Workflow`, `Team`); the underlying filter keys stay lowercase (`completed`, `workflow`, `team`).
66
- - **Errors-only** — header toggle that keeps just `failed` / `cancelled` runs.
67
- - **Session chip** — exact `sessionId`; **prompt chip** — exact `name@version` key.
68
- - **Group-by-type** — buckets the list by root type under `Type (N)` headers; mutually exclusive with group-by-session and group-by-prompt.
69
-
70
- The pure matching/grouping rules are unit-tested in `trace-filter.ts` and mirrored 1:1 by the inlined dashboard JS, exported for reuse / your own list views:
71
-
72
- ```ts
73
- import {
74
- filterTraces, matchesFilter, groupBySession, rollupCost, maxNodeCost, heatIntensity,
75
- type TraceFilter, type SessionGroup,
76
- } from "@warlock.js/ai-panoptic"; // dashboard helpers, re-exported
77
-
78
- const failed = filterTraces(traces, { errorsOnly: true });
79
- const bySession = groupBySession(failed); // SessionGroup[], sessionless → "(no session)"
80
- ```
81
-
82
- `TraceFilter` is `{ text?, statuses?, types?, sessionId?, promptKey?, errorsOnly? }` — every field optional and ANDed, so `{}` matches everything. `rollupCost(span)` / `maxNodeCost(root)` / `heatIntensity(nodeCost, maxCost)` are the pure heatmap folds (intensity in `[0,1]`, `0` for a free trace rather than dividing by zero).
83
-
84
- ### Deep-links
85
-
86
- The open trace + span are reflected in the URL hash (`#trace=<id>&span=<id>`) and re-opened from it on load — so a drawer view is **shareable / bookmarkable**. (A live socket tail is a noted follow-up; this pass stays on the 2s poll.)
87
-
88
- ### Prompt-version linkage
89
-
90
- When an agent ran against a *named* `ai.prompts` builder, the collector stamps `agent.promptName` / `agent.promptVersion` onto the root span. The dashboard derives a `name@version` key from them (`tracePromptKey(trace)`) and offers **filter-by-prompt** and **group-by-prompt** beside session — so a reviewer sees every run of `support@2` together and compares its cost / failure rate against `support@3`. Runs with no named prompt bucket under `"(no prompt)"`.
91
-
92
- ```ts
93
- import { tracePromptKey, groupByPrompt, type PromptGroup } from "@warlock.js/ai-panoptic";
94
-
95
- const byPrompt = groupByPrompt(store.query()); // PromptGroup[] keyed by name@version
96
- ```
97
-
98
- ## Persistent traces — `cache` (survive a restart)
99
-
100
- By default the dashboard reads a fresh **in-memory** store, so traces vanish on restart. Point `panoptic.cache` at a `@warlock.js/cache` `CacheDriver` (Redis, file, …) and panoptic serves the dashboard from a **cache-backed** store instead — traces persist and re-hydrate on the next boot:
101
-
102
- ```ts
103
- import { ai } from "@warlock.js/ai";
104
- import "@warlock.js/ai-panoptic";
105
- import { RedisCacheDriver } from "@warlock.js/cache";
106
-
107
- ai.config({
108
- panoptic: {
109
- dashboard: true,
110
- observeAll: true,
111
- // A driver, or a (possibly async) factory resolved lazily on first use —
112
- // the factory form defers the Redis connect until the first trace lands.
113
- cache: async () => {
114
- const driver = new RedisCacheDriver();
115
- await driver.connect();
116
- return driver;
117
- },
118
- },
119
- });
120
- ```
121
-
122
- Panoptic awaits the store's `ready()` hydration **before** the server starts serving, so a restart surfaces previously-persisted traces immediately. Reads stay synchronous (served from an in-memory mirror); writes go through to the cache fire-and-forget, so a flaky cache never throws into the collector's hot path. A store-shaped exporter you pass via `exporters` still wins over `cache`. The store building block (`createCacheTraceStore`) and its durability contract live in [`query-traces/SKILL.md`](@warlock.js/ai-panoptic/query-traces/SKILL.md).
123
-
124
- ## The low-level building block — `dashboard(store, options)`
125
-
126
- Reach for this directly only when you manage the store yourself (e.g. a long-lived store you also query elsewhere, or a non-default lifecycle). It returns a `Promise<DashboardHandle>`:
127
-
128
- ```ts
129
- import { dashboard, createInMemoryTraceStore } from "@warlock.js/ai-panoptic";
130
-
131
- const store = createInMemoryTraceStore();
132
- const handle = await dashboard(store, { port: 4319, open: true });
133
-
134
- console.log(handle.url); // http://127.0.0.1:4319/
135
- console.log(handle.port); // 4319 (the REAL OS port when port: 0 was passed)
136
-
137
- // ...on teardown:
138
- await handle.close();
139
- ```
140
-
141
- The store is the same object the collector writes to — wire the collector to it (pass it as an exporter to `panoptic({ exporters: [store] })` or via `ai.config`) and the dashboard reflects new traces on its next poll.
142
-
143
- ### `DashboardOptions`
144
-
145
- Every field optional, every default safe:
146
-
147
- | Option | Default | Notes |
148
- |---|---|---|
149
- | `port` | `4319` | Pass `0` for an ephemeral port — the resolved port comes back on `handle.port`. A busy port rejects with a clear `Error` (`port N in use; pass { port: 0 }`), not raw `EADDRINUSE`. |
150
- | `host` | `"127.0.0.1"` | **Loopback only** by default, so prompt content is never exposed to the LAN. Binding a non-loopback interface is gated — it requires `authToken` (see below) or `dashboard()` rejects. |
151
- | `basePath` | `"/"` | Mount-path prefix for both the page and every `/api/...` route, e.g. `"/panoptic"`. Normalized to leading + trailing slash. |
152
- | `open` | `false` | Open the default browser at the resolved URL once listening. Best-effort and fully swallowed — failing to open a browser never rejects the start. |
153
- | `title` | `"Panoptic"` | Header title baked into the served page. |
154
- | `authToken` | _unset_ | Bearer token required on every request. Compared with `crypto.timingSafeEqual` (constant-time, length-checked first) against both the header and query-string forms — never a plain `===` — so a network-reachable dashboard doesn't leak the token to a byte-at-a-time timing attack. `Authorization: Bearer <token>` is accepted on every route; `?token=` is accepted **only on the HTML page route** (the JSON API routes are header-only), since the page load is the one request that structurally can't carry a header — this narrows the token's footprint in access/proxy logs and browser history. Missing/wrong → `401`. **Required** when `host` is non-loopback. |
155
- | `allowedHosts` | loopback names | `Host`-header allowlist (DNS-rebind guard); an unlisted `Host` → `403`. Defaults to `localhost` / `127.0.0.1` / `[::1]` for a loopback bind. |
156
-
157
- ### `DashboardHandle`
158
-
159
- ```ts
160
- type DashboardHandle = {
161
- readonly url: string; // http://host:port{basePath}
162
- readonly port: number; // real OS port (when port: 0 was requested)
163
- close(): Promise<void>; // stop the server, release the port
164
- };
165
- ```
166
-
167
- ## The JSON API
168
-
169
- Every route is **`GET`-only and read-only** (non-`GET` → `405`), mounted under `basePath` — EXCEPT the one opt-in `POST .../spans/:spanId/evaluate` route `DashboardOptions.evaluate` enables (see [`evaluate-system-prompt/SKILL.md`](@warlock.js/ai-panoptic/evaluate-system-prompt/SKILL.md)); absent that config, it 405s like everything else. The page polls the `GET` routes; you can curl them too. Store shapes are already JSON-safe, so responses are a plain `JSON.stringify`.
170
-
171
- | Route | Returns |
172
- |---|---|
173
- | `GET /api/traces` | `store.query(...)` — the trace list, filtered by the query string (`id` / `sessionId` / `status` / time window, parsed by `parseQuery`). |
174
- | `GET /api/traces/:id` | `store.get(id)` — one full trace, or `404 { error: "trace_not_found" }`. |
175
- | `GET /api/aggregate` | `store.aggregate(...)` — the usage + cost + status rollup over the same query filter. |
176
- | `GET /` (basePath) | the self-contained HTML page. |
177
- | `POST /api/traces/:traceId/spans/:spanId/evaluate` | grades the span's last captured system prompt — only when `evaluate` is configured. |
178
-
179
- Query-string filters map 1:1 onto `TraceQuery`:
180
-
181
- ```sh
182
- curl 'http://127.0.0.1:4319/api/traces?status=failed'
183
- curl 'http://127.0.0.1:4319/api/aggregate?sessionId=sess_42'
184
- ```
185
-
186
- (The exact filter keys and the aggregate shape live in [`query-traces/SKILL.md`](@warlock.js/ai-panoptic/query-traces/SKILL.md).)
187
-
188
- ## Advanced — build the handler yourself
189
-
190
- `createRequestHandler(store, config)` is the bare `(req, res) => void` the server closes over — split out from the lifecycle so it unit-tests without binding a port, and so you can mount the same routes inside an existing server:
191
-
192
- ```ts
193
- import { createRequestHandler } from "@warlock.js/ai-panoptic";
194
- import { createServer } from "node:http";
195
-
196
- const handler = createRequestHandler(store, {
197
- basePath: "/",
198
- title: "Panoptic",
199
- allowedHosts: ["127.0.0.1", "localhost"],
200
- });
201
- createServer(handler).listen(4319, "127.0.0.1");
202
- ```
203
-
204
- `dashboardHtml(basePath, title, evaluateEnabled?, evaluateDefaultInstructions?)` returns the served page as a string if you embed it elsewhere. `ServeConfig` is `{ basePath; title; allowedHosts; authToken?; evaluate? }`.
205
-
206
- ## Security note
207
-
208
- The dashboard surfaces whatever the store holds — including captured prompt/response content when content capture is on (`captureContent`). It binds **loopback-only** by default precisely so that content never leaves the machine. Binding a non-loopback host is gated — it requires an `authToken` (and checks a `Host` allowlist, sending `nosniff` / CSP / `X-Frame-Options: DENY` on every response) — but still prefer loopback for dev, and for production ship to a real backend via an exporter rather than exposing the dashboard. The dashboard has exactly one write-capable route (`evaluate`, off by default) — everything else stays read-only regardless of configuration.
209
-
210
- **Trace content is untrusted input to the dashboard's own UI, and is treated that way:**
211
- - **Markdown links in captured content are sanitized.** `span.input`/`span.output` can contain model output or attacker-controlled tool results — i.e. prompt-injected content — and the drawer renders it as Markdown. Link URLs go through `sanitizeHref` (a scheme allowlist: `http:`, `https:`, `mailto:`, plus relative/anchor URLs) after normalizing the way a browser will — attribute-entity decode, strip of ignored control chars/whitespace, lowercase — so `javascript:`/`data:`/`vbscript:` payloads (including case/whitespace/entity-obfuscated variants, and scheme-relative `//host` links) never become a live `href`; a rejected link renders its label as plain text instead. This closes a stored-XSS path that could otherwise reach the in-page bearer token.
212
- - **The bearer token is no longer a page-global.** The client script's `TOKEN` is sealed inside its own inner IIFE that exposes only the `fetchAuthed` helper, so other code in the page's closure can't read the raw token by name.
213
-
214
- These three findings (link sanitization + the two token-handling items above) came from the same audit; none is independently exploitable today, but each narrowed the blast radius of a future bug in an operator-facing surface that renders untrusted model/tool content.
215
-
216
- ## See also
217
-
218
- - [`@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md`](@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md) — the `ai.config({ panoptic })` wiring and the underlying `panoptic(...)` subscriber that fills the store.
219
- - [`@warlock.js/ai-panoptic/query-traces/SKILL.md`](@warlock.js/ai-panoptic/query-traces/SKILL.md) — the `TraceStoreContract` (`query` / `get` / `aggregate`) the dashboard reads, the `TraceQuery` filter, and capacity eviction.
220
- - [`@warlock.js/ai-panoptic/export-traces/SKILL.md`](@warlock.js/ai-panoptic/export-traces/SKILL.md) — shipping traces to OTel / Langfuse / a file instead of (or alongside) the local dashboard.
221
- - [`@warlock.js/ai-panoptic/evaluate-system-prompt/SKILL.md`](@warlock.js/ai-panoptic/evaluate-system-prompt/SKILL.md) — the drawer's "Evaluate system prompt" action, the dashboard's one write route.
1
+ ---
2
+ name: use-local-dashboard
3
+ description: 'Run the zero-setup local Panoptic dashboard — a loopback `node:http` server over a trace store served from `ai.config({ panoptic: { dashboard } })`, with a two-pane call-tree + metadata UI (light/dark/system theme, search/filter, session + prompt-version grouping, cost heatmap, Gantt timeline, errors filter, hash deep-links) and an optional cache-backed persistent store. Triggers: `dashboard`, `DashboardOptions`, `DashboardHandle`, `ai.config({ panoptic })`, `panoptic.dashboard`, `panoptic.cache`, `port: 4319`, `basePath`, `open`, `authToken`, `allowedHosts`, `/api/traces`, `/api/aggregate`, `/api/traces/:id`, `createRequestHandler`, `dashboardHtml`, `sanitizeHref`, `ServeConfig`, `TraceStoreContract`, `createCacheTraceStore`, theme, dark mode, call tree, metadata panel, cost heatmap, timeline / Gantt, errors only, deep-link, group by session, prompt version, group by type, type stats panel, started / ended timestamps, team type; ''open a local AI trace dashboard'', ''view agent traces in the browser'', ''self-hosted panoptic UI'', ''inspect traces without Langfuse'', ''spin up a dev observability server'', ''dark mode dashboard'', ''persist traces across restart'', ''group traces by session / prompt version / type'', ''cost heatmap'', ''timeline / waterfall view'', ''filter failed runs in the dashboard'', ''per-type stats panel'', ''is the dashboard bearer token safe'', ''markdown links XSS in trace content''; typical import `import { ai } from "@warlock.js/ai"` + `import "@warlock.js/ai-panoptic"`. Skip: shipping traces to a backend (OTel / Langfuse / file) — `@warlock.js/ai-panoptic/export-traces/SKILL.md`; the queryable store API the dashboard reads — `@warlock.js/ai-panoptic/query-traces/SKILL.md`; wiring the subscriber by hand — `@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md`.'
4
+ ---
5
+
6
+ # The local Panoptic dashboard — zero-setup trace viewing
7
+
8
+ A loopback-only `node:http` server that serves one self-contained HTML page polling a read-only JSON API over a trace store. No external backend and no build step; it binds `127.0.0.1` with no auth by default, and gains optional bearer-token auth + a Host allowlist (`authToken` / `allowedHosts`, **required** off-loopback). The store is the live object the collector fills, so each poll reflects the latest completed traces.
9
+
10
+ ## The documented path — `ai.config({ panoptic: { dashboard } })`
11
+
12
+ You almost never call `dashboard(...)` directly. Set it declaratively and the side-effect import wires the store, the collector, and the server for you:
13
+
14
+ ```ts
15
+ import { ai } from "@warlock.js/ai";
16
+ import "@warlock.js/ai-panoptic"; // side-effect: subscribes panoptic to the config seam
17
+
18
+ ai.config({
19
+ panoptic: {
20
+ dashboard: true, // serve on http://127.0.0.1:4319/
21
+ observeAll: true, // route every workflow/supervisor/team run to the collector
22
+ },
23
+ });
24
+ ```
25
+
26
+ `dashboard: true` uses the defaults. Pass an object to override:
27
+
28
+ ```ts
29
+ ai.config({
30
+ panoptic: {
31
+ exporters: [createInMemoryTraceStore({ capacity: 5000 })],
32
+ dashboard: { port: 4319, open: true, title: "My App — AI traces" },
33
+ observeAll: true,
34
+ },
35
+ });
36
+ ```
37
+
38
+ **Where the dashboard reads its traces from.** It needs a queryable store (a `TraceStoreContract`). Panoptic resolves one in this order:
39
+
40
+ 1. The first **store-shaped exporter** in `config.exporters` — `createInMemoryTraceStore()` doubles as an `ExporterContract`, so a store passed in `exporters` is reused (captured AND served from the same object).
41
+ 2. If none was supplied but `dashboard` is set, panoptic creates a fresh in-memory store and registers it as an exporter so the collector fills it.
42
+
43
+ So `dashboard: true` with no exporters Just Works — you get a store for free. Supply your own when you want a capacity cap or want to `query` / `aggregate` it from app code too.
44
+
45
+ **Idempotent.** Applying the config is safe on every `ai.config(...)` merge: the collector is registered as a core `Observer` exactly once and the dashboard is started at most once. Repeat calls only refresh the `observeAll` flag (latest config wins). `panoptic` undefined in the config is a no-op.
46
+
47
+ ## What the UI shows
48
+
49
+ The page is one self-contained, dependency-free vanilla-JS file (no bundler, no framework, works offline). It polls the JSON API every 2s and renders:
50
+
51
+ - **Header** — the Warlock logo + your `title`, the `TraceAggregate` headline counts (traces / completed / failed / cancelled), tokens as **`↓input · ↑output · total`**, total cost, an **errors-only** toggle, a **search** box, and a **light / dark / system** theme switch (persisted to `localStorage`, so the choice survives a reload; `system` follows the OS `prefers-color-scheme`).
52
+ - **Trace list** — newest-first clickable master rows, each led by a **colour-coded Title-case type label** (`Supervisor` / `Team` / `Workflow` / `Agent` / `Tool` / …; `ai.team` reports its own `team` type rather than `supervisor`, so it groups, filters, and labels separately). An optional **group-by-session** view collapses runs under collapsible session headers; a **group-by-prompt** view does the same over the `name@version` prompt key (see *Prompt-version linkage* below); a **group-by-type** view buckets the list by root type under collapsible `Type (N)` headers (`Agent (12)`, `Team (3)`, …). The three groupings are mutually exclusive.
53
+ - **Two-pane drawer** (click a row) — a collapsible **nested call tree** on the left and the **selected node's detail** on the right: rich structured input/output (chat bubbles / key-value / Markdown), a per-node **token breakdown** (`↓in · ↑out · total`), the node's **rollup cost** (its own cost, else the sum of its children — so wrapper nodes that carry tokens but no cost don't double-count), and a **metadata panel** with humanized keys (`Supervisor Terminated By`, `Span ID` — not raw `supervisor.terminatedBy`), and **started** / **ended** wall-clock timestamps (`28 Jun 2026 03:16 PM` — named month, 12-hour, minute precision) alongside the elapsed duration. Zero-valued token/cost rows are hidden so the panel stays readable.
54
+ - **Cost heatmap** — every tree node and trace row carries a left accent tinted by its rollup cost relative to the trace's most expensive node, with a small legend, so the spend hot-spots jump out at a glance.
55
+ - **Timeline view** — the drawer's left pane toggles between the nested call **Tree** and a Gantt **Timeline** (each span drawn at its offset from the root start, width = duration, critical path highlighted).
56
+ - **Stats panel** — a header **Stats** toggle shows a per-type aggregate table above the trace list, one row per root type (`Agent` / `Team` / `Workflow` / …) with Count, Failed (rate), p50 / p95 latency, total Tokens, and total Cost, computed over the active filtered set. Distinct from the headline counts in the header.
57
+
58
+ All UI state (selected trace, selected span, collapsed nodes, theme, filters) lives in JS, **not** the DOM, so the 2s poll never disturbs an open drawer.
59
+
60
+ ### Filtering & grouping (all client-side over the polled list)
61
+
62
+ The list narrows entirely in the browser — no server round-trip per keystroke:
63
+
64
+ - **Search** — free-text, case-insensitive, matched against root span name + session id.
65
+ - **Status / type chips** — restrict to selected terminal statuses or executable types. Chip, badge, drawer, and group-header labels are **Title-cased** everywhere (`Completed`, `Workflow`, `Team`); the underlying filter keys stay lowercase (`completed`, `workflow`, `team`).
66
+ - **Errors-only** — header toggle that keeps just `failed` / `cancelled` runs.
67
+ - **Session chip** — exact `sessionId`; **prompt chip** — exact `name@version` key.
68
+ - **Group-by-type** — buckets the list by root type under `Type (N)` headers; mutually exclusive with group-by-session and group-by-prompt.
69
+
70
+ The pure matching/grouping rules are unit-tested in `trace-filter.ts` and mirrored 1:1 by the inlined dashboard JS, exported for reuse / your own list views:
71
+
72
+ ```ts
73
+ import {
74
+ filterTraces, matchesFilter, groupBySession, rollupCost, maxNodeCost, heatIntensity,
75
+ type TraceFilter, type SessionGroup,
76
+ } from "@warlock.js/ai-panoptic"; // dashboard helpers, re-exported
77
+
78
+ const failed = filterTraces(traces, { errorsOnly: true });
79
+ const bySession = groupBySession(failed); // SessionGroup[], sessionless → "(no session)"
80
+ ```
81
+
82
+ `TraceFilter` is `{ text?, statuses?, types?, sessionId?, promptKey?, errorsOnly? }` — every field optional and ANDed, so `{}` matches everything. `rollupCost(span)` / `maxNodeCost(root)` / `heatIntensity(nodeCost, maxCost)` are the pure heatmap folds (intensity in `[0,1]`, `0` for a free trace rather than dividing by zero).
83
+
84
+ ### Deep-links
85
+
86
+ The open trace + span are reflected in the URL hash (`#trace=<id>&span=<id>`) and re-opened from it on load — so a drawer view is **shareable / bookmarkable**. (A live socket tail is a noted follow-up; this pass stays on the 2s poll.)
87
+
88
+ ### Prompt-version linkage
89
+
90
+ When an agent ran against a *named* `ai.prompts` builder, the collector stamps `agent.promptName` / `agent.promptVersion` onto the root span. The dashboard derives a `name@version` key from them (`tracePromptKey(trace)`) and offers **filter-by-prompt** and **group-by-prompt** beside session — so a reviewer sees every run of `support@2` together and compares its cost / failure rate against `support@3`. Runs with no named prompt bucket under `"(no prompt)"`.
91
+
92
+ ```ts
93
+ import { tracePromptKey, groupByPrompt, type PromptGroup } from "@warlock.js/ai-panoptic";
94
+
95
+ const byPrompt = groupByPrompt(store.query()); // PromptGroup[] keyed by name@version
96
+ ```
97
+
98
+ ## Persistent traces — `cache` (survive a restart)
99
+
100
+ By default the dashboard reads a fresh **in-memory** store, so traces vanish on restart. Point `panoptic.cache` at a `@warlock.js/cache` `CacheDriver` (Redis, file, …) and panoptic serves the dashboard from a **cache-backed** store instead — traces persist and re-hydrate on the next boot:
101
+
102
+ ```ts
103
+ import { ai } from "@warlock.js/ai";
104
+ import "@warlock.js/ai-panoptic";
105
+ import { RedisCacheDriver } from "@warlock.js/cache";
106
+
107
+ ai.config({
108
+ panoptic: {
109
+ dashboard: true,
110
+ observeAll: true,
111
+ // A driver, or a (possibly async) factory resolved lazily on first use —
112
+ // the factory form defers the Redis connect until the first trace lands.
113
+ cache: async () => {
114
+ const driver = new RedisCacheDriver();
115
+ await driver.connect();
116
+ return driver;
117
+ },
118
+ },
119
+ });
120
+ ```
121
+
122
+ Panoptic awaits the store's `ready()` hydration **before** the server starts serving, so a restart surfaces previously-persisted traces immediately. Reads stay synchronous (served from an in-memory mirror); writes go through to the cache fire-and-forget, so a flaky cache never throws into the collector's hot path. A store-shaped exporter you pass via `exporters` still wins over `cache`. The store building block (`createCacheTraceStore`) and its durability contract live in [`query-traces/SKILL.md`](@warlock.js/ai-panoptic/query-traces/SKILL.md).
123
+
124
+ ## The low-level building block — `dashboard(store, options)`
125
+
126
+ Reach for this directly only when you manage the store yourself (e.g. a long-lived store you also query elsewhere, or a non-default lifecycle). It returns a `Promise<DashboardHandle>`:
127
+
128
+ ```ts
129
+ import { dashboard, createInMemoryTraceStore } from "@warlock.js/ai-panoptic";
130
+
131
+ const store = createInMemoryTraceStore();
132
+ const handle = await dashboard(store, { port: 4319, open: true });
133
+
134
+ console.log(handle.url); // http://127.0.0.1:4319/
135
+ console.log(handle.port); // 4319 (the REAL OS port when port: 0 was passed)
136
+
137
+ // ...on teardown:
138
+ await handle.close();
139
+ ```
140
+
141
+ The store is the same object the collector writes to — wire the collector to it (pass it as an exporter to `panoptic({ exporters: [store] })` or via `ai.config`) and the dashboard reflects new traces on its next poll.
142
+
143
+ ### `DashboardOptions`
144
+
145
+ Every field optional, every default safe:
146
+
147
+ | Option | Default | Notes |
148
+ |---|---|---|
149
+ | `port` | `4319` | Pass `0` for an ephemeral port — the resolved port comes back on `handle.port`. A busy port rejects with a clear `Error` (`port N in use; pass { port: 0 }`), not raw `EADDRINUSE`. |
150
+ | `host` | `"127.0.0.1"` | **Loopback only** by default, so prompt content is never exposed to the LAN. Binding a non-loopback interface is gated — it requires `authToken` (see below) or `dashboard()` rejects. |
151
+ | `basePath` | `"/"` | Mount-path prefix for both the page and every `/api/...` route, e.g. `"/panoptic"`. Normalized to leading + trailing slash. |
152
+ | `open` | `false` | Open the default browser at the resolved URL once listening. Best-effort and fully swallowed — failing to open a browser never rejects the start. |
153
+ | `title` | `"Panoptic"` | Header title baked into the served page. |
154
+ | `authToken` | _unset_ | Bearer token required on every request. Compared with `crypto.timingSafeEqual` (constant-time, length-checked first) against both the header and query-string forms — never a plain `===` — so a network-reachable dashboard doesn't leak the token to a byte-at-a-time timing attack. `Authorization: Bearer <token>` is accepted on every route; `?token=` is accepted **only on the HTML page route** (the JSON API routes are header-only), since the page load is the one request that structurally can't carry a header — this narrows the token's footprint in access/proxy logs and browser history. Missing/wrong → `401`. **Required** when `host` is non-loopback. |
155
+ | `allowedHosts` | loopback names | `Host`-header allowlist (DNS-rebind guard); an unlisted `Host` → `403`. Defaults to `localhost` / `127.0.0.1` / `[::1]` for a loopback bind. |
156
+
157
+ ### `DashboardHandle`
158
+
159
+ ```ts
160
+ type DashboardHandle = {
161
+ readonly url: string; // http://host:port{basePath}
162
+ readonly port: number; // real OS port (when port: 0 was requested)
163
+ close(): Promise<void>; // stop the server, release the port
164
+ };
165
+ ```
166
+
167
+ ## The JSON API
168
+
169
+ Every route is **`GET`-only and read-only** (non-`GET` → `405`), mounted under `basePath` — EXCEPT the one opt-in `POST .../spans/:spanId/evaluate` route `DashboardOptions.evaluate` enables (see [`evaluate-system-prompt/SKILL.md`](@warlock.js/ai-panoptic/evaluate-system-prompt/SKILL.md)); absent that config, it 405s like everything else. The page polls the `GET` routes; you can curl them too. Store shapes are already JSON-safe, so responses are a plain `JSON.stringify`.
170
+
171
+ | Route | Returns |
172
+ |---|---|
173
+ | `GET /api/traces` | `store.query(...)` — the trace list, filtered by the query string (`id` / `sessionId` / `status` / time window, parsed by `parseQuery`). |
174
+ | `GET /api/traces/:id` | `store.get(id)` — one full trace, or `404 { error: "trace_not_found" }`. |
175
+ | `GET /api/aggregate` | `store.aggregate(...)` — the usage + cost + status rollup over the same query filter. |
176
+ | `GET /` (basePath) | the self-contained HTML page. |
177
+ | `POST /api/traces/:traceId/spans/:spanId/evaluate` | grades the span's last captured system prompt — only when `evaluate` is configured. |
178
+
179
+ Query-string filters map 1:1 onto `TraceQuery`:
180
+
181
+ ```sh
182
+ curl 'http://127.0.0.1:4319/api/traces?status=failed'
183
+ curl 'http://127.0.0.1:4319/api/aggregate?sessionId=sess_42'
184
+ ```
185
+
186
+ (The exact filter keys and the aggregate shape live in [`query-traces/SKILL.md`](@warlock.js/ai-panoptic/query-traces/SKILL.md).)
187
+
188
+ ## Advanced — build the handler yourself
189
+
190
+ `createRequestHandler(store, config)` is the bare `(req, res) => void` the server closes over — split out from the lifecycle so it unit-tests without binding a port, and so you can mount the same routes inside an existing server:
191
+
192
+ ```ts
193
+ import { createRequestHandler } from "@warlock.js/ai-panoptic";
194
+ import { createServer } from "node:http";
195
+
196
+ const handler = createRequestHandler(store, {
197
+ basePath: "/",
198
+ title: "Panoptic",
199
+ allowedHosts: ["127.0.0.1", "localhost"],
200
+ });
201
+ createServer(handler).listen(4319, "127.0.0.1");
202
+ ```
203
+
204
+ `dashboardHtml(basePath, title, evaluateEnabled?, evaluateDefaultInstructions?)` returns the served page as a string if you embed it elsewhere. `ServeConfig` is `{ basePath; title; allowedHosts; authToken?; evaluate? }`.
205
+
206
+ ## Security note
207
+
208
+ The dashboard surfaces whatever the store holds — including captured prompt/response content when content capture is on (`captureContent`). It binds **loopback-only** by default precisely so that content never leaves the machine. Binding a non-loopback host is gated — it requires an `authToken` (and checks a `Host` allowlist, sending `nosniff` / CSP / `X-Frame-Options: DENY` on every response) — but still prefer loopback for dev, and for production ship to a real backend via an exporter rather than exposing the dashboard. The dashboard has exactly one write-capable route (`evaluate`, off by default) — everything else stays read-only regardless of configuration.
209
+
210
+ **Trace content is untrusted input to the dashboard's own UI, and is treated that way:**
211
+ - **Markdown links in captured content are sanitized.** `span.input`/`span.output` can contain model output or attacker-controlled tool results — i.e. prompt-injected content — and the drawer renders it as Markdown. Link URLs go through `sanitizeHref` (a scheme allowlist: `http:`, `https:`, `mailto:`, plus relative/anchor URLs) after normalizing the way a browser will — attribute-entity decode, strip of ignored control chars/whitespace, lowercase — so `javascript:`/`data:`/`vbscript:` payloads (including case/whitespace/entity-obfuscated variants, and scheme-relative `//host` links) never become a live `href`; a rejected link renders its label as plain text instead. This closes a stored-XSS path that could otherwise reach the in-page bearer token.
212
+ - **The bearer token is no longer a page-global.** The client script's `TOKEN` is sealed inside its own inner IIFE that exposes only the `fetchAuthed` helper, so other code in the page's closure can't read the raw token by name.
213
+
214
+ These three findings (link sanitization + the two token-handling items above) came from the same audit; none is independently exploitable today, but each narrowed the blast radius of a future bug in an operator-facing surface that renders untrusted model/tool content.
215
+
216
+ ## See also
217
+
218
+ - [`@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md`](@warlock.js/ai-panoptic/observe-with-panoptic/SKILL.md) — the `ai.config({ panoptic })` wiring and the underlying `panoptic(...)` subscriber that fills the store.
219
+ - [`@warlock.js/ai-panoptic/query-traces/SKILL.md`](@warlock.js/ai-panoptic/query-traces/SKILL.md) — the `TraceStoreContract` (`query` / `get` / `aggregate`) the dashboard reads, the `TraceQuery` filter, and capacity eviction.
220
+ - [`@warlock.js/ai-panoptic/export-traces/SKILL.md`](@warlock.js/ai-panoptic/export-traces/SKILL.md) — shipping traces to OTel / Langfuse / a file instead of (or alongside) the local dashboard.
221
+ - [`@warlock.js/ai-panoptic/evaluate-system-prompt/SKILL.md`](@warlock.js/ai-panoptic/evaluate-system-prompt/SKILL.md) — the drawer's "Evaluate system prompt" action, the dashboard's one write route.