brane 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +79 -27
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,568 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createTestDb } from "./helpers.js";
7
+ import { appendEvent } from "../db/events.js";
8
+ import { readToolUse, readToolUseWindow, maxToolUseRev } from "../db/tool-uses.js";
9
+ import { newCodexLogCursor, pollCodexToolLog, toolUseFromCodexRecord, } from "../codex-tool-log.js";
10
+ import { findCodexRollout, readCodexUsage, resetCodexCaches } from "../agents.js";
11
+ import { startCodexToolSampler } from "../daemon/server.js";
12
+ // ---------------------------------------------------------------------------
13
+ // Fixture construction — the real layout:
14
+ // <base>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl, first line a `session_meta`.
15
+ // ---------------------------------------------------------------------------
16
+ const WHEN = new Date(2026, 2, 5, 10, 0, 0);
17
+ function rolloutPath(base, uuid, when = WHEN) {
18
+ const dir = join(base, String(when.getFullYear()), String(when.getMonth() + 1).padStart(2, "0"), String(when.getDate()).padStart(2, "0"));
19
+ mkdirSync(dir, { recursive: true });
20
+ return join(dir, `rollout-${when.toISOString().replace(/[:.]/g, "-")}-${uuid}.jsonl`);
21
+ }
22
+ function line(obj) {
23
+ return JSON.stringify(obj) + "\n";
24
+ }
25
+ function sessionMeta(uuid, cwd = "/Users/test/dev/proj") {
26
+ return line({
27
+ type: "session_meta",
28
+ // `base_instructions` stands in for codex's inlined system prompt, which is
29
+ // what makes a real first line hundreds of kilobytes.
30
+ payload: { id: uuid, cwd, timestamp: WHEN.toISOString(), base_instructions: "x".repeat(4096) },
31
+ });
32
+ }
33
+ function tokenCount(total, extra = {}) {
34
+ return line({
35
+ type: "event_msg",
36
+ payload: {
37
+ type: "token_count",
38
+ info: {
39
+ total_token_usage: {
40
+ input_tokens: total,
41
+ cached_input_tokens: 0,
42
+ cache_write_input_tokens: 0,
43
+ output_tokens: 0,
44
+ total_tokens: total,
45
+ ...extra,
46
+ },
47
+ },
48
+ },
49
+ });
50
+ }
51
+ function toolCall(callId, name, input, custom = false) {
52
+ return line({
53
+ type: "response_item",
54
+ payload: custom
55
+ ? { type: "custom_tool_call", call_id: callId, name, input, status: "completed" }
56
+ : { type: "function_call", call_id: callId, name, arguments: input },
57
+ });
58
+ }
59
+ function toolOutput(callId, output, custom = false) {
60
+ return line({
61
+ type: "response_item",
62
+ payload: {
63
+ type: custom ? "custom_tool_call_output" : "function_call_output",
64
+ call_id: callId,
65
+ output,
66
+ },
67
+ });
68
+ }
69
+ function newBase() {
70
+ return mkdtempSync(join(tmpdir(), "codex-log-test-"));
71
+ }
72
+ function seedNarrative(db, id = "codex-test0001") {
73
+ db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at) VALUES (?, 'brane-test', 'codex', 'worker', 'active', '/tmp', ?)").run(id, Date.now());
74
+ return id;
75
+ }
76
+ /** One narrative's reader: a fresh cursor, bound to a fixture root. A second
77
+ * `reader()` over the same fixture is what a daemon restart looks like — the
78
+ * rows are already in the database and the cursor knows nothing about them. */
79
+ function reader(db, nid, sessionId, base) {
80
+ const cursor = newCodexLogCursor();
81
+ return () => pollCodexToolLog(db, nid, sessionId, cursor, base);
82
+ }
83
+ // The rollout-path and usage caches are process-lifetime by design, keyed on a
84
+ // path that a fresh mkdtemp fixture never reuses — but reset anyway so no test
85
+ // can depend on another having run.
86
+ beforeEach(() => resetCodexCaches());
87
+ // ---------------------------------------------------------------------------
88
+ describe("findCodexRollout", () => {
89
+ it("locates a rollout by session id under the YYYY/MM/DD tiers", () => {
90
+ const base = newBase();
91
+ const path = rolloutPath(base, "aaaaaaaa-0000-4000-8000-000000000001");
92
+ writeFileSync(path, sessionMeta("aaaaaaaa-0000-4000-8000-000000000001"));
93
+ assert.equal(findCodexRollout("aaaaaaaa-0000-4000-8000-000000000001", base), path);
94
+ });
95
+ it("returns undefined when no rollout records that session", () => {
96
+ const base = newBase();
97
+ writeFileSync(rolloutPath(base, "aaaa-1"), sessionMeta("aaaa-1"));
98
+ assert.equal(findCodexRollout("bbbb-2", base), undefined);
99
+ });
100
+ it("trusts session_meta over the filename", () => {
101
+ // A file *named* for one session whose meta records another is not a match:
102
+ // the name is a hint, the meta is the authority.
103
+ const base = newBase();
104
+ const path = rolloutPath(base, "cccc-3");
105
+ writeFileSync(path, sessionMeta("dddd-4"));
106
+ assert.equal(findCodexRollout("cccc-3", base), undefined);
107
+ });
108
+ it("does not cache a miss — a session found later still resolves", () => {
109
+ const base = newBase();
110
+ assert.equal(findCodexRollout("eeee-5", base), undefined);
111
+ const path = rolloutPath(base, "eeee-5");
112
+ writeFileSync(path, sessionMeta("eeee-5"));
113
+ assert.equal(findCodexRollout("eeee-5", base), path);
114
+ });
115
+ });
116
+ describe("readCodexUsage", () => {
117
+ it("reports the last cumulative total as TOK, not USD", () => {
118
+ // pricing.ts prices Anthropic models only and falls through to a
119
+ // $10/$50-per-Mtok default tier, so pricing a GPT session would invoice an
120
+ // OpenAI transcript at Opus rates. Tokens are the honest unit.
121
+ const base = newBase();
122
+ const path = rolloutPath(base, "usage-1");
123
+ writeFileSync(path, sessionMeta("usage-1") + tokenCount(100) + tokenCount(4200));
124
+ const reading = readCodexUsage("usage-1", base);
125
+ assert.equal(reading?.unit, "TOK");
126
+ assert.equal(reading?.amount, 4200);
127
+ });
128
+ it("subtracts cached input so the breakdown is disjoint like claude's", () => {
129
+ // Codex's `cached_input_tokens` is a SUBSET of `input_tokens`; claude
130
+ // reports the two separately. A consumer summing the fields must not count
131
+ // the cached portion twice.
132
+ const base = newBase();
133
+ const path = rolloutPath(base, "usage-2");
134
+ writeFileSync(path, sessionMeta("usage-2") +
135
+ tokenCount(0, {
136
+ input_tokens: 1000,
137
+ cached_input_tokens: 900,
138
+ output_tokens: 50,
139
+ total_tokens: 1050,
140
+ }));
141
+ const reading = readCodexUsage("usage-2", base);
142
+ assert.deepEqual(reading?.tokens, { input: 100, output: 50, cacheRead: 900, cacheWrite: 0 });
143
+ assert.equal(reading?.amount, 1050);
144
+ // The parts of a disjoint breakdown add back up to the whole.
145
+ const t = reading.tokens;
146
+ assert.equal(t.input + t.cacheRead + t.output, reading.amount);
147
+ });
148
+ it("clamps a cached count that exceeds input rather than reporting negative input", () => {
149
+ const base = newBase();
150
+ writeFileSync(rolloutPath(base, "usage-3"), sessionMeta("usage-3") +
151
+ tokenCount(0, { input_tokens: 10, cached_input_tokens: 999, total_tokens: 10 }));
152
+ const reading = readCodexUsage("usage-3", base);
153
+ assert.equal(reading?.tokens?.input, 0);
154
+ });
155
+ it("finds the last counter past a tail window filled with a huge tool output", () => {
156
+ // The window doubles until it catches a `token_count`. A single turn can
157
+ // append far more than the initial 64KB window (one big tool result), and
158
+ // the counter must not be lost behind it.
159
+ const base = newBase();
160
+ const path = rolloutPath(base, "usage-4");
161
+ const huge = line({ type: "response_item", payload: { type: "message", text: "y".repeat(200_000) } });
162
+ writeFileSync(path, sessionMeta("usage-4") + tokenCount(777) + huge);
163
+ assert.equal(readCodexUsage("usage-4", base)?.amount, 777);
164
+ });
165
+ it("returns null for a rollout with no counters, and for an unknown session", () => {
166
+ const base = newBase();
167
+ writeFileSync(rolloutPath(base, "usage-5"), sessionMeta("usage-5"));
168
+ assert.equal(readCodexUsage("usage-5", base), null);
169
+ assert.equal(readCodexUsage("nope", base), null);
170
+ });
171
+ it("picks up a counter appended after a previous read", () => {
172
+ // The (size, mtime) memo must not pin a stale reading once the file grows.
173
+ const base = newBase();
174
+ const path = rolloutPath(base, "usage-6");
175
+ writeFileSync(path, sessionMeta("usage-6") + tokenCount(10));
176
+ assert.equal(readCodexUsage("usage-6", base)?.amount, 10);
177
+ appendFileSync(path, tokenCount(20));
178
+ assert.equal(readCodexUsage("usage-6", base)?.amount, 20);
179
+ });
180
+ });
181
+ describe("toolUseFromCodexRecord", () => {
182
+ it("maps both call shapes, reading input from the field each one uses", () => {
183
+ // function_call carries `arguments`; custom_tool_call carries `input`.
184
+ const fn = toolUseFromCodexRecord(JSON.parse(toolCall("call_a", "shell", '{"cmd":"ls"}')));
185
+ assert.equal(fn?.name, "shell");
186
+ assert.equal(fn?.input, '{"cmd":"ls"}');
187
+ assert.equal(fn?.status, "pending");
188
+ const custom = toolUseFromCodexRecord(JSON.parse(toolCall("call_b", "exec", "text(1)", true)));
189
+ assert.equal(custom?.name, "exec");
190
+ assert.equal(custom?.input, "text(1)");
191
+ });
192
+ it("ignores records that are not tool calls", () => {
193
+ for (const record of [
194
+ { type: "event_msg", payload: { type: "token_count" } },
195
+ { type: "response_item", payload: { type: "reasoning" } },
196
+ { type: "response_item", payload: { type: "function_call" } }, // no call_id
197
+ { type: "session_meta", payload: { id: "x" } },
198
+ null,
199
+ "not an object",
200
+ ]) {
201
+ assert.equal(toolUseFromCodexRecord(record), undefined);
202
+ }
203
+ });
204
+ it("attributes an output from the names map, falling back to unknown", () => {
205
+ const names = new Map();
206
+ toolUseFromCodexRecord(JSON.parse(toolCall("call_c", "apply_patch", "{}")), names);
207
+ const known = toolUseFromCodexRecord(JSON.parse(toolOutput("call_c", "ok")), names);
208
+ assert.equal(known?.name, "apply_patch");
209
+ assert.equal(known?.status, "completed");
210
+ const orphan = toolUseFromCodexRecord(JSON.parse(toolOutput("call_z", "ok")), names);
211
+ assert.equal(orphan?.name, "unknown");
212
+ });
213
+ it("flattens custom_tool_call_output content parts into text", () => {
214
+ // The wire shape is [{type,text},…]. Storing it verbatim would show the
215
+ // operator JSON where the output should be.
216
+ const mapped = toolUseFromCodexRecord(JSON.parse(toolOutput("call_d", [
217
+ { type: "input_text", text: "Script completed\n" },
218
+ { type: "input_text", text: "2" },
219
+ ], true)));
220
+ assert.equal(mapped?.output, "Script completed\n2");
221
+ });
222
+ });
223
+ describe("pollCodexToolLog", () => {
224
+ it("records calls, completes them from their output, and returns written rows", () => {
225
+ const db = createTestDb();
226
+ const nid = seedNarrative(db);
227
+ const base = newBase();
228
+ writeFileSync(rolloutPath(base, "tools-1"), sessionMeta("tools-1") +
229
+ toolCall("call_a", "shell", '{"cmd":"ls"}') +
230
+ toolOutput("call_a", "a.txt\n"));
231
+ const written = reader(db, nid, "tools-1", base)();
232
+ // The caller pushes what comes back, so the return value is part of the
233
+ // contract, not a convenience.
234
+ assert.deepEqual(written.map((r) => `${r.dedup_key}:${r.status}`), ["call_a:pending", "call_a:completed"]);
235
+ const row = readToolUse(db, nid, "call_a");
236
+ assert.equal(row?.name, "shell");
237
+ assert.equal(row?.status, "completed");
238
+ assert.equal(row?.input, '{"cmd":"ls"}');
239
+ assert.equal(row?.output, "a.txt\n");
240
+ assert.equal(row?.source, "session_log");
241
+ assert.equal(row?.agent_type, "codex");
242
+ });
243
+ it("leaves a call pending until its output is appended", () => {
244
+ const db = createTestDb();
245
+ const nid = seedNarrative(db);
246
+ const base = newBase();
247
+ const path = rolloutPath(base, "tools-2");
248
+ writeFileSync(path, sessionMeta("tools-2") + toolCall("call_b", "shell", "{}"));
249
+ const poll = reader(db, nid, "tools-2", base);
250
+ poll();
251
+ assert.equal(readToolUse(db, nid, "call_b")?.status, "pending");
252
+ appendFileSync(path, toolOutput("call_b", "done"));
253
+ poll();
254
+ const row = readToolUse(db, nid, "call_b");
255
+ assert.equal(row?.status, "completed");
256
+ assert.equal(row?.output, "done");
257
+ });
258
+ it("attributes an output whose call was consumed by an earlier read", () => {
259
+ // The names map lives on the cursor precisely so this survives the gap
260
+ // between two polls.
261
+ const db = createTestDb();
262
+ const nid = seedNarrative(db);
263
+ const base = newBase();
264
+ const path = rolloutPath(base, "tools-3");
265
+ writeFileSync(path, sessionMeta("tools-3") + toolCall("call_c", "apply_patch", "{}"));
266
+ const poll = reader(db, nid, "tools-3", base);
267
+ poll();
268
+ appendFileSync(path, toolOutput("call_c", "ok"));
269
+ poll();
270
+ // The upsert never overwrites `name`, so the real one survives regardless;
271
+ // this pins that the completing write doesn't even try to replace it.
272
+ assert.equal(readToolUse(db, nid, "call_c")?.name, "apply_patch");
273
+ });
274
+ it("reads only appended bytes — a re-poll with no growth writes nothing", () => {
275
+ const db = createTestDb();
276
+ const nid = seedNarrative(db);
277
+ const base = newBase();
278
+ writeFileSync(rolloutPath(base, "tools-4"), sessionMeta("tools-4") + toolCall("call_d", "shell", "{}") + toolOutput("call_d", "ok"));
279
+ const poll = reader(db, nid, "tools-4", base);
280
+ poll();
281
+ const after = maxToolUseRev(db, nid);
282
+ assert.deepEqual(poll(), []);
283
+ assert.deepEqual(poll(), []);
284
+ assert.equal(maxToolUseRev(db, nid), after);
285
+ });
286
+ it("does not restate unchanged rows when a cold cursor replays the file", () => {
287
+ // The cursor is in memory, so the first poll after a daemon restart (or a
288
+ // revive) re-reads the whole rollout. Every write bumps rev, and rev is the
289
+ // client sync cursor — without the guard that would be a sync storm on
290
+ // every restart.
291
+ const db = createTestDb();
292
+ const nid = seedNarrative(db);
293
+ const base = newBase();
294
+ writeFileSync(rolloutPath(base, "tools-5"), sessionMeta("tools-5") +
295
+ toolCall("call_e", "shell", "{}") +
296
+ toolOutput("call_e", "ok") +
297
+ toolCall("call_f", "read", "{}") +
298
+ toolOutput("call_f", "ok"));
299
+ reader(db, nid, "tools-5", base)();
300
+ const after = maxToolUseRev(db, nid);
301
+ // A brand-new cursor over the same file: what a restart actually does.
302
+ assert.deepEqual(reader(db, nid, "tools-5", base)(), []);
303
+ assert.equal(maxToolUseRev(db, nid), after);
304
+ assert.equal(readToolUseWindow(db, nid).length, 2);
305
+ });
306
+ it("survives a chunk boundary that splits a multi-byte character", () => {
307
+ // This is why `pending` is a Buffer. Decoding each chunk independently
308
+ // would leave U+FFFD inside the JSON and the line would never parse.
309
+ const db = createTestDb();
310
+ const nid = seedNarrative(db);
311
+ const base = newBase();
312
+ const path = rolloutPath(base, "tools-6");
313
+ writeFileSync(path, sessionMeta("tools-6"));
314
+ const poll = reader(db, nid, "tools-6", base);
315
+ const record = Buffer.from(toolCall("call_g", "shell", "héllo — wörld 🌍"), "utf8");
316
+ const cut = record.lastIndexOf(Buffer.from("🌍", "utf8")) + 2; // inside the emoji
317
+ appendFileSync(path, record.subarray(0, cut));
318
+ poll();
319
+ assert.equal(readToolUse(db, nid, "call_g"), null); // incomplete — held back
320
+ appendFileSync(path, record.subarray(cut));
321
+ poll();
322
+ assert.equal(readToolUse(db, nid, "call_g")?.input, "héllo — wörld 🌍");
323
+ });
324
+ it("holds back a partial trailing line and completes it on the next poll", () => {
325
+ const db = createTestDb();
326
+ const nid = seedNarrative(db);
327
+ const base = newBase();
328
+ const path = rolloutPath(base, "tools-7");
329
+ writeFileSync(path, sessionMeta("tools-7"));
330
+ const poll = reader(db, nid, "tools-7", base);
331
+ const record = toolCall("call_h", "shell", "{}");
332
+ appendFileSync(path, record.slice(0, 20));
333
+ poll();
334
+ assert.equal(readToolUse(db, nid, "call_h"), null);
335
+ appendFileSync(path, record.slice(20));
336
+ poll();
337
+ assert.equal(readToolUse(db, nid, "call_h")?.name, "shell");
338
+ });
339
+ it("skips malformed lines without blocking the ones after them", () => {
340
+ const db = createTestDb();
341
+ const nid = seedNarrative(db);
342
+ const base = newBase();
343
+ writeFileSync(rolloutPath(base, "tools-8"), sessionMeta("tools-8") +
344
+ "{not json at all\n" +
345
+ toolCall("call_i", "shell", "{}") +
346
+ toolOutput("call_i", "ok"));
347
+ reader(db, nid, "tools-8", base)();
348
+ assert.equal(readToolUse(db, nid, "call_i")?.status, "completed");
349
+ });
350
+ it("restarts from zero when the rollout shrinks", () => {
351
+ // A shorter file was replaced or truncated, so the stored offset no longer
352
+ // points at anything meaningful and reading from it would yield garbage.
353
+ const db = createTestDb();
354
+ const nid = seedNarrative(db);
355
+ const base = newBase();
356
+ const path = rolloutPath(base, "tools-9");
357
+ writeFileSync(path, sessionMeta("tools-9") + toolCall("call_j", "shell", "{}") + toolOutput("call_j", "ok"));
358
+ const poll = reader(db, nid, "tools-9", base);
359
+ poll();
360
+ writeFileSync(path, sessionMeta("tools-9") + toolCall("call_k", "read", "{}"));
361
+ poll();
362
+ assert.equal(readToolUse(db, nid, "call_k")?.name, "read");
363
+ });
364
+ it("anchors rows to the narrative head that preceded them", () => {
365
+ // The flush runs before the MCP tool dispatches, so calls made since the
366
+ // last narration anchor under it — which is what places them between
367
+ // messages in chat rather than bunched at the end.
368
+ const db = createTestDb();
369
+ const nid = seedNarrative(db);
370
+ const base = newBase();
371
+ const path = rolloutPath(base, "tools-10");
372
+ writeFileSync(path, sessionMeta("tools-10"));
373
+ const poll = reader(db, nid, "tools-10", base);
374
+ appendEvent(db, nid, "narration", { content: "first" });
375
+ appendFileSync(path, toolCall("call_l", "shell", "{}"));
376
+ poll();
377
+ appendEvent(db, nid, "narration", { content: "second" });
378
+ appendFileSync(path, toolCall("call_m", "read", "{}"));
379
+ poll();
380
+ assert.equal(readToolUse(db, nid, "call_l")?.after_seq, 1);
381
+ assert.equal(readToolUse(db, nid, "call_m")?.after_seq, 2);
382
+ });
383
+ it("does nothing for a session with no rollout on disk", () => {
384
+ const db = createTestDb();
385
+ const nid = seedNarrative(db);
386
+ assert.deepEqual(reader(db, nid, "absent", newBase())(), []);
387
+ assert.equal(readToolUseWindow(db, nid).length, 0);
388
+ });
389
+ });
390
+ // ---------------------------------------------------------------------------
391
+ // The daemon-side driver. Codex is read from two directions over one cursor:
392
+ // a timer (so a call shows while it runs) and the agent's own MCP calls (so
393
+ // nothing dangles when it goes quiet). These pin that they cooperate.
394
+ //
395
+ // Asserted behaviourally — through rows appearing or not appearing — rather
396
+ // than by inspecting timer handles, since the interval is `unref`'d and so is
397
+ // deliberately invisible to the event loop.
398
+ // ---------------------------------------------------------------------------
399
+ describe("startCodexToolSampler", () => {
400
+ const TICK_MS = 10;
401
+ const settle = () => new Promise((resolve) => setTimeout(resolve, TICK_MS * 5));
402
+ /** A stand-in for a connected MCP bridge. Only the fields the sampler reads
403
+ * are real; `ws` needs nothing but a readyState. */
404
+ function bridge(narrativeId, agentType, open = true) {
405
+ return {
406
+ ws: { readyState: open ? 1 : 3 },
407
+ narrativeId,
408
+ agentType,
409
+ pendingListens: new Map(),
410
+ };
411
+ }
412
+ function seedSession(db, nid, sessionId) {
413
+ db.prepare("UPDATE narratives SET agent_session_id = ? WHERE id = ?").run(sessionId, nid);
414
+ }
415
+ it("flushes inline for codex and ignores every other agent", () => {
416
+ const db = createTestDb();
417
+ const nid = seedNarrative(db);
418
+ const base = newBase();
419
+ writeFileSync(rolloutPath(base, "samp-1"), sessionMeta("samp-1") + toolCall("call_a", "shell", "{}") + toolOutput("call_a", "ok"));
420
+ seedSession(db, nid, "samp-1");
421
+ const pushed = [];
422
+ const sampler = startCodexToolSampler(db, new Set(), (r) => pushed.push(r), 0, base);
423
+ // A non-codex bridge on the same narrative must not trigger a read.
424
+ sampler.flushFor(bridge(nid, "claude"));
425
+ assert.equal(pushed.length, 0);
426
+ sampler.flushFor(bridge(nid, "codex"));
427
+ assert.deepEqual(pushed.map((r) => r.status), ["pending", "completed"]);
428
+ sampler.stop();
429
+ });
430
+ it("shares one cursor between the timer and the inline flush", () => {
431
+ // Both paths drain through the same cursor, so whichever runs first
432
+ // advances the byte offset and the other finds nothing. A second read of
433
+ // the same bytes would bump rev and re-push rows that never changed.
434
+ const db = createTestDb();
435
+ const nid = seedNarrative(db);
436
+ const base = newBase();
437
+ writeFileSync(rolloutPath(base, "samp-2"), sessionMeta("samp-2") + toolCall("call_b", "shell", "{}") + toolOutput("call_b", "ok"));
438
+ seedSession(db, nid, "samp-2");
439
+ const pushed = [];
440
+ const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), (r) => pushed.push(r), 0, base);
441
+ sampler.flushFor(bridge(nid, "codex"));
442
+ assert.equal(pushed.length, 2);
443
+ const rev = maxToolUseRev(db, nid);
444
+ sampler.flushFor(bridge(nid, "codex"));
445
+ sampler.flushFor(bridge(nid, "codex"));
446
+ assert.equal(pushed.length, 2);
447
+ assert.equal(maxToolUseRev(db, nid), rev);
448
+ sampler.stop();
449
+ });
450
+ it("does not arm for a non-codex agent", async () => {
451
+ // A daemon that never runs codex does no recurring work at all: arming is
452
+ // one string comparison per bridge and nothing else.
453
+ const db = createTestDb();
454
+ const nid = seedNarrative(db);
455
+ const base = newBase();
456
+ writeFileSync(rolloutPath(base, "samp-3"), sessionMeta("samp-3") + toolCall("call_c", "shell", "{}"));
457
+ seedSession(db, nid, "samp-3");
458
+ const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), () => { }, TICK_MS, base);
459
+ sampler.armFor("claude");
460
+ sampler.armFor("copilot");
461
+ sampler.armFor(undefined);
462
+ await settle();
463
+ assert.equal(readToolUse(db, nid, "call_c"), null);
464
+ // The same sampler, armed properly, does read it.
465
+ sampler.armFor("codex");
466
+ await settle();
467
+ assert.equal(readToolUse(db, nid, "call_c")?.name, "shell");
468
+ sampler.stop();
469
+ });
470
+ it("picks up calls as they are appended, without an MCP call to prompt it", async () => {
471
+ // This is the whole point of the timer: a burst of tool calls with no
472
+ // narration in it used to stay invisible until the burst ended.
473
+ const db = createTestDb();
474
+ const nid = seedNarrative(db);
475
+ const base = newBase();
476
+ const path = rolloutPath(base, "samp-4");
477
+ writeFileSync(path, sessionMeta("samp-4"));
478
+ seedSession(db, nid, "samp-4");
479
+ const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), () => { }, TICK_MS, base);
480
+ sampler.armFor("codex");
481
+ appendFileSync(path, toolCall("call_d", "shell", "{}"));
482
+ await settle();
483
+ assert.equal(readToolUse(db, nid, "call_d")?.status, "pending");
484
+ appendFileSync(path, toolOutput("call_d", "ok"));
485
+ await settle();
486
+ assert.equal(readToolUse(db, nid, "call_d")?.status, "completed");
487
+ sampler.stop();
488
+ });
489
+ it("drains every connected codex narrative on one tick", async () => {
490
+ const db = createTestDb();
491
+ const one = seedNarrative(db, "codex-test0001");
492
+ const two = seedNarrative(db, "codex-test0002");
493
+ const base = newBase();
494
+ writeFileSync(rolloutPath(base, "samp-5"), sessionMeta("samp-5") + toolCall("call_e", "shell", "{}"));
495
+ writeFileSync(rolloutPath(base, "samp-6", new Date(2026, 2, 6, 10, 0, 0)), sessionMeta("samp-6") + toolCall("call_f", "read", "{}"));
496
+ seedSession(db, one, "samp-5");
497
+ seedSession(db, two, "samp-6");
498
+ const sampler = startCodexToolSampler(db, new Set([bridge(one, "codex"), bridge(two, "codex")]), () => { }, TICK_MS, base);
499
+ sampler.armFor("codex");
500
+ await settle();
501
+ sampler.stop();
502
+ assert.equal(readToolUse(db, one, "call_e")?.name, "shell");
503
+ assert.equal(readToolUse(db, two, "call_f")?.name, "read");
504
+ });
505
+ it("stops ticking once the last codex bridge is gone", async () => {
506
+ // An ended codex session must not leave recurring work behind. The first
507
+ // tick that finds nothing connected disarms the timer; the next codex
508
+ // bridge to arm restarts it.
509
+ const db = createTestDb();
510
+ const nid = seedNarrative(db);
511
+ const base = newBase();
512
+ const path = rolloutPath(base, "samp-7");
513
+ writeFileSync(path, sessionMeta("samp-7"));
514
+ seedSession(db, nid, "samp-7");
515
+ const clients = new Set([bridge(nid, "codex")]);
516
+ const sampler = startCodexToolSampler(db, clients, () => { }, TICK_MS, base);
517
+ sampler.armFor("codex");
518
+ await settle();
519
+ clients.clear(); // the bridge disconnected
520
+ await settle(); // a tick observes that and disarms
521
+ appendFileSync(path, toolCall("call_g", "shell", "{}"));
522
+ await settle();
523
+ assert.equal(readToolUse(db, nid, "call_g"), null);
524
+ // Re-arming after a new bridge connects picks it straight back up.
525
+ clients.add(bridge(nid, "codex"));
526
+ sampler.armFor("codex");
527
+ await settle();
528
+ assert.equal(readToolUse(db, nid, "call_g")?.name, "shell");
529
+ sampler.stop();
530
+ });
531
+ it("keeps the inline flush working when the timer is disabled", async () => {
532
+ // `codex_tool_log.poll_ms = 0` turns off the timer only. Unlike crush and
533
+ // copilot, where that setting disables capture outright, codex falls back
534
+ // to MCP-call-driven reads — less immediate, but still complete.
535
+ const db = createTestDb();
536
+ const nid = seedNarrative(db);
537
+ const base = newBase();
538
+ writeFileSync(rolloutPath(base, "samp-8"), sessionMeta("samp-8") + toolCall("call_h", "shell", "{}"));
539
+ seedSession(db, nid, "samp-8");
540
+ const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), () => { }, 0, base);
541
+ sampler.armFor("codex");
542
+ await settle();
543
+ assert.equal(readToolUse(db, nid, "call_h"), null); // no timer ever started
544
+ sampler.flushFor(bridge(nid, "codex"));
545
+ assert.equal(readToolUse(db, nid, "call_h")?.name, "shell");
546
+ sampler.stop();
547
+ });
548
+ it("releases a narrative's cursor when its last bridge closes", () => {
549
+ // A revive re-reads from zero, which the row-level guard makes cheap — the
550
+ // point is not to hold cursors for narratives that are gone.
551
+ const db = createTestDb();
552
+ const nid = seedNarrative(db);
553
+ const base = newBase();
554
+ writeFileSync(rolloutPath(base, "samp-9"), sessionMeta("samp-9") + toolCall("call_i", "shell", "{}") + toolOutput("call_i", "ok"));
555
+ seedSession(db, nid, "samp-9");
556
+ const pushed = [];
557
+ const sampler = startCodexToolSampler(db, new Set(), (r) => pushed.push(r), 0, base);
558
+ sampler.flushFor(bridge(nid, "codex"));
559
+ assert.equal(pushed.length, 2);
560
+ sampler.release(nid);
561
+ // Cursor gone, so the file is replayed — but nothing changed, so the guard
562
+ // rewrites no row and re-pushes nothing.
563
+ sampler.flushFor(bridge(nid, "codex"));
564
+ assert.equal(pushed.length, 2);
565
+ sampler.stop();
566
+ });
567
+ });
568
+ //# sourceMappingURL=codex-tool-log.test.js.map
@@ -0,0 +1,71 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { getConfig } from "../commands/config.js";
5
+ import { openDatabase } from "../db/schema.js";
6
+ // Test the config logic directly against the meta table,
7
+ // since the config command's run() relies on openDatabase()
8
+ // which opens the real ~/.brane/brane.db.
9
+ describe("config storage", () => {
10
+ it("stores and retrieves a config value", () => {
11
+ const db = createTestDb();
12
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "my.domain.dev");
13
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("config.app_domain");
14
+ assert.equal(row?.value, "my.domain.dev");
15
+ });
16
+ it("overwrites an existing config value", () => {
17
+ const db = createTestDb();
18
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "old.domain.dev");
19
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "new.domain.dev");
20
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("config.app_domain");
21
+ assert.equal(row.value, "new.domain.dev");
22
+ });
23
+ it("deletes a config value", () => {
24
+ const db = createTestDb();
25
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "test.dev");
26
+ const result = db.prepare("DELETE FROM meta WHERE key = ?").run("config.app_domain");
27
+ assert.equal(result.changes, 1);
28
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("config.app_domain");
29
+ assert.equal(row, undefined);
30
+ });
31
+ it("lists config values with config. prefix", () => {
32
+ const db = createTestDb();
33
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "test.dev");
34
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.theme", "dark");
35
+ // This is NOT a config key
36
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("daemon_pid", "12345");
37
+ const rows = db
38
+ .prepare("SELECT key, value FROM meta WHERE key LIKE 'config.%' ORDER BY key")
39
+ .all();
40
+ assert.equal(rows.length, 2);
41
+ assert.equal(rows[0].key, "config.app_domain");
42
+ assert.equal(rows[1].key, "config.theme");
43
+ });
44
+ it("config keys don't collide with daemon state", () => {
45
+ const db = createTestDb();
46
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("daemon_port", "7890");
47
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.port", "8080");
48
+ const daemonPort = db.prepare("SELECT value FROM meta WHERE key = 'daemon_port'").get();
49
+ const configPort = db.prepare("SELECT value FROM meta WHERE key = 'config.port'").get();
50
+ assert.equal(daemonPort.value, "7890");
51
+ assert.equal(configPort.value, "8080");
52
+ });
53
+ });
54
+ describe("the suite never opens ~/.brane", () => {
55
+ it("getConfig reads the db it is handed", () => {
56
+ const db = createTestDb();
57
+ db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.mentions.receipt_limit", "3");
58
+ assert.equal(getConfig("mentions.receipt_limit", db), "3");
59
+ // Unset keys still fall back to the defaults table.
60
+ assert.equal(getConfig("app_domain", db), "brane.organelle.dev");
61
+ });
62
+ it("openDatabase() refuses under node --test", () => {
63
+ // The backstop: a path with no db threaded to it fails the test that
64
+ // reached it, instead of opening the operator's live database (and running
65
+ // migrations on it) from a test run — `brane doctor`'s included.
66
+ assert.ok(process.env.NODE_TEST_CONTEXT, "node --test sets NODE_TEST_CONTEXT in each test process");
67
+ assert.throws(() => openDatabase(), /under node --test/);
68
+ assert.throws(() => getConfig("app_domain"), /under node --test/);
69
+ });
70
+ });
71
+ //# sourceMappingURL=config.test.js.map