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,237 @@
1
+ /**
2
+ * Narrative meta formatting — the single source of truth for how a narrative's
3
+ * cost and activity span are worded, shared by every surface that renders them.
4
+ *
5
+ * Four consumers today: the Expo list card, the page header, and the switcher
6
+ * (all via `expo/components/NarrativeMeta.tsx` / `expo/lib/api.ts`), plus the
7
+ * server-side HTML export (`src/export.ts`). They must read alike — a narrative
8
+ * card that says "$6.81 /worker from 3d to 4h ago" in the app has to say the
9
+ * same thing in an export of that narrative.
10
+ *
11
+ * Deliberately dependency-free, framework-free (no React, no react-native, no
12
+ * node builtins) *and stateless* so both the tsc build and the metro bundler
13
+ * can consume it. Stateless is load-bearing, not stylistic: this file is
14
+ * compiled twice — once by the root tsc into `dist/shared/`, once inlined into
15
+ * the metro bundle — so the two builds hold separate copies. Pure functions
16
+ * don't care; a module-level cache or counter added here would silently
17
+ * diverge between the server and the app.
18
+ *
19
+ * Expo reaches this file through the `@shared/*` tsconfig path and the
20
+ * `watchFolders` entry in `expo/metro.config.js` — keep it that way.
21
+ */
22
+ /** Event types that carry conversation: an agent's own voice, the operator's
23
+ * signals, and foreign agents' signals. Exported as an array as well, because
24
+ * `src/export.ts` asks SQLite the same question (`type IN (…)`) and a second
25
+ * hand-written list of these three strings is a list that drifts. */
26
+ export const CONVERSATIONAL_EVENT_TYPES_LIST = ["narration", "human_signal", "agent_signal"];
27
+ const CONVERSATIONAL_EVENT_TYPES = new Set(CONVERSATIONAL_EVENT_TYPES_LIST);
28
+ /** True for events the daemon wrote on its own behalf — `started` (spawn/twin/
29
+ * external MCP client), `restarted` (revive), `binding_rejected`. They're part
30
+ * of the record (a twin's `/twin from <nid>:<seq>` back-pointer lives here),
31
+ * but nobody *said* them, so every surface renders them as a recessive system
32
+ * line rather than as a speaker's bubble. */
33
+ export function isSystemEvent(type) {
34
+ return !CONVERSATIONAL_EVENT_TYPES.has(type);
35
+ }
36
+ /*
37
+ * Lifecycle lines — the text of a `started` / `restarted` event.
38
+ *
39
+ * One shape, written the way the loop already speaks: a verb, the invocation
40
+ * as a code span, and the role as a `/protocol` command.
41
+ *
42
+ * Spawned `claude --model opus-5` /worker
43
+ * Spawned `claude` /twin from claude-abc:42
44
+ * Revived `claude --dangerously-skip-permissions` /worker (forked — original still live)
45
+ * Running `lazygit`
46
+ *
47
+ * The code span is what the operator chose — the binary, its model flag, and
48
+ * any passthrough args — never the daemon's plumbing (session ids, MCP config
49
+ * paths, the bind secret, the bootstrap prompt). The `/role` is the same token
50
+ * the card's meta line and the `/keeper` slash convention use, and the app's
51
+ * protocol linkifier turns it into a link to the role's protocol. The
52
+ * predecessor after `from` is a bare `nid:seq` on purpose: a backtick there
53
+ * would stop it linkifying, and that link is the whole point of the line.
54
+ *
55
+ * The fold is deliberately *not* in the stored text. It is a column on the
56
+ * narrative's own row — a twin's row carries the twin's fold, which is what
57
+ * makes it worth showing — and every renderer has that row, so `systemLine`
58
+ * appends it at render time from the column rather than from the string.
59
+ *
60
+ * Every writer (spawn, twin, revive, run, absorb) calls these so the stored
61
+ * text is already the final form; `formatSystemContent` below normalizes the
62
+ * rows written before this shape existed.
63
+ */
64
+ /** A single argv word as a shell would want it typed: bare when it can be,
65
+ * single-quoted when it holds whitespace or a quote. */
66
+ function shellWord(arg) {
67
+ if (arg !== "" && !/[\s'"$`\\]/.test(arg))
68
+ return arg;
69
+ return `'${arg.replace(/'/g, "'\\''")}'`;
70
+ }
71
+ /** An invocation as a markdown code span. Backticks inside it widen the fence
72
+ * rather than break it. */
73
+ export function formatInvocation(command) {
74
+ const text = command.map(shellWord).join(" ");
75
+ const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), (m) => m[0].length));
76
+ const fence = "`".repeat(longest + 1);
77
+ return longest ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
78
+ }
79
+ /** `Spawned \`claude --model x\` /worker`, or with a predecessor,
80
+ * `Spawned \`claude\` /twin from claude-abc:42`. */
81
+ export function spawnedContent(agent, role, opts = {}) {
82
+ const line = `Spawned ${formatInvocation([agent, ...(opts.args ?? [])])} /${role}`;
83
+ return opts.from ? `${line} from ${opts.from}` : line;
84
+ }
85
+ /** `Revived \`claude\` /worker`, flagged when the resume forked a still-live
86
+ * original — the one thing about a revive the arguments don't say. */
87
+ export function revivedContent(agent, role, args = [], forked = false) {
88
+ const line = `Revived ${formatInvocation([agent, ...args])} /${role}`;
89
+ return forked ? `${line} (forked — original still live)` : line;
90
+ }
91
+ /** A utility narrative (`POST /api/run`) has no role and no agent: the line is the
92
+ * command itself. */
93
+ export function runningContent(command) {
94
+ return `Running ${formatInvocation(command)}`;
95
+ }
96
+ export function restartedContent(command) {
97
+ return `Restarted ${formatInvocation(command)}`;
98
+ }
99
+ /** The agent a narrative id was minted for: `claude-rfhbth2v` → `claude`.
100
+ * Every brane-spawned id is `<agent>-<8 base36>`, so the prefix is exact for
101
+ * them; an absorbed import's `organelle-…` id names the importer, which is
102
+ * why callers prefer the row's `agent_type` and fall back to this. */
103
+ export function agentTypeOf(nid) {
104
+ return nid.replace(/-[a-z0-9]{8}$/, "");
105
+ }
106
+ /** Bring a lifecycle line written before the invocation shape existed up to
107
+ * it, at render time — changing the writers alone would fix narratives
108
+ * spawned from then on and leave every existing one reading the old way:
109
+ *
110
+ * Spawned claude (worker) → Spawned `claude` /worker
111
+ * Twinned from claude-abc:42 → Spawned `claude` /twin from claude-abc:42
112
+ * Revived claude session (…) → Revived `claude` (…)
113
+ * Running lazygit → Running `lazygit`
114
+ *
115
+ * The old twin line never recorded which agent was spawned, so the caller
116
+ * supplies it from the narrative row; without one the line falls back to
117
+ * `/twin from …`.
118
+ *
119
+ * A line already carrying a code span is in the new shape and passes through
120
+ * untouched — a row written since this shape landed is never transformed. Deliberately no allowlist of known
121
+ * roles: whatever a narrative was spawned as gets the same notation. Each
122
+ * pattern is anchored to the whole line so ordinary prose that happens to
123
+ * start with one of these verbs is left alone. */
124
+ export function formatSystemContent(content, agent) {
125
+ const s = content.trimEnd();
126
+ if (s.includes("`"))
127
+ return s;
128
+ let m;
129
+ if ((m = /^Spawned (\S+) \(([A-Za-z0-9][\w.-]*)\)$/.exec(s))) {
130
+ return spawnedContent(m[1], m[2]);
131
+ }
132
+ if ((m = /^Twinned from (\S+)$/.exec(s))) {
133
+ return agent ? spawnedContent(agent, "twin", { from: m[1] }) : `/twin from ${m[1]}`;
134
+ }
135
+ if ((m = /^Revived (\S+) session( \(.*\))?$/.exec(s))) {
136
+ return `Revived \`${m[1]}\`${m[2] ?? ""}`;
137
+ }
138
+ if ((m = /^(Running|Restarted) (.+)$/.exec(s))) {
139
+ return `${m[1]} \`${m[2]}\``;
140
+ }
141
+ return s;
142
+ }
143
+ /** The href scheme a lifecycle line's fold is linked under, so a renderer can
144
+ * pick it out and paint it the way the identity row paints a fold — green,
145
+ * monospace — rather than link blue. It identifies; it does not navigate. */
146
+ export const FOLD_LINK_SCHEME = "brane://fold/";
147
+ /** The fold color the identity row uses (`NarrativeIdentity` in
148
+ * `expo/components/NarrativeMeta.tsx`); the export's stylesheet reads it from
149
+ * here so a system line's fold is the same green in both. */
150
+ export const FOLD_COLOR = "#5e8a72";
151
+ /** The markdown a renderer shows for a system event: the stored line (legacy
152
+ * shapes normalized), and for a `started` event the narrative's fold appended
153
+ * as `in [fold](brane://fold/fold)`. The fold comes from the row, and whether
154
+ * to show it from the event type — nothing is read out of the text. A
155
+ * `restarted` line omits it (a revive cannot move folds, and the header
156
+ * already says where the narrative lives); an external narrative has no fold
157
+ * and so shows none.
158
+ *
159
+ * The one text comparison is an equality: for a few hours on 2026-09-08 the
160
+ * writers baked ` in <fold>` into the content. A row from that window ends
161
+ * with exactly its own `fold_path`, which is dropped so the fold isn't shown
162
+ * twice. */
163
+ export function systemLine(event, narrative = {}) {
164
+ let text = formatSystemContent(event.content, narrative.agent);
165
+ const fold = narrative.fold ?? null;
166
+ if (!fold)
167
+ return text;
168
+ const baked = ` in ${fold}`;
169
+ if (text.endsWith(baked))
170
+ text = text.slice(0, -baked.length);
171
+ return event.type === "started" ? `${text} in [${fold}](${FOLD_LINK_SCHEME}${fold})` : text;
172
+ }
173
+ /** One end of the activity span. `val` is the bare magnitude ("3d", "5m",
174
+ * "30s") or an absolute date once older than a week; `ago` says whether an
175
+ * "ago" suffix applies (so a span can share one "ago": "3d to 4h ago"). */
176
+ export function relPart(ms, now = Date.now()) {
177
+ const sec = Math.floor((now - ms) / 1000);
178
+ if (sec < 60)
179
+ return { val: `${Math.max(1, sec)}s`, ago: true };
180
+ const min = Math.floor(sec / 60);
181
+ if (min < 60)
182
+ return { val: `${min}m`, ago: true };
183
+ const hr = Math.floor(min / 60);
184
+ if (hr < 24)
185
+ return { val: `${hr}h`, ago: true };
186
+ const day = Math.floor(hr / 24);
187
+ if (day < 7)
188
+ return { val: `${day}d`, ago: true };
189
+ const d = new Date(ms);
190
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
191
+ const date = `${months[d.getMonth()]} ${d.getDate()}`;
192
+ return { val: d.getFullYear() === new Date(now).getFullYear() ? date : `${date}, ${d.getFullYear()}`, ago: false };
193
+ }
194
+ /** Humanized activity span "created-ago to last-active-ago" — e.g. "3d to 4h
195
+ * ago". Collapses to a single value when there's no distinct last activity
196
+ * (brand-new narrative, or before the daemon exposes last_activity).
197
+ * When both ends are relative they share one "ago"; mixed/absolute ends are
198
+ * spelled out per-end ("Jun 10 to 4 hours ago").
199
+ *
200
+ * `now` is the instant the span is relative to. The live surfaces leave it at
201
+ * the default; the HTML export pins it to the export timestamp so a static
202
+ * document's "3d ago" stays anchored to the moment it was written. */
203
+ export function formatActivitySpan(createdMs, lastMs, now = Date.now()) {
204
+ const c = relPart(createdMs, now);
205
+ const withAgo = (p) => (p.ago ? `${p.val} ago` : p.val);
206
+ const hasSpan = typeof lastMs === "number" && lastMs - createdMs > 60_000;
207
+ if (!hasSpan)
208
+ return withAgo(c);
209
+ const l = relPart(lastMs, now);
210
+ if (c.val === l.val)
211
+ return withAgo(c);
212
+ if (c.ago && l.ago)
213
+ return `${c.val} to ${l.val} ago`;
214
+ return `${withAgo(c)} to ${withAgo(l)}`;
215
+ }
216
+ /** Format a metered cost for display, generic across units. USD renders as
217
+ * "$6.81"; any other unit (e.g. Copilot's "AIC" credits) renders as
218
+ * "1,200 AIC". Returns null when there's nothing to show. */
219
+ export function formatCost(amount, unit) {
220
+ if (amount == null || !unit)
221
+ return null;
222
+ if (unit === "USD")
223
+ return `$${amount.toFixed(2)}`;
224
+ const n = Number.isInteger(amount)
225
+ ? amount.toLocaleString()
226
+ : amount.toLocaleString(undefined, { maximumFractionDigits: 2 });
227
+ return `${n} ${unit}`;
228
+ }
229
+ /** Format a set of per-unit totals as one line: "$2,430.46 · 100.2 AIC".
230
+ * Units are listed, never added. Returns null when there is nothing metered. */
231
+ export function formatCostTotals(totals) {
232
+ if (!totals?.length)
233
+ return null;
234
+ const parts = totals.map((t) => formatCost(t.amount, t.unit)).filter((p) => !!p);
235
+ return parts.length ? parts.join(" · ") : null;
236
+ }
237
+ //# sourceMappingURL=narrative-format.js.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The 🦠 menu's *shape* — icons, labels, and which items a given narrative
3
+ * gets — in one place, so the two surfaces that offer it (the detail header's
4
+ * 🦠 button and the list card's status badge) cannot drift apart. Adding an
5
+ * item here adds it to both; that is the whole point of the module.
6
+ *
7
+ * Lives in `shared/` rather than beside the components because it is pure: no
8
+ * React, no window, no network. That makes the "cannot drift" claim something
9
+ * the node suite can actually check (`src/test/narrative-menu.test.ts`) rather
10
+ * than something a comment asserts.
11
+ */
12
+ /** Roles with no tmux process to resume: reviving them means twinning instead —
13
+ * an external session (an MCP client that connected without `brane spawn`) or
14
+ * an absorbed import, neither of which has an agent to restart.
15
+ *
16
+ * Stated once because three places need to agree on it: the pane that turns
17
+ * Revive into Twin, the detail screen that must not rewrite its view for a
18
+ * revive that is really going to open a dialog, and the list that should ask
19
+ * the same question the detail screen asks. */
20
+ export function revivesAsTwin(role) {
21
+ return role === "external" || role === "absorbed";
22
+ }
23
+ export function narrativeMenuItems({ role, status, mcpConnected, archived = false, isIsolated = false, selectedCount = 0, onMeld, onIsolate, onExport, onRecolor, onTwin, onRevive, onEnd, onArchiveToggle, }) {
24
+ const isEnded = status === "ended" && !mcpConnected;
25
+ // External narratives have no tmux process — no in-place End/Revive applies;
26
+ // their only lifecycle action is Twin (continue into a brane-managed agent).
27
+ // Absorbed imports twin as well, but they reach it *through* Revive, which is
28
+ // why they keep the item and `external` does not: an import is a transcript
29
+ // you continue, an external session is one you can only fork.
30
+ const isExternal = role === "external";
31
+ return [
32
+ { icon: "🖖", label: "Meld", onPress: onMeld },
33
+ {
34
+ icon: isIsolated ? "🏘️" : "🏠",
35
+ label: isIsolated ? "Unisolate" : "Isolate",
36
+ onPress: onIsolate,
37
+ },
38
+ // With a selection, Export becomes two items — the picks, or everything but
39
+ // the picks — because a selection is as often "these are the interesting
40
+ // ones" as "these are the ones nobody else should read". Both items name
41
+ // the same N: it is the count the operator can see highlighted, and the
42
+ // complement's own message count exists only on the daemon.
43
+ //
44
+ // 📤 rather than ✂️ for the second: scissors in a menu that also holds
45
+ // Archive read as a deletion, and an export removes nothing from anything.
46
+ ...(selectedCount > 0
47
+ ? [
48
+ {
49
+ icon: "📨",
50
+ label: `Export ${selectedCount} selected`,
51
+ onPress: () => onExport("selected"),
52
+ },
53
+ {
54
+ icon: "📤",
55
+ label: `Export ${selectedCount} excluded`,
56
+ onPress: () => onExport("excluded"),
57
+ },
58
+ ]
59
+ : [{ icon: "📨", label: "Export", onPress: () => onExport("all") }]),
60
+ // Recolor sits with Export rather than the lifecycle verbs below it: both
61
+ // act on how the narrative is *seen*, and neither touches the daemon.
62
+ { icon: "🎨", label: "Recolor", onPress: onRecolor },
63
+ // Twin is available on every narrative — fork/continue it into a
64
+ // new brane-managed agent (UI-gated via the spawn dialog). A plain verb
65
+ // like its neighbors; the /twin protocol syntax belongs to the dialog the
66
+ // press opens and to the line the resulting narrative starts with.
67
+ { icon: "🧬", label: "Twin", onPress: onTwin },
68
+ ...(isExternal
69
+ ? []
70
+ : [
71
+ isEnded
72
+ ? { icon: "🐦‍🔥", label: "Revive", onPress: onRevive }
73
+ : { icon: "🫠", label: "End", onPress: onEnd },
74
+ ]),
75
+ {
76
+ icon: archived ? "📂" : "🗄️",
77
+ label: archived ? "Unarchive" : "Archive",
78
+ onPress: onArchiveToggle,
79
+ },
80
+ ];
81
+ }
82
+ //# sourceMappingURL=narrative-menu.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Narrative list ordering — the single source of truth for the order narratives
3
+ * appear in, shared by every surface that shows more than one of them.
4
+ *
5
+ * Two consumers today: the list screen (`expo/app/index.tsx`) and the header's
6
+ * narrative switcher (`expo/app/[nid].tsx`). They have to agree. When this rule
7
+ * lived only in the list screen, the switcher kept the older three-way rank it
8
+ * had been copied from and went on rearranging itself under the operator's
9
+ * thumb — in the one place that hurts most, since the switcher is a modal you
10
+ * open in order to reach for a card.
11
+ *
12
+ * Deliberately dependency-free and framework-free, like its neighbors here, and
13
+ * structurally typed: `NarrativeInfo` lives in `expo/lib/api.ts`, which this
14
+ * file must not reach for. Ordering needs two fields, so it takes exactly those.
15
+ *
16
+ * Expo reaches this file through the `@shared/*` tsconfig path and the
17
+ * `watchFolders` entry in `expo/metro.config.js` — keep it that way.
18
+ */
19
+ /** Sort narratives: DEAD below everything, LIVE and IDLE interleaved. Newest first
20
+ * within each of the two groups.
21
+ *
22
+ * Only the bottom edge is a partition, and that asymmetry is the point. DEAD is
23
+ * a durable fact about a session — it stays dead until someone revives it — so
24
+ * sinking it costs nobody a moving target. LIVE vs IDLE is not durable at all:
25
+ * it tracks `mcp_connected`, which drops whenever an agent's own work blocks its
26
+ * bridge, and comes back a moment later. Ranking on it made the list rearrange
27
+ * itself under the operator's thumb for a distinction that had already expired
28
+ * by the time the card landed. A card the operator is reaching for should not
29
+ * move because the agent inside it got busy.
30
+ *
31
+ * `created_at` is the tiebreak rather than last activity for the same reason:
32
+ * it is the one key a status flip cannot touch, so within the live/idle block
33
+ * nothing moves at all.
34
+ *
35
+ * The dead predicate is `status === "ended"`, matching the badge exactly (see
36
+ * `statusText` in `expo/app/index.tsx`) — the order on screen and the word on
37
+ * the card are then answering the same question.
38
+ *
39
+ * Returns a new array; the input is not mutated. */
40
+ export function sortNarratives(narrs) {
41
+ return [...narrs].sort((a, b) => {
42
+ const rank = (n) => (n.status === "ended" ? 1 : 0);
43
+ const diff = rank(a) - rank(b);
44
+ return diff !== 0 ? diff : b.created_at - a.created_at;
45
+ });
46
+ }
47
+ //# sourceMappingURL=narrative-sort.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The awaiting glow: how a narrative whose agent is parked waiting for
3
+ * direction — a blocking `listen`, or a `narrate({ wait: true })` — shows it.
4
+ *
5
+ * It is deliberately not a status word. LIVE / IDLE / DEAD say what the
6
+ * session is; the glow says the agent is waiting on you, and it lights the
7
+ * whole row (list card, switcher row, narrative header) from the inside, the
8
+ * way padding would sit, so nothing moves when it comes and goes.
9
+ *
10
+ * The hue is allocated per device (`src/shared/glow-hues.ts`): chosen once,
11
+ * as far as it can be from the hues of the other recently glowing narratives,
12
+ * then kept, so the operator can learn sessions by color. 🎨 Recolor moves it
13
+ * to a random hue still clear of the others.
14
+ *
15
+ * The fact comes from the daemon's `awaiting` bit (src/daemon/awaiting.ts).
16
+ * It is only honoured while bridged: a parked call cannot outlive its socket,
17
+ * so an unbridged narrative claiming it is a stale client-side value.
18
+ */
19
+ export function isAwaiting({ mcpConnected, awaiting }) {
20
+ return !!mcpConnected && !!awaiting;
21
+ }
22
+ /** An inset-only `box-shadow`, all glow and no line: every layer is blurred,
23
+ * none is a solid edge. Inset shadows take no layout space and never spill
24
+ * onto neighbours.
25
+ *
26
+ * Near-opaque at the rim, fading inward. The hue is what tells sessions
27
+ * apart, and translucency over a dark row mixes it toward the background:
28
+ * the first version peaked at 0.45 alpha, and hues a quarter turn apart
29
+ * read as the same dim tint. OKLCH rather than HSL because equal HSL
30
+ * lightness is not equal brightness — yellow and cyan glared while blue and
31
+ * violet sank — so each layer holds lightness and chroma fixed and only the
32
+ * hue varies. */
33
+ export function awaitingGlow(h) {
34
+ return [
35
+ `inset 0 0 3px 1px oklch(0.78 0.16 ${h} / 0.95)`,
36
+ `inset 0 0 18px 2px oklch(0.74 0.15 ${h} / 0.55)`,
37
+ `inset 0 0 40px 4px oklch(0.7 0.14 ${h} / 0.28)`,
38
+ // The top edge reads a pixel thinner than the bottom, which has the
39
+ // tab bar's hairline under it. A one-sided copy of the first layer
40
+ // thickens the top alone: the negative spread grows the shadow's hole
41
+ // past every edge, and the offset pulls it back to 1px below the top.
42
+ `inset 0 4px 3px -3px oklch(0.78 0.16 ${h} / 0.95)`,
43
+ ].join(", ");
44
+ }
45
+ /** The daemon reports the bit's edges as their own control message,
46
+ * `{ type: "awaiting", narrative, awaiting }` — never as a `status`, which an
47
+ * older app would read as the narrative's lifecycle status. Inside the app the
48
+ * root layout forwards them to the status listeners as these two words, so a
49
+ * screen needs one subscription for everything a row depends on. */
50
+ export const AWAITING_STATUS = "awaiting";
51
+ export const NOT_AWAITING_STATUS = "not_awaiting";
52
+ //# sourceMappingURL=narrative-status.js.map
@@ -0,0 +1,85 @@
1
+ /** Rules that keep iOS Safari from moving the page under the app, window-free.
2
+ *
3
+ * Every scroller in brane is an inner one; the page itself never scrolls.
4
+ * iOS moves it anyway in two ways, and both hide the header:
5
+ *
6
+ * 1. **The focus pan.** Focusing an input the keyboard will cover scrolls
7
+ * the page until the input clears it. Refused at the source: a tap on an
8
+ * editable is taken over and focuses it with `preventScroll`, which iOS
9
+ * honours for the keyboard too (measured on device, claude-3j4583pl:124).
10
+ * 2. **The drag pan.** With the keyboard up the visible area is shorter than
11
+ * the page, and any drag the app doesn't scroll — on the header, on a bar,
12
+ * or past the end of a list — pans the page within it. Refused by allowing
13
+ * a vertical drag only where something under the finger can itself scroll
14
+ * that way.
15
+ *
16
+ * The layout, meanwhile, is sized to the visible area (`--brane-vvh`), so a
17
+ * bottom bar sits on the keyboard without anything having to move. */
18
+ /** Movement past this between touchstart and touchend makes it a drag, not a
19
+ * tap: a scroll that happens to start on an input must not focus it. */
20
+ export const TAP_SLOP_PX = 10;
21
+ const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"]);
22
+ /** Whether a tap on this element should be taken over to focus it without
23
+ * the pan: text-entry controls that are enabled and writable. */
24
+ export function isTextEntry(el) {
25
+ if (el.isContentEditable)
26
+ return true;
27
+ const tag = el.tagName.toUpperCase();
28
+ if (tag !== "INPUT" && tag !== "TEXTAREA")
29
+ return false;
30
+ if (el.disabled || el.readOnly)
31
+ return false;
32
+ return tag === "TEXTAREA" || !NON_TEXT_INPUTS.has((el.type ?? "text").toLowerCase());
33
+ }
34
+ /** A box's room each way from its raw scroll numbers. `reversed` is a
35
+ * `column-reverse` flex container, whose scroll origin is its bottom. */
36
+ export function scrollBoxOf(el) {
37
+ const range = el.scrollHeight - el.clientHeight;
38
+ const scrolls = (el.overflowY === "auto" || el.overflowY === "scroll") && range > 1;
39
+ if (!scrolls)
40
+ return { scrolls: false, roomAbove: 0, roomBelow: 0 };
41
+ const [min, max] = el.reversed ? [-range, 0] : [0, range];
42
+ return { scrolls: true, roomAbove: el.scrollTop - min, roomBelow: max - el.scrollTop };
43
+ }
44
+ /** Whether a drag may proceed. Horizontal drags always may — tab swipes and
45
+ * wide code blocks are the app's, and the page never pans sideways. A
46
+ * vertical one may only if some element under the finger (innermost first)
47
+ * can scroll in its direction; otherwise it would move the page. `dy > 0`
48
+ * is a finger moving down, which reveals what is above. */
49
+ export function dragAllowed(dx, dy, chain) {
50
+ if (Math.abs(dx) > Math.abs(dy))
51
+ return true;
52
+ if (dy === 0)
53
+ return true;
54
+ for (const b of chain) {
55
+ if (!b.scrolls)
56
+ continue;
57
+ if (dy > 0 && b.roomAbove > 1)
58
+ return true;
59
+ if (dy < 0 && b.roomBelow > 1)
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ /** The layout height to use: the visible area's, except while pinch-zoomed,
65
+ * when the visible area is a magnified piece of the page and the layout must
66
+ * not change at all. */
67
+ export function layoutHeight(vvHeight, scale, previous) {
68
+ if (Math.abs(scale - 1) > 0.01)
69
+ return previous;
70
+ return Math.round(vvHeight);
71
+ }
72
+ /** After our own tap focuses a field, the keyboard takes a moment to rise;
73
+ * the field is not "focused with the keyboard down" during that window. */
74
+ export const TAP_FOCUS_GRACE_MS = 1000;
75
+ /** The invariant that keeps the focus pan from ever coming back: on a touch
76
+ * screen, a text field may hold focus only while the soft keyboard is up,
77
+ * or just after our own tap focused it. A field focused with the keyboard
78
+ * down — swiped away, focused from code, autofocused on load — turns the
79
+ * next tap into a native keyboard raise, and iOS pans for that one
80
+ * (claude-3j4583pl:171, :176). Every such state is blurred, whoever made
81
+ * it. Never on a fine pointer: there is no soft keyboard to wait for. */
82
+ export function mustBlur(s) {
83
+ return s.touch && s.textEntryFocused && !s.keyboardUp && s.msSinceTapFocus > TAP_FOCUS_GRACE_MS;
84
+ }
85
+ //# sourceMappingURL=no-pan.js.map
@@ -0,0 +1,50 @@
1
+ /** Which panes a narrative screen shows, and what tapping into one does to
2
+ * that set.
3
+ *
4
+ * The visible set is `active ∪ pinned` — one derived rule with two writers
5
+ * (the tab bar's pin toggle, and every place that changes the active tab), so
6
+ * it belongs somewhere both can reach. It lives in `shared/` for the same
7
+ * reason `view-url.ts` does: being window-free is what makes the invariant
8
+ * below testable without a browser, and the invariant is one a DOM test would
9
+ * never have caught anyway.
10
+ *
11
+ * Nothing here touches the URL; `viewParams` still serializes the pair.
12
+ */
13
+ import { CANONICAL_TABS } from "./view-url.js";
14
+ /** The panes rendered right now, in tab-bar order: the active one plus every
15
+ * pinned one. A single-element result is the full-width case; two or three
16
+ * split the row evenly. */
17
+ export function visiblePanes(active, pinned) {
18
+ return CANONICAL_TABS.filter((t) => t === active || pinned.has(t));
19
+ }
20
+ /** Move the keyboard to `tapped` **without taking a pane away**.
21
+ *
22
+ * Naively, selecting a pane is just `setActiveTab` — but the outgoing pane is
23
+ * only visible because it was active, so unless it is pinned on the way past
24
+ * it disappears, and the split collapses to one full-width pane. That is the
25
+ * ordinary shape of a split (you are in Chat, you pin the Terminal), so the
26
+ * naive version means tapping the terminal in a split drops Chat.
27
+ *
28
+ * Collapsing does not just surprise; it eats the tap. Pane selection runs on
29
+ * `pointerdown`, so the surviving pane doubles in width — sliding every
30
+ * control inside it sideways — before the finger lifts. The pointerup lands
31
+ * on some other element, no `click` is dispatched, and the button that was
32
+ * pressed never fires. On the terminal quick bar, whose row is centered, this
33
+ * is a quarter of the screen's travel and a keystroke silently lost.
34
+ *
35
+ * So: pin the outgoing pane. The visible set is then unchanged whenever
36
+ * `tapped` was already visible, and merely grows by `tapped` when it wasn't
37
+ * (the reveal case — `revealTerminal`). It never shrinks.
38
+ *
39
+ * Nor does the pin set. Tapping used to unpin the incoming pane too, on the
40
+ * grounds that being active already shows it — true, but a pin is the
41
+ * operator's statement, not bookkeeping: with Terminal and Files pinned,
42
+ * tapping into Files cleared Files' pin, and the next tab change dropped a
43
+ * pane the operator had asked to keep. A pin comes off only through its 📌.
44
+ */
45
+ export function selectPane(active, pinned, tapped) {
46
+ const next = new Set(pinned);
47
+ next.add(active);
48
+ return { active: tapped, pinned: next };
49
+ }
50
+ //# sourceMappingURL=pane-layout.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What a payload is allowed to *become* in a browser.
3
+ *
4
+ * Every daemon URL carries `?token=` in its query string (see
5
+ * `api-request.ts` for why it is there rather than in a header). That is
6
+ * harmless while the response is data — an `<img>`, a `fetch` — and stops
7
+ * being harmless the moment the browser makes a *document* of it: the token
8
+ * is then in the address bar, in history, and in the base URL that "Save
9
+ * Page As" resolves the page's relative links against, which is how a daemon
10
+ * token gets written into a saved file.
11
+ *
12
+ * So there is one rule, and both halves of the system have to agree on it:
13
+ * the app renders image payloads inline and hands everything else over as a
14
+ * file, and the daemon serves everything else with
15
+ * `Content-Disposition: attachment` so a payload URL that reaches a browser
16
+ * some other way still cannot become a page.
17
+ */
18
+ /** The extensions the app renders inline, with the type each is served and
19
+ * embedded as. Everything else is a file to be handed over — including
20
+ * formats a browser *could* display (`.pdf`, `.txt`, `.html`): "can the
21
+ * browser render it" is not the question, "does brane render it inline" is.
22
+ *
23
+ * One table rather than a predicate beside a lookup, because the HTML export
24
+ * needs both answers and a disagreement between them is silent — an image
25
+ * that passes the filter but misses the type is simply dropped from the
26
+ * export with nothing said. */
27
+ export const INLINE_PAYLOAD_MIME = {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ ".bmp": "image/bmp",
34
+ ".avif": "image/avif",
35
+ ".svg": "image/svg+xml",
36
+ };
37
+ /** The lowercased extension of a path, `""` when it has none. Hand-rolled
38
+ * because this module is shared with the app, where `node:path` is not. */
39
+ export function payloadExtname(path) {
40
+ const dot = path.lastIndexOf(".");
41
+ if (dot < 0)
42
+ return "";
43
+ const ext = path.slice(dot).toLowerCase();
44
+ // A dot in a *directory* name is not this file's extension.
45
+ return ext.includes("/") ? "" : ext;
46
+ }
47
+ /** Whether this payload renders inline (an image) rather than downloading. */
48
+ export function isInlinePayload(path) {
49
+ return payloadExtname(path) in INLINE_PAYLOAD_MIME;
50
+ }
51
+ /** Control characters, which a header has no way to carry: a CR would end
52
+ * it outright. Stripped rather than escaped — none of them belong in a
53
+ * filename in the first place. */
54
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
55
+ /** The `Content-Disposition` value that hands a payload over as a file.
56
+ *
57
+ * The header is a quoted-string, so a quote in a filename has to be escaped
58
+ * or it ends the parameter early; anything non-ASCII additionally
59
+ * gets the RFC 5987 `filename*` form, since the plain parameter has no
60
+ * encoding of its own. Both parameters are sent together on purpose — a
61
+ * browser that understands `filename*` prefers it, and one that doesn't
62
+ * still gets a usable ASCII name. */
63
+ export function attachmentDisposition(filename) {
64
+ // Path separators and control characters never belong in a download name;
65
+ // a payload filename comes from whoever narrated it.
66
+ const safe = filename.replace(/[\\/]/g, "_").replace(CONTROL_CHARS, "") || "download";
67
+ // Backslashes are gone with the separators above, so a quote is the only
68
+ // character left that could end the quoted string early.
69
+ const quoted = safe.replace(/"/g, '\\"');
70
+ const ascii = /^[\x20-\x7e]*$/.test(safe);
71
+ return ascii
72
+ ? `attachment; filename="${quoted}"`
73
+ : `attachment; filename="${asciiFallback(safe)}"; filename*=UTF-8''${encodeRFC5987(safe)}`;
74
+ }
75
+ /** A best-effort ASCII name for the plain `filename` parameter. */
76
+ function asciiFallback(name) {
77
+ const stripped = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
78
+ return stripped.replace(/^_+$/, "") || "download";
79
+ }
80
+ function encodeRFC5987(value) {
81
+ return encodeURIComponent(value).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
82
+ }
83
+ //# sourceMappingURL=payload-inline.js.map