Electron Stagewright docs

ADR-009: Trace artifact format and dispatch-observer seam

Status: Accepted (capture + token report + replay + token budget + offline viewer all shipped)

Context

The server can drive an app but cannot record what it did. An agent (or a human debugging one) has no portable record of which tools ran, in what order, with what inputs and outputs, or where the token budget went. The trace capability is the first product differentiator and the first genuine consumer of the plugin model (ADR-004): it must observe the WHOLE session, not just add isolated tools. Two new decisions are needed — how a plugin observes every tool call, and what the recorded artifact looks like.

Decision

1. A dispatch-observer seam on the dispatcher

The dispatcher (ADR-008) gains a set of best-effort observers. After every dispatch() resolves — success, validation failure, or unknown tool — it notifies each observer exactly once, through a single internal funnel, with a DispatchRecord:

{ tool, args, result, startedAt, finishedAt }

args is the parsed input (or the raw input when validation failed); result is the exact agent-facing envelope (so estimated_tokens is read from result._meta). Observers register via Dispatcher.addObserver(observer): () => void (returns an unsubscribe), also surfaced to tool handlers as ToolContext.addDispatchObserver so a plugin tool (e.g. trace_start) can attach a sink without the core depending on the plugin.

Observers are best-effort and must not throw: a throw is caught and logged, never propagated to the agent (same contract as the transport's console/dialog listeners). With zero observers the funnel is a single size > 0 check — no per-call overhead.

2. The trace artifact is JSONL, schema version 1

One JSON record per line. The first line is a meta header; each subsequent line is a call:

{ "v": 1, "kind": "meta", "started_at", "core_version" }
{ "kind": "call", "tool", "ok", "code"?, "started_at", "finished_at", "elapsed_ms",
  "estimated_tokens", "args", "result" }

JSONL was chosen over zstd/protobuf/OpenTelemetry: it is append-friendly, human-readable, and parses with JSON.parse per line — no dependency, no schema compiler. The explicit v lets replay, budget metadata, and viewer features evolve the shape compatibly.

3. Trace is a plugin, recording is opt-in

Per the lean-core thesis (ADR-007), trace ships as @electron-stagewright/plugin-trace, not core. It records only between trace_start and trace_stop, to an operator-chosen path, and skips its own trace_* calls. Records are buffered in memory (bounded by maxRecords) and written on stop, so the observer stays a cheap array push on the dispatch hot path; a crash before stop loses the buffer (streaming is a forthcoming improvement).

Rationale

Alternatives considered

Consequences

References

Status Update (2026-06-04) — the seam becomes bidirectional; trace_replay

The original seam made plugins OBSERVE every dispatch. Replay needs the inverse — to RE-DISPATCH recorded calls — so the seam gains an active half:

New references: packages/plugin-trace/src/replay.ts (engine); ToolContext.dispatch / validate and the REDISPATCH_DEPTH guard in dispatcher.ts.

Status Update (2026-06-04) — token budget + a pre-dispatch guard seam

The trace plugin gains a token budget, and the dispatch seam gains a third capability — a pre-dispatch VETO — alongside observe and re-dispatch:

New references: DispatchGuard / DispatchGuardCall / ToolContext.addDispatchGuard and the #guards set in dispatcher.ts; BudgetStatus / budgetStatusOf and budget tracking in packages/plugin-trace/src/recorder.ts.

Status Update (2026-06-06) — the offline viewer

The last forthcoming piece — a visual viewer — ships as trace_view. The format decision the original scope left open ("viewer format chosen before implementation") is resolved as a single self-contained HTML document: inline CSS and JS, no external assets, no CDN, no runtime server, no build step. The operator opens it by double-clicking; it works fully offline and is trivial to attach to a bug report. This was chosen over a hosted/served viewer (needs a process and a port), a framework SPA (build step + bundle), or a terminal viewer (not shareable) because a trace is a portable record — the viewer should be as portable as the artifact.

New references: packages/plugin-trace/src/viewer.ts (renderTraceHtml / escapeHtml); the trace_view tool in packages/plugin-trace/src/index.ts.