@nanobpm/agentic 0.7.0 → 0.8.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.
@@ -20,9 +20,11 @@
20
20
  import type { DemandSupplyReport } from "../demand/index.ts";
21
21
  import { type Scheduler, type SocketFactory } from "./relay-client.ts";
22
22
  import { type DocumentLike, type ElementLike } from "./render.ts";
23
- import { type TerminalSink } from "./terminal-session.ts";
23
+ import { type StructuredSink, type TerminalSink } from "./terminal-session.ts";
24
24
  /** Mounts a terminal into `host` and returns the sink relay output is written to. */
25
25
  export type CreateTerminal = (host: ElementLike) => TerminalSink;
26
+ /** Mounts a structured (ACP) view into `host` and returns the sink decoded transcript events are routed to. */
27
+ export type CreateStructured = (host: ElementLike) => StructuredSink;
26
28
  /** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
27
29
  export type TimerHandle = unknown;
28
30
  export interface CockpitEnv {
@@ -36,6 +38,14 @@ export interface CockpitEnv {
36
38
  readonly connectRelay: SocketFactory;
37
39
  /** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
38
40
  readonly createTerminal: CreateTerminal;
41
+ /**
42
+ * Mounts the structured (ACP) view widget and returns its event sink. Optional:
43
+ * when omitted the drill-in uses the built-in {@link createStructuredSink} DOM
44
+ * renderer over {@link doc}, so marker-tagged chunks are decoded and routed to
45
+ * that structured surface while raw bytes still flow to the {@link createTerminal}
46
+ * sink. Provide your own to override the built-in renderer.
47
+ */
48
+ readonly createStructured?: CreateStructured;
39
49
  /** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
40
50
  readonly schedule?: Scheduler;
41
51
  /** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must be paired with {@link clearTimer}. */
@@ -1,6 +1,7 @@
1
1
  import { isPosInt } from "../relay/index.js";
2
2
  import { RelayChannelClient } from "./relay-client.js";
3
3
  import { renderCockpit } from "./render.js";
4
+ import { createStructuredSink } from "./structured-view.js";
4
5
  import { TerminalSession } from "./terminal-session.js";
5
6
  import { cockpitView } from "./view.js";
6
7
  const DEFAULT_REFRESH_MS = 2000;
@@ -8,6 +9,8 @@ class Cockpit {
8
9
  #env;
9
10
  #matrixRegion;
10
11
  #terminalHost;
12
+ #structuredHost;
13
+ #createStructured;
11
14
  #refreshMs;
12
15
  #setTimer;
13
16
  #clearTimer;
@@ -20,6 +23,9 @@ class Cockpit {
20
23
  // The currently mounted terminal, tracked so switching streams (and dispose)
21
24
  // tears down the prior xterm instance instead of leaking it + its listeners.
22
25
  #terminal;
26
+ // The currently mounted structured view, torn down alongside #terminal so a
27
+ // stream switch / dispose never leaks the prior worker's structured widget.
28
+ #structured;
23
29
  // Bumped by every start()/stop() so an in-flight #tick() from a previous
24
30
  // start cycle can't reschedule after a stop→start race and leave two
25
31
  // overlapping poll chains running against the same cockpit.
@@ -64,6 +70,10 @@ class Cockpit {
64
70
  this.#timeouts.delete(handle);
65
71
  }
66
72
  });
73
+ // The structured (ACP) view mounter defaults to the built-in DOM renderer over
74
+ // the injected document, so a structured stream derives + renders without any
75
+ // extra wiring; a browser caller may override it (e.g. a richer widget).
76
+ this.#createStructured = env.createStructured ?? ((host) => createStructuredSink(host, env.doc));
67
77
  // Build the stable skeleton once: a volatile matrix region the poll
68
78
  // re-renders, and a PERSISTENT terminal region a refresh never touches.
69
79
  env.host.replaceChildren();
