brane 0.0.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) 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 +81 -23
  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/.npmignore +0 -2
  336. package/.travis.yml +0 -5
  337. package/index.js +0 -8
  338. package/index.js~ +0 -12
@@ -0,0 +1,413 @@
1
+ # Token Rotation
2
+
3
+ "Sign out other devices." The daemon stores its tokens **as hashes only**,
4
+ in the `tokens` table (`src/db/tokens.ts`), so that table authenticates
5
+ nothing by itself and the daemon can never print a token back. (The rest of
6
+ `brane.db` is another matter: narrations and captured tool calls are kept
7
+ verbatim, so a token an agent printed or quoted is in there in the clear
8
+ until a rotation retires it. See SECURITY.md, "What hashing does not
9
+ cover".) There are two kinds. Each web client holds a **device** token as a durable credential
10
+ — a registry entry in `localStorage`, written from a meld QR — and `brane
11
+ meld` mints a fresh one for every QR (`POST /api/tokens`), as does the app's
12
+ Meld sheet each time it opens, so each device can be revoked alone. This
13
+ machine's CLI and MCP bridges hold **no token**: outside an agent session
14
+ they present nothing over `daemon.sock` (`resolveCredential`,
15
+ `src/daemon/client.ts`), and a request there with no token acts as the one
16
+ live **local** row, the account row (`TokenStore.account`,
17
+ `authenticateRequest`; see [The CLI is a daemon
18
+ client](daemon-api.md#the-cli-is-a-daemon-client)). No token opens that row
19
+ — see [No local token](#no-local-token). Inside an agent session they
20
+ present `BRANE_NARRATIVE_SECRET`. Each agent session holds an
21
+ **agent** token of its own — see [Agent tokens](#agent-tokens). Rows are
22
+ revoked, never deleted.
23
+
24
+ **An unscanned meld token lapses.** Every QR mints a full-power device
25
+ token, and since the app's Meld sheet mints one each time it opens, most are
26
+ never scanned. A minted device token that nobody presents within
27
+ `UNUSED_TOKEN_LAPSE_MS` (10 minutes, `src/daemon/token-ring.ts`) is refused,
28
+ and its row is revoked — by `DbTokenStore.find` when it is presented late,
29
+ and by a sweep (`lapseUnused`) the daemon runs at start and every minute, so
30
+ a QR that was photographed but never used stops working and never shows up
31
+ as a device. First use makes a token permanent, as before. Three things keep
32
+ a used token from lapsing by mistake:
33
+
34
+ - A rotation's replacement row is written already used (`insert(..., { used:
35
+ true })`): the request that completes the rotation is its first use, and a
36
+ device that then only holds a socket open makes no further requests.
37
+ - First use is also remembered in memory, since the `last_used_at` stamp is
38
+ best-effort; a lost stamp cannot lapse a token this daemon has seen.
39
+ - Only `device` rows with `origin = 'minted'` lapse. The account row and
40
+ adopted tokens are in use by construction.
41
+
42
+ `POST /api/tokens` returns `expiresAt` and `validForMs`; `brane meld` prints
43
+ the window, and the Meld sheet mints a replacement a minute before its code
44
+ would lapse, so the code on screen always works.
45
+
46
+ **Upgrading signs nobody out.** Before hashing, one plaintext token sat in
47
+ `meta.daemon_token` and `.daemon_token` and on every melded device. On the
48
+ first start of a hashing daemon, `bootstrapTokens` adopts it as a device row
49
+ ("melded before hashing"), so those devices keep working; removes it from
50
+ `meta` (with `secure_delete` and a WAL checkpoint, so it does not linger in
51
+ freed pages); and empties the file. A token whose
52
+ hash is already known, live or revoked, is never adopted again, so a stale
53
+ file or a stale `--token` cannot bring a revoked token back. Pinned by
54
+ `src/test/token-bootstrap.test.ts`.
55
+
56
+ **An adopted token is marked, and the daemon nags until it is gone.** Every
57
+ row carries an `origin`: `minted` by this daemon, or `adopted` from a
58
+ plaintext that existed before (the old shared token, or a `--token`).
59
+ Adopted tokens may have been written down — older daemons printed theirs in
60
+ `daemon.log` at every start, and a `--token` sits in argv and in whatever
61
+ config passes it — so while one is live, the daemon warns at startup and
62
+ `brane daemon status` says how to retire it: one rotation. The mark is a
63
+ column, not the label, so renaming the device cannot silence it. The app
64
+ warns on adopted *device* rows; an adopted agent row (a running session's
65
+ pre-#561 bind secret) is noted as replaced on its next revive. When the old
66
+ shared token is the one that asks to rotate, its replacement is labelled
67
+ "kept when other devices were signed out": it was minted then, not melded
68
+ before hashing.
69
+
70
+ **The swap happens on first use of the new token, not on the request that
71
+ minted it.** `POST /api/token/rotate` returns a *pending* token for the
72
+ requester and changes nothing durable; every live token still
73
+ authenticates. The first request that presents the new one is the proof it
74
+ arrived, and only then is it written down as a row of the requester's kind
75
+ and the requester's old row and every other device row revoked, in one
76
+ transaction (a failed write changes nothing, and the pending token survives
77
+ for the client's retry). Then every control/terminal socket those rows opened is
78
+ closed, with WebSocket close code **4401**, so a
79
+ client can tell "signed out" from a network blip. This machine rides
80
+ through a device's rotation: the CLI and agents are not "other devices". A
81
+ pending token nobody uses lapses after 60 s and nothing changes.
82
+
83
+ **The shell's rotate is one request.** `brane token rotate` holds no token
84
+ to swap, so nothing of its own can be stranded by a lost response: once the
85
+ operator passes the [presence check](presence.md#the-gate),
86
+ `TokenRing.signOutDevices` revokes every device row and the externals they
87
+ dialed in, voids any rotation a device had pending (its token must not write
88
+ that device back in), and the route answers `{ signed_out, replaced }` with
89
+ no token in it. `signed_out` counts the devices someone was signed in on; a
90
+ QR nobody scanned is revoked too but not counted. `beginRotation` throws
91
+ for a `local` requester: completing one would write a token that opens the
92
+ `local` row, and that must not depend on which route called it. An answer in the two-step shape means an older daemon; the
93
+ CLI reports that instead of claiming a sign-out that did not happen.
94
+
95
+ | Piece | Where | Does |
96
+ |---|---|---|
97
+ | `TokenRing` | `src/daemon/token-ring.ts` | Hash lookup over a `TokenStore`, the pending token, `authenticate()` that performs the swap; pinned by `src/test/token-ring.test.ts` |
98
+ | `DbTokenStore`, `bootstrapTokens()`, `legacyTokenWarning()` | `src/db/tokens.ts` | The `tokens` table, the one-time move from a plaintext token to hashes, and the warning while an adopted token is live; pinned by `src/test/token-bootstrap.test.ts` |
99
+ | `mintDeviceTokenRoute()`, `retireTokens()` | `src/daemon/token-routes.ts` | `POST /api/tokens`, and the end of a rotation: the socket sweep, run inside the request that first used the new token, before it is answered; pinned by `src/test/token-routes.test.ts` |
100
+ | `listTokensRoute()`, `revokeTokenRoute()`, `closeSocketsOf()` | `src/daemon/token-routes.ts` | The Devices list (`GET /api/tokens`) and per-device revoke (`POST /api/tokens/:id/revoke`); pinned by `src/test/token-devices.test.ts` |
101
+ | `DevicesLayer` | `expo/components/DevicesLayer.tsx` | The switcher's Devices layer: one row per token, Revoke with an in-place confirm, and "Sign out other devices" at its foot (its confirm layer is the switcher's, in `expo/app/index.tsx`) |
102
+ | `viewerSockets` | `src/daemon/server.ts` | Control + terminal sockets tagged with the token row they authenticated under. MCP bridges are deliberately absent |
103
+ | `WS_CLOSE_TOKEN_ROTATED` | `src/shared/token-rotation.ts` | The close code, shared with the app |
104
+ | `rotateActiveToken()` | `expo/app/_layout.tsx` | The requester's sequence: rotate → close own channel → `probeDaemon` under the new token → rewrite the registry entry → reconnect |
105
+ | `revoked` | `expo/lib/registry.ts` | The flag a refused entry gets. Never removed — the banner names what you were signed out of; a fresh meld of the same brane replaces the entry and clears it |
106
+ | `rotateDaemonToken()` | `src/commands/token.ts` | The CLI's version: one request, since the shell holds no token to swap; `src/test/token-rotate-cli.test.ts` pins the one-request shape, and that an older daemon's two-step answer is reported |
107
+
108
+ Five things are less obvious than they look:
109
+
110
+ **Why first use.** The naive shape — rotate returns the new token and the
111
+ old dies on the spot — has one failure the [request layer](request-layer.md)
112
+ makes real: on a 741 ms link, a lost *response* leaves the daemon rotated
113
+ and nobody, the requester included, holding the new token, and a `POST` is
114
+ rightly never retried. Under first-use, a lost response costs nothing, and a
115
+ daemon restart mid-handshake forgets the pending token and lands on the old
116
+ one — the safe side, with no new persistence.
117
+
118
+ **A pending token is issued exactly once.** The person rotation exists to
119
+ evict — whoever holds the old token — can call the route too. The first
120
+ draft answered a second call within the window with the *same* pending
121
+ token, for retry-safety, which handed an attacker racing the operator the
122
+ new credential to use first while the operator's own confirm still
123
+ succeeded. So a second `rotate` now *replaces* the pending token: the
124
+ requester's confirm fails with a 401 (the app restores its old channel and
125
+ says so) and the daemon logs `pending rotation REPLACED before use`. A
126
+ double-tap costs one visible retry; a race is visible instead of silent.
127
+ What the route still cannot do is stop an old-token holder from rotating
128
+ *first* — but the token already grants keystrokes into every tmux session,
129
+ so that is no new capability. The guarantee is only: once your rotation
130
+ completes, holders of the old token are out. When a leak is suspected to be
131
+ *live*, rotate from the shell: `brane token rotate` signs every device out
132
+ in the one request it makes, with no window. It waits for the operator
133
+ at the [presence check](presence.md#the-gate) first, before anything is
134
+ revoked.
135
+
136
+ **The requester must close its own old-token channel before confirming.**
137
+ Retirement sweeps every old-generation socket, and a control channel still
138
+ open at that moment would read its own 4401 as someone else's sign-out and
139
+ flag the entry it is about to rewrite. The order in `rotateActiveToken` is
140
+ the fix, and it is why the confirming request is an explicit `probeDaemon`
141
+ rather than "whatever request happens next".
142
+
143
+ **A 401 has two faces.** A device live at the moment of retirement gets the
144
+ 4401 close and stops reconnecting. A device that loads *later* never sees
145
+ it — its upgrade is refused with a 401 the browser reports as a bare 1006 —
146
+ so the HTTP layer reports every 401 to `setUnauthorizedListener`, keyed on
147
+ the refused `{ host, token }` rather than "the active daemon", which is what
148
+ keeps a wrong token typed into the add-daemon form from signing out the one
149
+ you are connected to. `connectChannel` refuses to dial a `revoked` entry at
150
+ all: without that, the old 2 s reconnect loop would run forever and
151
+ silently, which was the pre-existing behavior on any 401.
152
+
153
+ **Sibling tabs are the same device.** They share `localStorage`, so one
154
+ tab's rotation is written where the others read it; a `storage` listener in
155
+ the root layout reconnects them on the new token instead of letting them
156
+ be refused. "Other devices" means other credential stores.
157
+
158
+ ## Agent tokens
159
+
160
+ Every agent session authenticates with a token of its own, and the secret
161
+ in its env is that token: `BRANE_NARRATIVE_SECRET`. It used to be a
162
+ per-narrative bind secret, kept in plaintext in `narratives.bind_token` and
163
+ presented alongside the local token of the time; it is now an `agent` row in
164
+ `tokens`, hashed like any other, with `owner_nid` naming the one narrative
165
+ it may act as. Reusing the variable that already rode every harness's MCP
166
+ config (Claude's env, Goose's inline env, Codex's `env_vars`, Copilot's and
167
+ Cline's per-session files) meant no harness's plumbing had to change.
168
+
169
+ - **Minted per launch** (`mintAgentToken`, src/db/tokens.ts): spawn, revive,
170
+ an agent switch, and an external client's first dial. Each launch revokes
171
+ the narrative's previous token in the same write, so one is live per
172
+ narrative, and the daemon has the plaintext only at that moment — which
173
+ is why `welcome` carries a secret only for a narrative it just minted.
174
+ - **Presented as the bearer** by the MCP bridge and by the `brane` CLI when
175
+ `BRANE_NARRATIVE_SECRET` is set (`resolveDaemonEndpoint`), so what an
176
+ agent does is attributed to, and revocable with, its narrative. The hook
177
+ route checks it by hash too (`verifyNarrativeBind`).
178
+ - **Bound to its narrative.** On `/ws/mcp` an agent token may bind only as
179
+ its `owner_nid`; any other claim is refused with 403, never re-minted as
180
+ a fresh external, which would hand a revocable credential a way around
181
+ its own row. It cannot rotate (403): its replacement is a revive.
182
+ - **Narrower than a device, and only from here.** An agent token's routes
183
+ are a default-deny list (`AGENT_ROUTES`, `src/daemon/agent-scope.ts`):
184
+ reads, renderers, spawn/revive and its own `/ws/mcp`; not the
185
+ terminal or control sockets, signals, `POST /api/run` (a utility
186
+ session carries no agent token; nor may it revive or prompt one), the Devices list or
187
+ the operator's housekeeping. It works only over `daemon.sock` or loopback,
188
+ which every bridge, spawned or external, already uses. A refusal is a
189
+ 403 naming a utility session, started from the app, as the way to do it. Pinned by
190
+ `src/test/agent-scope.test.ts`; SECURITY.md, "What an agent token can
191
+ do", has the reasoning.
192
+ - **Revoking one closes its bridge** with 4401, including a bridge that
193
+ dialed as this account (an external's first connection, a session
194
+ from before agent tokens), whose socket is tagged with its narrative's
195
+ agent token. The bridge then stops reconnecting and answers every tool
196
+ call with "this session's brane token was revoked". The tmux session is
197
+ left alone.
198
+ - **In the list and the cascade.** Agent rows show as "Agent claude-…",
199
+ "started by" the device or CLI that launched them (`recordLauncher` sets
200
+ `minted_by`), and revoking a device offers its agents' tokens too.
201
+ - **Running sessions from before agent tokens keep working**: at daemon
202
+ start, `adoptBindTokens` turns the plaintext bind secret of each narrative
203
+ whose tmux session is alive into an adopted agent row (once; a hash
204
+ already known is skipped, so a revoked one never comes back). Only
205
+ running ones: a dead or archived narrative's stored secret would
206
+ otherwise become a permanent bearer for a session nobody runs. The
207
+ plaintext column is left as it was; a secret not adopted authenticates
208
+ nothing. A connected external client (no tmux session) is not adopted,
209
+ and lands on a fresh `external-*` narrative once, at the upgrade.
210
+ Adopted agent rows don't trigger the plaintext-token warning.
211
+ - **Archiving revokes** a narrative's agent token when its session is not
212
+ running. Unarchiving brings nothing back; a revive mints a new one.
213
+ - **An external goes with its dialer, always.** An external's token
214
+ records the token that dialed it (`minted_by`); it is that token's
215
+ credential in another form, since one `/ws/mcp` dial mints it, so
216
+ revoking the dialer revokes it with no checkbox (it isn't offered as a
217
+ choice in the cascade), and a completing rotation revokes every external
218
+ whose dialer is no longer live — a stolen token can't mint itself a
219
+ survivor. Spawned agents are not touched by a rotation. The requester's own
220
+ children are re-parented onto its replacement row.
221
+ - **The secret stays out of argv.** Launch commands reach tmux on stdin
222
+ (`newTmuxSession`, `tmux start-server \; source-file -`; `start-server`
223
+ because `source-file` alone needs a server already running), so neither a tmux client's nor
224
+ a newly started tmux server's arguments carry it (a server started before
225
+ this change keeps the command line it was started with). The daemon drops
226
+ any inherited agent session (`INHERITED_AGENT_ENV`, `src/agent-env.ts`:
227
+ `BRANE_NARRATIVE_*` and the harness's session-scoped variables such as
228
+ `CLAUDE_CODE_SESSION_ID` and `CLAUDE_CODE_MESSAGING_TOKEN`) from its own
229
+ env and the tmux server's global env, and utility sessions (`POST
230
+ /api/run`) unset
231
+ them, so nothing that isn't the agent acts as it. Without the harness
232
+ half, a daemon restarted from a Claude shell handed that session's id to
233
+ every non-Claude agent it spawned, whose bridge then stamped it on their
234
+ narrations (claude-eiuzl79r:33). Goose is the exception: its inline
235
+ `--with-extension` env is an argument, visible in `ps` for that agent's
236
+ life.
237
+ - **No rollback.** `main` before this change can't bind a session whose
238
+ secret exists only as a hash; going back rebinds such sessions to fresh
239
+ `external-*` narratives.
240
+
241
+ **Not containment:** an agent runs as your Unix user, so it can drop its
242
+ secret, reach `daemon.sock` with no token, and act as this machine instead
243
+ of as itself. Retiring `.daemon_token` did not change that; the socket gives
244
+ the same standing to the same account. A
245
+ per-agent token is attribution and revocation, not containment, and brane
246
+ offers no sandbox (SECURITY.md, "Who the operator is").
247
+
248
+ ## No local token
249
+
250
+ This machine's standing is the account row: the one live `local` row, whose
251
+ `hash` column holds a placeholder (`no-token:<uuid>`, `ACCOUNT_HASH_PREFIX`)
252
+ instead of a SHA-256, so no presented token can ever match it. Only a
253
+ tokenless request over `daemon.sock` acts as it, and only this Unix account
254
+ can reach the socket.
255
+
256
+ Older branes kept a **local token** in `~/.brane/.daemon_token`, which the
257
+ CLI and bridges re-read on every use. It was a bearer token like any other:
258
+ it worked over TCP from any address the IP filter admits, so a copy of the
259
+ file (a backup, a synced home directory, an agent that printed it) was this
260
+ machine's shell from somewhere else on the tailnet. `bootstrapTokens`
261
+ retires it at daemon start:
262
+
263
+ - Every live `local` row that a token can open (`isTokenHash`) is revoked,
264
+ whether or not its token is still in the file. What any earlier `local`
265
+ row had minted, dialed in or launched — the rows just retired, and ones
266
+ revoked before, by a shell rotation on an older brane or by an older
267
+ daemon started in between — is re-parented onto the account row, so a device
268
+ `brane meld` added last week is still "added by this machine", the agents
269
+ `brane spawn` started are still listed under it, and its externals are
270
+ not swept by the next rotation.
271
+ - **The file is emptied, not removed.** Its hash stays a revoked row, so an
272
+ older brane started later cannot adopt the same token as a device. Emptying
273
+ matters most on an install from before hashing, where the file holds the
274
+ shared token every device still uses: that token stays live (adopted as a
275
+ device row), and must not stay on disk. If the file cannot be emptied the
276
+ daemon warns and carries on; the token in it is revoked either way.
277
+ - `brane doctor` reports the leftover (`– token file`) with the `rm` line.
278
+ Removing it is the operator's to do.
279
+ - Nothing writes the file again, and no client reads it. A CLI from just
280
+ before this change that finds it empty falls back to the socket and keeps
281
+ working.
282
+
283
+ **There is no fallback when the socket cannot be used.** If the daemon
284
+ cannot listen on it (the path is over 103 bytes, the filesystem cannot hold
285
+ a socket, something that is not a stale socket is in the way) or `~/.brane`
286
+ is not `0700` and the daemon's own, the shell CLI and a new external MCP
287
+ client cannot reach the daemon. The daemon logs why (`[socket]`), every CLI
288
+ command says so, and `brane doctor` fails its daemon check with the reason.
289
+ Agents and signed-in devices hold tokens and are unaffected. On such a
290
+ machine the first device comes from `--token` or `BRANE_DEVICE_TOKEN_SHA256`
291
+ at daemon start. Writing the file there instead would bring back the
292
+ copyable token on exactly the machines least looked at.
293
+
294
+ Pinned by `src/test/token-bootstrap.test.ts` ("retiring the local token")
295
+ and `src/test/socket-account.test.ts`.
296
+
297
+ ## The Devices list
298
+
299
+ `GET /api/tokens` lists every live token, `brane token list` prints it, and
300
+ the brane switcher's **Devices** layer shows it with a Revoke on each other
301
+ device. Less obvious than it looks:
302
+
303
+ - **A row is named by its first sighting.** The store records the
304
+ User-Agent and peer address of the first request, HTTP or WebSocket, that
305
+ presents the token (`DbTokenStore.find`; columns `user_agent`,
306
+ `remote_addr`), and never overwrites them, so a row keeps naming the
307
+ device that claimed it. A rotation's replacement takes the sighting of the
308
+ request that confirms it, which is the requester's device.
309
+ - **A copy shows as a second address.** Beside the first sighting the store
310
+ keeps the latest (`last_user_agent`, `last_remote_addr`, on the same
311
+ once-a-minute throttle as `last_used_at`), and stamps `elsewhere_at` the
312
+ moment a token is presented from an address other than its first. The
313
+ stamp is sticky, so a copy used once is still flagged after the real
314
+ device is back. On a tailnet each device has a stable address, so a
315
+ second one is almost always a copy; it can also be the same phone on the
316
+ LAN, which is why the app says "also used from another address", not
317
+ "stolen". Loopback is one place however it is spelled (`samePlace`):
318
+ the MCP bridge dials `localhost`, often `::1`, the CLI `127.0.0.1`, and
319
+ both prefer the daemon's Unix socket, recorded as `daemon.sock` since it
320
+ has no address (`connectionLabel`); one token used by any of them is not
321
+ a copy.
322
+ - **The address is the only field a token holder cannot choose.** The
323
+ User-Agent and the label are both free text from whoever holds the
324
+ token. So the app never uses the label as a row's title — a row with a
325
+ narrative is "Agent <nid>", and a device whose User-Agent does not parse
326
+ is named by its address ("Device at 100.x.y.z"), label quoted beneath —
327
+ and nothing branches on the parsed name (`src/user-agent.ts`), which is
328
+ only for a person telling rows apart. A name from `tailscale whois` on
329
+ the address would be the first trustworthy one; it is not done yet,
330
+ since it shells out to the extension that has wedged before.
331
+ - **Who added a device is recorded at mint** (`minted_by`): the token whose
332
+ request minted it, shown as "added by Safari on iPhone" or "by this
333
+ machine" (`brane meld`). It can't be reconstructed later, which is why it
334
+ is written before anything uses it, and it is what the cascade below
335
+ walks.
336
+ - **Which device started each agent** is recorded the same way
337
+ (`narratives.launched_by`, written by spawn, run and revive), and the
338
+ list shows each token's agents. Each agent holds a token of its own
339
+ ([Agent tokens](#agent-tokens)), which revoking the device offers in its
340
+ cascade. **A revoke never touches
341
+ their sessions**: only token revocation cascades, and tmux is left alone
342
+ (the operator's decision, claude-xk1iyc17:58).
343
+ - **`connected`** marks a token with a socket open now, since a client
344
+ parked on one makes no requests and its `last_used_at` stands still.
345
+ - **Recording is best-effort, like `last_used_at`**: it never fails the
346
+ request it rides on (nor a rotation's transaction), and control
347
+ characters become spaces before anything is stored, since a newline in a
348
+ list is a forged row.
349
+ - **An unscanned QR is listed**, as `unused` with its `expires_at`, until it
350
+ lapses: a code still on someone's screen is a live credential. Once
351
+ lapsed it is left out even before the sweep revokes it.
352
+
353
+ `POST /api/tokens/:id/revoke` is bodiless, so the app sends it without a
354
+ preflight, and stamps `revoked_at` (rows are never deleted). **Every socket
355
+ the revoked token opened closes with 4401**: control and terminal sockets,
356
+ and MCP sockets too when the token is a device token
357
+ (`closesOnRevoke`, `src/daemon/token-routes.ts`). The MCP one is the one
358
+ that matters: a device token can open `/ws/mcp`, which reaches every MCP
359
+ tool, and the first version of this list left it open, so a revoked token
360
+ kept narrating and reviving for as long as it stayed connected. The same
361
+ set is what a rotation closes. Local MCP bridges stay exempt; this account
362
+ is not what either retires.
363
+
364
+ It refuses three things, each naming what the caller probably meant: the
365
+ **local** row (400; it is this machine's account, which holds no token to
366
+ revoke), the **requester's own** token (409; "Sign
367
+ out other devices" keeps this one and signs out the rest), and an id that
368
+ is **not live** (404, which the app treats as done).
369
+
370
+ **A revoke can take the devices it added** (`?also=a,b`, in the query
371
+ string so the request stays bodiless; `brane token revoke --cascade`). The
372
+ first thing a stolen token's holder does is mint a spare, and a spare's
373
+ first sighting is the thief, so its row looks clean: without the cascade,
374
+ revoking the stolen row leaves them in. The app's confirm step lists every
375
+ device the one being revoked added, down the chain, each **checked by
376
+ default**, so the honest cost — you melded your laptop from the phone you
377
+ lost — is one visible untick, and the dishonest one is not silent.
378
+ `DbTokenStore.descendants` computes the set, walking through links that
379
+ are already revoked; the route revokes only ids in it, in one write, and
380
+ refuses the request otherwise. It never includes the requester or anything
381
+ the requester added by a path that doesn't run through the device being
382
+ revoked. Through the root matters: the requester is often an *ancestor* (this
383
+ machine's `brane meld` added the phone), and revoking the phone from here
384
+ must still take the laptop the phone added. The first version excluded the
385
+ requester's whole subtree and so found nothing in exactly that case; the
386
+ sandbox run caught it, and a test pins it. Once passkeys gate minting, a
387
+ stolen token can't mint spares at all, and the default may flip to
388
+ unchecked.
389
+
390
+ **Revoking a device abandons any rotation it had begun.** A pending
391
+ rotation is held in memory against its requester's row. Left alone, the
392
+ revoked device could confirm it afterwards: the confirm writes a fresh row
393
+ of the requester's kind and revokes every other device, so the device you
394
+ just signed out would be back in and everyone else would be out.
395
+ `TokenRing.abandonRotationBy` drops it.
396
+
397
+ **Who may revoke.** Any live token can revoke any other device, which is no
398
+ more than "Sign out other devices" already lets it do. Whether a passkey
399
+ should guard revoking, as it will guard adding a device, is open: see
400
+ [Passkeys](passkeys.md), open question 1.
401
+
402
+ **Known gaps, outside the token layer:**
403
+
404
+ - A revoke does not cut a streaming response already in flight (the Files
405
+ search and `POST /api/absorb`, both NDJSON). Each is bounded work, not a
406
+ listener, and ends on its own.
407
+ - The WebSocket origin check compares only the port for same-origin and
408
+ accepts any `*.ts.net` host. Nothing rides on it today, since every
409
+ request must present a token; a passkey verifier will need its own exact
410
+ origin list.
411
+ - Fold registration treats a loopback peer as the operator's shell, so a
412
+ forwarder to 127.0.0.1 (`tailscale serve`, an SSH tunnel) would make a
413
+ remote caller look local.
@@ -0,0 +1,114 @@
1
+ # View URLs
2
+
3
+ The query string of a narrative screen is not decoration — it is the only way
4
+ a view travels. Active tab, pinned tabs, open file and line range are kept in
5
+ `sessionStorage`, which is **origin-scoped**, and two menu items cross an
6
+ origin: **Isolate** hops between `brane.organelle.dev` and a daemon's own
7
+ bundle, and **Meld** hands a URL to another device. Whatever is not in the URL
8
+ does not arrive.
9
+
10
+ `src/shared/view-url.ts` holds the contract, window-free so it can be tested
11
+ without a browser (`src/test/view-url.test.ts` runs it over a grid of every
12
+ tab, pin subset, path and line spec):
13
+
14
+ | Piece | Does |
15
+ |---|---|
16
+ | `viewParams()` | The view as params, `undefined` for anything at its default — which is how `router.setParams` clears one |
17
+ | `parseActiveTab()`, `parsePinnedTabs()`, `parseLineSpec()` | Read them back. Writer and reader live together on purpose: a param whose two halves sit in different modules drifts |
18
+ | `buildViewUrl(target, opts)` | The same narrative, same view, somewhere else |
19
+ | `buildIsolateUrl()` / `buildMeldUrl()` | The same builder with one bit different — see below |
20
+ | `withoutConnectionParams()`, `withoutConnectionHash()` | Strip `host`/`token` from the query string and from the fragment, and leave everything else |
21
+ | `connectionFragment()` | `#host=…&token=…` for the shared app, `#token=…` for a daemon's own bundle — the one place the connection is written into a URL, used by `buildViewUrl` and by `brane meld` |
22
+
23
+ **Isolate flips the origin kind; Meld stays on it.** Isolate exists to change
24
+ which app serves you. Meld aims at another *device* — hand a phone a shared-SPA
25
+ link while the operator is reaching the daemon directly, and the phone loads an
26
+ app that has never heard of that daemon.
27
+
28
+ **Back undoes a jump, never a view change.** A jump pushes a history entry: to
29
+ a different narrative (from the list, an `@nid` link, the switcher, Revive or
30
+ Twin), and to a message by `nid:seq`, *including one in the narrative you are
31
+ already reading*. That last case is what makes a `/summarize` summary usable as
32
+ a table of contents: tap a citation, read, press Back, and you are where you
33
+ were, ready for the next one. Changing the view *within* a narrative (tab,
34
+ pins, Files path and line, the `?scrollToSeq=` selection) goes through
35
+ `router.setParams`, which Expo Router's linking writes with `replaceState`,
36
+ since the stack did not grow. So the URL keeps that state and Back returns to
37
+ it, but it never costs an extra Back: browsing ten files is one entry, not ten.
38
+ A bare link to the narrative you are in, with no seq, goes nowhere. The list
39
+ is a jump too: **All narratives** in the switcher pushes it, so Back returns
40
+ to the narrative, whether or not a list was already beneath. The header has no
41
+ ← of its own; Back is the browser's.
42
+
43
+ Back is instant and complete because the Stack keeps the screens behind the
44
+ top one mounted. The list behind a narrative still holds its search query and
45
+ results, and the narrative behind another (or behind a later copy of itself)
46
+ still holds its scroll, selection and open file. Nothing is rebuilt from
47
+ storage. A deep stack is left mounted on purpose: on the web, a new tab is the
48
+ way out of one. Because two mounted screens can watch the same narrative,
49
+ `subscribe`/`unsubscribe` in `expo/lib/connection.ts` are refcounted. Otherwise
50
+ the top screen's unmount would silence the one that Back returns to.
51
+
52
+ Staying mounted has a cost: a screen behind the top still runs its effects, so
53
+ a listener it registers on `document` still hears every event on the page,
54
+ including taps on the screen in front. And `router.setParams` is the *global*
55
+ router: it writes to whichever route is focused, not to the screen that
56
+ called it. So a backgrounded screen that reacts to a document event by writing
57
+ its view writes it into the URL of the screen you are looking at, and that
58
+ screen's URL-sync effect adopts it. That is how tapping a Terminal pane once
59
+ opened and pinned Chat: the narrative underneath was on its Chat tab, its
60
+ pane-select handler pinned the outgoing Chat, and the pin landed on top. Every
61
+ document-level listener in `[nid].tsx` is therefore gated on `useIsFocused()`,
62
+ the same rule #342 applied to keystrokes.
63
+
64
+ **Why a same-narrative jump is a new screen, not a scroll.** Expo Router (v55,
65
+ React Navigation 7) has no way to add a history entry without growing a stack:
66
+ no `pushParams`, and `singular`/`getId` reuse a screen by moving it, which
67
+ shrinks the stack. A scroll-in-place jump with its own `pushState` was tried
68
+ (#554) and the Back entry was set aside, because the router keeps its own model
69
+ of the history stack. An entry it didn't write makes browser Back `resetRoot`
70
+ the stack (unmounting what's under it) and made the header's own ← (since
71
+ removed; the browser's Back is the only one) miscount its `history.go(n)`.
72
+ Re-rendering the screen costs a React render, not a page load.
73
+
74
+ **Seed from the URL, then overlay live state.** Seeding carries params the
75
+ screen doesn't model (`scrollToSeq`). The overlay wins on everything it does
76
+ own, because a tab layout restored from `sessionStorage` deliberately never
77
+ writes itself back to the URL (`router.setParams` there can run before the root
78
+ layout mounts and blank the screen). A copy of `window.location.search` alone
79
+ would miss exactly that case.
80
+
81
+ **Connection params are never carried, only re-set.** They describe the origin
82
+ building the link. And the receiving side cleans them: `clearMeldUrl` in
83
+ `app/_layout.tsx` strips `host`/`token` from the address bar once the meld has
84
+ been used — with `withoutConnectionParams` and `withoutConnectionHash`, *not*
85
+ by resetting to the bare pathname, which would throw away the view that just
86
+ arrived.
87
+
88
+ **The connection rides in the fragment; the view rides in the query string.**
89
+ A query string is part of the HTTP request. On a `brane.organelle.dev` link
90
+ that meant the daemon's bearer token and tailnet hostname reached Cloudflare's
91
+ edge with every meld; on an Isolate hop it landed in the daemon's own request
92
+ log; and a meld URL pasted into iMessage or Slack handed it to the link
93
+ unfurler. A fragment is never sent to any server, so `#host=…&token=…` is what
94
+ every link carries now (`connectionFragment`), and `?token=` is read only for
95
+ old QR codes and bookmarks. The view stays in the query string because it is
96
+ harmless to share and because the fragment belongs to the connection.
97
+
98
+ **The fragment must be consumed before Expo Router reads the URL.** On web the
99
+ router parses the initial URL's hash into a route param named `#` and appends
100
+ it again every time it writes the URL (`appendQueryAndHash` in its
101
+ `getPathFromState` fork, and `routeInfo`). Consume it in a `useEffect` and
102
+ strip it with `replaceState`, and the next `router.setParams` — a tab change,
103
+ a pin, an opened file — writes the token straight back into the address bar
104
+ and the back stack. So `expo/lib/api.ts` runs `consumeConnectionFromLocation`
105
+ at **module evaluation**: the credentials go into the `sessionStorage` slot
106
+ `parseConnectionParams` already falls back to, and the URL is rewritten
107
+ without them, view intact. Query params get the same treatment. Observed in a
108
+ headless browser (claude-7irbudpw:65): the router still restores the hash on
109
+ its *first* URL write, so the fragment is visible while the "Meld this
110
+ device?" dialog is up; `clearMeldUrl` removes it at confirm, and it did not
111
+ come back across an in-app card tap and two tab changes. So the module-level
112
+ consume is what gets the credentials into storage early, `clearMeldUrl` is
113
+ what actually clears the address bar, and `clearStoredConnection` still ends
114
+ the flow.
@@ -0,0 +1,23 @@
1
+ # Web App (Expo)
2
+
3
+ The Expo app runs as a web PWA served by the daemon (and optionally via Cloudflare Workers at `brane.organelle.dev`). It provides:
4
+
5
+ - **Narrative list** — shows all narratives with summary, status, and MCP connection indicator
6
+ - **Spawn button** — header button that opens fold picker modal (or spawns directly with 1 fold)
7
+ - **Chat view** — message bubbles (blue for operator, gray for agent, pastel for foreign)
8
+ - **🎨 Recolor** — a 🦠 menu item that reshuffles the hues foreign speakers wear in one narrative, for when two agents hash to near-identical colors. Hues come from `src/shared/speaker-color.ts` (salt 0 is the palette every narrative has always had; a nonzero salt is mixed in per speaker through a nonlinear finalizer, since seeding the polynomial hash would rotate every equal-length nid by the same amount). The salt is a per-device view preference in `localStorage` (`expo/lib/recolor.ts`), never sent to the daemon; the list card's menu offers the same item and only advances the stored salt. Export sends it along (`&palette=`), so a snapshot shows the palette the operator was looking at
9
+ - **Terminal view** — live tmux mirroring with ANSI color rendering (ansi_up)
10
+ - **Files view** — browse the narrative's fold: directory listing, Shiki-highlighted code (`CodeView`), rendered markdown (`Markdown`), PDFs (`PdfView`, platform-split — the `.web` variant lazy-imports pdf.js), hex for everything else (`HexView`), and archives as folders — see [Files View Formats](files-view.md#files-view-formats). The bottom bar searches whatever is showing: **🔎 find in file** on code and markdown, and on hex, where the daemon searches every byte (see [Searching the bytes](files-view.md#searching-the-bytes)) (smart-case literal, `:N` jumps to a line, hits painted with the CSS Custom Highlight API so no DOM changes — `expo/lib/findInFile.ts`, rules in `src/shared/find-in-text.ts`). The hit counter is a drum (`HitDrum`, physics in `src/shared/hit-drum.ts`): faces read `3/17` right-aligned so the totals stand in one column, swipe or flick to roll, tap a half to step, Enter / ⇧Enter roll it too. It stops at the ends (rubber-band past them, a bump on a step into them). The page doesn't glide with it: as the drum crosses each detent the page snaps that hit onto the scroller's middle line, so every hit lands where the eye already is, and half a screen of room below the file lets the last hits reach it. Settling with the keyboard down writes `line=` as closing search does; clearing the query takes it back, **🔮 fuzzy path finder** over `/api/files/list` everywhere else. The view picks the mode and the emoji shows it — there is no switch; the query survives navigation and re-runs in the next view's mode. Closing 🔎 on a hit writes its line into the URL. `#L10-L20` line specs deep-link and scroll to a range
11
+ - **Markdown link handling** — `MarkdownLinksProvider` (`components/MarkdownLinks.tsx`) is mounted once per screen and supplies `knownNarrativeIds`, `knownProtocols` (defaulting to `useBrane().protocols`), `onNarrativePress`, and `onProtocolPress` to every `MarkdownContent` beneath it, so chat bubbles, Files-tab markdown, and the wiki reader linkify `nid:seq` and `/command` identically without threading props. Nested providers override only the fields they set (the wiki modal wraps itself to close before navigating); explicit props on `MarkdownContent` still win
12
+ - **Tabs** — Chat / Terminal / Files. The active tab plus any **pinned** tabs render simultaneously; the set persists per narrative in storage and round-trips through the `tab` and `pinned` URL params. Tapping into a visible pane selects it *without changing which panes are visible* and never unpins anything — a pin comes off only through its 📌 (`selectPane`, `src/shared/pane-layout.ts`), and a press on a control — a quick key, a menu, a copy button — selects nothing at all: selection runs on `pointerdown`, so a pane that widens under the finger slides the button away before the release, and the tap that caused it is never delivered as a `click`
13
+ - **Quick bar** — macros (via MacroMenu component) + quick keys
14
+ - **Full software keyboard** — multitouch with sticky/held modifiers
15
+ - **Live updates** — control WebSocket pushes `status`, `meta`, and `new_events` hints
16
+ - **Archive/unarchive** — swipe or long-press to archive narratives
17
+ - **Payload rendering** — inline display of image payloads in chat
18
+ - **Export** — download a narrative as standalone HTML via `/api/narratives/:id/export?format=html`. Foreign rows carry their speaker's hue inline, from the same `speakerColor` / `speakerBgColor` the chat uses, at the 🎨 Recolor salt the device sends as `&palette=` — the one place the salt leaves the browser. With messages selected, the menu offers both sides of the selection — **those messages only**, or the conversation with **those messages left out** — see [Export Scope](export-scope.md)
19
+ - **Brane switcher** — dropdown to filter narratives by brane, add/switch daemons. Each brane row (and "All branes") shows its running metered cost as `$2441.72 (138) + 100.2 AIC (1)` — one total per unit with the number of sessions it covers, so a per-session figure can be read off without the line asserting one; the list screen re-reads `/api/branes` (debounced) when a cost `meta` arrives, since the total includes archived narratives the screen never loads. **Devices** lists every token on the active daemon (this device, this machine's CLI and agents, each melded device by browser, platform, address and when it was last seen, and any meld QR not yet scanned) with a Revoke on each other device; **Sign out other devices**, at the foot of that list, rotates instead — see [Token Rotation](token-rotation.md#the-devices-list)
20
+
21
+ `expo/lib/api.ts` also exports `searchNarratives()`, `searchWiki()`, and `readWikiSection()` clients for `/api/search` and `/api/wiki/*`. These have no UI yet — the transport landed ahead of the views.
22
+
23
+ Connection via the URL **fragment**, `#host=<host>&token=<token>` on the shared app and `#token=<token>` on a daemon's own bundle (the legacy `?token=` query form is still read). Tailscale hostname auto-detected by `brane meld`. See [View URLs](view-urls.md) for why the fragment, and for the Expo Router trap it has to dodge.
package/docs/wiki.md ADDED
@@ -0,0 +1,43 @@
1
+ # Wiki: Local and Shared
2
+
3
+ A fold's wiki is `wiki/` plus at most one subdirectory, `wiki/shared/`.
4
+ Both are flat. A page's **slug is its path under `wiki/`** — `foo.md` is
5
+ local, `shared/foo.md` is shared — so a citation (`slug#anchor`) means the
6
+ same thing to every reader and never depends on who is asking.
7
+
8
+ One visibility rule, no special cases. Every wiki source is `(dir, owned)`:
9
+
10
+ | Caller | Sees |
11
+ |---|---|
12
+ | An agent in its own fold | everything under `wiki/` |
13
+ | An agent reaching another fold (`all: true`, `source:`) | that fold's `shared/` only |
14
+ | The operator over HTTP (`/api/wiki/*`) | everything, in every fold — scoping is about agents' reach, not operator secrecy |
15
+
16
+ `list_wiki`, `read_wiki`, and `search_wiki` all apply it (`shared_only` on
17
+ `WikiSource`, `src/memory/wiki.ts`). Reading another fold's local page
18
+ returns *"local to wiki source X, not shared"* rather than not-found, and
19
+ `locatePage` lets a bare `foo` fall through to `shared/foo.md`. The index
20
+ holds a fold's whole wiki (its owner searches it too); visibility is a
21
+ query-time filter, so no schema changed.
22
+
23
+ **The install directory is a fold.** `ensureInstallFold` registers the
24
+ package root (`dist/../..` — a git checkout, a global npm install, a
25
+ `node_modules` copy) in `folds` at daemon startup if it isn't there, and
26
+ never touches an existing row. There is no longer a hardcoded `brane` wiki
27
+ source: brane's shipped pages are the install fold's `shared/`, visible to
28
+ every fold by the rule above and *owned* only when the caller's fold is the
29
+ checkout itself. `package.json` `files` ships the single directory
30
+ `wiki/shared`, which is what makes the tarball the same set of pages the
31
+ daemon serves. Source labels are the fold's basename, disambiguated in
32
+ registration order (`brane`, `brane~2`) by `wikiFoldLabels` so a label is
33
+ stable across callers; `protocols` is reserved.
34
+
35
+ **Links are filesystem-relative, and shared → local is a citation, not a
36
+ promise.** From `shared/a.md`, `b.md` is `shared/b.md` and `../c.md` is the
37
+ local `c.md`. `resolveWikiLink` in `src/shared/wiki-links.ts` is the one
38
+ resolver, used by `expo/components/WikiReader.tsx` and pinned by
39
+ `src/test/wiki-links.test.ts`; it returns `null` for a scheme, an absolute
40
+ path, or a climb above `wiki/`. A shared page may link to a local one — a
41
+ non-owner following it gets the scoped error — so the protocol requires a
42
+ shared page to read without its local links. Which side a page lives on is
43
+ the operator's call; `/wiki` never moves pages.