@nanobpm/nano-workforce 0.168.0 → 0.168.2

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.
@@ -3,8 +3,8 @@
3
3
  // This is the ONE wiring both the standalone shell and the console App-View embed call — they differ
4
4
  // only in the host element they pass, so the supply cockpit renders identically embedded and
5
5
  // standalone. It supplies the browser capabilities the injection-based core needs: the real
6
- // `document`, a `fetch`-based supply report source, a `WebSocket` relay socket factory, and an
7
- // xterm.js terminal sink.
6
+ // `document`, a `fetch`-based supply report source, a `WebSocket` relay socket factory, and a
7
+ // rendered-transcript sink (message turns + tool/diff/permission cards) over the relay stream.
8
8
  //
9
9
  // The genuinely reusable, correctness-critical parts — the relay client and the resume-from-offset
10
10
  // terminal session — are REUSED from `@nanobpm/agentic/cockpit` (resolved via the host page's import
@@ -16,8 +16,16 @@
16
16
  //
17
17
  // It renders the SUPPLY worker list ONLY — NOT the packaged demand×supply matrix / missing-agent reds
18
18
  // / diversity-SLO light (deferred to enrolment epic #152).
19
+ //
20
+ // The transcript is RENDERED, not dumped (#660): both the live drill and the past-session replay feed
21
+ // their `nwfTranscriptEvent` chunks through `renderDerivedTranscript` — the SAME parse→derive→render
22
+ // path the typed core uses — so the operator sees readable message turns + tool/diff/permission cards
23
+ // instead of raw event JSON. That renderer is NOT re-copied here (the drift that caused #660): it is
24
+ // imported from `./generated/transcript-derive.js`, a build-step emit of the ONE typed core
25
+ // (`app/agentic/cockpit/transcript-derive.ts` + `app/agentic/transcript-events.ts`) that a drift-guard
26
+ // test keeps byte-identical to its source.
19
27
  import { RelayChannelClient, TerminalSession } from "@nanobpm/agentic/cockpit";
20
- import { Terminal } from "@xterm/xterm";
28
+ import { renderDerivedTranscript } from "./generated/transcript-derive.js";
21
29
 
22
30
  const DEFAULT_REFRESH_MS = 2000;
23
31
  const DEFAULT_STALE_AFTER_MS = 15_000;
@@ -382,23 +390,48 @@ function renderTranscripts(host, doc, view, onReplay, activeStream, title = "Pas
382
390
  host.appendChild(root);
383
391
  }
384
392
 
385
- /** Feed a fetched transcript's stored chunks through a resume-from-offset TerminalSession (static playback). */
386
- function replayTranscript(session, data) {
387
- session.handle({ op: "subscribed", stream: data.stream, gap: data.gap, nextOffset: data.nextOffset });
388
- for (const entry of data.entries ?? []) {
389
- session.handle({ stream: data.stream, offset: entry.offset, chunk: entry.chunk });
390
- }
393
+ // ── transcript rendering (#660) ────────────────────────────────────────────────────────────────
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.
402
+ 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
+ return {
411
+ /** Fold in the resume ack (offset/gap metadata) and redraw. */
412
+ ack: (message) => {
413
+ if (typeof message?.nextOffset === "number") nextOffset = message.nextOffset;
414
+ if (typeof message?.gap === "boolean") gap = message.gap;
415
+ draw();
416
+ },
417
+ /** Fold in one relay data chunk (deduped by offset) and redraw. */
418
+ add: (offset, chunk) => {
419
+ byOffset.set(offset, chunk);
420
+ draw();
421
+ },
422
+ /** Render a whole fetched transcript page in one pass (static replay). */
423
+ 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();
429
+ },
430
+ };
391
431
  }
392
432
 
393
433
  // ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
394
434
 
395
- /** An xterm.js-backed terminal sink mounted into `host`. */
396
- function xtermSink(host) {
397
- const term = new Terminal({ convertEol: true, fontFamily: "ui-monospace, monospace", fontSize: 13 });
398
- term.open(host);
399
- return { write: (chunk) => term.write(chunk), dispose: () => term.dispose() };
400
- }
401
-
402
435
  /** A WebSocket relay socket factory for the agentic channel at `url`. */
