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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +43 -3
- package/README.md +7 -19
- package/dist/daemon/archive-gate.js +11 -0
- package/dist/daemon/server.js +24 -5
- package/dist/export.js +1 -1
- package/dist/markdown-diagnostics.js +1 -1
- package/dist/math-syntax.js +354 -0
- package/dist/shared/membrane-quips.js +77 -0
- package/dist/shared/mo-glyph.js +4 -0
- package/dist/shared/narrative-menu.js +8 -5
- package/dist/shared/view-url.js +64 -2
- package/dist/test/archive-gate.test.js +22 -0
- package/dist/test/markdown-math.test.js +1 -1
- package/dist/test/membrane-quips.test.js +62 -0
- package/dist/test/mo-glyph.test.js +3 -1
- package/dist/test/narrative-menu.test.js +17 -3
- package/dist/test/view-url.test.js +106 -1
- package/docs/daemon-api.md +1 -1
- package/docs/math.md +3 -0
- package/docs/mcp.md +1 -1
- package/docs/releasing.md +6 -4
- package/docs/token-rotation.md +4 -2
- package/docs/view-urls.md +40 -2
- package/docs/web-app.md +1 -1
- package/expo/dist/_expo/static/js/web/{entry-e94247f119fdcd3b424a1764233a41d2.js → entry-dd90cc09dc13952c6fa96ce25b41c64d.js} +29 -28
- package/expo/dist/index.html +1 -1
- package/expo/dist/sw.js +2 -2
- package/package.json +5 -2
package/dist/shared/view-url.js
CHANGED
|
@@ -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
|
-
|
|
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 "
|
|
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
|
package/docs/daemon-api.md
CHANGED
|
@@ -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
|
|
13
|
-
private, so
|
|
14
|
-
release
|
|
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.
|
package/docs/token-rotation.md
CHANGED
|
@@ -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
|
|
212
|
-
|
|
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:
|
|
60
|
-
to the narrative, whether or not a list was
|
|
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** —
|
|
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)
|