brane 0.1.1 → 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.
Files changed (348) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -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 +116 -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 +332 -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 +1094 -0
  37. package/dist/commands/token.js +153 -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 +6349 -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 +633 -0
  58. package/dist/db/tokens.js +583 -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/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +79 -27
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,548 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import WebSocket from "ws";
4
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createTestDb } from "./helpers.js";
8
+ import { adoptBindTokens } from "../db/tokens.js";
9
+ import { appendEvent, readEventsSince, readEvents, getMaxSeq } from "../db/events.js";
10
+ import { getBraneId } from "../db/schema.js";
11
+ import { walkProcessTreeForSession, sessionIdFromRegistryEntry } from "../mcp/server.js";
12
+ import { resolveMcpBind, mintExternalNarrativeWithSecret, boundClientCount, handleMcpNarrate, maybeCaptureSessionId, recordSessionId, getSessionIds, } from "../daemon/server.js";
13
+ import { buildAgentArgs } from "../commands/spawn.js";
14
+ import { getAgentAdapter, discoverCodexSession } from "../agents.js";
15
+ // We test the MCP tool logic directly rather than going through the stdio protocol.
16
+ // The handlers are internal to server.ts, so we replicate the core logic here
17
+ // using the same DB functions the handlers use.
18
+ describe("MCP narrate logic", () => {
19
+ it("local narration creates a narration event", () => {
20
+ const db = createTestDb();
21
+ const selfId = "claude-test";
22
+ // Simulate handleNarrate: local narration
23
+ appendEvent(db, selfId, "narration", {
24
+ content: "thinking out loud",
25
+ source: selfId,
26
+ });
27
+ const events = readEventsSince(db, selfId, 0);
28
+ assert.equal(events.length, 1);
29
+ assert.equal(events[0].type, "narration");
30
+ assert.equal(events[0].content, "thinking out loud");
31
+ assert.equal(events[0].source, selfId);
32
+ });
33
+ it("foreign narration creates an agent_signal event on target", () => {
34
+ const db = createTestDb();
35
+ const senderId = "claude-sender";
36
+ const targetId = "claude-target";
37
+ // Simulate handleNarrate: foreign narration (agent → agent)
38
+ appendEvent(db, targetId, "agent_signal", {
39
+ content: "hey there",
40
+ source: senderId,
41
+ });
42
+ const events = readEventsSince(db, targetId, 0);
43
+ assert.equal(events.length, 1);
44
+ assert.equal(events[0].type, "agent_signal");
45
+ assert.equal(events[0].source, senderId);
46
+ // Sender's narrative should be empty
47
+ const senderEvents = readEventsSince(db, senderId, 0);
48
+ assert.equal(senderEvents.length, 0);
49
+ });
50
+ it("narrate with polling returns events from target narratives", () => {
51
+ const db = createTestDb();
52
+ // Pre-populate some events
53
+ appendEvent(db, "other-agent", "narration", { content: "msg1" });
54
+ appendEvent(db, "other-agent", "narration", { content: "msg2" });
55
+ // Simulate polling via after parameter
56
+ const events = readEventsSince(db, "other-agent", 0, 50);
57
+ const headSeq = getMaxSeq(db, "other-agent");
58
+ assert.equal(events.length, 2);
59
+ assert.equal(headSeq, 2);
60
+ });
61
+ it("meta.session_id is stored on narrative", () => {
62
+ const db = createTestDb();
63
+ const narrativeId = "claude-meta-test";
64
+ // Create narrative row first
65
+ db.prepare(`INSERT INTO narratives (id, brane_id, fold_path, agent_type, role, created_at, status)
66
+ VALUES (?, 'brane-test', '~/dev/test', 'claude', 'worker', ?, 'active')`).run(narrativeId, Date.now());
67
+ // Simulate meta handling in handleNarrate
68
+ const sessionId = "abc-123-session";
69
+ db.prepare("UPDATE narratives SET agent_session_id = ? WHERE id = ?").run(sessionId, narrativeId);
70
+ const row = db.prepare("SELECT agent_session_id FROM narratives WHERE id = ?").get(narrativeId);
71
+ assert.equal(row.agent_session_id, sessionId);
72
+ });
73
+ });
74
+ describe("MCP listen logic", () => {
75
+ it("returns events after cursor", () => {
76
+ const db = createTestDb();
77
+ appendEvent(db, "n1", "narration", { content: "old" });
78
+ appendEvent(db, "n1", "narration", { content: "new" });
79
+ // Simulate listen with cursor at 1
80
+ const events = readEventsSince(db, "n1", 1, 50);
81
+ assert.equal(events.length, 1);
82
+ assert.equal(events[0].content, "new");
83
+ });
84
+ it("returns empty when no new events", () => {
85
+ const db = createTestDb();
86
+ appendEvent(db, "n1", "narration", { content: "only" });
87
+ const events = readEventsSince(db, "n1", 1, 50);
88
+ assert.equal(events.length, 0);
89
+ });
90
+ it("works with tail only (no after)", () => {
91
+ const db = createTestDb();
92
+ appendEvent(db, "n1", "narration", { content: "first" });
93
+ appendEvent(db, "n1", "narration", { content: "second" });
94
+ appendEvent(db, "n1", "narration", { content: "third" });
95
+ // Simulate listen { tail: 2, wait: false } — after is omitted
96
+ const events = readEvents(db, "n1", { tail: 2 });
97
+ assert.equal(events.length, 2);
98
+ assert.equal(events[0].content, "second");
99
+ assert.equal(events[1].content, "third");
100
+ });
101
+ it("works with before only (no after)", () => {
102
+ const db = createTestDb();
103
+ appendEvent(db, "n1", "narration", { content: "first" });
104
+ appendEvent(db, "n1", "narration", { content: "second" });
105
+ appendEvent(db, "n1", "narration", { content: "third" });
106
+ // Simulate listen { before: 3, wait: false } — after is omitted
107
+ const events = readEvents(db, "n1", { before: 3 });
108
+ assert.equal(events.length, 2);
109
+ assert.equal(events[0].content, "first");
110
+ assert.equal(events[1].content, "second");
111
+ });
112
+ it("works with tail + before (no after)", () => {
113
+ const db = createTestDb();
114
+ for (let i = 0; i < 10; i++) {
115
+ appendEvent(db, "n1", "narration", { content: `msg-${i}` });
116
+ }
117
+ // Simulate listen { tail: 3, before: 8, wait: false } — backward pagination
118
+ const events = readEvents(db, "n1", { tail: 3, before: 8 });
119
+ assert.equal(events.length, 3);
120
+ assert.equal(events[0].content, "msg-4"); // seq 5
121
+ assert.equal(events[1].content, "msg-5"); // seq 6
122
+ assert.equal(events[2].content, "msg-6"); // seq 7
123
+ });
124
+ it("works with no cursor parameters at all", () => {
125
+ const db = createTestDb();
126
+ appendEvent(db, "n1", "narration", { content: "only" });
127
+ // Simulate listen {} — defaults to after: 0 in daemon
128
+ const events = readEvents(db, "n1", {});
129
+ assert.equal(events.length, 1);
130
+ assert.equal(events[0].content, "only");
131
+ });
132
+ });
133
+ describe("MCP whoami logic", () => {
134
+ it("returns brane_id from meta table", () => {
135
+ const db = createTestDb();
136
+ const braneId = getBraneId(db);
137
+ assert.equal(braneId, "brane-test");
138
+ });
139
+ });
140
+ describe("session-id process-tree fallback", () => {
141
+ // Fake registry: only the Claude process pid 100 has a session entry.
142
+ const registry = { 100: "sess-abc" };
143
+ const lookup = (pid) => registry[pid];
144
+ it("finds the session at the immediate parent (zero-hop case)", () => {
145
+ // Bridge's process.ppid is already the Claude pid.
146
+ const getParent = () => 0;
147
+ assert.equal(walkProcessTreeForSession(100, lookup, getParent), "sess-abc");
148
+ });
149
+ it("climbs the parent chain through an intervening shell", () => {
150
+ // bridge(13) → shell(50) → claude(100)
151
+ const parents = { 50: 100, 13: 50 };
152
+ const getParent = (pid) => parents[pid] ?? 0;
153
+ assert.equal(walkProcessTreeForSession(50, lookup, getParent), "sess-abc");
154
+ });
155
+ it("returns undefined when no ancestor is in the registry", () => {
156
+ const parents = { 7: 8, 8: 1 };
157
+ const getParent = (pid) => parents[pid] ?? 0;
158
+ assert.equal(walkProcessTreeForSession(7, lookup, getParent), undefined);
159
+ });
160
+ it("stops after maxHops and does not loop forever", () => {
161
+ // A chain that never reaches pid 100; each pid's parent is pid+1.
162
+ let calls = 0;
163
+ const getParent = (pid) => {
164
+ calls++;
165
+ return pid + 1;
166
+ };
167
+ assert.equal(walkProcessTreeForSession(200, lookup, getParent, 3), undefined);
168
+ assert.equal(calls, 3); // exactly maxHops climbs, no runaway
169
+ });
170
+ });
171
+ describe("session registry cwd guard (pid-reuse defense)", () => {
172
+ const fold = "/Users/ben/dev/brane";
173
+ it("returns the sessionId when cwd matches", () => {
174
+ const raw = JSON.stringify({ sessionId: "sess-1", cwd: fold });
175
+ assert.equal(sessionIdFromRegistryEntry(raw, fold), "sess-1");
176
+ });
177
+ it("rejects a stale entry whose cwd is a different directory (recycled pid)", () => {
178
+ const raw = JSON.stringify({ sessionId: "wrong-sess", cwd: "/some/other/project" });
179
+ assert.equal(sessionIdFromRegistryEntry(raw, fold), undefined);
180
+ });
181
+ it("tolerates trailing-slash / unnormalized cwd via resolve()", () => {
182
+ const raw = JSON.stringify({ sessionId: "sess-2", cwd: fold + "/" });
183
+ assert.equal(sessionIdFromRegistryEntry(raw, fold), "sess-2");
184
+ });
185
+ it("returns undefined for an entry missing cwd or sessionId", () => {
186
+ assert.equal(sessionIdFromRegistryEntry(JSON.stringify({ sessionId: "x" }), fold), undefined);
187
+ assert.equal(sessionIdFromRegistryEntry(JSON.stringify({ cwd: fold }), fold), undefined);
188
+ });
189
+ it("returns undefined for malformed JSON", () => {
190
+ assert.equal(sessionIdFromRegistryEntry("{not json", fold), undefined);
191
+ });
192
+ });
193
+ // ---------------------------------------------------------------------------
194
+ // Same-agent MCP bind race — the two race-deleting rules:
195
+ // Rule 1: the secret is the identity; every authenticated holder binds and
196
+ // concurrent connections coexist (no session-conflict eviction).
197
+ // Rule 2: the daemon owns agent_session_id; clients never write it.
198
+ // ---------------------------------------------------------------------------
199
+ describe("same-agent MCP bind race", () => {
200
+ const BRANE = "brane-test";
201
+ function seedSpawned(db, id, bindToken, sessionId = "sess-A") {
202
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status, agent_session_id, bind_token)
203
+ VALUES (?, 'brane-test', 'claude', 'worker', 0, 'active', ?, ?)`).run(id, sessionId, bindToken);
204
+ // As the daemon's start does: the plaintext becomes an agent token row.
205
+ adoptBindTokens(db);
206
+ }
207
+ function sessionOf(db, id) {
208
+ return db.prepare("SELECT agent_session_id FROM narratives WHERE id = ?").get(id).agent_session_id;
209
+ }
210
+ function narrativeCount(db) {
211
+ return db.prepare("SELECT COUNT(*) as n FROM narratives").get().n;
212
+ }
213
+ function rejectedCount(db, id) {
214
+ return db
215
+ .prepare("SELECT COUNT(*) as n FROM events WHERE narrative_id = ? AND type = 'binding_rejected'")
216
+ .get(id).n;
217
+ }
218
+ // Minimal stand-in for a live MCP connection: boundClientCount only reads
219
+ // narrativeId + ws.readyState. Cast to the real client type at the call.
220
+ function fakeClient(narrativeId, readyState = WebSocket.OPEN) {
221
+ return { narrativeId, ws: { readyState } };
222
+ }
223
+ // Test 1 — Two concurrent authenticated connections, same nid, different
224
+ // self-detected sessions → both bind; neither is evicted; no binding_rejected,
225
+ // no external-* row minted.
226
+ it("two authenticated connections to the same nid both bind and coexist", () => {
227
+ const db = createTestDb();
228
+ seedSpawned(db, "claude-alpha", "secret-alpha", "sess-A");
229
+ const before = narrativeCount(db);
230
+ // Both connections present the correct secret (identities differ only in the
231
+ // unreliable self-detected session param, which no longer decides anything).
232
+ const bindA = resolveMcpBind(db, BRANE, "claude-alpha", "secret-alpha");
233
+ const bindB = resolveMcpBind(db, BRANE, "claude-alpha", "secret-alpha");
234
+ for (const bind of [bindA, bindB]) {
235
+ assert.equal(bind.nid, "claude-alpha");
236
+ assert.equal(bind.minted, false);
237
+ assert.equal(bind.rejected, undefined);
238
+ }
239
+ // Both resolve to the claimed nid even though their self-detected sessions
240
+ // differ, so nothing downstream has a conflict to arbitrate: the secret is
241
+ // the proof of identity, and a parent bridge, a reconnect and a subagent
242
+ // bridge are facets of one agent.
243
+ assert.equal(narrativeCount(db), before, "no external-* narrative minted");
244
+ assert.equal(rejectedCount(db, "claude-alpha"), 0, "no binding_rejected event");
245
+ // Both connections' whoami would report the same claimed nid.
246
+ assert.equal(boundClientCount(new Set([fakeClient("claude-alpha"), fakeClient("claude-alpha")]), "claude-alpha"), 2);
247
+ });
248
+ // Test 2 — the daemon owns agent_session_id; clients never write it.
249
+ it("spawn pins daemon-minted session ids for Claude and Copilot", () => {
250
+ const uuid = "11111111-1111-4111-8111-111111111111";
251
+ const claudeArgs = buildAgentArgs("claude", { sessionId: uuid });
252
+ const flagIdx = claudeArgs.indexOf("--session-id");
253
+ assert.notEqual(flagIdx, -1, "claude launch carries --session-id");
254
+ assert.equal(claudeArgs[flagIdx + 1], uuid, "the pinned id is the daemon-minted uuid");
255
+ const copilotArgs = buildAgentArgs("copilot", { sessionId: uuid });
256
+ const copilotFlagIdx = copilotArgs.indexOf("--session-id");
257
+ assert.notEqual(copilotFlagIdx, -1, "copilot launch carries --session-id");
258
+ assert.equal(copilotArgs[copilotFlagIdx + 1], uuid);
259
+ // Other agent types get no --session-id (they keep their own resolution).
260
+ const codexArgs = buildAgentArgs("codex", { sessionId: uuid });
261
+ assert.ok(!codexArgs.includes("--session-id"), "non-claude agents are not pinned at spawn");
262
+ // Claude with no minted id (defensive) omits the flag rather than emitting a bare one.
263
+ assert.ok(!buildAgentArgs("claude", {}).includes("--session-id"));
264
+ });
265
+ it("a client-supplied session id never overwrites the row (bind param or narrate meta)", () => {
266
+ const db = createTestDb();
267
+ seedSpawned(db, "claude-beta", "secret-beta", "owner-sess");
268
+ // A bind carrying a *different* session param: the secret check binds the
269
+ // connection without writing agent_session_id.
270
+ resolveMcpBind(db, BRANE, "claude-beta", "secret-beta");
271
+ assert.equal(sessionOf(db, "claude-beta"), "owner-sess", "bind path leaves the daemon-owned id intact");
272
+ // A narrate carrying meta.session_id: the row's session is unchanged, but
273
+ // meta.summary still lands (only the session-id write was removed).
274
+ handleMcpNarrate({ say: "working", meta: { session_id: "client-claimed-session", summary: "doing the thing" } }, db, "claude-beta", () => { }, "bridge-session");
275
+ assert.equal(sessionOf(db, "claude-beta"), "owner-sess", "narrate meta.session_id is ignored");
276
+ const row = db.prepare("SELECT summary FROM narratives WHERE id = ?").get("claude-beta");
277
+ assert.equal(row.summary, "doing the thing", "meta.summary is still applied");
278
+ });
279
+ /** Rendering warnings ride back on the receipt — advisory, and absent when
280
+ * there is nothing to say, so a narration that renders as written gets the
281
+ * result shape it always got. See src/markdown-diagnostics.ts. */
282
+ it("narrate reports rendering warnings, and only when there are any", () => {
283
+ const db = createTestDb();
284
+ seedSpawned(db, "claude-warn", "secret-warn", "sess-w");
285
+ const clean = handleMcpNarrate({ say: "Euler: $e^{i\\pi} + 1 = 0$ — and $5 is not math." }, db, "claude-warn", () => { });
286
+ assert.ok(!("warnings" in clean), "no key at all when nothing is wrong");
287
+ const broken = handleMcpNarrate({ say: "The identity $\\bogusmacro{x}$ holds." }, db, "claude-warn", () => { });
288
+ assert.equal(broken.warnings?.length, 1);
289
+ assert.equal(broken.warnings[0].kind, "math_parse_error");
290
+ assert.equal(broken.warnings[0].fallback, "$\\bogusmacro{x}$");
291
+ // Advisory, not a veto: the narration is written either way.
292
+ assert.equal(readEvents(db, "claude-warn", { after: 0 }).length, 2);
293
+ });
294
+ /** `strict: true` is the opt-in exception, and it has to leave *nothing*
295
+ * behind — a refusal that had already written the summary or copied the
296
+ * payloads would not be safely retryable, and the golden rule forbids
297
+ * cleaning either up afterwards. */
298
+ it("strict refuses the narration and writes nothing at all", () => {
299
+ const db = createTestDb();
300
+ seedSpawned(db, "claude-strict", "secret-strict", "sess-s");
301
+ const summaryOf = () => db.prepare("SELECT summary FROM narratives WHERE id = ?").get("claude-strict")
302
+ .summary;
303
+ const refused = handleMcpNarrate({
304
+ say: "```js\nconst x = 1;\nand the rest of this message",
305
+ meta: { summary: "must not be applied" },
306
+ strict: true,
307
+ }, db, "claude-strict", () => { });
308
+ assert.match(refused.error ?? "", /strict was requested/);
309
+ assert.match(refused.error ?? "", /fence_unclosed/);
310
+ // The refusal has to say what to do about it, or it costs a turn for nothing.
311
+ assert.match(refused.error ?? "", /Renders as:/);
312
+ assert.equal(readEvents(db, "claude-strict", { after: 0 }).length, 0, "nothing appended");
313
+ assert.notEqual(summaryOf(), "must not be applied", "no metadata written either");
314
+ // The same text without `strict` posts, warnings and all — pace over polish.
315
+ const posted = handleMcpNarrate({ say: "```js\nconst x = 1;\nand the rest of this message" }, db, "claude-strict", () => { });
316
+ assert.equal(posted.warnings?.[0].kind, "fence_unclosed");
317
+ assert.equal(readEvents(db, "claude-strict", { after: 0 }).length, 1);
318
+ });
319
+ it("strict posts as normal when the markdown renders as written", () => {
320
+ const db = createTestDb();
321
+ seedSpawned(db, "claude-strict-ok", "secret-ok", "sess-ok");
322
+ const result = handleMcpNarrate({ say: "Euler: $e^{i\\pi} + 1 = 0$, and $5 is still five.", strict: true }, db, "claude-strict-ok", () => { });
323
+ assert.ok(!("error" in result), "clean markdown is never refused");
324
+ assert.ok(!("warnings" in result));
325
+ assert.equal(readEvents(db, "claude-strict-ok", { after: 0 }).length, 1);
326
+ });
327
+ /** The receipt carries what the caller has not seen, never the narration it
328
+ * just sent: echoing `say` back put every narration into the agent's
329
+ * context twice, re-read on each later turn. */
330
+ it("narrate's receipt omits the narration it just wrote", () => {
331
+ const db = createTestDb();
332
+ seedSpawned(db, "claude-echo", "secret-echo", "sess-e");
333
+ seedSpawned(db, "claude-other", "secret-other", "sess-o");
334
+ appendEvent(db, "claude-echo", "human_signal", { content: "go ahead", source: "web" });
335
+ const first = handleMcpNarrate({ say: "starting", after: 0 }, db, "claude-echo", () => { });
336
+ assert.equal(first.seq, 2, "seq still says where the narration landed");
337
+ assert.deepEqual(first.events.self.map((e) => e.seq), [1], "the signal is returned, the narration is not");
338
+ assert.equal(readEvents(db, "claude-echo", { after: 0 }).length, 2, "and it was written");
339
+ // An earlier narration of our own is still history like any other event;
340
+ // only this call's own event is left out, including from a `tail` read.
341
+ const second = handleMcpNarrate({ say: "done", tail: 5 }, db, "claude-echo", () => { });
342
+ assert.deepEqual(second.events.self.map((e) => e.seq), [1, 2]);
343
+ // Foreign narration: the target's cursor gets everything but our event.
344
+ appendEvent(db, "claude-other", "narration", { content: "hello", source: "claude-other" });
345
+ const foreign = handleMcpNarrate({ say: "ping", nid: "claude-other", after: { self: 3, "claude-other": 0 } }, db, "claude-echo", () => { });
346
+ assert.equal(foreign.seq, 2);
347
+ assert.deepEqual(foreign.events["claude-other"].map((e) => e.content), ["hello"]);
348
+ assert.deepEqual(foreign.events.self, []);
349
+ });
350
+ // Test 3 — wrong/absent secret is rejected and minted external (regression
351
+ // pin on #354). The claimed narrative is left completely untouched.
352
+ it("a wrong or absent secret is rejected and minted external (#354 regression)", () => {
353
+ const db = createTestDb();
354
+ seedSpawned(db, "claude-gamma", "secret-gamma", "sess-g");
355
+ const wrong = resolveMcpBind(db, BRANE, "claude-gamma", "not-the-secret");
356
+ assert.match(wrong.nid, /^external-[0-9a-z]{8}$/);
357
+ assert.equal(wrong.minted, true);
358
+ assert.deepEqual(wrong.rejected, { claimedNid: "claude-gamma", reason: "secret_mismatch" });
359
+ const absent = resolveMcpBind(db, BRANE, "claude-gamma", undefined);
360
+ assert.match(absent.nid, /^external-[0-9a-z]{8}$/);
361
+ assert.equal(absent.minted, true);
362
+ assert.deepEqual(absent.rejected, { claimedNid: "claude-gamma", reason: "secret_absent" });
363
+ // Victim untouched: its session is preserved and nothing leaked into it.
364
+ assert.equal(sessionOf(db, "claude-gamma"), "sess-g");
365
+ assert.equal(db.prepare("SELECT COUNT(*) as n FROM events WHERE narrative_id = ?").get("claude-gamma").n, 0);
366
+ });
367
+ // Test 4 — the migration grace is retired. A `bind_token IS NULL` row (every
368
+ // narrative spawned before #354) is no longer bindable without a secret; it
369
+ // is refused like any other unproven claim, with its own reason so the log
370
+ // says *why* rather than blaming a secret the client may well have sent.
371
+ it("a tokenless row is refused, not waved through (grace retirement)", () => {
372
+ const db = createTestDb();
373
+ seedSpawned(db, "claude-legacy", null, "sess-A");
374
+ const before = narrativeCount(db);
375
+ const bind = resolveMcpBind(db, BRANE, "claude-legacy", undefined);
376
+ assert.match(bind.nid, /^external-[0-9a-z]{8}$/, "refused claims land on a fresh external id");
377
+ assert.equal(bind.minted, true);
378
+ assert.deepEqual(bind.rejected, { claimedNid: "claude-legacy", reason: "no_bind_token" });
379
+ // Presenting *a* secret doesn't help either — there is nothing to check it
380
+ // against — and the reason still names the row, not the client.
381
+ const withSecret = resolveMcpBind(db, BRANE, "claude-legacy", "anything-at-all");
382
+ assert.deepEqual(withSecret.rejected, { claimedNid: "claude-legacy", reason: "no_bind_token" });
383
+ // The claimed narrative is untouched: its session survives and nothing was
384
+ // written into it by the refusal itself.
385
+ assert.equal(sessionOf(db, "claude-legacy"), "sess-A");
386
+ assert.equal(rejectedCount(db, "claude-legacy"), 0, "resolveMcpBind reports; the caller records");
387
+ assert.equal(narrativeCount(db), before + 2, "one external per refused claim");
388
+ });
389
+ // Test 4b — the reconnect that the retirement would otherwise have broken.
390
+ // An external's id is minted server-side and adopted by the bridge from
391
+ // `welcome`; without a token of its own, every reconnect carrying that id
392
+ // would now be refused and minted afresh, fragmenting one client across a
393
+ // new narrative per reconnect. So externals get a real bind token.
394
+ it("a minted external can re-bind to itself (no litter on reconnect)", () => {
395
+ const db = createTestDb();
396
+ const { nid, secret: token } = mintExternalNarrativeWithSecret(db, BRANE);
397
+ assert.ok(token, "a minted external carries an agent token");
398
+ const before = narrativeCount(db);
399
+ const again = resolveMcpBind(db, BRANE, nid, token);
400
+ assert.equal(again.nid, nid, "reconnect returns to the same narrative");
401
+ assert.equal(again.minted, false);
402
+ assert.equal(again.rejected, undefined);
403
+ assert.equal(narrativeCount(db), before, "nothing new minted");
404
+ // And it is a real credential: another client that merely knows the id
405
+ // cannot take the narrative over.
406
+ const impostor = resolveMcpBind(db, BRANE, nid, undefined);
407
+ assert.match(impostor.nid, /^external-[0-9a-z]{8}$/);
408
+ assert.notEqual(impostor.nid, nid);
409
+ assert.deepEqual(impostor.rejected, { claimedNid: nid, reason: "secret_absent" });
410
+ });
411
+ // Test 5 — status transitions fire on the 0↔1 client-count boundary, so a
412
+ // secondary connection closing can't flash the narrative offline.
413
+ it("mcp connect/disconnect status keys off the 0↔1 client-count transition", () => {
414
+ const nid = "claude-delta";
415
+ const first = fakeClient(nid);
416
+ const second = fakeClient(nid);
417
+ const set = new Set([first, second]);
418
+ // Two live connections on the narrative.
419
+ assert.equal(boundClientCount(set, nid), 2);
420
+ // First of two closes — the handler deletes it, one live sibling remains
421
+ // (>0): NOT a disconnect, the close handler returns before broadcasting or
422
+ // touching tmux, so the narrative can't flash offline mid-session.
423
+ set.delete(first);
424
+ assert.equal(boundClientCount(set, nid), 1);
425
+ // Last one closes → 0 remaining: the 1→0 transition, disconnect path runs.
426
+ set.delete(second);
427
+ assert.equal(boundClientCount(set, nid), 0);
428
+ // A CLOSED socket still lingering in the set is not counted as live.
429
+ const stale = fakeClient(nid, WebSocket.CLOSED);
430
+ assert.equal(boundClientCount(new Set([stale]), nid), 0, "a CLOSED socket is not live");
431
+ // The very first connection is a 0→1 transition (empty set → connect fires).
432
+ assert.equal(boundClientCount(new Set(), nid), 0);
433
+ });
434
+ });
435
+ // ---------------------------------------------------------------------------
436
+ // Per-adapter session-id ownership + codex session capture.
437
+ // - "assign" agents (claude) get a daemon-minted id (tested above, Test 2).
438
+ // - "capture" agents (codex) generate their own id, which the daemon
439
+ // discovers from the agent's own store, scoped per-narrative.
440
+ // - the cost `session_ids` SET stays populated regardless (cost path intact).
441
+ // ---------------------------------------------------------------------------
442
+ describe("session-id ownership + codex capture", () => {
443
+ // Write a codex rollout into a fixture tree matching the real layout:
444
+ // <base>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl, first line a `session_meta`.
445
+ function writeRollout(base, when, uuid, cwd) {
446
+ const dir = join(base, String(when.getFullYear()), String(when.getMonth() + 1).padStart(2, "0"), String(when.getDate()).padStart(2, "0"));
447
+ mkdirSync(dir, { recursive: true });
448
+ const fname = `rollout-${when.toISOString().replace(/[:.]/g, "-")}-${uuid}.jsonl`;
449
+ const meta = JSON.stringify({
450
+ type: "session_meta",
451
+ payload: { id: uuid, cwd, timestamp: when.toISOString() },
452
+ });
453
+ // A second line ensures we only ever parse the first (bounded prefix read).
454
+ writeFileSync(join(dir, fname), meta + "\n" + JSON.stringify({ type: "message" }) + "\n");
455
+ }
456
+ it("adapters declare the intended session-id ownership strategy", () => {
457
+ assert.equal(getAgentAdapter("claude").sessionIdOwnership, "assign");
458
+ assert.equal(getAgentAdapter("claude").resumeCreatesNewSession, true);
459
+ assert.equal(getAgentAdapter("codex").sessionIdOwnership, "capture");
460
+ // Gemini has no local session source. Copilot accepts a daemon-minted UUID,
461
+ // so its cost and tool log are exactly scoped even in a shared fold.
462
+ assert.equal(getAgentAdapter("gemini").sessionIdOwnership, undefined);
463
+ assert.equal(getAgentAdapter("copilot").sessionIdOwnership, "assign");
464
+ assert.equal(getAgentAdapter("copilot").resumeCreatesNewSession, undefined);
465
+ assert.equal(getAgentAdapter("unknown-agent").sessionIdOwnership, undefined);
466
+ });
467
+ it("keeps Copilot's assigned session stable across revives", () => {
468
+ const copilot = getAgentAdapter("copilot");
469
+ assert.deepEqual(copilot.resumeArgs({ sessionId: "existing", newSessionId: "unused", foldPath: "/work" }), ["--session-id", "existing"]);
470
+ assert.deepEqual(copilot.resumeArgs({ newSessionId: "legacy-row-repair", foldPath: "/work" }), ["--session-id", "legacy-row-repair"]);
471
+ });
472
+ it("discoverCodexSession finds this narrative's rollout by cwd + spawn time", () => {
473
+ const base = mkdtempSync(join(tmpdir(), "codex-sessions-"));
474
+ const fold = "/Users/test/dev/proj";
475
+ const spawn = new Date(2026, 2, 5, 10, 0, 0);
476
+ // The narrative's own rollout: same fold, created just after spawn.
477
+ writeRollout(base, new Date(2026, 2, 5, 10, 0, 30), "aaaaaaaa-0000-4000-8000-000000000001", fold);
478
+ const found = discoverCodexSession(base, fold, spawn.getTime());
479
+ assert.equal(found, "aaaaaaaa-0000-4000-8000-000000000001");
480
+ });
481
+ it("discoverCodexSession ignores rollouts from other folds and before spawn", () => {
482
+ const base = mkdtempSync(join(tmpdir(), "codex-sessions-"));
483
+ const fold = "/Users/test/dev/proj";
484
+ const spawn = new Date(2026, 2, 5, 10, 0, 0);
485
+ // Different fold — must be ignored even though it's after spawn.
486
+ writeRollout(base, new Date(2026, 2, 5, 10, 1, 0), "bbbbbbbb-0000-4000-8000-000000000002", "/Users/test/dev/other");
487
+ // Right fold but BEFORE spawn (a stale predecessor session) — ignored.
488
+ writeRollout(base, new Date(2026, 2, 5, 9, 0, 0), "cccccccc-0000-4000-8000-000000000003", fold);
489
+ assert.equal(discoverCodexSession(base, fold, spawn.getTime()), undefined);
490
+ });
491
+ it("discoverCodexSession picks the earliest rollout after spawn (two same-fold narratives)", () => {
492
+ const base = mkdtempSync(join(tmpdir(), "codex-sessions-"));
493
+ const fold = "/Users/test/dev/proj";
494
+ // Narrative A spawns first; its rollout is the earliest one after A's spawn.
495
+ const spawnA = new Date(2026, 2, 5, 10, 0, 0);
496
+ writeRollout(base, new Date(2026, 2, 5, 10, 0, 20), "aaaaaaaa-0000-4000-8000-00000000000a", fold);
497
+ // Narrative B spawns later in the SAME fold; its rollout comes after B's spawn.
498
+ const spawnB = new Date(2026, 2, 5, 10, 5, 0);
499
+ writeRollout(base, new Date(2026, 2, 5, 10, 5, 20), "bbbbbbbb-0000-4000-8000-00000000000b", fold);
500
+ // Each narrative resolves to its OWN rollout via its own spawn time —
501
+ // the cwd+since scoping that avoids fold-wide cross-contamination.
502
+ assert.equal(discoverCodexSession(base, fold, spawnA.getTime()), "aaaaaaaa-0000-4000-8000-00000000000a");
503
+ assert.equal(discoverCodexSession(base, fold, spawnB.getTime()), "bbbbbbbb-0000-4000-8000-00000000000b");
504
+ });
505
+ // Narrative row seed for the capture-write tests.
506
+ function seedNarrative(db, id, agentType, sessionId) {
507
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, created_at, status, fold_path, agent_session_id)
508
+ VALUES (?, 'brane-test', ?, 'worker', 0, 'active', '~/dev/proj', ?)`).run(id, agentType, sessionId);
509
+ }
510
+ function sessionOf(db, id) {
511
+ return db.prepare("SELECT agent_session_id FROM narratives WHERE id = ?").get(id).agent_session_id;
512
+ }
513
+ it("maybeCaptureSessionId captures a codex id (and records it for cost) when the pointer is unset", () => {
514
+ const db = createTestDb();
515
+ seedNarrative(db, "codex-one", "codex", null);
516
+ const captured = maybeCaptureSessionId(db, "codex-one", "codex", "~/dev/proj", 0, null, () => "discovered-codex-id");
517
+ assert.equal(captured, "discovered-codex-id");
518
+ assert.equal(sessionOf(db, "codex-one"), "discovered-codex-id", "pointer captured onto the row");
519
+ assert.deepEqual(getSessionIds(db, "codex-one"), ["discovered-codex-id"], "captured id is in the cost set");
520
+ });
521
+ it("maybeCaptureSessionId is a no-op for assign agents, none agents, and an already-set pointer", () => {
522
+ const db = createTestDb();
523
+ // assign agent (claude): the daemon assigns; capture never runs.
524
+ seedNarrative(db, "claude-x", "claude", null);
525
+ assert.equal(maybeCaptureSessionId(db, "claude-x", "claude", "~/dev/proj", 0, null, () => "should-not-be-used"), undefined);
526
+ assert.equal(sessionOf(db, "claude-x"), null);
527
+ // none agent (gemini): no id to capture.
528
+ seedNarrative(db, "gemini-x", "gemini", null);
529
+ assert.equal(maybeCaptureSessionId(db, "gemini-x", "gemini", "~/dev/proj", 0, null, () => "nope"), undefined);
530
+ assert.equal(sessionOf(db, "gemini-x"), null);
531
+ // capture agent but pointer already set: never clobbered.
532
+ seedNarrative(db, "codex-set", "codex", "already-have-one");
533
+ assert.equal(maybeCaptureSessionId(db, "codex-set", "codex", "~/dev/proj", 0, "already-have-one", () => "new-id"), undefined);
534
+ assert.equal(sessionOf(db, "codex-set"), "already-have-one", "an established pointer is not overwritten");
535
+ });
536
+ it("the cumulative session_ids cost set stays populated (cost path intact under Rule 2)", () => {
537
+ const db = createTestDb();
538
+ seedNarrative(db, "claude-cost", "claude", "current-sess");
539
+ // recordSessionId feeds the SET independently of the row pointer; dedups.
540
+ recordSessionId(db, "claude-cost", "sess-1");
541
+ recordSessionId(db, "claude-cost", "sess-2");
542
+ recordSessionId(db, "claude-cost", "sess-1"); // duplicate ignored
543
+ // getSessionIds = the SET ∪ the current agent_session_id — what cost sums over.
544
+ const ids = getSessionIds(db, "claude-cost");
545
+ assert.deepEqual(ids.sort(), ["current-sess", "sess-1", "sess-2"].sort());
546
+ });
547
+ });
548
+ //# sourceMappingURL=mcp.test.js.map
@@ -0,0 +1,85 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { buildMeldUrl } from "../commands/meld.js";
5
+ function setTls(db, value) {
6
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_tls', ?)").run(value ? "true" : "false");
7
+ }
8
+ const daemon = { port: "8443", token: "tok" };
9
+ const TS_HOST = "myhost.tailnet.ts.net";
10
+ describe("buildMeldUrl", () => {
11
+ let db;
12
+ before(() => {
13
+ db = createTestDb();
14
+ });
15
+ after(() => {
16
+ db.close();
17
+ });
18
+ it("emits an HTTPS Tailscale URL when a cert is present (healthy path)", () => {
19
+ setTls(db, true);
20
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
21
+ assert.equal(r.scheme, "https");
22
+ assert.equal(r.useLocal, false);
23
+ assert.equal(r.isLoopbackFallback, false);
24
+ assert.equal(r.tlsDegraded, false);
25
+ assert.ok(r.url.includes("brane.organelle.dev"));
26
+ });
27
+ it("carries host and token in the fragment, never the query string", () => {
28
+ // The shared SPA is served by a third party (Cloudflare). A query string
29
+ // is part of the request it receives; a fragment never leaves the browser.
30
+ setTls(db, true);
31
+ const u = new URL(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST }).url);
32
+ assert.equal(u.search, "");
33
+ const conn = new URLSearchParams(u.hash.slice(1));
34
+ // daemonHost is host:port unless the TLS port is 443; the round trip
35
+ // through the fragment must keep the port, which is what a phone dials.
36
+ assert.equal(conn.get("host"), `${TS_HOST}:${daemon.port}`);
37
+ assert.equal(conn.get("token"), daemon.token);
38
+ assert.ok(!u.href.includes("?"), u.href);
39
+ });
40
+ it("the direct daemon URL carries only the token, in the fragment", () => {
41
+ setTls(db, false);
42
+ const u = new URL(buildMeldUrl({ db, daemon, tailscaleHost: undefined }).url);
43
+ assert.equal(u.search, "");
44
+ const conn = new URLSearchParams(u.hash.slice(1));
45
+ assert.equal(conn.get("host"), null);
46
+ assert.equal(conn.get("token"), daemon.token);
47
+ });
48
+ it("keeps the fragment after a path", () => {
49
+ setTls(db, true);
50
+ const u = new URL(buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, path: "claude-abc123" }).url);
51
+ assert.equal(u.pathname, "/claude-abc123");
52
+ assert.equal(new URLSearchParams(u.hash.slice(1)).get("token"), daemon.token);
53
+ });
54
+ it("flags tlsDegraded when Tailscale is up but no cert exists", () => {
55
+ setTls(db, false);
56
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST });
57
+ assert.equal(r.scheme, "http");
58
+ assert.equal(r.useLocal, true);
59
+ assert.equal(r.isLoopbackFallback, false);
60
+ assert.equal(r.tlsDegraded, true, "should warn: Tailscale up, no cert");
61
+ assert.ok(r.daemonHost.startsWith(TS_HOST));
62
+ });
63
+ it("does not flag tlsDegraded when --local is forced", () => {
64
+ setTls(db, false);
65
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, local: true });
66
+ assert.equal(r.tlsDegraded, false, "explicit --local is a deliberate choice");
67
+ assert.equal(r.useLocal, true);
68
+ });
69
+ it("falls back to loopback (not tlsDegraded) when Tailscale is absent", () => {
70
+ setTls(db, false);
71
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: undefined });
72
+ assert.equal(r.isLoopbackFallback, true);
73
+ assert.equal(r.tlsDegraded, false);
74
+ assert.ok(r.daemonHost.startsWith("127.0.0.1"));
75
+ });
76
+ it("does not flag tlsDegraded when advertising a custom --host", () => {
77
+ // A custom host that isn't the Tailscale hostname isn't a degraded TLS
78
+ // case — the operator chose to advertise something else.
79
+ setTls(db, false);
80
+ const r = buildMeldUrl({ db, daemon, tailscaleHost: TS_HOST, host: "192.168.1.5" });
81
+ assert.equal(r.tlsDegraded, false);
82
+ assert.equal(r.isLoopbackFallback, false);
83
+ });
84
+ });
85
+ //# sourceMappingURL=meld.test.js.map