@nanobpm/nano-workforce 0.146.0 → 0.147.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.147.0](https://github.com/nanobpm/nano-workforce/compare/v0.146.0...v0.147.0) (2026-08-26)
2
+
3
+ ### Features
4
+
5
+ * **cockpit:** rich tool/diff cards + permission prompt mounted into the typed replay path ([#561](https://github.com/nanobpm/nano-workforce/issues/561)) ([4bd04a0](https://github.com/nanobpm/nano-workforce/commit/4bd04a06f12569e418566cb47b8a7bbf053cdb20)), closes [#559](https://github.com/nanobpm/nano-workforce/issues/559) [#setMode](https://github.com/nanobpm/nano-workforce/issues/setMode) [#structuredRegion](https://github.com/nanobpm/nano-workforce/issues/structuredRegion)
6
+
1
7
  ## [0.146.0](https://github.com/nanobpm/nano-workforce/compare/v0.145.1...v0.146.0) (2026-08-26)
2
8
 
3
9
  ### Features
@@ -36,6 +36,7 @@ export {
36
36
  export {
37
37
  type DerivedTranscriptDom,
38
38
  deriveTranscript,
39
+ type RenderDerivedTranscriptOptions,
39
40
  renderDerivedTranscript,
40
41
  } from "./transcript-derive.ts";
41
42
  export {
@@ -7,8 +7,10 @@ import assert from "node:assert/strict";
7
7
  import { test } from "node:test";
8
8
 
9
9
  import { FakeDocument, FakeElement, FakeSocket } from "../../../test/agentic-cockpit-doubles.ts";
10
+ import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
10
11
  import { bootSupplyCockpit, type SupplyCockpitEnv } from "./supply-boot.ts";
11
12
  import type { SupplyReport } from "./supply-view.ts";
13
+ import type { TranscriptDataReport } from "./transcript-render.ts";
12
14
 
13
15
  const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
14
16
 
@@ -358,3 +360,95 @@ test("a drill whose terminal build throws resets the panel to idle instead of le
358
360
  assert.equal(cockpit.currentMode, "live");
359
361
  assert.equal(cockpit.currentStream, "wk-a");
360
362
  });
363
+
364
+ // A transcript envelope (marker + kind + fields), matching the one event grammar the fold parses.
365
+ function env(kind: string, extra: Record<string, unknown> = {}): string {
366
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
367
+ }
368
+
369
+ // A captured transcript carrying a pending ESCALATE permission request, for the structured replay path.
370
+ function permissionTranscript(stream: string): TranscriptDataReport {
371
+ const chunks = [
372
+ env("turn", { index: 0 }),
373
+ env("message", { role: "assistant", text: "I need to run a command" }),
374
+ env("permission", {
375
+ phase: "request",
376
+ callId: "perm-1",
377
+ policy: "escalate",
378
+ title: "Run `rm -rf build`?",
379
+ options: [
380
+ { optionId: "allow", name: "Allow", kind: "allow-once" },
381
+ { optionId: "deny", name: "Deny", kind: "reject-once" },
382
+ ],
383
+ }),
384
+ ];
385
+ return { stream, from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
386
+ }
387
+
388
+ // A rig whose replay endpoints (list + per-session bytes) are wired, plus an optional permission hook.
389
+ function replayRig(onPermissionResolve?: SupplyCockpitEnv["onPermissionResolve"]): {
390
+ readonly env: SupplyCockpitEnv;
391
+ readonly host: FakeElement;
392
+ readonly data: TranscriptDataReport;
393
+ } {
394
+ const r = rig();
395
+ const data = permissionTranscript("job:done");
396
+ const env: SupplyCockpitEnv = {
397
+ ...r.env,
398
+ fetchTranscripts: () => Promise.resolve({ count: 0, transcripts: [] }),
399
+ fetchTranscript: () => Promise.resolve(data),
400
+ ...(onPermissionResolve !== undefined ? { onPermissionResolve } : {}),
401
+ };
402
+ return { env, host: r.host, data };
403
+ }
404
+
405
+ test("replay MOUNTS the structured derived view beside the byte replay (typed boot/replay path)", async () => {
406
+ const { env, host } = replayRig();
407
+ const cockpit = bootSupplyCockpit(env);
408
+ await cockpit.replay("job:done");
409
+
410
+ // The dedicated structured region exists and now holds the derived view (the byte terminal replay,
411
+ // asserted elsewhere, is untouched — this is additive beside it).
412
+ const region = host.byData("structured", "region")[0];
413
+ assert.ok(region !== undefined, "the structured region is present");
414
+ assert.equal(region?.byClass("cockpit-transcript-derived").length, 1, "the derived structured view is mounted");
415
+ assert.equal(host.byData("permission", "request").length, 1, "the permission prompt is rendered in the structured view");
416
+ });
417
+
418
+ test("a SupplyCockpitEnv.onPermissionResolve hook is threaded into the structured render so a click reaches it", async () => {
419
+ const resolutions: Array<{ callId: string; optionId: string; allowed: boolean }> = [];
420
+ const { env, host } = replayRig((resolution) => resolutions.push(resolution));
421
+ const cockpit = bootSupplyCockpit(env);
422
+ await cockpit.replay("job:done");
423
+
424
+ const allow = host.byClass("cockpit-transcript-permission-option").find((b) => b.getAttribute("data-option-id") === "allow");
425
+ assert.ok(allow !== undefined, "the Allow button rendered");
426
+ allow?.dispatch("click");
427
+ assert.deepEqual(resolutions, [{ callId: "perm-1", optionId: "allow", allowed: true }]);
428
+ });
429
+
430
+ test("the structured view renders even without an onPermissionResolve hook (buttons are inert, no throw)", async () => {
431
+ const { env, host } = replayRig();
432
+ const cockpit = bootSupplyCockpit(env);
433
+ await cockpit.replay("job:done");
434
+ const allow = host.byClass("cockpit-transcript-permission-option").find((b) => b.getAttribute("data-option-id") === "allow");
435
+ assert.ok(allow !== undefined, "the Allow button rendered without a hook");
436
+ allow?.dispatch("click"); // no handler wired — must not throw
437
+ });
438
+
439
+ test("switching from replay to a LIVE drill CLEARS the stale structured derived view (no wrong-callId clicks)", async () => {
440
+ const { env, host } = replayRig();
441
+ const cockpit = bootSupplyCockpit(env);
442
+ await cockpit.replay("job:done");
443
+ // The structured region is populated with the derived view + pending permission prompt.
444
+ assert.equal(host.byData("permission", "request").length, 1, "the permission prompt is mounted on replay");
445
+
446
+ // Drilling into a live stream must tear the stale structured view down — leaving a clickable permission
447
+ // prompt over a live terminal risks an operator action against the wrong callId.
448
+ cockpit.drill("wk-a");
449
+ assert.equal(cockpit.currentMode, "live");
450
+ const region = host.byData("structured", "region")[0];
451
+ assert.ok(region !== undefined, "the structured region element persists (it is cleared, not removed)");
452
+ assert.equal(region?.byClass("cockpit-transcript-derived").length, 0, "the derived structured view is cleared on live drill");
453
+ assert.equal(host.byData("permission", "request").length, 0, "the stale permission prompt is gone in live mode");
454
+ });
@@ -32,6 +32,7 @@ import type { CockpitRoute } from "./cockpit-route.ts";
32
32
  import { renderSupply } from "./supply-render.ts";
33
33
  import type { SupplyReport, SupplyView } from "./supply-view.ts";
34
34
  import { supplyView } from "./supply-view.ts";
35
+ import { type RenderDerivedTranscriptOptions, renderDerivedTranscript } from "./transcript-derive.ts";
35
36
  import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts";
36
37
  import type { TranscriptListReport } from "./transcript-view.ts";
37
38
  import { transcriptsView } from "./transcript-view.ts";
@@ -88,6 +89,16 @@ export interface SupplyCockpitEnv {
88
89
  readonly pastFetchTimeoutMs?: number;
89
90
  /** Notified of a fetch/render/relay error (the poll keeps going). */
90
91
  readonly onError?: (err: unknown) => void;
92
+ /**
93
+ * The seam the wave-2 escalation bridge attaches to: threaded into the STRUCTURED replay view's
94
+ * {@link RenderDerivedTranscriptOptions.onPermissionResolve}, so a pending escalate-permission
95
+ * prompt's Allow/Deny click reaches the bridge. Optional (default `undefined`): the structured view
96
+ * still renders, its prompt buttons just have no handler. NOTE (runtime-layer truth): this is the
97
+ * TYPED boot/replay path exercised by unit tests + the published package, NOT the operator's live
98
+ * browser cockpit (`pages/cockpit/mount.js`, a hand-maintained drift twin) — surfacing the prompt
99
+ * there is a separate follow-up.
100
+ */
101
+ readonly onPermissionResolve?: RenderDerivedTranscriptOptions["onPermissionResolve"];
91
102
  }
92
103
 
93
104
  /** The running supply cockpit; dispose to stop polling and tear down the terminal. */
@@ -132,6 +143,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
132
143
  readonly #env: SupplyCockpitEnv;
133
144
  readonly #listRegion: ElementLike;
134
145
  readonly #pastRegion: ElementLike | undefined;
146
+ // A dedicated volatile region the STRUCTURED derived view (messages, rich tool/diff cards, permission
147
+ // prompts) is mounted into on a replay — beside, and additive to, the byte-level terminal replay
148
+ // (which is left untouched). Present only when the transcript read endpoints are wired.
149
+ readonly #structuredRegion: ElementLike | undefined;
135
150
  readonly #terminalHost: ElementLike;
136
151
  readonly #terminalTitle: ElementLike;
137
152
  readonly #terminalNote: ElementLike;
@@ -247,6 +262,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
247
262
  this.#terminalHost.className = "cockpit-terminal-host";
248
263
  this.#terminalHost.setAttribute("data-terminal", "host");
249
264
  this.#terminalPanel.appendChild(this.#terminalHost);
265
+ // The STRUCTURED derived view is mounted here on a replay, additive beside the byte terminal above.
266
+ // Only exists when the transcript read endpoints are wired (same pairing as the past-sessions list).
267
+ if (env.fetchTranscripts !== undefined) {
268
+ this.#structuredRegion = env.doc.createElement("div");
269
+ this.#structuredRegion.className = "cockpit-structured-region";
270
+ this.#structuredRegion.setAttribute("data-structured", "region");
271
+ this.#terminalPanel.appendChild(this.#structuredRegion);
272
+ }
250
273
  // A status note under the terminal, shown while a LIVE drill has connected but no output has
251
274
  // arrived yet (a quiet job between frames): without it the panel is an indistinguishable blank
252
275
  // black rectangle, so the operator can't tell "connected, waiting" from "broken". Cleared the
@@ -284,6 +307,11 @@ class SupplyCockpit implements SupplyCockpitHandle {
284
307
  // Any mode change replaces what's behind the panel, so the prior "waiting for output" note is
285
308
  // stale — clear it. A live drill re-arms it (below) once its fresh terminal is mounted.
286
309
  this.#setNote(undefined);
310
+ // The STRUCTURED derived view is only valid alongside a replay. Any non-replay mode (a live drill
311
+ // or idle) must clear it, or a stale derived transcript / pending permission prompt would linger —
312
+ // visible and CLICKABLE — over the live terminal, risking an operator action against the wrong
313
+ // callId. A replay re-mounts it (in replay(), after this #setMode("replay", …)).
314
+ if (mode !== "replay") this.#structuredRegion?.replaceChildren();
287
315
  }
288
316
 
289
317
  /** Show (or clear) the terminal status note — the "connected, waiting for output" affordance. */
@@ -577,6 +605,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
577
605
  const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from });
578
606
  replayTranscript(session, data);
579
607
  this.#setMode("replay", stream);
608
+ // Additively mount the STRUCTURED derived view beside the byte replay above (the byte replay is
609
+ // left untouched). The env's onPermissionResolve seam is threaded into the render so a pending
610
+ // escalate-permission prompt's Allow/Deny click reaches whatever the bridge wires there.
611
+ if (this.#structuredRegion !== undefined) {
612
+ renderDerivedTranscript(this.#structuredRegion, this.#env.doc, data, {
613
+ onPermissionResolve: this.#env.onPermissionResolve,
614
+ });
615
+ }
580
616
  // Re-render the past list so the just-selected session shows as active (best-effort).
581
617
  void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
582
618
  } catch (err) {
@@ -4,7 +4,7 @@
4
4
  // cards, per-turn boundaries), and that raw chunks are preserved in the fidelity footer — the byte
5
5
  // replay is not lost. It renders into the in-memory DOM double, no browser.
6
6
  import { test } from "node:test";
7
- import { assertEquals } from "#test-assert";
7
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
8
8
  import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
9
9
  import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
10
10
  import { deriveTranscript, renderDerivedTranscript } from "./transcript-derive.ts";
@@ -76,3 +76,184 @@ test("renderDerivedTranscript shows an empty state for an all-raw (unstructured)
76
76
  assertEquals(host.byData("empty", "true").length, 1);
77
77
  assertEquals(host.byData("turn-count", "0").length, 1);
78
78
  });
79
+
80
+ // A minimal single-page report of the given entries (offsets assigned in order).
81
+ function page(chunks: readonly string[], stream = "job:x"): TranscriptDataReport {
82
+ return { stream, from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
83
+ }
84
+
85
+ test("a tool card renders its args and result content", () => {
86
+ const host = new FakeElement("div");
87
+ renderDerivedTranscript(
88
+ host,
89
+ doc,
90
+ page([
91
+ env("tool-call", { name: "read", callId: "c1", args: { path: "README.md" } }),
92
+ env("tool-result", { callId: "c1", ok: true, content: "file contents here" }),
93
+ ]),
94
+ );
95
+ const card = host.byData("tool", "read")[0];
96
+ assertEquals(card?.getAttribute("data-status"), "ok");
97
+ assertEquals(card?.getAttribute("data-tool-kind"), undefined); // not a diff
98
+ const argsEl = host.byData("tool-args", "true")[0];
99
+ assertStringIncludes(argsEl?.textContent ?? "", "README.md");
100
+ const resultEl = host.byData("tool-result", "true")[0];
101
+ assertEquals(resultEl?.textContent, "file contents here");
102
+ });
103
+
104
+ test("a diff tool renders a cockpit-transcript-diff block with per-line add/del/ctx markers", () => {
105
+ const host = new FakeElement("div");
106
+ const unified = "--- a/foo.txt\n+++ b/foo.txt\n@@ -1,2 +1,2 @@\n-old line\n+new line\n unchanged\n";
107
+ renderDerivedTranscript(
108
+ host,
109
+ doc,
110
+ page([
111
+ env("tool-call", { name: "edit", callId: "d1" }),
112
+ env("tool-result", { callId: "d1", ok: true, content: unified }),
113
+ ]),
114
+ );
115
+ const card = host.byData("tool", "edit")[0];
116
+ assertEquals(card?.getAttribute("data-tool-kind"), "diff");
117
+ assertEquals(host.byClass("cockpit-transcript-diff").length, 1);
118
+ assertEquals(host.byData("diff-line", "add").length, 1);
119
+ assertEquals(host.byData("diff-line", "del").length, 1);
120
+ assert(host.byData("diff-line", "ctx").length >= 1, "the hunk/header/context lines are marked ctx");
121
+ // The diff replaces the plain result rendering — no raw result <pre> for a diff tool.
122
+ assertEquals(host.byData("tool-result", "true").length, 0);
123
+ });
124
+
125
+ test("structured edit args (path + old/new text) render as a synthesized diff", () => {
126
+ const host = new FakeElement("div");
127
+ renderDerivedTranscript(
128
+ host,
129
+ doc,
130
+ page([env("tool-call", { name: "write", callId: "s1", args: { path: "a.txt", oldText: "one\ntwo", newText: "one\nTWO" } })]),
131
+ );
132
+ const card = host.byData("tool", "write")[0];
133
+ assertEquals(card?.getAttribute("data-tool-kind"), "diff");
134
+ assertEquals(host.byData("diff-line", "del").length, 2);
135
+ assertEquals(host.byData("diff-line", "add").length, 2);
136
+ // The diff was synthesized FROM args, so the raw args <pre> is not ALSO rendered.
137
+ assertEquals(host.byData("tool-args", "true").length, 0);
138
+ });
139
+
140
+ test("structured edit args ending in a trailing newline do not synthesize a spurious empty diff line", () => {
141
+ const host = new FakeElement("div");
142
+ renderDerivedTranscript(
143
+ host,
144
+ doc,
145
+ page([env("tool-call", { name: "write", callId: "s2", args: { path: "a.txt", oldText: "one\ntwo\n", newText: "one\nTWO\n" } })]),
146
+ );
147
+ // "one\ntwo\n" would naively split into 3 segments (…, "two", "") — the trailing empty is dropped.
148
+ assertEquals(host.byData("diff-line", "del").length, 2);
149
+ assertEquals(host.byData("diff-line", "add").length, 2);
150
+ });
151
+
152
+ test("an args-synthesized diff still renders non-diff result content (args+result, diff special render)", () => {
153
+ const host = new FakeElement("div");
154
+ renderDerivedTranscript(
155
+ host,
156
+ doc,
157
+ page([
158
+ env("tool-call", { name: "write", callId: "s3", args: { path: "a.txt", oldText: "one", newText: "ONE" } }),
159
+ env("tool-result", { callId: "s3", ok: true, content: "wrote 1 file" }),
160
+ ]),
161
+ );
162
+ const card = host.byData("tool", "write")[0];
163
+ assertEquals(card?.getAttribute("data-tool-kind"), "diff");
164
+ // The diff came from args, so the result content is not the diff source — it is still shown.
165
+ const resultEl = host.byData("tool-result", "true")[0];
166
+ assertEquals(resultEl?.textContent, "wrote 1 file");
167
+ });
168
+
169
+ test("a pending escalate permission renders Allow/Deny buttons that invoke onPermissionResolve", () => {
170
+ const host = new FakeElement("div");
171
+ const calls: Array<{ callId: string; optionId: string; allowed: boolean }> = [];
172
+ renderDerivedTranscript(
173
+ host,
174
+ doc,
175
+ page([
176
+ env("permission", {
177
+ phase: "request",
178
+ callId: "p1",
179
+ policy: "escalate",
180
+ title: "Run a shell command?",
181
+ options: [
182
+ { optionId: "allow", name: "Allow", kind: "allow-once" },
183
+ { optionId: "deny", name: "Deny", kind: "reject-once" },
184
+ ],
185
+ }),
186
+ ]),
187
+ { onPermissionResolve: (resolution) => calls.push(resolution) },
188
+ );
189
+ const card = host.byData("permission", "request")[0];
190
+ assertEquals(card?.getAttribute("data-policy"), "escalate");
191
+ assertEquals(card?.getAttribute("data-status"), "pending");
192
+ assertEquals(card?.getAttribute("data-call-id"), "p1");
193
+ const buttons = host.byClass("cockpit-transcript-permission-option");
194
+ assertEquals(buttons.length, 2);
195
+
196
+ buttons.find((b) => b.getAttribute("data-option-id") === "allow")?.dispatch("click");
197
+ buttons.find((b) => b.getAttribute("data-option-id") === "deny")?.dispatch("click");
198
+ assertEquals(calls, [
199
+ { callId: "p1", optionId: "allow", allowed: true },
200
+ { callId: "p1", optionId: "deny", allowed: false },
201
+ ]);
202
+ });
203
+
204
+ test("a yolo permission request renders informational only — no Allow/Deny buttons", () => {
205
+ const host = new FakeElement("div");
206
+ const calls: unknown[] = [];
207
+ renderDerivedTranscript(
208
+ host,
209
+ doc,
210
+ page([
211
+ env("permission", {
212
+ phase: "request",
213
+ callId: "y1",
214
+ policy: "yolo",
215
+ options: [{ optionId: "allow", name: "Allow", kind: "allow-always" }],
216
+ }),
217
+ ]),
218
+ { onPermissionResolve: (resolution) => calls.push(resolution) },
219
+ );
220
+ const card = host.byData("permission", "request")[0];
221
+ assertEquals(card?.getAttribute("data-policy"), "yolo");
222
+ assertEquals(card?.getAttribute("data-status"), "auto");
223
+ assertEquals(host.byClass("cockpit-transcript-permission-option").length, 0);
224
+ assertEquals(calls.length, 0);
225
+ });
226
+
227
+ test("a resolved permission renders settled (allowed/denied) with the chosen option and no live buttons", () => {
228
+ const host = new FakeElement("div");
229
+ renderDerivedTranscript(
230
+ host,
231
+ doc,
232
+ page([
233
+ env("permission", {
234
+ phase: "request",
235
+ callId: "r1",
236
+ policy: "escalate",
237
+ title: "Delete the file?",
238
+ options: [
239
+ { optionId: "allow", name: "Allow once", kind: "allow-once" },
240
+ { optionId: "deny", name: "Deny", kind: "reject-once" },
241
+ ],
242
+ }),
243
+ env("permission", { phase: "resolution", callId: "r1", optionId: "deny", allowed: false, by: "operator" }),
244
+ ]),
245
+ );
246
+ const card = host.byData("permission", "request")[0];
247
+ assertEquals(card?.getAttribute("data-status"), "denied");
248
+ const settled = host.byClass("cockpit-transcript-permission-settled")[0];
249
+ assertEquals(settled?.getAttribute("data-chosen-option"), "deny");
250
+ assertEquals(settled?.textContent, "Deny");
251
+ assertEquals(host.byClass("cockpit-transcript-permission-option").length, 0);
252
+ });
253
+
254
+ test("the existing 3-arg renderDerivedTranscript(host, doc, data) call still works (options optional)", () => {
255
+ const host = new FakeElement("div");
256
+ renderDerivedTranscript(host, doc, report());
257
+ assertEquals(host.byData("turn-count", "1").length, 1);
258
+ assertEquals(host.byData("permission-count", "0").length, 1);
259
+ });
@@ -13,7 +13,13 @@
13
13
  // the same {@link DerivedView}, and the renderer draws into the injected {@link DocumentLike} subset so
14
14
  // a real DOM satisfies it at runtime and an in-memory fake satisfies it for DOM-free Node tests.
15
15
  import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
16
- import { type DerivedView, deriveViewFromChunks } from "../transcript-events.ts";
16
+ import {
17
+ type DerivedPermission,
18
+ type DerivedTool,
19
+ type DerivedView,
20
+ deriveViewFromChunks,
21
+ type PermissionOptionKind,
22
+ } from "../transcript-events.ts";
17
23
  import type { TranscriptDataReport } from "./transcript-render.ts";
18
24
 
19
25
  /**
@@ -36,13 +42,235 @@ export interface DerivedTranscriptDom {
36
42
  readonly root: ElementLike;
37
43
  }
38
44
 
45
+ /**
46
+ * Options for {@link renderDerivedTranscript}. This is the SHARED SEAM the wave-2 escalation bridge
47
+ * attaches its handler to: an escalate-policy permission prompt's Allow/Deny buttons invoke
48
+ * {@link onPermissionResolve} on click (mirroring how `transcript-render.ts` wires `onReplay`). The
49
+ * render itself only *invokes* the callback — the relay round-trip that actually releases the blocked
50
+ * agent lives in the bridge, not here. Optional/defaulted so the 3-arg call sites keep working.
51
+ */
52
+ export interface RenderDerivedTranscriptOptions {
53
+ /**
54
+ * Called when the operator picks an Allow/Deny option on a pending `escalate` permission prompt. The
55
+ * resolution shape is the minimal `{ callId, optionId, allowed }` the bridge folds into a
56
+ * `permission` RESOLUTION frame — `allowed` is derived from the chosen option's kind (allow-* ⇒ true,
57
+ * reject-* ⇒ false). Yolo requests never prompt, so this never fires for a yolo policy.
58
+ */
59
+ readonly onPermissionResolve?: (resolution: { callId: string; optionId: string; allowed: boolean }) => void;
60
+ }
61
+
62
+ /** A single classified line of a rendered diff block. */
63
+ type DiffLineKind = "add" | "del" | "ctx";
64
+ interface DiffLine {
65
+ readonly kind: DiffLineKind;
66
+ readonly text: string;
67
+ }
68
+ interface DetectedDiff {
69
+ readonly lines: readonly DiffLine[];
70
+ /** Where the diff came from — so the raw `args`/`result` content isn't ALSO rendered redundantly. */
71
+ readonly source: "args" | "result";
72
+ }
73
+
74
+ /** Render an arbitrary derived value (tool args/result) as displayable text without re-parsing the log. */
75
+ function toText(value: unknown): string {
76
+ if (typeof value === "string") return value;
77
+ if (value === undefined) return "";
78
+ return JSON.stringify(value, null, 2);
79
+ }
80
+
81
+ /** Read the first string-valued field among `keys` off an object, without an `as` cast. */
82
+ function pickString(obj: object, keys: readonly string[]): string | undefined {
83
+ for (const key of keys) {
84
+ const value = Reflect.get(obj, key);
85
+ if (typeof value === "string") return value;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /** Classify one line of a unified diff (file/hunk headers are context, not add/del). */
91
+ function classifyUnifiedLine(line: string): DiffLineKind {
92
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("diff ")) return "ctx";
93
+ if (line.startsWith("+")) return "add";
94
+ if (line.startsWith("-")) return "del";
95
+ return "ctx";
96
+ }
97
+
98
+ /** Heuristic: does this string look like a unified diff (a hunk header, or paired +/- content lines)? */
99
+ function looksLikeUnifiedDiff(text: string): boolean {
100
+ if (text.length === 0) return false;
101
+ let add = false;
102
+ let del = false;
103
+ let hunk = false;
104
+ for (const line of text.split("\n")) {
105
+ if (line.startsWith("@@") || line.startsWith("diff --git")) hunk = true;
106
+ else if (line.startsWith("+++") || line.startsWith("---")) continue;
107
+ else if (line.startsWith("+")) add = true;
108
+ else if (line.startsWith("-")) del = true;
109
+ }
110
+ return hunk || (add && del);
111
+ }
112
+
113
+ /** Split a unified-diff string into classified lines (dropping a single trailing empty line). */
114
+ function parseUnifiedDiff(text: string): DiffLine[] {
115
+ const lines = text.split("\n");
116
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
117
+ return lines.map((line) => ({ kind: classifyUnifiedLine(line), text: line }));
118
+ }
119
+
120
+ /** Split a block of text into lines, dropping a single trailing empty segment (text ending in "\n"). */
121
+ function splitTextLines(text: string): string[] {
122
+ const lines = text.split("\n");
123
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
124
+ return lines;
125
+ }
126
+
127
+ /** Synthesize a diff from structured edit args (`{ path?, oldText/old_string, newText/new_string }`). */
128
+ function structuredDiff(args: unknown): DiffLine[] | undefined {
129
+ if (typeof args !== "object" || args === null) return undefined;
130
+ const oldText = pickString(args, ["oldText", "old_string", "oldStr", "old", "before"]);
131
+ const newText = pickString(args, ["newText", "new_string", "newStr", "new", "after"]);
132
+ if (oldText === undefined && newText === undefined) return undefined;
133
+ const lines: DiffLine[] = [];
134
+ const path = pickString(args, ["path", "file", "filePath", "fileName"]);
135
+ if (path !== undefined) lines.push({ kind: "ctx", text: `diff --git a/${path} b/${path}` });
136
+ if (oldText !== undefined && oldText.length > 0) {
137
+ for (const line of splitTextLines(oldText)) lines.push({ kind: "del", text: `-${line}` });
138
+ }
139
+ if (newText !== undefined && newText.length > 0) {
140
+ for (const line of splitTextLines(newText)) lines.push({ kind: "add", text: `+${line}` });
141
+ }
142
+ return lines.length > 0 ? lines : undefined;
143
+ }
144
+
145
+ /** Detect diff-shaped content on a tool call/result — a unified-diff string or structured edit args. */
146
+ function detectDiff(tool: DerivedTool): DetectedDiff | undefined {
147
+ const content = tool.result?.content;
148
+ if (typeof content === "string" && looksLikeUnifiedDiff(content)) {
149
+ return { lines: parseUnifiedDiff(content), source: "result" };
150
+ }
151
+ if (typeof tool.args === "string" && looksLikeUnifiedDiff(tool.args)) {
152
+ return { lines: parseUnifiedDiff(tool.args), source: "args" };
153
+ }
154
+ const structured = structuredDiff(tool.args);
155
+ if (structured !== undefined) return { lines: structured, source: "args" };
156
+ return undefined;
157
+ }
158
+
159
+ /** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
160
+ function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
161
+ const card = el(doc, "div", "cockpit-transcript-tool");
162
+ card.setAttribute("data-tool", tool.name);
163
+ card.setAttribute("data-offset", String(tool.offset));
164
+ card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
165
+ card.appendChild(el(doc, "div", "cockpit-transcript-tool-name", tool.name));
166
+
167
+ const diff = detectDiff(tool);
168
+ if (diff !== undefined) card.setAttribute("data-tool-kind", "diff");
169
+
170
+ // Show the raw args unless the diff was synthesized FROM the args (then the diff block replaces it).
171
+ if (tool.args !== undefined && !(diff !== undefined && diff.source === "args")) {
172
+ const argsEl = el(doc, "pre", "cockpit-transcript-tool-args", toText(tool.args));
173
+ argsEl.setAttribute("data-tool-args", "true");
174
+ card.appendChild(argsEl);
175
+ }
176
+
177
+ if (diff !== undefined) {
178
+ const pre = el(doc, "pre", "cockpit-transcript-diff");
179
+ pre.setAttribute("data-diff", "true");
180
+ for (const line of diff.lines) {
181
+ // A <pre> may only contain phrasing content, so each diff line is a phrasing <span>
182
+ // (not a block <div>, which would be invalid markup) carrying a trailing "\n". The
183
+ // enclosing <pre> preserves that newline, so lines break onto their own line without
184
+ // depending on host CSS forcing display:block.
185
+ const row = el(doc, "span", "cockpit-transcript-diff-line", `${line.text}\n`);
186
+ row.setAttribute("data-diff-line", line.kind);
187
+ pre.appendChild(row);
188
+ }
189
+ card.appendChild(pre);
190
+ }
191
+
192
+ // Render the result content unless it was itself consumed as the diff source (source === "result").
193
+ if (typeof tool.result?.content === "string" && !(diff !== undefined && diff.source === "result")) {
194
+ const resEl = el(doc, "pre", "cockpit-transcript-tool-result", tool.result.content);
195
+ resEl.setAttribute("data-tool-result", "true");
196
+ card.appendChild(resEl);
197
+ }
198
+ return card;
199
+ }
200
+
201
+ /** Does a permission option kind allow (true) or reject (false) the proposed action? */
202
+ function optionAllows(kind: PermissionOptionKind): boolean {
203
+ return kind === "allow-once" || kind === "allow-always";
204
+ }
205
+
206
+ /**
207
+ * Render one permission prompt card from a {@link DerivedPermission}:
208
+ * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
209
+ * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
210
+ * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
211
+ */
212
+ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
213
+ const card = el(doc, "div", "cockpit-transcript-permission");
214
+ card.setAttribute("data-permission", "request");
215
+ card.setAttribute("data-policy", perm.policy);
216
+ card.setAttribute("data-call-id", perm.callId);
217
+ card.setAttribute("data-offset", String(perm.offset));
218
+ if (perm.toolName !== undefined) card.setAttribute("data-tool", perm.toolName);
219
+ if (perm.title !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-title", perm.title));
220
+ if (perm.reason !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-reason", perm.reason));
221
+
222
+ if (perm.resolved !== undefined) {
223
+ // Settled: show which option was chosen and no live buttons.
224
+ card.setAttribute("data-status", perm.resolved.allowed ? "allowed" : "denied");
225
+ const chosen = perm.options.find((option) => option.optionId === perm.resolved?.optionId);
226
+ const settled = el(doc, "div", "cockpit-transcript-permission-settled", chosen?.name ?? perm.resolved.optionId);
227
+ settled.setAttribute("data-chosen-option", perm.resolved.optionId);
228
+ if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
229
+ card.appendChild(settled);
230
+ return card;
231
+ }
232
+
233
+ if (perm.policy === "yolo") {
234
+ // Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
235
+ card.setAttribute("data-status", "auto");
236
+ card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
237
+ return card;
238
+ }
239
+
240
+ // Pending escalate: one interactive button per offered option, wired to the resolve seam.
241
+ card.setAttribute("data-status", "pending");
242
+ const actions = el(doc, "div", "cockpit-transcript-permission-actions");
243
+ for (const option of perm.options) {
244
+ const allowed = optionAllows(option.kind);
245
+ const button = el(doc, "button", "cockpit-transcript-permission-option", option.name);
246
+ button.setAttribute("type", "button");
247
+ button.setAttribute("data-option-id", option.optionId);
248
+ button.setAttribute("data-option-kind", option.kind);
249
+ button.setAttribute("data-allowed", String(allowed));
250
+ const onPermissionResolve = options.onPermissionResolve;
251
+ if (onPermissionResolve !== undefined) {
252
+ button.addEventListener("click", () => onPermissionResolve({ callId: perm.callId, optionId: option.optionId, allowed }));
253
+ }
254
+ actions.appendChild(button);
255
+ }
256
+ card.appendChild(actions);
257
+ return card;
258
+ }
259
+
39
260
  /**
40
261
  * Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
41
- * Draws per-turn sections with their derived messages and tool cards, plus a raw-fidelity footer
42
- * (retained bytes/chunks) so the operator sees the byte-replay is preserved alongside the structure.
43
- * Idempotent — call again on each refresh. Everything it shows is a derivation of the one event log.
262
+ * Draws per-turn sections with their derived messages, rich tool/diff cards and permission prompts, plus
263
+ * a raw-fidelity footer (retained bytes/chunks) so the operator sees the byte-replay is preserved
264
+ * alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
265
+ * of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
266
+ * escalate-permission prompt's Allow/Deny buttons.
44
267
  */
45
- export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, data: TranscriptDataReport): DerivedTranscriptDom {
268
+ export function renderDerivedTranscript(
269
+ host: ElementLike,
270
+ doc: DocumentLike,
271
+ data: TranscriptDataReport,
272
+ options: RenderDerivedTranscriptOptions = {},
273
+ ): DerivedTranscriptDom {
46
274
  const view = deriveTranscript(data);
47
275
  host.replaceChildren();
48
276
  const root = el(doc, "div", "cockpit-transcript-derived");
@@ -51,6 +279,7 @@ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, da
51
279
  root.setAttribute("data-turn-count", String(view.turns.length));
52
280
  root.setAttribute("data-message-count", String(view.messages.length));
53
281
  root.setAttribute("data-tool-count", String(view.tools.length));
282
+ root.setAttribute("data-permission-count", String(view.permissions.length));
54
283
 
55
284
  if (view.turns.length === 0) {
56
285
  const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
@@ -70,11 +299,10 @@ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, da
70
299
  section.appendChild(row);
71
300
  }
72
301
  for (const tool of turn.tools) {
73
- const card = el(doc, "div", "cockpit-transcript-tool", tool.name);
74
- card.setAttribute("data-tool", tool.name);
75
- card.setAttribute("data-offset", String(tool.offset));
76
- card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
77
- section.appendChild(card);
302
+ section.appendChild(renderTool(doc, tool));
303
+ }
304
+ for (const perm of turn.permissions) {
305
+ section.appendChild(renderPermission(doc, perm, options));
78
306
  }
79
307
  root.appendChild(section);
80
308
  }
@@ -275,6 +275,14 @@ test("core vocab: malformed permission envelopes fall back to raw stream-chunk",
275
275
  }).kind,
276
276
  "stream-chunk",
277
277
  );
278
+ // resolution with a present-but-NON-STRING `by` (e.g. 123) — rejected, not accepted with `by` dropped
279
+ assertEquals(
280
+ parseTranscriptEvent({
281
+ offset: 0,
282
+ chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: 123 }),
283
+ }).kind,
284
+ "stream-chunk",
285
+ );
278
286
  // missing callId
279
287
  assertEquals(
280
288
  parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "request", policy: "escalate", options: REQUEST_OPTIONS }) }).kind,
@@ -346,7 +346,10 @@ export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
346
346
  if (typeof body.allowed !== "boolean") return undefined;
347
347
  const by = str(body, "by");
348
348
  // Reject a malformed `by` rather than silently dropping it: a present-but-unknown provenance is a
349
- // producer bug, and swallowing it would make the typed event diverge from the on-wire JSON.
349
+ // producer bug, and swallowing it would make the typed event diverge from the on-wire JSON. This
350
+ // covers BOTH a present-but-non-string `by` (e.g. `by: 123`, where str() coerces to undefined) and
351
+ // a string that isn't a known provenance — either way the on-wire `by` is present but invalid.
352
+ if (body.by !== undefined && by === undefined) return undefined;
350
353
  if (by !== undefined && by !== "operator" && by !== "auto") return undefined;
351
354
  const event: PermissionResolutionEvent = {
352
355
  kind: "permission",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.146.0",
3
+ "version": "0.147.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",