@luckydraw/cumulus 1.0.0 → 1.0.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.
@@ -409,6 +409,19 @@
409
409
  '.blex-block-container[data-blex-ready="true"] {',
410
410
  ' min-height: auto;',
411
411
  '}',
412
+ /* Raw-fence fallback: shown when nothing claims the container (user message,
413
+ changelog, or blex.min.js failed to load). Better a visible fence than the
414
+ blank rectangle min-height used to produce. */
415
+ '.blex-fallback {',
416
+ ' margin: 0; padding: 0.6em 0.8em;',
417
+ ' background: rgba(255,255,255,0.04);',
418
+ ' border: 1px solid rgba(255,255,255,0.08);',
419
+ ' border-radius: 8px;',
420
+ ' font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;',
421
+ ' font-size: 0.82em; line-height: 1.45;',
422
+ ' white-space: pre-wrap; word-break: break-word;',
423
+ ' opacity: 0.85;',
424
+ '}',
412
425
  /* Blex CSS isolation — revert widget resets inside blex blocks */
413
426
  '.blex-block-container * { margin: revert; padding: revert; line-height: revert; list-style: revert; border-spacing: revert; border: revert; }',
414
427
  '.blex-block-container { isolation: isolate; }',
@@ -2037,8 +2050,8 @@
2037
2050
 
2038
2051
  // ── Blex Integration ─────────────────────────────────────────────────────
2039
2052
 
2040
- // WeakMap: DOM element -> BlockHandle[] for lifecycle management
2041
- var blexHandles = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
2053
+ // Handle lifecycle (element -> BlockHandle[]) lives in blex-render.js — one
2054
+ // owner, so destroy() cannot miss handles the other side created.
2042
2055
 
2043
2056
  // Per-thread store for blex interaction values (poll answers, confirm clicks, etc.)
2044
2057
  // Key: "threadName" → Map<"msgTimestamp:blockIdx", interactionValue>
@@ -2189,136 +2202,99 @@
2189
2202
  });
2190
2203
  }
2191
2204
 
2192
- // Extract ~~~blex:TYPE\n{json}\n~~~ fences from text
2193
- // Returns { text: string (with placeholders), blocks: Array<{type, json, idx}> }
2205
+ // ── Shared renderer seam (task 131) ────────────────────────────────────────
2206
+ //
2207
+ // Extraction, placeholder markup, block rendering and handle lifecycle live in
2208
+ // /blex-render.js — ONE implementation shared with the web-app-agent kit and
2209
+ // any embedding app. chat.html loads it before this file.
2210
+ //
2211
+ // The EMBEDDED widget (a bare <script src="/widget.js"> on a third-party page)
2212
+ // loads neither blex.min.js nor blex-render.js, and never has — blex has only
2213
+ // ever worked on /chat. There, fences degrade to literal text, which is what
2214
+ // an author who typed one would expect to see.
2215
+ function blexCore() {
2216
+ return typeof CumulusBlexRender !== 'undefined' ? CumulusBlexRender : null;
2217
+ }
2218
+
2219
+ // Fenced-code regex, sourced from the shared module so the blex extractor and
2220
+ // Phase 1 of renderMarkdown cannot disagree about where a code block starts —
2221
+ // a fence falling through that gap is exactly the bug this seam closes.
2222
+ function codeFenceRe() {
2223
+ var core = blexCore();
2224
+ return new RegExp(core ? core.CODE_FENCE_SRC : '```([^\\n`]*)\\n([\\s\\S]*?)```', 'g');
2225
+ }
2226
+
2194
2227
  function extractBlexBlocks(text) {
2195
- var blocks = [];
2196
- var BLEX_PREFIX = '\x00BLEX_';
2197
- var replaced = text.replace(/~~~blex:(\w[\w-]*)\n([\s\S]*?)\n~~~/g, function (_, type, json) {
2198
- var idx = blocks.length;
2199
- blocks.push({ type: type, json: json.trim(), idx: idx });
2200
- return BLEX_PREFIX + idx + '\x00';
2201
- });
2202
- return { text: replaced, blocks: blocks };
2228
+ var core = blexCore();
2229
+ return core ? core.extractBlocks(text) : { text: text, blocks: [] };
2203
2230
  }
2204
2231
 
