brane 0.3.1 → 0.3.3

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.
@@ -91,10 +91,72 @@ export function parseLineSpec(s) {
91
91
  const end = m[2] ? Number(m[2]) : undefined;
92
92
  return end !== undefined && end >= line ? { line, lineEnd: end } : { line };
93
93
  }
94
+ /** Which melded daemon a same-origin link means, named by its home `brane_id`
95
+ * (the registry's dedup key, and no secret), in the link's **fragment**:
96
+ * `#daemon=brane-f66d2b`.
97
+ *
98
+ * A tab's active daemon lives in its own `sessionStorage`, and a link opened in
99
+ * a new tab (⌘-click, middle-click, `target="_blank"`) starts with none: it
100
+ * falls back to this browser's *default* daemon, which on a shared app with
101
+ * several melded need not be the one the link was written from. So in-app
102
+ * hrefs carry this param, and an arriving tab whose slot is still empty
103
+ * adopts it as its active daemon before anything connects (`expo/lib/api.ts`,
104
+ * `adoptDaemonByBraneId`). A tab that already has one ignores it, which is
105
+ * what keeps a reload from undoing a later switch. Plain clicks navigate
106
+ * within the tab and never put it in the URL.
107
+ *
108
+ * It says which daemon, not what you are looking at, so it rides where the
109
+ * connection does (`connectionFragment`): a fragment reaches no server, and
110
+ * the shared host's edge, a daemon's request log and a link unfurler never
111
+ * see it. It is not `brane`, because a daemon hosts several branes and the
112
+ * list already filters by one; this names the daemon. A brane_id this browser
113
+ * has not melded is ignored, and the default applies as before. */
114
+ export const DAEMON_PARAM = "daemon";
94
115
  /** The params that describe *which daemon*, as opposed to what you are looking
95
116
  * at. They are origin-scoped: meaningful to the page that received them, never
96
- * to be carried onward. */
97
- export const CONNECTION_PARAMS = ["host", "token"];
117
+ * to be carried onward. `daemon` is one of them: cleaned on arrival like the
118
+ * token, and dropped by Isolate and Meld, whose token already says. */
119
+ export const CONNECTION_PARAMS = ["host", "token", DAEMON_PARAM];
120
+ /** An in-app href (path, optional query, optional fragment) with `daemon` set
121
+ * to `braneId` in its fragment, replacing any already there and leaving the
122
+ * query, which is the view, untouched. Without a `braneId` the href comes
123
+ * back unchanged, so a caller that does not know its daemon yet still gets a
124
+ * working link. */
125
+ export function withDaemonFragment(href, braneId) {
126
+ if (!braneId)
127
+ return href;
128
+ const hashAt = href.indexOf("#");
129
+ const beforeHash = hashAt === -1 ? href : href.slice(0, hashAt);
130
+ const params = new URLSearchParams(hashAt === -1 ? "" : href.slice(hashAt + 1));
131
+ params.set(DAEMON_PARAM, braneId);
132
+ return `${beforeHash}#${params.toString()}`;
133
+ }
134
+ /** The `daemon` param from a location hash (with or without its `#`), or null
135
+ * when absent or empty. */
136
+ export function daemonFromHash(hash) {
137
+ return new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash).get(DAEMON_PARAM) || null;
138
+ }
139
+ /** The brane_id an in-app link should name: the tab's active entry's, falling
140
+ * back to the first entry when `activeId` is missing or stale, which is what
141
+ * the root layout connects to in that case. Null with nothing melded. */
142
+ export function activeDaemonBraneId(daemons, activeId) {
143
+ return (daemons.find((d) => d.id === activeId) ?? daemons[0])?.brane_id ?? null;
144
+ }
145
+ /** Which registry entry a page arriving with `#daemon=<braneId>` should make
146
+ * this tab's active one, or null to leave things as they are.
147
+ *
148
+ * Only an **empty** slot is filled (`tabActiveId` is the tab's own
149
+ * sessionStorage value, not the cross-tab default it falls back to). A new
150
+ * tab has none; a reloaded, restored or duplicated tab has one and keeps it.
151
+ * That matters because the router puts the initial fragment back on its
152
+ * first URL write, so `#daemon=` can outlive a daemon switched later in the
153
+ * same tab, and a reload must not switch it back. A brane_id this browser
154
+ * has not melded is no instruction at all. */
155
+ export function daemonToAdopt(daemons, tabActiveId, braneId) {
156
+ if (tabActiveId !== null)
157
+ return null;
158
+ return daemons.find((d) => d.brane_id === braneId)?.id ?? null;
159
+ }
98
160
  /** A search string with the connection params removed and everything else left
99
161
  * alone, ready to hand to `history.replaceState`.
100
162
  *
@@ -0,0 +1,22 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { archiveRefusal } from "../daemon/archive-gate.js";
4
+ const ended = { status: "ended", mcpConnected: false, tmuxLive: false };
5
+ describe("archiveRefusal", () => {
6
+ it("allows an ended narrative with nothing running", () => {
7
+ assert.equal(archiveRefusal(ended), null);
8
+ });
9
+ it("refuses a narrative whose status is not ended", () => {
10
+ assert.ok(archiveRefusal({ ...ended, status: "active" }));
11
+ assert.ok(archiveRefusal({ ...ended, status: null }));
12
+ });
13
+ it("refuses an ended row with an agent still on the bridge", () => {
14
+ // The stale row the menu also treats as running: hiding it would hide a
15
+ // live agent.
16
+ assert.match(archiveRefusal({ ...ended, mcpConnected: true }) ?? "", /connected/);
17
+ });
18
+ it("refuses an ended row whose tmux session is still up", () => {
19
+ assert.match(archiveRefusal({ ...ended, tmuxLive: true }) ?? "", /running/);
20
+ });
21
+ });
22
+ //# sourceMappingURL=archive-gate.test.js.map
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { micromark } from "micromark";
4
- import { math } from "micromark-extension-math";
4
+ import { math } from "../math-syntax.js";
5
5
  import { MATH_BLOCK_CSS, prepareMathMarkdown, isMathContent, renderMathHtml } from "../shared/markdown-math.js";
6
6
  import { mathHtmlExtension, katexStyleFor } from "../math.js";
7
7
  import { readFileSync } from "node:fs";
@@ -0,0 +1,62 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { QuipLoop, shuffled } from "../shared/membrane-quips.js";
4
+ // The caption's order: one shuffle per page load, then a fixed loop through
5
+ // it — every line once before any line twice, the same order each time round.
6
+ const list = ["a", "b", "c", "d", "e"];
7
+ /** A seeded generator, so a "random" shuffle is the same on every run. */
8
+ function seeded(seed) {
9
+ return () => {
10
+ seed = (seed * 1103515245 + 12345) % 2 ** 31;
11
+ return seed / 2 ** 31;
12
+ };
13
+ }
14
+ describe("shuffled", () => {
15
+ it("is a permutation, and leaves its input alone", () => {
16
+ const before = [...list];
17
+ const out = shuffled(list, seeded(7));
18
+ assert.deepEqual([...out].sort(), before);
19
+ assert.deepEqual(list, before);
20
+ });
21
+ it("can produce every order", () => {
22
+ const seen = new Set();
23
+ for (let s = 1; s < 2000; s++)
24
+ seen.add(shuffled(list.slice(0, 3), seeded(s)).join(""));
25
+ assert.equal(seen.size, 6);
26
+ });
27
+ it("clamps a rand that returns 1", () => {
28
+ assert.equal(shuffled(list, () => 1).length, list.length);
29
+ });
30
+ });
31
+ describe("QuipLoop", () => {
32
+ it("shows every line once per cycle, and the same cycle each time round", () => {
33
+ const loop = new QuipLoop(list, seeded(3));
34
+ const first = [loop.current()];
35
+ for (let i = 1; i < list.length; i++)
36
+ first.push(loop.next());
37
+ assert.equal(new Set(first).size, list.length);
38
+ const second = [loop.next()];
39
+ for (let i = 1; i < list.length; i++)
40
+ second.push(loop.next());
41
+ assert.deepEqual(second, first);
42
+ });
43
+ it("never shows the same line twice in a row", () => {
44
+ const loop = new QuipLoop(list, seeded(11));
45
+ let prev = loop.current();
46
+ for (let i = 0; i < list.length * 3; i++) {
47
+ const q = loop.next();
48
+ assert.notEqual(q, prev);
49
+ prev = q;
50
+ }
51
+ });
52
+ it("does not move until told to", () => {
53
+ const loop = new QuipLoop(list, seeded(5));
54
+ assert.equal(loop.current(), loop.current());
55
+ });
56
+ it("holds a single line", () => {
57
+ const one = ["solo"];
58
+ const loop = new QuipLoop(one);
59
+ assert.equal(loop.next(), one[0]);
60
+ });
61
+ });
62
+ //# sourceMappingURL=membrane-quips.test.js.map
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { MO_GLYPH_BOX, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
3
+ import { MO_GLYPH_BOX, MO_GLYPH_MARGIN, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
4
4
  test("mo-glyph: closed subpaths, absolute commands only, every point inside the box", () => {
5
5
  assert.match(MO_GLYPH_PATH, /^M/);
6
6
  assert.match(MO_GLYPH_PATH, /Z$/);
@@ -18,5 +18,7 @@ test("mo-glyph: closed subpaths, absolute commands only, every point inside the
18
18
  const mid = MO_GLYPH_BOX / 2;
19
19
  assert.ok(Math.abs(Math.min(...xs) + Math.max(...xs) - 2 * mid) < 2);
20
20
  assert.ok(Math.abs(Math.min(...ys) + Math.max(...ys) - 2 * mid) < 2);
21
+ // The caption under the glyph is spaced by this (MembraneEmpty.web.tsx).
22
+ assert.ok(Math.abs(Math.min(...ys) - MO_GLYPH_MARGIN) < 0.01, `ink top is ${Math.min(...ys)}`);
21
23
  });
22
24
  //# sourceMappingURL=mo-glyph.test.js.map
@@ -69,7 +69,6 @@ describe("narrativeMenuItems", () => {
69
69
  "Recolor",
70
70
  "Twin",
71
71
  "End",
72
- "Archive",
73
72
  ]);
74
73
  });
75
74
  it("swaps End for Revive once the session is over", () => {
@@ -93,8 +92,23 @@ describe("narrativeMenuItems", () => {
93
92
  assert.ok(labels({ role: "absorbed", status: "ended" }).includes("Revive"));
94
93
  });
95
94
  it("flips the archive item both ways", () => {
96
- assert.ok(labels({ archived: false }).includes("Archive"));
97
- assert.ok(labels({ archived: true }).includes("Unarchive"));
95
+ assert.ok(labels({ status: "ended", archived: false }).includes("Archive"));
96
+ assert.ok(labels({ status: "ended", archived: true }).includes("Unarchive"));
97
+ });
98
+ it("offers Archive only once the session has ended", () => {
99
+ const running = labels({ status: "active", mcpConnected: true });
100
+ assert.ok(!running.includes("Archive") && !running.includes("Unarchive"));
101
+ // The same stale-row test as Revive: an ended row with an agent still on
102
+ // the bridge is running, and isn't archivable yet.
103
+ assert.ok(!labels({ status: "ended", mcpConnected: true }).includes("Archive"));
104
+ assert.ok(labels({ status: "ended" }).includes("Archive"));
105
+ });
106
+ it("always offers Unarchive on an archived narrative", () => {
107
+ for (const status of ["active", "ended", null]) {
108
+ for (const mcpConnected of [true, false]) {
109
+ assert.ok(labels({ archived: true, status, mcpConnected }).includes("Unarchive"), `status: ${status}, mcpConnected: ${mcpConnected}`);
110
+ }
111
+ }
98
112
  });
99
113
  it("flips isolate by where the app is served from", () => {
100
114
  assert.ok(labels({ isIsolated: false }).includes("Isolate"));
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
3
+ import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, withDaemonFragment, daemonFromHash, activeDaemonBraneId, daemonToAdopt, } from "../shared/view-url.js";
4
4
  // These pin the URL contract that the Isolate menu item depends on. It opens
5
5
  // the same narrative on the *other* origin — a shared app, or a daemon's own
6
6
  // bundle — and the tab layout and open file live in sessionStorage, which is
@@ -219,6 +219,9 @@ describe("withoutConnectionHash", () => {
219
219
  ["#other=1&token=t", "#other=1"],
220
220
  ["host=h:1&token=t", ""],
221
221
  ["#token=a&token=b&x=1", "#x=1"],
222
+ // `daemon` names which daemon too, so it is consumed like the token.
223
+ ["#daemon=brane-f66d2b", ""],
224
+ ["#host=h:1&daemon=brane-f66d2b&token=t&x=1", "#x=1"],
222
225
  ];
223
226
  for (const [input, expected] of cases) {
224
227
  assert.equal(withoutConnectionHash(input), expected, JSON.stringify(input));
@@ -624,4 +627,106 @@ describe("terminalOnlyHref", () => {
624
627
  assert.equal(parsePinnedTabs(url.searchParams.get("pinned") ?? undefined).size, 0);
625
628
  });
626
629
  });
630
+ describe("withDaemonFragment", () => {
631
+ // A link opened in a new tab arrives with no active daemon in that tab's
632
+ // sessionStorage; the fragment is how it finds the one the link was written
633
+ // from. So the question each case asks: is the daemon named, in the
634
+ // fragment, and is the view the href's query already described left exactly
635
+ // as it was?
636
+ const BRANE = "brane-f66d2b";
637
+ it("names the daemon in the fragment of a bare path", () => {
638
+ assert.equal(withDaemonFragment("/", BRANE), `/#daemon=${BRANE}`);
639
+ assert.equal(withDaemonFragment(`/${NID}`, BRANE), `/${NID}#daemon=${BRANE}`);
640
+ });
641
+ it("leaves the query, the view, exactly as it was", () => {
642
+ const href = `/${NID}?tab=files&path=src%2Fa.ts&line=10-20`;
643
+ const url = new URL(withDaemonFragment(href, BRANE), "https://example.test");
644
+ assert.equal(url.pathname + url.search, href);
645
+ assert.equal(url.searchParams.get("daemon"), null);
646
+ assert.equal(daemonFromHash(url.hash), BRANE);
647
+ });
648
+ it("replaces a daemon already there, and keeps any other fragment param", () => {
649
+ const url = new URL(withDaemonFragment(`/${NID}?scrollToSeq=4#x=1&daemon=old`, BRANE), "https://example.test");
650
+ const frag = new URLSearchParams(url.hash.slice(1));
651
+ assert.deepEqual(frag.getAll("daemon"), [BRANE]);
652
+ assert.equal(frag.get("x"), "1");
653
+ assert.equal(url.searchParams.get("scrollToSeq"), "4");
654
+ });
655
+ it("returns the href unchanged when the daemon is not known", () => {
656
+ for (const none of [null, undefined, ""]) {
657
+ assert.equal(withDaemonFragment(`/${NID}?scrollToSeq=4`, none), `/${NID}?scrollToSeq=4`);
658
+ }
659
+ });
660
+ it("reads back through daemonFromHash, and is what the fragment cleaner removes", () => {
661
+ const url = new URL(withDaemonFragment(`/${NID}?scrollToSeq=4`, BRANE), "https://example.test");
662
+ assert.equal(daemonFromHash(url.hash), BRANE);
663
+ assert.equal(daemonFromHash(url.hash.slice(1)), BRANE);
664
+ assert.equal(withoutConnectionHash(url.hash), "");
665
+ assert.equal(withoutConnectionParams(url.search), "?scrollToSeq=4");
666
+ });
667
+ it("is absent from daemonFromHash when missing or empty", () => {
668
+ assert.equal(daemonFromHash(""), null);
669
+ assert.equal(daemonFromHash("#"), null);
670
+ assert.equal(daemonFromHash("#daemon="), null);
671
+ assert.equal(daemonFromHash("#token=t"), null);
672
+ });
673
+ it("is not carried by Isolate or Meld, whose token already names the daemon", () => {
674
+ // Isolate and Meld seed from the page's search string; a stray `daemon`
675
+ // there (an old link) must not ride along in either half of the URL.
676
+ const opts = {
677
+ host: SHARED_SPA_HOST,
678
+ protocol: "https:",
679
+ search: `?scrollToSeq=4&daemon=${BRANE}`,
680
+ path: `/${NID}`,
681
+ conn: CONN,
682
+ view: viewParams("chat", new Set()),
683
+ };
684
+ for (const built of [buildIsolateUrl(opts), buildMeldUrl(opts)]) {
685
+ const { params, conn } = open(built);
686
+ assert.equal(params.get("daemon"), null, built);
687
+ assert.equal(conn.get("daemon"), null, built);
688
+ assert.equal(params.get("scrollToSeq"), "4", built);
689
+ assert.equal(conn.get("token"), CONN.token, built);
690
+ }
691
+ });
692
+ });
693
+ describe("the daemon a tab names, and the one it adopts", () => {
694
+ // The registry entries these read, as `expo/lib/registry.ts` stores them:
695
+ // `id` is the display key the tab's slot holds, `brane_id` what a link names.
696
+ const CHALK = { id: "ben@chalk", brane_id: "brane-aaaaaa" };
697
+ const SLATE = { id: "ben@slate", brane_id: "brane-bbbbbb" };
698
+ const BOTH = [CHALK, SLATE];
699
+ it("a link names the tab's active daemon", () => {
700
+ assert.equal(activeDaemonBraneId(BOTH, SLATE.id), SLATE.brane_id);
701
+ assert.equal(activeDaemonBraneId(BOTH, CHALK.id), CHALK.brane_id);
702
+ });
703
+ it("falls back to the first entry when the active id is missing or stale, as the layout connects", () => {
704
+ assert.equal(activeDaemonBraneId(BOTH, null), CHALK.brane_id);
705
+ assert.equal(activeDaemonBraneId(BOTH, "ben@gone"), CHALK.brane_id);
706
+ });
707
+ it("names nothing with nothing melded, so the link stays bare", () => {
708
+ assert.equal(activeDaemonBraneId([], null), null);
709
+ assert.equal(withDaemonFragment("/", activeDaemonBraneId([], null)), "/");
710
+ });
711
+ it("a new tab, whose slot is empty, adopts the daemon its link named", () => {
712
+ assert.equal(daemonToAdopt(BOTH, null, SLATE.brane_id), SLATE.id);
713
+ });
714
+ it("a tab that already has a daemon keeps it, so a reload never undoes a switch", () => {
715
+ // The router restores the initial fragment on its first URL write, so a
716
+ // tab opened on SLATE and later switched to CHALK can still say SLATE.
717
+ assert.equal(daemonToAdopt(BOTH, CHALK.id, SLATE.brane_id), null);
718
+ assert.equal(daemonToAdopt(BOTH, SLATE.id, SLATE.brane_id), null);
719
+ });
720
+ it("a daemon this browser has not melded is no instruction at all", () => {
721
+ assert.equal(daemonToAdopt(BOTH, null, "brane-cccccc"), null);
722
+ assert.equal(daemonToAdopt([], null, SLATE.brane_id), null);
723
+ });
724
+ it("round-trips: what one tab names, a fresh tab adopts", () => {
725
+ for (const from of BOTH) {
726
+ const href = withDaemonFragment(`/${NID}`, activeDaemonBraneId(BOTH, from.id));
727
+ const named = daemonFromHash(new URL(href, "https://example.test").hash);
728
+ assert.equal(daemonToAdopt(BOTH, null, named), from.id);
729
+ }
730
+ });
731
+ });
627
732
  //# sourceMappingURL=view-url.test.js.map
@@ -27,7 +27,7 @@ until someone lists it. An agent also may not revive or prompt a
27
27
  | `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`, `app_domain`: the shared app's host, where a daemon's own bundle hops out to on Isolate — see [View URLs](view-urls.md)) |
28
28
  | `GET` | `/api/narratives?archived=1` | List narratives (excludes archived by default) |
29
29
  | `GET` | `/api/narratives/recent?since=&search=&status=&fold=&limit=&archived=1&brane_ids=` | Recently active narratives — the browse half of the `narrative_info` MCP tool; every brane unless `brane_ids` scopes it |
30
- | `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative |
30
+ | `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative; archiving a running one is a 409 (`src/daemon/archive-gate.ts`) |
31
31
  | `GET` | `/api/narratives/:id/events?after=N&before=S&tail=T` | Fetch events with cursor-based query |
32
32
  | `POST` | `/api/narratives/:id/signal` | Send operator signal (supports payloads). `source` is stamped from the token (`web` for a device, `local` for this account); a `source` in the body is ignored, and an agent's token is refused (`src/daemon/signal-source.ts`) |
33
33
  | `GET` | `/api/narratives/:id/export?format=html[&palette=N][&seqs=A,B,C][&seqs_mode=except]` | Conversation as a standalone HTML document; `palette` is the device's 🎨 Recolor salt, so foreign speakers wear the hues the operator sees, `seqs` is the chat's message selection, and `seqs_mode=except` exports its complement — see [Export Scope](export-scope.md) |
package/docs/math.md CHANGED
@@ -7,6 +7,7 @@ LaTeX in narrative markdown renders through **KaTeX**, in the app and in the HTM
7
7
  | `renderMathHtml()` | `src/shared/markdown-math.ts` | The one KaTeX call. `htmlAndMathml`; unparseable LaTeX falls back to the literal source, not KaTeX's red error markup |
8
8
  | `prepareMathMarkdown()` | `src/shared/markdown-math.ts` | Pre-parse normalization, run by both sides before micromark/mdast |
9
9
  | `mathHtmlExtension()`, `katexStyleFor()` | `src/math.ts` | Export only: micromark hookup, and the inlined stylesheet |
10
+ | `math()` | `src/math-syntax.ts` | Server only: the `$` syntax extension, vendored from `micromark-extension-math` (see below) |
10
11
  | `public/katex/` | `expo/` | The app's stylesheet + woff2, vendored by `npm run sync:katex` |
11
12
  | `diagnoseMarkdown()` | `src/markdown-diagnostics.ts` | The same pipeline run to *report* rather than render — see [Rendering Warnings](rendering-warnings.md) |
12
13
 
@@ -29,4 +30,6 @@ Two more things are less obvious than they look:
29
30
 
30
31
  **Fonts don't come along by themselves.** KaTeX lays its span tree out entirely in CSS and needs its woff2 faces; without them math is a pile of overlapping glyphs. The export inlines only the families the document reaches for (~166 KB of base64 for an equation-dense narrative, nothing at all for one with no math), derived from the stylesheet so a KaTeX upgrade can't silently drop a face. The app can't inline per document, so it serves `public/katex/` — vendored the way `pdf.min.mjs` already is, because metro bundles a CSS *import* but never copies the `url(fonts/…)` it points at. The `<link>` lives in **`public/index.html`**, which is the real HTML template; `app/+html.tsx` is kept in step but is not what ships.
31
32
 
33
+ **The server vendors the syntax half of `micromark-extension-math`.** That package's index loads its own `mathHtml()` too, and with it `katex` at the range it pins (`^0.16.0`). Root `overrides` dedupe that onto our katex in a checkout, but npm ignores a dependency's `overrides`, so every `npm install brane` got a second, older katex — never called, since `src/math.ts` is our `mathHtml`, but loaded, and flagged by `npm audit`. `src/math-syntax.ts` is upstream 3.1.0's tokenizers with TypeScript annotations and nothing else changed (80,000 fuzzed parses matched upstream event for event); the package is no longer a root dependency. The app still imports the package: it ships prebuilt, built under `expo/`'s own overrides. If upstream ever changes how it tokenizes, the two parsers can drift, so carry the change into `src/math-syntax.ts` by hand.
34
+
32
35
  `src/shared/markdown-math.ts` is also the first shared module to import an npm package. Metro resolves a module's dependencies from its own directory and `src/shared` sits outside the expo project root, so `metro.config.js` maps `katex` explicitly — the next shared module with a bare import will need the same.
package/docs/mcp.md CHANGED
@@ -66,4 +66,4 @@ Core tools available via MCP:
66
66
 
67
67
  Narratives have persistent `summary` and `title` columns. Agents set them via the `meta` parameter on `narrate`. The daemon broadcasts metadata changes over the control WebSocket as `type: "meta"` messages. The Expo app renders the summary as a subtitle below the narrative ID.
68
68
 
69
- Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database.
69
+ Narratives can be **archived** via `PATCH /api/narratives/:id/archive`. Archived narratives are hidden from the default list but preserved in the database. Only a narrative whose session is over can be archived: the daemon answers 409 while its status isn't `ended`, an agent is on its bridge, or its tmux session is up (`src/daemon/archive-gate.ts`), so archiving can't hide a running agent. Unarchiving is never refused.
package/docs/releasing.md CHANGED
@@ -9,10 +9,12 @@ publishing](https://docs.npmjs.com/trusted-publishers)). **Agents never run
9
9
  `npm publish` is not passed `--provenance`, and needs no flag for it.
10
10
  Trusted publishing attests a build by itself when the repository is public
11
11
  and leaves attestation out when it is private, where `--provenance` would
12
- fail the publish on a 422. 0.3.0 was published while the repository was still
13
- private, so it is the one release with **no provenance attestation**; every
14
- release since the repository went public carries it, with no workflow
15
- change.
12
+ fail the publish on a 422. 0.3.0 and 0.3.1 were published while the
13
+ repository was still private, so they carry **no provenance attestation**;
14
+ every release after the repository goes public will, with no workflow
15
+ change. Check `gh repo view --json visibility` before a release's notes
16
+ say which it is: the 0.3.1 CHANGELOG entry in its tarball claims
17
+ provenance it does not have.
16
18
 
17
19
  The trigger is a **published GitHub release**, not a tag push, so the release
18
20
  notes and the publish are one act and a stray tag cannot publish anything.
@@ -208,8 +208,10 @@ Cline's per-session files) meant no harness's plumbing had to change.
208
208
  nothing. A connected external client (no tmux session) is not adopted,
209
209
  and lands on a fresh `external-*` narrative once, at the upgrade.
210
210
  Adopted agent rows don't trigger the plaintext-token warning.
211
- - **Archiving revokes** a narrative's agent token when its session is not
212
- running. Unarchiving brings nothing back; a revive mints a new one.
211
+ - **Archiving revokes** a narrative's agent token. Only an ended narrative
212
+ can be archived (the daemon answers 409 otherwise; see
213
+ `src/daemon/archive-gate.ts`), so there is never a live session to cut
214
+ off. Unarchiving brings nothing back; a revive mints a new one.
213
215
  - **An external goes with its dialer, always.** An external's token
214
216
  records the token that dialed it (`minted_by`); it is that token's
215
217
  credential in another form, since one `/ws/mcp` dial mints it, so
package/docs/view-urls.md CHANGED
@@ -56,8 +56,19 @@ pins, Files path and line, the `?scrollToSeq=` selection) goes through
56
56
  since the stack did not grow. So the URL keeps that state and Back returns to
57
57
  it, but it never costs an extra Back: browsing ten files is one entry, not ten.
58
58
  A bare link to the narrative you are in, with no seq, goes nowhere. The list
59
- is a jump too: **All narratives** in the switcher pushes it, so Back returns
60
- to the narrative, whether or not a list was already beneath. The header has no
59
+ is a jump too: a swipe down on the narrative's header card dismisses it to
60
+ a pushed list, so Back returns to the narrative, whether or not a list was
61
+ already beneath. **All narratives** in the switcher, in a browser, does not
62
+ navigate this tab at all: it is a link that opens the list in a new one, on
63
+ the same daemon (see "A new tab names its daemon" below). On native, and in
64
+ the web app launched from the home screen, it pushes like the swipe: there is
65
+ no tab to open, and on iOS a `_blank` link from a home-screen app lands in a
66
+ browser view that shares none of its storage, so it would open on the meld
67
+ screen. So does
68
+ **Archive** in the narrative's 🦠 menu, once the daemon has confirmed it (the
69
+ pushed list fetches on mount, and nothing is broadcast to correct a fetch that
70
+ raced the archive); Back returns to the archived narrative. Archiving from the
71
+ list's own menu just drops the row and goes nowhere. The header has no
61
72
  ← of its own; Back is the browser's.
62
73
 
63
74
  Back is instant and complete because the Stack keeps the screens behind the
@@ -132,3 +143,30 @@ come back across an in-app card tap and two tab changes. So the module-level
132
143
  consume is what gets the credentials into storage early, `clearMeldUrl` is
133
144
  what actually clears the address bar, and `clearStoredConnection` still ends
134
145
  the flow.
146
+
147
+ **A new tab names its daemon.** Which melded daemon a tab is on lives in that
148
+ tab's `sessionStorage` (`expo/lib/registry.ts`), and a tab opened from a link
149
+ starts with none: ⌘-click, middle-click, long-press "Open in New Tab" and
150
+ `target="_blank"` are all `noopener`, which gets no copy. It falls back to
151
+ this browser's *default* daemon, which on a shared app with several melded
152
+ need not be the one the link came from. So every in-app anchor names it:
153
+ `inAppLinkProps` (the `@nid`, `nid:seq` and file links in prose, the narrate
154
+ rows in tool groups) and `newTabLinkProps` (**All narratives**) render their
155
+ href with `#daemon=<home brane_id>` (`DAEMON_PARAM`, `withDaemonFragment`),
156
+ while a plain click still navigates in place by the clean path. It goes in the
157
+ fragment because it says which daemon, not what you are looking at: it is a
158
+ connection param (`CONNECTION_PARAMS`), and like the token it never reaches the
159
+ shared host's edge, a daemon's request log or a link unfurler. On arrival,
160
+ `consumeConnectionFromLocation` in `expo/lib/api.ts` hands it to
161
+ `adoptDaemonByBraneId` at module evaluation, before the root layout reads the
162
+ registry, and cleans it off with the token. The rules are pure functions
163
+ beside the param (`activeDaemonBraneId`, `daemonToAdopt`), tested in
164
+ `src/test/view-url.test.ts`; `registry.ts` only reads and writes storage for
165
+ them. Adoption fills only an **empty** slot. The router restores the initial fragment on its first URL write (see
166
+ above), so the param can sit in the address bar after a daemon
167
+ switched later in that tab, and a reload must not switch back; a tab that has
168
+ a daemon, reloaded, restored or duplicated, keeps it. A brane_id this browser
169
+ has not melded is ignored, and the default applies. It is `daemon`, not
170
+ `brane`, because one daemon hosts several branes and the list already filters
171
+ by one. It names a daemon and carries no credential, so it is safe to copy.
172
+ Isolate and Meld never carry it: their token already picks the daemon.
package/docs/web-app.md CHANGED
@@ -13,7 +13,7 @@ The Expo app runs as a web PWA served by the daemon (and optionally via Cloudfla
13
13
  - **Quick bar** — macros (via MacroMenu component) + quick keys
14
14
  - **Full software keyboard** — multitouch with sticky/held modifiers
15
15
  - **Live updates** — control WebSocket pushes `status`, `meta`, and `new_events` hints
16
- - **Archive/unarchive** — swipe or long-press to archive narratives
16
+ - **Archive/unarchive** — from the 🦠 menu, on a list card's status badge or in the narrative's header. Archive is offered only once the session has ended (the same test that swaps End for Revive); Unarchive on any archived narrative. Archiving from the header goes to the list as a jump, so Back returns — see [View URLs](view-urls.md)
17
17
  - **Payload rendering** — inline display of image payloads in chat
18
18
  - **Export** — download a narrative as standalone HTML via `/api/narratives/:id/export?format=html`. Foreign rows carry their speaker's hue inline, from the same `speakerColor` / `speakerBgColor` the chat uses, at the 🎨 Recolor salt the device sends as `&palette=` — the one place the salt leaves the browser. With messages selected, the menu offers both sides of the selection — **those messages only**, or the conversation with **those messages left out** — see [Export Scope](export-scope.md)
19
19
  - **Brane switcher** — dropdown to filter narratives by brane, add/switch daemons. Each brane row (and "All branes") shows its running metered cost as `$2441.72 (138) + 100.2 AIC (1)` — one total per unit with the number of sessions it covers, so a per-session figure can be read off without the line asserting one; the list screen re-reads `/api/branes` (debounced) when a cost `meta` arrives, since the total includes archived narratives the screen never loads. **Devices** lists every token on the active daemon (this device, this machine's CLI and agents, each melded device by browser, platform, address and when it was last seen, and any meld QR not yet scanned) with a Revoke on each other device; **Sign out other devices**, at the foot of that list, rotates instead — see [Token Rotation](token-rotation.md#the-devices-list)