brane 0.1.1 → 0.3.1

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.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,72 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { replaceTomlTable } from "../commands/spawn.js";
4
+ const HEADER = "[mcp_servers.brane]";
5
+ const NEW_SECTION = `[mcp_servers.brane]
6
+ command = "/usr/local/bin/brane"
7
+ args = ["mcp"]
8
+ env_vars = ["BRANE_NARRATIVE_ID"]
9
+ tool_timeout_sec = 3600
10
+ `;
11
+ describe("replaceTomlTable", () => {
12
+ // Regression: the original implementation used
13
+ // existing.replace(/\[mcp_servers\.brane\][^\[]*(?=\[|$)/s, braneSection)
14
+ // The [^\[]* halts at the first '[' *anywhere* — including the one inside
15
+ // `args = ["mcp"]`. That truncated the match mid-table and left the tail
16
+ // (`["mcp"]\nenv_vars = [...]`) orphaned in ~/.codex/config.toml as a bogus
17
+ // table header, corrupting the operator's global codex config.
18
+ it("does not orphan the tail when a value contains '['", () => {
19
+ const source = `[model]
20
+ name = "gpt-5"
21
+
22
+ ${HEADER}
23
+ command = "/old/brane"
24
+ args = ["mcp"]
25
+ env_vars = ["BRANE_NARRATIVE_ID"]
26
+
27
+ [other]
28
+ x = 1
29
+ `;
30
+ const out = replaceTomlTable(source, HEADER, NEW_SECTION);
31
+ assert.ok(!/^\["mcp"\]/m.test(out), "orphaned array literal became a table header");
32
+ assert.equal((out.match(/env_vars/g) ?? []).length, 1, "env_vars duplicated");
33
+ assert.equal((out.match(/\[mcp_servers\.brane\]/g) ?? []).length, 1);
34
+ });
35
+ it("preserves sibling tables on both sides", () => {
36
+ const source = `[model]
37
+ name = "gpt-5"
38
+
39
+ ${HEADER}
40
+ command = "/old/brane"
41
+ args = ["mcp"]
42
+
43
+ [other]
44
+ x = 1
45
+ `;
46
+ const out = replaceTomlTable(source, HEADER, NEW_SECTION);
47
+ assert.match(out, /\[model\]\nname = "gpt-5"/);
48
+ assert.match(out, /\[other\]\nx = 1/);
49
+ assert.match(out, /tool_timeout_sec = 3600/);
50
+ assert.ok(!out.includes("/old/brane"), "stale command survived the replace");
51
+ });
52
+ it("replaces a trailing table with no following header", () => {
53
+ const source = `[model]\nname = "gpt-5"\n\n${HEADER}\ncommand = "/old/brane"\nargs = ["mcp"]\n`;
54
+ const out = replaceTomlTable(source, HEADER, NEW_SECTION);
55
+ assert.match(out, /\[model\]/);
56
+ assert.match(out, /tool_timeout_sec = 3600/);
57
+ assert.ok(!out.includes("/old/brane"));
58
+ });
59
+ it("appends when the table is absent", () => {
60
+ const source = `[model]\nname = "gpt-5"\n`;
61
+ const out = replaceTomlTable(source, HEADER, NEW_SECTION);
62
+ assert.match(out, /\[model\]\nname = "gpt-5"/);
63
+ assert.equal((out.match(/\[mcp_servers\.brane\]/g) ?? []).length, 1);
64
+ });
65
+ it("is idempotent across repeated spawns", () => {
66
+ const source = `[model]\nname = "gpt-5"\n\n${HEADER}\ncommand = "/old"\nargs = ["mcp"]\n\n[other]\nx = 1\n`;
67
+ const once = replaceTomlTable(source, HEADER, NEW_SECTION);
68
+ const twice = replaceTomlTable(once, HEADER, NEW_SECTION);
69
+ assert.equal(once, twice, "second application changed the file");
70
+ });
71
+ });
72
+ //# sourceMappingURL=spawn-toml.test.js.map
@@ -0,0 +1,90 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { speakerHue, speakerColor, speakerBgColor, parseRecolorSalt, } from "../shared/speaker-color.js";
4
+ // The function the app shipped with, verbatim — salt 0 must reproduce it so a
5
+ // narrative nobody has recolored keeps the colors it has always had.
6
+ function shippedHue(speaker) {
7
+ let hash = 0;
8
+ for (let i = 0; i < speaker.length; i++) {
9
+ hash = speaker.charCodeAt(i) + ((hash << 5) - hash);
10
+ }
11
+ return Math.abs(hash) % 360;
12
+ }
13
+ // Same shape as real nids: `{type}-{8hex}`, so every one is the same length,
14
+ // which is the case a linear reshuffle gets wrong.
15
+ const SPEAKERS = Array.from({ length: 40 }, (_, i) => {
16
+ const hex = ((i + 1) * 0x9e3779b1 >>> 0).toString(16).padStart(8, "0");
17
+ return `claude-${hex}`;
18
+ });
19
+ const gap = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
20
+ describe("speakerHue", () => {
21
+ it("is the shipped hash at salt 0", () => {
22
+ for (const s of [...SPEAKERS, "goose-aa11bb22", "web", "x", "brane"]) {
23
+ assert.equal(speakerHue(s), shippedHue(s), s);
24
+ assert.equal(speakerHue(s, 0), shippedHue(s), s);
25
+ }
26
+ });
27
+ it("stays in [0, 360) for every salt", () => {
28
+ for (let salt = 0; salt < 32; salt++) {
29
+ for (const s of SPEAKERS) {
30
+ const h = speakerHue(s, salt);
31
+ assert.ok(Number.isInteger(h) && h >= 0 && h < 360, `${s} @${salt} → ${h}`);
32
+ }
33
+ }
34
+ });
35
+ it("reshuffles per speaker, not by rotating the whole palette", () => {
36
+ // Under a uniform rotation every pairwise gap survives. Under a real
37
+ // reshuffle most of them change — that is what lets two close hues part.
38
+ const before = SPEAKERS.map((s) => speakerHue(s, 0));
39
+ const after = SPEAKERS.map((s) => speakerHue(s, 1));
40
+ let moved = 0, pairs = 0;
41
+ for (let i = 0; i < SPEAKERS.length; i++) {
42
+ for (let j = i + 1; j < SPEAKERS.length; j++) {
43
+ pairs++;
44
+ if (gap(before[i], before[j]) !== gap(after[i], after[j]))
45
+ moved++;
46
+ }
47
+ }
48
+ assert.ok(moved > pairs * 0.9, `${moved}/${pairs} pairwise gaps changed`);
49
+ });
50
+ it("separates a colliding pair within a few recolors", () => {
51
+ // The operator's case: two speakers that hash within 15° of each other.
52
+ // Find such a pair in the fixture, then show that some salt in the first
53
+ // handful puts them at least 60° apart — a tap or two, not a lottery.
54
+ let pair = null;
55
+ outer: for (let i = 0; i < SPEAKERS.length; i++) {
56
+ for (let j = i + 1; j < SPEAKERS.length; j++) {
57
+ if (gap(speakerHue(SPEAKERS[i]), speakerHue(SPEAKERS[j])) <= 15) {
58
+ pair = [SPEAKERS[i], SPEAKERS[j]];
59
+ break outer;
60
+ }
61
+ }
62
+ }
63
+ assert.ok(pair, "fixture should contain a colliding pair");
64
+ const [a, b] = pair;
65
+ const salts = [1, 2, 3, 4, 5].filter((salt) => gap(speakerHue(a, salt), speakerHue(b, salt)) >= 60);
66
+ assert.ok(salts.length > 0, `no salt in 1..5 separates ${a} and ${b}`);
67
+ });
68
+ });
69
+ describe("speakerColor / speakerBgColor", () => {
70
+ it("share one hue and differ only in lightness", () => {
71
+ for (const salt of [0, 3]) {
72
+ const h = speakerHue("goose-aa11bb22", salt);
73
+ assert.equal(speakerColor("goose-aa11bb22", salt), `hsl(${h}, 60%, 80%)`);
74
+ assert.equal(speakerBgColor("goose-aa11bb22", salt), `hsl(${h}, 30%, 20%)`);
75
+ }
76
+ });
77
+ });
78
+ describe("parseRecolorSalt", () => {
79
+ it("reads a stored count and treats anything else as the default palette", () => {
80
+ assert.equal(parseRecolorSalt("3"), 3);
81
+ assert.equal(parseRecolorSalt("0"), 0);
82
+ assert.equal(parseRecolorSalt(null), 0);
83
+ assert.equal(parseRecolorSalt(undefined), 0);
84
+ assert.equal(parseRecolorSalt(""), 0);
85
+ assert.equal(parseRecolorSalt("-2"), 0);
86
+ assert.equal(parseRecolorSalt("1.5"), 0);
87
+ assert.equal(parseRecolorSalt("purple"), 0);
88
+ });
89
+ });
90
+ //# sourceMappingURL=speaker-color.test.js.map
@@ -0,0 +1,79 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { addressesToBind, nextProbeDelay, TAILSCALE_PROBE_INITIAL_MS, TAILSCALE_PROBE_MAX_MS, TAILSCALE_PROBE_STEADY_MS, } from "../daemon/tailscale-rebind.js";
4
+ import { parseTailscaleIps } from "../tailscale.js";
5
+ describe("nextProbeDelay", () => {
6
+ it("starts the not-found backoff at the initial delay", () => {
7
+ assert.equal(nextProbeDelay(null, false), TAILSCALE_PROBE_INITIAL_MS);
8
+ assert.equal(nextProbeDelay(0, false), TAILSCALE_PROBE_INITIAL_MS);
9
+ });
10
+ // The daemon seeds its first timer with nextProbeDelay(null, …) and keeps
11
+ // `probeDelay` at null until the first probe runs, so the first *computed*
12
+ // step is the initial delay again. That extra 5 s tick is deliberate — one
13
+ // quick retry for a Tailscale that is merely still launching — and this
14
+ // pins the sequence the daemon actually runs, not an idealized one.
15
+ it("runs 5, 5, 10, 20, 40, 60 while nothing is found, then holds the cap", () => {
16
+ const seen = [nextProbeDelay(null, false)];
17
+ let d = null;
18
+ for (let i = 0; i < 7; i++) {
19
+ d = nextProbeDelay(d, false);
20
+ seen.push(d);
21
+ }
22
+ assert.deepEqual(seen, [5_000, 5_000, 10_000, 20_000, 40_000, 60_000, 60_000, 60_000]);
23
+ assert.equal(Math.max(...seen), TAILSCALE_PROBE_MAX_MS);
24
+ });
25
+ // A found probe resets to the steady cadence no matter how deep the
26
+ // backoff had gone — the daemon should notice a later address change in
27
+ // a minute, not in whatever the backoff had climbed to.
28
+ it("returns to the steady cadence once Tailscale is seen", () => {
29
+ assert.equal(nextProbeDelay(60_000, true), TAILSCALE_PROBE_STEADY_MS);
30
+ assert.equal(nextProbeDelay(null, true), TAILSCALE_PROBE_STEADY_MS);
31
+ });
32
+ });
33
+ describe("addressesToBind", () => {
34
+ it("returns only addresses not already bound, in probe order", () => {
35
+ const bound = ["127.0.0.1", "::1"];
36
+ assert.deepEqual(addressesToBind(bound, ["100.64.167.92", "fd7a:115c:a1e0::4401:a76f"]), ["100.64.167.92", "fd7a:115c:a1e0::4401:a76f"]);
37
+ });
38
+ // The scenario this module exists for: the daemon came up blind, Tailscale
39
+ // recovered, and a later probe reports the same two addresses every
40
+ // minute. The first such probe binds them; every later one must be a no-op.
41
+ it("is a no-op once the probed addresses are bound", () => {
42
+ const ips = ["100.64.167.92", "fd7a:115c:a1e0::4401:a76f"];
43
+ const bound = new Set(["127.0.0.1", "::1"]);
44
+ for (const a of addressesToBind(bound, ips))
45
+ bound.add(a);
46
+ assert.deepEqual(addressesToBind(bound, ips), []);
47
+ });
48
+ it("drops blanks and duplicates within one probe", () => {
49
+ assert.deepEqual(addressesToBind([], ["", " 100.64.1.1 ", "100.64.1.1"]), ["100.64.1.1"]);
50
+ });
51
+ it("picks up an address that changed since the last probe", () => {
52
+ const bound = ["127.0.0.1", "::1", "100.64.167.92"];
53
+ assert.deepEqual(addressesToBind(bound, ["100.64.200.7"]), ["100.64.200.7"]);
54
+ });
55
+ });
56
+ describe("parseTailscaleIps", () => {
57
+ it("reads Self.TailscaleIPs", () => {
58
+ const out = JSON.stringify({ Self: { TailscaleIPs: ["100.64.167.92", "fd7a:115c:a1e0::4401:a76f"] } });
59
+ assert.deepEqual(parseTailscaleIps(out), ["100.64.167.92", "fd7a:115c:a1e0::4401:a76f"]);
60
+ });
61
+ // The three ways the CLI's answer can be wrong, each of which must read
62
+ // as "no Tailscale" rather than throw: a hung/killed CLI leaves partial or
63
+ // empty output; a version skew can drop the field; a corrupt value must
64
+ // not smuggle a non-string into a bind address.
65
+ it("is empty on non-JSON (timeout, partial output)", () => {
66
+ assert.deepEqual(parseTailscaleIps(""), []);
67
+ assert.deepEqual(parseTailscaleIps('{"Self":{"TailscaleIPs":["100.64.'), []);
68
+ });
69
+ it("is empty when Self or TailscaleIPs is absent", () => {
70
+ assert.deepEqual(parseTailscaleIps("{}"), []);
71
+ assert.deepEqual(parseTailscaleIps(JSON.stringify({ Self: {} })), []);
72
+ assert.deepEqual(parseTailscaleIps(JSON.stringify({ Self: { TailscaleIPs: null } })), []);
73
+ });
74
+ it("keeps only string entries", () => {
75
+ const out = JSON.stringify({ Self: { TailscaleIPs: ["100.64.1.1", 7, null, "fd7a::1"] } });
76
+ assert.deepEqual(parseTailscaleIps(out), ["100.64.1.1", "fd7a::1"]);
77
+ });
78
+ });
79
+ //# sourceMappingURL=tailscale-rebind.test.js.map
@@ -0,0 +1,61 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseTerminalStatus, terminalCursorFrom } from "../daemon/server.js";
4
+ // The mirror sends a pane's text; the cursor is the one thing about a pane
5
+ // that isn't in its text. These two functions are the whole translation from
6
+ // what tmux reports to what a client can draw, and both halves are pure — the
7
+ // arithmetic is the part that can be wrong without anyone noticing, since a
8
+ // cursor one row off still looks like a cursor.
9
+ describe("parseTerminalStatus", () => {
10
+ it("reads the five fields display-message emits", () => {
11
+ assert.deepEqual(parseTerminalStatus("39 23 1 0 24"), {
12
+ cursorX: 39,
13
+ cursorY: 23,
14
+ cursorVisible: true,
15
+ alternateOn: false,
16
+ paneHeight: 24,
17
+ });
18
+ });
19
+ it("treats flags as flags — only 1 is on", () => {
20
+ const status = parseTerminalStatus("0 0 0 1 24");
21
+ assert.equal(status?.cursorVisible, false);
22
+ assert.equal(status?.alternateOn, true);
23
+ });
24
+ it("returns null rather than NaN for a line it can't read", () => {
25
+ // A tmux that fails, a truncated read, an error string where a status
26
+ // line was expected: better no cursor than one at NaN.
27
+ assert.equal(parseTerminalStatus(""), null);
28
+ assert.equal(parseTerminalStatus("no server running"), null);
29
+ assert.equal(parseTerminalStatus("39 23 1 0"), null);
30
+ assert.equal(parseTerminalStatus("39 x 1 0 24"), null);
31
+ });
32
+ });
33
+ describe("terminalCursorFrom", () => {
34
+ // Measured against a real session: 798 rows of history under a 24-row pane
35
+ // captures 822 lines, and the shell prompt sits on the last of them.
36
+ it("places the cursor below the scrollback a frame carries", () => {
37
+ const status = parseTerminalStatus("39 23 1 0 24");
38
+ assert.deepEqual(terminalCursorFrom(status, 822), { line: 821, col: 39 });
39
+ });
40
+ it("is the row itself when there is no history (alternate screen)", () => {
41
+ const status = parseTerminalStatus("2 31 1 1 34");
42
+ assert.deepEqual(terminalCursorFrom(status, 34), { line: 31, col: 2 });
43
+ });
44
+ // cursor_flag is the pane's own DECTCEM state. An application that hides the
45
+ // cursor — Claude Code with a dialog up, lazygit throughout — means it, and a
46
+ // block drawn anyway would be a cursor the operator's real terminal doesn't
47
+ // show. Nothing is more misleading than a caret in the wrong place.
48
+ it("draws nothing when the pane has hidden its cursor", () => {
49
+ assert.equal(terminalCursorFrom(parseTerminalStatus("1 81 0 1 85"), 85), null);
50
+ });
51
+ it("draws nothing when the status line was unreadable", () => {
52
+ assert.equal(terminalCursorFrom(null, 822), null);
53
+ });
54
+ // Defensive: a capture that raced a resize can report fewer rows than the
55
+ // pane height, which would put the cursor above the frame's first line.
56
+ it("declines a line that would fall off the top of the frame", () => {
57
+ assert.equal(terminalCursorFrom(parseTerminalStatus("0 0 1 1 40"), 10), null);
58
+ assert.equal(terminalCursorFrom(parseTerminalStatus("0 0 1 1 0"), 10), null);
59
+ });
60
+ });
61
+ //# sourceMappingURL=terminal-cursor.test.js.map
@@ -0,0 +1,23 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { isResyncDue } from "../daemon/server.js";
4
+ // The mirror records what it *sent*, not what the browser *rendered*, and a
5
+ // quiet pane never speaks again — so a dropped frame used to persist until the
6
+ // operator reloaded the page. The heartbeat is what bounds that to one
7
+ // interval; these pin the boundary it turns on.
8
+ test("a viewer just sent a frame is not due", () => {
9
+ assert.equal(isResyncDue(1_000, 1_000, 2_000), false);
10
+ assert.equal(isResyncDue(1_000, 2_999, 2_000), false);
11
+ });
12
+ test("a viewer that has gone an interval without content is due", () => {
13
+ assert.equal(isResyncDue(1_000, 3_000, 2_000), true);
14
+ assert.equal(isResyncDue(1_000, 9_999, 2_000), true);
15
+ });
16
+ test("a viewer that has never been sent anything is due immediately", () => {
17
+ // `lastSentAt: 0` is the state of a fresh socket and of one whose DOM was
18
+ // just wiped by `refresh`. Both want content on the very next tick, and the
19
+ // sentinel gets that for free against a real epoch clock — which is the only
20
+ // clock the capture loop ever passes.
21
+ assert.equal(isResyncDue(0, 1_787_598_000_000, 2_000), true);
22
+ });
23
+ //# sourceMappingURL=terminal-resync.test.js.map
@@ -0,0 +1,36 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseTerminalSize, newSessionLine } from "../tmux.js";
4
+ // The grid a spawn or run request carries: the size the requesting viewer's
5
+ // pane last measured, so the session starts at the width it will be watched at
6
+ // and a TUI's launch screen isn't redrawn — and left in scrollback — at each
7
+ // width it is resized through.
8
+ test("parseTerminalSize accepts a positive integer grid", () => {
9
+ assert.deepEqual(parseTerminalSize({ cols: 80, rows: 24 }), { cols: 80, rows: 24 });
10
+ });
11
+ test("parseTerminalSize drops extra fields", () => {
12
+ assert.deepEqual(parseTerminalSize({ cols: 120, rows: 40, dpr: 2 }), { cols: 120, rows: 40 });
13
+ });
14
+ test("parseTerminalSize rejects anything that isn't a usable grid", () => {
15
+ for (const bad of [
16
+ undefined,
17
+ null,
18
+ "80x24",
19
+ [80, 24],
20
+ {},
21
+ { cols: 80 },
22
+ { cols: 0, rows: 24 },
23
+ { cols: 80, rows: -1 },
24
+ { cols: 80.5, rows: 24 },
25
+ { cols: "80", rows: "24" },
26
+ { cols: 80, rows: 100000 },
27
+ ]) {
28
+ assert.equal(parseTerminalSize(bad), null, JSON.stringify(bad));
29
+ }
30
+ });
31
+ test("a parsed size reaches new-session as -x/-y", () => {
32
+ const size = parseTerminalSize({ cols: 93, rows: 41 });
33
+ const line = newSessionLine({ name: "claude-abc", cwd: "/tmp", size, command: "claude" });
34
+ assert.match(line, / -x 93 -y 41 /);
35
+ });
36
+ //# sourceMappingURL=terminal-size.test.js.map