brane 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
package/docs/absorb.md ADDED
@@ -0,0 +1,231 @@
1
+ # Absorb
2
+
3
+ `brane absorb` imports conversation history from external LLM providers. Input is an **extracted export directory** (not a ZIP). `--fold` is required.
4
+
5
+ The import runs **in the daemon**: `brane absorb` POSTs to `/api/absorb` and prints what comes back. `src/absorb/run.ts` holds the orchestration the CLI used to do in-process (`runAbsorb`), and the route streams its `AbsorbEvent`s as NDJSON — `info` lines for the pre-flight report, throttled `progress` lines the CLI renders as its `\r` counter, and a final `summary`; a failure after the stream has opened ends it with an `error` line, while anything the operator has to fix (unknown provider, missing directory, no identity, a fold the daemon would refuse) is refused as a plain 400/403 before the stream opens (`AbsorbInputError`, `FoldRegistrationError`) — a dry run checks the fold too, so it cannot pass a case the real run would fail. A successful import kicks off a memory indexing pass, not awaited, so search works right after it. The CLI resolves relative paths against its own cwd before sending, since the daemon's cwd means nothing to the operator; `~/…` is left for the daemon to expand. The organelle importer yields to the event loop between narratives so the daemon keeps answering — and so the progress lines actually reach the client mid-run.
6
+
7
+ ```bash
8
+ brane absorb chatgpt <export-dir> --fold <fold-path> --dry-run # preview
9
+ brane absorb chatgpt <export-dir> --fold <fold-path> # import
10
+ ```
11
+
12
+ The absorb pipeline:
13
+ 1. Scans directory for conversation shards (`conversations-NNN.json`) + `user.json`
14
+ 2. Builds a file index mapping asset pointer IDs to local files
15
+ 3. Stream-parses each shard (one conversation at a time, low memory)
16
+ 4. Resolves image asset pointers → copies files to `~/.brane/payloads/`
17
+ 5. Extracts audio transcription text alongside string content parts
18
+ 6. Inserts conversations as archived narratives with `role: "absorbed"`
19
+
20
+ `--dry-run` exercises all parsing and asset resolution without writing to the database.
21
+
22
+ ## Local sources
23
+
24
+ The export providers above read a directory the operator asked a vendor
25
+ for. A **local source** reads a session store an agent harness keeps on this
26
+ machine, in place, and can be read again tomorrow. `brane absorb claude` is
27
+ the first: `~/.claude/projects/<cwd>/<session>.jsonl`, the engineering
28
+ half of a Claude user's history, already on the disk of anyone who has run
29
+ `claude` once. It is the launch demo — one command and every Claude Code
30
+ session ever run is searchable — and the reason `brane absorb survey`
31
+ exists: a new user's first step is to be told what is there. A bare
32
+ `brane absorb` prints usage, which names it.
33
+
34
+ ```bash
35
+ brane absorb # usage, naming the providers and `survey`
36
+ brane absorb survey # what this machine has, what an import would do
37
+ brane absorb claude --dry-run # the import, planned
38
+ brane absorb claude # the import
39
+ brane absorb claude --fold ~/dev/app # only sessions run in that directory
40
+ brane absorb claude --since 2w # only sessions active recently
41
+ brane absorb claude --session 3f1a # one session, by id prefix
42
+ brane absorb claude ~/backup/.claude # a copied store instead of ~/.claude
43
+ brane absorb claude --rebuild # re-derive absorbed sessions nobody has written into
44
+ ```
45
+
46
+ | Piece | Where | Does |
47
+ |---|---|---|
48
+ | `LocalSource` | `src/absorb/types.ts` | The seam: `locate` (is the store here), `identity`, `survey` (one cheap row per session — a stat and a head read, never message content), `extract` (the parse) |
49
+ | `claudeCodeSource` | `src/absorb/claude-code.ts` | Survey and parser for Claude Code's JSONL |
50
+ | `surveyAbsorbSources()` | `src/absorb/sources.ts` | The inventory behind `brane absorb survey` and `GET /api/absorb/survey`; also the `PROBES` — stores brane recognises but cannot read yet (codex, goose, copilot, gemini, cline, amp, Claude Desktop agent-mode), reported as *found, no extractor* so the operator learns what is there either way. The list is the tools brane spawns plus Claude Desktop, because those formats have been looked at, not because a source needs spawn support: an importer for a tool brane cannot run is welcome, the probes just aren't speculative (claude-xiy9e8wq:16, :17) |
51
+ | `classifySessions()` | `src/absorb/sources.ts` | Each session as `managed` / absorbed-`unchanged` / absorbed-changed / `new`, shared by survey and import so the two cannot disagree. "Unchanged" means same size and mtime *and* read by a parser as new as this one |
52
+ | `runLocalAbsorb()` | `src/absorb/run.ts` | The import: survey → select → classify → parse only what is new or changed |
53
+ | `createAbsorbContext()` / `absorbOne()` | `src/absorb/index.ts` | The per-conversation *plan* and the write that follows it exactly; the ChatGPT stream feeds the same pair |
54
+
55
+ Four things are less obvious than they look:
56
+
57
+ **Idempotent per message, not per conversation.** The count-based append
58
+ the ChatGPT path uses ("more messages than events → append the tail")
59
+ cannot survive Claude's `--fork-session`, which copies a predecessor's
60
+ records — ids included — into the successor's file. `absorb_messages` keys
61
+ every written message by the provider's own id (`uuid`); a key already held
62
+ is never written again, wherever it is read from. A successor file therefore
63
+ lands as **only its new messages, the first of them `re` the predecessor's
64
+ last** — a fork reads as a continuation, the same shape the twin protocol
65
+ gives a live fork. A file that repeats another with nothing after it is not a
66
+ narrative and is skipped. Sessions are absorbed oldest first so the
67
+ predecessor's keys are always there before the successor asks (the cost
68
+ backfill's ordering rule, for the same reason).
69
+
70
+ **`--brane` is a destination, and destinations are independent.** Both
71
+ ledgers and the absorbed-narrative lookup are keyed by brane as well as
72
+ provider, so the same store imported into two branes is two complete
73
+ copies, each with its own idempotent re-runs. The first cut keyed on
74
+ provider alone, and a second brane saw every file as "unchanged" and read
75
+ nothing (claude-xiy9e8wq:38); the provider-only rows were carried over by a
76
+ migration that keeps the old tables as `_v1`. The survey answers for the
77
+ brane a plain `brane absorb <source>` would target, the one derived from
78
+ the account.
79
+
80
+ **Sessions brane already owns are not imported** (claude-xiy9e8wq:13). A
81
+ brane-spawned agent's session file sits in the same store as everyone else's.
82
+ `classifySessions` marks a session `managed` when a live (non-absorbed)
83
+ narrative of that agent type holds its id in `agent_session_id` or
84
+ `session_ids`, and the import skips it and says so. Beneath that, the append
85
+ path matches only `role = 'absorbed'` narratives, so nothing an import reads
86
+ can ever be written into a live narrative's log. The agent-type filter is
87
+ load-bearing, not an oversight: an ordinary `claude` run in a fold with a
88
+ `.mcp.json` still bridges in, as an `external` narrative holding the session
89
+ id, and it is deliberately *not* managed. That narrative is a participant
90
+ that narrates almost nothing; the transcript is the only copy of the
91
+ conversation, so skipping it would make the conversation unsearchable rather
92
+ than deduplicated. The two coexist — the participant live in the home brane,
93
+ the record absorbed into the account's.
94
+
95
+ **Tool calls go to `tool_uses`, not into the text** (claude-xlolbv6l:65). A
96
+ `tool_use` block becomes a row with `source: "absorb"` through the same
97
+ `recordToolUse` the hooks use, keyed on the block's `toolu_…` id; the
98
+ `tool_result` that follows supplies output, `failed` on `is_error`, and a
99
+ duration from the two records' timestamps. `recordToolUse` grew an optional
100
+ `afterSeq` for this: a hook's row belongs at the narrative's head because the
101
+ call is happening now, an absorbed row belongs beside the message that made
102
+ it. `thinking` blocks are not imported, and reasoning is not something
103
+ absorb promises for any source: some tools withhold it, brane has nowhere
104
+ to show it, it is far more text than a reader wants, and it reads as
105
+ authoritative while being the part most likely to be wrong
106
+ (claude-xiy9e8wq:20, :22). A narrative still never records itself: for a brane-managed session the whole
107
+ file is skipped, so no `narrate`/`listen` row is ever written from a
108
+ transcript.
109
+
110
+ **A user turn is not always the operator's.** Claude Code writes its own
111
+ text into `user` records, each kind in a tag, and read as prose the tags
112
+ show through (claude-xiy9e8wq:29). Measured over one machine's store, by
113
+ text block: 2,100 `<task-notification>` (a background task finished), 398
114
+ `[Request interrupted by user]`, 17 `<command-name>` slash commands with
115
+ their `<local-command-stdout>`, and 9 `<bash-input>`/`<bash-stdout>` pairs
116
+ from `!` commands. `classifyUserBlock` gives each the rendering its meaning
117
+ calls for, per block, so typed text beside an injected block keeps its own
118
+ shape: a `!` command becomes `$ cmd` in a `sh` fence and its output a plain
119
+ fence (stderr labelled), a slash command a code span, an interrupt an italic
120
+ marker. A task notification is the completion of a call the agent made, not
121
+ something the operator said, so it becomes a `tool_uses` row of its own
122
+ (`name: task-notification`, keyed on the record uuid since one task can
123
+ notify twice, `parent_id` the call's `toolu_…` id when the harness names it)
124
+ and never enters the chat. Fences are sized past the longest backtick run
125
+ inside them, so a captured code block cannot break out.
126
+
127
+ **A message sent mid-turn is an attachment, and the attachment is the
128
+ receipt** (claude-jdfccmvh:10). What the operator types while the agent is
129
+ working is never written as a `user` record. The harness queues it
130
+ (`queue-operation`: enqueue, then dequeue or remove), and when the running
131
+ turn takes it in, writes an `attachment` of type `queued_command` at the
132
+ point the agent read it — after the tool result it was waiting on, in the
133
+ `parentUuid` chain — stamped with the time it was typed. Reading only `user`
134
+ records dropped every such message, and left the record full of confident
135
+ answers to questions it did not contain. `queuedTurnOf` reads the attachment
136
+ and nothing else: `commandMode: "prompt"` is the operator's message,
137
+ `"task-notification"` is a background task finishing by the same route and
138
+ goes to `tool_uses` like its `user`-record twin, and an unknown mode is left
139
+ out rather than guessed at. The queue-operation records are no second
140
+ signal — older harness versions write `remove` with no reason for a
141
+ delivery, and task notifications carry no `origin` — and none is needed: a
142
+ message the operator cancelled (popped back into the editor) or that was
143
+ never delivered has no attachment, so it stays out of the record as it
144
+ stayed out of the conversation (claude-jdfccmvh:8). Measured on one
145
+ machine's store: 450 attachments, 103 `prompt` and 347 `task-notification`;
146
+ of 113 queued messages that never went through, none had one.
147
+
148
+ **A pasted image is a payload** (claude-7irbudpw:52). Claude Code writes a
149
+ pasted image into the `user` record itself, base64 in an `image` block
150
+ beside its `[Image #N]` text; an image that arrives mid-turn is a
151
+ `turnCompanion` record written the same way, with `isMeta` set and no text.
152
+ The parser carries both as `attachments` on the message, the inline
153
+ counterpart of the ChatGPT path's `assetPointers`, and the write decodes
154
+ them into `~/.brane/payloads/<nid>/<time>_<seq>_<j>.<ext>` with the
155
+ extension from the block's `media_type`, so the chat shows them inline
156
+ through the payload route with no change on that side. A record with an
157
+ image and no words becomes a message reading `(attachment)`, the placeholder
158
+ the web app writes for an image-only signal, rather than falling to the
159
+ empty-body guard; an `isMeta` record still contributes no text, only its
160
+ images. Per-message idempotence covers the files: a message written once has
161
+ its payloads written once. `createAbsorbContext` takes a `payloadsDir`
162
+ override so the suite never writes under `~/.brane`. Not yet a payload: an
163
+ image inside a `tool_result` (a `Read` of a `.png`), because `tool_uses` has
164
+ no payload column; today that base64 lands in `output` under the capture cap.
165
+
166
+ **A parser improvement reaches old narratives late, not out of order.**
167
+ Events are append-only, so a record type the parser learns to read (#460's
168
+ slash commands, the mid-turn messages above) has no key in `absorb_messages`
169
+ and lands at the narrative's tail on the next ordinary re-run — after
170
+ messages it preceded. `plan()` notices: a new message that belongs before
171
+ something the narrative already holds is written `re` the held message it
172
+ follows in the conversation (`lateFor`). It reads as late, it links to its
173
+ place, and every `nid:seq` anyone has cited still says what it said. For
174
+ the re-run to happen at all, `absorb_files` records which parser read each
175
+ file (`LocalSource.parserVersion`): a finished session's file never changes
176
+ again, so on (size, mtime) alone the improvement would reach it only through
177
+ `--rebuild`. A file last read by an older parser is not "unchanged", and the
178
+ next ordinary run reads it once more; the survey counts such files as their
179
+ own bucket ("to re-read (newer parser)"), so a parser upgrade does not read
180
+ as every absorbed file having changed. Bump the version when the parser
181
+ learns to read something it used to skip. That is the default way a parser
182
+ change reaches history; `--rebuild` is not. (Run over one machine's store
183
+ for the mid-turn change: every absorbed session re-read, 0 of 11,812
184
+ existing events altered, one recovered message landing `re` the narration it
185
+ interrupted.)
186
+
187
+ **`--rebuild` is the one deleting operation, and it deletes only a
188
+ rendering.** It empties each selected narrative this brane has absorbed —
189
+ events, tool rows, ledger rows, memory-index rows and cursor — and imports
190
+ its file again: same narrative id and `created_at`, but **seqs from 1**, so
191
+ the narrative still resolves and a `nid:seq` citation into it may now land
192
+ on a different message; title refreshed; payload files left where they are
193
+ and rewritten by name with the same bytes. It is allowed under the golden
194
+ rule because the flag is the operator's explicit instruction and what is
195
+ discarded is derivable from a file that is demonstrably still on disk (only
196
+ survey candidates reach it). That holds only while the narrative is nothing
197
+ but that rendering. An absorbed narrative can still be written into — a
198
+ signal from the app, a foreign narration, a payload attached live — and
199
+ none of that is in any file. So a narrative holding an event with no
200
+ `absorb_messages` row (`liveContributions`) is **locked in**: `--rebuild`
201
+ reads it as an ordinary re-run, which only appends, and reports it as "Not
202
+ rebuilt" (claude-jdfccmvh:13). Talking in an absorbed narrative should be
203
+ uncommon anyway — a conversation that wants to continue belongs in a
204
+ `/twin`, which reads the record and leaves it alone (claude-jdfccmvh:15).
205
+ The guard is in the SQL (`resetAbsorbedNarrative`): only `role =
206
+ 'absorbed'`, only this brane, only the narrative bound to that session id,
207
+ and only one holding nothing but what an import wrote; a live narrative is
208
+ never a candidate. Composes with `--fold`, `--since`, `--session` and
209
+ `--dry-run`. `memory_content_df` is left alone; `brane memory index
210
+ --rebuild` recomputes it. An ordinary re-run also refreshes a narrative's
211
+ title from a grown file now, since the harness retitles as it goes.
212
+
213
+ **No fold is registered.** Each session knows the directory it ran in, and
214
+ that becomes the narrative's `fold_path`. Registering it too would call
215
+ `registerFold` with the foreign brane id and *rebind* the operator's own
216
+ `~/dev/x` to the absorbed brane, changing what `brane spawn` does there. So
217
+ for a local source `--fold` is a **filter** ("sessions from this directory"),
218
+ never a destination, and the survey reports directories as a count.
219
+
220
+ What the survey promises a new user, and how it keeps it: nothing is read
221
+ but a stat and the first 64 KB of each file (for `cwd` and the start time),
222
+ nothing is imported until a source is named, and an import makes no network
223
+ call — `src/absorb/` imports node builtins, better-sqlite3 and stream-json,
224
+ and nothing else. The identity that names the brane comes from
225
+ `~/.claude.json`'s `oauthAccount` (the account uuid, so one person's sessions
226
+ from two machines share a brane), falling back to the OS user.
227
+
228
+ Not yet a source, and each is one `LocalSource` away: Claude Desktop's
229
+ agent-mode `audit.jsonl` (stream-json records, plaintext, tool lineage
230
+ included), a claude.ai data export, and the codex/goose/copilot/cline stores
231
+ whose readers `src/agents.ts` already has for resume and cost.
@@ -0,0 +1,125 @@
1
+ # Cost Ledger
2
+
3
+ Each narrative card shows a cumulative cost (`narratives.cost_amount` /
4
+ `cost_unit`), sampled every 20 s for MCP-connected narratives from the
5
+ harness's own usage log — a Claude JSONL, goose's SQLite, a codex rollout.
6
+ Those logs belong to the harness, which may purge them, and `--fork-session`
7
+ copies a predecessor's messages (ids included) into the successor's file. Both
8
+ facts bear on the same question: *who gets credited for a message, and does
9
+ the answer survive the file going away?*
10
+
11
+ `usage_ledger` is the answer. The sampler writes what it read into the ledger
12
+ and reads the narrative's total back **from the ledger**, never from the
13
+ transcripts directly:
14
+
15
+ | row kind | key | written by | rule |
16
+ |---|---|---|---|
17
+ | entry | `<agent>:<message id>` | adapters with `readUsageEntries` (claude) | `INSERT OR IGNORE` — the first narrative to insert a key keeps the credit |
18
+ | session high-water | `session:<agent>:<session id>` | every other adapter, via its `readUsage` | `amount = MAX(old, new)` |
19
+
20
+ The primary key is the dedup. A message fork-copied into a later session, or
21
+ into another narrative, is a no-op on insert — and so is re-reading it after
22
+ the file that originally held it has been purged, which is what the older
23
+ "per-session totals that never decrease" idea got wrong: credit that is
24
+ recomputed from whichever files survive *moves*, and a moved credit plus a
25
+ frozen total is a double count. High-water rows are right for the harnesses
26
+ that never share history between sessions (codex's counter restarts at zero on
27
+ resume), because there is nothing there to mis-credit.
28
+
29
+ Rows are never deleted, and never re-priced implicitly, so between
30
+ re-pricings a ledger sum is monotonic by construction. Entry rows carry the
31
+ `model` and token counts that priced them — so a deliberate re-pricing is
32
+ possible as an explicit operation (see [Re-pricing](#re-pricing)), which is
33
+ precisely what a purged transcript would have made impossible. `cost_amount` caches it, under one transition rule: a column
34
+ value above the ledger sum is kept, since it was filled pre-ledger from
35
+ transcripts that may already be gone. Id-less `<synthetic>` records key on
36
+ their file position — unique and stable for an append-only transcript.
37
+ Writes are skipped for a session whose transcript signature hasn't changed,
38
+ so an idle session still costs one `stat` per tick.
39
+
40
+ **A session can change under a running process.** Claude's id is assigned
41
+ at launch (`--session-id`), but `/clear`, or `/resume` from inside a session,
42
+ moves the same process to a new transcript under an id Claude Code mints
43
+ itself. The bridge read `CLAUDE_CODE_SESSION_ID` once at startup and `/clear`
44
+ doesn't restart MCP servers, so nothing reported it: one narrative's card sat
45
+ at $5.52 while $26.97 went unread, and revive would have resumed the
46
+ conversation from before the `/clear`. Each live tick therefore runs
47
+ `maybeFollowLiveSession`, which asks the adapter's `liveSessionId` and, on a
48
+ change, retires the old id into `session_ids` and moves the pointer. Claude's
49
+ answer comes from `~/.claude/sessions/<pid>.json` for the pane's own pid
50
+ (`src/claude-live-session.ts`), accepted only if its `pid`, `tmux` session,
51
+ `cwd` and `startedAt` (no earlier than the latest spawn or revive) all match.
52
+ It is a pull from the one process whose record it is, deliberately not a hook
53
+ payload or bridge report: everything in the pane inherits the narrative's bind
54
+ secret, including a nested `claude -p`, and a subagent or reconnecting bridge
55
+ must have no way to move the pointer. A narrative whose pane is already gone
56
+ is not followed; its missed session reaches the ledger only if it is added to
57
+ `session_ids` and backfilled.
58
+
59
+ The 20 s sampler covers MCP-connected narratives plus a 120 s **linger** after
60
+ a narrative's last bridge closes — a session's final turns are written to the
61
+ transcript *after* MCP teardown, and the linger's few extra ticks are what
62
+ catch them. The linger set is in-memory, so a disconnect that straddles a
63
+ daemon restart is missed; that case — and every session that ended before the
64
+ ledger existed — reaches the ledger through **`brane cost backfill`**
65
+ (`POST /api/cost/backfill`). It runs in the daemon, like spawn and revive,
66
+ because the readers resolve transcripts against the daemon's HOME. It walks
67
+ narratives **oldest first**, which is what makes the credit assignment right:
68
+ an original must insert its messages before the twin that forked from them.
69
+ It is synchronous on the daemon's only thread — a cold pass over this
70
+ fold's transcripts is a few seconds — which is fine for a command the
71
+ operator runs and the reason it must never be wired to startup. It does
72
+ not run session-id discovery — fold+time matching against years of
73
+ history could adopt a transcript that was never the narrative's — so only
74
+ already-bound sessions are read. `--dry-run` reports the same per-narrative
75
+ lines (`readable/bound sessions`, rows and amount it would add, column before
76
+ → after) without writing; a second real run reports 0 changed.
77
+
78
+ ## Re-pricing
79
+
80
+ `src/cost/pricing.ts` is a substring-keyed table, first match wins, and a
81
+ price change reaches the ledger only through **`brane cost reprice`**
82
+ (`POST /api/cost/reprice`, `src/cost/reprice.ts`). It re-prices every USD
83
+ entry row that has a model and token counts under the current table,
84
+ **moves each affected narrative's `cost_amount` by the same delta**, and
85
+ broadcasts the new totals. `--dry-run` computes the identical result and
86
+ writes nothing. The operator runs it; it is never wired to startup.
87
+
88
+ | Piece | Where | Does |
89
+ |---|---|---|
90
+ | `DEFAULT_PRICES` | `src/cost/pricing.ts` | The table. A specific key (`fable-5-1`, `opus-5-5`, `sonnet-5`) sits above its family key, and an entry names its own `cacheRead` when it isn't 0.1× input. Rates last checked against the published pricing page on 2026-09-23 |
91
+ | `pricing.json` | `~/.brane/` | Operator overrides, matched **before** the defaults. Appended after them, as they once were, an override for `opus-5-5` could never beat `opus` |
92
+ | `LEGACY_PRICES_V1` | `src/cost/pricing.ts` | The table before 2026-09-23, frozen. Never edit it: it describes rows that already exist |
93
+ | `recoverCacheWrite1h` | `src/cost/reprice.ts` | The 5-minute / 1-hour split of an old row's `cache_write` |
94
+ | `cache_write_1h`, `speed` | `usage_ledger` | Recorded on every new row, so later reprices never have to infer |
95
+
96
+ Three things are less obvious than they look:
97
+
98
+ **Old rows lost the cache split, and the amount gives it back.** Rows
99
+ written before `cache_write_1h` existed hold 5-minute and 1-hour writes as
100
+ one count, and those bill at 1.25× and 2× input. But each amount was
101
+ computed by a known table from exactly those tokens, so pricing every write
102
+ as 5-minute undershoots it by `0.75 × input × (1-hour tokens)`. A recovered
103
+ split is accepted only if it is a whole number of tokens in `[0,
104
+ cache_write]` that reproduces the recorded amount. Anything else (a row
105
+ priced under an override, say) is reported as unrecoverable and left as
106
+ recorded. The first reprice writes the split it recovers. On this machine's
107
+ ledger, all 24,075 priced rows recovered.
108
+
109
+ **The card total moves by the correction; it is not reset to the ledger
110
+ sum.** A `cost_amount` above its ledger sum holds pre-ledger cost from
111
+ transcripts that may be gone, which is why the sampler keeps it. A reprice
112
+ that reset the column would discard that. One that only lowered ledger rows
113
+ would change nothing on the card, because the column already sits above the
114
+ new sum.
115
+
116
+ **Fast mode is a multiplier, not a table.** `usage.speed === "fast"` bills
117
+ every category at 2× the model's standard rate, with the cache multipliers
118
+ on top ($8/$40 on Opus 5.5, $10/$50 on Opus 5 and 4.8). The table can't
119
+ express `inference_geo: "us"` (1.1×) or web search ($10 per 1,000). Neither
120
+ appears in a transcript here yet.
121
+
122
+ The first run corrected what three mispriced models had recorded: Fable 5.1
123
+ cache reads at 4× the published rate, Opus 5.5 at the general Opus rate,
124
+ and Sonnet 5 at Sonnet 4.x's. On a copy of the operator's database that was
125
+ 4,906 rows over 70 narratives, $4,576.26 → $3,852.50.
@@ -0,0 +1,189 @@
1
+ # Daemon
2
+
3
+ The daemon serves HTTP API + WebSocket endpoints + the Expo web app as static files.
4
+
5
+ ```bash
6
+ node dist/cli.js daemon start # Background (detached)
7
+ node dist/cli.js daemon start --foreground
8
+ node dist/cli.js daemon restart # Preserves port across restarts
9
+ node dist/cli.js daemon status # Shows pid, port, and the socket this machine's CLI uses
10
+ node dist/cli.js daemon logs -f # Tail logs
11
+ ```
12
+
13
+ ## HTTP API Endpoints
14
+
15
+ All endpoints require `Authorization: Bearer <token>` (or `?token=`), except
16
+ a request over `daemon.sock`, which presents none and acts as this account.
17
+
18
+ **Agent tokens reach less.** A request made with an agent's token is refused
19
+ (403) unless its route is listed in `AGENT_ROUTES`
20
+ (`src/daemon/agent-scope.ts`), and unless it comes over `daemon.sock` or
21
+ loopback; WebSocket upgrades too. A new route is therefore closed to agents
22
+ until someone lists it. An agent also may not revive or prompt a
23
+ `utility` session (`POST /api/run`). See [Agent tokens](token-rotation.md#agent-tokens).
24
+
25
+ | Method | Path | Purpose |
26
+ |--------|------|---------|
27
+ | `GET` | `/api/status` | Daemon status (brane_id, hostname, username, pid, port, uptime_ms, `tls: { state, expires_at, ... }`, `app_domain`: the shared app's host, where a daemon's own bundle hops out to on Isolate — see [View URLs](view-urls.md)) |
28
+ | `GET` | `/api/narratives?archived=1` | List narratives (excludes archived by default) |
29
+ | `GET` | `/api/narratives/recent?since=&search=&status=&fold=&limit=&archived=1&brane_ids=` | Recently active narratives — the browse half of the `narrative_info` MCP tool; every brane unless `brane_ids` scopes it |
30
+ | `PATCH` | `/api/narratives/:id/archive` | Archive/unarchive a narrative |
31
+ | `GET` | `/api/narratives/:id/events?after=N&before=S&tail=T` | Fetch events with cursor-based query |
32
+ | `POST` | `/api/narratives/:id/signal` | Send operator signal (supports payloads). `source` is stamped from the token (`web` for a device, `local` for this account); a `source` in the body is ignored, and an agent's token is refused (`src/daemon/signal-source.ts`) |
33
+ | `GET` | `/api/narratives/:id/export?format=html[&palette=N][&seqs=A,B,C][&seqs_mode=except]` | Conversation as a standalone HTML document; `palette` is the device's 🎨 Recolor salt, so foreign speakers wear the hues the operator sees, `seqs` is the chat's message selection, and `seqs_mode=except` exports its complement — see [Export Scope](export-scope.md) |
34
+ | `GET` | `/api/payloads/:nid/:filename` | Serve stored payload files (immutable, cached) |
35
+ | `GET` | `/api/branes` | List branes with narrative counts, home flag, and `cost: [{ unit, amount, metered }]` — per-unit running totals over every narrative, archived included |
36
+ | `GET` | `/api/folds?archived=1` | List registered fold directories (includes brane_id; archived hidden unless asked) |
37
+ | `POST` | `/api/folds` | Register a fold (`{ path, brane_id? }`) — idempotent; see [Folds API](#folds-api) for who may register what |
38
+ | `PATCH` | `/api/folds/archive` | Soft-hide/unhide a fold (`{ path, archived }`) — never deletes the row |
39
+ | `POST` | `/api/spawn` | Spawn agent (`{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }`) — used by both the web app and `brane spawn` |
40
+ | `GET` | `/api/spawn-history` | Recent distinct `launch_args`, most-recent first (spawn dialog quick-fill) |
41
+ | `POST` | `/api/run` | Run non-agent command (`{ command, fold_path, terminal_size? }`) |
42
+ | `GET` | `/api/run-history` | Commands utility sessions have run before (by basename), most-recent first — the spawn dialog's Utility list; derived from `narratives`, nothing stored |
43
+ | `POST` | `/api/revive/:nid` | Revive a dead agent session (`{ prompt?, no_prompt?, agent? }`) — see [Revive API](#revive-api) |
44
+ | `POST` | `/api/search` | Semantic search over narrative history (`{ query, limit?, brane_ids? }`) |
45
+ | `POST` | `/api/cost/backfill` | Sweep the usage ledger over every narrative with a bound session (`{ dry_run? }`) — see [Cost Ledger](cost-ledger.md) |
46
+ | `POST` | `/api/cost/reprice` | Re-price recorded ledger rows under the current price table and move each card total by the correction (`{ dry_run? }`) — see [Re-pricing](cost-ledger.md#re-pricing) |
47
+ | `POST` | `/api/tokens` | Mint a device token (`{ label? }` → `{ token, id, label }`); only its hash is kept, so this response is the one copy. `brane meld` calls it for every QR — see [Token Rotation](token-rotation.md). From the shell it waits for the [presence check](presence.md#the-gate) (403, 409 or 429 when refused); an agent's token gets 403 unasked |
48
+ | `POST` | `/api/tokens/remote` | A device token for a daemon on another machine, and its hash (`{ token, hash }`), for `brane meld --remote`; nothing is stored, so this response is the one copy of either. Gated like `POST /api/tokens`: the shell waits for the [presence check](presence.md#the-gate), an agent's token gets 403 unasked |
49
+ | `GET` | `/api/tokens` | The Devices list: every live token (`{ tokens }`), each with `device` ("Safari on iPhone", from the User-Agent it was first presented with), `remote_addr`, the latest sighting and `elsewhere_at` (presented from a second address), `minted_by_device`, `last_used_at`, `current` (the requester), `connected`, and `unused` / `expires_at` for a QR nobody has scanned — see [Token Rotation](token-rotation.md#the-devices-list) |
50
+ | `POST` | `/api/tokens/:id/revoke` | Revoke one device token, bodiless, plus `?also=a,b` from the devices it added (each row's `descendants`); every socket it opened (control, terminal, MCP) closes with 4401. Refuses this machine's own `local` row (400), the requester's own (409), and an id that is not live (404), all before any prompt. Signing out a device waits for the [presence check](presence.md#the-gate) from the shell and is refused (403) to an agent's token; revoking an agent does neither |
51
+ | `POST` | `/api/token/rotate` | From a device: mint a pending replacement token, returned as `{ token, expires_at, replaced }`; the old one is retired on the new one's first use — see [Token Rotation](token-rotation.md). From this machine's shell (no token, over `daemon.sock`): wait for the [presence check](presence.md#the-gate), then sign out every device in this one request, answering `{ signed_out, replaced }`. An agent's token gets 403 |
52
+ | — | `BRANE_DEVICE_TOKEN_SHA256` | Not a route: comma-separated SHA-256 hashes in the daemon's environment, each adopted at startup as a device row unless ever seen before, then dropped from the environment. How `brane meld --remote` hands a remote daemon (a Fly machine, say) a token minted elsewhere — see [Presence Check](presence.md#the-gate) |
53
+ | `POST` | `/api/presence/cancel` | Close the presence prompt that is up (`?id=` from the control socket's `presence` message, so a stale tap closes nothing) → `{ canceled }`. Device tokens only (403 otherwise); there is no approve |
54
+ | `GET` | `/api/absorb/survey` | Every session store brane can look for under the daemon's HOME, with per-session `new / changed / unchanged / managed` counts for the readable ones and a found-flag for the rest; reads no content — see [Absorb](absorb.md) |
55
+ | `POST` | `/api/absorb` | Import conversation history (`{ provider, path?, fold?, identity?, brane?, dry_run?, since?, session?, rebuild? }`); answers as an NDJSON progress stream — see [Absorb](absorb.md) |
56
+ | `POST` | `/api/memory/index` | Run a memory indexing pass (`{ rebuild? }`), queued behind the daemon's own background pass; returns `IndexStats` |
57
+ | `GET` | `/api/files?nid=<nid>&path=<rel>[&raw=1][&offset=N]` | Browse a narrative's fold, archives included — see [Files API](files-view.md#files-api) and [Files View Formats](files-view.md#files-view-formats) |
58
+ | `GET` | `/api/files/list?nid=<nid>` | Flat list of every file path under the fold (for fuzzy find) |
59
+ | `GET` | `/api/files/find?nid=<nid>&path=<rel>&q=<query>[&from=N]` | Search a file's bytes for the hex view's 🔎, as an NDJSON stream — see [Searching the bytes](files-view.md#searching-the-bytes) |
60
+ | `POST` | `/api/highlight` | Shiki-highlight a snippet (`{ content, lang }`) — used by chat code blocks |
61
+ | `POST` | `/api/diagram` | Render a ```mermaid fence to static SVG (`{ source }`) — `{ diagram: null }` means "not a diagram brane draws" and is a normal answer |
62
+ | `POST` | `/api/wiki/search` | Semantic search over wiki pages (`{ query, limit?, brane_ids? }`) — see [Wiki: Local and Shared](wiki.md) |
63
+ | `GET` | `/api/wiki/read?slug=&source=&anchor=&window=` | Read a wiki page or one section with heading neighbors |
64
+ | `GET` | `/api/wiki/asset?source=&path=` | Serve a binary asset from a wiki source dir (traversal-confined) |
65
+
66
+ ## WebSocket Endpoints
67
+
68
+ | Path | Purpose |
69
+ |------|---------|
70
+ | `WS /ws/control` | Real-time control channel (event hints, signals, status/meta broadcasts) |
71
+ | `WS /ws/terminal/:nid` | tmux terminal mirroring (capture-pane at 10fps) |
72
+ | `WS /ws/mcp?nid=<id>` | MCP tool bridge (daemon owns all tool logic) |
73
+
74
+ Control WebSocket broadcasts (server → client):
75
+ - `state` — initial state with all narratives
76
+ - `new_events` — event count hints for a narrative
77
+ - `status` — narrative status changes (active, ended, mcp_connected, mcp_disconnected)
78
+ - `awaiting` — `{ narrative, awaiting }`: the agent parked in a blocking `listen` or a `narrate({ wait: true })`, or stopped waiting (`src/daemon/awaiting.ts`). The app shows it as an inset glow on the list card, switcher row and header (`awaitingGlow`, `src/shared/narrative-status.ts`), in a hue this device allocates: chosen once, as far as possible from the other recently glowing narratives' hues, then kept so sessions can be learned by color; 🎨 Recolor moves it to a random hue at least 100° away, so a press changes the color family rather than shading it, and at least 60° from the hues it was recolored away from in the last hour, so repeated presses explore rather than toggle. It stays clear of the other sessions' hues where the wheel allows; when crowded, that clearance gives way before the step or the return does (`src/shared/glow-hues.ts`, `localStorage` only). Its own message type, not a `status`, so an older app ignores it
79
+ - `meta` — narrative metadata updates (summary, title)
80
+ - `heartbeat` — 30-second keepalive
81
+
82
+ Control WebSocket commands (client → server):
83
+ - `signal` — send signal to a narrative (with payload support); stamped and refused as `POST /api/narratives/:id/signal` is, with a refusal answered by a `signal_refused` frame
84
+ - `subscribe` / `unsubscribe` — narrative event subscriptions
85
+
86
+ ## Spawn API
87
+
88
+ `POST /api/spawn` accepts `{ agent, fold_path, role?, model?, re?, extra_args?, terminal_size? }` and returns the new narrative object with `mcp_connected` status. `terminal_size` (`{ cols, rows }`) is the grid the requesting viewer's terminal pane last measured; the session starts at it rather than at the last size applied to any session, so its first screen is not redrawn through a resize — see [Launch residue in scrollback](terminal-mirroring.md#launch-residue-in-scrollback). `/api/run` takes it too. The fold picker modal lets the operator choose which registered fold to spawn into. The dialog's Agent list is the built-in adapters plus an "Other…" entry that takes any agent name, as `brane spawn <agent>` does: the daemon accepts any `[A-Za-z0-9_-]+` name it can find with `which`, and an agent without an adapter gets tmux, MCP config and mirroring but no session resumption. What is typed there splits like the args box, so the first word is the agent and the rest lead its `extra_args`.
89
+
90
+ This is the **only** spawn path. The web UI calls it, and so does `brane spawn` — the CLI resolves a daemon (starting one if needed), POSTs here with `fold_path` set from `cwd` and `-- <args>` as `extra_args`, and then does only terminal-shaped work with the response: `--meld`'s QR code and `--attach`. It does not open the database or create tmux sessions itself, so tmux bootstrap (`ensureTmux`), terminal sizing, and MCP config writing happen once, daemon-side, identically for both callers. Two consequences follow from that and are intended: the agent binary is resolved against the *daemon's* `PATH`, and MCP config files point at the checkout the *daemon* runs from — not the CLI you invoked.
91
+
92
+ `src/daemon/client.ts` is the CLI-side counterpart to `daemon/server.ts`: `resolveDaemonEndpoint()` (find-or-start, and pick a locally reachable origin — under TLS that means the HTTP fallback port, since the Tailscale cert is never valid for `127.0.0.1`) plus `daemonRequest()` (bearer auth, JSON, rethrows the daemon's own `error` string).
93
+
94
+ ## Revive API
95
+
96
+ `POST /api/revive/:nid` accepts `{ prompt?, no_prompt?, agent? }` and returns `{ status, nid, agent }`. It is the **only** revive path — the web app's revive menu item, the `revive` MCP tool, and `brane revive` are all clients of it, in the same shape as the Spawn API above. The CLI resolves a daemon, POSTs, and prints; it does not open the database, resolve adapters, or create tmux sessions.
97
+
98
+ `status` is the outcome, and clients must not assume `nid` is the one they asked for:
99
+
100
+ | `status` | Meaning |
101
+ |---|---|
102
+ | `revived` | The session was dead and has been restarted in place, resuming its agent session (Claude forks with `--fork-session` when the original is still live). |
103
+ | `already_running` | tmux still had the session. A `prompt` is delivered if given; a stale `ended` row is self-healed to `active` and broadcast. |
104
+ | `continued` | The narrative can't be resumed in place — it was absorbed, or `agent` names a different agent — so a **twin** was spawned that reads its history. `nid` is the twin's, not the requested one. |
105
+
106
+ ## The CLI is a daemon client
107
+
108
+ Spawn and revive set the pattern; the rest of the CLI follows it. A command
109
+ that touches narratives, folds, sessions, or the memory index does so by
110
+ calling the daemon, and keeps only terminal-shaped work for itself — resolving
111
+ the operator's relative paths against *this* shell's cwd, printing, attaching
112
+ to tmux. The reasons are the ones the Spawn API section gives, and one more
113
+ that absorb makes concrete: every path the pipeline resolves — payloads, the
114
+ organelle `search.db`, the niche registry — resolves against **the daemon's
115
+ HOME**, and two processes indexing the same cursors (the daemon's background
116
+ pass and an in-process `brane memory index`) was a race nobody had to have.
117
+
118
+ | Command | Calls |
119
+ |---|---|
120
+ | `spawn`, `revive` | `POST /api/spawn`, `/api/revive/:nid` |
121
+ | `list` | `GET /api/narratives`, `GET /api/branes` |
122
+ | `fold` | `POST /api/folds`, `GET /api/folds?archived=1` |
123
+ | `absorb` | `GET /api/absorb/survey`, `POST /api/absorb` — an NDJSON stream, read with `daemonStream()` |
124
+ | `memory log` / `search` / `index` | `GET /api/narratives/recent`, `POST /api/search`, `POST /api/memory/index` |
125
+ | `cost backfill` / `reprice` | `POST /api/cost/backfill`, `POST /api/cost/reprice` |
126
+ | `token list` | `GET /api/tokens` |
127
+ | `token revoke <id>` | `POST /api/tokens/:id/revoke` |
128
+ | `token rotate` | `POST /api/token/rotate` (one request: the shell holds no token to swap) |
129
+
130
+ **How it gets there: `daemon.sock` first.** The daemon also listens on a
131
+ Unix socket beside the database (`~/.brane/daemon.sock`, recorded as
132
+ `meta.daemon_socket`; `src/daemon/local-socket.ts`), with the same request
133
+ and upgrade handlers. `daemonRequest()` and `daemonStream()` send over it
134
+ when it is there. Inside an agent session, which has a token to present,
135
+ they fall back to TCP at `buildDaemonBaseUrl()` when the socket
136
+ cannot be connected to — any failure before it connects, so nothing was
137
+ sent; a failure after that is reported, never retried, so a POST cannot run
138
+ twice. Outside one there is nothing to present over TCP, so there is no
139
+ fallback: the socket is the only way in, and the error says so. `brane mcp` dials `ws://localhost/ws/mcp` with `createConnection` to
140
+ the socket (`mcpDialTarget()`), keeping the path out of the URL, and uses
141
+ TCP whenever the socket does not answer a probe at (re)connect. Reaching the socket takes search permission on the
142
+ 0700 `~/.brane`, so the kernel admits only this Unix account (and root),
143
+ which loopback TCP cannot promise. So a request over it that presents **no
144
+ token** acts as this account: the live `local` row ("this machine"), with
145
+ the same last-seen bookkeeping a presented token gets
146
+ (`authenticateRequest()` in `server.ts`, `TokenStore.account()`). That holds
147
+ only while `~/.brane` is owned by the daemon's uid with no group or other
148
+ bits (`accountDirTrust()`), checked on every such request, since both
149
+ chmods that set it up swallow their errors; otherwise the request is
150
+ refused and the daemon logs why, once per reason until one is let in again.
151
+ A tokenless request with an `Origin` header, or a `Host` other than
152
+ `localhost`, is refused too: the CLI and bridge send neither, and that is
153
+ the shape of a browser's request arriving through a relay to the socket.
154
+ An empty token counts as none. No token over TCP, even
155
+ loopback, is refused as before. A client sends a token only inside an
156
+ agent session (`resolveCredential()`); everything else on this machine sends
157
+ none, over the socket, and never falls back to TCP, where it would only be
158
+ a 401. No client reads `.daemon_token`, which older branes kept a local
159
+ token in — see [No local token](token-rotation.md#no-local-token).
160
+ A crashed daemon leaves its socket file behind; the next one, once
161
+ it holds the claim, removes it only if it is a socket whose connect is
162
+ refused (`ECONNREFUSED`), and otherwise leaves the path alone; until it is
163
+ cleared, agents and devices are served over TCP and this machine's shell,
164
+ which holds no token, cannot reach the daemon (`brane doctor` says why).
165
+
166
+ What stays local is what must: `daemon` *is* the daemon; `mcp` is the bridge
167
+ into it; `config` is how `token` and `default.brane` are set before a daemon
168
+ exists (and the daemon itself reads them through `getConfig`); `certify`
169
+ writes the cert files the daemon hot-reloads; `doctor` is a diagnostic and
170
+ must not depend on the thing it diagnoses; `attach` is tmux; `meld` already
171
+ finds-or-starts the daemon and reads two `meta` rows to build a URL.
172
+
173
+ Every client calls `warnIfVersionMismatch()` first, because a request field
174
+ this CLI knows about is silently ignored by an older daemon. A new
175
+ source-reading habit to resist: a command that wants a new fact should get it
176
+ from a route (adding one if needed), not from `openDatabase()` — the moment a
177
+ command opens the database it has a second implementation to keep in step.
178
+
179
+ ## Run API
180
+
181
+ `POST /api/run` accepts `{ command, fold_path }` where `command` is an array of strings. Creates a tracked tmux session for non-agent processes (lazygit, htop, etc.) with terminal mirroring but no MCP, LIVE loop, or agent adapter. The narrative gets `role: "utility"` and `agent_type` set to the command basename. The spawn dialog reaches it through its **Utility** side (the label opposite **Agent**), whose list is `GET /api/run-history` plus an "Other…" entry for a command never run before. The dialog is its only client: the `brane run` CLI command was retired (2026-10-10). A utility session's environment carries no agent token, so it is this account's standing in one call, and the route is closed to agents (`src/daemon/agent-scope.ts`); the CLI command added nothing the dialog lacks (same route, same env scrubbing, no env passthrough from the calling shell), and keeping it kept a `brane` verb in every agent's shell whose only job was to start such a session. `brane attach <nid>` attaches to one the app started.
182
+
183
+ ## Folds API
184
+
185
+ `GET /api/folds` returns registered fold directories sorted by `created_at` descending, archived ones only with `?archived=1`. Used by the Expo app to populate the spawn fold picker.
186
+
187
+ `POST /api/folds` with `{ path, brane_id? }` registers one — the app's "add a spawn point" affordance and `brane fold` are both clients — and returns `{ fold, created }`. It is idempotent: re-registering un-archives the row and, with `brane_id`, rebinds it (`brane fold --brane`, and absorb tagging a fold with the brane it imports into). The implementation is `registerFold` in `src/folds.ts`, shared with absorb.
188
+
189
+ **Who may register what.** A phone on the tailnet must not be able to make `/` or `/etc` a spawn point, so a remote client's folds are confined to the operator's home directory (403 outside it, and for `$HOME` itself). The operator's own shell has never been so limited — `brane fold /Volumes/scratch/project` has always worked — and now that the CLI reaches this through the daemon, that reach is recognized on the wire as **loopback**: `isLoopbackRequest()` in `src/daemon/server.ts` grants it to a connection on `daemon.sock` and to `127.0.0.0/8` and `::1`, which is what the CLI dials (`daemon.sock`, else `buildDaemonBaseUrl`) and what nothing off this machine can. The one gap is the TLS window where the fallback port isn't recorded yet and the CLI dials the Tailscale hostname; it lasts about a second after daemon start and the request is then simply confined. `foldPathRefusal()` is the pure decision, pinned by `src/test/thin-cli.test.ts`. Read the rule as a guard against **mistakes**, not a security boundary: the token already grants keyboard input to every tmux session, so a holder who wanted `/etc` could have it; what the rule prevents is a phone's file picker or a typo quietly making the wrong directory a spawn point. That is also why `POST /api/absorb` confines the *fold* it registers but not the export *path* it reads.