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/dist/agents.js ADDED
@@ -0,0 +1,1168 @@
1
+ import { readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import Database from "better-sqlite3";
5
+ import { priceMessage } from "./cost/pricing.js";
6
+ import { readClaudeLiveSession } from "./claude-live-session.js";
7
+ function num(v) {
8
+ return typeof v === "number" ? v : 0;
9
+ }
10
+ // ---------------------------------------------------------------------------
11
+ // Shared plumbing for harness usage readers (goose / cline / crush)
12
+ // ---------------------------------------------------------------------------
13
+ /** Signature of the files a reading derives from, for memoization. Returns null
14
+ * when the *first* path is missing — that's the store itself, so there is
15
+ * nothing to read; later paths are optional and sign as "-" when absent.
16
+ *
17
+ * For a SQLite store, always pass the `-wal` sidecar alongside the db: under
18
+ * WAL a writer appends to the sidecar without touching the main file's mtime,
19
+ * so signing on the db alone would pin a stale reading for the whole life of
20
+ * the WAL — precisely the case here, since every one of these harnesses is
21
+ * writing while we sample. */
22
+ function fileSignature(paths) {
23
+ let sig = "";
24
+ for (const [i, p] of paths.entries()) {
25
+ try {
26
+ const st = statSync(p);
27
+ sig += `${st.size}:${st.mtimeMs};`;
28
+ }
29
+ catch {
30
+ if (i === 0)
31
+ return null;
32
+ sig += "-;";
33
+ }
34
+ }
35
+ return sig;
36
+ }
37
+ const usageMemo = new Map();
38
+ /** Memoize a reading on its source files' signature, so the daemon's 20s cost
39
+ * sampler re-reads a session store only when it actually changed. A null
40
+ * result is never cached — absence is usually transient (a session dir that
41
+ * hasn't been written yet), and re-checking it costs one stat. */
42
+ function memoized(key, paths, compute) {
43
+ const sig = fileSignature(paths);
44
+ if (sig === null)
45
+ return null;
46
+ const hit = usageMemo.get(key);
47
+ if (hit && hit.sig === sig)
48
+ return hit.value;
49
+ const value = compute();
50
+ if (value === null)
51
+ return null;
52
+ usageMemo.set(key, { sig, value });
53
+ return value;
54
+ }
55
+ /** Run `fn` against a read-only connection to `dbPath`, always closing it.
56
+ * Returns null if the database can't be opened or the query throws — a
57
+ * harness store we can't read must degrade to "no cost", never to an
58
+ * exception in the sampler. Read-only is not merely polite: these are live
59
+ * databases owned by other processes, and brane must never write to them. */
60
+ function withReadonlyDb(dbPath, fn) {
61
+ let db;
62
+ try {
63
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ try {
69
+ return fn(db);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ finally {
75
+ try {
76
+ db.close();
77
+ }
78
+ catch {
79
+ // Nothing actionable — the connection is being discarded anyway.
80
+ }
81
+ }
82
+ }
83
+ /** Sum per-session cost readings into one cumulative total. Readings share an
84
+ * agent (and thus a unit) since they belong to one narrative; a stray
85
+ * mismatched unit is skipped rather than summed across units. Returns null when
86
+ * no reading is available (every session's transcript gone / unreadable). */
87
+ export function sumCostReadings(readings) {
88
+ let unit;
89
+ let amount = 0;
90
+ let tokens;
91
+ let any = false;
92
+ for (const r of readings) {
93
+ if (!r)
94
+ continue;
95
+ if (unit === undefined)
96
+ unit = r.unit;
97
+ if (r.unit !== unit)
98
+ continue; // never add across differing units
99
+ any = true;
100
+ amount += r.amount;
101
+ if (r.tokens) {
102
+ tokens ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
103
+ tokens.input += r.tokens.input;
104
+ tokens.output += r.tokens.output;
105
+ tokens.cacheRead += r.tokens.cacheRead;
106
+ tokens.cacheWrite += r.tokens.cacheWrite;
107
+ }
108
+ }
109
+ if (!any || unit === undefined)
110
+ return null;
111
+ return tokens ? { amount, unit, tokens } : { amount, unit };
112
+ }
113
+ /** Epoch seconds from a timestamp that may be recorded in either unit. Crush's
114
+ * `sessions.created_at` is annotated "Unix timestamp in milliseconds" in its
115
+ * own schema but is written in *seconds* (verified against a live store), so
116
+ * neither the comment nor a single observation is worth trusting on its own.
117
+ * Values past ~2001 in ms are unambiguous, so magnitude decides. */
118
+ function epochSeconds(ts) {
119
+ return ts > 1e12 ? Math.floor(ts / 1000) : ts;
120
+ }
121
+ // Memoize a session's parsed per-message entries on its file (size, mtime)
122
+ // signature so the daemon's periodic cost sampler doesn't re-read an unchanged
123
+ // multi-MB transcript every tick. Invalidated automatically when the file grows.
124
+ const claudeUsageCache = new Map();
125
+ // Parse a session transcript into priced per-message entries, deduping streamed
126
+ // duplicates (same message id repeated within the file). Returns null when the
127
+ // session file is gone or unreadable.
128
+ function readClaudeUsageEntries(sessionId, foldPath) {
129
+ return readClaudeUsageEntriesSigned(sessionId, foldPath)?.entries ?? null;
130
+ }
131
+ function readClaudeUsageEntriesSigned(sessionId, foldPath) {
132
+ const cwdEncoded = foldPath.replace(/\//g, "-");
133
+ const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${sessionId}.jsonl`);
134
+ let sig;
135
+ try {
136
+ const st = statSync(sessionFile);
137
+ sig = `${st.size}:${st.mtimeMs}`;
138
+ }
139
+ catch {
140
+ return null; // session file gone or never existed
141
+ }
142
+ const cached = claudeUsageCache.get(sessionFile);
143
+ if (cached && cached.sig === sig)
144
+ return cached;
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(sessionFile, "utf-8");
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ const entries = [];
153
+ const seen = new Set();
154
+ for (const line of raw.split("\n")) {
155
+ if (!line)
156
+ continue;
157
+ let obj;
158
+ try {
159
+ obj = JSON.parse(line);
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ const message = obj.message;
165
+ const usage = message?.usage;
166
+ if (!usage)
167
+ continue;
168
+ const id = typeof message?.id === "string" ? message.id : undefined;
169
+ if (id) {
170
+ if (seen.has(id))
171
+ continue; // dedupe streamed duplicates
172
+ seen.add(id);
173
+ }
174
+ const i = num(usage.input_tokens);
175
+ const o = num(usage.output_tokens);
176
+ const r = num(usage.cache_read_input_tokens);
177
+ const cc = usage.cache_creation;
178
+ const w5 = num(cc?.ephemeral_5m_input_tokens);
179
+ const w1 = num(cc?.ephemeral_1h_input_tokens);
180
+ const model = typeof message?.model === "string" ? message.model : undefined;
181
+ const speed = typeof usage.speed === "string" ? usage.speed : undefined;
182
+ entries.push({
183
+ id,
184
+ model,
185
+ cost: priceMessage(model ?? "", {
186
+ input: i,
187
+ output: o,
188
+ cacheRead: r,
189
+ cacheWrite5m: w5,
190
+ cacheWrite1h: w1,
191
+ speed,
192
+ }),
193
+ input: i,
194
+ output: o,
195
+ cacheRead: r,
196
+ cacheWrite: w5 + w1,
197
+ cacheWrite1h: w1,
198
+ speed,
199
+ });
200
+ }
201
+ const result = { sig, entries };
202
+ claudeUsageCache.set(sessionFile, result);
203
+ return result;
204
+ }
205
+ /** Ledger key for a claude message. Id-less records (`<synthetic>` messages)
206
+ * carry no identity to dedup on, so they key on their position in the file
207
+ * they were read from: unique and stable for an append-only transcript. A
208
+ * fork that copies one gets a second key for it — accepted, because
209
+ * synthetic records carry no usage, so only the row count is affected. */
210
+ function claudeEntryKey(sessionId, e, index) {
211
+ return e.id ? `claude:${e.id}` : `claude:${sessionId}#${index}`;
212
+ }
213
+ // Sum priced entries from one or more session transcripts into a single
214
+ // reading. One seen-set spans all the sessions (walked in bind order), so each
215
+ // message id counts only at its first sighting: fork-copied history prices to
216
+ // zero in successor files, while a genuinely diverged fork's distinct new
217
+ // messages on both sides still count.
218
+ function sumClaudeEntries(sessions) {
219
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
220
+ const seen = new Set();
221
+ for (const entries of sessions) {
222
+ for (const e of entries) {
223
+ if (e.id) {
224
+ if (seen.has(e.id))
225
+ continue;
226
+ seen.add(e.id);
227
+ }
228
+ input += e.input;
229
+ output += e.output;
230
+ cacheRead += e.cacheRead;
231
+ cacheWrite += e.cacheWrite;
232
+ cost += e.cost;
233
+ }
234
+ }
235
+ return {
236
+ amount: Math.round(cost * 1e4) / 1e4,
237
+ unit: "USD",
238
+ tokens: { input, output, cacheRead, cacheWrite },
239
+ };
240
+ }
241
+ // Claude Code: stores sessions at ~/.claude/projects/<path-encoded>/
242
+ const claudeAdapter = {
243
+ // Claude accepts a caller-assigned id via `--session-id`, so the daemon mints
244
+ // and pins it (see buildAgentArgs / reviveNarrative). First agent through the
245
+ // "assign" gate, not a special-cased one.
246
+ sessionIdOwnership: "assign",
247
+ resumeCreatesNewSession: true,
248
+ liveSessionId: readClaudeLiveSession,
249
+ resumeArgs({ sessionId, foldPath, forkSession, newSessionId }) {
250
+ // A fresh start still pins the daemon-minted id when one is supplied, the
251
+ // same way spawn does — otherwise the row's stale pointer survives the
252
+ // revive and keeps naming a session that no longer exists.
253
+ const freshStart = newSessionId ? ["--session-id", newSessionId] : [];
254
+ // Per-narrative session ID is the only safe key. Falling back to
255
+ // discoverSessionId(foldPath) cross-contaminates narratives that share
256
+ // a fold: it picks the most-recently-modified jsonl in the fold's project
257
+ // dir, which can belong to an unrelated narrative. Start fresh instead.
258
+ if (!sessionId)
259
+ return freshStart;
260
+ // Validate the .jsonl file still exists
261
+ const cwdEncoded = foldPath.replace(/\//g, "-");
262
+ const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${sessionId}.jsonl`);
263
+ try {
264
+ statSync(sessionFile);
265
+ }
266
+ catch {
267
+ // Session file gone (e.g., after cleanup), start fresh
268
+ return freshStart;
269
+ }
270
+ // Any `--resume` rotates to a fresh session id — Claude Code never
271
+ // continues under the old one — so an un-pinned resume strands the row's
272
+ // agent_session_id on the dead predecessor (frozen cost readings, and the
273
+ // next revive resumes the pre-revive fork point). With a daemon-minted id
274
+ // in hand, always resume fork-style and pin the successor's id via
275
+ // `--session-id`; `--fork-session` also covers the case where the original
276
+ // is still live (e.g. background-agent mode), which refuses a plain
277
+ // `--resume`.
278
+ if (newSessionId) {
279
+ return ["--resume", sessionId, "--fork-session", "--session-id", newSessionId];
280
+ }
281
+ if (forkSession)
282
+ return ["--resume", sessionId, "--fork-session"];
283
+ return ["--resume", sessionId];
284
+ },
285
+ discoverSessionId(foldPath) {
286
+ const cwdEncoded = foldPath.replace(/\//g, "-");
287
+ const projectDir = join(homedir(), ".claude", "projects", cwdEncoded);
288
+ try {
289
+ const files = readdirSync(projectDir);
290
+ let latestId;
291
+ let latestMtime = 0;
292
+ for (const file of files) {
293
+ // Skip non-jsonl and agent-* files
294
+ if (!file.endsWith(".jsonl") || file.startsWith("agent-"))
295
+ continue;
296
+ const id = file.replace(/\.jsonl$/, "");
297
+ // UUID-like: contains dashes
298
+ if (!id.includes("-"))
299
+ continue;
300
+ try {
301
+ const stat = statSync(join(projectDir, file));
302
+ const mtime = stat.mtimeMs;
303
+ if (mtime > latestMtime) {
304
+ latestMtime = mtime;
305
+ latestId = id;
306
+ }
307
+ }
308
+ catch {
309
+ continue;
310
+ }
311
+ }
312
+ return latestId;
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ },
318
+ // Claude can switch models mid-session (/model), so model is per-message.
319
+ modelGranularity: "per-message",
320
+ // Parse the whole session JSONL once into a `say` → {offset, model} map.
321
+ // Each assistant turn carrying an mcp__brane__narrate tool-call contributes
322
+ // one entry keyed by its exact `say` text; the assistant message UUID is a
323
+ // stable, append-only-immutable offset and message.model is the truth.
324
+ // First occurrence wins, matching resolveTrace's original first-match scan.
325
+ buildTraceIndex({ sessionId, foldPath }) {
326
+ const cwdEncoded = foldPath.replace(/\//g, "-");
327
+ const sessionFile = join(homedir(), ".claude", "projects", cwdEncoded, `${sessionId}.jsonl`);
328
+ let raw;
329
+ try {
330
+ raw = readFileSync(sessionFile, "utf-8");
331
+ }
332
+ catch {
333
+ return null; // session file gone or never existed
334
+ }
335
+ const index = new Map();
336
+ for (const line of raw.split("\n")) {
337
+ if (!line)
338
+ continue;
339
+ let obj;
340
+ try {
341
+ obj = JSON.parse(line);
342
+ }
343
+ catch {
344
+ continue;
345
+ }
346
+ if (obj.type !== "assistant")
347
+ continue;
348
+ const message = obj.message;
349
+ const blocks = message?.content;
350
+ if (!Array.isArray(blocks))
351
+ continue;
352
+ for (const block of blocks) {
353
+ if (block &&
354
+ typeof block === "object" &&
355
+ block.type === "tool_use" &&
356
+ block.name === "mcp__brane__narrate") {
357
+ const input = block.input;
358
+ if (input && typeof input.say === "string" && !index.has(input.say)) {
359
+ index.set(input.say, {
360
+ offset: typeof obj.uuid === "string" ? obj.uuid : undefined,
361
+ model: typeof message?.model === "string" ? message.model : undefined,
362
+ });
363
+ }
364
+ }
365
+ }
366
+ }
367
+ return index;
368
+ },
369
+ // Thin lookup over buildTraceIndex — preserved for callers that resolve a
370
+ // single narration. Batch callers should use buildTraceIndex directly to
371
+ // parse each transcript once.
372
+ resolveTrace({ sessionId, foldPath, content }) {
373
+ const index = this.buildTraceIndex({ sessionId, foldPath });
374
+ return index?.get(content) ?? null;
375
+ },
376
+ // Cumulative USD cost from the session JSONL: sum each assistant message's
377
+ // token usage, priced by its *own* model (Claude can switch models
378
+ // mid-session via /model). Deduped by message id to skip streamed
379
+ // duplicates, matching the `brane-cost` reference. Memoized on the file
380
+ // signature so unchanged transcripts aren't re-parsed.
381
+ readUsage({ sessionId, foldPath }) {
382
+ const entries = readClaudeUsageEntries(sessionId, foldPath);
383
+ if (!entries)
384
+ return null;
385
+ return sumClaudeEntries([entries]);
386
+ },
387
+ // Narrative-level cost: sum every bound session's entries with one shared
388
+ // seen-set so fork-copied history (see readUsageMany in AgentAdapter) counts
389
+ // once. Null only when no session file is readable at all.
390
+ readUsageMany({ sessionIds, foldPath }) {
391
+ const sessions = sessionIds
392
+ .map((sessionId) => readClaudeUsageEntries(sessionId, foldPath))
393
+ .filter((entries) => entries !== null);
394
+ if (sessions.length === 0)
395
+ return null;
396
+ return sumClaudeEntries(sessions);
397
+ },
398
+ // Same entries, keyed for the ledger. No seen-set here: the ledger's primary
399
+ // key is the dedup, and it has to be, because it is the only copy that
400
+ // survives the harness purging a transcript.
401
+ readUsageEntries({ sessionIds, foldPath }) {
402
+ const readings = [];
403
+ for (const sessionId of sessionIds) {
404
+ const read = readClaudeUsageEntriesSigned(sessionId, foldPath);
405
+ if (!read)
406
+ continue;
407
+ readings.push({
408
+ sessionId,
409
+ sig: read.sig,
410
+ unit: "USD",
411
+ entries: read.entries.map((e, i) => ({
412
+ key: claudeEntryKey(sessionId, e, i),
413
+ amount: e.cost,
414
+ model: e.model,
415
+ tokens: {
416
+ input: e.input,
417
+ output: e.output,
418
+ cacheRead: e.cacheRead,
419
+ cacheWrite: e.cacheWrite,
420
+ cacheWrite1h: e.cacheWrite1h,
421
+ },
422
+ speed: e.speed,
423
+ })),
424
+ });
425
+ }
426
+ return readings.length ? readings : null;
427
+ },
428
+ };
429
+ // Goose keeps every session in one SQLite database (WAL), not per-session
430
+ // files: `sessions` is a row per session and `usage_ledger` a row per LLM
431
+ // request. Overridable for tests.
432
+ function gooseSessionsDb() {
433
+ return join(homedir(), ".local", "share", "goose", "sessions", "sessions.db");
434
+ }
435
+ function copilotSessionStoreDb() {
436
+ return join(homedir(), ".copilot", "session-store.db");
437
+ }
438
+ /** Copilot stores the metered amount in nano AIU, so divide exactly once at
439
+ * the harness boundary and expose the existing unit-agnostic cost surface as
440
+ * AIC. Token columns are per request and can be summed independently. */
441
+ export function readCopilotUsage(dbPath, sessionId) {
442
+ return memoized(`copilot:${dbPath}:${sessionId}`, [dbPath, `${dbPath}-wal`], () => withReadonlyDb(dbPath, (db) => {
443
+ const row = db
444
+ .prepare(`SELECT COUNT(*) AS count,
445
+ SUM(COALESCE(total_nano_aiu, 0)) AS nanoAiu,
446
+ SUM(COALESCE(input_tokens, 0)) AS input,
447
+ SUM(COALESCE(output_tokens, 0)) AS output,
448
+ SUM(COALESCE(cache_read_tokens, 0)) AS cacheRead,
449
+ SUM(COALESCE(cache_write_tokens, 0)) AS cacheWrite
450
+ FROM assistant_usage_events WHERE session_id = ?`)
451
+ .get(sessionId);
452
+ if (row.count === 0)
453
+ return null;
454
+ return {
455
+ amount: Math.round((row.nanoAiu / 1e9) * 1e9) / 1e9,
456
+ unit: "AIC",
457
+ tokens: { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite },
458
+ };
459
+ }));
460
+ }
461
+ /** Resolve the goose session id backing a narrative. Exact, not heuristic:
462
+ * brane launches goose as `-n <narrativeId>` at both spawn and revive, and
463
+ * goose stores that verbatim as `sessions.name`, so the narrative id *is* the
464
+ * lookup key. That avoids the fold+time scoping codex needs (and its
465
+ * same-fold cross-assignment race) entirely.
466
+ *
467
+ * Because revive reuses `-n <nid>`, goose resumes the same named row rather
468
+ * than minting a new session — so unlike cline and crush, a goose narrative
469
+ * keeps one session id for life and needs no post-revive re-capture.
470
+ *
471
+ * Newest-first only to be safe: `name` is not declared UNIQUE, so a goose that
472
+ * somehow failed to resume could leave two rows sharing it, and the latest is
473
+ * then the live one. */
474
+ export function discoverGooseSession(dbPath, narrativeId) {
475
+ return (withReadonlyDb(dbPath, (db) => {
476
+ const row = db
477
+ .prepare("SELECT id FROM sessions WHERE name = ? ORDER BY created_at DESC LIMIT 1")
478
+ .get(narrativeId);
479
+ return typeof row?.id === "string" ? row.id : null;
480
+ }) ?? undefined);
481
+ }
482
+ /** Price a goose session from its usage ledger. Goose records usage faithfully
483
+ * but prices nothing — `usage_ledger.cost`/`cost_source` are NULL for a
484
+ * custom provider — so brane owns the pricing here, unlike cline and crush
485
+ * which report USD the harness already computed.
486
+ *
487
+ * Grouped by model because a session can switch models mid-flight; each group
488
+ * is priced by its own rate. `is_compaction` rows are real spend and count. */
489
+ export function readGooseUsage(dbPath, sessionId) {
490
+ return memoized(`goose:${dbPath}:${sessionId}`, [dbPath, `${dbPath}-wal`], () => withReadonlyDb(dbPath, (db) => {
491
+ const rows = db
492
+ .prepare(`SELECT model,
493
+ SUM(COALESCE(input_tokens, 0)) AS input,
494
+ SUM(COALESCE(output_tokens, 0)) AS output,
495
+ SUM(COALESCE(cache_read_tokens, 0)) AS cacheRead,
496
+ SUM(COALESCE(cache_write_tokens, 0)) AS cacheWrite
497
+ FROM usage_ledger
498
+ WHERE session_id = ?
499
+ GROUP BY model`)
500
+ .all(sessionId);
501
+ if (rows.length === 0)
502
+ return null;
503
+ let cost = 0, input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
504
+ for (const r of rows) {
505
+ // Goose's `input_tokens` is *inclusive* of the cache-read portion
506
+ // (a live sample read 172,498 input of which 171,667 were cache
507
+ // reads), whereas priceMessage expects Anthropic-style disjoint
508
+ // counts. Subtract, and clamp: a provider that reported them
509
+ // disjointly would otherwise price as a negative.
510
+ const freshInput = Math.max(0, r.input - r.cacheRead);
511
+ // A NULL model falls through priceForModel to the most-expensive known
512
+ // tier, per the price table's never-under-report policy. Not observed
513
+ // in a live ledger, but worth knowing the error direction is over.
514
+ cost += priceMessage(r.model ?? "", {
515
+ input: freshInput,
516
+ output: r.output,
517
+ cacheRead: r.cacheRead,
518
+ // Goose records one undifferentiated cache-write count with no TTL,
519
+ // so bill it at the 5-minute rate — right for Anthropic-backed
520
+ // sessions, and moot for Fireworks, which never charges for cache
521
+ // writes and always reports zero here.
522
+ cacheWrite5m: r.cacheWrite,
523
+ cacheWrite1h: 0,
524
+ });
525
+ input += r.input;
526
+ output += r.output;
527
+ cacheRead += r.cacheRead;
528
+ cacheWrite += r.cacheWrite;
529
+ }
530
+ return {
531
+ amount: Math.round(cost * 1e4) / 1e4,
532
+ unit: "USD",
533
+ // Report goose's own inclusive input count, matching what its ledger
534
+ // says; only the pricing math needs the disjoint split.
535
+ tokens: { input, output, cacheRead, cacheWrite },
536
+ };
537
+ }));
538
+ }
539
+ // Goose: resumes by session name (narrative ID) with -r flag
540
+ const gooseAdapter = {
541
+ // Goose mints its own session id (`<yyyymmdd>_<n>`); brane learns it by
542
+ // looking up the name it launched goose under. Capture rather than assign.
543
+ sessionIdOwnership: "capture",
544
+ resumeArgs() {
545
+ return ["-r"];
546
+ },
547
+ // foldPath/since are unused: the narrative id is an exact key, which is
548
+ // strictly better than scoping by fold and spawn time.
549
+ discoverSessionId(_foldPath, _since, narrativeId) {
550
+ if (!narrativeId)
551
+ return undefined;
552
+ return discoverGooseSession(gooseSessionsDb(), narrativeId);
553
+ },
554
+ readUsage({ sessionId }) {
555
+ return readGooseUsage(gooseSessionsDb(), sessionId);
556
+ },
557
+ };
558
+ // Gemini: no session resume support (uses -i for prompt injection)
559
+ const geminiAdapter = {
560
+ resumeArgs() {
561
+ return [];
562
+ },
563
+ discoverSessionId() {
564
+ return undefined;
565
+ },
566
+ };
567
+ // Read just the first line of a file without slurping a multi-MB rollout: read a
568
+ // bounded prefix and cut at the first newline. Codex's `session_meta` line embeds
569
+ // the full base instructions, so it can be several KB — 256KB is ample headroom.
570
+ // Degradation (accepted): if a first line somehow exceeds `maxBytes` there's no
571
+ // newline in the prefix, so the truncated slice fails to JSON-parse and that
572
+ // rollout is skipped (its narrative just won't auto-resume). Only reachable with a
573
+ // pathologically large base_instructions block; a larger cap trades memory for it.
574
+ function readFirstLine(path, maxBytes = 1 << 18) {
575
+ let fd;
576
+ try {
577
+ fd = openSync(path, "r");
578
+ }
579
+ catch {
580
+ return undefined;
581
+ }
582
+ try {
583
+ const buf = Buffer.allocUnsafe(maxBytes);
584
+ const n = readSync(fd, buf, 0, maxBytes, 0);
585
+ const s = buf.toString("utf8", 0, n);
586
+ const nl = s.indexOf("\n");
587
+ return nl === -1 ? s : s.slice(0, nl);
588
+ }
589
+ catch {
590
+ return undefined;
591
+ }
592
+ finally {
593
+ closeSync(fd);
594
+ }
595
+ }
596
+ // Parse a codex rollout's leading `session_meta` event into the fields we scope
597
+ // on. Returns undefined for anything that isn't a well-formed session_meta line.
598
+ function readCodexSessionMeta(path) {
599
+ const line = readFirstLine(path);
600
+ if (!line)
601
+ return undefined;
602
+ let obj;
603
+ try {
604
+ obj = JSON.parse(line);
605
+ }
606
+ catch {
607
+ return undefined;
608
+ }
609
+ if (!obj || obj.type !== "session_meta")
610
+ return undefined;
611
+ const p = obj.payload;
612
+ if (!p ||
613
+ typeof p.id !== "string" ||
614
+ typeof p.cwd !== "string" ||
615
+ typeof p.timestamp !== "string") {
616
+ return undefined;
617
+ }
618
+ const ts = Date.parse(p.timestamp);
619
+ if (!Number.isFinite(ts))
620
+ return undefined;
621
+ return { id: p.id, cwd: p.cwd, ts };
622
+ }
623
+ // Return numeric-named children of a directory as { name, n } (e.g. the
624
+ // "2026"/"03"/"05" tiers of codex's sessions tree), skipping non-numeric names.
625
+ function numericEntries(dir) {
626
+ let names;
627
+ try {
628
+ names = readdirSync(dir);
629
+ }
630
+ catch {
631
+ return [];
632
+ }
633
+ return names
634
+ .map((name) => ({ name, n: Number(name) }))
635
+ .filter((e) => Number.isInteger(e.n));
636
+ }
637
+ // Discover a codex session id for `foldPath` from its rollout store, laid out as
638
+ // `<sessionsDir>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`. Each rollout's first line
639
+ // is a `session_meta` event carrying { id, cwd, timestamp }. We pick *this*
640
+ // narrative's own rollout: cwd must equal the fold, and — given a spawn time
641
+ // `since` — the session must have started after it, taking the EARLIEST such
642
+ // start (the rollout created right after this spawn). The cwd + since scoping is
643
+ // what stops two codex narratives sharing a fold from cross-contaminating — the
644
+ // fold-wide-latest trap the claude adapter documents. Exported for testing
645
+ // against a fixture tree; production passes `~/.codex/sessions`.
646
+ //
647
+ // Known limitation (accepted, filesystem-discovery inherent): "earliest after
648
+ // `since`" assumes rollout-creation order matches spawn order. Two codex
649
+ // narratives spawned into the *same fold* near-simultaneously, where the
650
+ // later-spawned one writes its `session_meta` first, could cross-assign. It's a
651
+ // narrow race with no tighter handle available (codex owns its own id and we
652
+ // only learn it from disk); the single-narrative and staggered-spawn cases — the
653
+ // norm — are correct. Cf. the resume-rotation limitation on maybeCaptureSessionId.
654
+ export function discoverCodexSession(sessionsDir, foldPath, since) {
655
+ const sinceDate = since !== undefined ? new Date(since) : undefined;
656
+ const wantCwd = resolve(foldPath);
657
+ let best;
658
+ for (const y of numericEntries(sessionsDir)) {
659
+ if (sinceDate && y.n < sinceDate.getFullYear())
660
+ continue;
661
+ const yDir = join(sessionsDir, y.name);
662
+ for (const mo of numericEntries(yDir)) {
663
+ if (sinceDate && y.n === sinceDate.getFullYear() && mo.n < sinceDate.getMonth() + 1)
664
+ continue;
665
+ const moDir = join(yDir, mo.name);
666
+ for (const d of numericEntries(moDir)) {
667
+ if (sinceDate &&
668
+ y.n === sinceDate.getFullYear() &&
669
+ mo.n === sinceDate.getMonth() + 1 &&
670
+ d.n < sinceDate.getDate()) {
671
+ continue;
672
+ }
673
+ const dDir = join(moDir, d.name);
674
+ let files;
675
+ try {
676
+ files = readdirSync(dDir);
677
+ }
678
+ catch {
679
+ continue;
680
+ }
681
+ for (const f of files) {
682
+ if (!f.startsWith("rollout-") || !f.endsWith(".jsonl"))
683
+ continue;
684
+ const meta = readCodexSessionMeta(join(dDir, f));
685
+ if (!meta)
686
+ continue;
687
+ if (resolve(meta.cwd) !== wantCwd)
688
+ continue;
689
+ if (since !== undefined && meta.ts <= since)
690
+ continue;
691
+ if (!best || meta.ts < best.ts)
692
+ best = { id: meta.id, ts: meta.ts };
693
+ }
694
+ }
695
+ }
696
+ }
697
+ return best?.id;
698
+ }
699
+ /** Default root of codex's rollout store. */
700
+ export function codexSessionsRoot() {
701
+ return join(homedir(), ".codex", "sessions");
702
+ }
703
+ /** sessionId → rollout path. A rollout is named for the session it records and
704
+ * is never renamed, so the mapping is stable for the life of the process and
705
+ * worth caching: it is the only part of a usage or tool-log read that must
706
+ * walk the tree. Misses are not cached — a young session has no file yet. */
707
+ const rolloutPaths = new Map();
708
+ /** Find the rollout recording `sessionId`, or undefined if there isn't one yet.
709
+ *
710
+ * The filename ends with `-<sessionId>.jsonl`, which makes the scan a string
711
+ * test per entry, but the name is only a hint: `session_meta` is the
712
+ * authority, so a match is confirmed by reading the first line. Exported for
713
+ * the tool-log reader and for testing against a fixture tree. */
714
+ export function findCodexRollout(sessionId, sessionsRoot = codexSessionsRoot()) {
715
+ const key = `${sessionsRoot} ${sessionId}`;
716
+ const cached = rolloutPaths.get(key);
717
+ if (cached !== undefined) {
718
+ try {
719
+ statSync(cached);
720
+ return cached;
721
+ }
722
+ catch {
723
+ rolloutPaths.delete(key); // moved out from under us; fall through to rescan
724
+ }
725
+ }
726
+ const suffix = `-${sessionId}.jsonl`;
727
+ for (const y of numericEntries(sessionsRoot)) {
728
+ const yDir = join(sessionsRoot, y.name);
729
+ for (const mo of numericEntries(yDir)) {
730
+ const moDir = join(yDir, mo.name);
731
+ for (const d of numericEntries(moDir)) {
732
+ const dDir = join(moDir, d.name);
733
+ let files;
734
+ try {
735
+ files = readdirSync(dDir);
736
+ }
737
+ catch {
738
+ continue;
739
+ }
740
+ for (const f of files) {
741
+ if (!f.endsWith(suffix))
742
+ continue;
743
+ const path = join(dDir, f);
744
+ if (readCodexSessionMeta(path)?.id !== sessionId)
745
+ continue;
746
+ rolloutPaths.set(key, path);
747
+ return path;
748
+ }
749
+ }
750
+ }
751
+ }
752
+ return undefined;
753
+ }
754
+ /** Memoized on the rollout's (size, mtime), so a cost-sampler tick against an
755
+ * unchanged file costs one `stat` and nothing else. */
756
+ const codexUsageCache = new Map();
757
+ /** Bytes of the tail searched for the last `token_count`, doubling up to the
758
+ * whole file if the window lands in a stretch without one. Codex emits a
759
+ * `token_count` per turn, so the first window almost always holds several; the
760
+ * growth path covers a turn that appended a very large tool result after the
761
+ * counter. */
762
+ const CODEX_TAIL_WINDOW_BYTES = 1 << 16;
763
+ /** Read the tail of a file as lines, dropping the leading partial line unless
764
+ * the window starts at byte 0. Slicing the *buffer* and decoding after is
765
+ * deliberate: a window boundary landing inside a multi-byte sequence would
766
+ * otherwise leave a replacement character in the JSON. Returns [] when the
767
+ * window holds no line boundary at all, which tells the caller to widen. */
768
+ function readTailLines(path, size, windowBytes) {
769
+ const start = Math.max(0, size - windowBytes);
770
+ const length = size - start;
771
+ if (length <= 0)
772
+ return [];
773
+ let fd;
774
+ try {
775
+ fd = openSync(path, "r");
776
+ }
777
+ catch {
778
+ return [];
779
+ }
780
+ let buf;
781
+ try {
782
+ buf = Buffer.allocUnsafe(length);
783
+ const n = readSync(fd, buf, 0, length, start);
784
+ buf = buf.subarray(0, n);
785
+ }
786
+ catch {
787
+ return [];
788
+ }
789
+ finally {
790
+ closeSync(fd);
791
+ }
792
+ if (start > 0) {
793
+ const nl = buf.indexOf(0x0a);
794
+ if (nl === -1)
795
+ return [];
796
+ buf = buf.subarray(nl + 1);
797
+ }
798
+ return buf.toString("utf8").split("\n");
799
+ }
800
+ /** Cumulative token usage for a codex session.
801
+ *
802
+ * Reported in `TOK`, not `USD`, and that is not a placeholder. Codex records
803
+ * exact counters but no durable model-price attribution, and `priceMessage`
804
+ * knows Anthropic models only — an unmatched id falls through to a
805
+ * $10/$50-per-Mtok default tier. Pricing a GPT session through that would not
806
+ * approximate anything; it would invoice an OpenAI transcript at Opus rates.
807
+ * `formatCost` already renders non-USD units, so the card stays truthful.
808
+ *
809
+ * No `readUsageMany` counterpart, deliberately: `total_token_usage` is
810
+ * cumulative *within one rollout* and starts at zero in a resumed session,
811
+ * which pays for its replayed context again as cache-read input. Summing
812
+ * per-session readings is therefore already correct — unlike claude, whose
813
+ * `--fork-session` copies priced history forward and must dedupe by id.
814
+ *
815
+ * Only the last counter matters, so this searches a tail window rather than
816
+ * the file: O(window), not O(session). A rollout passes two megabytes within
817
+ * the hour and this runs on the daemon's only thread. */
818
+ export function readCodexUsage(sessionId, sessionsRoot = codexSessionsRoot()) {
819
+ const path = findCodexRollout(sessionId, sessionsRoot);
820
+ if (!path)
821
+ return null;
822
+ let size, sig;
823
+ try {
824
+ const st = statSync(path);
825
+ size = st.size;
826
+ sig = `${st.size}:${st.mtimeMs}`;
827
+ }
828
+ catch {
829
+ return null;
830
+ }
831
+ const cached = codexUsageCache.get(path);
832
+ if (cached && cached.sig === sig)
833
+ return cached.reading;
834
+ let totals;
835
+ for (let window = CODEX_TAIL_WINDOW_BYTES;; window *= 2) {
836
+ for (const line of readTailLines(path, size, window)) {
837
+ // Cheap reject before parse: nearly every line is a message, reasoning,
838
+ // or tool traffic, and parsing them all is the cost this reader exists
839
+ // to avoid.
840
+ if (!line.includes('"token_count"'))
841
+ continue;
842
+ try {
843
+ const obj = JSON.parse(line);
844
+ if (obj.type !== "event_msg" || obj.payload?.type !== "token_count")
845
+ continue;
846
+ const t = obj.payload.info?.total_token_usage;
847
+ if (t)
848
+ totals = t;
849
+ }
850
+ catch {
851
+ // A line clipped by the window edge, or a partial final write.
852
+ }
853
+ }
854
+ if (totals || window >= size)
855
+ break;
856
+ }
857
+ const finish = (reading) => {
858
+ codexUsageCache.set(path, { sig, reading });
859
+ return reading;
860
+ };
861
+ if (!totals)
862
+ return finish(null);
863
+ // Codex's `cached_input_tokens` is a *subset* of `input_tokens`, where claude
864
+ // reports the two disjointly. Subtract so a breakdown means the same thing
865
+ // whichever agent produced it, and so a consumer summing the fields doesn't
866
+ // count the cached portion twice. Clamped because a negative input would be a
867
+ // worse answer than a zero one.
868
+ const rawInput = num(totals.input_tokens);
869
+ const cacheRead = Math.min(num(totals.cached_input_tokens), rawInput);
870
+ const output = num(totals.output_tokens);
871
+ const total = num(totals.total_tokens) || rawInput + output;
872
+ if (!total)
873
+ return finish(null);
874
+ return finish({
875
+ amount: total,
876
+ unit: "TOK",
877
+ tokens: {
878
+ input: rawInput - cacheRead,
879
+ output,
880
+ cacheRead,
881
+ cacheWrite: num(totals.cache_write_input_tokens),
882
+ },
883
+ });
884
+ }
885
+ /** Drop cached codex rollout state. Tests only — production state is
886
+ * process-lifetime and self-invalidating on the file signature. */
887
+ export function resetCodexCaches() {
888
+ rolloutPaths.clear();
889
+ codexUsageCache.clear();
890
+ }
891
+ // Codex: resumes via `codex resume <session_id>`. Codex generates its own
892
+ // session id (no caller-assign flag like claude's `--session-id`), so the daemon
893
+ // *captures* it from codex's rollout store rather than assigning it.
894
+ const codexAdapter = {
895
+ sessionIdOwnership: "capture",
896
+ resumeArgs({ sessionId }) {
897
+ if (!sessionId)
898
+ return [];
899
+ return ["resume", sessionId];
900
+ },
901
+ discoverSessionId(foldPath, since) {
902
+ return discoverCodexSession(codexSessionsRoot(), foldPath, since);
903
+ },
904
+ readUsage({ sessionId }) {
905
+ return readCodexUsage(sessionId);
906
+ },
907
+ };
908
+ // Amp: no simple CLI resume — uses thread references (@T-<id>) in prompt
909
+ const ampAdapter = {
910
+ resumeArgs() {
911
+ return [];
912
+ },
913
+ discoverSessionId() {
914
+ return undefined;
915
+ },
916
+ };
917
+ // Copilot accepts a caller-assigned UUID with --session-id, avoiding the
918
+ // fold-wide `--continue` ambiguity when multiple narratives share a fold.
919
+ const copilotAdapter = {
920
+ sessionIdOwnership: "assign",
921
+ resumeArgs({ sessionId, newSessionId }) {
922
+ const id = sessionId ?? newSessionId;
923
+ return id ? ["--session-id", id] : [];
924
+ },
925
+ discoverSessionId() {
926
+ return undefined;
927
+ },
928
+ readUsage({ sessionId }) {
929
+ return readCopilotUsage(copilotSessionStoreDb(), sessionId);
930
+ },
931
+ };
932
+ // Cline is launched with `--config /tmp/brane-cline-<nid>` (see
933
+ // ensureClineMcpConfig in commands/spawn.ts), so its whole store is private to
934
+ // one narrative — sessions live at
935
+ // `<dir>/data/sessions/<sid>/<sid>.messages.json`.
936
+ //
937
+ // Caveat, accepted: /tmp is cleared on reboot, so a narrative's cost history
938
+ // dies with it and the reading degrades to null. That's already the contract.
939
+ function clineSessionsDir(narrativeId) {
940
+ return join("/tmp", `brane-cline-${narrativeId}`, "data", "sessions");
941
+ }
942
+ function clineMessagesFile(narrativeId, sessionId) {
943
+ return join(clineSessionsDir(narrativeId), sessionId, `${sessionId}.messages.json`);
944
+ }
945
+ /** Sum one cline session's cost from its message log.
946
+ *
947
+ * Per-message `metrics.cost` rather than the session record's
948
+ * `metadata.totalCost`: cline accumulates that total only on the turn-success
949
+ * path, so a session with errored turns (an MCP listen timeout, say) persists
950
+ * the message metrics but silently drops their cost from the total. Summing
951
+ * the messages is never lower and sometimes much higher.
952
+ *
953
+ * The costs are cline's own, computed from its models.dev rate card, and are
954
+ * taken as authoritative — never re-priced. A narrative's provider can change
955
+ * across revives, so the rate that applied is knowable only to the harness
956
+ * that made the call. */
957
+ export function readClineSessionUsage(file) {
958
+ return memoized(`cline:${file}`, [file], () => {
959
+ let parsed;
960
+ try {
961
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
962
+ }
963
+ catch {
964
+ return null;
965
+ }
966
+ const messages = parsed?.messages;
967
+ if (!Array.isArray(messages))
968
+ return null;
969
+ let cost = 0, input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
970
+ // Dedup within the file only. Cline never copies history between sessions
971
+ // (each revive starts an empty log), so the cross-file seen-set that
972
+ // claude's fork handling needs would here only risk *under*-counting two
973
+ // genuinely distinct messages that happened to share an id.
974
+ const seen = new Set();
975
+ for (const m of messages) {
976
+ const metrics = m?.metrics;
977
+ if (!metrics)
978
+ continue;
979
+ const id = m.id;
980
+ if (typeof id === "string") {
981
+ if (seen.has(id))
982
+ continue;
983
+ seen.add(id);
984
+ }
985
+ cost += num(metrics.cost);
986
+ input += num(metrics.inputTokens);
987
+ output += num(metrics.outputTokens);
988
+ cacheRead += num(metrics.cacheReadTokens);
989
+ cacheWrite += num(metrics.cacheWriteTokens);
990
+ }
991
+ return {
992
+ amount: Math.round(cost * 1e6) / 1e6,
993
+ unit: "USD",
994
+ tokens: { input, output, cacheRead, cacheWrite },
995
+ };
996
+ });
997
+ }
998
+ /** Every session id present in a narrative's private cline store. */
999
+ function clineStoredSessions(narrativeId) {
1000
+ try {
1001
+ return readdirSync(clineSessionsDir(narrativeId), { withFileTypes: true })
1002
+ .filter((e) => e.isDirectory())
1003
+ .map((e) => e.name);
1004
+ }
1005
+ catch {
1006
+ return [];
1007
+ }
1008
+ }
1009
+ // Cline CLI: resumes via `--taskId <id>` (but we don't discover task IDs)
1010
+ const clineAdapter = {
1011
+ // Cline mints `<epochMs>_<rand5>` ids and starts a fresh session on every
1012
+ // revive, so brane captures them rather than assigning.
1013
+ sessionIdOwnership: "capture",
1014
+ resumeArgs() {
1015
+ return [];
1016
+ },
1017
+ // The config dir is per-narrative, so no fold scoping is needed — but `since`
1018
+ // still is, and cheaply: a cline session id is `<epochMs>_<rand5>`, so it
1019
+ // carries its own start time. Honouring it matters after a revive, when the
1020
+ // previous session is still the newest thing on disk for the moment it takes
1021
+ // the new one to appear; returning that incumbent would otherwise be taken
1022
+ // for the successor. Ids also sort lexicographically by start time, which
1023
+ // holds until epoch ms gains a digit in the year 33658.
1024
+ discoverSessionId(_foldPath, since, narrativeId) {
1025
+ if (!narrativeId)
1026
+ return undefined;
1027
+ return clineStoredSessions(narrativeId)
1028
+ .filter((id) => since === undefined || Number(id.split("_")[0]) >= since)
1029
+ .sort()
1030
+ .pop();
1031
+ },
1032
+ readUsage({ sessionId, narrativeId }) {
1033
+ if (!narrativeId)
1034
+ return null;
1035
+ return readClineSessionUsage(clineMessagesFile(narrativeId, sessionId));
1036
+ },
1037
+ // Sum every session in the narrative's private store, not just the ones
1038
+ // brane managed to record. The directory is brane-owned and strictly
1039
+ // per-narrative, so whatever is in it *is* this narrative's spend — which
1040
+ // keeps the badge correct for revives that happened before the daemon
1041
+ // learned to re-capture, and for any session captured late or not at all.
1042
+ // The recorded ids are still unioned in, so a session that has been moved
1043
+ // or pre-dates the current dir layout is never dropped.
1044
+ readUsageMany({ sessionIds, narrativeId }) {
1045
+ if (!narrativeId)
1046
+ return null;
1047
+ const ids = [...new Set([...clineStoredSessions(narrativeId), ...sessionIds])];
1048
+ const readings = ids.map((id) => readClineSessionUsage(clineMessagesFile(narrativeId, id)));
1049
+ return sumCostReadings(readings);
1050
+ },
1051
+ };
1052
+ // Crush keeps one SQLite database per *fold* (`<fold>/.crush/crush.db`, WAL),
1053
+ // so unlike goose and cline its store is shared by every crush narrative in
1054
+ // that fold — hence the fold + spawn-time scoping below. The data dir is
1055
+ // configurable via crush's `options.data_directory`; only the default is
1056
+ // handled, and a fold that overrides it simply reports no cost.
1057
+ function crushDbPath(foldPath) {
1058
+ return join(foldPath, ".crush", "crush.db");
1059
+ }
1060
+ /** Crush spawns synthetic sessions alongside real ones: `title-<parentId>` for
1061
+ * title generation and `<messageID>$$<toolCallID>` for its `agent` tool. Both
1062
+ * are spend, but neither is the narrative's own session, so discovery must
1063
+ * skip them. Real sessions have a NULL parent. */
1064
+ const CRUSH_REAL_SESSION = "parent_session_id IS NULL AND id NOT LIKE 'title-%' AND id NOT LIKE '%$$%'";
1065
+ /** Discover the crush session a narrative spawned, by fold + start time.
1066
+ *
1067
+ * Earliest session starting at or after `since`, matching the codex adapter's
1068
+ * reasoning: the row created right after this spawn is ours, whereas
1069
+ * newest-in-fold would drift to an unrelated later session. It inherits
1070
+ * codex's known limitation too — two crush narratives spawned into one fold
1071
+ * within the same moment could cross-assign — because crush owns its ids and
1072
+ * we only learn them from disk.
1073
+ *
1074
+ * The unit handling is the subtle part: crush's schema annotates `created_at`
1075
+ * as milliseconds but writes seconds, so comparing a ms `since` against it
1076
+ * directly matches nothing, forever, with no error to notice. Both sides are
1077
+ * normalized to seconds; the truncation of `since` is deliberately the lenient
1078
+ * direction, so a session created in the same second as the spawn still
1079
+ * matches. */
1080
+ export function discoverCrushSession(dbPath, since) {
1081
+ return (withReadonlyDb(dbPath, (db) => {
1082
+ const rows = db
1083
+ .prepare(`SELECT id, created_at FROM sessions WHERE ${CRUSH_REAL_SESSION} ORDER BY created_at ASC`)
1084
+ .all();
1085
+ const floor = since === undefined ? undefined : Math.floor(since / 1000);
1086
+ for (const r of rows) {
1087
+ if (floor !== undefined && epochSeconds(r.created_at) < floor)
1088
+ continue;
1089
+ return r.id;
1090
+ }
1091
+ return null;
1092
+ }) ?? undefined);
1093
+ }
1094
+ /** Read a crush session's cumulative USD cost. Crush computes and accumulates
1095
+ * cost itself (`session.Cost += cost` per request), so brane sums the stored
1096
+ * figure and never re-prices — the model and provider behind a session can
1097
+ * change, and the harness's own number is authoritative.
1098
+ *
1099
+ * Includes the session's direct children: crush's `agent` tool and its
1100
+ * title-generation pass each run in their own session row, and that spend is
1101
+ * the narrative's. (Only one level down — deeper nesting isn't something the
1102
+ * live store exercises, so it isn't something this claims to handle.)
1103
+ *
1104
+ * Deliberately returns no `tokens`: `prompt_tokens`/`completion_tokens` are
1105
+ * overwritten with each request's usage rather than accumulated — they exist
1106
+ * to gauge context size for auto-summarization — so reporting them as a
1107
+ * session total would be a lie. Crush would need a usage ledger for that.
1108
+ *
1109
+ * A $0 reading is meaningful and returned as-is: it means the fold's crush
1110
+ * config priced the model at zero (crush's default for a custom provider that
1111
+ * declares no `cost_per_1m_*` fields), not that usage is unknown. */
1112
+ export function readCrushUsage(dbPath, sessionId) {
1113
+ return memoized(`crush:${dbPath}:${sessionId}`, [dbPath, `${dbPath}-wal`], () => withReadonlyDb(dbPath, (db) => {
1114
+ const row = db
1115
+ .prepare(`SELECT SUM(cost) AS cost FROM sessions
1116
+ WHERE id = ? OR parent_session_id = ? OR id = 'title-' || ?`)
1117
+ .get(sessionId, sessionId, sessionId);
1118
+ if (row?.cost == null)
1119
+ return null; // no such session
1120
+ return { amount: Math.round(row.cost * 1e4) / 1e4, unit: "USD" };
1121
+ }));
1122
+ }
1123
+ // Crush (formerly OpenCode): no CLI session resume yet
1124
+ const crushAdapter = {
1125
+ // Crush mints its own uuid per session and has no resume, so every revive
1126
+ // starts a fresh row; brane captures each one for cost aggregation.
1127
+ sessionIdOwnership: "capture",
1128
+ resumeArgs() {
1129
+ return [];
1130
+ },
1131
+ discoverSessionId(foldPath, since) {
1132
+ return discoverCrushSession(crushDbPath(foldPath), since);
1133
+ },
1134
+ readUsage({ sessionId, foldPath }) {
1135
+ return readCrushUsage(crushDbPath(foldPath), sessionId);
1136
+ },
1137
+ };
1138
+ // Default adapter for agents without session resumption
1139
+ const defaultAdapter = {
1140
+ resumeArgs() {
1141
+ return [];
1142
+ },
1143
+ discoverSessionId() {
1144
+ return undefined;
1145
+ },
1146
+ };
1147
+ // Registry of agent adapters. Add new agents here.
1148
+ const AGENTS = {
1149
+ claude: claudeAdapter,
1150
+ goose: gooseAdapter,
1151
+ gemini: geminiAdapter,
1152
+ codex: codexAdapter,
1153
+ amp: ampAdapter,
1154
+ copilot: copilotAdapter,
1155
+ cline: clineAdapter,
1156
+ crush: crushAdapter,
1157
+ };
1158
+ export const KNOWN_AGENTS = new Set(Object.keys(AGENTS));
1159
+ // Agent types whose adapter can resolve per-message model/offset provenance.
1160
+ // Used by the trace resolver to filter candidate events in SQL so it never
1161
+ // materializes events authored by agents it could only skip in JS.
1162
+ export const TRACE_RESOLVABLE_AGENTS = Object.entries(AGENTS)
1163
+ .filter(([, adapter]) => typeof adapter.resolveTrace === "function")
1164
+ .map(([type]) => type);
1165
+ export function getAgentAdapter(agentType) {
1166
+ return AGENTS[agentType] ?? defaultAdapter;
1167
+ }
1168
+ //# sourceMappingURL=agents.js.map