@nanobpm/nano-workforce 0.185.1 → 0.186.1

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.
@@ -0,0 +1,316 @@
1
+ // @generated from node_modules/@nanobpm/agentic/dist/transcript/display.js by scripts/build-cockpit-browser.ts — DO NOT EDIT.
2
+ //
3
+ // Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js
4
+ // renders the agentic transcript from ONE source of truth (#660). Regenerate with:
5
+ // node --experimental-strip-types scripts/build-cockpit-browser.ts
6
+
7
+ const NOOP = Object.freeze({ appended: false });
8
+ /** Recursively freeze a value already owned exclusively by the caller (a fresh clone), so no consumer
9
+ * can mutate it at any depth. Idempotent, and a no-op for primitives and already-frozen objects. A
10
+ * `seen` set guards against cyclic references so an arbitrarily-shaped `args` payload with a cycle
11
+ * freezes without recursing forever. */
12
+ function deepFreeze(value, seen = new WeakSet()) {
13
+ if (value === null || typeof value !== "object" || Object.isFrozen(value) || seen.has(value))
14
+ return;
15
+ seen.add(value);
16
+ Object.freeze(value);
17
+ for (const nested of Object.values(value))
18
+ deepFreeze(nested, seen);
19
+ }
20
+ /** Decouple a producer-owned, arbitrarily-shaped `args` value from projection state: deep clone it (so
21
+ * the returned snapshot shares no mutable reference) then deep-freeze the clone. When `structuredClone`
22
+ * is unavailable or `args` is non-cloneable (e.g. it contains functions), fall back to deep-freezing
23
+ * `args` *in place* rather than returning it unfrozen: a shared-by-reference fallback would let a
24
+ * consumer mutate `tool.args` back into the projection's internals, so freezing the shared object is
25
+ * what preserves the "cannot reach back" guarantee even when cloning fails. */
26
+ function freezeArgs(args) {
27
+ if (args === null || typeof args !== "object")
28
+ return args;
29
+ let cloned;
30
+ try {
31
+ cloned = structuredClone(args);
32
+ }
33
+ catch {
34
+ deepFreeze(args);
35
+ return args;
36
+ }
37
+ deepFreeze(cloned);
38
+ return cloned;
39
+ }
40
+ /** Deep-freeze a {@link DerivedTool} into a snapshot decoupled from the projection's mutable internals:
41
+ * a shallow clone whose nested `result` is itself cloned + frozen and whose producer-owned `args` is
42
+ * deep cloned + frozen ({@link freezeArgs}), so a consumer that mutates the returned `tool` (or
43
+ * `tool.result` / `tool.args`) cannot reach back into projection state. */
44
+ function freezeTool(tool) {
45
+ return Object.freeze({
46
+ ...tool,
47
+ ...(tool.args !== undefined ? { args: freezeArgs(tool.args) } : {}),
48
+ ...(tool.result !== undefined ? { result: Object.freeze({ ...tool.result }) } : {}),
49
+ });
50
+ }
51
+ /** Deep-freeze a {@link DerivedPermission} into a snapshot decoupled from the projection's mutable
52
+ * internals: a shallow clone whose nested `options` (and each option) and `resolved` are cloned +
53
+ * frozen, so a consumer cannot mutate projection state through the returned `permission`. */
54
+ function freezePermission(permission) {
55
+ return Object.freeze({
56
+ ...permission,
57
+ options: Object.freeze(permission.options.map((option) => Object.freeze({ ...option }))),
58
+ ...(permission.resolved !== undefined ? { resolved: Object.freeze({ ...permission.resolved }) } : {}),
59
+ });
60
+ }
61
+ function freezeBlock(block) {
62
+ switch (block.kind) {
63
+ case "text":
64
+ return Object.freeze({
65
+ kind: "text",
66
+ id: `text:${block.startOffset}`,
67
+ role: block.role,
68
+ ...(block.messageId !== undefined ? { messageId: block.messageId } : {}),
69
+ text: block.text,
70
+ startOffset: block.startOffset,
71
+ endOffset: block.endOffset,
72
+ complete: block.complete,
73
+ });
74
+ case "tool":
75
+ return Object.freeze({
76
+ kind: "tool",
77
+ id: `tool:${block.startOffset}`,
78
+ tool: freezeTool(block.tool),
79
+ startOffset: block.startOffset,
80
+ endOffset: block.endOffset,
81
+ });
82
+ case "permission":
83
+ return Object.freeze({
84
+ kind: "permission",
85
+ id: `permission:${block.startOffset}`,
86
+ permission: freezePermission(block.permission),
87
+ startOffset: block.startOffset,
88
+ endOffset: block.endOffset,
89
+ });
90
+ case "gap":
91
+ return Object.freeze({
92
+ kind: "gap",
93
+ id: `gap:${block.ordinal}`,
94
+ ...(block.beforeOffset !== undefined ? { beforeOffset: block.beforeOffset } : {}),
95
+ });
96
+ }
97
+ }
98
+ function toolFromCall(event) {
99
+ return {
100
+ name: event.name,
101
+ offset: event.offset,
102
+ ...(event.callId !== undefined ? { callId: event.callId } : {}),
103
+ ...(event.args !== undefined ? { args: event.args } : {}),
104
+ };
105
+ }
106
+ function toolWithResult(tool, result) {
107
+ return {
108
+ ...tool,
109
+ result: {
110
+ ok: result.ok,
111
+ offset: result.offset,
112
+ ...(result.content !== undefined ? { content: result.content } : {}),
113
+ },
114
+ };
115
+ }
116
+ function permissionFromRequest(event) {
117
+ return {
118
+ callId: event.callId,
119
+ policy: event.policy,
120
+ options: event.options,
121
+ offset: event.offset,
122
+ ...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
123
+ ...(event.title !== undefined ? { title: event.title } : {}),
124
+ ...(event.reason !== undefined ? { reason: event.reason } : {}),
125
+ };
126
+ }
127
+ function permissionWithResolution(permission, resolution) {
128
+ return {
129
+ ...permission,
130
+ resolved: {
131
+ allowed: resolution.allowed,
132
+ optionId: resolution.optionId,
133
+ offset: resolution.offset,
134
+ ...(resolution.by !== undefined ? { by: resolution.by } : {}),
135
+ },
136
+ };
137
+ }
138
+ export function createDisplayProjection() {
139
+ const blocks = [];
140
+ const openTools = new Map();
141
+ let anonymousTool;
142
+ const openPermissions = new Map();
143
+ // The append-order idempotency key: the highest offset ever folded. Any event at or below it was
144
+ // already applied (a replayed/duplicated chunk), so it is a no-op — this is what keeps replay,
145
+ // reconnect and pagination overlap from doubling text. Starts at -1 so offset 0 applies.
146
+ let lastOffset = -1;
147
+ // A pending gap awaiting the offset of the next block, so a consumer can anchor the break.
148
+ let pendingGapBlock;
149
+ let gapOrdinal = 0;
150
+ /** The active text block a delta may extend: the LAST block, iff it is an open text block. Any other
151
+ * trailing block (a tool card, a permission, a gap) means there is no open text run to coalesce into. */
152
+ const activeText = () => {
153
+ const tail = blocks[blocks.length - 1];
154
+ return tail !== undefined && tail.kind === "text" && !tail.complete ? tail : undefined;
155
+ };
156
+ const closeActiveText = () => {
157
+ const active = activeText();
158
+ if (active !== undefined)
159
+ active.complete = true;
160
+ };
161
+ /** Anchor a not-yet-anchored gap to the first block that opens after it, returning the now-anchored gap
162
+ * (frozen) so the triggering {@link apply} can surface it as {@link DisplayApplyResult.anchored} — else
163
+ * `undefined` when there is no pending gap. */
164
+ const anchorGap = (offset) => {
165
+ if (pendingGapBlock === undefined)
166
+ return undefined;
167
+ pendingGapBlock.beforeOffset = offset;
168
+ const anchored = freezeBlock(pendingGapBlock);
169
+ pendingGapBlock = undefined;
170
+ return anchored;
171
+ };
172
+ const applyMessage = (event) => {
173
+ const active = activeText();
174
+ // A delta may extend the active block only when it is the SAME speaker AND the SAME logical message
175
+ // AND the producer did not force a new block with `start`. Message identity: if both sides carry a
176
+ // `messageId` they must match; a changed id (or one side having an id the other lacks) is a distinct
177
+ // message. With no ids on either side, the fallback is purely structural — adjacent + same speaker.
178
+ const idsMatch = event.messageId !== undefined || active?.messageId !== undefined
179
+ ? event.messageId === active?.messageId
180
+ : true;
181
+ const canExtend = active !== undefined && event.start !== true && active.role === event.role && idsMatch;
182
+ if (canExtend && active !== undefined) {
183
+ // Snapshot REPLACES the accumulated text; a delta APPENDS exactly (no separator).
184
+ active.text = event.mode === "snapshot" ? event.text : active.text + event.text;
185
+ active.endOffset = event.offset;
186
+ if (event.final === true)
187
+ active.complete = true;
188
+ return { changed: freezeBlock(active), appended: false };
189
+ }
190
+ // Open a fresh block. (A `start`/id-change/role-change also closes any still-open predecessor so the
191
+ // next unrelated delta cannot re-open it.)
192
+ closeActiveText();
193
+ const anchored = anchorGap(event.offset);
194
+ const block = {
195
+ kind: "text",
196
+ role: event.role,
197
+ text: event.text,
198
+ startOffset: event.offset,
199
+ endOffset: event.offset,
200
+ complete: event.final === true,
201
+ ...(event.messageId !== undefined ? { messageId: event.messageId } : {}),
202
+ };
203
+ blocks.push(block);
204
+ return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
205
+ };
206
+ const applyToolCall = (event) => {
207
+ // A tool call interrupts any running text: it becomes the trailing block, so a later delta opens a
208
+ // new text block rather than coalescing across the tool.
209
+ closeActiveText();
210
+ const anchored = anchorGap(event.offset);
211
+ const block = {
212
+ kind: "tool",
213
+ tool: toolFromCall(event),
214
+ startOffset: event.offset,
215
+ endOffset: event.offset,
216
+ };
217
+ blocks.push(block);
218
+ const pending = { block };
219
+ if (event.callId !== undefined)
220
+ openTools.set(event.callId, pending);
221
+ else
222
+ anonymousTool = pending;
223
+ return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
224
+ };
225
+ const applyToolResult = (event) => {
226
+ const pending = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
227
+ if (pending === undefined)
228
+ return NOOP; // A result with no open call — nothing to pair (never invents a card).
229
+ pending.block.tool = toolWithResult(pending.block.tool, event);
230
+ pending.block.endOffset = event.offset;
231
+ if (event.callId !== undefined)
232
+ openTools.delete(event.callId);
233
+ else
234
+ anonymousTool = undefined;
235
+ return { changed: freezeBlock(pending.block), appended: false };
236
+ };
237
+ const applyPermissionRequest = (event) => {
238
+ closeActiveText();
239
+ const anchored = anchorGap(event.offset);
240
+ const block = {
241
+ kind: "permission",
242
+ permission: permissionFromRequest(event),
243
+ startOffset: event.offset,
244
+ endOffset: event.offset,
245
+ };
246
+ blocks.push(block);
247
+ openPermissions.set(event.callId, { block });
248
+ return { changed: freezeBlock(block), appended: true, ...(anchored !== undefined ? { anchored } : {}) };
249
+ };
250
+ const applyPermissionResolution = (event) => {
251
+ const pending = openPermissions.get(event.callId);
252
+ if (pending === undefined)
253
+ return NOOP;
254
+ pending.block.permission = permissionWithResolution(pending.block.permission, event);
255
+ pending.block.endOffset = event.offset;
256
+ openPermissions.delete(event.callId);
257
+ return { changed: freezeBlock(pending.block), appended: false };
258
+ };
259
+ const apply = (event) => {
260
+ // Idempotency gate: this projection requires events in strictly increasing `offset` order, so any
261
+ // offset at or below the high-water mark is treated as already folded (replay / reconnect /
262
+ // pagination overlap / a duplicated chunk) and re-applying it must not change anything. A genuinely
263
+ // out-of-order event (offset <= lastOffset arriving late) is likewise dropped here, not merged — a
264
+ // caller seeing a "missing" block must re-feed the stream in order rather than read it as deduped.
265
+ if (event.offset <= lastOffset)
266
+ return NOOP;
267
+ lastOffset = event.offset;
268
+ switch (event.kind) {
269
+ case "message":
270
+ return applyMessage(event);
271
+ case "tool-call":
272
+ return applyToolCall(event);
273
+ case "tool-result":
274
+ return applyToolResult(event);
275
+ case "permission":
276
+ return event.phase === "request" ? applyPermissionRequest(event) : applyPermissionResolution(event);
277
+ case "turn":
278
+ // An explicit turn boundary closes the running message so the next turn's text starts fresh.
279
+ closeActiveText();
280
+ return NOOP;
281
+ // A `step`, a `lifecycle` transition, and a raw `stream-chunk` do not themselves produce a display
282
+ // block and do not break text coalescing (raw bytes render on the separate byte-terminal plane).
283
+ case "step":
284
+ case "lifecycle":
285
+ case "stream-chunk":
286
+ return NOOP;
287
+ }
288
+ };
289
+ return {
290
+ apply,
291
+ applyAll(events) {
292
+ for (const event of events)
293
+ apply(event);
294
+ },
295
+ noteGap() {
296
+ closeActiveText();
297
+ const block = { kind: "gap", ordinal: gapOrdinal++ };
298
+ blocks.push(block);
299
+ pendingGapBlock = block;
300
+ return { changed: freezeBlock(block), appended: true };
301
+ },
302
+ blocks() {
303
+ return blocks.map(freezeBlock);
304
+ },
305
+ };
306
+ }
307
+ /**
308
+ * The pure batch convenience: fold a whole run of offset-ordered events into the ordered display blocks
309
+ * in one call, over a fresh {@link createDisplayProjection}. Duplicate offsets in the input are deduped
310
+ * by the same idempotency gate, so a replayed slice folds to the same result as a gap-free one.
311
+ */
312
+ export function deriveDisplay(events) {
313
+ const projection = createDisplayProjection();
314
+ projection.applyAll(events);
315
+ return projection.blocks();
316
+ }
@@ -75,6 +75,12 @@ function num(body, key) {
75
75
  const v = body[key];
76
76
  return typeof v === "number" && Number.isFinite(v) ? v : undefined;
77
77
  }