@@ -81,6 +91,14 @@ class Cockpit {
81
91
  this.#terminalHost.className = "cockpit-terminal-host";
82
92
  this.#terminalHost.setAttribute("data-terminal", "host");
83
93
  terminalPanel.appendChild(this.#terminalHost);
94
+ // A sibling PERSISTENT region for the derived structured (ACP) view. A raw
95
+ // stream keeps it in its initial/empty structured state; a structured stream
96
+ // renders here instead of dumping JSON into the byte-terminal; a mixed stream
97
+ // feeds both.
98
+ this.#structuredHost = env.doc.createElement("div");
99
+ this.#structuredHost.className = "cockpit-structured-host";
100
+ this.#structuredHost.setAttribute("data-structured", "host");
101
+ terminalPanel.appendChild(this.#structuredHost);
84
102
  shell.appendChild(this.#matrixRegion);
85
103
  shell.appendChild(terminalPanel);
86
104
  env.host.appendChild(shell);
@@ -157,11 +175,17 @@ class Cockpit {
157
175
  // still cleans it up on the next drill or on dispose().
158
176
  this.#terminal?.dispose?.();
159
177
  this.#terminal = undefined;
178
+ // The structured view is torn down in lockstep with the terminal.
179
+ this.#structured?.dispose?.();
180
+ this.#structured = undefined;
160
181
  try {
161
- // Fresh terminal for the newly selected worker.
182
+ // Fresh terminal + structured view for the newly selected worker.
162
183
  this.#terminalHost.replaceChildren();
163
184
  const sink = this.#env.createTerminal(this.#terminalHost);
164
185
  this.#terminal = sink;
186
+ this.#structuredHost.replaceChildren();
187
+ const structured = this.#createStructured(this.#structuredHost);
188
+ this.#structured = structured;
165
189
  let session;
166
190
  const client = new RelayChannelClient({
167
191
  connect: this.#env.connectRelay,
@@ -175,6 +199,7 @@ class Cockpit {
175
199
  session = new TerminalSession({
176
200
  stream,
177
201
  sink,
202
+ structured,
178
203
  send: (message) => client.sendRelay(message),
179
204
  credit: this.#env.credit,
180
205
  });
@@ -194,6 +219,8 @@ class Cockpit {
194
219
  this.#drill = undefined;
195
220
  this.#terminal?.dispose?.();
196
221
  this.#terminal = undefined;
222
+ this.#structured?.dispose?.();
223
+ this.#structured = undefined;
197
224
  }
198
225
  }
199
226
  /** Boot the cockpit against an injected environment. Call {@link CockpitHandle.start} to poll. */
@@ -21,7 +21,8 @@
21
21
  * Nothing here rides the Camunda-8 engine or its transport.
22
22
  */
23
23
  export { cockpitView, type CockpitLight, type CockpitNetworkRow, type CockpitTokenRow, type CockpitView, type SloStatus, } from "./view.ts";
24
- export { TerminalSession, type RelayInbound, type RelayOutbound, type RelaySend, type TerminalSessionOptions, type TerminalSink, } from "./terminal-session.ts";
24
+ export { TerminalSession, type RelayInbound, type RelayOutbound, type RelaySend, type StructuredSink, type TerminalSessionOptions, type TerminalSink, } from "./terminal-session.ts";
25
25
  export { RelayChannelClient, type RawSocket, type RelayChannelClientOptions, type Scheduler, type SocketFactory, } from "./relay-client.ts";
26
26
  export { renderCockpit, type CockpitDom, type DocumentLike, type ElementLike, type RenderOptions, } from "./render.ts";
27
- export { bootCockpit, type CockpitEnv, type CockpitHandle, type CreateTerminal, type TimerHandle, } from "./boot.ts";
27
+ export { createStructuredSink, renderStructured, type StructuredTerminal, } from "./structured-view.ts";
28
+ export { bootCockpit, type CockpitEnv, type CockpitHandle, type CreateStructured, type CreateTerminal, type TimerHandle, } from "./boot.ts";
@@ -24,4 +24,5 @@ export { cockpitView, } from "./view.js";
24
24
  export { TerminalSession, } from "./terminal-session.js";
25
25
  export { RelayChannelClient, } from "./relay-client.js";
26
26
  export { renderCockpit, } from "./render.js";
27
+ export { createStructuredSink, renderStructured, } from "./structured-view.js";
27
28
  export { bootCockpit, } from "./boot.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The cockpit's structured-stream renderer — S8's drill-in for an ACP stream.
3
+ *
4
+ * When a drilled worker's relay stream is a **structured** ACP stream (its chunks
5
+ * are {@link TRANSCRIPT_EVENT_MARKER}-tagged transcript-event envelopes rather than
6
+ * raw PTY bytes), the {@link TerminalSession} routes each decoded event here instead
7
+ * of the byte-terminal. This renderer does **not** re-parse or pretty-print JSON: it
8
+ * feeds the accumulated typed events straight through the ONE canonical
9
+ * {@link deriveView} fold from `@nanobpm/agentic/transcript` and renders the resulting
10
+ * {@link DerivedView} (turns → messages + tool cards) into the DOM.
11
+ *
12
+ * Like {@link renderCockpit} it builds against the structural {@link ElementLike} /
13
+ * {@link DocumentLike} subset (not lib.dom), so it renders identically embedded and
14
+ * standalone, is unit-tested on Node with the in-memory fake and no `as` cast, and is
15
+ * browser-safe — it relies only on the browser-safe transcript vocab (no `Buffer`).
16
+ *
17
+ * Events arrive offset-keyed and immutable in offset order, so re-deriving the whole
18
+ * (idempotent) log on each event is correct across a resume-from-offset reconnect: a
19
+ * replayed chunk below the resume point never reaches this sink, so no event is
20
+ * dropped or double-applied.
21
+ */
22
+ import { type DerivedView } from "../transcript/index.ts";
23
+ import type { DocumentLike, ElementLike } from "./render.ts";
24
+ import type { StructuredSink } from "./terminal-session.ts";
25
+ /**
26
+ * Render a derived structured view into `host`, replacing whatever was there.
27
+ * Idempotent: re-call it with the latest {@link DerivedView} on every new event.
28
+ */
29
+ export declare function renderStructured(host: ElementLike, doc: DocumentLike, view: DerivedView): void;
30
+ /** A structured sink with a `dispose` teardown (mirrors {@link TerminalSink}). */
31
+ export interface StructuredTerminal extends StructuredSink {
32
+ dispose(): void;
33
+ }
34
+ /**
35
+ * Build a {@link StructuredSink} that accumulates the offset-ordered transcript
36
+ * events a structured stream delivers, folds them through the canonical
37
+ * {@link deriveView}, and renders the derived view into `host` on each event. The
38
+ * accumulated log is this sink's own state, so constructing one per drill-in gives
39
+ * each worker its own structured view.
40
+ */
41
+ export declare function createStructuredSink(host: ElementLike, doc: DocumentLike): StructuredTerminal;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The cockpit's structured-stream renderer — S8's drill-in for an ACP stream.
3
+ *
4
+ * When a drilled worker's relay stream is a **structured** ACP stream (its chunks
5
+ * are {@link TRANSCRIPT_EVENT_MARKER}-tagged transcript-event envelopes rather than
6
+ * raw PTY bytes), the {@link TerminalSession} routes each decoded event here instead
7
+ * of the byte-terminal. This renderer does **not** re-parse or pretty-print JSON: it
8
+ * feeds the accumulated typed events straight through the ONE canonical
9
+ * {@link deriveView} fold from `@nanobpm/agentic/transcript` and renders the resulting
10
+ * {@link DerivedView} (turns → messages + tool cards) into the DOM.
11
+ *
12
+ * Like {@link renderCockpit} it builds against the structural {@link ElementLike} /
13
+ * {@link DocumentLike} subset (not lib.dom), so it renders identically embedded and
14
+ * standalone, is unit-tested on Node with the in-memory fake and no `as` cast, and is
15
+ * browser-safe — it relies only on the browser-safe transcript vocab (no `Buffer`).
16
+ *
17
+ * Events arrive offset-keyed and immutable in offset order, so re-deriving the whole
18
+ * (idempotent) log on each event is correct across a resume-from-offset reconnect: a
19
+ * replayed chunk below the resume point never reaches this sink, so no event is
20
+ * dropped or double-applied.
21
+ */
22
+ import { deriveView } from "../transcript/index.js";
23
+ function el(doc, tag, className, text) {
24
+ const node = doc.createElement(tag);
25
+ if (className !== undefined)
26
+ node.className = className;
27
+ if (text !== undefined)
28
+ node.textContent = text;
29
+ return node;
30
+ }
31
+ function toolCard(doc, tool) {
32
+ const card = el(doc, "div", "cockpit-structured-tool");
33
+ card.setAttribute("data-tool", tool.name);
34
+ card.setAttribute("data-offset", String(tool.offset));
35
+ card.setAttribute("data-state", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
36
+ const head = el(doc, "div", "cockpit-structured-tool-head");
37
+ head.appendChild(el(doc, "span", "cockpit-structured-tool-name", tool.name));
38
+ if (tool.callId !== undefined)
39
+ head.appendChild(el(doc, "span", "cockpit-structured-tool-id", tool.callId));
40
+ card.appendChild(head);
41
+ if (tool.args !== undefined) {
42
+ card.appendChild(el(doc, "pre", "cockpit-structured-tool-args", JSON.stringify(tool.args)));
43
+ }
44
+ if (tool.result !== undefined) {
45
+ const result = el(doc, "div", "cockpit-structured-tool-result");
46
+ result.setAttribute("data-ok", tool.result.ok ? "true" : "false");
47
+ if (tool.result.content !== undefined)
48
+ result.textContent = tool.result.content;
49
+ card.appendChild(result);
50
+ }
51
+ return card;
52
+ }
53
+ function turnSection(doc, turn) {
54
+ const section = el(doc, "section", "cockpit-structured-turn");
55
+ section.setAttribute("data-turn", String(turn.index));
56
+ section.setAttribute("data-steps", String(turn.steps));
57
+ for (const message of turn.messages) {
58
+ const row = el(doc, "div", "cockpit-structured-message");
59
+ row.setAttribute("data-role", message.role);
60
+ row.setAttribute("data-offset", String(message.offset));
61
+ row.appendChild(el(doc, "span", "cockpit-structured-role", message.role));
62
+ row.appendChild(el(doc, "span", "cockpit-structured-text", message.text));
63
+ section.appendChild(row);
64
+ }
65
+ for (const tool of turn.tools) {
66
+ section.appendChild(toolCard(doc, tool));
67
+ }
68
+ return section;
69
+ }
70
+ /**
71
+ * Render a derived structured view into `host`, replacing whatever was there.
72
+ * Idempotent: re-call it with the latest {@link DerivedView} on every new event.
73
+ */
74
+ export function renderStructured(host, doc, view) {
75
+ host.replaceChildren();
76
+ const root = el(doc, "div", "cockpit-structured");
77
+ root.setAttribute("data-lifecycle", view.lifecycle);
78
+ root.setAttribute("data-events", String(view.eventCount));
79
+ for (const turn of view.turns) {
80
+ root.appendChild(turnSection(doc, turn));
81
+ }
82
+ host.appendChild(root);
83
+ }
84
+ /**
85
+ * Build a {@link StructuredSink} that accumulates the offset-ordered transcript
86
+ * events a structured stream delivers, folds them through the canonical
87
+ * {@link deriveView}, and renders the derived view into `host` on each event. The
88
+ * accumulated log is this sink's own state, so constructing one per drill-in gives
89
+ * each worker its own structured view.
90
+ */
91
+ export function createStructuredSink(host, doc) {
92
+ const events = [];
93
+ // Render an empty derived view up-front so the structured region is present and
94
+ // consistent before the first event lands.
95
+ renderStructured(host, doc, deriveView(events));
96
+ return {
97
+ event(event) {
98
+ events.push(event);
99
+ renderStructured(host, doc, deriveView(events));
100
+ },
101
+ dispose() {
102
+ events.length = 0;
103
+ host.replaceChildren();
104
+ },
105
+ };
106
+ }
@@ -19,8 +19,24 @@
19
19
  * - inbound `{ op: "subscribed", stream, gap, nextOffset }` — the resume ack
20
20
  * (`gap: boolean` — the S5 wire flags whether chunks aged out),
21
21
  * - inbound {@link RelayPayload} `{ stream, offset, chunk }` — a data chunk.
22
+ *
23
+ * ## Structured (ACP) vs. raw streams
24
+ *
25
+ * A relay stream is either a **raw** byte stream (PTY output — arbitrary bytes) or
26
+ * a **structured** ACP stream whose chunks are {@link TRANSCRIPT_EVENT_MARKER}-tagged
27
+ * JSON envelopes (the transcript-event vocabulary) riding the *same*
28
+ * `{ stream, offset, chunk }` frames. The session classifies **each chunk** through
29
+ * the one canonical {@link parseTranscriptEvent} — detection is on the marker tag,
30
+ * never a guess — and routes a decoded structured event to the {@link StructuredSink}
31
+ * (the derived structured renderer) while writing a raw chunk verbatim to the byte
32
+ * {@link TerminalSink}. A mixed stream that starts raw and only later carries tagged
33
+ * chunks is handled per-chunk, so each chunk lands on the right surface. Routing does
34
+ * not touch the resume machinery: `nextOffset` advances identically whichever surface
35
+ * a chunk is applied to, so resume-from-offset neither loses nor double-applies
36
+ * structured events across a reconnect exactly as for raw output.
22
37
  */
23
38
  import type { RelayPayload } from "../protocol/index.ts";
39
+ import { type TranscriptEvent } from "../transcript/index.ts";
24
40
  /** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
25
41
  export interface TerminalSink {
26
42
  /** Append a chunk of terminal output. */
@@ -28,6 +44,19 @@ export interface TerminalSink {
28
44
  /** Tear down the underlying terminal widget and its listeners, if any. */
29
45
  dispose?(): void;
30
46
  }
47
+ /**
48
+ * The structured sink a session routes decoded transcript events to when the stream
49
+ * is a structured ACP stream (marker-tagged chunks). It receives the offset-keyed,
50
+ * immutable {@link TranscriptEvent} the one canonical {@link parseTranscriptEvent}
51
+ * derived from the chunk — never raw JSON — so the derived structured renderer folds
52
+ * over typed events rather than pretty-printing bytes.
53
+ */
54
+ export interface StructuredSink {
55
+ /** Apply one decoded structured transcript event (in offset order). */
56
+ event(event: TranscriptEvent): void;
57
+ /** Tear down the underlying structured widget and its listeners, if any. */
58
+ dispose?(): void;
59
+ }
31
60
  /** An outbound relay message the session asks its transport to send. */
32
61
  export type RelayOutbound = {
33
62
  readonly op: "subscribe";
@@ -50,8 +79,15 @@ export type RelaySend = (message: RelayOutbound) => void;
50
79
  export interface TerminalSessionOptions {
51
80
  /** The relay stream id (one worker's terminal). */
52
81
  readonly stream: string;
53
- /** Where decoded output is written. */
82
+ /** Where decoded raw output is written. */
54
83
  readonly sink: TerminalSink;
84
+ /**
85
+ * Where decoded structured transcript events are routed when the stream is a
86
+ * structured ACP stream (marker-tagged chunks). Omit for a pure byte-terminal:
87
+ * without it every chunk — even a marker-tagged one — is written verbatim to
88
+ * {@link sink}, preserving the legacy raw-only behaviour.
89
+ */
90
+ readonly structured?: StructuredSink;
55
91
  /** Emits outbound relay messages. */
56
92
  readonly send: RelaySend;
57
93
  /** Bulk credit requested on each (re)subscribe. Default 1024. */
@@ -1,4 +1,5 @@
1
1
  import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.js";
2
+ import { parseTranscriptEvent } from "../transcript/index.js";
2
3
  const DEFAULT_CREDIT = 1024;
3
4
  function isRelayData(message) {
4
5
  return !("op" in message);
@@ -11,6 +12,7 @@ function isRelayData(message) {
11
12
  export class TerminalSession {
12
13
  #stream;
13
14
  #sink;
15
+ #structured;
14
16
  #send;
15
17
  #credit;
16
18
  #onGap;
@@ -21,6 +23,7 @@ export class TerminalSession {
21
23
  constructor(options) {
22
24
  this.#stream = options.stream;
23
25
  this.#sink = options.sink;
26
+ this.#structured = options.structured;
24
27
  this.#send = options.send;
25
28
  this.#credit = options.credit ?? DEFAULT_CREDIT;
26
29
  if (!isPosInt(this.#credit)) {
@@ -84,18 +87,39 @@ export class TerminalSession {
84
87
  // re-deliver the boundary chunk; anything we have already applied is dropped.
85
88
  if (data.offset < this.#nextOffset)
86
89
  return;
87
- // Compute the next resume point BEFORE writing so the apply is atomic: a
90
+ // Compute the next resume point BEFORE applying so the apply is atomic: a
88
91
  // chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
89
- // before we touch the sink — otherwise the chunk is written but #nextOffset
92
+ // before we touch either sink — otherwise the chunk is applied but #nextOffset
90
93
  // is not advanced, leaving a partially-applied state that re-delivers (and
91
94
  // so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
92
95
  // fast rather than overflowing into an unsafe nextOffset — that value would
93
96
  // later be echoed in subscribe.from and lose precision on any JSON
94
97
  // round-trip, silently corrupting resume semantics.
95
98
  const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
96
- this.#sink.write(data.chunk);
99
+ this.#apply(data);
97
100
  this.#nextOffset = nextOffset;
98
101
  }
102
+ /**
103
+ * Route one already-de-duplicated data chunk to the right surface. Classify it
104
+ * through the ONE canonical {@link parseTranscriptEvent}: a marker-tagged, known
105
+ * envelope decodes to a typed event and (when a {@link StructuredSink} is wired)
106
+ * is routed there — the derived structured renderer — instead of dumping raw JSON
107
+ * into the byte-terminal; anything else (raw bytes, non-JSON, an untagged or
108
+ * malformed envelope) falls back to `stream-chunk` and is written verbatim to the
109
+ * byte {@link TerminalSink}, so a legacy/raw or mixed stream renders exactly as
110
+ * before. With no structured sink the parse is skipped entirely, keeping the
111
+ * raw-only path byte-identical to a pure byte-terminal.
112
+ */
113
+ #apply(data) {
114
+ if (this.#structured !== undefined) {
115
+ const event = parseTranscriptEvent({ offset: data.offset, chunk: data.chunk });
116
+ if (event.kind !== "stream-chunk") {
117
+ this.#structured.event(event);
118
+ return;
119
+ }
120
+ }
121
+ this.#sink.write(data.chunk);
122
+ }
99
123
  #onSubscribed(gap, nextOffset) {
100
124
  // Validate the ack's resume point exactly as the constructor validates
101
125
  // `from`: `nextOffset` is echoed back into subscribe.from on the next
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/agentic",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The Nano agentic protocol (ADR 0056): one app-tier channel carrying agent presence/registry, demand×supply, a shared blackboard and live terminal relay — with the wire contract, channel/hub, family modules and the operator cockpit, as subpath exports.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
 
4
4
  import { decodeFrame, encodeFrame, type Frame } from "../protocol/index.ts";
5
5
  import type { DemandSupplyReport } from "../demand/index.ts";
6
+ import { type TranscriptEvent, encodeTranscriptEvent } from "../transcript/index.ts";
6
7
 
7
8
  import { bootCockpit, type CockpitEnv } from "./boot.ts";
8
9
  import { FakeDocument, FakeElement } from "./fake-dom.ts";
@@ -64,8 +65,11 @@ interface Rig {
64
65
  readonly host: FakeElement;
65
66
  readonly sockets: FakeSocket[];
66
67
  readonly terminalWrites: string[];
68
+ readonly structuredEvents: TranscriptEvent[];
67
69
  terminalMounts: number;
68
70
  terminalDisposes: number;
71
+ structuredMounts: number;
72
+ structuredDisposes: number;
69
73
  readonly timers: Array<{ run: () => void; ms: number }>;
70
74
  reconnect: (() => void) | undefined;
71
75
  report: DemandSupplyReport;
@@ -76,13 +80,17 @@ function rig(): Rig {
76
80
  const host = new FakeElement("body");
77
81
  const sockets: FakeSocket[] = [];
78
82
  const terminalWrites: string[] = [];
83
+ const structuredEvents: TranscriptEvent[] = [];
79
84
  const timers: Array<{ run: () => void; ms: number }> = [];
80
85
  const state: Rig = {
81
86
  host,
82
87
  sockets,
83
88
  terminalWrites,
89
+ structuredEvents,
84
90
  terminalMounts: 0,
85
91
  terminalDisposes: 0,
92
+ structuredMounts: 0,
93
+ structuredDisposes: 0,
86
94
  timers,
87
95
  reconnect: undefined,
88
96
  report: served,
@@ -106,6 +114,16 @@ function rig(): Rig {
106
114
  },
107
115
  };
108
116
  },
117
+ createStructured: (structuredHost) => {
118
+ state.structuredMounts += 1;
119
+ structuredHost.appendChild(new FakeElement("div"));
120
+ return {
121
+ event: (event) => structuredEvents.push(event),
122
+ dispose: () => {
123
+ state.structuredDisposes += 1;
124
+ },
125
+ };
126
+ },
109
127
  schedule: (run) => {
110
128
  state.reconnect = run;
111
129
  },
@@ -172,6 +190,102 @@ test("relay output is written to the drilled worker's terminal", async () => {
172
190
  r.sockets[0]?.fireOpen();
173
191
  r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "boot\n" } });
174
192
  assert.deepEqual(r.terminalWrites, ["boot\n"]);
193
+ assert.deepEqual(r.structuredEvents, [], "a raw stream routes nothing to the structured view");
194
+ });
195
+
196
+ function structuredFrame(seq: number, offset: number, event: TranscriptEvent): Frame {
197
+ return { lane: "bulk", family: "relay", seq, payload: { stream: "ci-a", offset, chunk: encodeTranscriptEvent(event) } };
198
+ }
199
+
200
+ test("a structured (marker-tagged) stream routes to the structured view, NOT the byte-terminal", async () => {
201
+ const r = rig();
202
+ const cockpit = bootCockpit(r.env);
203
+ await cockpit.refresh();
204
+ cockpit.drill("ci-a");
205
+ r.sockets[0]?.fireOpen();
206
+ r.sockets[0]?.deliver(structuredFrame(0, 0, { kind: "turn", offset: 0, index: 0 }));
207
+ r.sockets[0]?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "hi" }));
208
+ assert.deepEqual(r.terminalWrites, [], "structured chunks are not dumped into the byte-terminal");
209
+ assert.deepEqual(
210
+ r.structuredEvents.map((e) => e.kind),
211
+ ["turn", "message"],
212
+ "the decoded transcript events reach the structured view",
213
+ );
214
+ });
215
+
216
+ test("a mixed stream routes each chunk to the right surface (raw → terminal, tagged → structured)", async () => {
217
+ const r = rig();
218
+ const cockpit = bootCockpit(r.env);
219
+ await cockpit.refresh();
220
+ cockpit.drill("ci-a");
221
+ r.sockets[0]?.fireOpen();
222
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 0, payload: { stream: "ci-a", offset: 0, chunk: "booting\n" } });
223
+ r.sockets[0]?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "ready" }));
224
+ r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 2, payload: { stream: "ci-a", offset: 2, chunk: "tail\n" } });
225
+ assert.deepEqual(r.terminalWrites, ["booting\n", "tail\n"]);
226
+ assert.deepEqual(
227
+ r.structuredEvents.map((e) => (e.kind === "message" ? e.text : e.kind)),
228
+ ["ready"],
229
+ );
230
+ });
231
+
232
+ test("the structured view survives a cockpit reconnect — resume-from-offset, no loss, no dup", async () => {
233
+ const r = rig();
234
+ const cockpit = bootCockpit(r.env);
235
+ await cockpit.refresh();
236
+ cockpit.drill("ci-a");
237
+
238
+ const s1 = r.sockets[0];
239
+ s1?.fireOpen();
240
+ s1?.deliver(structuredFrame(0, 0, { kind: "message", offset: 0, role: "assistant", text: "a" }));
241
+ s1?.deliver(structuredFrame(1, 1, { kind: "message", offset: 1, role: "assistant", text: "b" }));
242
+
243
+ // The cockpit's socket drops; the client reconnects.
244
+ s1?.fireClose();
245
+ assert.ok(r.reconnect !== undefined, "a reconnect was scheduled");
246
+ r.reconnect?.();
247
+ const s2 = r.sockets[1];
248
+ s2?.fireOpen(); // re-attach → resume from offset 2
249
+ const subs = s2?.subscribeFrames() ?? [];
250
+ assert.deepEqual(subs.at(-1)?.payload, { op: "subscribe", stream: "ci-a", from: 2, credit: 1024 });
251
+
252
+ // The hub replays the retained tail (re-sends offset 1) then continues.
253
+ s2?.deliver(structuredFrame(0, 1, { kind: "message", offset: 1, role: "assistant", text: "b" }));
254
+ s2?.deliver(structuredFrame(1, 2, { kind: "message", offset: 2, role: "assistant", text: "c" }));
255
+ assert.deepEqual(
256
+ r.structuredEvents.map((e) => (e.kind === "message" ? e.text : e.kind)),
257
+ ["a", "b", "c"],
258
+ "no dropped and no duplicated structured events across the reconnect",
259
+ );
260
+ });
261
+
262
+ test("the built-in structured renderer derives into the structured host when none is injected", async () => {
263
+ const r = rig();
264
+ // Drop the custom createStructured so the default DOM renderer is exercised end-to-end.
265
+ const { createStructured: _drop, ...envWithoutStructured } = r.env;
266
+ const cockpit = bootCockpit(envWithoutStructured);
267
+ await cockpit.refresh();
268
+ cockpit.drill("ci-a");
269
+ r.sockets[0]?.fireOpen();
270
+ r.sockets[0]?.deliver(structuredFrame(0, 0, { kind: "message", offset: 0, role: "assistant", text: "hello" }));
271
+ assert.deepEqual(r.terminalWrites, [], "structured chunk is not dumped as raw");
272
+ const rendered = r.host.byClass("cockpit-structured-message");
273
+ assert.equal(rendered.length, 1, "the built-in structured renderer rendered the derived message");
274
+ assert.match(rendered[0]?.text() ?? "", /hello/);
275
+ });
276
+
277
+ test("switching streams disposes the prior structured view", async () => {
278
+ const r = rig();
279
+ const cockpit = bootCockpit(r.env);
280
+ await cockpit.refresh();
281
+ cockpit.drill("ci-a");
282
+ assert.equal(r.structuredMounts, 1);
283
+ assert.equal(r.structuredDisposes, 0);
284
+ cockpit.drill("ci-b");
285
+ assert.equal(r.structuredMounts, 2);
286
+ assert.equal(r.structuredDisposes, 1, "prior structured view disposed on stream switch");
287
+ cockpit.dispose();
288
+ assert.equal(r.structuredDisposes, 2, "the live structured view is disposed on dispose()");
175
289
  });
176
290
 
177
291
  test("the terminal survives a matrix refresh — it is not re-mounted and keeps streaming", async () => {
@@ -21,12 +21,16 @@ import type { DemandSupplyReport } from "../demand/index.ts";
21
21
  import { isPosInt } from "../relay/index.ts";
22
22
  import { RelayChannelClient, type Scheduler, type SocketFactory } from "./relay-client.ts";
23
23
  import { type DocumentLike, type ElementLike, renderCockpit } from "./render.ts";
24
- import { TerminalSession, type TerminalSink } from "./terminal-session.ts";
24
+ import { createStructuredSink } from "./structured-view.ts";
25
+ import { type StructuredSink, TerminalSession, type TerminalSink } from "./terminal-session.ts";
25
26
  import { cockpitView } from "./view.ts";
26
27
 
27
28
  /** Mounts a terminal into `host` and returns the sink relay output is written to. */
28
29
  export type CreateTerminal = (host: ElementLike) => TerminalSink;
29
30
 
31
+ /** Mounts a structured (ACP) view into `host` and returns the sink decoded transcript events are routed to. */
32
+ export type CreateStructured = (host: ElementLike) => StructuredSink;
33
+
30
34
  /** An opaque poll-timer handle (a Node `Timeout` or a browser timer id). */
31
35
  export type TimerHandle = unknown;
32
36
 
@@ -41,6 +45,14 @@ export interface CockpitEnv {
41
45
  readonly connectRelay: SocketFactory;
42
46
  /** Mounts the terminal widget (xterm.js in the browser) and returns its write sink. */
43
47
  readonly createTerminal: CreateTerminal;
48
+ /**
49
+ * Mounts the structured (ACP) view widget and returns its event sink. Optional:
50
+ * when omitted the drill-in uses the built-in {@link createStructuredSink} DOM
51
+ * renderer over {@link doc}, so marker-tagged chunks are decoded and routed to
52
+ * that structured surface while raw bytes still flow to the {@link createTerminal}
53
+ * sink. Provide your own to override the built-in renderer.
54
+ */
55
+ readonly createStructured?: CreateStructured;
44
56
  /** Reconnect scheduler for the relay client. Default `setTimeout(run, 0)`. */
45
57
  readonly schedule?: Scheduler;
46
58
  /** Poll scheduler. Default `setTimeout`. Injected so tests drive it by hand. Must be paired with {@link clearTimer}. */
@@ -82,6 +94,8 @@ class Cockpit implements CockpitHandle {
82
94
  readonly #env: CockpitEnv;
83
95
  readonly #matrixRegion: ElementLike;
84
96
  readonly #terminalHost: ElementLike;
97
+ readonly #structuredHost: ElementLike;
98
+ readonly #createStructured: CreateStructured;
85
99
  readonly #refreshMs: number;
86
100
  readonly #setTimer: (run: () => void, ms: number) => TimerHandle;
87
101
  readonly #clearTimer: (handle: TimerHandle) => void;
@@ -94,6 +108,9 @@ class Cockpit implements CockpitHandle {
94
108
  // The currently mounted terminal, tracked so switching streams (and dispose)
95
109
  // tears down the prior xterm instance instead of leaking it + its listeners.
96
110
  #terminal: TerminalSink | undefined;
111
+ // The currently mounted structured view, torn down alongside #terminal so a
112
+ // stream switch / dispose never leaks the prior worker's structured widget.
113
+ #structured: StructuredSink | undefined;
97
114
  // Bumped by every start()/stop() so an in-flight #tick() from a previous
98
115
  // start cycle can't reschedule after a stop→start race and leave two
99
116
  // overlapping poll chains running against the same cockpit.
@@ -142,6 +159,11 @@ class Cockpit implements CockpitHandle {
142
159
  }
143
160
  });
144
161
 
162
+ // The structured (ACP) view mounter defaults to the built-in DOM renderer over
163
+ // the injected document, so a structured stream derives + renders without any
164
+ // extra wiring; a browser caller may override it (e.g. a richer widget).
165
+ this.#createStructured = env.createStructured ?? ((host) => createStructuredSink(host, env.doc));
166
+
145
167
  // Build the stable skeleton once: a volatile matrix region the poll
146
168
  // re-renders, and a PERSISTENT terminal region a refresh never touches.
147
169
  env.host.replaceChildren();
@@ -159,6 +181,14 @@ class Cockpit implements CockpitHandle {
159
181
  this.#terminalHost.className = "cockpit-terminal-host";
160
182
  this.#terminalHost.setAttribute("data-terminal", "host");
161
183
  terminalPanel.appendChild(this.#terminalHost);
184
+ // A sibling PERSISTENT region for the derived structured (ACP) view. A raw
185
+ // stream keeps it in its initial/empty structured state; a structured stream
186
+ // renders here instead of dumping JSON into the byte-terminal; a mixed stream
187
+ // feeds both.
188
+ this.#structuredHost = env.doc.createElement("div");
189
+ this.#structuredHost.className = "cockpit-structured-host";
190
+ this.#structuredHost.setAttribute("data-structured", "host");
191
+ terminalPanel.appendChild(this.#structuredHost);
162
192
  shell.appendChild(this.#matrixRegion);
163
193
  shell.appendChild(terminalPanel);
164
194
  env.host.appendChild(shell);
@@ -233,12 +263,18 @@ class Cockpit implements CockpitHandle {
233
263
  // still cleans it up on the next drill or on dispose().
234
264
  this.#terminal?.dispose?.();
235
265
  this.#terminal = undefined;
266
+ // The structured view is torn down in lockstep with the terminal.
267
+ this.#structured?.dispose?.();
268
+ this.#structured = undefined;
236
269
 
237
270
  try {
238
- // Fresh terminal for the newly selected worker.
271
+ // Fresh terminal + structured view for the newly selected worker.
239
272
  this.#terminalHost.replaceChildren();
240
273
  const sink = this.#env.createTerminal(this.#terminalHost);
241
274
  this.#terminal = sink;
275
+ this.#structuredHost.replaceChildren();
276
+ const structured = this.#createStructured(this.#structuredHost);
277
+ this.#structured = structured;
242
278
 
243
279
  let session: TerminalSession | undefined;
244
280
  const client = new RelayChannelClient({
@@ -253,6 +289,7 @@ class Cockpit implements CockpitHandle {
253
289
  session = new TerminalSession({
254
290
  stream,
255
291
  sink,
292
+ structured,
256
293
  send: (message) => client.sendRelay(message),
257
294
  credit: this.#env.credit,
258
295
  });
@@ -271,6 +308,8 @@ class Cockpit implements CockpitHandle {
271
308
  this.#drill = undefined;
272
309
  this.#terminal?.dispose?.();
273
310
  this.#terminal = undefined;
311
+ this.#structured?.dispose?.();
312
+ this.#structured = undefined;
274
313
  }
275
314
  }
276
315
 
@@ -34,6 +34,7 @@ export {
34
34
  type RelayInbound,
35
35
  type RelayOutbound,
36
36
  type RelaySend,
37
+ type StructuredSink,
37
38
  type TerminalSessionOptions,
38
39
  type TerminalSink,
39
40
  } from "./terminal-session.ts";
@@ -54,10 +55,17 @@ export {
54
55
  type RenderOptions,
55
56
  } from "./render.ts";
56
57
 
58
+ export {
59
+ createStructuredSink,
60
+ renderStructured,
61
+ type StructuredTerminal,
62
+ } from "./structured-view.ts";
63
+
57
64
  export {
58
65
  bootCockpit,
59
66
  type CockpitEnv,
60
67
  type CockpitHandle,
68
+ type CreateStructured,
61
69
  type CreateTerminal,
62
70
  type TimerHandle,
63
71
  } from "./boot.ts";
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { type TranscriptEvent, deriveView } from "../transcript/index.ts";
5
+
6
+ import { FakeDocument, FakeElement } from "./fake-dom.ts";
7
+ import { createStructuredSink, renderStructured } from "./structured-view.ts";
8
+
9
+ function fixture(): { host: FakeElement; doc: FakeDocument } {
10
+ return { host: new FakeElement("div"), doc: new FakeDocument() };
11
+ }
12
+
13
+ test("renderStructured renders derived turns, messages and tool cards (not raw JSON)", () => {
14
+ const { host, doc } = fixture();
15
+ const events: TranscriptEvent[] = [
16
+ { kind: "turn", offset: 0, index: 0 },
17
+ { kind: "message", offset: 1, role: "user", text: "run the build" },
18
+ { kind: "message", offset: 2, role: "assistant", text: "on it" },
19
+ { kind: "tool-call", offset: 3, name: "shell", callId: "c1", args: { cmd: "build" } },
20
+ { kind: "tool-result", offset: 4, callId: "c1", ok: true, content: "done" },
21
+ { kind: "lifecycle", offset: 5, phase: "completed" },
22
+ ];
23
+ renderStructured(host, doc, deriveView(events));
24
+
25
+ const root = host.byClass("cockpit-structured")[0];
26
+ assert.ok(root, "a structured root is rendered");
27
+ assert.equal(root.getAttribute("data-lifecycle"), "completed");
28
+
29
+ const messages = host.byClass("cockpit-structured-message");
30
+ assert.equal(messages.length, 2);
31
+ assert.equal(messages[0]?.getAttribute("data-role"), "user");
32
+ assert.match(messages[0]?.text() ?? "", /run the build/);
33
+
34
+ const tool = host.byClass("cockpit-structured-tool")[0];
35
+ assert.ok(tool, "the tool card is rendered");
36
+ assert.equal(tool.getAttribute("data-tool"), "shell");
37
+ assert.equal(tool.getAttribute("data-state"), "ok");
38
+ assert.match(tool.text(), /done/);
39
+ });
40
+
41
+ test("renderStructured replaces prior content (idempotent re-render)", () => {
42
+ const { host, doc } = fixture();
43
+ renderStructured(host, doc, deriveView([{ kind: "message", offset: 0, role: "assistant", text: "one" }]));
44
+ renderStructured(host, doc, deriveView([{ kind: "message", offset: 0, role: "assistant", text: "two" }]));
45
+ assert.equal(host.byClass("cockpit-structured").length, 1, "no duplicated roots after a re-render");
46
+ assert.equal(host.byClass("cockpit-structured-message").length, 1);
47
+ assert.match(host.byClass("cockpit-structured-message")[0]?.text() ?? "", /two/);
48
+ });
49
+
50
+ test("createStructuredSink accumulates events and re-derives the view on each one", () => {
51
+ const { host, doc } = fixture();
52
+ const sink = createStructuredSink(host, doc);
53
+ // An empty view is present up-front, before any event.
54
+ assert.equal(host.byClass("cockpit-structured").length, 1);
55
+ assert.equal(host.byClass("cockpit-structured-message").length, 0);
56
+
57
+ sink.event({ kind: "message", offset: 0, role: "assistant", text: "first" });
58
+ sink.event({ kind: "message", offset: 1, role: "assistant", text: "second" });
59
+ const messages = host.byClass("cockpit-structured-message");
60
+ assert.equal(messages.length, 2, "both accumulated events are folded into the view");
61
+ assert.match(messages[1]?.text() ?? "", /second/);
62
+ });
63
+
64
+ test("createStructuredSink.dispose clears the accumulated log and the DOM", () => {
65
+ const { host, doc } = fixture();
66
+ const sink = createStructuredSink(host, doc);
67
+ sink.event({ kind: "message", offset: 0, role: "assistant", text: "hi" });
68
+ sink.dispose();
69
+ assert.equal(host.children.length, 0, "the structured host is emptied on dispose");
70
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The cockpit's structured-stream renderer — S8's drill-in for an ACP stream.
3
+ *
4
+ * When a drilled worker's relay stream is a **structured** ACP stream (its chunks
5
+ * are {@link TRANSCRIPT_EVENT_MARKER}-tagged transcript-event envelopes rather than
6
+ * raw PTY bytes), the {@link TerminalSession} routes each decoded event here instead
7
+ * of the byte-terminal. This renderer does **not** re-parse or pretty-print JSON: it
8
+ * feeds the accumulated typed events straight through the ONE canonical
9
+ * {@link deriveView} fold from `@nanobpm/agentic/transcript` and renders the resulting
10
+ * {@link DerivedView} (turns → messages + tool cards) into the DOM.
11
+ *
12
+ * Like {@link renderCockpit} it builds against the structural {@link ElementLike} /
13
+ * {@link DocumentLike} subset (not lib.dom), so it renders identically embedded and
14
+ * standalone, is unit-tested on Node with the in-memory fake and no `as` cast, and is
15
+ * browser-safe — it relies only on the browser-safe transcript vocab (no `Buffer`).
16
+ *
17
+ * Events arrive offset-keyed and immutable in offset order, so re-deriving the whole
18
+ * (idempotent) log on each event is correct across a resume-from-offset reconnect: a
19
+ * replayed chunk below the resume point never reaches this sink, so no event is
20
+ * dropped or double-applied.
21
+ */
22
+ import { type DerivedView, type TranscriptEvent, deriveView } from "../transcript/index.ts";
23
+ import type { DocumentLike, ElementLike } from "./render.ts";
24
+ import type { StructuredSink } from "./terminal-session.ts";
25
+
26
+ function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
27
+ const node = doc.createElement(tag);
28
+ if (className !== undefined) node.className = className;
29
+ if (text !== undefined) node.textContent = text;
30
+ return node;
31
+ }
32
+
33
+ function toolCard(doc: DocumentLike, tool: DerivedView["tools"][number]): ElementLike {
34
+ const card = el(doc, "div", "cockpit-structured-tool");
35
+ card.setAttribute("data-tool", tool.name);
36
+ card.setAttribute("data-offset", String(tool.offset));
37
+ card.setAttribute("data-state", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
38
+ const head = el(doc, "div", "cockpit-structured-tool-head");
39
+ head.appendChild(el(doc, "span", "cockpit-structured-tool-name", tool.name));
40
+ if (tool.callId !== undefined) head.appendChild(el(doc, "span", "cockpit-structured-tool-id", tool.callId));
41
+ card.appendChild(head);
42
+ if (tool.args !== undefined) {
43
+ card.appendChild(el(doc, "pre", "cockpit-structured-tool-args", JSON.stringify(tool.args)));
44
+ }
45
+ if (tool.result !== undefined) {
46
+ const result = el(doc, "div", "cockpit-structured-tool-result");
47
+ result.setAttribute("data-ok", tool.result.ok ? "true" : "false");
48
+ if (tool.result.content !== undefined) result.textContent = tool.result.content;
49
+ card.appendChild(result);
50
+ }
51
+ return card;
52
+ }
53
+
54
+ function turnSection(doc: DocumentLike, turn: DerivedView["turns"][number]): ElementLike {
55
+ const section = el(doc, "section", "cockpit-structured-turn");
56
+ section.setAttribute("data-turn", String(turn.index));
57
+ section.setAttribute("data-steps", String(turn.steps));
58
+ for (const message of turn.messages) {
59
+ const row = el(doc, "div", "cockpit-structured-message");
60
+ row.setAttribute("data-role", message.role);
61
+ row.setAttribute("data-offset", String(message.offset));
62
+ row.appendChild(el(doc, "span", "cockpit-structured-role", message.role));
63
+ row.appendChild(el(doc, "span", "cockpit-structured-text", message.text));
64
+ section.appendChild(row);
65
+ }
66
+ for (const tool of turn.tools) {
67
+ section.appendChild(toolCard(doc, tool));
68
+ }
69
+ return section;
70
+ }
71
+
72
+ /**
73
+ * Render a derived structured view into `host`, replacing whatever was there.
74
+ * Idempotent: re-call it with the latest {@link DerivedView} on every new event.
75
+ */
76
+ export function renderStructured(host: ElementLike, doc: DocumentLike, view: DerivedView): void {
77
+ host.replaceChildren();
78
+ const root = el(doc, "div", "cockpit-structured");
79
+ root.setAttribute("data-lifecycle", view.lifecycle);
80
+ root.setAttribute("data-events", String(view.eventCount));
81
+ for (const turn of view.turns) {
82
+ root.appendChild(turnSection(doc, turn));
83
+ }
84
+ host.appendChild(root);
85
+ }
86
+
87
+ /** A structured sink with a `dispose` teardown (mirrors {@link TerminalSink}). */
88
+ export interface StructuredTerminal extends StructuredSink {
89
+ dispose(): void;
90
+ }
91
+
92
+ /**
93
+ * Build a {@link StructuredSink} that accumulates the offset-ordered transcript
94
+ * events a structured stream delivers, folds them through the canonical
95
+ * {@link deriveView}, and renders the derived view into `host` on each event. The
96
+ * accumulated log is this sink's own state, so constructing one per drill-in gives
97
+ * each worker its own structured view.
98
+ */
99
+ export function createStructuredSink(host: ElementLike, doc: DocumentLike): StructuredTerminal {
100
+ const events: TranscriptEvent[] = [];
101
+ // Render an empty derived view up-front so the structured region is present and
102
+ // consistent before the first event lands.
103
+ renderStructured(host, doc, deriveView(events));
104
+ return {
105
+ event(event: TranscriptEvent): void {
106
+ events.push(event);
107
+ renderStructured(host, doc, deriveView(events));
108
+ },
109
+ dispose(): void {
110
+ events.length = 0;
111
+ host.replaceChildren();
112
+ },
113
+ };
114
+ }
@@ -2,23 +2,30 @@ import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
4
  import type { RelayPayload } from "../protocol/index.ts";
5
+ import { TRANSCRIPT_EVENT_MARKER, type TranscriptEvent, encodeTranscriptEvent } from "../transcript/index.ts";
5
6
 
6
- import { type RelayOutbound, TerminalSession } from "./terminal-session.ts";
7
+ import { type RelayOutbound, type StructuredSink, TerminalSession } from "./terminal-session.ts";
7
8
 
8
9
  interface Harness {
9
10
  readonly session: TerminalSession;
10
11
  readonly sent: RelayOutbound[];
11
12
  readonly writes: string[];
13
+ readonly events: TranscriptEvent[];
12
14
  data(offset: number, chunk: string, stream?: string): RelayPayload;
13
15
  }
14
16
 
15
- function harness(options: { from?: number; credit?: number; stream?: string } = {}): Harness {
17
+ function harness(options: { from?: number; credit?: number; stream?: string; structured?: boolean } = {}): Harness {
16
18
  const stream = options.stream ?? "worker-1";
17
19
  const sent: RelayOutbound[] = [];
18
20
  const writes: string[] = [];
21
+ const events: TranscriptEvent[] = [];
22
+ const structured: StructuredSink | undefined = options.structured
23
+ ? { event: (event) => events.push(event) }
24
+ : undefined;
19
25
  const session = new TerminalSession({
20
26
  stream,
21
27
  sink: { write: (chunk) => writes.push(chunk) },
28
+ structured,
22
29
  send: (message) => sent.push(message),
23
30
  from: options.from,
24
31
  credit: options.credit,
@@ -27,6 +34,7 @@ function harness(options: { from?: number; credit?: number; stream?: string } =
27
34
  session,
28
35
  sent,
29
36
  writes,
37
+ events,
30
38
  data: (offset, chunk, s = stream) => ({ stream: s, offset, chunk }),
31
39
  };
32
40
  }
@@ -250,3 +258,88 @@ test("grant rejects a non-positive or unsafe credit", () => {
250
258
  }
251
259
  });
252
260
 
261
+ // A marker-tagged chunk (encoded via the ONE canonical grammar, never a hand-rolled marker literal).
262
+ function structuredChunk(event: TranscriptEvent): string {
263
+ return encodeTranscriptEvent(event);
264
+ }
265
+
266
+ test("a structured (marker-tagged) chunk routes to the structured sink, NOT the byte-terminal", () => {
267
+ const h = harness({ structured: true });
268
+ h.session.attach();
269
+ h.session.handle(h.data(0, structuredChunk({ kind: "message", offset: 0, role: "assistant", text: "hi" })));
270
+ assert.deepEqual(h.writes, [], "a structured chunk must not be dumped into the byte-terminal");
271
+ assert.equal(h.events.length, 1);
272
+ assert.deepEqual(h.events[0], { kind: "message", offset: 0, role: "assistant", text: "hi" });
273
+ assert.equal(h.session.nextOffset, 1, "the resume offset advances for a structured chunk too");
274
+ });
275
+
276
+ test("a raw (untagged) chunk still renders on the byte-terminal even when a structured sink is wired", () => {
277
+ const h = harness({ structured: true });
278
+ h.session.attach();
279
+ h.session.handle(h.data(0, "plain bytes\n"));
280
+ assert.deepEqual(h.writes, ["plain bytes\n"]);
281
+ assert.deepEqual(h.events, [], "raw bytes never reach the structured sink");
282
+ assert.equal(h.session.nextOffset, 1);
283
+ });
284
+
285
+ test("with no structured sink a marker-tagged chunk is written verbatim (legacy raw-only behaviour)", () => {
286
+ const h = harness();
287
+ h.session.attach();
288
+ const chunk = structuredChunk({ kind: "message", offset: 0, role: "assistant", text: "hi" });
289
+ h.session.handle(h.data(0, chunk));
290
+ assert.deepEqual(h.writes, [chunk], "without a structured sink the envelope falls through to the byte-terminal");
291
+ assert.equal(h.session.nextOffset, 1);
292
+ });
293
+
294
+ test("a mixed stream that starts raw and only later carries tagged chunks routes each chunk correctly", () => {
295
+ const h = harness({ structured: true });
296
+ h.session.attach();
297
+ h.session.handle(h.data(0, "booting...\n"));
298
+ h.session.handle(h.data(1, structuredChunk({ kind: "turn", offset: 1, index: 0 })));
299
+ h.session.handle(h.data(2, structuredChunk({ kind: "message", offset: 2, role: "assistant", text: "done" })));
300
+ h.session.handle(h.data(3, "trailing raw\n"));
301
+ assert.deepEqual(h.writes, ["booting...\n", "trailing raw\n"], "raw chunks land on the terminal");
302
+ assert.deepEqual(
303
+ h.events.map((e) => e.kind),
304
+ ["turn", "message"],
305
+ "only the tagged chunks reach the structured sink",
306
+ );
307
+ assert.equal(h.session.nextOffset, 4);
308
+ });
309
+
310
+ test("a marker-tagged but malformed envelope falls back to raw bytes (byte-terminal), never the structured sink", () => {
311
+ // A chunk mentioning the marker but with an unknown/rejected body must be
312
+ // retained verbatim for byte-replay fidelity, not routed as a structured event.
313
+ const h = harness({ structured: true });
314
+ h.session.attach();
315
+ const malformed = JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: 1, kind: "message" }); // no text → decoder rejects
316
+ h.session.handle(h.data(0, malformed));
317
+ assert.deepEqual(h.writes, [malformed]);
318
+ assert.deepEqual(h.events, []);
319
+ assert.equal(h.session.nextOffset, 1);
320
+ });
321
+
322
+ test("a structured reconnect resumes from nextOffset — no dropped and no double-applied events", () => {
323
+ const h = harness({ structured: true });
324
+ h.session.attach();
325
+ h.session.handle(h.data(0, structuredChunk({ kind: "turn", offset: 0, index: 0 })));
326
+ h.session.handle(h.data(1, structuredChunk({ kind: "message", offset: 1, role: "assistant", text: "a" })));
327
+ assert.equal(h.session.nextOffset, 2);
328
+
329
+ // Socket drops; the client reconnects → re-attach resumes from offset 2.
330
+ h.session.attach();
331
+ assert.deepEqual(h.sent[1], { op: "subscribe", stream: "worker-1", from: 2, credit: 1024 });
332
+
333
+ // The hub replays the retained tail (re-sends offset 1) then continues. The
334
+ // replayed event is below nextOffset and must be dropped (no double-apply);
335
+ // the fresh event applies exactly once (no loss).
336
+ h.session.handle(h.data(1, structuredChunk({ kind: "message", offset: 1, role: "assistant", text: "a" })));
337
+ h.session.handle(h.data(2, structuredChunk({ kind: "message", offset: 2, role: "assistant", text: "b" })));
338
+ assert.deepEqual(
339
+ h.events.map((e) => (e.kind === "message" ? e.text : e.kind)),
340
+ ["turn", "a", "b"],
341
+ "no dropped and no duplicated structured events across the reconnect",
342
+ );
343
+ assert.equal(h.session.nextOffset, 3);
344
+ });
345
+
@@ -19,9 +19,25 @@
19
19
  * - inbound `{ op: "subscribed", stream, gap, nextOffset }` — the resume ack
20
20
  * (`gap: boolean` — the S5 wire flags whether chunks aged out),
21
21
  * - inbound {@link RelayPayload} `{ stream, offset, chunk }` — a data chunk.
22
+ *
23
+ * ## Structured (ACP) vs. raw streams
24
+ *
25
+ * A relay stream is either a **raw** byte stream (PTY output — arbitrary bytes) or
26
+ * a **structured** ACP stream whose chunks are {@link TRANSCRIPT_EVENT_MARKER}-tagged
27
+ * JSON envelopes (the transcript-event vocabulary) riding the *same*
28
+ * `{ stream, offset, chunk }` frames. The session classifies **each chunk** through
29
+ * the one canonical {@link parseTranscriptEvent} — detection is on the marker tag,
30
+ * never a guess — and routes a decoded structured event to the {@link StructuredSink}
31
+ * (the derived structured renderer) while writing a raw chunk verbatim to the byte
32
+ * {@link TerminalSink}. A mixed stream that starts raw and only later carries tagged
33
+ * chunks is handled per-chunk, so each chunk lands on the right surface. Routing does
34
+ * not touch the resume machinery: `nextOffset` advances identically whichever surface
35
+ * a chunk is applied to, so resume-from-offset neither loses nor double-applies
36
+ * structured events across a reconnect exactly as for raw output.
22
37
  */
23
38
  import type { RelayPayload } from "../protocol/index.ts";
24
39
  import { addSafeInt, isNonNegInt, isPosInt } from "../relay/index.ts";
40
+ import { type TranscriptEvent, parseTranscriptEvent } from "../transcript/index.ts";
25
41
 
26
42
  /** The terminal sink the session writes decoded output to (xterm.js satisfies this). */
27
43
  export interface TerminalSink {
@@ -31,6 +47,20 @@ export interface TerminalSink {
31
47
  dispose?(): void;
32
48
  }
33
49
 
50
+ /**
51
+ * The structured sink a session routes decoded transcript events to when the stream
52
+ * is a structured ACP stream (marker-tagged chunks). It receives the offset-keyed,
53
+ * immutable {@link TranscriptEvent} the one canonical {@link parseTranscriptEvent}
54
+ * derived from the chunk — never raw JSON — so the derived structured renderer folds
55
+ * over typed events rather than pretty-printing bytes.
56
+ */
57
+ export interface StructuredSink {
58
+ /** Apply one decoded structured transcript event (in offset order). */
59
+ event(event: TranscriptEvent): void;
60
+ /** Tear down the underlying structured widget and its listeners, if any. */
61
+ dispose?(): void;
62
+ }
63
+
34
64
  /** An outbound relay message the session asks its transport to send. */
35
65
  export type RelayOutbound =
36
66
  | { readonly op: "subscribe"; readonly stream: string; readonly from: number; readonly credit: number }
@@ -47,8 +77,15 @@ export type RelaySend = (message: RelayOutbound) => void;
47
77
  export interface TerminalSessionOptions {
48
78
  /** The relay stream id (one worker's terminal). */
49
79
  readonly stream: string;
50
- /** Where decoded output is written. */
80
+ /** Where decoded raw output is written. */
51
81
  readonly sink: TerminalSink;
82
+ /**
83
+ * Where decoded structured transcript events are routed when the stream is a
84
+ * structured ACP stream (marker-tagged chunks). Omit for a pure byte-terminal:
85
+ * without it every chunk — even a marker-tagged one — is written verbatim to
86
+ * {@link sink}, preserving the legacy raw-only behaviour.
87
+ */
88
+ readonly structured?: StructuredSink;
52
89
  /** Emits outbound relay messages. */
53
90
  readonly send: RelaySend;
54
91
  /** Bulk credit requested on each (re)subscribe. Default 1024. */
@@ -73,6 +110,7 @@ function isRelayData(message: RelayInbound): message is RelayPayload {
73
110
  export class TerminalSession {
74
111
  readonly #stream: string;
75
112
  readonly #sink: TerminalSink;
113
+ readonly #structured: StructuredSink | undefined;
76
114
  readonly #send: RelaySend;
77
115
  readonly #credit: number;
78
116
  readonly #onGap: TerminalSessionOptions["onGap"];
@@ -84,6 +122,7 @@ export class TerminalSession {
84
122
  constructor(options: TerminalSessionOptions) {
85
123
  this.#stream = options.stream;
86
124
  this.#sink = options.sink;
125
+ this.#structured = options.structured;
87
126
  this.#send = options.send;
88
127
  this.#credit = options.credit ?? DEFAULT_CREDIT;
89
128
  if (!isPosInt(this.#credit)) {
@@ -152,19 +191,41 @@ export class TerminalSession {
152
191
  // Idempotent apply: a reconnect resubscribes from nextOffset, so the hub may
153
192
  // re-deliver the boundary chunk; anything we have already applied is dropped.
154
193
  if (data.offset < this.#nextOffset) return;
155
- // Compute the next resume point BEFORE writing so the apply is atomic: a
194
+ // Compute the next resume point BEFORE applying so the apply is atomic: a
156
195
  // chunk at Number.MAX_SAFE_INTEGER makes addSafeInt throw, and it must throw
157
- // before we touch the sink — otherwise the chunk is written but #nextOffset
196
+ // before we touch either sink — otherwise the chunk is applied but #nextOffset
158
197
  // is not advanced, leaving a partially-applied state that re-delivers (and
159
198
  // so duplicates) the chunk on reconnect. Advancing via addSafeInt also fails
160
199
  // fast rather than overflowing into an unsafe nextOffset — that value would
161
200
  // later be echoed in subscribe.from and lose precision on any JSON
162
201
  // round-trip, silently corrupting resume semantics.
163
202
  const nextOffset = addSafeInt(data.offset, 1, "nextOffset");
164
- this.#sink.write(data.chunk);
203
+ this.#apply(data);
165
204
  this.#nextOffset = nextOffset;
166
205
  }
167
206
 
207
+ /**
208
+ * Route one already-de-duplicated data chunk to the right surface. Classify it
209
+ * through the ONE canonical {@link parseTranscriptEvent}: a marker-tagged, known
210
+ * envelope decodes to a typed event and (when a {@link StructuredSink} is wired)
211
+ * is routed there — the derived structured renderer — instead of dumping raw JSON
212
+ * into the byte-terminal; anything else (raw bytes, non-JSON, an untagged or
213
+ * malformed envelope) falls back to `stream-chunk` and is written verbatim to the
214
+ * byte {@link TerminalSink}, so a legacy/raw or mixed stream renders exactly as
215
+ * before. With no structured sink the parse is skipped entirely, keeping the
216
+ * raw-only path byte-identical to a pure byte-terminal.
217
+ */
218
+ #apply(data: RelayPayload): void {
219
+ if (this.#structured !== undefined) {
220
+ const event = parseTranscriptEvent({ offset: data.offset, chunk: data.chunk });
221
+ if (event.kind !== "stream-chunk") {
222
+ this.#structured.event(event);
223
+ return;
224
+ }
225
+ }
226
+ this.#sink.write(data.chunk);
227
+ }
228
+
168
229
  #onSubscribed(gap: boolean, nextOffset: number): void {
169
230
  // Validate the ack's resume point exactly as the constructor validates
170
231
  // `from`: `nextOffset` is echoed back into subscribe.from on the next