@luckydraw/cumulus 1.0.1 → 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.
@@ -111,11 +111,28 @@ Namespace semantics (locked in task 097):
111
111
  - Longest prefix wins, so `myapp-demo` can be its own nested namespace under `myapp` later.
112
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.
113
113
 
114
- ### 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
115
115
 
116
- 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.
117
117
 
118
- `~/.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`:
119
136
 
120
137
  ```json
121
138
  {
@@ -126,9 +143,34 @@ Visitor threads inherit their config from the base by **prefix-fallback** (task
126
143
  }
127
144
  ```
128
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
+
129
159
  - `projectDir` — the working directory for the agent's turns (where `alwaysInclude` paths resolve).
130
- - `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.
131
- - `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.
132
174
 
133
175
  ### 2.4 Verify
134
176
 
@@ -429,13 +471,25 @@ Serve both halves — `blex.min.js` and `blex-render.js` — **from your own ori
429
471
 
430
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.
431
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
+
432
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.
433
487
 
434
488
  Three cache traps — the first two measured at a real Cloudflare edge, and each one makes a _correct_ deploy look broken:
435
489
 
436
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.
437
- - **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. Measured on `@luckydraw/blex@0.1.16` — its only dynamic `import()` is the bare `"mermaid"` specifier discussed in §4.6b, `Chart.js v4.5.1` is inlined, and `blex-chart.min.js` is an opt-in companion global that nothing requests.)
438
- - **Static ESM `import` specifiers can't be stamped this way.** `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.
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.
439
493
 
440
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.)
441
495
 
@@ -522,7 +576,7 @@ Operational corollaries:
522
576
  .then(() => console.log("config OK"), e => { console.error(e.message); process.exit(1); })' "$(npm root -g)"
523
577
  ```
524
578
 
525
- 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.
526
580
  4. Ask the gateway admin to reload the gateway (if needed), then run the §2.4 probes.
527
581
 
528
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}"`.
@@ -541,19 +595,22 @@ development and will hit on your first real day.
541
595
 
542
596
  Every path below exists in the shipped package — this is the runnable kit, not a description of someone else's repo.
543
597
 
544
- | Layer | File | Role |
545
- | --------- | ---------------------------------------------------- | --------------------------------------------------------- |
546
- | Gateway | `~/.cumulus/gateway.config.json` | namespace, scoped key, `executorProxy`, `extraMcpServers` |
547
- | Gateway | `examples/web-app-agent/gateway.config.example.json` | the fragment to merge into it |
548
- | Gateway | `~/.cumulus/threads/myapp-v.config.json` | base config visitor threads inherit (model, prompt, cwd) |
549
- | Gateway | `dist/gateway/bridge/{protocol,gateway,client}.js` | the bridge itself cumulus-owned, never forked |
550
- | Serving | `examples/web-app-agent/server.js` | session-gated `/api/agent-config` |
551
- | Shim | `examples/web-app-agent/agent/mcp-shim.js` | manifest MCP tools; calls → `/bridge/call` |
552
- | Front end | `public/agent/device-thread.js` | per-visitor thread identity (16-hex) |
553
- | Front end | `public/agent/commands.js` | **the command registry — the file you write** |
554
- | Front end | `public/agent/bridge-mount.js` | wires the cumulus browser client to your registry |
555
- | Front end | `public/agent/chat-client.js` | SSE chat against `/api/thread/:name/message` |
556
- | Front end | `public/agent/panel.js`, `panel.css` | chat window + home bar |
557
- | 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 |
558
615
 
559
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.
@@ -76,6 +76,9 @@ server.js your serving layer's one job: hand the scoped key
76
76
  agent/mcp-shim.js stdio MCP server the gateway spawns per turn;
77
77
  turns your registry into the model's tools
78
78
  gateway.config.example.json the namespace, the scoped key, the shim
79
+ thread-config.example.json YOUR management thread's config
80
+ thread-config.visitor.example.json EVERY visitor's config — the cheap model
81
+ agent/apply-thread-configs.mjs one command to push both to the gateway
79
82
 
80
83
  public/index.html the demo app
81
84
  public/app.js the demo app + window.HostApp (the adapter)
@@ -114,6 +117,23 @@ It loads two scripts from your own origin (`/agent/blex/…`, served out of the
114
117
  installed cumulus package — see above) at mount time, and degrades to plain
