brane 0.0.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +81 -23
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.npmignore +0 -2
- package/.travis.yml +0 -5
- package/index.js +0 -8
- package/index.js~ +0 -12
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, existsSync, writeFileSync, mkdirSync, statSync, chmodSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createTestDb } from "./helpers.js";
|
|
7
|
+
import { ensureClaudeHookConfig, ensureGooseHookConfig, ensureClineHookConfig, ensureHookConfig, } from "../commands/spawn.js";
|
|
8
|
+
const HOOK_PATH = "/api/hooks/tool-use";
|
|
9
|
+
function seedDaemon(db, port, httpPort) {
|
|
10
|
+
const rows = [
|
|
11
|
+
["daemon_port", port],
|
|
12
|
+
["daemon_token", "tok"],
|
|
13
|
+
// Our own pid is alive, so getDaemonInfo's liveness check passes.
|
|
14
|
+
["daemon_pid", String(process.pid)],
|
|
15
|
+
];
|
|
16
|
+
// The http-fallback row only routes hooks when TLS is live (the row
|
|
17
|
+
// survives shutdown as a hint, so presence alone must not be trusted).
|
|
18
|
+
rows.push(["daemon_tls", httpPort ? "true" : "false"]);
|
|
19
|
+
if (httpPort)
|
|
20
|
+
rows.push(["daemon_http_port", httpPort]);
|
|
21
|
+
for (const [key, value] of rows) {
|
|
22
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function readSettings(fold) {
|
|
26
|
+
return JSON.parse(readFileSync(join(fold, ".claude", "settings.local.json"), "utf-8"));
|
|
27
|
+
}
|
|
28
|
+
describe("ensureClaudeHookConfig", () => {
|
|
29
|
+
it("writes a PostToolUse entry pointed at the daemon, capturing every tool", () => {
|
|
30
|
+
const db = createTestDb();
|
|
31
|
+
seedDaemon(db, "4321");
|
|
32
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
33
|
+
ensureClaudeHookConfig(db, fold);
|
|
34
|
+
const entry = readSettings(fold).hooks.PostToolUse[0];
|
|
35
|
+
assert.equal(entry.hooks[0].url, `http://127.0.0.1:4321${HOOK_PATH}`);
|
|
36
|
+
// Nothing is excluded at the hook: what the client shows is a render
|
|
37
|
+
// decision, and a call the hook never saw can't be recovered later.
|
|
38
|
+
const matcher = new RegExp(entry.matcher);
|
|
39
|
+
assert.ok(matcher.test("Bash"), "ordinary tools must match");
|
|
40
|
+
assert.ok(matcher.test("mcp__brane__whoami"), "other brane tools are captured, filtered at render");
|
|
41
|
+
assert.ok(matcher.test("mcp__brane__listen"), "listen is captured — idle time is worth showing");
|
|
42
|
+
assert.ok(matcher.test("mcp__brane__narrate"), "narrate is captured — foreign narration is invisible otherwise");
|
|
43
|
+
});
|
|
44
|
+
// Both ends of every call: PostToolUse can only report a call once it's
|
|
45
|
+
// over, so the chat stays silent while a long tool runs.
|
|
46
|
+
it("registers the same entry on PreToolUse so a call is visible as it starts", () => {
|
|
47
|
+
const db = createTestDb();
|
|
48
|
+
seedDaemon(db, "4321");
|
|
49
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
50
|
+
ensureClaudeHookConfig(db, fold);
|
|
51
|
+
const settings = readSettings(fold);
|
|
52
|
+
assert.deepEqual(settings.hooks.PreToolUse[0], settings.hooks.PostToolUse[0]);
|
|
53
|
+
});
|
|
54
|
+
it("prefers the HTTP fallback port when TLS is active", () => {
|
|
55
|
+
const db = createTestDb();
|
|
56
|
+
seedDaemon(db, "4321", "8888");
|
|
57
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
58
|
+
ensureClaudeHookConfig(db, fold);
|
|
59
|
+
const entry = readSettings(fold).hooks.PostToolUse[0];
|
|
60
|
+
assert.equal(entry.hooks[0].url, `http://127.0.0.1:8888${HOOK_PATH}`);
|
|
61
|
+
});
|
|
62
|
+
it("ignores a surviving daemon_http_port row when TLS is off", () => {
|
|
63
|
+
const db = createTestDb();
|
|
64
|
+
seedDaemon(db, "4321");
|
|
65
|
+
// The row outlives shutdown as a stability hint for the next TLS run —
|
|
66
|
+
// its presence alone must not route hooks to a port nothing listens on.
|
|
67
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_http_port', '9999')").run();
|
|
68
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
69
|
+
ensureClaudeHookConfig(db, fold);
|
|
70
|
+
const entry = readSettings(fold).hooks.PostToolUse[0];
|
|
71
|
+
assert.equal(entry.hooks[0].url, `http://127.0.0.1:4321${HOOK_PATH}`);
|
|
72
|
+
});
|
|
73
|
+
it("is idempotent and heals port drift in place", () => {
|
|
74
|
+
const db = createTestDb();
|
|
75
|
+
seedDaemon(db, "4321");
|
|
76
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
77
|
+
ensureClaudeHookConfig(db, fold);
|
|
78
|
+
ensureClaudeHookConfig(db, fold);
|
|
79
|
+
assert.equal(readSettings(fold).hooks.PostToolUse.length, 1);
|
|
80
|
+
assert.equal(readSettings(fold).hooks.PreToolUse.length, 1);
|
|
81
|
+
seedDaemon(db, "5555");
|
|
82
|
+
ensureClaudeHookConfig(db, fold);
|
|
83
|
+
for (const event of ["PreToolUse", "PostToolUse"]) {
|
|
84
|
+
const entries = readSettings(fold).hooks[event];
|
|
85
|
+
assert.equal(entries.length, 1, `${event}: stale entry is replaced, not duplicated`);
|
|
86
|
+
assert.equal(entries[0].hooks[0].url, `http://127.0.0.1:5555${HOOK_PATH}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it("merges around the user's own settings and hooks instead of clobbering them", () => {
|
|
90
|
+
const db = createTestDb();
|
|
91
|
+
seedDaemon(db, "4321");
|
|
92
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
93
|
+
mkdirSync(join(fold, ".claude"), { recursive: true });
|
|
94
|
+
writeFileSync(join(fold, ".claude", "settings.local.json"), JSON.stringify({
|
|
95
|
+
permissions: { allow: ["Bash(ls:*)"] },
|
|
96
|
+
hooks: { PostToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "echo hi" }] }] },
|
|
97
|
+
}));
|
|
98
|
+
ensureClaudeHookConfig(db, fold);
|
|
99
|
+
const settings = readSettings(fold);
|
|
100
|
+
assert.deepEqual(settings.permissions, { allow: ["Bash(ls:*)"] });
|
|
101
|
+
assert.equal(settings.hooks.PostToolUse.length, 2);
|
|
102
|
+
assert.equal(settings.hooks.PostToolUse[0].matcher, "Edit");
|
|
103
|
+
});
|
|
104
|
+
it("degrades silently when no daemon is running", () => {
|
|
105
|
+
const db = createTestDb();
|
|
106
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
107
|
+
ensureClaudeHookConfig(db, fold);
|
|
108
|
+
assert.ok(!existsSync(join(fold, ".claude", "settings.local.json")));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
// The other harnesses execute commands rather than speaking HTTP themselves,
|
|
112
|
+
// so their "hook" is a script brane writes. What the script must not do is the
|
|
113
|
+
// interesting part — see braneHookScript.
|
|
114
|
+
describe("the hook script the non-Claude harnesses run", () => {
|
|
115
|
+
function scriptFor(agent, fold, nid, port = "4321") {
|
|
116
|
+
const db = createTestDb();
|
|
117
|
+
seedDaemon(db, port);
|
|
118
|
+
ensureHookConfig(db, agent, fold, nid);
|
|
119
|
+
return readFileSync(agent === "goose"
|
|
120
|
+
? join(fold, ".agents", "plugins", "brane", "hooks", "report-tool-use.sh")
|
|
121
|
+
: join("/tmp", `brane-cline-${nid}`, "hooks", "PreToolUse"), "utf-8");
|
|
122
|
+
}
|
|
123
|
+
it("exits 0 whatever curl does, so recording never vetoes a tool call", () => {
|
|
124
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
125
|
+
const script = scriptFor("goose", fold, "claude-nid00001");
|
|
126
|
+
// In goose and crush a hook's exit code is a verdict: 2 blocks the call, and
|
|
127
|
+
// crush reads 49 as halt-the-turn. curl's own failures live in that space
|
|
128
|
+
// (7 refused, 28 timeout), so an escaping status would let a daemon restart
|
|
129
|
+
// cancel an agent's work.
|
|
130
|
+
assert.match(script, /\nexit 0\n/);
|
|
131
|
+
assert.doesNotMatch(script, /\bexec curl/, "exec would hand curl's status to the harness");
|
|
132
|
+
});
|
|
133
|
+
it("says nothing on stdout, which cline parses as control output", () => {
|
|
134
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
135
|
+
const script = scriptFor("cline", fold, "claude-nid00002");
|
|
136
|
+
assert.match(script, />\/dev\/null 2>&1/);
|
|
137
|
+
});
|
|
138
|
+
it("reads auth from the environment, never the daemon's bearer token", () => {
|
|
139
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
140
|
+
const script = scriptFor("goose", fold, "claude-nid00003");
|
|
141
|
+
assert.match(script, /X-Brane-Narrative-Id: \$\{BRANE_NARRATIVE_ID\}/);
|
|
142
|
+
assert.match(script, /X-Brane-Narrative-Secret: \$\{BRANE_NARRATIVE_SECRET\}/);
|
|
143
|
+
assert.doesNotMatch(script, /\btok\b/, "the daemon token must never reach disk");
|
|
144
|
+
});
|
|
145
|
+
it("is executable, and stays executable when it is rewritten", () => {
|
|
146
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
147
|
+
scriptFor("goose", fold, "claude-nid00004");
|
|
148
|
+
const path = join(fold, ".agents", "plugins", "brane", "hooks", "report-tool-use.sh");
|
|
149
|
+
assert.equal(statSync(path).mode & 0o111, 0o111, "a hook the harness cannot execute is no hook");
|
|
150
|
+
// writeFileSync's `mode` applies only on creation, so every rewrite after
|
|
151
|
+
// the first — a port drift, a change to the script — lands on an existing
|
|
152
|
+
// file and silently ignores it. A script that lost its execute bit would
|
|
153
|
+
// never get it back, and the harness would just stop running the hook.
|
|
154
|
+
chmodSync(path, 0o644);
|
|
155
|
+
const db = createTestDb();
|
|
156
|
+
seedDaemon(db, "5555");
|
|
157
|
+
ensureGooseHookConfig(db, fold);
|
|
158
|
+
assert.equal(statSync(path).mode & 0o111, 0o111, "the rewrite restores the execute bit");
|
|
159
|
+
// And the same is true when only the mode is wrong: an unchanged script
|
|
160
|
+
// must still be repaired rather than short-circuited as already current.
|
|
161
|
+
chmodSync(path, 0o644);
|
|
162
|
+
ensureGooseHookConfig(db, fold);
|
|
163
|
+
assert.equal(statSync(path).mode & 0o111, 0o111);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("ensureGooseHookConfig", () => {
|
|
167
|
+
const pluginHooks = (fold) => join(fold, ".agents", "plugins", "brane", "hooks");
|
|
168
|
+
it("writes an Open Plugins hooks.json wired to the script beside it", () => {
|
|
169
|
+
const db = createTestDb();
|
|
170
|
+
seedDaemon(db, "4321");
|
|
171
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
172
|
+
ensureGooseHookConfig(db, fold);
|
|
173
|
+
const config = JSON.parse(readFileSync(join(pluginHooks(fold), "hooks.json"), "utf-8"));
|
|
174
|
+
const rule = config.hooks.PreToolUse[0];
|
|
175
|
+
assert.equal(rule.matcher, ".*");
|
|
176
|
+
assert.equal(rule.hooks[0].type, "command", "goose has no HTTP hook type");
|
|
177
|
+
assert.equal(rule.hooks[0].command, join(pluginHooks(fold), "report-tool-use.sh"));
|
|
178
|
+
assert.ok(existsSync(rule.hooks[0].command), "the command it names must exist");
|
|
179
|
+
});
|
|
180
|
+
// goose reports a failed call as its own event rather than as a field on the
|
|
181
|
+
// ordinary one — the plain PostToolUse never fires — so a tool that failed
|
|
182
|
+
// would sit pending forever without this.
|
|
183
|
+
it("registers PostToolUseFailure, or failures never complete", () => {
|
|
184
|
+
const db = createTestDb();
|
|
185
|
+
seedDaemon(db, "4321");
|
|
186
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
187
|
+
ensureGooseHookConfig(db, fold);
|
|
188
|
+
const config = JSON.parse(readFileSync(join(pluginHooks(fold), "hooks.json"), "utf-8"));
|
|
189
|
+
assert.deepEqual(Object.keys(config.hooks).sort(), [
|
|
190
|
+
"PostToolUse",
|
|
191
|
+
"PostToolUseFailure",
|
|
192
|
+
"PreToolUse",
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
it("heals port drift in the script and is otherwise idempotent", () => {
|
|
196
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
197
|
+
const db = createTestDb();
|
|
198
|
+
seedDaemon(db, "4321");
|
|
199
|
+
ensureGooseHookConfig(db, fold);
|
|
200
|
+
const first = statSync(join(pluginHooks(fold), "report-tool-use.sh")).mtimeMs;
|
|
201
|
+
ensureGooseHookConfig(db, fold);
|
|
202
|
+
assert.equal(statSync(join(pluginHooks(fold), "report-tool-use.sh")).mtimeMs, first, "an unchanged script is left alone — this runs on every spawn and revive");
|
|
203
|
+
seedDaemon(db, "5555");
|
|
204
|
+
ensureGooseHookConfig(db, fold);
|
|
205
|
+
assert.match(readFileSync(join(pluginHooks(fold), "report-tool-use.sh"), "utf-8"), /127\.0\.0\.1:5555/);
|
|
206
|
+
});
|
|
207
|
+
it("prefers the HTTP fallback port when TLS is active", () => {
|
|
208
|
+
const db = createTestDb();
|
|
209
|
+
seedDaemon(db, "4321", "8080");
|
|
210
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
211
|
+
ensureGooseHookConfig(db, fold);
|
|
212
|
+
assert.match(readFileSync(join(pluginHooks(fold), "report-tool-use.sh"), "utf-8"), new RegExp(`http://127\\.0\\.0\\.1:8080${HOOK_PATH}`));
|
|
213
|
+
});
|
|
214
|
+
it("degrades silently when no daemon is running", () => {
|
|
215
|
+
const db = createTestDb();
|
|
216
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
217
|
+
ensureGooseHookConfig(db, fold);
|
|
218
|
+
assert.ok(!existsSync(join(fold, ".agents")));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
describe("ensureClineHookConfig", () => {
|
|
222
|
+
it("names one script per event in the config dir brane already owns", () => {
|
|
223
|
+
const db = createTestDb();
|
|
224
|
+
seedDaemon(db, "4321");
|
|
225
|
+
const nid = `claude-t${Math.floor(Date.now() % 1e6)}`;
|
|
226
|
+
ensureClineHookConfig(db, nid);
|
|
227
|
+
// cline discovers hooks by filename, and its hooks dir follows --config —
|
|
228
|
+
// which brane sets to this per-narrative directory. Nothing in the
|
|
229
|
+
// operator's fold is touched.
|
|
230
|
+
for (const event of ["PreToolUse", "PostToolUse"]) {
|
|
231
|
+
assert.ok(existsSync(join("/tmp", `brane-cline-${nid}`, "hooks", event)));
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
it("degrades silently when no daemon is running", () => {
|
|
235
|
+
const db = createTestDb();
|
|
236
|
+
const nid = `claude-u${Math.floor(Date.now() % 1e6)}`;
|
|
237
|
+
ensureClineHookConfig(db, nid);
|
|
238
|
+
assert.ok(!existsSync(join("/tmp", `brane-cline-${nid}`, "hooks")));
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
describe("ensureHookConfig", () => {
|
|
242
|
+
it("installs nothing for an agent with no capture path", () => {
|
|
243
|
+
const db = createTestDb();
|
|
244
|
+
seedDaemon(db, "4321");
|
|
245
|
+
const fold = mkdtempSync(join(tmpdir(), "brane-hook-"));
|
|
246
|
+
// crush has a PreToolUse hook but no PostToolUse event, and its pre payload
|
|
247
|
+
// carries no call id to pair the halves with — so a hook here would strand
|
|
248
|
+
// every call pending. Its completions come from its session log instead.
|
|
249
|
+
for (const agent of ["crush", "codex", "gemini", "amp", "copilot"]) {
|
|
250
|
+
ensureHookConfig(db, agent, fold, "claude-nid00005");
|
|
251
|
+
}
|
|
252
|
+
assert.ok(!existsSync(join(fold, ".claude")));
|
|
253
|
+
assert.ok(!existsSync(join(fold, ".agents")));
|
|
254
|
+
});
|
|
255
|
+
it("never throws — capture is optional and must not fail a spawn", () => {
|
|
256
|
+
const db = createTestDb();
|
|
257
|
+
seedDaemon(db, "4321");
|
|
258
|
+
// A fold path that cannot be written to: the spawn still has to proceed.
|
|
259
|
+
assert.doesNotThrow(() => ensureHookConfig(db, "goose", "/dev/null/nope", "claude-nid00006"));
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
//# sourceMappingURL=hook-config.test.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { sendJson, trackRequest } from "../daemon/server.js";
|
|
5
|
+
/** The daemon's JSON responses go out through one function, and until now
|
|
6
|
+
* nothing tested it — `daemon-client.test.ts` stands up a *stub* server to
|
|
7
|
+
* exercise the CLI side, so the real handler had no HTTP coverage at all
|
|
8
|
+
* (claude-n1izig04:78). These tests drive the actual `sendJson` over a real
|
|
9
|
+
* socket, because content negotiation is exactly the kind of thing that looks
|
|
10
|
+
* right in review and is wrong on the wire. */
|
|
11
|
+
describe("sendJson content negotiation", () => {
|
|
12
|
+
let server;
|
|
13
|
+
let base;
|
|
14
|
+
// Comfortably past the compress-it threshold, and repetitive enough that a
|
|
15
|
+
// failure to compress is unmistakable in the numbers.
|
|
16
|
+
const big = { rows: Array.from({ length: 400 }, (_, i) => ({ i, text: "brane ".repeat(20) })) };
|
|
17
|
+
const small = { ok: true };
|
|
18
|
+
before(async () => {
|
|
19
|
+
server = createServer((req, res) => {
|
|
20
|
+
trackRequest(res, req);
|
|
21
|
+
sendJson(res, 200, req.url === "/small" ? small : big);
|
|
22
|
+
});
|
|
23
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
24
|
+
base = `http://127.0.0.1:${server.address().port}`;
|
|
25
|
+
});
|
|
26
|
+
after(() => server.close());
|
|
27
|
+
it("gzips a large body for a client that accepts it, byte-identical after decoding", async () => {
|
|
28
|
+
const res = await fetch(`${base}/big`, { headers: { "accept-encoding": "gzip" } });
|
|
29
|
+
assert.equal(res.headers.get("content-encoding"), "gzip");
|
|
30
|
+
// Undici decodes transparently, so equality here proves the round trip.
|
|
31
|
+
assert.deepEqual(await res.json(), big);
|
|
32
|
+
});
|
|
33
|
+
it("sets Vary, so a cache can't hand a gzipped body to a client that can't read it", async () => {
|
|
34
|
+
const res = await fetch(`${base}/big`, { headers: { "accept-encoding": "gzip" } });
|
|
35
|
+
assert.equal(res.headers.get("vary"), "Accept-Encoding");
|
|
36
|
+
await res.arrayBuffer();
|
|
37
|
+
});
|
|
38
|
+
it("sends plain when the client didn't offer gzip", async () => {
|
|
39
|
+
const res = await fetch(`${base}/big`, { headers: { "accept-encoding": "identity" } });
|
|
40
|
+
assert.equal(res.headers.get("content-encoding"), null);
|
|
41
|
+
assert.deepEqual(await res.json(), big);
|
|
42
|
+
});
|
|
43
|
+
it("leaves a small body alone — the header costs more than the saving", async () => {
|
|
44
|
+
const res = await fetch(`${base}/small`, { headers: { "accept-encoding": "gzip" } });
|
|
45
|
+
assert.equal(res.headers.get("content-encoding"), null);
|
|
46
|
+
assert.deepEqual(await res.json(), small);
|
|
47
|
+
});
|
|
48
|
+
it("actually shrinks the bytes on the wire", async () => {
|
|
49
|
+
const [zipped, plain] = await Promise.all([
|
|
50
|
+
fetch(`${base}/big`, { headers: { "accept-encoding": "gzip" } }),
|
|
51
|
+
fetch(`${base}/big`, { headers: { "accept-encoding": "identity" } }),
|
|
52
|
+
]);
|
|
53
|
+
// Undici decodes the gzip stream before handing it over, so the honest
|
|
54
|
+
// measure is what the server declared it was sending, not what the client
|
|
55
|
+
// ended up holding.
|
|
56
|
+
const zippedBytes = Number(zipped.headers.get("content-length"));
|
|
57
|
+
const plainBytes = Number(plain.headers.get("content-length"));
|
|
58
|
+
await Promise.all([zipped.arrayBuffer(), plain.arrayBuffer()]);
|
|
59
|
+
assert.ok(plainBytes > 10_000, `sanity: plain body was ${plainBytes} bytes`);
|
|
60
|
+
assert.ok(zippedBytes * 5 < plainBytes, `expected a real saving, got ${zippedBytes} vs ${plainBytes} bytes`);
|
|
61
|
+
});
|
|
62
|
+
it("gzips regardless of how the client spells its accept-encoding", async () => {
|
|
63
|
+
for (const header of ["gzip", "gzip, deflate", "deflate, gzip;q=1.0, *;q=0.5", "GZIP"]) {
|
|
64
|
+
const res = await fetch(`${base}/big`, { headers: { "accept-encoding": header } });
|
|
65
|
+
assert.equal(res.headers.get("content-encoding"), "gzip", `for "${header}"`);
|
|
66
|
+
await res.arrayBuffer();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
//# sourceMappingURL=json-gzip.test.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test, describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
// Keystrokes in the web app are routed by ONE rule: the foreground route's
|
|
7
|
+
// green tab owns the keyboard. Two document-level keydown listeners implement
|
|
8
|
+
// it — the terminal's (TerminalPane.tsx) and the chat composer's funnel
|
|
9
|
+
// ([nid].tsx) — and Expo Router keeps every visited [nid] screen mounted under
|
|
10
|
+
// the top of the stack, so a listener gated only on its own screen's tab state
|
|
11
|
+
// hears keys typed into a DIFFERENT narrative. #342 gated the terminal on
|
|
12
|
+
// useIsFocused(); the chat funnel predates it and was never gated, which is
|
|
13
|
+
// how typing into one narrative's terminal filled another narrative's
|
|
14
|
+
// composer. This test reads the source because the failure is invisible on a
|
|
15
|
+
// single-narrative page: it only reproduces with two screens on the stack.
|
|
16
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
17
|
+
const read = (rel) => readFileSync(join(repoRoot, rel), "utf8");
|
|
18
|
+
// These tests lint source the tarball doesn't ship (`expo/dist/` ships, the
|
|
19
|
+
// `.tsx` doesn't). From an installed package — `brane doctor` — they skip
|
|
20
|
+
// with a reason instead of failing on ENOENT.
|
|
21
|
+
const sourceMissing = !existsSync(join(repoRoot, "expo", "app", "[nid].tsx"));
|
|
22
|
+
const skip = sourceMissing ? "expo source not shipped" : false;
|
|
23
|
+
const LISTENERS = [
|
|
24
|
+
{ file: "expo/app/[nid].tsx", gate: "chatActiveRef.current" },
|
|
25
|
+
{ file: "expo/components/TerminalPane.tsx", gate: "isFocusedRef.current" },
|
|
26
|
+
// The narrative list's scroll keys. It isn't a [nid] screen, but it sits
|
|
27
|
+
// under every one of them on the stack, so an ungated listener would scroll
|
|
28
|
+
// the hidden list while the operator pages through a chat.
|
|
29
|
+
{ file: "expo/app/index.tsx", gate: "listOwnsKeysRef.current" },
|
|
30
|
+
];
|
|
31
|
+
describe("document-level keydown listeners are gated on route focus", () => {
|
|
32
|
+
for (const { file, gate } of LISTENERS) {
|
|
33
|
+
test(file, { skip }, () => {
|
|
34
|
+
const src = read(file);
|
|
35
|
+
assert.match(src, /useIsFocused\(\)/, `${file} must read useIsFocused()`);
|
|
36
|
+
const sites = [...src.matchAll(/document\.addEventListener\("keydown", (\w+)\)/g)];
|
|
37
|
+
assert.ok(sites.length > 0, `${file} should register a keydown listener`);
|
|
38
|
+
for (const m of sites) {
|
|
39
|
+
const name = m[1];
|
|
40
|
+
// Find the handler's body and require that it checks the route-focus
|
|
41
|
+
// ref before doing anything with the key.
|
|
42
|
+
const decl = src.indexOf(`const ${name} = (e: KeyboardEvent) => {`);
|
|
43
|
+
assert.notEqual(decl, -1, `handler ${name} not found in ${file}`);
|
|
44
|
+
const body = src.slice(decl, src.indexOf("document.addEventListener", decl));
|
|
45
|
+
// `if (!gate) return;` or `if (!x || !gate) return;` — either way the
|
|
46
|
+
// key is dropped before it is forwarded anywhere.
|
|
47
|
+
const bail = new RegExp(`if \\(([^)]*\\|\\| )?!${gate.replace(".", "\\.")}\\) return;`);
|
|
48
|
+
assert.match(body, bail, `${file}: keydown handler ${name} must bail when !${gate}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
test("the chat gate carries route focus, not just the tab", { skip }, () => {
|
|
53
|
+
const src = read("expo/app/[nid].tsx");
|
|
54
|
+
assert.match(src, /const chatOwnsKeys = chatActive && isFocused;/);
|
|
55
|
+
assert.match(src, /chatActiveRef\.current = chatOwnsKeys;/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
//# sourceMappingURL=key-routing.test.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
4
|
+
import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
|
|
5
|
+
import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
|
|
6
|
+
import { narrativeIdLinks } from "../shared/markdown-nids.js";
|
|
7
|
+
import { collectKnownNids } from "../shared/known-nids.js";
|
|
8
|
+
// The bug these are written against, reproduced from a real report:
|
|
9
|
+
//
|
|
10
|
+
// A twin's first message read "/twin from claude-l4qmxmpb:40" and did not
|
|
11
|
+
// linkify. Neither did the `crush-ci2eu0eb:N` references in its own bubble
|
|
12
|
+
// headers. `/review` in the very next message linkified fine.
|
|
13
|
+
//
|
|
14
|
+
// Nothing was wrong with the parser. The operator was on a slow network, the
|
|
15
|
+
// one fetch that filled `knownNarrativeIds` failed, its rejection was
|
|
16
|
+
// swallowed, the effect could not re-fire, and an empty set makes every
|
|
17
|
+
// reference on the screen silently stop being a link.
|
|
18
|
+
//
|
|
19
|
+
// A parser test could never have caught that — the parser was right. What was
|
|
20
|
+
// needed is a test of *where the ids come from*, which is why that decision now
|
|
21
|
+
// lives in a module of its own.
|
|
22
|
+
const VIEWING = "crush-ci2eu0eb";
|
|
23
|
+
const PREDECESSOR = "claude-l4qmxmpb";
|
|
24
|
+
const ALL = [{ id: VIEWING }, { id: PREDECESSOR }, { id: "goose-aa11bb22" }];
|
|
25
|
+
/** The screen's markdown pipeline, over whatever ids it managed to collect. */
|
|
26
|
+
function linksFor(md, known) {
|
|
27
|
+
const tree = fromMarkdown(md, {
|
|
28
|
+
extensions: [gfmAutolinkLiteral()],
|
|
29
|
+
mdastExtensions: [gfmAutolinkLiteralFromMarkdown(), narrativeIdLinks(known)],
|
|
30
|
+
});
|
|
31
|
+
const out = [];
|
|
32
|
+
const walk = (n) => {
|
|
33
|
+
if (n.type === "link") {
|
|
34
|
+
out.push(n.url);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const c of n.children ?? [])
|
|
38
|
+
walk(c);
|
|
39
|
+
};
|
|
40
|
+
walk(tree);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
describe("linkification survives the fetch that feeds it", () => {
|
|
44
|
+
it("still links when the list sweep never arrives — the reported bug", () => {
|
|
45
|
+
// Exactly the failure: `listed` is the request that died on a slow link.
|
|
46
|
+
const known = collectKnownNids({ viewing: VIEWING, pushed: ALL, listed: null });
|
|
47
|
+
assert.deepEqual(linksFor("/twin from claude-l4qmxmpb:40", known), [
|
|
48
|
+
"brane://nid/claude-l4qmxmpb/40",
|
|
49
|
+
]);
|
|
50
|
+
assert.deepEqual(linksFor("see crush-ci2eu0eb:2", known), ["brane://nid/crush-ci2eu0eb/2"]);
|
|
51
|
+
});
|
|
52
|
+
it("links the narrative you are looking at even with no network at all", () => {
|
|
53
|
+
// The floor. The route param needs no request, so a reference to the
|
|
54
|
+
// narrative on screen can never be a dead link.
|
|
55
|
+
const known = collectKnownNids({ viewing: VIEWING, pushed: null, listed: null });
|
|
56
|
+
assert.deepEqual(linksFor("see crush-ci2eu0eb:2", known), ["brane://nid/crush-ci2eu0eb/2"]);
|
|
57
|
+
});
|
|
58
|
+
it("degrades rather than switching off", () => {
|
|
59
|
+
// The property the whole module exists for: losing a source costs you the
|
|
60
|
+
// ids only that source had, never the ones you already held.
|
|
61
|
+
const full = collectKnownNids({ viewing: VIEWING, pushed: ALL, listed: ALL });
|
|
62
|
+
const degraded = collectKnownNids({ viewing: VIEWING, pushed: null, listed: null });
|
|
63
|
+
assert.ok(degraded.size > 0, "a screen with no completed fetch must still link something");
|
|
64
|
+
for (const id of degraded) {
|
|
65
|
+
assert.ok(full.has(id), `${id} appeared only when sources were missing`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
it("does not link a well-shaped id nobody has heard of", () => {
|
|
69
|
+
// The degradation must not become permissiveness: the existence check is
|
|
70
|
+
// what stops arbitrary strings of that shape becoming dead links.
|
|
71
|
+
const known = collectKnownNids({ viewing: VIEWING, pushed: ALL, listed: ALL });
|
|
72
|
+
assert.deepEqual(linksFor("claude-deadbeef never existed", known), []);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe("collectKnownNids", () => {
|
|
76
|
+
it("unions its sources rather than preferring one", () => {
|
|
77
|
+
// Each covers the other's gap: the `state` frame is a snapshot from connect
|
|
78
|
+
// and misses later spawns; the list sweep is a moment behind.
|
|
79
|
+
const known = collectKnownNids({
|
|
80
|
+
pushed: [{ id: "claude-11111111" }],
|
|
81
|
+
listed: [{ id: "claude-22222222" }],
|
|
82
|
+
viewing: "claude-33333333",
|
|
83
|
+
});
|
|
84
|
+
assert.deepEqual([...known].sort(), ["claude-11111111", "claude-22222222", "claude-33333333"]);
|
|
85
|
+
});
|
|
86
|
+
it("survives every source being absent, null, or empty", () => {
|
|
87
|
+
assert.equal(collectKnownNids({}).size, 0);
|
|
88
|
+
assert.equal(collectKnownNids({ viewing: null, pushed: null, listed: null }).size, 0);
|
|
89
|
+
assert.equal(collectKnownNids({ viewing: "", pushed: [], listed: [] }).size, 0);
|
|
90
|
+
});
|
|
91
|
+
it("ignores entries without a usable id", () => {
|
|
92
|
+
const known = collectKnownNids({
|
|
93
|
+
listed: [{ id: "" }, { id: undefined }, null, { id: "claude-44444444" }],
|
|
94
|
+
});
|
|
95
|
+
assert.deepEqual([...known], ["claude-44444444"]);
|
|
96
|
+
});
|
|
97
|
+
it("dedups across sources", () => {
|
|
98
|
+
const known = collectKnownNids({
|
|
99
|
+
viewing: VIEWING,
|
|
100
|
+
pushed: [{ id: VIEWING }],
|
|
101
|
+
listed: [{ id: VIEWING }],
|
|
102
|
+
});
|
|
103
|
+
assert.equal(known.size, 1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
//# sourceMappingURL=known-nids.test.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The link preview card is three files that have to agree: the served HTML
|
|
2
|
+
// (expo/public/index.html), its kept-in-step copy (expo/app/+html.tsx), and
|
|
3
|
+
// the image the tags name. An unfurler that follows the og:image URL to a
|
|
4
|
+
// 404, or reads a declared size the PNG doesn't have, shows no card at all —
|
|
5
|
+
// and nothing in a local build would notice. This pins the agreement.
|
|
6
|
+
//
|
|
7
|
+
// Reads source, so it skips from an install, where expo/ is not shipped —
|
|
8
|
+
// the same arrangement as api-request.test.ts.
|
|
9
|
+
import { test, describe } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
15
|
+
const publicDir = join(repoRoot, "expo", "public");
|
|
16
|
+
const servedPath = join(publicDir, "index.html");
|
|
17
|
+
const templatePath = join(repoRoot, "expo", "app", "+html.tsx");
|
|
18
|
+
const layoutPath = join(repoRoot, "expo", "app", "_layout.tsx");
|
|
19
|
+
const SHARED_HOST = "https://brane.organelle.dev";
|
|
20
|
+
function tag(html, attr, key) {
|
|
21
|
+
const m = html.match(new RegExp(`<(?:meta|link)[^>]*\\b${attr}="${key}"[^>]*\\b(?:content|href)="([^"]*)"`));
|
|
22
|
+
return m?.[1];
|
|
23
|
+
}
|
|
24
|
+
/** Width and height from a PNG's IHDR chunk, which always follows the
|
|
25
|
+
* 8-byte signature: length(4) "IHDR"(4) width(4) height(4). */
|
|
26
|
+
function pngSize(file) {
|
|
27
|
+
const b = readFileSync(file);
|
|
28
|
+
assert.equal(b.subarray(1, 4).toString("latin1"), "PNG", `${file} is not a PNG`);
|
|
29
|
+
return [b.readUInt32BE(16), b.readUInt32BE(20)];
|
|
30
|
+
}
|
|
31
|
+
describe("the link preview card", { skip: existsSync(servedPath) ? false : "expo source not shipped" }, () => {
|
|
32
|
+
const served = existsSync(servedPath) ? readFileSync(servedPath, "utf8") : "";
|
|
33
|
+
const template = existsSync(templatePath) ? readFileSync(templatePath, "utf8") : "";
|
|
34
|
+
const layout = existsSync(layoutPath) ? readFileSync(layoutPath, "utf8") : "";
|
|
35
|
+
test("the served HTML and its template carry the same card", () => {
|
|
36
|
+
for (const key of ["og:site_name", "og:title", "og:description", "og:image", "og:image:width", "og:image:height", "og:image:alt"]) {
|
|
37
|
+
assert.ok(tag(served, "property", key), `public/index.html lacks ${key}`);
|
|
38
|
+
assert.equal(tag(template, "property", key), tag(served, "property", key), `${key} differs in +html.tsx`);
|
|
39
|
+
}
|
|
40
|
+
assert.equal(tag(served, "name", "twitter:card"), "summary_large_image");
|
|
41
|
+
assert.equal(tag(template, "name", "twitter:card"), tag(served, "name", "twitter:card"));
|
|
42
|
+
});
|
|
43
|
+
test("og:image is absolute on the shared host, and names a file public/ ships at the declared size", () => {
|
|
44
|
+
const url = tag(served, "property", "og:image");
|
|
45
|
+
assert.ok(url.startsWith(`${SHARED_HOST}/`), `og:image must be absolute on ${SHARED_HOST}: ${url}`);
|
|
46
|
+
const file = join(publicDir, url.slice(SHARED_HOST.length + 1));
|
|
47
|
+
assert.ok(existsSync(file), `og:image points at ${file}, which does not exist`);
|
|
48
|
+
const [w, h] = pngSize(file);
|
|
49
|
+
assert.equal(String(w), tag(served, "property", "og:image:width"));
|
|
50
|
+
assert.equal(String(h), tag(served, "property", "og:image:height"));
|
|
51
|
+
// 1200×630 is the size every unfurler renders as a large card; smaller
|
|
52
|
+
// falls back to a thumbnail on some and to nothing on others.
|
|
53
|
+
assert.ok(w >= 1200 && h >= 630, `og:image is ${w}×${h}, below 1200×630`);
|
|
54
|
+
});
|
|
55
|
+
test("og:image is versioned, because iMessage caches a preview by URL", () => {
|
|
56
|
+
const url = tag(served, "property", "og:image");
|
|
57
|
+
assert.match(url, /\.v\d+\.png$/, `expected a .v<N>.png name: ${url}`);
|
|
58
|
+
});
|
|
59
|
+
test("the touch icon is linked from all three heads and is 180×180", () => {
|
|
60
|
+
const href = tag(served, "rel", "apple-touch-icon");
|
|
61
|
+
assert.equal(href, "/apple-touch-icon.png");
|
|
62
|
+
assert.equal(tag(template, "rel", "apple-touch-icon"), href);
|
|
63
|
+
assert.equal(tag(layout, "rel", "apple-touch-icon"), href);
|
|
64
|
+
assert.deepEqual(pngSize(join(publicDir, href.slice(1))), [180, 180]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
//# sourceMappingURL=link-preview.test.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ARROW_STEP_PX, PAGE_FRACTION, isListScrollKey, listScrollTarget, } from "../shared/list-scroll-keys.js";
|
|
4
|
+
// The narrative list scrolls itself for the browser's scroll keys when nothing
|
|
5
|
+
// in it has focus. These tests pin the arithmetic. The routing (route focus,
|
|
6
|
+
// modals, editables) is pinned in key-routing.test.ts.
|
|
7
|
+
const bare = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false };
|
|
8
|
+
const list = { clientHeight: 800, scrollHeight: 5000 }; // max scrollTop 4200
|
|
9
|
+
describe("isListScrollKey", () => {
|
|
10
|
+
it("takes the six unmodified scroll keys", () => {
|
|
11
|
+
for (const key of ["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"]) {
|
|
12
|
+
assert.equal(isListScrollKey({ key, ...bare }), true, key);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
it("leaves other keys alone", () => {
|
|
16
|
+
for (const key of ["ArrowLeft", "ArrowRight", " ", "a", "Enter", "Escape", "Tab"]) {
|
|
17
|
+
assert.equal(isListScrollKey({ key, ...bare }), false, JSON.stringify(key));
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
it("leaves modified scroll keys to the browser and OS", () => {
|
|
21
|
+
for (const mod of ["metaKey", "ctrlKey", "altKey", "shiftKey"]) {
|
|
22
|
+
assert.equal(isListScrollKey({ key: "ArrowDown", ...bare, [mod]: true }), false, mod);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe("listScrollTarget", () => {
|
|
27
|
+
it("steps an arrow's worth", () => {
|
|
28
|
+
assert.equal(listScrollTarget("ArrowDown", 1000, list), 1000 + ARROW_STEP_PX);
|
|
29
|
+
assert.equal(listScrollTarget("ArrowUp", 1000, list), 1000 - ARROW_STEP_PX);
|
|
30
|
+
});
|
|
31
|
+
it("pages by most of the visible height, so a line of the old page stays", () => {
|
|
32
|
+
const page = 800 * PAGE_FRACTION;
|
|
33
|
+
assert.ok(page < 800);
|
|
34
|
+
assert.equal(listScrollTarget("PageDown", 1000, list), 1000 + page);
|
|
35
|
+
assert.equal(listScrollTarget("PageUp", 1000, list), 1000 - page);
|
|
36
|
+
});
|
|
37
|
+
it("jumps to the ends", () => {
|
|
38
|
+
assert.equal(listScrollTarget("Home", 1234, list), 0);
|
|
39
|
+
assert.equal(listScrollTarget("End", 1234, list), 4200);
|
|
40
|
+
});
|
|
41
|
+
it("clamps to the scrollable range", () => {
|
|
42
|
+
assert.equal(listScrollTarget("ArrowUp", 10, list), 0);
|
|
43
|
+
assert.equal(listScrollTarget("PageUp", 0, list), 0);
|
|
44
|
+
assert.equal(listScrollTarget("PageDown", 4000, list), 4200);
|
|
45
|
+
assert.equal(listScrollTarget("ArrowDown", 4200, list), 4200);
|
|
46
|
+
});
|
|
47
|
+
it("stays at 0 when the content fits", () => {
|
|
48
|
+
const short = { clientHeight: 800, scrollHeight: 300 };
|
|
49
|
+
for (const key of ["PageDown", "End", "ArrowDown", "PageUp", "Home"]) {
|
|
50
|
+
assert.equal(listScrollTarget(key, 0, short), 0, key);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
it("stacks presses from the pending target, not the mid-glide position", () => {
|
|
54
|
+
// A smooth PageDown from 0 is heading for one page down and has covered a
|
|
55
|
+
// third of it when the second press lands. Starting from the target lands
|
|
56
|
+
// two full pages down, which is what two presses mean.
|
|
57
|
+
const page = 800 * PAGE_FRACTION;
|
|
58
|
+
const first = listScrollTarget("PageDown", 0, list);
|
|
59
|
+
assert.equal(listScrollTarget("PageDown", first, list), 2 * page);
|
|
60
|
+
});
|
|
61
|
+
it("returns null for keys that don't scroll", () => {
|
|
62
|
+
assert.equal(listScrollTarget("ArrowLeft", 100, list), null);
|
|
63
|
+
assert.equal(listScrollTarget("x", 100, list), null);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
//# sourceMappingURL=list-scroll-keys.test.js.map
|