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,50 @@
1
+ # The Selection Store
2
+
3
+ The chat's message selection is a store outside React state
4
+ (`MessageSelection`, `src/shared/message-selection.ts`), and the two hooks
5
+ that bind it (`expo/lib/selection.ts`) are the only React in it. The screen
6
+ holds no selection state at all.
7
+
8
+ It was `useState<Set<number>>`, and that shape had both a cost and a bug.
9
+
10
+ **The cost is a cascade, not a copy.** A tap replaced the `Set`, which
11
+ re-rendered the `[nid]` screen, which rebuilt `handleTimestampPress`
12
+ (dependency: `selectedSeqs`), which rebuilt `renderItem`, which handed every
13
+ mounted `MessageBubble` a new callback prop — so `memo` missed on all of
14
+ them. Two bubbles changed color; two hundred rows and the screen above them
15
+ re-rendered.
16
+
17
+ **The bug is a stale closure, and it drops taps.** Two taps in the same
18
+ React batch — a fast double-tap, a trackpad — and the second computed from
19
+ the set the first had already replaced, silently undoing it. Measured in a
20
+ headless browser against a real daemon: on `origin/main` two rapid taps left
21
+ **one** message selected, on the store both. A store is single-writer and
22
+ authoritative; a closure over a snapshot is neither.
23
+
24
+ | Subscription | Who | Wakes when |
25
+ |---|---|---|
26
+ | `subscribeSeq(seq)` | each `MessageBubble`, via `useIsSelected` | *its* seq is selected or deselected |
27
+ | `subscribe()` | the header (Export's count), the composer (`re`) | any change — one component each, which is the point |
28
+ | none | the screen, `renderItem`, Export's press handler (`getSelected()` at press time) | never |
29
+
30
+ Three details are load-bearing:
31
+
32
+ **A clear notifies what was cleared, not everyone.** `clear()` wakes exactly
33
+ the rows that were selected and returns them; an empty clear is not a change
34
+ and wakes nobody. `replace()` wakes the symmetric difference. A broadcast
35
+ would put the whole list back on the path this exists to keep it off.
36
+
37
+ **The snapshot's identity is stable.** `useSyncExternalStore` compares
38
+ snapshots by identity, so a getter that allocates re-renders forever. The
39
+ ascending array is rebuilt on change and only on change, and an empty
40
+ selection is one frozen shared array.
41
+
42
+ **Buckets are dropped when their last listener unsubscribes.** Rows unmount
43
+ as the list scrolls; without that, the per-seq map would grow by every
44
+ message ever scrolled past.
45
+
46
+ Pure and in `shared/` for the usual reason: `src/test/message-selection.test.ts`
47
+ checks the notification rules without a browser, and
48
+ `src/test/selection-wiring.test.ts` reads `expo/app/[nid].tsx` to hold the
49
+ screen to the wiring — both spellings select messages, so only a source lint
50
+ can keep the old one from coming back.
@@ -0,0 +1,61 @@
1
+ # Session Affinity
2
+
3
+ A `claude` narrative whose Claude Code talks to Fireworks (an
4
+ `ANTHROPIC_BASE_URL` on a `fireworks.ai` host) sends `X-Session-Affinity:
5
+ <nid>` with its LLM requests. `ensureSessionAffinityConfig`
6
+ (`src/session-affinity.ts`) runs on every spawn and revive, from `doSpawn`,
7
+ `prepareAgentLaunch`, and the same-agent revive path in `src/daemon/server.ts`,
8
+ and returns `{ args, env }`: CLI args for the agent and an env prefix for the
9
+ tmux command. Fireworks uses the header to keep a session on one replica, and
10
+ its per-replica KV prefix cache only stays warm that way. The nid survives
11
+ revives, so the affinity does too. Every other claude spawn launches exactly as
12
+ before. Opt out with `brane config session-affinity.enabled false`.
13
+
14
+ The header name and its alternatives come from Fireworks' prompt-caching guide:
15
+ `x-session-affinity` (header), `prompt_cache_key` (request body, the
16
+ preferred field), `user` (body, fallback), and `x-multi-turn-session-id`
17
+ (agent traffic). Claude Code can't set a body field (it has no config surface
18
+ for one), so the header is what it gets. Cache hits show up as
19
+ `usage.cache_read_input_tokens` on the Anthropic-compatible `/v1/messages`
20
+ path, which the cost ledger already records, and in the
21
+ `fireworks-cached-prompt-tokens` response header.
22
+
23
+ Claude Code reads `ANTHROPIC_CUSTOM_HEADERS` (newline-separated
24
+ `Name: Value`) from its environment. Where that value comes from decides how
25
+ brane adds to it:
26
+
27
+ **A settings file defines it.** A settings file's `env` block is written
28
+ *over* the inherited shell value, and settings `env` values are not
29
+ `${VAR}`-expanded (verified against claude 2.1.278). So nothing in the shell
30
+ can add to it, and a shared fold file could only hold one literal nid. The
31
+ workaround is `claude --settings <path>`, which outranks every settings file.
32
+ brane writes one file per narrative at `~/.brane/session-affinity/<nid>.json`
33
+ containing one key: the value from the highest-precedence file (local >
34
+ project > user), with any stale affinity line stripped (case-insensitive
35
+ name match, the same parse Claude Code performs) and the affinity line
36
+ appended. That value includes whatever credentials the operator's headers
37
+ carry, so the file is `0600` in a `0700` directory, re-tightened on every
38
+ write. The operator's own settings files are never written. This was verified
39
+ end to end against a header-logging listener: the nid arrives, the fold's other
40
+ lines come through, and a hand-added stale affinity line is replaced.
41
+
42
+ **Nothing defines it.** Then Claude Code uses whatever the *session's* shell
43
+ inherits, which comes from the tmux server, and the tmux server need not share
44
+ the daemon's environment. Reading the daemon's `process.env` into a
45
+ `--settings` file would *replace* headers the session had and the daemon
46
+ didn't. The first draft did exactly that, and an `X-Shell` header exported
47
+ where tmux started disappeared from the wire. So the header goes in the tmux
48
+ command's env prefix instead (`shellAffinityEnv`), and the session's own shell
49
+ appends it to whatever it inherited. The Fireworks check does read the
50
+ daemon's env as a last resort for `ANTHROPIC_BASE_URL`. If that misses a URL
51
+ only the session has, the cost is affinity and nothing more.
52
+
53
+ Managed settings still outrank `--settings`; delivering through them is out of
54
+ scope.
55
+
56
+ **Crush is deliberately left out.** It already sends `x-session-affinity` and
57
+ `x-session-id` (a hash of its own session id), and that value wins over any
58
+ configured `extra_headers`. A static `$BRANE_NARRATIVE_ID` entry was tried and
59
+ never reached the wire. Crush's key changes when a narrative is revived,
60
+ because crush starts a new session. Other harnesses have no observed Fireworks
61
+ traffic.
@@ -0,0 +1,211 @@
1
+ # Terminal Mirroring
2
+
3
+ The terminal tab is a **mirror, not an attach**, and that is a design position
4
+ rather than an unfinished one. `capture-pane` is a *read* of shared state, so
5
+ any number of viewers watch one 100 ms capture loop with no tmux client
6
+ attaching, no attach/detach churn, and sizing that is brane's policy
7
+ (`fitWindowToViewers` — smallest active viewer wins, so a phone joining can
8
+ never clip a TUI's bottom rows). Idle costs almost nothing: `content ===
9
+ cap.lastContent` returns before a *diff* is sent, so a session waiting on
10
+ `listen` produces no repaints and no client rAF work — only the resync
11
+ heartbeat below. And because the frame is text rendered into DOM lines,
12
+ selection, ⌘F and copy are the platform's rather than ours — an emulator would
13
+ have us re-implement them.
14
+
15
+ The mirror's own consequence is that **anything not in the pane's text has to
16
+ be sent explicitly**. There is exactly one such thing, and it is the cursor.
17
+
18
+ ## The resync heartbeat
19
+
20
+ Sending a diff only when the content changes is right, but it rests on an
21
+ assumption the protocol never checks: that a frame the daemon *sent* is a frame
22
+ the browser *rendered*. `c.lastContent` records the former and is read as
23
+ though it meant the latter. Every way a browser can drop a frame — a
24
+ backgrounded tab whose `requestAnimationFrame` never ran, a socket that
25
+ reconnected into a wiped `<pre>`, an early return in `applyFrame` — therefore
26
+ strands that viewer, and because the pane then goes quiet, *nothing ever
27
+ corrects it*. The only repair is a page reload, which is not a repair the
28
+ operator should have to know about. Observed in the wild as a phone showing an
29
+ entirely black terminal while the daemon's socket was demonstrably serving
30
+ frames the whole time.
31
+
32
+ `TERMINAL_RESYNC_MS` (2 s) closes the class without an ack protocol: a viewer
33
+ that hasn't been sent *content* in that long is re-sent the current frame,
34
+ whatever the reason it fell behind. Any dropped frame self-heals within one
35
+ interval.
36
+
37
+ | Piece | Where | Does |
38
+ |---|---|---|
39
+ | `lastSentAt` | `TerminalClient` | When this viewer was last sent content. Cursor-only messages don't touch it — they carry none |
40
+ | `isResyncDue()` | `src/daemon/server.ts` | The boundary, pure, so `src/test/terminal-resync.test.ts` can pin it |
41
+ | `resyncStaleViewers()` | the capture loop | Runs on **both** paths — see below |
42
+
43
+ Three details are load-bearing:
44
+
45
+ **Both paths, not just the idle one.** The obvious place is the `content ===
46
+ cap.lastContent` early return. The other is after the per-client fan-out, where
47
+ a viewer skipped by `content === c.lastContent` is *exactly* the stranded case
48
+ — its `lastContent` says it has this screen and its DOM says otherwise.
49
+
50
+ **`lastSentAt: 0` is the "due now" sentinel.** A fresh socket and a `refresh`
51
+ that just wiped the client's `<pre>` both set it, and against an epoch clock
52
+ `now - 0` clears any threshold — so those viewers get content on the very next
53
+ tick rather than waiting out an interval.
54
+
55
+ **Idle stays cheap.** One ~1.6 KB message per viewer every 2 s, serialized once
56
+ per tick and shared across viewers. On the client it is a full frame whose
57
+ per-line diff finds nothing changed, so it writes no DOM. A busy pane already
58
+ sends more often than this and is unaffected.
59
+
60
+ ## The stranded rAF
61
+
62
+ The heartbeat guarantees the daemon keeps *offering* content. It does not
63
+ guarantee the client can *accept* it, and there was one way it could not.
64
+
65
+ Frames are coalesced through a single `requestAnimationFrame`, with
66
+ `rafIdRef !== null` standing for "a drain is already coming". That reading
67
+ holds only while the browser still intends to run the callback. **iOS Safari
68
+ freezes a backgrounded tab and can drop the scheduled callback outright** — an
69
+ app switch, a screen lock, an incoming call. The page resumes holding a
70
+ non-null id for a drain that will never fire, and from that moment the guard is
71
+ permanently closed: every later frame is stored in `pendingRafFrameRef` and
72
+ none is applied. Unmounting is the only thing that clears it, which is to say a
73
+ page reload. A pane that never got its first frame this way is not stale but
74
+ *blank*.
75
+
76
+ Note the interaction: **a stranded rAF swallows the heartbeat too**, delivering
77
+ fresh frames every 2 s into a queue nothing drains. The two fixes are not
78
+ alternatives; neither is sufficient alone.
79
+
80
+ | Piece | Does |
81
+ |---|---|
82
+ | `RAF_DRAIN_DEADLINE_MS` (400 ms) | An arriving frame that finds one already waiting this long stops trusting the schedule: cancel the token and drain inline. Generous beside a 16 ms paint on purpose — a liveness backstop, not a latency budget |
83
+ | `visibilitychange` → visible | Clear the stale token, drain anything pending, and `refresh()` the channel |
84
+
85
+ The `visibilitychange` half earns its place because foregrounding is both the
86
+ moment a callback was most likely dropped and the only moment repair is
87
+ possible without waiting: an idle pane's next frame is two seconds out, and the
88
+ `refresh()` is what restates a DOM that went stale for a reason the client
89
+ never observed. It is gated on the pane being active: an inactive one
90
+ has its DOM wiped and refreshed by `cleanRefresh` on activation anyway.
91
+
92
+ **A frame arriving no longer means the pane changed.** That equivalence was
93
+ free before the heartbeat and is what `applyFrame`'s auto-scroll was built on.
94
+ "At bottom" is a 40 px band, so an identical frame every 2 s would yank a
95
+ just-started finger-scroll back down for as long as the reader stayed inside
96
+ that band — a window that recurs forever rather than a one-off, on every
97
+ scrollback pane (alternate-screen panes are `overflow: hidden` and unaffected).
98
+ `applyFrameLines` and `applyAppendLines` therefore **return whether they wrote
99
+ any line node**, and the scroll follows that rather than the frame's arrival. A
100
+ frame that changed nothing has nothing to follow.
101
+
102
+ ## The cursor
103
+
104
+ One tmux invocation per tick carries both halves of a frame:
105
+
106
+ ```
107
+ display-message -p -F "#{cursor_x} #{cursor_y} #{cursor_flag} #{alternate_on} #{pane_height}" \; capture-pane -e -p -S - -E - -t <nid>
108
+ ```
109
+
110
+ Deliberately **one** call. A keystroke landing between a separate cursor query
111
+ and its capture would paint the block a cell away from the character it belongs
112
+ on — the one artifact a cursor cannot have. It also replaced the periodic
113
+ `alternate_on` probe, so the per-session exec count went down.
114
+
115
+ | Piece | Where | Does |
116
+ |---|---|---|
117
+ | `TERMINAL_STATUS_FORMAT` | `src/daemon/server.ts` | The five fields, all numeric, so the parse cannot be ambiguous |
118
+ | `parseTerminalStatus()` | `src/daemon/server.ts` | Status line → fields, `null` on anything unreadable |
119
+ | `terminalCursorFrom()` | `src/daemon/server.ts` | Pane coordinates → frame coordinates (pure; `src/test/terminal-cursor.test.ts`) |
120
+ | `syncCursor()` | `expo/components/TerminalPane.tsx` | Parks the block on the line it belongs to |
121
+
122
+ Three things are less obvious than they look:
123
+
124
+ **`cursor_y` is pane-relative; a frame is not.** `capture-pane -S - -E -`
125
+ returns `history_size + pane_height` rows, so the visible pane is the *last*
126
+ `pane_height` of them and the cursor's frame line is `rows − pane_height +
127
+ cursor_y`. The pane's trailing blank rows are sent, not trimmed, and the
128
+ client keeps them. They are what puts a cleared screen's prompt (^L, `clear`)
129
+ at the top of the viewer, with the old screen scrolled into history above it,
130
+ as in a real terminal; trimmed, the prompt sat at the bottom, right under the
131
+ output it had cleared. Because the window is fitted to the smallest active
132
+ viewer, the pane always fits, so those blank rows can never fill the view of
133
+ someone scrolled to the bottom.
134
+
135
+ **`cursor_flag` gates rendering.** An application that hides its cursor means
136
+ it — Claude Code while a dialog is up, lazygit throughout — and a block drawn
137
+ anyway is a caret the operator's real terminal doesn't show. Claude Code
138
+ otherwise uses the *real* terminal cursor, so what tmux reports is Claude
139
+ Code's cursor rather than an approximation of it.
140
+
141
+ **A cursor moves when nothing else does.** Walking an arrow key along a shell
142
+ input line repaints not one character, so the frame early-return would send
143
+ nothing at all — precisely when a cursor is worth most. Position changes with
144
+ unchanged content therefore get their own `{type: "cursor"}` message, outside
145
+ the rAF frame coalescer, which exists to batch whole-screen work.
146
+
147
+ Client-side, the block is a span parked **inside the line div it belongs to**,
148
+ not positioned against the pane. It inherits that line's vertical position, so
149
+ scrolling, alternate-screen centering and reflow are all correct for free, and
150
+ the only thing computed here is `col × charWidth`. It paints with
151
+ `mix-blend-mode: difference` over white, inverting the glyph underneath the way
152
+ a real terminal does instead of covering it — which also means the client never
153
+ has to know which character it is sitting on. Solid and blinking when this pane
154
+ is where keystrokes land, hollow when they aren't: across split tabs and
155
+ stacked routes, "which terminal am I typing into" is otherwise a guess.
156
+
157
+ ## Paste and copy
158
+
159
+ Paste goes through a tmux **buffer**, never `send-keys`:
160
+
161
+ ```
162
+ load-buffer -b brane-paste - (text on stdin)
163
+ paste-buffer -b brane-paste -p -t <nid>
164
+ ```
165
+
166
+ `-p` is the point: it wraps the text in bracketed-paste markers *when the
167
+ application asked for them*, so Claude Code sees 40 pasted lines as one paste
168
+ rather than 40 submits, while a shell that doesn't do bracketed paste behaves
169
+ exactly as it would under `tmux attach`. A buffer is also not bounded by argv
170
+ (1 MB ceiling, `MAX_PASTE_BYTES`).
171
+
172
+ Reaching the clipboard differs by platform, and all three paths converge on the
173
+ socket's `paste` message:
174
+
175
+ | Where | Path |
176
+ |---|---|
177
+ | Desktop | A document-level `paste` listener, behind the same two guards as keydown — the terminal is a `<pre>`, never an editable, so the event has no closer home |
178
+ | Any platform chord | `isPasteChord()` makes keydown *step aside* for ⌘V (Mac) / ⌃V, ⌃⇧V (elsewhere). Swallowing ⌃V sent `C-v` to tmux and the browser never fired `paste`; ⌃V on a Mac stays quoted-insert |
179
+ | Touch | The `^V 📋` keycap in the 🦾 controls menu (it took ^R's slot, so the quick bar fits phones narrower than a Max) reads the clipboard directly (a tap is the required gesture; iOS shows its own confirm chip). Mobile fires no `paste` event without a focused editable, so there is nothing for the listener to catch |
180
+ | Touch, refused | A sheet with a real `<textarea>`, so the OS long-press → Paste menu works. The document listener catches that paste and closes the sheet |
181
+
182
+ **Copy-out needs an escape hatch, because the mouse is spoken for.** In
183
+ alternate mode every drag is forwarded as an SGR mouse event, which leaves no
184
+ way to select a line. Holding **Option or Shift** suppresses forwarding and
185
+ lets the browser select normally — the same convention Terminal.app and iTerm
186
+ use when an application has grabbed the mouse — and the modifier also restores
187
+ the context menu with its Copy item.
188
+
189
+ ## Block glyphs
190
+
191
+ A mirror line is text in a line box pinned at `line-height: 1.4`, and a
192
+ font's block glyph (`▀ ▄ █`) covers only its own ascent and descent, about
193
+ 14 px of a 16.8 px row. So block art was striped with a dark gap on every
194
+ row, and `brane meld`'s half-block QR code came out looking like a barcode
195
+ that scanned badly. The fix is on the client and nowhere else. `meld` prints
196
+ the same bytes, so a real terminal is unaffected.
197
+
198
+ `paintBlockGlyphs()` (`src/shared/block-glyphs.ts`, pinned by
199
+ `src/test/block-glyphs.test.ts`) runs on each changed line after ansi_up. It
200
+ wraps every U+2580–U+259F character in a span one cell wide and one line box
201
+ tall, which paints the glyph as a background in `currentColor`, so SGR
202
+ colors carry through. The character stays in the DOM, hidden with
203
+ `-webkit-text-fill-color`, so copy still yields it. A line with no block
204
+ element costs one regex test.
205
+
206
+ **One full-cell image per glyph, never a sized layer.** The first cut
207
+ painted each rectangle as a layer sized and positioned inside the cell, and
208
+ left a hairline between lit rows. Each row starts on a fractional pixel, and
209
+ each layer anti-aliased its own edge onto the shared boundary pixel. A
210
+ full-cell gradient with hard stops (linear for bars, conic for quadrants)
211
+ has the span's own edges, and the browser snaps those to whole pixels.