115
118
  markdown if either is unavailable. Nothing else in the kit depends on it.
116
119
 
120
+ **Diagrams need one extra tag.** `~~~blex:mermaid` is the only block type whose
121
+ renderer loads a library at render time, using a **bare** module specifier — and
122
+ an import map is the one thing a browser can resolve that with. `index.html`
123
+ carries it; keep it when you copy:
124
+
125
+ ```html
126
+ <script type="importmap">
127
+ { "imports": { "mermaid": "/agent/blex/mermaid-esm.js" } }
128
+ </script>
129
+ ```
130
+
131
+ The vendored mermaid bundle is served by the same `/agent/blex/*` route, and only
132
+ fetched when a diagram actually renders. Drop the tag and nothing breaks — the
133
+ renderer checks the document for the mapping and leaves mermaid fences as
134
+ readable text rather than painting an error box. Diagram colours follow the blex
135
+ card's own background, so they match whatever you theme blex to.
136
+
117
137
  The panel is **render-only**: `confirm`, `poll`, `form` and `diff` are not
118
138
  rendered, because their buttons have no local half here and a dead "Apply"
119
139
  button reads as live in a way raw JSON does not. Denied fences stay visible as
@@ -177,7 +197,7 @@ anything.
177
197
 
178
198
  ---
179
199
 
180
- ## The five things that are easy to get wrong
200
+ ## The six things that are easy to get wrong
181
201
 
182
202
  ### 1. The thread name is the capability
183
203
 
@@ -240,14 +260,53 @@ capability. Reads outside the namespace are the ones that `403`. Don't write a
240
260
  client that reads `200` as "I'm allowed to enumerate, there just isn't anything
241
261
  here" — it will never see a thread, and no error will tell it why.
242
262
 
243
- ### 3. Commands act through an adapter, not the DOM
263
+ ### 3. Visitor threads need their OWN model config
264
+
265
+ Your app has two kinds of thread and they should not run the same model:
266
+
267
+ | File | Applies to |
268
+ | ----------------------- | -------------------------------------- |
269
+ | `demoapp.config.json` | your management thread — strong model |
270
+ | `demoapp-v.config.json` | **every visitor** — small, fast, cheap |
271
+
272
+ The `-v` is what makes that possible. `server.js` sends `THREAD_ID: 'demoapp-v'`
273
+ and `device-thread.js` appends the device id, so a visitor lands on
274
+ `demoapp-v-<deviceId>`. Config resolves by stripping trailing segments and taking
275
+ the longest match:
276
+
277
+ ```
278
+ demoapp-v-a3f8c2d1.config.json (none — visitors never get their own)
279
+ demoapp-v.config.json <- every visitor turn
280
+ demoapp.config.json (only if the -v file is absent)
281
+ ```
282
+
283
+ **Omit the `-v` file and every anonymous visitor runs your management thread's
284
+ model.** It is invisible in development — with one tester the bill looks fine —
285
+ and it is the most expensive mistake in this kit.
286
+
287
+ Edit the two `thread-config*.example.json` files and push them:
288
+
289
+ ```bash
290
+ GATEWAY_ORIGIN=http://127.0.0.1:8080 GATEWAY_ADMIN_KEY=sk-... \
291
+ node agent/apply-thread-configs.mjs --namespace demoapp
292
+ ```
293
+
294
+ The **admin** key, not the app's: a namespace covers `demoapp-*`, so the scoped
295
+ key can write `demoapp-v` but is refused on the bare `demoapp`. The config API
296
+ takes `projectDir`, `template`, `model`, `effort`, `claudeModel` and
297
+ `contextLimit`; `alwaysInclude` and `disallowedTools` have to be added to the file
298
+ on the gateway host (that is deliberate — `alwaysInclude` plus `projectDir` would
299
+ let a public scoped key read any file into its own prompt). The applier reads each
300
+ config back and tells you exactly what didn't stick. No reload needed.
301
+
302
+ ### 4. Commands act through an adapter, not the DOM
244
303
 
245
304
  `window.HostApp` is the app's own actions exposed as functions. Commands call
246
305
  those. That's why validation, persistence, and re-render work identically
247
306
  whether a human or the model is driving — and why your commands survive a UI
248
307
  rewrite.
249
308
 
