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,101 @@
1
+ # Mentions
2
+
3
+ `@claude-4c7289d1` **addresses** a narrative; the bare `claude-4c7289d1`
4
+ **cites** one. Until this existed, the only way to get another agent's
5
+ attention was to narrate into its narrative — which puts a message in someone
6
+ else's conversation, permanently, to say "look over here". A mention is the
7
+ quiet instrument: it leaves the other log untouched and points at what you
8
+ wrote in yours.
9
+
10
+ That requirement is also the design's whole justification. A mention is
11
+ deliberately **not an event**:
12
+
13
+ | | ledger row | synthetic event |
14
+ |---|---|---|
15
+ | Writes into the recipient's log | no — *the point* | yes |
16
+ | `nid:seq` permalink space | untouched | diluted (the `tool_uses` argument) |
17
+ | Re-scan / rebuild | idempotent via `UNIQUE` | impossible |
18
+ | Recursion when the copy quotes the handle | can't happen | needs a guard |
19
+
20
+ The cost is that delivery isn't free: `checkForEvents` returns `null` unless an
21
+ `events` row appeared, so a ledger row alone would wake a parked `listen` that
22
+ finds nothing and parks again — and with `LISTEN_TIMEOUT_BY_AGENT.claude` at
23
+ 3400s, "discovered on the next listen" means up to 56 minutes. Mentions
24
+ therefore have their own resolve predicate, threaded through `PendingListen`.
25
+
26
+ | Piece | Where | Does |
27
+ |---|---|---|
28
+ | `mentionSpec` / `collectMentionSpec` | `src/shared/markdown-mentions.ts` | The one pattern. No bare npm imports, so metro needs no alias |
29
+ | `extractMentions()` | `src/mentions-scan.ts` | The daemon-side walk — the renderer with its output thrown away |
30
+ | `mentions` table | `src/db/schema.ts` | The row, and what became of it |
31
+ | `src/db/mentions.ts` | | record / pending / ack / annotate / dismiss |
32
+ | `recordEventMentions()` | `src/daemon/server.ts` | The scan and the insert |
33
+ | `announceEvent()` | `src/daemon/server.ts` | The one order: record, *then* wake. Every conversational append site calls it |
34
+ | `mentionReceipt()` | `src/daemon/server.ts` | Ack, annotate, report — in that order |
35
+
36
+ Six things are less obvious than they look:
37
+
38
+ **One parse, never a second scanner.** Recording runs the *same* mdast walk and
39
+ the *same* spec as the renderer, so a handle fires a notification **iff** the
40
+ operator sees it as a mention. A regex over `content` would be the parallel
41
+ scanner the [Rendering Warnings](rendering-warnings.md) section forbids, and its
42
+ failure is worse here because it is invisible: a pasted log with a handle in a
43
+ fenced block shows as inert code while silently waking an agent. This is also
44
+ why `mdast-util-from-markdown` and `mdast-util-gfm-autolink-literal` are
45
+ `dependencies` — as devDependencies they worked in the checkout and would have
46
+ thrown on first `narrate` from a published tarball.
47
+
48
+ **Order: mentions, then citations.** Each mdast extension's links are opaque to
49
+ the next, so `narrativeIdLinks` running first consumes the id and strands the
50
+ `@` — which is exactly what brane did before mentions existed. `nidSpec` now
51
+ carries `boundary: before => before !== "@"`, so the two **must ship
52
+ together**: with the veto and without `mentionLinks`, an `@`-prefixed id stops
53
+ rendering as a link at all.
54
+
55
+ **Two marks, per row, not per narrative.** `sent_at` governs the wake — set on
56
+ delivery, so a mention wakes an agent exactly once. `seen_at` governs the
57
+ reminder — set only by evidence of attention, so a delivered-but-unread mention
58
+ keeps reappearing. They must be separate: if the only thing clearing a mention
59
+ were acknowledgment, an agent that never acked would get a `listen` returning
60
+ instantly forever. And they must be **per row**, because acknowledgment is per
61
+ *source*: a high-water mark over a shared id space settles every lower id when
62
+ one is acked, silently marking read a mention from a narrative the agent never
63
+ opened. `NULL → timestamp` also makes monotonicity structural — there is no
64
+ value that could rewind, which a cursor re-derived from `tail: 50` after a
65
+ restart otherwise can.
66
+
67
+ **Receiving the event is the acknowledgment.** Interpreting a mention means
68
+ reading its source, and an agent does that by naming the narrative in the
69
+ cursor it already passes. So the evidence and the attention are the same act —
70
+ strictly stronger than an ack tool, which an agent can call reflexively without
71
+ looking. It settles the seqs **actually returned**, never `seq > after`:
72
+ `before` truncates a range and `tail: N` skips the middle, so a bound would
73
+ settle mentions the agent was never shown.
74
+
75
+ **Settle in the same block as the send.** `mentionReceipt` is spread directly
76
+ into the object handed to `sendToolResult`, so nothing can run between marking
77
+ sent and sending. A resolve that doesn't advance durable state makes the next
78
+ `listen` return instantly, forever, at inference speed, with no operator in the
79
+ loop — the failure that would make the feature worse than not having it.
80
+ `src/test/mentions-listen.test.ts` is the regression guard. The shutdown flush
81
+ is the one deliberate exception: it replies without a receipt, leaving mentions
82
+ unsent rather than marking them delivered to an agent about to lose its bridge.
83
+
84
+ **Record before you wake, and in one place.** `notifyListenWaiters` resolves a
85
+ parked `listen` **synchronously** — the waiter builds its receipt and acks by
86
+ read inside that call. So an append that wakes before it inserts acks a row
87
+ that does not exist yet: zero rows updated, the mention lands `seen_at NULL`,
88
+ and the agent's cursor has now moved past the one event that could ever settle
89
+ it. It is delivered, read, and permanently unsettled — reappearing in every
90
+ later receipt until someone reads backwards or dismisses it. This is not a
91
+ race; it is deterministic for exactly the agent watching the narrative that
92
+ mentioned it, which is why the feature's own tests missed it: every one of them
93
+ records before any listener parks. `announceEvent()` is therefore the single
94
+ ordering, called from all six sites rather than open-coded at each — the bug
95
+ was six independent chances to get two lines wrong, and a seventh site would
96
+ have had nothing to remind it.
97
+
98
+ Delivery is at-most-once — `sendToolResult` drops silently on a closing socket
99
+ — so "settled" must never mean "unreachable": `whoami` and
100
+ `listen({mentions: "all"})` replay regardless of either mark. Knobs:
101
+ `mentions.enabled` (kill switch), `mentions.receipt_limit`.
@@ -0,0 +1,14 @@
1
+ # Multi-Brane
2
+
3
+ A single daemon can host multiple branes. The **home brane** is auto-generated on first run (`brane-<hex>`). Foreign branes are created by `brane absorb` when importing conversation history from other providers.
4
+
5
+ - **Narratives** have a `brane_id` column linking them to their brane
6
+ - **Folds** have a `brane_id` column — `brane spawn` inherits the fold's brane_id
7
+ - **Absorb** creates a deterministic foreign brane_id from `sha256(provider:identity_id)`, ensuring re-imports target the same brane
8
+ - **`brane list`** groups narratives by brane with per-brane limits
9
+ - **`GET /api/branes`** returns all branes with narrative counts for the web UI
10
+ - **BraneSwitcher** in the Expo app replaces the old DaemonSwitcher — shows branes in a flat list, filters narratives by selected brane
11
+
12
+ Brane_id resolution chain for new narratives: fold's `brane_id` → `config default.brane` → home brane (`getBraneId`).
13
+
14
+ Existing folds are backfilled to the home brane on upgrade (idempotent, runs on every `openDatabase()`).
@@ -0,0 +1,165 @@
1
+ # Passkeys
2
+
3
+ Passkeys are **designed, spiked and tested on real devices, but not built**.
4
+ This page is the design and the evidence behind it, for whoever builds
5
+ enrollment. The pieces already in the code are per-device tokens (#549,
6
+ #552), the lapse for unused tokens (#555), and the presence check
7
+ (`src/presence.ts`, [Presence Check](presence.md)).
8
+
9
+ ## The rule
10
+
11
+ **A passkey guards minting a token, never using one.** Every request keeps
12
+ authenticating with its device token, so a browser that cannot do WebAuthn
13
+ — the plain-HTTP fallback, an in-app webview, one with a flaky ceremony —
14
+ keeps working once it holds a token. The ceremony happens only when a new
15
+ device is let in (claude-pbg4whll:21). The worst outcome of a passkey
16
+ failure is a slower way to add a device, never a browser locked out of
17
+ brane.
18
+
19
+ What else a passkey guards — "Sign out other devices", an agent asking for
20
+ a token — is **not decided** (claude-pbg4whll:40).
21
+
22
+ ## Decisions
23
+
24
+ | Decision | Why | Where |
25
+ |---|---|---|
26
+ | The RP ID is the daemon's own hostname (`<machine>.<tailnet>.ts.net`) | The daemon works with nothing outside the machine and the tailnet | claude-0d36vrut:16, claude-0d36vrut:21 |
27
+ | The hosted shell (`brane.organelle.dev`) does not share the passkey | It hops to the daemon's own page for the rare ceremony. No `/.well-known/webauthn`, no port 443, no `tailscale serve`, no reliance on related-origins support | claude-pbg4whll:35 |
28
+ | No attestation, random user handle | The daemon learns a public key and nothing about the authenticator — except for the bootstrap key (below) | claude-82b6w9o3:12 |
29
+ | The first enrollment needs the presence check | A software authenticator sets "user verified" with nobody present, and without attestation the daemon cannot tell; an agent acting as this account could otherwise enroll its own passkey | claude-pbg4whll:42 |
30
+ | YubiKeys stay possible and the top tier | Attested hardware proves itself; a key also covers headless Linux | claude-pbg4whll:49, claude-pbg4whll:50 |
31
+ | Ship enrollment before requiring it | Enrolling changes nothing until the restriction is switched on, so it can ship and be lived with first | claude-pbg4whll:33 |
32
+
33
+ ## Evidence
34
+
35
+ **Spike, headless Chromium** (claude-0d36vrut:21). A virtual authenticator
36
+ against a `node:crypto`-only verifier: single-use challenge, origin
37
+ allowlist, RP ID hash, user-verified flag, ES256 signature. A passkey on
38
+ the daemon's hostname enrolled and signed in on the daemon's page; a
39
+ replayed assertion and an unlisted origin were refused. An RP ID of the
40
+ whole tailnet (`<tailnet>.ts.net`) works on the daemon's page but nothing
41
+ can serve its `.well-known`, so it was dropped.
42
+
43
+ **Check 1, real devices** (claude-pbg4whll:29). The same verifier on a
44
+ spare port, tailnet-only, with the daemon's Tailscale certificate:
45
+
46
+ | Browser | Enroll | Sign in | Replay refused |
47
+ |---|---|---|---|
48
+ | iOS Safari | ✅ Face ID | ✅ | ✅ |
49
+ | iOS Chrome | ✅ | ✅ 5/5 after the fix below | not rerun |
50
+ | Mac Chrome 152 | — | ✅ through the phone QR (hybrid) | ✅ |
51
+ | Mac Safari, Firefox, Android, Windows | not tried | | |
52
+
53
+ - The passkey **synced to iCloud Keychain** (backup-eligible and
54
+ backed-up flags both set), and Face ID set user-verified.
55
+ - The origin carried the non-standard port (`:47443`) and verified; the
56
+ port is not part of the RP ID.
57
+ - Mac Chrome did not offer the iCloud passkey directly and went through
58
+ the phone. Whether a Chrome setting changes that is unverified.
59
+
60
+ ## What the browser side must do
61
+
62
+ Learned on the phone, then confirmed by the research below:
63
+
64
+ 1. **Have the challenge before the tap.** Fetching it between the tap and
65
+ `navigator.credentials.get/create` made iOS refuse with
66
+ `NotAllowedError` (2 of 7 sign-ins succeeded in iOS Chrome before the
67
+ fix, 5 of 5 after). Fetch the next challenge the moment one is taken.
68
+ 2. **One ceremony at a time.** Disable the button and keep a single
69
+ `AbortController`, aborting the previous request before starting
70
+ another. A second tap while a prompt was open failed in testing.
71
+ 3. **Treat `NotAllowedError` as routine.** It means cancel, timeout, no
72
+ credential, or no user activation, and since Safari 17.4 WebKit also
73
+ rate-limits ceremonies (unverified: a vendor blog, no Apple figures).
74
+ Reset quietly and let the operator tap again; never show it as a fault.
75
+ 4. **If no challenge is ready, wait for one** with a spinner, then ask for
76
+ a fresh tap. Calling the prompt without one is what WebKit refuses.
77
+
78
+ ## Daemon side
79
+
80
+ - **Challenges** are random, single-use, bound to the pending ceremony, and
81
+ expire with the ceremony's `timeout` (a few minutes; the exact lifetime is
82
+ a choice, not a sourced figure).
83
+ - **Verify the full origin, port included**, against the daemon's own list;
84
+ the RP ID hash; user-verified; the signature.
85
+ - **Ignore `signCount` when it is 0**, which is every synced passkey.
86
+ Enforce it only for a credential that has reported a non-zero count.
87
+ - **Store at registration:** credential id, public key, algorithm,
88
+ transports, AAGUID, sign count, and the backup-eligible / backed-up
89
+ flags. Record which presence method confirmed the enrollment.
90
+ - **When a device-bound key enrolls** (backup-eligible = 0, e.g. a
91
+ YubiKey), prompt for a second credential as its backup.
92
+ - **Bootstrap attestation:** ask for `attestation: "direct"` on the first
93
+ enrollment only. A YubiKey 5+ answers with a certificate the daemon can
94
+ verify against the FIDO Metadata Service by AAGUID; that counts as
95
+ presence by itself. iCloud passkeys send none and fall back to the
96
+ presence check.
97
+
98
+ ## Research
99
+
100
+ From a web survey on making passkeys robust (claude-pbg4whll:70). Claims
101
+ from vendor blogs are marked.
102
+
103
+ - **`excludeCredentials` on create**, with stored transports; treat
104
+ `InvalidStateError` ("already registered") as success.
105
+ [web.dev](https://web.dev/articles/passkey-registration)
106
+ - **`hints: ["security-key", …]`** keeps YubiKeys first-class; without it
107
+ Chrome on Android may not offer an NFC key. Chrome 128+ honours hints;
108
+ Safari and Firefox are unverified.
109
+ [Chrome](https://developer.chrome.com/blog/passkeys-updates-chrome-129)
110
+ - **Discoverable credentials with an empty `allowCredentials`**: a list
111
+ whose transports are only `internal` makes Chrome hide the phone QR.
112
+ [Corbado](https://www.corbado.com/blog/webauthn-passkey-qr-code) (vendor)
113
+ - **The Signal API** cleans stale passkeys out of the password manager —
114
+ the remedy for the orphan the test page left on the operator's phone.
115
+ `signalAllAcceptedCredentials` after a device is added or removed, never
116
+ with an empty list (that hides every passkey). Chrome 132+; Safari 26
117
+ partial; Firefox none.
118
+ [Chrome](https://developer.chrome.com/docs/identity/webauthn-signal-api)
119
+ - **Feature-detect with `PublicKeyCredential.getClientCapabilities()`**
120
+ (Safari 17.4+, Chrome 133+, Firefox 135+) to choose which buttons to
121
+ show, never to block.
122
+ [web.dev](https://web.dev/articles/webauthn-client-capabilities)
123
+ - **Hybrid (phone QR) is the most fragile path**: Bluetooth on both
124
+ devices, close together; Chrome on macOS can fail to find the phone after
125
+ its Bluetooth permission prompt
126
+ ([Chromium 349393380](https://issues.chromium.org/issues/349393380)).
127
+ - **Passkey autofill (conditional UI) adds little**: brane has no username
128
+ field and the ceremony is rare.
129
+ - **The RP ID cannot change.** Renaming the machine or the tailnet orphans
130
+ every passkey; recovery covers it.
131
+ - **WebAuthn refuses a page with a certificate error**, and the HTTP
132
+ fallback can never run a ceremony.
133
+
134
+ ## Open questions
135
+
136
+ 1. **What a passkey guards beyond adding a device** — the operator's call
137
+ (claude-pbg4whll:40).
138
+ 2. **The flow for approving a new device from one already trusted**: the
139
+ new device shows a code, the trusted one approves with Face ID. Not
140
+ designed; includes how the approval returns to an app saved to the home
141
+ screen, which may open Safari.
142
+ 3. **The hop from the hosted shell** to the daemon's page and back must
143
+ carry a short-lived, single-use approval, never a token.
144
+ 4. **Renames**: should the daemon record each passkey's RP ID and warn
145
+ before a hostname change?
146
+ 5. **Untried**: Mac Safari and Firefox; Android; the presence check's
147
+ Windows/WSL and polkit backends; a YubiKey's attestation and its
148
+ signature as a presence method (claude-pbg4whll:63).
149
+
150
+ ## Build order
151
+
152
+ 1. ~~Unused-token lapse~~ — #555, verified live: an unscanned meld token
153
+ was revoked by the sweep 31 s after its deadline (claude-pbg4whll:96).
154
+ 2. ~~Devices list~~ — #558: each row records the browser, platform and address
155
+ it was first presented from, and revoke is a bodiless
156
+ `POST /api/tokens/:id/revoke` (claude-59qp4ud5:4). See [Token
157
+ Rotation](token-rotation.md#the-devices-list). Revoking is no more gated
158
+ than "Sign out other devices" yet; whether a passkey should guard it is
159
+ open question 1.
160
+ 3. **Passkey enrollment and verification**, nothing yet required.
161
+ 4. **Wire in `confirmPresence`** for the first enrollment and for
162
+ recovery, with `reason` built on the daemon.
163
+ 5. **Switch the restriction on**: after the first passkey, this machine's
164
+ account (a tokenless request over `daemon.sock`) can no longer mint
165
+ device tokens, and meld tickets need a passkey.
@@ -0,0 +1,202 @@
1
+ # Presence Check
2
+
3
+ `confirmPresence(reason)` (`src/presence.ts`) asks the operator to prove
4
+ they are at this machine, using the operating system's own
5
+ owner-authentication prompt. It is the piece passkey enrollment and
6
+ recovery rest on, and what [the gate](#the-gate) holds meld, a device's
7
+ sign-out and rotation on. Passing it is what makes someone
8
+ the operator — see [Who the operator is](../SECURITY.md#who-the-operator-is).
9
+
10
+ ## Why passkeys need it
11
+
12
+ A passkey's "user verified" flag says a person unlocked the authenticator,
13
+ but a software authenticator can set that flag with nobody present (the
14
+ passkey spike did exactly that with a virtual authenticator). iCloud
15
+ passkeys carry no attestation, so the daemon cannot tell one from a
16
+ script. An agent acting as this account could therefore enroll a passkey
17
+ of its own. The first enrollment, and recovery when no passkey works, are
18
+ confirmed here instead: an agent running as the operator can raise the
19
+ system prompt but cannot put a finger on the sensor or type a password it
20
+ does not know.
21
+
22
+ An attested hardware key (a YubiKey 5 or later) proves itself without this
23
+ check and remains the strongest option; see [Passkeys](passkeys.md).
24
+
25
+ ## Backends
26
+
27
+ Each runs a tool the OS already ships, so brane carries no native module.
28
+
29
+ | Platform | Tool | Asks for | Tested |
30
+ |---|---|---|---|
31
+ | macOS | `osascript -l JavaScript` → `LAContext`, policy `deviceOwnerAuthentication` | Touch ID, Apple Watch, or the login password | yes, on a real prompt; and raised by the daemon's gate: approve, decline, cooldown, Ctrl-C (2026-10-05) |
32
+ | Windows, WSL | `powershell.exe` (5.1) → `UserConsentVerifier` | Windows Hello: face, fingerprint, or PIN | no |
33
+ | Linux desktop | `pkcheck` against `dev.brane.confirm-presence` | the user's own password (`auth_self`) | no |
34
+ | Headless Linux | — | `unavailable` | — |
35
+
36
+ Less obvious than it looks:
37
+
38
+ - **macOS has no approve button.** The dialog's only ways through are
39
+ biometrics or the password field, so synthetic clicks can cancel it but
40
+ never pass it.
41
+ - **Every command is a fixed absolute system path** (`/usr/bin/osascript`,
42
+ `/usr/bin/pkcheck`, `C:\Windows\System32\…\powershell.exe`), never a
43
+ bare name on the daemon's inherited `PATH` nor a path built from its
44
+ environment (`%SystemRoot%`). This is hygiene against a careless agent's
45
+ `PATH`, not a defence against a hostile one: a process running as the
46
+ operator can already edit `dist/` or `brane.db`, which brane accepts as
47
+ out of scope. The check raises the bar for agents that follow the rules.
48
+ - **The reason never enters a script.** On macOS it is an `argv` element;
49
+ on Windows it is a PowerShell single-quoted literal inside a script sent
50
+ as `-EncodedCommand` (UTF-16LE base64), which survives WSL interop's
51
+ quoting untouched.
52
+ - **WSL uses Windows Hello**, not polkit: the prompt appears on the Windows
53
+ desktop, out of reach of anything inside the Linux VM. PowerShell 7
54
+ (`pwsh`) lacks the WinRT projection, hence `powershell.exe`.
55
+ - **`canceled` means the operator said no, and nothing else.** A system
56
+ that took the dialog away, too many wrong attempts, or polkit refusing
57
+ without asking (a daemon outside the active desktop session, e.g. started
58
+ over SSH: pkcheck exit 1) are all `failed`.
59
+ - **Linux needs brane's polkit policy installed.** polkit's built-in
60
+ actions mostly ask for an administrator, which is the wrong question; this
61
+ one asks the operator to prove they are themselves (`auth_self`), every
62
+ time (no `_keep`). Without it the check reports `unavailable`; the "no
63
+ such action" message is matched before the exit code, since pkcheck
64
+ reports errors as 127 (untested against a real polkit). brane ships no
65
+ copy of the file; install it with:
66
+
67
+ ```sh
68
+ sudo tee /usr/share/polkit-1/actions/dev.brane.confirm-presence.policy >/dev/null <<'EOF'
69
+ <?xml version="1.0" encoding="UTF-8"?>
70
+ <!DOCTYPE policyconfig PUBLIC
71
+ "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
72
+ "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
73
+ <policyconfig>
74
+ <vendor>brane</vendor>
75
+ <vendor_url>https://github.com/benjamn/brane</vendor_url>
76
+ <action id="dev.brane.confirm-presence">
77
+ <description>Confirm you are at this machine for brane</description>
78
+ <message>brane wants to confirm you are at this machine before it trusts a new passkey or device</message>
79
+ <defaults>
80
+ <allow_any>no</allow_any>
81
+ <allow_inactive>no</allow_inactive>
82
+ <allow_active>auth_self</allow_active>
83
+ </defaults>
84
+ </action>
85
+ </policyconfig>
86
+ EOF
87
+ ```
88
+ - **Headless Linux is `unavailable` on purpose.** polkit would fall back to
89
+ a password prompt in the terminal, and agents run in terminals.
90
+
91
+ ## The gate
92
+
93
+ `PresenceGate` (`src/daemon/presence-gate.ts`) is where the daemon calls
94
+ it, and `src/daemon/gated-routes.ts` holds the routes that ask it. Four
95
+ acts, asked from this machine's shell (a tokenless request over
96
+ `daemon.sock`), wait in the route until the operator answers:
97
+
98
+ | Act | Route | CLI | The prompt says |
99
+ |---|---|---|---|
100
+ | `meld` | `POST /api/tokens` | `brane meld`, `brane spawn --meld` | add a device to brane |
101
+ | `meld-remote` | `POST /api/tokens/remote` | `brane meld --remote` | add a device to brane on another machine |
102
+ | `revoke-device` | `POST /api/tokens/:id/revoke`, when it signs out a device | `brane token revoke <device>` | sign out a device from brane |
103
+ | `rotate` | `POST /api/token/rotate` | `brane token rotate` | sign out every other device from brane |
104
+
105
+ A device token is never prompted: the app's Meld sheet, Revoke and "Sign
106
+ out other devices" are the operator's delegate acting, and work from
107
+ anywhere. **An agent's token is refused all three, unasked** (403,
108
+ `presence: "agent"`; `gateStanding`): they are the operator's acts, and an
109
+ agent that follows the rules should never put a dialog in front of the
110
+ operator — one that read exactly like the prompt their own `brane meld`
111
+ raises would be approved out of habit. So an agent cannot `brane spawn
112
+ --meld`; the operator scans from the Meld sheet instead. Who is asking is
113
+ read from the token's kind, never from the address. Revoking an agent is
114
+ not gated, for an agent or the shell; stopping an agent the account
115
+ started is the account's to do.
116
+
117
+ Less obvious than it looks:
118
+
119
+ - **The prompt's words are fixed per act.** Nothing from a request reaches
120
+ them, not a device's label nor its user agent: whoever minted a token
121
+ chose those, and an agent that could write the prompt could make it read
122
+ like the one the operator is expecting.
123
+ - **Check, ask, check again.** A request the route would refuse (a bad
124
+ mint body; revoking this machine's own row, the requester's own, or a row that
125
+ is not live) is refused before anyone is asked, so the operator never
126
+ approves something that then fails. The revoke re-checks after the
127
+ prompt, since rows can change during it.
128
+ - **Rotation asks before `beginRotation`.** A second begin replaces a
129
+ pending rotation, so a refused request must never reach it: a loop could
130
+ otherwise void the operator's own rotation without passing a prompt.
131
+ - **One prompt at a time, no queue.** A second gated request while one is
132
+ up is refused at once (409). After one that was not approved (canceled,
133
+ timed out, failed, canceled from a device, or withdrawn by its asker) the
134
+ gate refuses for 15 s (429, with `Retry-After`), so a loop cannot keep a
135
+ dialog on screen until it is approved out of habit; giving up and asking
136
+ again is that loop, so a withdrawal counts. Both are global, but only the
137
+ shell can reach the gate (an agent is refused before it), so only another
138
+ shell's request can make the operator's `brane meld` wait; the Meld
139
+ sheet still works regardless. The 409 names the prompt that is up and who asked, since its
140
+ dialog may read exactly like the one the operator expected.
141
+ - **No dialog outlives its request.** A gated request waits 45 s; if its
142
+ client goes away first (Ctrl-C), the helper is killed (`runPlan`'s
143
+ `signal`), which takes the dialog down with it: seen live on macOS
144
+ (2026-10-05), logged as `aborted`, and followed by the cooldown like any
145
+ prompt not approved.
146
+ - **Every device hears of it, and can cancel; nothing can approve.** The
147
+ control socket carries `{ type: "presence", pending }` when a prompt
148
+ opens (`pending` names the act, the prompt, who asked, and when it
149
+ expires) and `pending: null` when it closes; a socket that connects
150
+ mid-prompt is told at once. `POST /api/presence/cancel?id=` closes it,
151
+ and only a device token may call it: anything on this machine that could
152
+ cancel could cancel every prompt as it appeared and, with the cooldown,
153
+ keep `brane token rotate` from ever completing. The asker withdraws by
154
+ disconnecting. A remote approve would be a device minting a token, which
155
+ the Meld sheet already does in the open.
156
+ - **Every attempt is a log line**: `[presence] <act> for <who>: approved`,
157
+ `canceled`, `remote-cancel`, `timeout`, `aborted`, `unavailable`, or
158
+ refused as busy, cooling down, or `an agent's token` — an agent's attempt
159
+ raises no prompt but is recorded like one that did.
160
+ - **No prompt, no act.** Where the platform cannot show one (headless
161
+ Linux, a container), the gated acts are refused with `unavailable`; a
162
+ device token still does them. The first device of such a machine comes
163
+ from elsewhere: `brane meld --remote` has the operator's own daemon mint
164
+ the token, behind this machine's prompt (`meld-remote`), and sends the
165
+ remote only its hash, which its daemon adopts at startup
166
+ (`BRANE_DEVICE_TOKEN_SHA256`; see `protocols/fly.md`). The minting daemon
167
+ keeps nothing: the token is for another daemon, and the response is its
168
+ only copy. There is no switch that turns the gate off.
169
+ - **Not containment.** `meld-remote` gates the sanctioned way to reach a
170
+ remote daemon, not the remote: a process that can run `fly secrets set`
171
+ (or whatever `--send` runs) can hand that daemon a hash of its own
172
+ making, no brane involved. A device token is never prompted, whoever holds it,
173
+ so a local process that minted itself one before the gate existed skips
174
+ it; so does one that restarts the daemon with `--token` or
175
+ `BRANE_DEVICE_TOKEN_SHA256` set, which adopt a device token at startup. Check the Devices list for devices added by "this machine" or by an
176
+ agent that you did not add.
177
+
178
+ ## Results
179
+
180
+ `{ ok: true, method }`, or `{ ok: false, method, reason, detail }` with
181
+ `reason` one of `canceled` (the operator said no), `unavailable` (no prompt
182
+ can be shown here), `failed`, `timeout` (default 120 s), or `aborted` (the
183
+ caller's `signal` withdrew the question, and the helper was killed). It
184
+ never throws.
185
+
186
+ ## Tests
187
+
188
+ `src/test/presence.test.ts` checks each backend's plan as data — command,
189
+ arguments, quoting, and how each tool's answer is read — so it runs on any
190
+ platform and never raises a prompt. The macOS cancel result it parses was
191
+ recorded from a real dialog.
192
+
193
+ ## For whoever wires it in, or first runs it on Windows
194
+
195
+ - **Build `reason` on the daemon** from what is being approved, never from
196
+ text in a request, or an agent writes the prompt's words itself ("Unlock
197
+ brane"). The prompt naming what it approves is half of what makes it safe.
198
+ - **Windows Hello from a hidden console may open behind other windows**;
199
+ the desktop API is `RequestVerificationForWindowAsync(hwnd)`. And a
200
+ timeout that kills `powershell.exe` (or WSL's interop proxy) may leave the
201
+ dialog open with nobody listening. Both are the first things to watch in
202
+ a real Windows run.
@@ -0,0 +1,104 @@
1
+ # Releasing
2
+
3
+ Publishing to npm is done by `.github/workflows/release.yml`, and only by it.
4
+ No npm token exists in this repository or its secrets: the job authenticates
5
+ with a short-lived OIDC token ([npm trusted
6
+ publishing](https://docs.npmjs.com/trusted-publishers)). **Agents never run
7
+ `npm publish`**, from a checkout or anywhere else.
8
+
9
+ `npm publish` is not passed `--provenance`, and needs no flag for it.
10
+ Trusted publishing attests a build by itself when the repository is public
11
+ and leaves attestation out when it is private, where `--provenance` would
12
+ fail the publish on a 422. 0.3.0 was published while the repository was still
13
+ private, so it is the one release with **no provenance attestation**; every
14
+ release since the repository went public carries it, with no workflow
15
+ change.
16
+
17
+ The trigger is a **published GitHub release**, not a tag push, so the release
18
+ notes and the publish are one act and a stray tag cannot publish anything.
19
+
20
+ 1. Land the version bump on `main` by pull request, with `npm version <v>
21
+ --no-git-tag-version` so `package-lock.json` moves with it.
22
+ 2. Create a GitHub release on `main` tagged `v<version>`, exactly the
23
+ `package.json` version with a `v` prefix. The workflow refuses any other
24
+ tag, and refuses a version that is already on the registry.
25
+ 3. The **"Set as a pre-release"** checkbox chooses the dist-tag: unchecked
26
+ publishes under `latest`, checked publishes under `next`. That is the only
27
+ place the choice is made.
28
+
29
+ The job builds from scratch (`npm run build` plus the precache check, since
30
+ `dist/` and `expo/dist/` are gitignored and there is no `prepare` script),
31
+ runs the full test suite, prints the `npm pack --dry-run` manifest to the log,
32
+ and then publishes. A failure anywhere before the last step is a red run and
33
+ nothing on the registry.
34
+
35
+ Trusted publishing needs npm 11.5.1 or newer, which no runner ships, so the job
36
+ runs `npm install -g npm@latest` first. The npm-side configuration lives at
37
+ `https://www.npmjs.com/package/brane/access`, is per package, and is the
38
+ operator's: the trusted publisher entry (`benjamn` / `brane` / `release.yml`,
39
+ no environment), and "Require two-factor authentication and disallow tokens",
40
+ which is what actually retires tokens as a publish path.
41
+
42
+ A newly created trusted-publisher entry reads **Pending validation** until
43
+ its first successful publish, and npm deletes it if none happens within
44
+ about 48 hours of creation; the access page shows the deadline. So either
45
+ create the entry on the day of the release, or validate a fresh one with a
46
+ release marked pre-release, which publishes to `next` and leaves `latest`
47
+ where it was.
48
+
49
+ `src/cli.ts` reads its version from `package.json` at startup, so
50
+ `brane --version` reports whatever was published rather than a string that
51
+ has to be bumped by hand.
52
+
53
+ ## The app ships with the release
54
+
55
+ The shared app (the `brane` worker in `worker/wrangler.toml`, at
56
+ `app_domain`'s default host) serves the web app of the version npm calls
57
+ `latest`, and nothing else. A merge to `main` deploys nothing: `ci.yml` only
58
+ tests.
59
+
60
+ - After an ordinary release publishes, `release.yml`'s `deploy-app` job runs
61
+ `.github/workflows/deploy-app.yml` with the release's version. A pre-release
62
+ deploys nothing.
63
+ - `deploy-app.yml` does not build. It waits until the registry has the version
64
+ and names it `latest` (right after a publish either can lag by up to a
65
+ minute), unpacks `npm pack <name>@<version>`, checks the tarball's name,
66
+ version and `expo/dist/index.html`, runs the precache check, and
67
+ `wrangler deploy`s that `expo/dist`. The shared app is therefore the same
68
+ bytes every daemon of that version installs and serves itself, by
69
+ construction rather than by a second build that is supposed to match.
70
+ - It is its own job because a publish cannot be undone and a deploy can be
71
+ repeated: a Cloudflare failure is a re-run of that job, never a second
72
+ release.
73
+ - **Redeploy or roll back** by dispatching `deploy-app.yml` from the Actions
74
+ tab with a version. It refuses a version that is not `latest`, so a rollback
75
+ is `npm dist-tag add brane@<version> latest` (the operator's, like everything
76
+ on the registry) and then the dispatch. The dispatch checks out the branch it
77
+ is run from, so an old version's bundle is deployed with *that branch's*
78
+ `wrangler.toml` and precache check, not its tag's: the route is where the
79
+ app lives whatever its version. A version that isn't `latest` is refused
80
+ only after the registry-lag retries, about four minutes in.
81
+ - **A `wrangler.toml` change is first exercised by the next release's
82
+ deploy.** A merge deploys nothing and `wrangler deploy --dry-run` doesn't
83
+ talk to Cloudflare, so watch that release's `deploy-app` job. Declaring the
84
+ custom domain needs zone permissions on `CLOUDFLARE_API_TOKEN`, not only
85
+ Workers ones; if the job fails there, the fix is the token and a re-run, and
86
+ the publish before it stands.
87
+
88
+ **Staging is Isolate.** A daemon running `main` (after `/rrr`) serves its own
89
+ bundle, built from that checkout, so Isolate on it shows everything merged
90
+ since the last release, together, before a release promotes it to the shared
91
+ app. Every daemon serves its own version's app the same way, which is also
92
+ what an older install has: `brane meld --local`, or Isolate, gives it the app
93
+ it shipped with. Between releases, then, a daemon running `main` is newer than
94
+ the shared app, the ordinary case of an older app on a newer daemon.
95
+
96
+ The deploy names no host and no package: the package name comes from
97
+ `package.json`, and the route is declared in `worker/wrangler.toml` as a
98
+ custom domain, with `workers_dev = false` so it is the only place the shared
99
+ app is served and the Cloudflare dashboard holds nothing the repository
100
+ doesn't. That route is `CONFIG_DEFAULTS.app_domain`, the host `brane meld`
101
+ points at, and `src/test/wrangler-route.test.ts` keeps the two equal. A fork
102
+ that publishes under its own name and serves its own app changes `name` in
103
+ `package.json`, `name` and the route in `wrangler.toml`, and
104
+ `CONFIG_DEFAULTS.app_domain`.
@@ -0,0 +1,22 @@
1
+ # Rendering Warnings
2
+
3
+ Every renderer here fails quietly, on purpose. Unparseable LaTeX comes back as its own source rather than KaTeX's red error markup; a `$…$` run that trips the currency guard is escaped into prose; an unclosed fence swallows the rest of the message. That is right for the **reader**, who wants a document and not a diagnostic — and exactly wrong for the **writer**, who otherwise never learns the equation did not set.
4
+
5
+ `narrate` closes that gap without reopening it for the reader: rendering is unchanged, and the reasoning the renderers were already doing is reported back instead of discarded. `diagnoseMarkdown()` runs on `say`, and the result grows a `warnings` array **only when non-empty** — a narration that renders as written gets the byte-identical receipt it always did.
6
+
7
+ | kind | fires on | what the operator sees instead |
8
+ |---|---|---|
9
+ | `math_parse_error` | KaTeX refused the LaTeX | the literal source with its delimiters |
10
+ | `math_demoted` | a `$…$` run the guard rejected, whose content is TeX-ish (`\cmd`, `^{`, `_{`) | the source verbatim, dollars and all |
11
+ | `math_unclosed` | `\(` or `\[` with no partner, or a `$$` that opens a line and never closes | literal text — or, for `$$`, everything after it as one math block. A mid-sentence `$$` or a `$$$` is literal and stays quiet |
12
+ | `fence_unclosed` | a code fence that never closes | everything after it as one code block |
13
+
14
+ Two properties make the warnings worth acting on:
15
+
16
+ **It is the real parse, not a lookalike.** The runs come from the same `prepareMathMarkdown` and the same micromark pass the export uses — `mathHtmlExtension(onError)` and `prepareMathMarkdown(md, onIssue)` grew optional collectors rather than gaining a second scanner beside them. So a warning fires *iff* the operator's view differs from intent. A parallel scanner would eventually disagree with the renderer, and a linter that cries wolf is worse than none.
17
+
18
+ **Every warning carries its `fallback`** — the literal text that will appear. Whether a failure *matters* is a judgment only the author can make and it turns almost entirely on that text: `$E = mc^2$` shown as source still communicates, a `pmatrix` shown as source is line noise. `protocols/markdown.md` tells agents to make exactly that call. `fence_unclosed` is the one that is never benign.
19
+
20
+ **Advisory by default, refusing only on request.** A warning never blocks a narration. The protocol tells agents to apply the fix *forward* — write the next one better — rather than to correct the last one, because a live loop that pauses to re-render a blemish has traded the thing narration is for.
21
+
22
+ `strict: true` is the opt-in exception, shaped after `wait: false` on `listen`: the narration is refused rather than posted, the call comes back as a tool error carrying the same diagnostics, and **nothing is written** — which is why `diagnoseMarkdown` runs before the summary update and before payloads are copied into managed storage. A refusal that had already copied files would leave orphans, and the golden rule forbids cleaning them up. Agents are told to reach for it deliberately (a derivation, a table the message builds on), never by habit: every refusal is a round trip the operator waits through.