brane 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,1101 @@
1
+ import { defineCommand } from "citty";
2
+ import { randomBase36 } from "../ids.js";
3
+ import { mintAgentToken } from "../db/tokens.js";
4
+ import { join, resolve } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { execFileSync } from "node:child_process";
7
+ import { readFileSync, writeFileSync, statSync, mkdirSync, chmodSync } from "node:fs";
8
+ import qrcode from "qrcode-terminal";
9
+ import { openDatabase, getBraneId, getDaemonAddress, toPortablePath, fromPortablePath } from "../db/schema.js";
10
+ import { appendEvent } from "../db/events.js";
11
+ import { getAgentAdapter } from "../agents.js";
12
+ import { getConfig } from "./config.js";
13
+ import { resolveTmux, newTmuxSession } from "../tmux.js";
14
+ import { resolveDaemonEndpoint, daemonRequest, warnIfVersionMismatch } from "../daemon/client.js";
15
+ import { buildMeldUrl, meldLapseNote, mintDeviceToken } from "./meld.js";
16
+ import { spawnedContent } from "../shared/narrative-format.js";
17
+ import { ensureSessionAffinityConfig } from "../session-affinity.js";
18
+ /** Extract passthrough args after "--" from process.argv */
19
+ function extractPassthroughArgs() {
20
+ const argv = process.argv;
21
+ // Find "--" after the "spawn" subcommand
22
+ const spawnIdx = argv.indexOf("spawn");
23
+ if (spawnIdx === -1)
24
+ return [];
25
+ const ddIdx = argv.indexOf("--", spawnIdx + 1);
26
+ if (ddIdx === -1)
27
+ return [];
28
+ return argv.slice(ddIdx + 1);
29
+ }
30
+ /** Locate an agent binary: `PATH` first, then brane's own `node_modules/.bin`
31
+ * (for dev deps like `amp` that are never installed globally). Exported so
32
+ * every launch path — spawn and revive alike — resolves agents identically;
33
+ * revive used to do its own `which` and lose the local-bin fallback. */
34
+ export function resolveAgentCommand(agent) {
35
+ try {
36
+ return execFileSync("which", [agent], { encoding: "utf-8" }).trim();
37
+ }
38
+ catch {
39
+ // Fall back to brane's own node_modules/.bin (for dev deps like amp)
40
+ const braneRoot = resolve(process.argv[1], "../..");
41
+ const localBin = join(braneRoot, "node_modules", ".bin", agent);
42
+ try {
43
+ statSync(localBin);
44
+ return localBin;
45
+ }
46
+ catch {
47
+ throw new Error(`Agent command not found: ${agent}`);
48
+ }
49
+ }
50
+ }
51
+ /** The kickoff prompt every fresh agent receives over tmux. Exported so the
52
+ * revive path sends the same words rather than a paraphrase. */
53
+ export const LIVE_LOOP_PROMPT = "Use your brane MCP server's read_protocol tool to read the 'LIVE_loop' protocol, then commence LIVE looping. All LIVE loop tools (narrate, listen, whoami, etc.) come from the brane MCP server.";
54
+ /**
55
+ * Build the bootstrap prompt for a twin — an agent spawned to continue from a
56
+ * predecessor narrative. The detailed behavior (read the inherited prefix
57
+ * through the pinned seq, multi-listen on self + predecessor, coordinate with a
58
+ * still-live predecessor) lives in the role-bound `twin` protocol; this prompt
59
+ * just points the agent at it. `pinnedRef` is the predecessor in `nid:seq` form.
60
+ */
61
+ function buildTwinPrompt(pinnedRef) {
62
+ return (`You are a twin: a brane agent spawned to continue from predecessor narrative ${pinnedRef}. ` +
63
+ `Use your brane MCP server's read_protocol tool to read the 'LIVE_loop' protocol, then read_protocol("twin") and follow it. ` +
64
+ `Your predecessor and the seq you were forked at are recorded in your own 'started' event's re (${pinnedRef}). ` +
65
+ `All LIVE loop tools (narrate, listen, whoami, etc.) come from the brane MCP server.`);
66
+ }
67
+ /** Resolve the brane CLI path — prefer compiled JS over TS source.
68
+ * Returns { command, args } for running `brane mcp`. */
69
+ function resolveBraneMcpCommand() {
70
+ let branePath = resolve(process.argv[1]);
71
+ if (branePath.endsWith(".ts")) {
72
+ const compiledPath = branePath
73
+ .replace("/src/", "/dist/")
74
+ .replace(/\.ts$/, ".js");
75
+ try {
76
+ readFileSync(compiledPath);
77
+ branePath = compiledPath;
78
+ }
79
+ catch {
80
+ // compiled version not found — will use tsx below
81
+ }
82
+ }
83
+ const command = branePath.endsWith(".ts") ? "npx" : "node";
84
+ const args = branePath.endsWith(".ts")
85
+ ? ["tsx", branePath, "mcp"]
86
+ : [branePath, "mcp"];
87
+ return { command, args };
88
+ }
89
+ /** Per-server MCP tool timeout requested of every agent client.
90
+ *
91
+ * `listen` is a long-poll held open as an MCP *tool call*, so the binding
92
+ * constraint on how long it can block is the **client's** per-call timeout,
93
+ * not the daemon's. When it elapses the client aborts the call itself
94
+ * (JSON-RPC -32001), which the agent sees as an error rather than a normal
95
+ * `{ timed_out: true }` return. Observed/documented defaults are low enough
96
+ * to break a long hold — codex 60s, copilot 60s, gemini 600s — so several
97
+ * agent types abort today at the daemon's 270s hold.
98
+ *
99
+ * Writing this knob is **inert on its own**: it only *permits* a longer
100
+ * hold. `LISTEN_TIMEOUT_MS` in the daemon still decides how long `listen`
101
+ * actually blocks, and must not be raised for an agent type until that
102
+ * agent has been *observed* honoring the knob. A config field is a request;
103
+ * the client honoring it is a fact. (Copilot 1.0.55 accepted `timeout` and
104
+ * aborted at 60s anyway.)
105
+ *
106
+ * Finite on purpose. Some clients treat 0 as "disable the abort entirely",
107
+ * which would let a wedged daemon hang an agent forever with no recovery. */
108
+ const MCP_TOOL_TIMEOUT_MS = 3_600_000;
109
+ const MCP_TOOL_TIMEOUT_SEC = MCP_TOOL_TIMEOUT_MS / 1000;
110
+ /** Ensure .mcp.json in the fold directory includes the brane MCP server.
111
+ * Narrative ID is NOT stored here — it's passed via tmux session env.
112
+ * `alwaysLoad: true` (Claude Code ≥ 2.1.121) bypasses the deferred-tool /
113
+ * ToolSearch flow so brane's tools are present from turn 1. Older Claude
114
+ * Code versions ignore unknown fields. */
115
+ function ensureMcpConfig(foldPath) {
116
+ const mcpJsonPath = join(foldPath, ".mcp.json");
117
+ let config = {};
118
+ try {
119
+ config = JSON.parse(readFileSync(mcpJsonPath, "utf-8"));
120
+ }
121
+ catch {
122
+ // No existing config — start fresh
123
+ }
124
+ if (!config.mcpServers)
125
+ config.mcpServers = {};
126
+ const { command, args } = resolveBraneMcpCommand();
127
+ // Skip write if brane entry already matches (idempotent across spawns)
128
+ const existing = config.mcpServers.brane;
129
+ if (existing &&
130
+ existing.command === command &&
131
+ JSON.stringify(existing.args) === JSON.stringify(args) &&
132
+ !existing.env &&
133
+ existing.alwaysLoad === true &&
134
+ existing.timeout === MCP_TOOL_TIMEOUT_MS) {
135
+ return;
136
+ }
137
+ config.mcpServers.brane = { command, args, alwaysLoad: true, timeout: MCP_TOOL_TIMEOUT_MS };
138
+ writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2) + "\n");
139
+ }
140
+ const BRANE_HOOK_URL_PATH = "/api/hooks/tool-use";
141
+ /** Ensure .claude/settings.local.json in the fold carries the PostToolUse hook
142
+ * that reports tool calls to the daemon. Claude-only for now — hook support
143
+ * varies per agent, and an agent without it spawns exactly as before.
144
+ *
145
+ * Auth rides the tmux session env the hook process inherits (the same
146
+ * inheritance the MCP bridge uses): BRANE_NARRATIVE_ID names the narrative,
147
+ * BRANE_NARRATIVE_SECRET proves it against narratives.bind_token. The daemon
148
+ * bearer token never touches this file.
149
+ *
150
+ * The daemon URL is the one hardcoded constant (hooks have no dynamic
151
+ * resolver the way the MCP bridge does). Port drift is healed here: every
152
+ * spawn/revive recomputes the URL and rewrites the entry when it's stale —
153
+ * the same idempotent-rewrite dance the MCP configs already do.
154
+ *
155
+ * The matcher captures everything, narrate and listen included. It once
156
+ * excluded them as "the narrative recording itself" (claude-xlolbv6l:57),
157
+ * but that traded away the two calls that carry the loop's own shape: a
158
+ * `listen` is how long the agent stood idle, and a foreign `narrate` is a
159
+ * message to another narrative that is otherwise invisible from this one.
160
+ * Both are worth showing, and neither can be reconstructed if the hook
161
+ * never fired — so the exclusion moves to render, where it is reversible
162
+ * (claude-vhkt2ogz:5, claude-mvpe4t45:3). Self-narrate is captured and
163
+ * dropped by the client, since the event it produced is already on screen.
164
+ *
165
+ * Both ends of every call are hooked. PostToolUse alone can only report a
166
+ * call once it's over, so a long one leaves the chat silent while the
167
+ * terminal is visibly busy (claude-mvpe4t45:37). PreToolUse names the tool as
168
+ * it starts, and the row fills in when the call returns — the same shape as
169
+ * an agent showing "calling narrate" before it actually invokes it
170
+ * (claude-mvpe4t45:46). */
171
+ /** The URL a hook process should POST a tool call to, or null when there's no
172
+ * running daemon to point it at.
173
+ *
174
+ * When TLS is active the main port speaks HTTPS with a cert for the tailscale
175
+ * hostname, useless for 127.0.0.1 — hooks target the HTTP fallback port
176
+ * instead. Without TLS the main port is plain HTTP; the daemon_http_port row
177
+ * may still exist then (it survives shutdown as the next TLS run's stability
178
+ * hint), so gate on the live daemon_tls flag rather than the row's presence. */
179
+ function braneHookUrl(db) {
180
+ const info = getDaemonAddress(db);
181
+ if (!info)
182
+ return null;
183
+ const tlsRow = db
184
+ .prepare("SELECT value FROM meta WHERE key = 'daemon_tls'")
185
+ .get();
186
+ const httpPortRow = db
187
+ .prepare("SELECT value FROM meta WHERE key = 'daemon_http_port'")
188
+ .get();
189
+ const port = tlsRow?.value === "true" && httpPortRow ? httpPortRow.value : info.port;
190
+ return `http://127.0.0.1:${port}${BRANE_HOOK_URL_PATH}`;
191
+ }
192
+ export function ensureClaudeHookConfig(db, foldPath) {
193
+ const url = braneHookUrl(db);
194
+ if (!url)
195
+ return; // No running daemon to point the hook at — degrade silently.
196
+ const settingsDir = join(foldPath, ".claude");
197
+ const settingsPath = join(settingsDir, "settings.local.json");
198
+ let config = {};
199
+ try {
200
+ config = JSON.parse(readFileSync(settingsPath, "utf-8"));
201
+ }
202
+ catch {
203
+ // No existing settings — start fresh
204
+ }
205
+ const desired = {
206
+ // Match-all as a regex, not `"*"`: the vendor compiles this string and
207
+ // partial-matches with it (the previous lookahead-only pattern depended on
208
+ // exactly that), and `"*"` is not a valid regex on its own.
209
+ matcher: ".*",
210
+ hooks: [
211
+ {
212
+ type: "http",
213
+ url,
214
+ timeout: 10,
215
+ headers: {
216
+ "X-Brane-Narrative-Id": "$BRANE_NARRATIVE_ID",
217
+ "X-Brane-Narrative-Secret": "$BRANE_NARRATIVE_SECRET",
218
+ },
219
+ allowedEnvVars: ["BRANE_NARRATIVE_ID", "BRANE_NARRATIVE_SECRET"],
220
+ },
221
+ ],
222
+ };
223
+ if (!config.hooks)
224
+ config.hooks = {};
225
+ let changed = false;
226
+ for (const event of ["PreToolUse", "PostToolUse"]) {
227
+ if (!Array.isArray(config.hooks[event]))
228
+ config.hooks[event] = [];
229
+ const entries = config.hooks[event];
230
+ // This file is genuinely the user's — merge around their entries, own only
231
+ // ours, identified by the URL path.
232
+ const idx = entries.findIndex((e) => e?.hooks?.some((h) => typeof h?.url === "string" && h.url.endsWith(BRANE_HOOK_URL_PATH)));
233
+ if (idx !== -1 && JSON.stringify(entries[idx]) === JSON.stringify(desired))
234
+ continue;
235
+ if (idx !== -1)
236
+ entries[idx] = desired;
237
+ else
238
+ entries.push(desired);
239
+ changed = true;
240
+ }
241
+ // Leave the user's file untouched when both entries are already current —
242
+ // this runs on every spawn and revive.
243
+ if (!changed)
244
+ return;
245
+ mkdirSync(settingsDir, { recursive: true });
246
+ writeFileSync(settingsPath, JSON.stringify(config, null, 2) + "\n");
247
+ }
248
+ /** The hook script every non-Claude harness runs.
249
+ *
250
+ * Claude Code has an HTTP hook type that does the POST and the env
251
+ * interpolation for us — that's the `type: "http"` entry above. goose, cline
252
+ * and crush all execute *commands* instead, so for them the hook is this
253
+ * script: read the vendor's JSON payload on stdin, hand it to the daemon,
254
+ * say nothing.
255
+ *
256
+ * Three properties are load-bearing:
257
+ *
258
+ * - **It always exits 0.** In all three harnesses a hook's exit code is a
259
+ * verdict on the tool call: exit 2 blocks it in goose and crush, and crush
260
+ * reads 49 as "halt the whole turn". curl's own failure codes are in that
261
+ * space (7 for a refused connection, 28 for a timeout), so letting curl's
262
+ * status escape would eventually let a daemon restart cancel an agent's
263
+ * tool call. Recording is a bystander; it never gets a vote.
264
+ * - **It writes nothing to stdout.** cline parses a hook's stdout as JSON
265
+ * control output, so a stray byte reads as a malformed directive.
266
+ * - **Auth comes from the environment.** `BRANE_NARRATIVE_ID` and
267
+ * `BRANE_NARRATIVE_SECRET` are set on the tmux session (see braneTmuxEnv),
268
+ * and all three harnesses hand their whole environment to hook commands —
269
+ * the same inheritance the MCP bridge rides. The daemon's bearer token is
270
+ * never written to disk. */
271
+ function braneHookScript(url) {
272
+ return `#!/bin/sh
273
+ # Written by brane. Reports one tool call to the daemon, then gets out of the way.
274
+ # Rewritten on every spawn and revive — edits here will not survive.
275
+ curl -sS -m 10 -X POST '${url}' \\
276
+ -H "X-Brane-Narrative-Id: \${BRANE_NARRATIVE_ID}" \\
277
+ -H "X-Brane-Narrative-Secret: \${BRANE_NARRATIVE_SECRET}" \\
278
+ -H 'Content-Type: application/json' \\
279
+ --data-binary @- >/dev/null 2>&1
280
+ # Never let curl's exit code become a verdict on the tool call.
281
+ exit 0
282
+ `;
283
+ }
284
+ /** Write the hook script, idempotently, and return its path.
285
+ *
286
+ * Same contract as the config writers: rewritten whenever it's stale (the
287
+ * daemon's port moves), left alone when it isn't, since this runs on every
288
+ * spawn and revive.
289
+ *
290
+ * The chmod is separate from the write, and not redundant: `writeFileSync`'s
291
+ * `mode` applies only when it *creates* the file. Every rewrite after the
292
+ * first — a port drift, a change to the script itself — lands on an existing
293
+ * file and silently ignores the mode, so a script that ever lost its execute
294
+ * bit would never get it back, and the harness would simply stop being able to
295
+ * run the hook. */
296
+ function writeBraneHookScript(dir, name, url) {
297
+ const path = join(dir, name);
298
+ const script = braneHookScript(url);
299
+ try {
300
+ if (readFileSync(path, "utf-8") === script && (statSync(path).mode & 0o111) === 0o111) {
301
+ return path;
302
+ }
303
+ }
304
+ catch {
305
+ // Not written yet, or unreadable — write it.
306
+ }
307
+ mkdirSync(dir, { recursive: true });
308
+ writeFileSync(path, script, { mode: 0o755 });
309
+ chmodSync(path, 0o755);
310
+ return path;
311
+ }
312
+ /** Ensure a goose plugin in the fold reports tool calls to the daemon.
313
+ *
314
+ * goose implements the Open Plugins hooks spec: a plugin directory holding
315
+ * `hooks/hooks.json`, discovered under `<fold>/.agents/plugins/*` when goose
316
+ * starts in that project (both strings are in the shipped binary). Events map
317
+ * to rules, rules to shell commands, and the payload arrives as JSON on stdin.
318
+ *
319
+ * `PostToolUseFailure` is listed alongside the other two because goose reports
320
+ * a failed call as its own event rather than as a field — without it a failing
321
+ * tool would sit `pending` forever, since the plain `PostToolUse` never fires.
322
+ *
323
+ * Unlike cline's, this file lives in the operator's fold. It is brane's own
324
+ * plugin directory rather than a shared config file, so owning it outright is
325
+ * honest — but it is still rewritten only when stale. */
326
+ export function ensureGooseHookConfig(db, foldPath) {
327
+ const url = braneHookUrl(db);
328
+ if (!url)
329
+ return;
330
+ const pluginDir = join(foldPath, ".agents", "plugins", "brane");
331
+ const hooksDir = join(pluginDir, "hooks");
332
+ const scriptPath = writeBraneHookScript(hooksDir, "report-tool-use.sh", url);
333
+ const rule = {
334
+ // Regex over the tool name, matching everything — including the loop's own
335
+ // narrate and listen, for the reasons in ensureClaudeHookConfig.
336
+ matcher: ".*",
337
+ hooks: [{ type: "command", command: scriptPath, timeout: 10 }],
338
+ };
339
+ const desired = {
340
+ hooks: {
341
+ PreToolUse: [rule],
342
+ PostToolUse: [rule],
343
+ PostToolUseFailure: [rule],
344
+ },
345
+ };
346
+ const configPath = join(hooksDir, "hooks.json");
347
+ const serialized = JSON.stringify(desired, null, 2) + "\n";
348
+ try {
349
+ if (readFileSync(configPath, "utf-8") === serialized)
350
+ return;
351
+ }
352
+ catch {
353
+ // Not written yet — write it.
354
+ }
355
+ mkdirSync(hooksDir, { recursive: true });
356
+ writeFileSync(configPath, serialized);
357
+ }
358
+ /** Ensure a brane-spawned cline session reports tool calls to the daemon.
359
+ *
360
+ * cline discovers hooks as *executable files named after the event*, and its
361
+ * hooks directory follows `--config` — which brane already sets to a private
362
+ * per-narrative directory (see ensureClineMcpConfig). So unlike goose and
363
+ * claude, this touches nothing the operator owns: the scripts live beside the
364
+ * MCP settings brane already writes there, and a session that brane didn't
365
+ * spawn is unaffected.
366
+ *
367
+ * `/tmp` is boot-volatile on macOS, which costs nothing here — the rows are in
368
+ * brane's database, and the next spawn or revive rewrites the scripts. */
369
+ export function ensureClineHookConfig(db, narrativeId) {
370
+ const url = braneHookUrl(db);
371
+ if (!url)
372
+ return;
373
+ const hooksDir = join("/tmp", `brane-cline-${narrativeId}`, "hooks");
374
+ // Both phases run the same script — the payload names which one it is, and
375
+ // the daemon reads that (normalizeHookPayload) rather than the filename.
376
+ for (const event of ["PreToolUse", "PostToolUse"]) {
377
+ writeBraneHookScript(hooksDir, event, url);
378
+ }
379
+ }
380
+ /** Install whatever tool-call capture the agent supports, or nothing.
381
+ *
382
+ * Optional capability, always: a failure to write hook config must never fail
383
+ * a spawn (claude-xlolbv6l:84), and an agent with no hook support launches
384
+ * exactly as it did before. crush is absent on purpose — it has a PreToolUse
385
+ * hook but no PostToolUse event at all, and its pre payload carries no call id
386
+ * to pair the two halves with, so its completions come from its session log
387
+ * instead. */
388
+ export function ensureHookConfig(db, agent, foldPath, narrativeId) {
389
+ try {
390
+ if (agent === "claude")
391
+ ensureClaudeHookConfig(db, foldPath);
392
+ else if (agent === "goose")
393
+ ensureGooseHookConfig(db, foldPath);
394
+ else if (agent === "cline")
395
+ ensureClineHookConfig(db, narrativeId);
396
+ }
397
+ catch (err) {
398
+ console.error(`Warning: could not write hook config: ${err}`);
399
+ }
400
+ }
401
+ /** Build the tmux env-var prefix that hands a session its identity and its
402
+ * agent token. The bridge presents the token as its bearer on /ws/mcp (and
403
+ * as `&secret=`), where the daemon checks it by hash against the
404
+ * narrative's live agent token; the session's `brane` CLI and hooks present
405
+ * it too. Tokens are UUIDs, shell-safe, so no escaping is needed. Omitted
406
+ * only when the caller passes none: every launch mints one first. The line
407
+ * reaches tmux on stdin (`newTmuxSession`), never in a process's argv. */
408
+ export function braneTmuxEnv(narrativeId, bindToken) {
409
+ const secret = bindToken ? ` BRANE_NARRATIVE_SECRET=${bindToken}` : "";
410
+ return `BRANE_NARRATIVE_ID=${narrativeId}${secret}`;
411
+ }
412
+ /** The secret a launch hands its session as `BRANE_NARRATIVE_SECRET`: a
413
+ * fresh agent token for the narrative (`mintAgentToken`, src/db/tokens.ts),
414
+ * which revokes whatever token its previous launch held. It is what the
415
+ * session's MCP bridge, hooks and `brane` CLI present, and the daemon keeps
416
+ * only its hash — nothing about it is written to `narratives`. Called once
417
+ * per launch: spawn, revive, and an agent switch. */
418
+ export function ensureBindToken(db, narrativeId) {
419
+ return mintAgentToken(db, narrativeId);
420
+ }
421
+ /** Build the --with-extension string for goose's inline MCP configuration.
422
+ * Format: "BRANE_NARRATIVE_ID=<nid> [BRANE_NARRATIVE_SECRET=<tok>] command args..."
423
+ * Goose runs the bridge with this inline env, so the secret must ride here
424
+ * rather than relying on the surrounding tmux shell env. */
425
+ export function buildGooseExtensionArg(narrativeId, bindToken) {
426
+ const { command, args } = resolveBraneMcpCommand();
427
+ return `${braneTmuxEnv(narrativeId, bindToken)} ${command} ${args.join(" ")}`;
428
+ }
429
+ /** Ensure .gemini/settings.json in the fold directory includes the brane MCP server.
430
+ * Gemini reads project-level MCP config from .gemini/settings.json. */
431
+ function ensureGeminiMcpConfig(foldPath) {
432
+ const geminiDir = join(foldPath, ".gemini");
433
+ const settingsPath = join(geminiDir, "settings.json");
434
+ let config = {};
435
+ try {
436
+ config = JSON.parse(readFileSync(settingsPath, "utf-8"));
437
+ }
438
+ catch {
439
+ // No existing config — start fresh
440
+ }
441
+ if (!config.mcpServers)
442
+ config.mcpServers = {};
443
+ const { command, args } = resolveBraneMcpCommand();
444
+ // Skip write if brane entry already matches
445
+ const existing = config.mcpServers.brane;
446
+ if (existing &&
447
+ existing.command === command &&
448
+ JSON.stringify(existing.args) === JSON.stringify(args) &&
449
+ !existing.env &&
450
+ existing.timeout === MCP_TOOL_TIMEOUT_MS) {
451
+ return;
452
+ }
453
+ // Gemini's default per-tool timeout is 600s — below a long `listen` hold.
454
+ config.mcpServers.brane = { command, args, timeout: MCP_TOOL_TIMEOUT_MS };
455
+ // Ensure .gemini directory exists
456
+ try {
457
+ execFileSync("mkdir", ["-p", geminiDir], { stdio: "ignore" });
458
+ }
459
+ catch { }
460
+ writeFileSync(settingsPath, JSON.stringify(config, null, 2) + "\n");
461
+ }
462
+ /** Replace a TOML table (header line through the line before the next table
463
+ * header, or EOF) with `replacement`; append if the table is absent.
464
+ *
465
+ * Line-anchored on purpose. A naive /\[header\][^\[]*(?=\[|$)/s stops at the
466
+ * first `[` *anywhere*, including inside a value like `args = ["mcp"]`, which
467
+ * truncates the match mid-section and leaves the tail (`["mcp"]\nenv_vars…`)
468
+ * orphaned in the file as a bogus table header. Table headers only ever start
469
+ * at column 0, so match on that. */
470
+ export function replaceTomlTable(source, header, replacement) {
471
+ const lines = source.split("\n");
472
+ const start = lines.findIndex((l) => l.trimEnd() === header);
473
+ if (start === -1) {
474
+ return source.trimEnd() + "\n\n" + replacement;
475
+ }
476
+ let end = lines.length;
477
+ for (let i = start + 1; i < lines.length; i++) {
478
+ if (/^\[[^\]]*\]\s*$/.test(lines[i])) {
479
+ end = i;
480
+ break;
481
+ }
482
+ }
483
+ const merged = [
484
+ ...lines.slice(0, start),
485
+ ...replacement.replace(/\n+$/, "").split("\n"),
486
+ "",
487
+ ...lines.slice(end),
488
+ ];
489
+ return merged.join("\n");
490
+ }
491
+ /** Ensure global ~/.codex/config.toml includes the brane MCP server.
492
+ * Project-level config requires trust, so we write to the global config.
493
+ * Uses env_vars to pass BRANE_NARRATIVE_ID from parent env to MCP servers. */
494
+ function ensureCodexMcpConfig() {
495
+ const globalConfigPath = join(process.env.HOME || "/tmp", ".codex", "config.toml");
496
+ let existing = "";
497
+ try {
498
+ existing = readFileSync(globalConfigPath, "utf-8");
499
+ }
500
+ catch {
501
+ return; // No global config — codex may not be installed
502
+ }
503
+ const { command, args } = resolveBraneMcpCommand();
504
+ // codex's default per-tool timeout is 60s — well below the daemon's `listen`
505
+ // hold, so an idle codex agent's long-poll aborts client-side today.
506
+ const braneSection = [
507
+ `[mcp_servers.brane]`,
508
+ `command = "${command}"`,
509
+ `args = ${JSON.stringify(args)}`,
510
+ `env_vars = ["BRANE_NARRATIVE_ID", "BRANE_NARRATIVE_SECRET"]`,
511
+ `tool_timeout_sec = ${MCP_TOOL_TIMEOUT_SEC}`,
512
+ ``,
513
+ ].join("\n");
514
+ // Check if brane MCP server is already configured
515
+ if (existing.includes("[mcp_servers.brane]")) {
516
+ // Verify command, args, timeout, and secret passthrough match — if so, skip.
517
+ // BRANE_NARRATIVE_SECRET in the check forces a rewrite of pre-bind-token
518
+ // configs so codex forwards the secret env var to the bridge.
519
+ if (existing.includes(`command = "${command}"`) &&
520
+ existing.includes("BRANE_NARRATIVE_SECRET") &&
521
+ existing.includes(`tool_timeout_sec = ${MCP_TOOL_TIMEOUT_SEC}`)) {
522
+ return;
523
+ }
524
+ // Replace existing brane table (header through the next table header / EOF)
525
+ writeFileSync(globalConfigPath, replaceTomlTable(existing, "[mcp_servers.brane]", braneSection));
526
+ }
527
+ else {
528
+ // Append brane MCP server section
529
+ writeFileSync(globalConfigPath, existing.trimEnd() + "\n\n" + braneSection);
530
+ }
531
+ }
532
+ /** Ensure a per-session MCP config JSON file for Copilot.
533
+ * Copilot does NOT inherit parent env vars into MCP subprocesses,
534
+ * so BRANE_NARRATIVE_ID must be explicit in the config's env field.
535
+ * Returns the absolute path to the config file. */
536
+ function ensureCopilotMcpConfig(_foldPath, narrativeId, bindToken) {
537
+ const configPath = join("/tmp", `brane-copilot-mcp-${narrativeId}.json`);
538
+ const { command, args } = resolveBraneMcpCommand();
539
+ // copilot's default per-tool timeout is 60s. `timeout` is nominally honored
540
+ // since copilot-cli#172 (closed 2026-04-06), but a post-fix report on 1.0.55
541
+ // set it correctly and still aborted at 60s — so treat this as a request,
542
+ // not a guarantee, and do not raise copilot's `listen` hold until measured.
543
+ const config = {
544
+ mcpServers: {
545
+ brane: {
546
+ type: "stdio",
547
+ command,
548
+ args,
549
+ env: {
550
+ BRANE_NARRATIVE_ID: narrativeId,
551
+ ...(bindToken ? { BRANE_NARRATIVE_SECRET: bindToken } : {}),
552
+ },
553
+ timeout: MCP_TOOL_TIMEOUT_MS,
554
+ },
555
+ },
556
+ };
557
+ // 0600: it holds the session's agent token, and /tmp is shared.
558
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
559
+ chmodSync(configPath, 0o600);
560
+ return configPath;
561
+ }
562
+ /** Ensure .amp/settings.json in the fold directory includes the brane MCP server.
563
+ * Amp reads workspace-level MCP config from .amp/settings.json using "amp.mcpServers" key. */
564
+ function ensureAmpMcpConfig(foldPath) {
565
+ const ampDir = join(foldPath, ".amp");
566
+ const settingsPath = join(ampDir, "settings.json");
567
+ let config = {};
568
+ try {
569
+ config = JSON.parse(readFileSync(settingsPath, "utf-8"));
570
+ }
571
+ catch {
572
+ // No existing config — start fresh
573
+ }
574
+ const mcpServers = (config["amp.mcpServers"] ?? {});
575
+ const { command, args } = resolveBraneMcpCommand();
576
+ // Skip write if brane entry already matches
577
+ const existing = mcpServers.brane;
578
+ if (existing &&
579
+ existing.command === command &&
580
+ JSON.stringify(existing.args) === JSON.stringify(args) &&
581
+ !existing.env) {
582
+ return;
583
+ }
584
+ mcpServers.brane = { command, args };
585
+ config["amp.mcpServers"] = mcpServers;
586
+ // Ensure .amp directory exists
587
+ try {
588
+ execFileSync("mkdir", ["-p", ampDir], { stdio: "ignore" });
589
+ }
590
+ catch { }
591
+ writeFileSync(settingsPath, JSON.stringify(config, null, 2) + "\n");
592
+ }
593
+ /** Ensure a per-session cline config directory with MCP settings.
594
+ * Cline doesn't inherit parent env vars into MCP subprocesses,
595
+ * so BRANE_NARRATIVE_ID must be explicit in the config's env field.
596
+ * Auth files (secrets.json, globalState.json) are symlinked from
597
+ * the default ~/.cline/data/ directory.
598
+ * Returns the path to the config directory (for --config flag). */
599
+ function ensureClineMcpConfig(narrativeId, bindToken) {
600
+ const configDir = join("/tmp", `brane-cline-${narrativeId}`);
601
+ const dataDir = join(configDir, "data");
602
+ const settingsDir = join(dataDir, "settings");
603
+ const settingsPath = join(settingsDir, "cline_mcp_settings.json");
604
+ const { command, args } = resolveBraneMcpCommand();
605
+ const config = {
606
+ mcpServers: {
607
+ brane: {
608
+ command,
609
+ args,
610
+ env: {
611
+ BRANE_NARRATIVE_ID: narrativeId,
612
+ ...(bindToken ? { BRANE_NARRATIVE_SECRET: bindToken } : {}),
613
+ },
614
+ // cline's per-tool timeout is in seconds, default 60 — below the
615
+ // daemon's `listen` hold, and three consecutive aborted listens trip
616
+ // cline's "max consecutive mistakes" breaker, ending the agent's turn.
617
+ timeout: MCP_TOOL_TIMEOUT_SEC,
618
+ },
619
+ },
620
+ };
621
+ // Ensure directories exist
622
+ try {
623
+ execFileSync("mkdir", ["-p", settingsDir], { stdio: "ignore" });
624
+ }
625
+ catch { }
626
+ // 0600 and a 0700 directory: it holds the session's agent token, and /tmp
627
+ // is shared.
628
+ writeFileSync(settingsPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
629
+ chmodSync(settingsPath, 0o600);
630
+ chmodSync(configDir, 0o700);
631
+ // Symlink auth files from default cline config. settings/providers.json is
632
+ // the v3 provider store (API keys, base URLs) — without it, API-key providers
633
+ // resolve to their default endpoints and only OAuth providers survive.
634
+ const defaultDataDir = join(process.env.HOME || "/tmp", ".cline", "data");
635
+ for (const file of [
636
+ "secrets.json",
637
+ "globalState.json",
638
+ "settings/providers.json",
639
+ "settings/models.json",
640
+ ]) {
641
+ const target = join(defaultDataDir, file);
642
+ const link = join(dataDir, file);
643
+ try {
644
+ statSync(target);
645
+ // Remove existing link/file before creating symlink
646
+ try {
647
+ execFileSync("rm", ["-f", link], { stdio: "ignore" });
648
+ }
649
+ catch { }
650
+ execFileSync("ln", ["-s", target, link], { stdio: "ignore" });
651
+ }
652
+ catch {
653
+ // Source file doesn't exist — skip
654
+ }
655
+ }
656
+ return configDir;
657
+ }
658
+ /** Re-establish a flag-based agent's per-session MCP config and return the
659
+ * launch flags that point the agent at it. Copilot and Cline read their
660
+ * brane MCP server from a config file passed on the command line, not from
661
+ * a persistent file in the fold — so a same-agent revive has to recreate it
662
+ * the way spawn's buildAgentArgs does. File-based agents (claude/gemini/
663
+ * codex/amp/crush) persist their config in the fold and need nothing here,
664
+ * so this returns []. */
665
+ export function reestablishMcpConfigArgs(agent, absoluteFoldPath, narrativeId, bindToken) {
666
+ if (agent === "copilot") {
667
+ return ["--additional-mcp-config", `@${ensureCopilotMcpConfig(absoluteFoldPath, narrativeId, bindToken)}`];
668
+ }
669
+ if (agent === "cline") {
670
+ return ["--config", ensureClineMcpConfig(narrativeId, bindToken)];
671
+ }
672
+ return [];
673
+ }
674
+ /** Ensure .crush.json in the fold directory includes the brane MCP server.
675
+ * Crush reads MCP config from .crush.json using an "mcp" key. */
676
+ function ensureCrushMcpConfig(foldPath) {
677
+ const configPath = join(foldPath, ".crush.json");
678
+ let config = {};
679
+ try {
680
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
681
+ }
682
+ catch {
683
+ // No existing config — start fresh
684
+ }
685
+ const mcp = (config.mcp ?? {});
686
+ const { command, args } = resolveBraneMcpCommand();
687
+ // Skip write if brane entry already matches
688
+ const existing = mcp.brane;
689
+ if (existing &&
690
+ existing.command === command &&
691
+ JSON.stringify(existing.args) === JSON.stringify(args) &&
692
+ !existing.env &&
693
+ existing.timeout === MCP_TOOL_TIMEOUT_SEC) {
694
+ return;
695
+ }
696
+ // crush's per-server MCP timeout is in seconds; without it the client cap
697
+ // sits below a long daemon `listen` hold.
698
+ mcp.brane = { type: "stdio", command, args, timeout: MCP_TOOL_TIMEOUT_SEC };
699
+ config.mcp = mcp;
700
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
701
+ }
702
+ /** The model flag as this agent spells it, or nothing for agents without one.
703
+ * Split out of `buildAgentArgs` because the lifecycle line shows it too: the
704
+ * operator's model choice is part of the invocation a reader should see. */
705
+ export function modelArgs(agent, model) {
706
+ if (!model)
707
+ return [];
708
+ switch (agent) {
709
+ case "claude":
710
+ case "codex":
711
+ return ["--model", model];
712
+ case "gemini":
713
+ case "cline":
714
+ case "crush":
715
+ return ["-m", model];
716
+ default:
717
+ return [];
718
+ }
719
+ }
720
+ export function buildAgentArgs(agent, opts) {
721
+ const args = [];
722
+ if (agent === "claude") {
723
+ args.push(...modelArgs(agent, opts.model));
724
+ // The daemon owns the claude session id: it mints a uuid and pins it here
725
+ // via `--session-id` so the value on the narrative row (written at INSERT)
726
+ // is authoritative, rather than relying on the bridge to self-detect and
727
+ // report one. Claude exports it as CLAUDE_CODE_SESSION_ID for the bridge.
728
+ if (opts.sessionId) {
729
+ args.push("--session-id", opts.sessionId);
730
+ }
731
+ }
732
+ else if (agent === "goose") {
733
+ args.push("session");
734
+ args.push("--with-builtin", "developer");
735
+ if (opts.narrativeId) {
736
+ args.push("--with-extension", buildGooseExtensionArg(opts.narrativeId, opts.bindToken));
737
+ args.push("-n", opts.narrativeId);
738
+ }
739
+ }
740
+ else if (agent === "gemini") {
741
+ args.push(...modelArgs(agent, opts.model));
742
+ }
743
+ else if (agent === "codex") {
744
+ args.push("--full-auto", "--no-alt-screen");
745
+ args.push("-c", "check_for_update_on_startup=false");
746
+ args.push("-c", 'project_root_markers=[".codex", ".git"]');
747
+ args.push(...modelArgs(agent, opts.model));
748
+ }
749
+ else if (agent === "amp") {
750
+ // Amp is an interactive TUI — no special args needed
751
+ // MCP config is read from .amp/settings.json
752
+ }
753
+ else if (agent === "copilot") {
754
+ if (opts.sessionId) {
755
+ args.push("--session-id", opts.sessionId);
756
+ }
757
+ if (opts.mcpConfigPath) {
758
+ args.push("--additional-mcp-config", `@${opts.mcpConfigPath}`);
759
+ }
760
+ }
761
+ else if (agent === "cline") {
762
+ args.push("-y");
763
+ if (opts.mcpConfigPath) {
764
+ args.push("--config", opts.mcpConfigPath);
765
+ }
766
+ args.push(...modelArgs(agent, opts.model));
767
+ }
768
+ else if (agent === "crush") {
769
+ args.push("-y");
770
+ args.push(...modelArgs(agent, opts.model));
771
+ }
772
+ return args;
773
+ }
774
+ /** Core spawn logic, usable from both CLI and daemon API. */
775
+ export function doSpawn(opts) {
776
+ const { db, agent, model, re, extraArgs } = opts;
777
+ // Validate agent name: alphanumeric, hyphens, underscores only
778
+ if (!/^[a-zA-Z0-9_-]+$/.test(agent)) {
779
+ throw new Error(`Invalid agent name: ${agent}`);
780
+ }
781
+ // A spawn with `re` is a twin: it continues from a predecessor narrative and
782
+ // is governed by the role-bound `twin` protocol. Force role "twin" (overriding
783
+ // any caller-supplied role) so the agent auto-reads that protocol on startup.
784
+ const role = re ? "twin" : (opts.role ?? "worker");
785
+ // Pin the predecessor reference to a specific seq so the fork point is
786
+ // explicit and durable: the predecessor may still be live and append events
787
+ // past it. If the caller already supplied a `nid:seq` ref, trust it; otherwise
788
+ // resolve the predecessor's current MAX(seq) here (atomic, race-free).
789
+ let pinnedRe;
790
+ if (re) {
791
+ if (re.includes(":")) {
792
+ pinnedRe = re;
793
+ }
794
+ else {
795
+ const row = db
796
+ .prepare("SELECT MAX(seq) as max_seq FROM events WHERE narrative_id = ?")
797
+ .get(re);
798
+ pinnedRe = `${re}:${row.max_seq ?? 0}`;
799
+ }
800
+ }
801
+ const foldPath = opts.foldPath;
802
+ const absoluteFoldPath = fromPortablePath(foldPath);
803
+ // Verify fold is registered and inherit its brane_id (if set)
804
+ const fold = db.prepare("SELECT path, brane_id FROM folds WHERE path = ?").get(foldPath);
805
+ if (!fold) {
806
+ throw new Error(`Not a fold: ${foldPath}. Run \`brane fold\` first.`);
807
+ }
808
+ const braneId = fold.brane_id ?? getConfig("default.brane", db) ?? getBraneId(db);
809
+ // Resolve agent command
810
+ const agentCommand = resolveAgentCommand(agent);
811
+ // Generate narrative ID
812
+ const uniqueId = randomBase36(8);
813
+ const narrativeId = `${agent}-${uniqueId}`;
814
+ // Per-narrative bind secret: the bridge presents it on /ws/mcp so the daemon
815
+ // can verify this connection genuinely owns `narrativeId` before trusting it.
816
+ // The daemon owns the session id for agents that let it *assign* one: mint it
817
+ // here and pass it via `--session-id` (below) so the value recorded on the row
818
+ // is authoritative and never contested by a self-detecting client. Keyed off
819
+ // the adapter capability, not the agent name — "assign" agents (currently only
820
+ // claude) mint at spawn; "capture"/none agents get no id here (a capture agent
821
+ // has its self-generated id recorded later; see the daemon's capture path).
822
+ const sessionId = getAgentAdapter(agent).sessionIdOwnership === "assign" ? randomUUID() : null;
823
+ // Record narrative in db — persist launch_args so revive can reuse them
824
+ const launchArgsJson = extraArgs && extraArgs.length > 0 ? JSON.stringify(extraArgs) : null;
825
+ db.prepare(`INSERT INTO narratives (id, brane_id, fold_path, agent_type, role, created_at, status, launch_args, bind_token, agent_session_id)
826
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(narrativeId, braneId, foldPath, agent, role, Date.now(), launchArgsJson, null, sessionId);
827
+ // The session's credential: an agent token, hashed in `tokens`, its
828
+ // plaintext only in the env built below. `bind_token` stays null.
829
+ const bindToken = mintAgentToken(db, narrativeId);
830
+ // Log started event: the invocation as the operator chose it (binary, model
831
+ // flag, passthrough args) and the role — a twin's line also names the
832
+ // predecessor, pinned in `re` so the fork point is durable.
833
+ appendEvent(db, narrativeId, "started", {
834
+ content: spawnedContent(agent, role, {
835
+ args: [...modelArgs(agent, model), ...(extraArgs ?? [])],
836
+ from: pinnedRe,
837
+ }),
838
+ ...(pinnedRe ? { re: [pinnedRe] } : {}),
839
+ });
840
+ // Update fold's last_spawned
841
+ db.prepare("UPDATE folds SET last_spawned = ? WHERE path = ?").run(Date.now(), foldPath);
842
+ // Write MCP config for agents that use file-based config
843
+ // (goose uses --with-extension inline instead)
844
+ let copilotMcpConfigPath;
845
+ if (agent === "gemini") {
846
+ ensureGeminiMcpConfig(absoluteFoldPath);
847
+ }
848
+ else if (agent === "codex") {
849
+ ensureCodexMcpConfig();
850
+ }
851
+ else if (agent === "amp") {
852
+ ensureAmpMcpConfig(absoluteFoldPath);
853
+ }
854
+ else if (agent === "copilot") {
855
+ copilotMcpConfigPath = ensureCopilotMcpConfig(absoluteFoldPath, narrativeId, bindToken);
856
+ }
857
+ else if (agent === "cline") {
858
+ copilotMcpConfigPath = ensureClineMcpConfig(narrativeId, bindToken);
859
+ }
860
+ else if (agent === "crush") {
861
+ ensureCrushMcpConfig(absoluteFoldPath);
862
+ }
863
+ else if (agent !== "goose") {
864
+ ensureMcpConfig(absoluteFoldPath);
865
+ }
866
+ ensureHookConfig(db, agent, absoluteFoldPath, narrativeId);
867
+ // Session affinity: CLI args (claude's per-narrative `--settings`) and/or an
868
+ // env prefix, both of which must reach the tmux command.
869
+ const affinity = ensureSessionAffinityConfig(agent, absoluteFoldPath, narrativeId, { db });
870
+ // Build the command to run inside tmux
871
+ const agentArgs = buildAgentArgs(agent, { model, role, narrativeId, mcpConfigPath: copilotMcpConfigPath, bindToken, sessionId: sessionId ?? undefined });
872
+ agentArgs.push(...affinity.args);
873
+ // Append caller-supplied passthrough args before the prompt (prompts are positional and must come last for claude/codex)
874
+ if (extraArgs && extraArgs.length > 0) {
875
+ agentArgs.push(...extraArgs);
876
+ }
877
+ // Pass the LIVE loop bootstrap prompt (or continuation prompt if re is set)
878
+ const prompt = pinnedRe ? buildTwinPrompt(pinnedRe) : LIVE_LOOP_PROMPT;
879
+ if (agent === "claude") {
880
+ // Claude accepts prompt as positional arg
881
+ agentArgs.push(prompt);
882
+ }
883
+ else if (agent === "codex") {
884
+ // Codex accepts prompt as positional arg (like Claude)
885
+ agentArgs.push(prompt);
886
+ }
887
+ else if (agent === "gemini") {
888
+ // Gemini uses -i for interactive mode with initial prompt
889
+ agentArgs.push("-i", prompt);
890
+ }
891
+ const agentCmd = [agentCommand, ...agentArgs]
892
+ .map((a) => (a.includes(" ") || a.includes("'") ? `'${a.replace(/'/g, "'\\''")}'` : a))
893
+ .join(" ");
894
+ // Prefix with BRANE_NARRATIVE_ID + bind secret so the MCP bridge inherits both
895
+ const tmuxCmd = [braneTmuxEnv(narrativeId, bindToken), affinity.env, agentCmd].filter(Boolean).join(" ");
896
+ // Create tmux session
897
+ try {
898
+ newTmuxSession({ name: narrativeId, cwd: absoluteFoldPath, size: opts.terminalSize, command: tmuxCmd });
899
+ }
900
+ catch (err) {
901
+ db.prepare("UPDATE narratives SET status = 'ended' WHERE id = ?").run(narrativeId);
902
+ throw new Error(`Failed to create tmux session: ${err}`);
903
+ }
904
+ // For goose/copilot: inject LIVE loop prompt via tmux send-keys (no positional prompt support)
905
+ if (agent === "goose" || agent === "copilot" || agent === "amp" || agent === "cline" || agent === "crush") {
906
+ const submitKey = agent === "copilot" ? "S-Enter" : "Enter";
907
+ setTimeout(() => {
908
+ try {
909
+ execFileSync(resolveTmux(), [
910
+ "send-keys", "-t", narrativeId,
911
+ prompt,
912
+ submitKey,
913
+ ], { stdio: "ignore" });
914
+ }
915
+ catch {
916
+ // Session may not be ready yet — prompt injection is best-effort
917
+ }
918
+ // TUI agents (cline, crush) may need a follow-up Enter after paste settles
919
+ if (agent === "cline" || agent === "crush") {
920
+ setTimeout(() => {
921
+ try {
922
+ execFileSync(resolveTmux(), ["send-keys", "-t", narrativeId, "Enter"], { stdio: "ignore" });
923
+ }
924
+ catch { }
925
+ }, 500);
926
+ }
927
+ }, 2000);
928
+ }
929
+ return { narrativeId, foldPath, agentType: agent, role };
930
+ }
931
+ /** Set up MCP config and build launch command for an agent in a fold.
932
+ * Nothing calls this: switching agents on revive goes through
933
+ * `doSpawn({ re })`, and a same-agent revive builds its own launch in
934
+ * `reviveNarrative`, which has to refresh hook and affinity config itself. */
935
+ export function prepareAgentLaunch(db, agent, narrativeId, foldPath) {
936
+ const agentCommand = resolveAgentCommand(agent);
937
+ const absoluteFoldPath = fromPortablePath(foldPath);
938
+ const bindToken = ensureBindToken(db, narrativeId);
939
+ // Set up MCP config
940
+ let copilotMcpConfigPath;
941
+ if (agent === "gemini") {
942
+ ensureGeminiMcpConfig(absoluteFoldPath);
943
+ }
944
+ else if (agent === "codex") {
945
+ ensureCodexMcpConfig();
946
+ }
947
+ else if (agent === "amp") {
948
+ ensureAmpMcpConfig(absoluteFoldPath);
949
+ }
950
+ else if (agent === "copilot") {
951
+ copilotMcpConfigPath = ensureCopilotMcpConfig(absoluteFoldPath, narrativeId, bindToken);
952
+ }
953
+ else if (agent === "cline") {
954
+ copilotMcpConfigPath = ensureClineMcpConfig(narrativeId, bindToken);
955
+ }
956
+ else if (agent === "crush") {
957
+ ensureCrushMcpConfig(absoluteFoldPath);
958
+ }
959
+ else if (agent !== "goose") {
960
+ ensureMcpConfig(absoluteFoldPath);
961
+ }
962
+ ensureHookConfig(db, agent, absoluteFoldPath, narrativeId);
963
+ const affinity = ensureSessionAffinityConfig(agent, absoluteFoldPath, narrativeId, { db });
964
+ // Build agent args (fresh spawn, no resume)
965
+ const agentArgs = buildAgentArgs(agent, {
966
+ narrativeId,
967
+ mcpConfigPath: copilotMcpConfigPath,
968
+ bindToken,
969
+ });
970
+ agentArgs.push(...affinity.args);
971
+ // Add LIVE loop prompt
972
+ if (agent === "claude" || agent === "codex") {
973
+ agentArgs.push(LIVE_LOOP_PROMPT);
974
+ }
975
+ else if (agent === "gemini") {
976
+ agentArgs.push("-i", LIVE_LOOP_PROMPT);
977
+ }
978
+ const agentCmd = [agentCommand, ...agentArgs]
979
+ .map((a) => (a.includes(" ") || a.includes("'") ? `'${a.replace(/'/g, "'\\''")}'` : a))
980
+ .join(" ");
981
+ const tmuxCmd = [braneTmuxEnv(narrativeId, bindToken), affinity.env, agentCmd].filter(Boolean).join(" ");
982
+ return { agentCommand, tmuxCmd };
983
+ }
984
+ export const spawnCommand = defineCommand({
985
+ meta: {
986
+ name: "spawn",
987
+ description: "Spawn an agent in a new tmux session",
988
+ },
989
+ args: {
990
+ agent: {
991
+ type: "positional",
992
+ required: true,
993
+ description: "Agent to spawn (e.g., claude, goose, aider, lazygit)",
994
+ },
995
+ role: {
996
+ type: "string",
997
+ default: "worker",
998
+ description: "Agent role (worker, coordinator, image-analyst)",
999
+ },
1000
+ attach: {
1001
+ type: "boolean",
1002
+ description: "Attach to session after spawning",
1003
+ },
1004
+ model: {
1005
+ type: "string",
1006
+ description: "Model hint (e.g., haiku, sonnet, opus)",
1007
+ },
1008
+ re: {
1009
+ type: "string",
1010
+ description: "Parent narrative ID for prefix memory",
1011
+ },
1012
+ meld: {
1013
+ type: "boolean",
1014
+ description: "Print a meld URL + QR code that opens the new narrative on a mobile device",
1015
+ },
1016
+ "meld-host": {
1017
+ type: "string",
1018
+ description: "Override the host advertised in the meld URL (default: Tailscale DNS > 127.0.0.1)",
1019
+ },
1020
+ "meld-local": {
1021
+ type: "boolean",
1022
+ description: "Use a direct daemon URL in the meld output instead of the shared app's",
1023
+ },
1024
+ "meld-app": {
1025
+ type: "string",
1026
+ description: "The shared app's host for the meld output (default: the `app_domain` config)",
1027
+ },
1028
+ },
1029
+ async run({ args }) {
1030
+ const foldPath = toPortablePath(resolve(process.cwd()));
1031
+ const extraArgs = extractPassthroughArgs();
1032
+ try {
1033
+ // Spawning goes through the daemon, exactly as the web app's spawn
1034
+ // button does — `POST /api/spawn` is the single spawn path, and the CLI
1035
+ // is one of its clients rather than a second implementation. Everything
1036
+ // below this call is terminal-only presentation (QR code, attach).
1037
+ const endpoint = await resolveDaemonEndpoint();
1038
+ warnIfVersionMismatch(endpoint);
1039
+ // Minted before the spawn: from this machine it waits for the presence
1040
+ // check, and a declined prompt should leave nothing running.
1041
+ const deviceToken = args.meld ? await mintDeviceToken(endpoint) : undefined;
1042
+ const { narrative } = await daemonRequest(endpoint, "/api/spawn", {
1043
+ method: "POST",
1044
+ body: {
1045
+ agent: args.agent,
1046
+ fold_path: foldPath,
1047
+ role: args.role,
1048
+ ...(args.model ? { model: args.model } : {}),
1049
+ ...(args.re ? { re: args.re } : {}),
1050
+ ...(extraArgs.length > 0 ? { extra_args: extraArgs } : {}),
1051
+ },
1052
+ });
1053
+ console.log(`Spawned ${narrative.agent_type} as ${narrative.id}`);
1054
+ console.log(` fold: ${narrative.fold_path}`);
1055
+ console.log(` role: ${narrative.role}`);
1056
+ if (args.model)
1057
+ console.log(` model: ${args.model}`);
1058
+ if (args.re)
1059
+ console.log(` re: ${args.re}`);
1060
+ if (deviceToken) {
1061
+ const meldDb = openDatabase();
1062
+ const { url, daemonHost, scheme, useLocal, appHost, isLoopbackFallback, tlsDegraded } = buildMeldUrl({
1063
+ db: meldDb,
1064
+ daemon: { port: endpoint.port, token: deviceToken },
1065
+ path: `/${narrative.id}`,
1066
+ host: args["meld-host"],
1067
+ local: args["meld-local"],
1068
+ app: args["meld-app"],
1069
+ });
1070
+ meldDb.close();
1071
+ console.log(`\nScan this QR code to open ${narrative.id}:\n`);
1072
+ qrcode.generate(url, { small: true }, (code) => {
1073
+ console.log(code);
1074
+ });
1075
+ console.log(`\n${url}\n`);
1076
+ console.log(`Daemon: ${daemonHost} (${scheme})`);
1077
+ if (!useLocal)
1078
+ console.log(`App: ${appHost}`);
1079
+ console.log(meldLapseNote());
1080
+ if (isLoopbackFallback) {
1081
+ console.log("\nNo Tailscale detected — this URL only works from this machine.");
1082
+ }
1083
+ else if (tlsDegraded) {
1084
+ console.log("\nWarning: Tailscale is up but the daemon has no TLS cert, so this URL uses plain HTTP.");
1085
+ console.log("Run `brane certify` to provision one — it'll tell you if \"HTTPS for your tailnet\" needs enabling.");
1086
+ }
1087
+ }
1088
+ // Optionally attach
1089
+ if (args.attach) {
1090
+ execFileSync(resolveTmux(), ["attach-session", "-t", narrative.id], {
1091
+ stdio: "inherit",
1092
+ });
1093
+ }
1094
+ }
1095
+ catch (err) {
1096
+ console.error(err instanceof Error ? err.message : String(err));
1097
+ process.exit(1);
1098
+ }
1099
+ },
1100
+ });
1101
+ //# sourceMappingURL=spawn.js.map