2205
- // Render blex blocks into placeholder divs within a message element
2206
- function renderBlexBlocks(el, blexBlocks, isStreaming, msgKey, threadName) {
2207
- if (!blexBlocks || blexBlocks.length === 0) return;
2208
- if (typeof Blex === 'undefined') {
2209
- console.warn('[blex] Blex library not loaded blex.min.js may have failed to fetch');
2210
- return;
2211
- }
2212
- var placeholders = el.querySelectorAll('.blex-block-container');
2213
- // Get the interaction store for this thread
2232
+ // The widget's adapter over the shared renderer. It supplies all five members;
2233
+ // a render-only surface (the web-app-agent kit's panel) supplies the three
2234
+ // required ones and lets addChip/persist default to no-ops.
2235
+ //
2236
+ // `el` is captured because it may be DETACHED by the time an interaction fires
2237
+ // — renderPanelMessages rebuilds the message list — which is why the panel
2238
+ // lookup falls back to a document query.
2239
+ function widgetBlexAdapter(el, threadName) {
2214
2240
  var store = threadName
2215
2241
  ? blexInteractionStore[threadName] || (blexInteractionStore[threadName] = {})
2216
2242
  : null;
2217
2243
 
2218
- placeholders.forEach(function (container) {
2219
- var idx = parseInt(container.getAttribute('data-blex-idx'), 10);
2220
- if (isNaN(idx) || idx >= blexBlocks.length) return;
2221
- var block = blexBlocks[idx];
2222
- var storeKey = msgKey ? msgKey + ':' + idx : null;
2223
-
2224
- try {
2225
- var data = JSON.parse(block.json);
2226
- var blockObj = { type: block.type, id: block.type + '-' + idx, data: data };
2227
-
2228
- if (isStreaming) {
2229
- // During streaming, show placeholder skeleton
2230
- Blex.renderPlaceholder(block.type, container);
2231
- } else {
2232
- // Check for previously stored interaction value
2233
- var renderOpts = {};
2234
- if (store && storeKey && store[storeKey]) {
2235
- renderOpts.previousValue = store[storeKey];
2236
- }
2244
+ function panelFor() {
2245
+ return (
2246
+ el.closest('.cumulus-thread-panel') ||
2247
+ el.closest('.cumulus-panel') ||
2248
+ document.querySelector('.cumulus-thread-panel') ||
2249
+ document.querySelector('.cumulus-panel')
2250
+ );
2251
+ }
2237
2252
 
2238
- // Final render — full interactive block
2239
- Blex.renderBlock(blockObj, container, renderOpts)
2240
- .then(function (handle) {
2241
- // Store handle immediately (inside async callback, not after sync loop)
2242
- if (blexHandles) {
2243
- var existing = blexHandles.get(el) || [];
2244
- existing.push(handle);
2245
- blexHandles.set(el, existing);
2246
- }
2247
- // Attach resize handle (drag to resize, double-click to reset)
2248
- attachBlexResize(container, threadName, storeKey);
2249
- if (handle && handle.onInteraction) {
2250
- // Wire interaction handler
2251
- handle.onInteraction(function (interaction) {
2252
- // Store the interaction value for persistence across re-renders + refreshes
2253
- if (store && storeKey && interaction.value !== undefined) {
2254
- store[storeKey] = interaction.value;
2255
- _flushBlexStore();
2256
- }
2253
+ return {
2254
+ // The widget is the interactive surface: every type is allowed here.
2255
+ allowType: function () {
2256
+ return true;
2257
+ },
2258
+ getInput: function () {
2259
+ var panel = panelFor();
2260
+ return panel
2261
+ ? panel.querySelector('[data-testid="webchat-input"]')
2262
+ : document.querySelector('[data-testid="webchat-input"]');
2263
+ },
2264
+ getSendButton: function () {
2265
+ var panel = panelFor();
2266
+ return panel ? panel.querySelector('[data-testid*="send"]') : null;
2267
+ },
2268
+ addChip: function (interaction, handle, sourceId) {
2269
+ addInteractionChip(panelFor(), interaction, handle, sourceId);
2270
+ },
2271
+ readPersisted: function (storeKey) {
2272
+ if (!store || !storeKey) return undefined;
2273
+ return store[storeKey] || undefined;
2274
+ },
2275
+ persist: function (storeKey, value) {
2276
+ if (!store || !storeKey) return;
2277
+ store[storeKey] = value;
2278
+ _flushBlexStore();
2279
+ },
2280
+ onRendered: function (container, storeKey) {
2281
+ attachBlexResize(container, threadName, storeKey);
2282
+ },
2283
+ };
2284
+ }
2257
2285
 
2258
- // Find the panel — try el.closest first, fall back to document query
2259
- // (el may be detached from DOM if renderPanelMessages rebuilt the message list)
2260
- var panel = el.closest('.cumulus-thread-panel') || el.closest('.cumulus-panel');
2261
- if (!panel) {
2262
- panel =
2263
- document.querySelector('.cumulus-thread-panel') ||
2264
- document.querySelector('.cumulus-panel');
2265
- }
2266
- var inputEl = panel
2267
- ? panel.querySelector('[data-testid="webchat-input"]')
2268
- : document.querySelector('[data-testid="webchat-input"]');
2269
-
2270
- if (inputEl && interaction.serialized) {
2271
- if (interaction.immediate) {
2272
- // Immediate: prepend any existing input text, then auto-send
2273
- var existingText = inputEl.value.trim();
2274
- inputEl.value = existingText
2275
- ? existingText + '\n\n' + interaction.serialized
2276
- : interaction.serialized;
2277
- inputEl.dispatchEvent(new Event('input', { bubbles: true }));
2278
- // Find and click the send button
2279
- var sendBtn = panel ? panel.querySelector('[data-testid*="send"]') : null;
2280
- if (sendBtn) {
2281
- sendBtn.click();
2282
- }
2283
- } else {
2284
- // Deferred: add to chip tray (sourceId deduplicates rapid-fire from same block)
2285
- addInteractionChip(panel, interaction, handle, block.type + '-' + idx);
2286
- }
2287
- }
2288
- });
2289
- }
2290
- })
2291
- .catch(function (err) {
2292
- container.innerHTML =
2293
- '<pre style="color:#f66;font-size:0.85em;padding:0.5em;">Blex error: ' +
2294
- (err.message || err) +
2295
- '</pre>';
2296
- });
2297
- }
2298
- } catch (e) {
2299
- container.innerHTML =
2300
- '<pre style="color:#f66;font-size:0.85em;padding:0.5em;">Invalid blex JSON: ' +
2301
- e.message +
2302
- '</pre>';
2303
- }
2286
+ function renderBlexBlocks(el, blexBlocks, isStreaming, msgKey, threadName) {
2287
+ var core = blexCore();
2288
+ if (!core) return;
2289
+ core.render(el, blexBlocks, widgetBlexAdapter(el, threadName), {
2290
+ msgKey: msgKey,
2291
+ streaming: isStreaming,
2304
2292
  });
2305
-
2306
- // Note: handles are stored inside the async .then() callback above,
2307
- // not here — by this point the promises haven't resolved yet.
2308
2293
  }
2309
2294
 
2310
- // Destroy blex block handles for a message element
2311
2295
  function destroyBlexBlocks(el) {
2312
- if (!blexHandles) return;
2313
- var handles = blexHandles.get(el);
2314
- if (handles) {
2315
- handles.forEach(function (h) {
2316
- try {
2317
- h.destroy();
2318
- } catch (e) {}
2319
- });
2320
- blexHandles.delete(el);
2321
- }
2296
+ var core = blexCore();
2297
+ if (core) core.destroy(el);
2322
2298
  }
2323
2299
 
2324
2300
  // Add a deferred interaction chip to the chip tray
@@ -2681,7 +2657,12 @@
2681
2657
  return out;
2682
2658
  }
2683
2659
 
2684
- function renderMarkdown(text) {
2660
+ // opts.streaming — suppress the raw-fence fallback body while a turn is still
2661
+ // streaming. A completed-but-not-yet-rendered fence would otherwise flash its
2662
+ // JSON before flipping to the rendered block at `done`.
2663
+ function renderMarkdown(text, opts) {
2664
+ var streaming = !!(opts && opts.streaming);
2665
+
2685
2666
  // Phase 0.5: Extract blex fences BEFORE code blocks
2686
2667
  var blexResult = extractBlexBlocks(text);
2687
2668
  text = blexResult.text;
@@ -2690,7 +2671,7 @@
2690
2671
  // Phase 1: Extract fenced code blocks — replace with unique tokens
2691
2672
  var codeBlocks = [];
2692
2673
  var TOKEN_PREFIX = '\x00CODEBLOCK_';
2693
- var processed = text.replace(/```([^\n`]*)\n([\s\S]*?)```/g, function (_, lang, code) {
2674
+ var processed = text.replace(codeFenceRe(), function (_, lang, code) {
2694
2675
  var idx = codeBlocks.length;
2695
2676
  var langLabel = lang.trim() || 'text';
2696
2677
  var escapedCode = escapeHtml(code.replace(/\n$/, '')); // trim trailing newline
@@ -2767,10 +2748,14 @@
2767
2748
  return codeBlocks[parseInt(idx, 10)];
2768
2749
  });
2769
2750
 
2770
- // Phase 7.5: Reinsert blex block placeholders
2771
- html = html.replace(/\x00BLEX_(\d+)\x00/g, function (_, idx) {
2772
- return '<div class="blex-block-container" data-blex-idx="' + idx + '"></div>';
2773
- });
2751
+ // Phase 7.5: Reinsert blex block placeholders — shared markup, so the widget
2752
+ // and any embedding app produce byte-identical containers and one renderer
2753
+ // can claim either. See blex-render.js for why the placeholder carries the
2754
+ // raw fence as its body.
2755
+ var core = blexCore();
2756
+ if (core) {
2757
+ html = core.insertPlaceholders(html, blexBlocks, { streaming: streaming });
2758
+ }
2774
2759
 
2775
2760
  return { html: html, blexBlocks: blexBlocks };
2776
2761
  }
@@ -3760,7 +3745,7 @@
3760
3745
  el.className = 'cumulus-msg assistant';
3761
3746
  if (isStreaming) el.setAttribute('data-testid', 'webchat-streaming');
3762
3747
  if (content) {
3763
- var mdResult = renderMarkdown(content);
3748
+ var mdResult = renderMarkdown(content, { streaming: isStreaming });
3764
3749
  el.innerHTML = mdResult.html;
3765
3750
  if (isStreaming) {
3766
3751
  el.innerHTML += '<span class="cumulus-cursor"></span>';
@@ -7461,7 +7446,7 @@
7461
7446
  el.className = 'cumulus-msg assistant';
7462
7447
  if (isStreaming) el.setAttribute('data-testid', 'webchat-streaming');
7463
7448
  if (content) {
7464
- var mdResult = renderMarkdown(content);
7449
+ var mdResult = renderMarkdown(content, { streaming: isStreaming });
7465
7450
  el.innerHTML = mdResult.html;
7466
7451
  if (isStreaming) {
7467
7452
  el.innerHTML += '<span class="cumulus-cursor"></span>';
@@ -92,6 +92,8 @@ Default is off; with it off every bridge surface is inert.
92
92
  "command": "node",
93
93
  "args": ["/path/to/myapp/mcp-shim.js"],
94
94
  "env": {
95
+ // 8080 is the fresh-install default — use your gateway's actual
96
+ // top-level "port" here (jq .port ~/.cumulus/gateway.config.json).
95
97
  "GATEWAY_ORIGIN": "http://127.0.0.1:8080",
96
98
  "GATEWAY_API_KEY": "sk-myapp-<same-scoped-key>",
97
99
  "BRIDGE_THREAD": "{thread}",
@@ -109,11 +111,28 @@ Namespace semantics (locked in task 097):
109
111
  - Longest prefix wins, so `myapp-demo` can be its own nested namespace under `myapp` later.
110
112
  - The scoped key is **confined**: it can read/write only `myapp-*` threads, gets an empty list from every enumeration surface (`/api/threads`, `/api/agents`, dashboard), and is rejected (403) everywhere else.
111
113
 
112
- ### 2.3 Create the base thread config
114
+ ### 2.3 Create the thread configs — two of them, and the second is the one people miss
113
115
 
114
- Visitor threads inherit their config from the base by **prefix-fallback** (task 098): a turn on `myapp-a3f8c2d1` that has no exact `myapp-a3f8c2d1.config.json` reads `myapp.config.json` instead. Writes stay exact, so a visitor session can never mutate the base.
116
+ Config is resolved by **prefix-fallback** (task 098): a turn strips one trailing `-segment` at a time and takes the longest match. Writes are always exact, so a visitor session can never mutate a config it inherited.
115
117
 
116
- `~/.cumulus/threads/myapp.config.json` (real Pursuit example):
118
+ Your app has **two** thread configs, because it has two kinds of thread:
119
+
120
+ | File | Applies to | Typical shape |
121
+ | --------------------- | --------------------------------------- | ------------------------- |
122
+ | `myapp.config.json` | your own management thread, `myapp` | strong model, high effort |
123
+ | `myapp-v.config.json` | **every visitor**, `myapp-v-<deviceId>` | small fast model |
124
+
125
+ The `-v` layer is not decoration — it is the seam that lets those two differ. The server hands the browser `THREAD_ID = "myapp-v"` and `device-thread.js` appends the device id, so resolution for `myapp-v-a3f8c2d1` goes:
126
+
127
+ ```
128
+ myapp-v-a3f8c2d1.config.json (none — visitors never get their own)
129
+ myapp-v.config.json <- every visitor turn
130
+ myapp.config.json (only if the -v file is absent)
131
+ ```
132
+
133
+ **Skip the `-v` file and every anonymous visitor runs your management thread's model.** That is the single most expensive omission in this guide, and it is invisible in development: with one tester the bill looks fine.
134
+
135
+ `~/.cumulus/threads/myapp.config.json`:
117
136
 
118
137
  ```json
119
138
  {
@@ -124,9 +143,34 @@ Visitor threads inherit their config from the base by **prefix-fallback** (task
124
143
  }
125
144
  ```
126
145
 
146
+ `~/.cumulus/threads/myapp-v.config.json`:
147
+
148
+ ```json
149
+ {
150
+ "projectDir": "/home/you/projects/myapp",
151
+ "model": "claude",
152
+ "claudeModel": "claude-haiku-4-5",
153
+ "effort": "medium",
154
+ "alwaysInclude": ["docs/myapp-system-prompt.md"],
155
+ "disallowedTools": ["AskUserQuestion"]
156
+ }
157
+ ```
158
+
127
159
  - `projectDir` — the working directory for the agent's turns (where `alwaysInclude` paths resolve).
128
- - `alwaysInclude` — the app's **system prompt document**: what the app is, how to talk to its users, when to use which commands, tone. This is where the agent's product knowledge and persona live.
129
- - `model` / `effort` — per-app quality/latency dial.
160
+ - `alwaysInclude` — the app's **system prompt document**: what the app is, how to talk to its users, when to use which commands, tone. This is where the agent's product knowledge and persona live. Usually the same document for both threads.
161
+ - `model` / `effort` / `claudeModel` the per-thread quality, latency and cost dial. `claudeModel` pins the specific Claude model; leave it out to follow the gateway default.
162
+ - `disallowedTools` — on visitor threads, strip `AskUserQuestion`: there is no operator on the other end, so a turn that asks one hangs.
163
+
164
+ Both files ship as editable examples in the kit (`thread-config.example.json`, `thread-config.visitor.example.json`), with a one-command applier:
165
+
166
+ ```bash
167
+ GATEWAY_ORIGIN=https://gw.example.com GATEWAY_ADMIN_KEY=sk-... \
168
+ node agent/apply-thread-configs.mjs --namespace myapp
169
+ ```
170
+
171
+ Use the **admin** key: a namespace covers `myapp-*`, so the app's scoped key can write `myapp-v` but is refused (403) on the bare `myapp` base thread. Note that the config API applies a whitelist — `projectDir`, `template`, `model`, `effort`, `claudeModel`, `contextLimit` — so `alwaysInclude` and `disallowedTools` must be added to the file on the gateway host. (That is deliberate: `alwaysInclude` plus `projectDir` would let a scoped key read an arbitrary file into its own prompt.) The applier reads each config back and names anything that did not stick, so the gap is visible rather than silent.
172
+
173
+ No gateway reload is needed — thread config is read per turn.
130
174
 
131
175
  ### 2.4 Verify
132
176
 
@@ -191,6 +235,7 @@ The front end then calls `AgentStart()` once the config arrives, and `AgentStop(
191
235
  Notes:
192
236
 
193
237
  - **Yes, the scoped key reaches the browser.** That is the design: it is worthless outside `myapp-*`, can enumerate nothing, and each visitor's thread name is its own secret. Ship only the namespace-scoped key — never an admin key.
238
+ - **Enumeration fails as `200` + empty, not `403`.** `/api/threads` and `/api/agents` answer `200 {"threads":[]}` to a scoped key even when the namespace holds hundreds of threads — a scoped caller already knows the one name it needs, and listing siblings would hand out every other visitor's capability. `403` is reserved for reaching _outside_ the namespace. Worth knowing if your client branches on status.
194
239
  - **Gate it on a session anyway.** Pursuit takes the older route and injects the key into `<head>` at serve time, which puts it in public HTML, in view-source, and in any intermediary cache. It works, and the blast radius is bounded by the namespace — but a session-gated endpoint is strictly better and costs one route. Use it for new apps.
195
240
  - In production, pass the key via a systemd drop-in (`Environment=MYAPP_API_KEY=...`), not a file in the repo.
196
241
  - `THREAD_ID` is the **base name**; the browser appends the per-device suffix (§4.1). Minting the suffix client-side keeps the full thread name — the actual capability — from ever travelling server → client.
@@ -292,7 +337,7 @@ MyAppAgent.register(def) MyAppAgent.call(name, params)
292
337
  MyAppAgent.list() MyAppAgent.manifest() // → [{ name, description, risk, input_schema }]
293
338
  ```
294
339
 
295
- Risk tiers gate what happens without a human in the loop: `read` and `display` run freely, `mutate` runs but is journaled, and **`export` never auto-runs** — the gateway forces a confirm round-trip (§5.3) regardless of caller flags.
340
+ The gate is binary, and only one tier is on the gated side. **`export` never auto-runs** — the gateway forces a confirm round-trip (§5.3) regardless of caller flags. `read`, `display` and `mutate` all dispatch immediately; the tier is advisory, riding in the tool description the model sees (`[mutate] …`) so it can weigh the call, but nothing stops it. So the question when tiering a command is not "is this irreversible?" — it is **"must a human see this before it happens?"** If yes, it is `export`, whatever the verb is.
296
341
 
297
342
  Two commands every app should register (the agent's eyes):
298
343
 
@@ -418,6 +463,54 @@ Delivery is decoupled via an event — `window.dispatchEvent(new CustomEvent('my
418
463
 
419
464
  **The `data-agent-ref` convention** is what makes captured feedback _addressable_: any element rendering a record carries `data-agent-ref="accounts:ACME-01"` (optionally `/field:owner`). Ref collection walks ancestors and contained elements of the selection/target, so "this row is wrong" arrives at the agent with the exact records attached.
420
465
 
466
+ ### 4.6b Rich blocks (`blex-mount.js`, ~110 lines)
467
+
468
+ The gateway instructs **every** thread to emit `~~~blex:TYPE` fences for tabular data, status boards, metrics, charts and diagrams. That rule is in the global includes and `mergeConfigs` is union-only, so a thread cannot opt out of it. The consequence is sharp and easy to miss: **a panel with no blex renderer shows the visitor raw JSON**, and it does so for exactly the content the model was told to present richly. Two independent halves of the system, each correct, never introduced.
469
+
470
+ Serve both halves — `blex.min.js` and `blex-render.js` — **from your own origin, resolved out of the installed cumulus package**, exactly as the kit already does for the bridge client (`server.js` → `/agent/blex/*`). Do not vendor them: `blex-render.js` is cumulus's own renderer, shared with the standalone chat widget, so a copy in your tree forks the seam contract and drifts the first time either side moves.
471
+
472
+ > **Do not `<script src="${GATEWAY_ORIGIN}/blex.min.js">`.** That recipe is correct only when your app is on a _different_ origin from the gateway. The common production shape is the opposite: `GATEWAY_ORIGIN` is your own hostname and an edge (Caddy, Cloudflare) routes just `/bridge*` and `/api/thread/*` through to the gateway. Your hostname has no `/blex.min.js`, so the load 404s and the panel degrades silently to plain text. Serving from your own origin is correct in **both** deployments, which is why the kit does it unconditionally.
473
+
474
+ **Diagrams need one extra tag: an import map.** `~~~blex:mermaid` is the one block type whose renderer loads a library at render time, and it does so with a **bare** module specifier (`await import("mermaid")`). A browser has exactly one mechanism for resolving a bare specifier — an import map — so without it the load fails and blex paints the literal string `Mermaid render error` where the fence used to be. Copy the tag from the kit's `index.html`:
475
+
476
+ ```html
477
+ <script type="importmap">
478
+ { "imports": { "mermaid": "/agent/blex/mermaid-esm.js" } }
479
+ </script>
480
+ ```
481
+
482
+ The library it points at is the vendored mermaid bundle, served out of the installed cumulus package by the same `/agent/blex/*` route as the rest — not vendored into your tree, and not cross-origin. It is fetched only when a mermaid block actually renders (≈1MB gzipped), so pages without diagrams pay nothing.
483
+
484
+ If you leave the tag out, nothing breaks and you get no error box: `blex-render.js` checks whether the document declares the mapping and, if not, leaves the fence as readable text. That check is on the **document**, not on your adapter, so it protects a surface whose adapter allows everything. Diagram colours are derived from the blex card's own background (`--blex-bg`), so they follow your theme automatically; set `window.__CUMULUS_MERMAID_THEME` to a mermaid theme name only to override it.
485
+
486
+ **Correct origin cache headers are not a defence.** Measured in both directions on this project's own edge, and independently reproduced on a second one: the origin answers `/widget.js` with `Cache-Control: no-cache, must-revalidate` and the browser is handed `max-age=14400`. A CDN-class edge rewrites by **file extension**, regardless of what the origin said. So the `?v=` stamp is not belt-and-braces over your headers — behind such an edge it is the _only_ mechanism you have, and a reader who concludes "my origin already sends no-cache" will skip the one thing that would have worked. HTML is the exception (`no-cache`, `cf-cache-status: DYNAMIC`), which is exactly why an HTML-level stamp works at all — and why everything fetched _after_ the HTML is the hole.
487
+
488
+ Three cache traps — the first two measured at a real Cloudflare edge, and each one makes a _correct_ deploy look broken:
489
+
490
+ - **404s are cached too.** If you probe the route before it exists, the edge caches the 404 for its default TTL (measured: `max-age=14400` with `cf-cache-status: HIT`, overriding the origin's `no-cache`) — so a correct deploy keeps serving "no library" for four hours. This is nastier than stale content because it reads as "my route isn't registered", sending you to re-debug working code. After adding a route the edge has already seen 404, purge or cache-bust before concluding anything about the route.
491
+ - **An HTML stamp only reaches what HTML requests.** A `?v=` on a `<script src>` versions that file and nothing the file goes on to fetch by itself — so a loader that pulls its own dependencies at runtime has to propagate the version token, or the parent is versioned and its children are not. The kit's `server.js` stamps every `src`/`href` it can see in the served HTML, and — because `panel.css` and the two blex scripts are attached from _inside_ JavaScript — also fills a `window.__AGENT_ASSET_V` map that those loaders consult via `window.agentAsset(url)`. **Do not lift the token off `document.currentScript.src`.** It is the obvious shortcut and it has already shipped broken in a real adopter: that token describes _your_ build, but the library it stamps comes out of the _installed cumulus package_, so a cumulus upgrade changes the bytes while your build — and therefore the URL — stands still, and browsers keep the old library indefinitely. A server-published map gives each file its **own** hash, so the upgrade busts it even though the loader's own bytes didn't move. If you test this, assert that the library URL's token moves when the **package** file changes, not when your build does; a test that rebuilds the app passes against the broken version. (This is a general rule, not a blex problem: `blex.min.js` fetches nothing by URL. Measured on `@luckydraw/blex@0.1.16` — its only dynamic `import()` is the bare `"mermaid"` specifier above, `Chart.js v4.5.1` is inlined, and `blex-chart.min.js` is an opt-in companion global that nothing requests.)
492
+ - **Import-map values _are_ stampable; static `import` specifiers are not.** The mermaid module URL lives in JSON inside a `<script type="importmap">`, which the server rewrites along with every `src`/`href` — so that library is versioned like any other, and it needs no runtime token map (there is only one file, so there is no loader→dependency edge at all). Static specifiers are the case that stays uncovered: `bridge-mount.js` imports `client.js`, which imports `protocol.js`; both are fetched bare. They ship from the cumulus package and change only on upgrade, and the kit serves them `no-cache, must-revalidate` — which, per the paragraph above, a CDN-class edge will override anyway. Treat these as genuinely unstamped: excluding `/agent/` from your CDN is the fix, not a precaution.
493
+
494
+ (One caveat if you probe with `curl -I`: the gateway answers `HEAD` on static assets with `401` while `GET` returns `200` with `Access-Control-Allow-Origin: *`. Probe with `GET`; the asset is not auth-gated.)
495
+
496
+ Drive it with an adapter. The required trio is `allowType` / `getInput` / `getSendButton`; `addChip` and `persist` are optional, **and leaving them out is what makes a surface render-only** — there is no mode flag to set.
497
+
498
+ ```js
499
+ window.CumulusBlexRender.renderOnlyAdapter({
500
+ getInput: () => document.querySelector('[data-testid="agent-input"]'),
501
+ getSendButton: () => document.querySelector('[data-testid="agent-send"]'),
502
+ });
503
+ ```
504
+
505
+ `renderOnlyAdapter` denies `confirm`, `poll`, `form` and `diff`. The test the allow set is derived from is: _a type may render iff every affordance it draws either completes locally or is purely visual._ Selection in `table`/`code`/`file-tree`/`timeline`/`image` is a highlight, so a no-op is invisible. `terminal`/`svg` draw labelled buttons but the clipboard write and the object-URL download both run **before** the emit — the emit is a receipt, not the mechanism. `diff` fails the test: Apply/Reject have no local half, so on a render-only surface they are guaranteed dead buttons that read as "click to apply" — worse than not rendering, because a dead button looks alive in a way raw JSON does not.
506
+
507
+ Two implementation traps:
508
+
509
+ - **Extract fences before your code-block pass**, not after. Otherwise a fence someone is _showing_ inside a ``` block is eaten by the extractor and vanishes from the block that exists to display it.
510
+ - **Do not implement deny with `unregisterBlockType`.** The registry is module-global, so unregistering `confirm` removes it for every surface sharing that module instance. Deny is a per-render gate. (Nor can you route denials to a library fallback renderer — the global bundle exports no such thing; it exists only on the ESM surface. Leaving the container unclaimed shows the raw fence, which is the honest outcome and costs nothing.)
511
+
512
+ Nothing is lost by not rendering `confirm`: export-tier confirms arrive over the **bridge** as a native, audited chip (§4.3), never through message content.
513
+
421
514
  ### 4.7 Testability (house rules)
422
515
 
423
516
  Per the global standards: every interactive element gets a `data-testid` (`agent-panel-*`, `agent-fb-pop|input|send|caret|queue`), the message list carries `data-loading`/`aria-busy` while streaming, and `window.__PUPPET_TEST_MODE__` sets `data-test-mode` to kill animations.
@@ -483,7 +576,7 @@ Operational corollaries:
483
576
  .then(() => console.log("config OK"), e => { console.error(e.message); process.exit(1); })' "$(npm root -g)"
484
577
  ```
485
578
 
486
- 3. Write `<app>.config.json` (projectDir, model, effort, `alwaysInclude` system-prompt doc) and the system-prompt doc itself.
579
+ 3. Write **both** thread configs — `<app>.config.json` (yours) and `<app>-v.config.json` (every visitor, cheap model) plus the system-prompt doc they include. `node agent/apply-thread-configs.mjs` covers the API-settable fields; §2.3 says which ones it cannot.
487
580
  4. Ask the gateway admin to reload the gateway (if needed), then run the §2.4 probes.
488
581
 
489
582
  **App backend:** 5. Serve `__AGENT_CONFIG__` from a session-gated route (key from env, not the repo) — §3.1. 6. Copy `examples/web-app-agent/agent/mcp-shim.js`; point its env at your gateway + key; wire it in `extraMcpServers` with `BRIDGE_THREAD: "{thread}"`.
@@ -502,19 +595,22 @@ development and will hit on your first real day.
502
595
 
503
596
  Every path below exists in the shipped package — this is the runnable kit, not a description of someone else's repo.
504
597
 
505
- | Layer | File | Role |
506
- | --------- | ---------------------------------------------------- | --------------------------------------------------------- |
507
- | Gateway | `~/.cumulus/gateway.config.json` | namespace, scoped key, `executorProxy`, `extraMcpServers` |
508
- | Gateway | `examples/web-app-agent/gateway.config.example.json` | the fragment to merge into it |
509
- | Gateway | `~/.cumulus/threads/myapp-v.config.json` | base config visitor threads inherit (model, prompt, cwd) |
510
- | Gateway | `dist/gateway/bridge/{protocol,gateway,client}.js` | the bridge itself cumulus-owned, never forked |
511
- | Serving | `examples/web-app-agent/server.js` | session-gated `/api/agent-config` |
512
- | Shim | `examples/web-app-agent/agent/mcp-shim.js` | manifest MCP tools; calls → `/bridge/call` |
513
- | Front end | `public/agent/device-thread.js` | per-visitor thread identity (16-hex) |
514
- | Front end | `public/agent/commands.js` | **the command registry — the file you write** |
515
- | Front end | `public/agent/bridge-mount.js` | wires the cumulus browser client to your registry |
516
- | Front end | `public/agent/chat-client.js` | SSE chat against `/api/thread/:name/message` |
517
- | Front end | `public/agent/panel.js`, `panel.css` | chat window + home bar |
518
- | Front end | `public/app.js` (`window.HostApp`) | the adapter commands act through never the DOM |
598
+ | Layer | File | Role |
599
+ | --------- | ------------------------------------------------------- | --------------------------------------------------------- |
600
+ | Gateway | `~/.cumulus/gateway.config.json` | namespace, scoped key, `executorProxy`, `extraMcpServers` |
601
+ | Gateway | `examples/web-app-agent/gateway.config.example.json` | the fragment to merge into it |
602
+ | Gateway | `~/.cumulus/threads/myapp.config.json` | YOUR management thread: strong model |
603
+ | Gateway | `~/.cumulus/threads/myapp-v.config.json` | EVERY visitor turn: cheap model, prompt, cwd (§2.3) |
604
+ | Gateway | `examples/web-app-agent/thread-config*.example.json` | both of the above, as editable examples |
605
+ | Gateway | `examples/web-app-agent/agent/apply-thread-configs.mjs` | one-command applier for the API-settable fields |
606
+ | Gateway | `dist/gateway/bridge/{protocol,gateway,client}.js` | the bridge itself — cumulus-owned, never forked |
607
+ | Serving | `examples/web-app-agent/server.js` | session-gated `/api/agent-config` |
608
+ | Shim | `examples/web-app-agent/agent/mcp-shim.js` | manifest MCP tools; calls `/bridge/call` |
609
+ | Front end | `public/agent/device-thread.js` | per-visitor thread identity (16-hex) |
610
+ | Front end | `public/agent/commands.js` | **the command registry the file you write** |
611
+ | Front end | `public/agent/bridge-mount.js` | wires the cumulus browser client to your registry |
612
+ | Front end | `public/agent/chat-client.js` | SSE chat against `/api/thread/:name/message` |
613
+ | Front end | `public/agent/panel.js`, `panel.css` | chat window + home bar |
614
+ | Front end | `public/app.js` (`window.HostApp`) | the adapter commands act through — never the DOM |
519
615
 
520
616
  Two pieces described in this guide are **not** in the starter kit, to keep it small: the selection/right-click feedback composer (§4.6) and a rich markdown renderer with entity chips. Both are additive — add them once the core loop works.