250
- ### 4. Descriptions are the interface
309
+ ### 5. Descriptions are the interface
251
310
 
252
311
  The model decides what to call based entirely on the `description` string.
253
312
  Write for a reader who cannot see your UI, and say what a command is _for_, not
@@ -262,7 +321,7 @@ description: 'Set the on-screen filter so the human sees a subset. ' +
262
321
 
263
322
  The second one prevents a whole class of annoying behaviour.
264
323
 
265
- ### 5. Pick the risk tier honestly
324
+ ### 6. Pick the risk tier honestly
266
325
 
267
326
  | tier | meaning | gated? |
268
327
  | --------- | ----------------------------------------------- | ---------------------- |
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /* Apply this app's two thread configs to the gateway — the base thread you work
3
+ in, and the '-v' sub-namespace every visitor turn inherits.
4
+ *
5
+ * WHY A SCRIPT
6
+ * The visitor config is the one piece of per-app setup with no UI and no obvious
7
+ * home: it lives in a file on the gateway host, its name encodes a namespace rule
8
+ * (see thread-config.visitor.example.json), and getting it wrong silently costs
9
+ * money — every anonymous visitor runs whatever model your management thread uses.
10
+ * One command, run once per app, beats a paragraph telling you to hand-write JSON
11
+ * in a directory you may not have shell access to.
12
+ *
13
+ * GATEWAY_ORIGIN=http://127.0.0.1:8080 \
14
+ * GATEWAY_ADMIN_KEY=sk-... \
15
+ * node agent/apply-thread-configs.mjs [--namespace demoapp] [--dry-run]
16
+ *
17
+ * GATEWAY_ADMIN_KEY, not the app's scoped key: a namespace covers '<ns>-*' only,
18
+ * so a scoped key can write '<ns>-v' but is refused (403) on the bare '<ns>' base
19
+ * thread. That asymmetry is the P7 access model working, not a bug — this is an
20
+ * operator action.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+
28
+ const ORIGIN = process.env.GATEWAY_ORIGIN;
29
+ const KEY = process.env.GATEWAY_ADMIN_KEY;
30
+
31
+ /* Near-miss names fail loudly rather than falling back to a default that might be
32
+ somebody's real gateway — the same rule server.js applies. */
33
+ const ALIASES = {
34
+ GATEWAY_URL: 'GATEWAY_ORIGIN',
35
+ AGENT_GATEWAY_ORIGIN: 'GATEWAY_ORIGIN',
36
+ GATEWAY_KEY: 'GATEWAY_ADMIN_KEY',
37
+ ADMIN_KEY: 'GATEWAY_ADMIN_KEY',
38
+ GATEWAY_API_KEY: 'GATEWAY_ADMIN_KEY (the scoped app key cannot write the base thread)',
39
+ };
40
+
41
+ function die(msg) {
42
+ console.error(`\n ${msg}\n`);
43
+ process.exit(1);
44
+ }
45
+
46
+ for (const [wrong, right] of Object.entries(ALIASES)) {
47
+ if (process.env[wrong] && !(wrong === 'GATEWAY_API_KEY' && KEY)) {
48
+ die(`${wrong} is set but this script reads ${right}.`);
49
+ }
50
+ }
51
+ if (!ORIGIN) die('GATEWAY_ORIGIN is required (no default — it must not guess a live gateway).');
52
+ if (!KEY) die('GATEWAY_ADMIN_KEY is required.');
53
+
54
+ const args = process.argv.slice(2);
55
+ const dryRun = args.includes('--dry-run');
56
+ const nsFlag = args.indexOf('--namespace');
57
+ const namespace = nsFlag !== -1 ? args[nsFlag + 1] : 'demoapp';
58
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(namespace)) die(`invalid --namespace: ${namespace}`);
59
+
60
+ /** Read an example config and drop the `_`-prefixed annotation keys. */
61
+ function load(file) {
62
+ const full = path.join(ROOT, file);
63
+ if (!fs.existsSync(full)) die(`missing ${file}`);
64
+ const parsed = JSON.parse(fs.readFileSync(full, 'utf-8'));
65
+ return Object.fromEntries(Object.entries(parsed).filter(([k]) => !k.startsWith('_')));
66
+ }
67
+
68
+ const targets = [
69
+ { thread: namespace, file: 'thread-config.example.json' },
70
+ { thread: `${namespace}-v`, file: 'thread-config.visitor.example.json' },
71
+ ];
72
+
73
+ let failed = false;
74
+ for (const { thread, file } of targets) {
75
+ const config = load(file);
76
+ if (String(config.projectDir || '').startsWith('/absolute/path/')) {
77
+ die(`${file} still has the placeholder projectDir — edit it before applying.`);
78
+ }
79
+ const label = `${thread.padEnd(24)} <- ${file}`;
80
+ if (dryRun) {
81
+ console.log(`DRY RUN ${label}\n ${JSON.stringify(config)}`);
82
+ continue;
83
+ }
84
+ let res;
85
+ try {
86
+ res = await fetch(`${ORIGIN}/api/thread/${encodeURIComponent(thread)}/config`, {
87
+ method: 'PUT',
88
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': KEY },
89
+ body: JSON.stringify(config),
90
+ });
91
+ } catch (err) {
92
+ die(`cannot reach ${ORIGIN}: ${err.message}`);
93
+ }
94
+ if (res.ok) {
95
+ console.log(`OK ${label}`);
96
+ /* The config API applies a whitelist (projectDir, template, model, effort,
97
+ claudeModel, contextLimit). alwaysInclude and disallowedTools are NOT in it,
98
+ deliberately: combined with projectDir, alwaysInclude would let a
99
+ namespace-scoped key — which ships in your public page — read an arbitrary
100
+ file into its own prompt. So rather than assume, read the config back and
101
+ name anything that did not stick. A silent drop here would look like a
102
+ working persona that was never installed. */
103
+ try {
104
+ const check = await fetch(`${ORIGIN}/api/thread/${encodeURIComponent(thread)}/config`, {
105
+ headers: { 'X-API-Key': KEY },
106
+ });
107
+ if (check.ok) {
108
+ const stored = (await check.json()) ?? {};
109
+ const missing = Object.keys(config).filter(k => stored[k] === undefined);
110
+ if (missing.length) {
111
+ console.log(` not applied by the API: ${missing.join(', ')}`);
112
+ console.log(
113
+ ` add them by hand to ~/.cumulus/threads/${thread}.config.json on the`
114
+ );
115
+ console.log(' gateway host — this file is exactly those contents.');
116
+ }
117
+ }
118
+ } catch {
119
+ /* the write succeeded; a failed read-back is not worth failing over */
120
+ }
121
+ } else {
122
+ failed = true;
123
+ const body = await res.text();
124
+ console.error(`FAILED ${label}\n ${res.status} ${body.slice(0, 200)}`);
125
+ if (res.status === 403) {
126
+ console.error(' 403 on the base thread means the key is namespace-scoped.');
127
+ console.error(' Use the gateway admin key for this one-time setup.');
128
+ }
129
+ }
130
+ }
131
+
132
+ if (failed) process.exit(1);
133
+ if (!dryRun) {
134
+ console.log(
135
+ `\nDone. Visitor turns on ${namespace}-v-<deviceId> now read ${namespace}-v.config.json;\n` +
136
+ `your own ${namespace} thread is unaffected. No gateway reload needed —\n` +
137
+ 'thread config is read per turn.'
138
+ );
139
+ }
@@ -4,6 +4,19 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <title>Demo Notes — cumulus web-app agent</title>
7
+ <!-- KEEP THIS if you want ~~~blex:mermaid diagrams to render in the panel.
8
+ blex's mermaid renderer resolves the bare specifier "mermaid", and an import
9
+ map is the only mechanism in a browser that can satisfy one. The library is
10
+ served out of the installed cumulus package by this app's own /agent/blex/
11
+ route (see server.js) — not vendored, not cross-origin. The URL is stamped
12
+ with a content hash at serve time.
13
+ Drop this tag and nothing breaks: blex-render.js checks for the mapping and
14
+ leaves mermaid fences as readable text instead of painting an error box. -->
15
+ <script type="importmap">{"imports":{"mermaid":"/agent/blex/mermaid-esm.js"}}</script>
16
+ <!-- Diagram colours are derived from the blex card's own background (the
17
+ --blex-bg variable), so they follow whatever you theme blex to with no
18
+ declaration here. Set window.__CUMULUS_MERMAID_THEME to a mermaid theme name
19
+ ('default' | 'dark' | 'neutral' | 'forest' | 'base') only to override it. -->
7
20
  <style>