403
436
  function relaySocketFactory(url) {
404
437
  return () => {
@@ -487,9 +520,11 @@ export function mountCockpit(host, opts = {}) {
487
520
  return headers;
488
521
  };
489
522
 
490
- // Stable skeleton: a volatile supply-list region + a volatile "past sessions" region the poll
491
- // re-renders, and a PERSISTENT terminal region a refresh never touches (so a drilled-in/replayed
492
- // terminal survives a list refresh). The terminal panel title distinguishes live vs replayed.
523
+ // Stable skeleton: a volatile supply-list region the poll re-renders, then the PERSISTENT terminal
524
+ // region a refresh never touches (so a drilled-in/replayed transcript survives a list refresh), then
525
+ // the volatile "past sessions" region. Ordered so the rendered transcript sits DIRECTLY BENEATH the
526
+ // "Workers — supply" table (#660), with past-sessions relocated below it. The terminal panel title
527
+ // distinguishes live vs replayed.
493
528
  host.replaceChildren();
494
529
  const shell = el(doc, "div", "cockpit-shell");
495
530
  const listRegion = el(doc, "div", "cockpit-supply-region");
@@ -508,8 +543,8 @@ export function mountCockpit(host, opts = {}) {
508
543
  terminalNote.setAttribute("data-terminal-note", "none");
509
544
  terminalPanel.appendChild(terminalNote);
510
545
  shell.appendChild(listRegion);
511
- shell.appendChild(pastRegion);
512
546
  shell.appendChild(terminalPanel);
547
+ shell.appendChild(pastRegion);
513
548
  host.appendChild(shell);
514
549
 
515
550
  let running = false;
@@ -517,7 +552,7 @@ export function mountCockpit(host, opts = {}) {
517
552
  let timer;
518
553
  let generation = 0;
519
554
  let drill; // { stream, client }
520
- let terminal; // the current xterm sink
555
+ let terminal; // the current rendered-transcript sink
521
556
  let mode; // "live" | "replay" | undefined
522
557
  let shownStream;
523
558
  let route = parseCockpitRoute(location.hash);
@@ -557,6 +592,7 @@ export function mountCockpit(host, opts = {}) {
557
592
  drill = undefined;
558
593
  terminal?.dispose?.();
559
594
  terminal = undefined;
595
+ terminalHost.replaceChildren();
560
596
  }
561
597
 
562
598
  function routeInstance() {
@@ -612,19 +648,12 @@ export function mountCockpit(host, opts = {}) {
612
648
  teardownTerminal();
613
649
  try {
614
650
  terminalHost.replaceChildren();
615
- const rawSink = xtermSink(terminalHost);
616
- terminal = rawSink;
617
- // Wrap the sink so the first byte of live output clears the "waiting" note (mirrors supply-boot).
651
+ // Render the LIVE stream through the shared derive+render path (#660): each relay chunk is folded
652
+ // into a rendered transcript (message turns + tool/diff/permission cards), never dumped as raw
653
+ // `nwfTranscriptEvent` JSON. The sink accumulates offsets and redraws via `renderDerivedTranscript`.
654
+ const rendered = transcriptSink(terminalHost, stream);
655
+ terminal = rendered;
618
656
  let cleared = false;
619
- const sink = {
620
- write: (chunk) => {
621
- if (!cleared) {
622
- cleared = true;
623
- setNote(undefined);
624
- }
625
- rawSink.write(chunk);
626
- },
627
- };
628
657
  let session;
629
658
  const client = new RelayChannelClient({
630
659
  connect: connectRelay,
@@ -633,13 +662,28 @@ export function mountCockpit(host, opts = {}) {
633
662
  // then it honestly reads "connecting", so a socket that never opens/subscribes stops
634
663
  // masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
635
664
  // resubscribe ack does not re-arm the note after real output has already flowed.
636
- if (!cleared && message?.op === "subscribed") setNote("Waiting for live output…", "waiting");
665
+ if (message?.op === "subscribed") {
666
+ if (!cleared) setNote("Waiting for live output…", "waiting");
667
+ rendered.ack(message);
668
+ } else if (message != null && typeof message.offset === "number" && typeof message.chunk === "string") {
669
+ // The first data chunk clears the "waiting" note and folds into the rendered transcript.
670
+ // Guard on `typeof chunk === "string"`: the derive/parser assumes string chunks (e.g.
671
+ // `trimStart()`), so a non-string/binary frame is dropped here rather than thrown downstream.
672
+ if (!cleared) {
673
+ cleared = true;
674
+ setNote(undefined);
675
+ }
676
+ rendered.add(message.offset, message.chunk);
677
+ }
678
+ // Still drive the TerminalSession so its subscribe/credit/resume-from-offset protocol runs
679
+ // (it is what makes the relay deliver chunks and survive a reconnect); its sink is a no-op
680
+ // because the rendered transcript above — not a byte terminal — is the operator's view.
637
681
  session?.handle(message);
638
682
  },
639
683
  onOpen: () => session?.attach(),
640
684
  onError,
641
685
  });
642
- session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message) });
686
+ session = new TerminalSession({ stream, sink: { write: () => {} }, send: (message) => client.sendRelay(message) });
643
687
  client.open();
644
688
  drill = { stream, client };
645
689
  setMode("live", stream);
@@ -689,10 +733,12 @@ export function mountCockpit(host, opts = {}) {
689
733
  if (disposed || token !== opToken) return;
690
734
  try {
691
735
  terminalHost.replaceChildren();
692
- const sink = xtermSink(terminalHost);
693
- terminal = sink;
694
- const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from ?? 0 });
695
- replayTranscript(session, data);
736
+ // Render the fetched past-session transcript through the SAME derive+render path as the live
737
+ // drill (#660): the stored `nwfTranscriptEvent` chunks become message turns + tool/diff/permission
738
+ // cards, never a raw JSON dump.
739
+ const rendered = transcriptSink(terminalHost, stream);
740
+ terminal = rendered;
741
+ rendered.replace(data);
696
742
  setMode("replay", stream);
697
743
  void refreshPast(routeInstance());
698
744
  } catch (err) {
@@ -5,22 +5,21 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <title>Agent cockpit — supply</title>
7
7
  <link rel="stylesheet" href="./cockpit.css" />
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
9
8
  <style>
10
9
  html, body { margin: 0; height: 100%; background: #0b0f14; }
11
10
  </style>
12
11
  <!--
13
- Resolve the reusable cockpit ESM (the relay client + resume-from-offset terminal session) and
14
- xterm.js. Only these correctness-critical primitives are imported from the package; the supply
15
- projection/render/poll lives inline in ./mount.js (the app has no build step to share its typed
16
- core with the browser). The SAME ./mount.js the console App-View embed uses is loaded here, so
17
- the standalone phone view and the embedded console view render identically.
12
+ Resolve the reusable cockpit ESM (the relay client + resume-from-offset terminal session). Only
13
+ these correctness-critical primitives are imported from the package; the supply projection/render/
14
+ poll lives in ./mount.js, and the transcript is rendered from the typed core's build-step emit
15
+ under ./generated/ (imported by ./mount.js) no runtime CDN dep (#660). The SAME ./mount.js the
16
+ console App-View embed uses is loaded here, so the standalone phone view and the embedded console
17
+ view render identically.
18
18
  -->
19
19
  <script type="importmap">
20
20
  {
21
21
  "imports": {
22
- "@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js",
23
- "@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
22
+ "@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js"
24
23
  }
25
24
  }
26
25
  </script>
@@ -0,0 +1,110 @@
1
+ // Build step (#660): emit the browser-consumable ESM of the typed transcript derive+render core so the
2
+ // deployed cockpit adapter (`pages/cockpit/mount.js`) can RENDER the agentic transcript from the SAME
3
+ // source of truth the Node/TypeScript core and its tests use — instead of hand-copying the derive
4
+ // logic (the drift surface that made the Worker terminal dump raw `nwfTranscriptEvent` JSON).
5
+ //
6
+ // The app has no bundler, so the browser cannot import the `.ts` core directly. Rather than duplicate
7
+ // the parse→derive→render path by hand in `mount.js` again, we DERIVE the browser bundle from the ONE
8
+ // typed source: each module is transpiled (type-strip only — no second implementation) to plain ESM
9
+ // under `pages/cockpit/generated/`, which `mount.js` imports. A drift-guard test regenerates in memory
10
+ // and asserts the committed output is byte-identical, so the generated files can never silently drift
11
+ // from their `.ts` origin (Derivation Over Duplication).
12
+ //
13
+ // Run `node --experimental-strip-types scripts/build-cockpit-browser.ts` to (re)generate, or
14
+ // `… --check` to fail if the committed output is stale.
15
+ import { readFileSync, writeFileSync } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import ts from "typescript";
19
+
20
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
21
+
22
+ /** One typed core module → its generated browser-ESM sibling, with any relative import specifiers rewritten. */
23
+ interface BundleModule {
24
+ /** Source `.ts`, repo-relative. */
25
+ readonly src: string;
26
+ /** Emitted `.js`, repo-relative (under pages/ so the browser serves it beside mount.js). */
27
+ readonly out: string;
28
+ /** Literal import-specifier rewrites applied to the emitted JS (`.ts` core path → generated `.js` sibling). */
29
+ readonly rewrites?: ReadonlyArray<readonly [from: string, to: string]>;
30
+ }
31
+
32
+ // The transcript RENDER path the browser needs is a two-module graph, both pure + DOM-agnostic:
33
+ // transcript-events.ts — the ONE parser + derive() fold (no runtime imports).
34
+ // transcript-derive.ts — renderDerivedTranscript(): message turns, tool/diff cards, permission prompts.
35
+ // Their only non-type imports are between each other; every type-only import (DocumentLike, ElementLike,
36
+ // TranscriptDataReport) is `import type` and is erased on transpile, so the emitted JS is import-clean.
37
+ const MODULES: readonly BundleModule[] = [
38
+ { src: "app/agentic/transcript-events.ts", out: "pages/cockpit/generated/transcript-events.js" },
39
+ {
40
+ src: "app/agentic/cockpit/transcript-derive.ts",
41
+ out: "pages/cockpit/generated/transcript-derive.js",
42
+ rewrites: [["../transcript-events.ts", "./transcript-events.js"]],
43
+ },
44
+ ];
45
+
46
+ function banner(src: string): string {
47
+ return [
48
+ `// @generated from ${src} by scripts/build-cockpit-browser.ts — DO NOT EDIT.`,
49
+ "//",
50
+ "// Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js",
51
+ "// renders the agentic transcript from ONE source of truth (#660). Regenerate with:",
52
+ "// node --experimental-strip-types scripts/build-cockpit-browser.ts",
53
+ "",
54
+ "",
55
+ ].join("\n");
56
+ }
57
+
58
+ function transpile(src: string): string {
59
+ const source = readFileSync(resolve(repoRoot, src), "utf8");
60
+ const result = ts.transpileModule(source, {
61
+ compilerOptions: {
62
+ module: ts.ModuleKind.ESNext,
63
+ target: ts.ScriptTarget.ES2022,
64
+ verbatimModuleSyntax: false,
65
+ removeComments: false,
66
+ },
67
+ fileName: src,
68
+ });
69
+ return result.outputText;
70
+ }
71
+
72
+ /** Generate the browser bundle in memory — the single source both the writer and the drift-guard test use. */
73
+ export function cockpitBrowserBundle(): ReadonlyArray<{ readonly out: string; readonly content: string }> {
74
+ return MODULES.map((mod) => {
75
+ let content = transpile(mod.src);
76
+ for (const [from, to] of mod.rewrites ?? []) {
77
+ content = content.split(from).join(to);
78
+ }
79
+ return { out: mod.out, content: banner(mod.src) + content };
80
+ });
81
+ }
82
+
83
+ function main(): void {
84
+ const check = process.argv.includes("--check");
85
+ let stale = false;
86
+ for (const { out, content } of cockpitBrowserBundle()) {
87
+ const path = resolve(repoRoot, out);
88
+ if (check) {
89
+ let current: string | undefined;
90
+ try {
91
+ current = readFileSync(path, "utf8");
92
+ } catch {
93
+ current = undefined;
94
+ }
95
+ if (current !== content) {
96
+ stale = true;
97
+ console.error(`✗ ${out} is stale — run: node --experimental-strip-types scripts/build-cockpit-browser.ts`);
98
+ }
99
+ } else {
100
+ writeFileSync(path, content);
101
+ console.log(`✓ wrote ${out}`);
102
+ }
103
+ }
104
+ if (check && stale) process.exit(1);
105
+ if (check) console.log("✓ cockpit browser bundle is up to date");
106
+ }
107
+
108
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
109
+ main();
110
+ }