78
+ /** Read a strictly-boolean field, or `undefined` when absent. A present-but-non-boolean value returns
79
+ * `undefined` too, so the caller can reject a malformed envelope rather than silently coercing it. */
80
+ function bool(body, key) {
81
+ const v = body[key];
82
+ return typeof v === "boolean" ? v : undefined;
83
+ }
78
84
  const ROLES = ["assistant", "user", "system", "tool"];
79
85
  /** Narrow an arbitrary string to a known {@link TranscriptRole}, defaulting to `assistant`. */
80
86
  function toRole(value) {
@@ -123,7 +129,35 @@ export const CORE_TRANSCRIPT_VOCAB = Object.freeze(Object.assign(Object.create(n
123
129
  if (text === undefined)
124
130
  return undefined;
125
131
  const roleRaw = str(body, "role");
126
- return { kind: "message", offset, role: toRole(roleRaw), text };
132
+ // The additive display metadata (all optional). Mirror the permission decoder's discipline: a
133
+ // present-but-malformed optional field REJECTS the whole envelope (→ raw stream-chunk) rather than
134
+ // being silently dropped, so a decoded event never diverges from the on-wire JSON.
135
+ const messageId = str(body, "messageId");
136
+ if (body.messageId !== undefined && messageId === undefined)
137
+ return undefined;
138
+ let mode;
139
+ if (body.mode !== undefined) {
140
+ const modeRaw = str(body, "mode");
141
+ if (modeRaw !== "delta" && modeRaw !== "snapshot")
142
+ return undefined;
143
+ mode = modeRaw;
144
+ }
145
+ const start = bool(body, "start");
146
+ if (body.start !== undefined && start === undefined)
147
+ return undefined;
148
+ const final = bool(body, "final");
149
+ if (body.final !== undefined && final === undefined)
150
+ return undefined;
151
+ return {
152
+ kind: "message",
153
+ offset,
154
+ role: toRole(roleRaw),
155
+ text,
156
+ ...(messageId !== undefined ? { messageId } : {}),
157
+ ...(mode !== undefined ? { mode } : {}),
158
+ ...(start !== undefined ? { start } : {}),
159
+ ...(final !== undefined ? { final } : {}),
160
+ };
127
161
  },
128
162
  "tool-call": (body, offset) => {
129
163
  const name = str(body, "name");
@@ -25,7 +25,7 @@
25
25
  // (`app/agentic/cockpit/transcript-derive.ts` + `app/agentic/transcript-events.ts`) that a drift-guard
26
26
  // test keeps byte-identical to its source.
27
27
  import { RelayChannelClient, TerminalSession } from "@nanobpm/agentic/cockpit";
28
- import { renderDerivedTranscript } from "./generated/transcript-derive.js";
28
+ import { createIncrementalTranscript } from "./generated/transcript-derive.js";
29
29
 
30
30
  const DEFAULT_REFRESH_MS = 2000;
31
31
  const DEFAULT_STALE_AFTER_MS = 15_000;
@@ -390,42 +390,96 @@ function renderTranscripts(host, doc, view, onReplay, activeStream, title = "Pas
390
390
  host.appendChild(root);
391
391
  }
392
392
 
393
- // ── transcript rendering (#660) ────────────────────────────────────────────────────────────────
393
+ // ── transcript rendering (#660, #757) ───────────────────────────────────────────────────────────
394
394
  //
395
- // A rendered-transcript sink over `host`: it accumulates the relay stream's `{offset, chunk}` entries
396
- // and (re-)draws them through the shared `renderDerivedTranscript` the SAME parse→derive→render path
397
- // the typed core uses — so the operator sees readable message turns + tool/diff/permission cards, never
398
- // the raw `nwfTranscriptEvent` envelopes. Chunks are keyed by offset, so a resume-from-offset reconnect
399
- // that re-delivers a chunk coalesces instead of doubling it (mirrors TerminalSession's own dedup). The
400
- // derive/render logic is NOT re-copied here that hand-copy was the #660 drift; this only collects
401
- // offsets and hands the whole page to the imported renderer, which parses + folds it exactly once.
395
+ // An INCREMENTAL rendered-transcript sink over `host`: it feeds each relay `{offset, chunk}` into the
396
+ // shared {@link createIncrementalTranscript} projection the SAME ordered-display fold the typed core
397
+ // uses (#757) — so the operator sees ONE growing text block per logical message with tool/diff/permission
398
+ // activity interleaved in chronological order, never one bordered card per streamed delta and never the
399
+ // raw `nwfTranscriptEvent` envelopes. The renderer patches only the ONE touched block's DOM node per
400
+ // chunk (append a new block, or grow/settle an existing one) instead of rebuilding the whole tree, so the
401
+ // operator's text selection and expansion survive live streaming.
402
+ //
403
+ // Redraws are BATCHED to an animation frame when the host provides one (bounded scheduling: many deltas
404
+ // arriving in one frame apply together, once), falling back to a synchronous flush where no rAF exists
405
+ // (Node/tests). Scroll is AUTO-FOLLOWED only when the operator is already at the tail — captured before
406
+ // each flush mutates the DOM — so reading back through history is not yanked to the bottom by new output.
407
+ // The derive/render logic is NOT re-copied here (that hand-copy was the #660 drift); this only schedules
408
+ // and collects chunks and hands them to the imported projection.
402
409
  function transcriptSink(host, stream, opts = {}) {
403
- const byOffset = new Map();
404
- let gap = false;
405
- let nextOffset = 0;
406
- const draw = () => {
407
- const entries = [...byOffset.entries()].sort((a, b) => a[0] - b[0]).map(([offset, chunk]) => ({ offset, chunk }));
408
- renderDerivedTranscript(host, host.ownerDocument, { stream, from: 0, gap, nextOffset, entries }, opts);
409
- };
410
+ const doc = host.ownerDocument;
411
+ const win = doc?.defaultView ?? (typeof window !== "undefined" ? window : undefined);
412
+ const raf = win && typeof win.requestAnimationFrame === "function" ? win.requestAnimationFrame.bind(win) : undefined;
413
+ const caf = win && typeof win.cancelAnimationFrame === "function" ? win.cancelAnimationFrame.bind(win) : undefined;
414
+
415
+ let renderer = createIncrementalTranscript(host, doc, stream, opts);
416
+ const pending = []; // buffered {offset, chunk} awaiting the next flush
417
+ let gapPending = false; // a retention gap to open before the next flush's chunks
418
+ let frame = 0; // scheduled animation-frame handle (0 = none)
419
+ let disposed = false;
420
+
421
+ // Is the operator following the tail (so new output should auto-scroll)? Only meaningful with real
422
+ // layout; a host without numeric scroll metrics (the Node fake DOM) is treated as following.
423
+ function atTail() {
424
+ const sh = host.scrollHeight;
425
+ const st = host.scrollTop;
426
+ const ch = host.clientHeight;
427
+ if (typeof sh !== "number" || typeof st !== "number" || typeof ch !== "number") return true;
428
+ return st + ch >= sh - 4;
429
+ }
430
+ function followTail() {
431
+ if (typeof host.scrollHeight === "number") host.scrollTop = host.scrollHeight;
432
+ }
433
+
434
+ function flush() {
435
+ frame = 0;
436
+ if (disposed) return;
437
+ // Capture follow-state BEFORE mutating so only an operator already at the tail is auto-scrolled.
438
+ const follow = atTail();
439
+ if (gapPending) {
440
+ renderer.noteGap();
441
+ gapPending = false;
442
+ }
443
+ const batch = pending.splice(0, pending.length).sort((a, b) => a.offset - b.offset);
444
+ for (const entry of batch) renderer.applyChunk(entry);
445
+ if (follow) followTail();
446
+ }
447
+
448
+ function schedule() {
449
+ if (disposed || frame !== 0) return;
450
+ if (raf) frame = raf(flush);
451
+ else flush(); // no animation-frame scheduler (Node/tests) — flush synchronously so the DOM is current
452
+ }
453
+
410
454
  return {
411
- /** Fold in the resume ack (offset/gap metadata) and redraw. */
455
+ /** Fold in the resume ack: a gap flag opens a visible retention break before the following chunks. */
412
456
  ack: (message) => {
413
- if (typeof message?.nextOffset === "number") nextOffset = message.nextOffset;
414
- if (typeof message?.gap === "boolean") gap = message.gap;
415
- draw();
457
+ if (message != null && message.gap === true) gapPending = true;
458
+ schedule();
416
459
  },
417
- /** Fold in one relay data chunk (deduped by offset) and redraw. */
460
+ /** Buffer one relay data chunk (deduped by offset inside the idempotent projection) and schedule a flush. */
418
461
  add: (offset, chunk) => {
419
- byOffset.set(offset, chunk);
420
- draw();
462
+ pending.push({ offset, chunk });
463
+ schedule();
421
464
  },
422
- /** Render a whole fetched transcript page in one pass (static replay). */
465
+ /** Render a whole fetched transcript page in one pass (static replay) — rebuild from a fresh projection. */
423
466
  replace: (data) => {
424
- byOffset.clear();
425
- gap = Boolean(data.gap);
426
- nextOffset = data.nextOffset ?? 0;
427
- for (const entry of data.entries ?? []) byOffset.set(entry.offset, entry.chunk);
428
- draw();
467
+ if (frame !== 0 && caf) caf(frame);
468
+ frame = 0;
469
+ pending.length = 0;
470
+ gapPending = false;
471
+ renderer = createIncrementalTranscript(host, doc, data.stream ?? stream, opts);
472
+ if (data.gap) renderer.noteGap();
473
+ const entries = [...(data.entries ?? [])].sort((a, b) => a.offset - b.offset);
474
+ for (const entry of entries) renderer.applyChunk(entry);
475
+ followTail();
476
+ },
477
+ /** Tear down the scheduler when the terminal region is claimed by another session (or disposed). */
478
+ dispose: () => {
479
+ disposed = true;
480
+ if (frame !== 0 && caf) caf(frame);
481
+ frame = 0;
482
+ pending.length = 0;
429
483
  },
430
484
  };
431
485
  }
@@ -5,43 +5,14 @@
5
5
  "components": [
6
6
  {
7
7
  "type": "text",
8
- "text": "### Delivery graph — node `{{nodeId}}`",
9
- "conditional": {
10
- "hide": "=(nodeId = null) or (nodeId = \"\")"
11
- }
12
- },
13
- {
14
- "type": "textarea",
15
- "key": "prompt",
16
8
  "label": "Now do this",
17
- "description": "The scheduled human step this delivery graph is waiting on.",
18
- "readonly": true
19
- },
20
- {
21
- "type": "text",
22
- "text": "**Emits:** {{emitLabel}} — enter its typed value below (validated against the node's declared fact).",
23
- "conditional": {
24
- "hide": "=emitMode != \"typed\""
25
- }
9
+ "text": "This task's instruction is shown in the Decision context above."
26
10
  },
27
11
  {
28
12
  "type": "textfield",
29
13
  "key": "value",
30
- "label": "Emitted value",
31
- "description": "The typed value this step hands forward to its downstream dependents. Validated against the node's declared emitted fact.",
32
- "conditional": {
33
- "hide": "=emitMode != \"typed\""
34
- },
35
- "validate": {
36
- "required": true
37
- }
38
- },
39
- {
40
- "type": "text",
41
- "text": "_This step emits no typed fact (N/A) — just complete it to unblock its dependents._",
42
- "conditional": {
43
- "hide": "=emitMode = \"typed\""
44
- }
14
+ "label": "Emitted value (only if this step emits a fact)",
15
+ "description": "The typed value this step hands forward to its downstream dependents. Leave blank for a click-done step that emits nothing."
45
16
  },
46
17
  {
47
18
  "type": "textarea",
@@ -40,12 +40,22 @@ interface BundleModule {
40
40
  // self-contained ESM (`dist/transcript/events.js` has no runtime imports), the SAME source of truth the
41
41
  // Node core re-exports via the `transcript-events.ts` barrel. This keeps "one grammar, no drift surface"
42
42
  // true across BOTH hosts: the cockpit renders from agentic's grammar, never a hand-rolled second copy.
43
+ // The ordered DISPLAY projection (agentic #566) is a THIRD browser-safe agentic module
44
+ // (`dist/transcript/display.js`, self-contained — its only import is `import type` from events.ts,
45
+ // erased on transpile). The cockpit's derive+render now folds through it (one growing block per message,
46
+ // chronological interleave), so it joins the bundle beside the events grammar. `transcript-derive.ts`
47
+ // imports the display fold from the `../transcript-display.ts` barrel, rewritten here to the generated
48
+ // display sibling — the SAME source of truth the Node core re-exports.
43
49
  const MODULES: readonly BundleModule[] = [
44
50
  { src: "node_modules/@nanobpm/agentic/dist/transcript/events.js", out: "pages/cockpit/generated/transcript-events.js" },
51
+ { src: "node_modules/@nanobpm/agentic/dist/transcript/display.js", out: "pages/cockpit/generated/transcript-display.js" },
45
52
  {
46
53
  src: "app/agentic/cockpit/transcript-derive.ts",
47
54
  out: "pages/cockpit/generated/transcript-derive.js",
48
- rewrites: [["../transcript-events.ts", "./transcript-events.js"]],
55
+ rewrites: [
56
+ ["../transcript-events.ts", "./transcript-events.js"],
57
+ ["../transcript-display.ts", "./transcript-display.js"],
58
+ ],
49
59
  },
50
60
  ];
51
61