8
21
  /* The six variables the agent panel themes itself from. Define these and
9
22
  panel.css needs no edits. */
@@ -168,9 +168,15 @@ function assetUrlToFile(pathname) {
168
168
  // blex-chart.min.js is an OPT-IN companion global (blex.min.js inlines Chart.js
169
169
  // and never fetches it). Nothing requests it today; it is allowlisted so an
170
170
  // adopter who wants it can add one script tag instead of editing this server.
171
+ // mermaid-esm.js is the vendored mermaid library wrapped as an ES module. It is
172
+ // fetched by the browser resolving the bare specifier "mermaid" through the
173
+ // import map in index.html — never by a script tag — so it must be served from
174
+ // this app's own origin like the rest.
171
175
  if (pathname.startsWith('/agent/blex/')) {
172
176
  const name = path.basename(pathname);
173
- if (!STATIC_DIR || !/^(blex\.min|blex-render|blex-chart\.min)\.js$/.test(name)) return null;
177
+ if (!STATIC_DIR || !/^(blex\.min|blex-render|blex-chart\.min|mermaid-esm)\.js$/.test(name)) {
178
+ return null;
179
+ }
174
180
  return path.join(STATIC_DIR, name);
175
181
  }
176
182
  const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
@@ -191,16 +197,34 @@ const RUNTIME_LOADED = [
191
197
 
192
198
  const ASSET_MAP_MARKER = 'window.__AGENT_ASSET_V = {};';
193
199
 
194
- /** Stamp served HTML: `?v=` on every same-origin .js/.css src/href, plus the
195
- version map for the runtime-loaded set. */
200
+ /** Stamp served HTML: `?v=` on every same-origin .js/.css src/href AND on
201
+ import-map values, plus the version map for the runtime-loaded set.
202
+
203
+ Import maps need their own pass: the module URL lives in JSON inside a
204
+ `<script type="importmap">`, where the src/href rule cannot see it — and an
205
+ unstamped module URL is exactly the multi-hour edge staleness this mechanism
206
+ exists to prevent. Import-map *keys* are bare specifiers, so they cannot match
207
+ a pattern that requires a leading `/`. */
196
208
  function stampHtml(html) {
197
- const stamped = html.replace(
198
- /(\s(?:src|href)=")(\/[^"?#]+\.(?:js|css))(")/g,
199
- (m, pre, url, post) => {
200
- const v = assetVersion(assetUrlToFile(url));
201
- return v ? `${pre}${url}?v=${v}${post}` : m;
202
- }
203
- );
209
+ const stampUrl = url => {
210
+ const v = assetVersion(assetUrlToFile(url));
211
+ return v ? `${url}?v=${v}` : undefined;
212
+ };
213
+ const stamped = html
214
+ .replace(/(\s(?:src|href)=")(\/[^"?#]+\.(?:js|css))(")/g, (m, pre, url, post) => {
215
+ const s = stampUrl(url);
216
+ return s ? `${pre}${s}${post}` : m;
217
+ })
218
+ .replace(
219
+ /(<script[^>]*type="importmap"[^>]*>)([\s\S]*?)(<\/script>)/gi,
220
+ (m, open, body, close) =>
221
+ open +
222
+ body.replace(/"(\/[^"?#]+\.m?js)"/g, (inner, url) => {
223
+ const s = stampUrl(url);
224
+ return s ? `"${s}"` : inner;
225
+ }) +
226
+ close
227
+ );
204
228
  const map = {};
205
229
  for (const url of RUNTIME_LOADED) {
206
230
  const v = assetVersion(assetUrlToFile(url));
@@ -0,0 +1,22 @@
1
+ {
2
+ "_readme": [
3
+ "BASE THREAD CONFIG -> ~/.cumulus/threads/demoapp.config.json",
4
+ "",
5
+ "This is YOUR management thread for the app — the one you talk to while",
6
+ "building it. It is NOT a visitor thread: a namespace covers '<ns>-*' only, so",
7
+ "the bare 'demoapp' name stays owned by your admin key and the app's scoped key",
8
+ "is rejected (403) on it.",
9
+ "",
10
+ "Give this one a strong model. Visitor turns read the sibling file",
11
+ "thread-config.visitor.example.json instead — see the '_readme' in there for how",
12
+ "the two are kept apart.",
13
+ "",
14
+ "Apply both with: node agent/apply-thread-configs.mjs",
15
+ "Keys starting with '_' are annotations and are stripped before sending."
16
+ ],
17
+
18
+ "projectDir": "/absolute/path/to/your/app",
19
+ "model": "claude",
20
+ "effort": "high",
21
+ "alwaysInclude": ["docs/demoapp-system-prompt.md"]
22
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "_readme": [
3
+ "VISITOR THREAD CONFIG -> ~/.cumulus/threads/demoapp-v.config.json",
4
+ "",
5
+ "WHY THIS FILE EXISTS, AND WHY THE '-v' MATTERS",
6
+ "The server hands the browser THREAD_ID = 'demoapp-v' and device-thread.js",
7
+ "appends 16 hex characters, so every visitor gets 'demoapp-v-<deviceId>'.",
8
+ "Config is resolved by prefix-fallback: a turn on 'demoapp-v-a3f8c2d1' looks for",
9
+ "its own exact file, then strips one trailing '-segment' at a time and takes the",
10
+ "longest match:",
11
+ "",
12
+ " demoapp-v-a3f8c2d1.config.json (none — visitors never get their own)",
13
+ " demoapp-v.config.json <- THIS FILE. Every visitor turn.",
14
+ " demoapp.config.json (only if this file is absent)",
15
+ "",
16
+ "That middle layer is the whole point. Without it, visitor turns inherit your",
17
+ "management thread's config and run your expensive model for every anonymous",
18
+ "visitor. With it, the two are set independently and neither can affect the",
19
+ "other — writes are always exact, so a visitor session can never mutate this",
20
+ "file or the base.",
21
+ "",
22
+ "COST IS THE MAIN DIAL. Visitor traffic is unbounded and mostly shallow, so a",
23
+ "small fast model is usually right here even when the base thread runs a large",
24
+ "one. Both live gateway apps on this box do exactly that.",
25
+ "",
26
+ "Apply with: node agent/apply-thread-configs.mjs",
27
+ "Keys starting with '_' are annotations and are stripped before sending."
28
+ ],
29
+
30
+ "projectDir": "/absolute/path/to/your/app",
31
+ "model": "claude",
32
+ "claudeModel": "claude-haiku-4-5",
33
+ "effort": "medium",
34
+ "alwaysInclude": ["docs/demoapp-system-prompt.md"],
35
+
36
+ "_disallowedTools": [
37
+ "Visitor-facing threads should not be able to stop and ask the operator a",
38
+ "question — there is nobody on the other end, and the turn would hang. Strip it:"
39
+ ],
40
+ "disallowedTools": ["AskUserQuestion"]
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckydraw/cumulus",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "RLM-based CLI chat wrapper for Claude with external history context management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,7 +37,7 @@
37
37
  "cumulus-gateway": "./dist/gateway/daemon.js"
38
38
  },
39
39
  "scripts": {
40
- "build": "rm -rf dist && tsc && cp -r src/gateway/static dist/gateway/static && cp node_modules/@luckydraw/blex/dist/blex.min.global.js dist/gateway/static/blex.min.js && cp node_modules/@luckydraw/blex/dist/blex-chart.min.global.js dist/gateway/static/blex-chart.min.js",
40
+ "build": "rm -rf dist && tsc && cp -r src/gateway/static dist/gateway/static && cp node_modules/@luckydraw/blex/dist/blex.min.global.js dist/gateway/static/blex.min.js && cp node_modules/@luckydraw/blex/dist/blex-chart.min.global.js dist/gateway/static/blex-chart.min.js && node scripts/build-mermaid-esm.mjs",
41
41
  "dev": "tsc --watch",
42
42
  "lint": "eslint src",
43
43
  "lint:fix": "eslint src --fix",
@@ -88,6 +88,7 @@
88
88
  "husky": "^9.1.7",
89
89
  "ink-testing-library": "^4.0.0",
90
90
  "lint-staged": "^16.2.7",
91
+ "mermaid": "^10.9.8",
91
92
  "prettier": "^3.8.1",
92
93
  "typescript": "^5.9.3",
93
94
  "typescript-eslint": "^8.54.0",