@crouton-kit/crouter 0.3.28 → 0.3.30

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 (324) hide show
  1. package/dist/build-root.d.ts +15 -7
  2. package/dist/build-root.js +48 -34
  3. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  4. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  6. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  8. package/dist/builtin-memory/design.md +1 -1
  9. package/dist/builtin-memory/development.md +1 -1
  10. package/dist/builtin-memory/internal/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  12. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  13. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  14. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  15. package/dist/builtin-memory/planning.md +1 -1
  16. package/dist/builtin-memory/spec.md +6 -4
  17. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  18. package/dist/builtin-personas/runtime-base.md +5 -4
  19. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  20. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  21. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  22. package/dist/builtin-views/canvas/core.mjs +586 -0
  23. package/dist/builtin-views/canvas/text.mjs +58 -0
  24. package/dist/builtin-views/canvas/tui.mjs +165 -0
  25. package/dist/builtin-views/canvas/web.jsx +120 -0
  26. package/dist/builtin-views/git-pr/core.mjs +673 -0
  27. package/dist/builtin-views/git-pr/text.mjs +84 -0
  28. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  29. package/dist/builtin-views/git-pr/web.jsx +216 -0
  30. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  31. package/dist/builtin-views/inbox/core.mjs +1273 -0
  32. package/dist/builtin-views/inbox/text.mjs +73 -0
  33. package/dist/builtin-views/inbox/tui.mjs +312 -0
  34. package/dist/builtin-views/inbox/web.jsx +188 -0
  35. package/dist/builtin-views/linkedin/core.mjs +906 -0
  36. package/dist/builtin-views/linkedin/text.mjs +69 -0
  37. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  38. package/dist/builtin-views/linkedin/web.jsx +206 -0
  39. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  40. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  41. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  42. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  43. package/dist/cli.js +22 -17
  44. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  45. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  46. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  47. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  48. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  51. package/dist/clients/attach/attach-cmd.js +282 -46
  52. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  53. package/dist/clients/attach/auth-pickers.js +194 -0
  54. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  55. package/dist/clients/attach/canvas-panels.js +45 -17
  56. package/dist/clients/attach/chat-view.d.ts +27 -2
  57. package/dist/clients/attach/chat-view.js +98 -9
  58. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  59. package/dist/clients/attach/clipboard-image.js +150 -26
  60. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  61. package/dist/clients/attach/clipboard-text.js +39 -0
  62. package/dist/clients/attach/config-load.d.ts +54 -7
  63. package/dist/clients/attach/config-load.js +99 -3
  64. package/dist/clients/attach/context-message.d.ts +12 -0
  65. package/dist/clients/attach/context-message.js +72 -0
  66. package/dist/clients/attach/git-info.d.ts +20 -0
  67. package/dist/clients/attach/git-info.js +44 -0
  68. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  69. package/dist/clients/attach/graph-overlay.js +59 -19
  70. package/dist/clients/attach/input-controller.d.ts +75 -13
  71. package/dist/clients/attach/input-controller.js +305 -67
  72. package/dist/clients/attach/pickers.d.ts +58 -0
  73. package/dist/clients/attach/pickers.js +171 -0
  74. package/dist/clients/attach/slash-commands.d.ts +22 -0
  75. package/dist/clients/attach/slash-commands.js +91 -25
  76. package/dist/clients/attach/titled-editor.d.ts +33 -0
  77. package/dist/clients/attach/titled-editor.js +84 -0
  78. package/dist/clients/attach/view-socket.d.ts +18 -1
  79. package/dist/clients/attach/view-socket.js +70 -1
  80. package/dist/clients/web/dev-server.d.ts +7 -0
  81. package/dist/clients/web/dev-server.js +59 -0
  82. package/dist/clients/web/events.d.ts +14 -0
  83. package/dist/clients/web/events.js +151 -0
  84. package/dist/clients/web/server.d.ts +18 -0
  85. package/dist/clients/web/server.js +450 -0
  86. package/dist/clients/web/web-cmd.d.ts +2 -0
  87. package/dist/clients/web/web-cmd.js +120 -0
  88. package/dist/commands/canvas.js +1 -2
  89. package/dist/commands/chord.js +4 -25
  90. package/dist/commands/dashboard.js +5 -1
  91. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  92. package/dist/commands/memory/lint.js +14 -2
  93. package/dist/commands/memory/read.js +2 -2
  94. package/dist/commands/memory/shared.d.ts +7 -1
  95. package/dist/commands/memory/shared.js +18 -3
  96. package/dist/commands/memory/write.js +8 -4
  97. package/dist/commands/memory.js +4 -4
  98. package/dist/commands/node.d.ts +1 -1
  99. package/dist/commands/node.js +64 -65
  100. package/dist/commands/pkg.js +1 -2
  101. package/dist/commands/revive.js +11 -3
  102. package/dist/commands/sys/sync.d.ts +1 -0
  103. package/dist/commands/sys/sync.js +187 -0
  104. package/dist/commands/sys.js +3 -2
  105. package/dist/commands/view-cycle.js +2 -2
  106. package/dist/commands/view-list.js +8 -8
  107. package/dist/commands/view-new.js +21 -17
  108. package/dist/commands/view-pick.js +1 -1
  109. package/dist/commands/view-run.js +35 -14
  110. package/dist/commands/view.js +8 -6
  111. package/dist/commands/workspace.d.ts +2 -0
  112. package/dist/commands/workspace.js +161 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  114. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  116. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  117. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  118. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  119. package/dist/core/__tests__/close.test.js +14 -20
  120. package/dist/core/__tests__/context-intro.test.js +19 -19
  121. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  122. package/dist/core/__tests__/draw-style.test.js +1 -1
  123. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  124. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  125. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  126. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  127. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  128. package/dist/core/__tests__/focuses.test.js +25 -13
  129. package/dist/core/__tests__/fork.test.js +22 -5
  130. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  131. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  133. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  135. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  136. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  137. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  138. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  139. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  140. package/dist/core/__tests__/helpers/harness.js +19 -36
  141. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  142. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  143. package/dist/core/__tests__/lifecycle.test.js +1 -19
  144. package/dist/core/__tests__/listing-completeness.test.js +2 -2
  145. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  146. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  147. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  148. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  149. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  150. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  151. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  152. package/dist/core/__tests__/reset.test.js +19 -79
  153. package/dist/core/__tests__/steer-note.test.js +9 -7
  154. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  155. package/dist/core/bootstrap.d.ts +0 -3
  156. package/dist/core/bootstrap.js +1 -143
  157. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  158. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  159. package/dist/core/canvas/browse/app.js +131 -8
  160. package/dist/core/canvas/browse/model.d.ts +23 -5
  161. package/dist/core/canvas/browse/model.js +68 -13
  162. package/dist/core/canvas/browse/render.d.ts +7 -1
  163. package/dist/core/canvas/browse/render.js +178 -47
  164. package/dist/core/canvas/canvas.js +1 -1
  165. package/dist/core/canvas/focuses.d.ts +4 -4
  166. package/dist/core/canvas/focuses.js +7 -7
  167. package/dist/core/canvas/nav-model.js +8 -7
  168. package/dist/core/canvas/render.d.ts +68 -9
  169. package/dist/core/canvas/render.js +195 -61
  170. package/dist/core/canvas/types.d.ts +16 -23
  171. package/dist/core/config.js +1 -4
  172. package/dist/core/memory-resolver.js +43 -5
  173. package/dist/core/runtime/bearings.d.ts +1 -1
  174. package/dist/core/runtime/bearings.js +8 -8
  175. package/dist/core/runtime/branded-host.d.ts +16 -0
  176. package/dist/core/runtime/branded-host.js +127 -0
  177. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  178. package/dist/core/runtime/broker.d.ts +4 -1
  179. package/dist/core/runtime/broker.js +494 -58
  180. package/dist/core/runtime/close.d.ts +12 -2
  181. package/dist/core/runtime/close.js +37 -16
  182. package/dist/core/runtime/front-door.js +6 -14
  183. package/dist/core/runtime/host.d.ts +14 -34
  184. package/dist/core/runtime/host.js +14 -50
  185. package/dist/core/runtime/launch.d.ts +12 -9
  186. package/dist/core/runtime/launch.js +27 -15
  187. package/dist/core/runtime/lifecycle.d.ts +1 -1
  188. package/dist/core/runtime/lifecycle.js +15 -19
  189. package/dist/core/runtime/nodes.d.ts +0 -55
  190. package/dist/core/runtime/nodes.js +6 -74
  191. package/dist/core/runtime/placement.d.ts +91 -335
  192. package/dist/core/runtime/placement.js +291 -858
  193. package/dist/core/runtime/promote.d.ts +2 -0
  194. package/dist/core/runtime/promote.js +24 -6
  195. package/dist/core/runtime/recap.d.ts +8 -0
  196. package/dist/core/runtime/recap.js +107 -0
  197. package/dist/core/runtime/recycle.js +25 -61
  198. package/dist/core/runtime/reset.d.ts +43 -37
  199. package/dist/core/runtime/reset.js +131 -218
  200. package/dist/core/runtime/revive.d.ts +9 -29
  201. package/dist/core/runtime/revive.js +40 -139
  202. package/dist/core/runtime/spawn.d.ts +15 -16
  203. package/dist/core/runtime/spawn.js +165 -194
  204. package/dist/core/runtime/surface-bg.d.ts +11 -0
  205. package/dist/core/runtime/surface-bg.js +68 -0
  206. package/dist/core/runtime/tmux.d.ts +15 -52
  207. package/dist/core/runtime/tmux.js +35 -99
  208. package/dist/core/scope.d.ts +1 -2
  209. package/dist/core/scope.js +1 -5
  210. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  211. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  212. package/dist/core/skill-sync/builtins.d.ts +42 -0
  213. package/dist/core/skill-sync/builtins.js +112 -0
  214. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  215. package/dist/core/skill-sync/claude-plugins.js +71 -0
  216. package/dist/core/skill-sync/engine.d.ts +42 -0
  217. package/dist/core/skill-sync/engine.js +633 -0
  218. package/dist/core/skill-sync/export.d.ts +23 -0
  219. package/dist/core/skill-sync/export.js +86 -0
  220. package/dist/core/skill-sync/manifest.d.ts +64 -0
  221. package/dist/core/skill-sync/manifest.js +181 -0
  222. package/dist/core/skill-sync/profile.d.ts +76 -0
  223. package/dist/core/skill-sync/profile.js +173 -0
  224. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  225. package/dist/core/skill-sync/snapshot.js +120 -0
  226. package/dist/core/substrate/index.d.ts +1 -1
  227. package/dist/core/substrate/index.js +1 -1
  228. package/dist/core/substrate/injected-store.js +3 -3
  229. package/dist/core/substrate/on-read.js +68 -6
  230. package/dist/core/substrate/render.d.ts +8 -11
  231. package/dist/core/substrate/render.js +29 -43
  232. package/dist/core/substrate/schema.d.ts +10 -3
  233. package/dist/core/substrate/schema.js +6 -3
  234. package/dist/core/tui/host.d.ts +12 -4
  235. package/dist/core/tui/host.js +280 -149
  236. package/dist/core/view/bridge.d.ts +10 -0
  237. package/dist/core/view/bridge.js +31 -0
  238. package/dist/core/view/chrome.d.ts +9 -0
  239. package/dist/core/view/chrome.js +22 -0
  240. package/dist/core/view/contract.d.ts +171 -0
  241. package/dist/core/view/contract.js +23 -0
  242. package/dist/core/view/loader.d.ts +31 -0
  243. package/dist/core/view/loader.js +188 -0
  244. package/dist/core/view/transport-local.d.ts +7 -0
  245. package/dist/core/view/transport-local.js +70 -0
  246. package/dist/core/view/transport.d.ts +4 -0
  247. package/dist/core/view/transport.js +15 -0
  248. package/dist/daemon/crtrd.d.ts +38 -27
  249. package/dist/daemon/crtrd.js +303 -376
  250. package/dist/daemon/manage.js +6 -1
  251. package/dist/index.js +1 -1
  252. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  253. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  254. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  255. package/dist/pi-extensions/canvas-nav.js +5 -0
  256. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  257. package/dist/pi-extensions/canvas-recap.js +236 -0
  258. package/dist/pi-extensions/canvas-resume.js +2 -1
  259. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  260. package/dist/pi-extensions/canvas-stophook.js +87 -101
  261. package/dist/pi-extensions/canvas-view.js +2 -1
  262. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  263. package/dist/pi-extensions/widget-order-bus.js +34 -0
  264. package/dist/prompts/view.d.ts +2 -2
  265. package/dist/prompts/view.js +145 -73
  266. package/dist/types.d.ts +1 -6
  267. package/dist/types.js +1 -3
  268. package/dist/web/ViewChrome.d.ts +7 -0
  269. package/dist/web/ViewChrome.js +28 -0
  270. package/dist/web/ViewPane.d.ts +39 -0
  271. package/dist/web/ViewPane.js +48 -0
  272. package/dist/web/index.d.ts +6 -0
  273. package/dist/web/index.js +16 -0
  274. package/dist/web/runtime.d.ts +39 -0
  275. package/dist/web/runtime.js +133 -0
  276. package/dist/web/states.d.ts +24 -0
  277. package/dist/web/states.js +24 -0
  278. package/dist/web/transport-http.d.ts +5 -0
  279. package/dist/web/transport-http.js +28 -0
  280. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  281. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  282. package/dist/web-client/index.html +13 -0
  283. package/package.json +20 -6
  284. package/dist/builtin-views/canvas/client.mjs +0 -303
  285. package/dist/builtin-views/canvas/view.mjs +0 -576
  286. package/dist/builtin-views/git-pr/client.mjs +0 -440
  287. package/dist/builtin-views/git-pr/view.mjs +0 -675
  288. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  289. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  290. package/dist/builtin-views/inbox/view.mjs +0 -889
  291. package/dist/builtin-views/linkedin/client.mjs +0 -610
  292. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  293. package/dist/commands/pkg/bridge.d.ts +0 -1
  294. package/dist/commands/pkg/bridge.js +0 -137
  295. package/dist/commands/skill/author.d.ts +0 -3
  296. package/dist/commands/skill/author.js +0 -140
  297. package/dist/commands/skill/shared.d.ts +0 -3
  298. package/dist/commands/skill/shared.js +0 -19
  299. package/dist/commands/skill.d.ts +0 -2
  300. package/dist/commands/skill.js +0 -21
  301. package/dist/commands/tmux-spread.d.ts +0 -2
  302. package/dist/commands/tmux-spread.js +0 -144
  303. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  304. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  305. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  306. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  307. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  308. package/dist/core/__tests__/home-session.test.js +0 -186
  309. package/dist/core/__tests__/relaunch.test.js +0 -335
  310. package/dist/core/bridge-map.d.ts +0 -19
  311. package/dist/core/bridge-map.js +0 -73
  312. package/dist/core/tui/contract.d.ts +0 -83
  313. package/dist/core/tui/contract.js +0 -8
  314. package/dist/core/tui/loader.d.ts +0 -16
  315. package/dist/core/tui/loader.js +0 -94
  316. package/dist/prompts/skill.d.ts +0 -2
  317. package/dist/prompts/skill.js +0 -650
  318. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  323. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  324. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
@@ -12,12 +12,14 @@
12
12
  // Cycle guard: the subscription graph is declared acyclic (a node cannot
13
13
  // subscribe to its own ancestor), but we track visited ids defensively because
14
14
  // the db is mutable and bugs happen.
15
- import { existsSync, readFileSync } from 'node:fs';
15
+ import { existsSync, readFileSync, statSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { getNode, listNodes, subscriptionsOf, view } from './canvas.js';
18
18
  import { fullName } from './labels.js';
19
19
  import { jobDir, contextDir } from './paths.js';
20
- import { countAsks } from './attention.js';
20
+ import { countAsks, asksForNodes } from './attention.js';
21
+ import { isPidAlive } from './pid.js';
22
+ import { listFocuses } from './focuses.js';
21
23
  // ---------------------------------------------------------------------------
22
24
  // Glyphs
23
25
  // ---------------------------------------------------------------------------
@@ -181,54 +183,98 @@ function readGoalText(nodeId) {
181
183
  return undefined;
182
184
  }
183
185
  }
184
- /** EVERY user prompt across a node's pi session the whole conversation, not just
185
- * the spawn prompt. Streams the session jsonl off disk, prefiltering to user-role
186
- * lines so the big assistant/toolResult lines are never JSON-parsed, extracts each
187
- * user message's text, joins newline-separated, and caps total + per-message so a
188
- * long session can't bloat the snapshot. Never throws; returns undefined when there
189
- * is no session file yet (a node that was never revived). */
186
+ /** The node's session-derived preview text in ONE file read (today's two reads,
187
+ * folded): EVERY user prompt across the pi session (`prompts` the whole
188
+ * conversation, not just the spawn prompt) AND the LAST assistant message text
189
+ * (`lastAssistant`). The single jsonl read is split once; a forward pass collects
190
+ * user prompts (prefiltering to user-role lines so the big assistant/toolResult
191
+ * lines are never JSON-parsed) and a backward pass finds the newest assistant
192
+ * reply with text content (tool-only replies skipped). Both are capped per-message
193
+ * + total so a long session can't bloat the snapshot. Never throws; returns an
194
+ * empty object when there is no session file yet (a node that was never revived). */
190
195
  const CONVO_CAP = 8192;
191
196
  const CONVO_MSG_CAP = 2048;
192
- function readConversationPrompts(sessionFile) {
197
+ function readSessionParts(sessionFile) {
193
198
  if (sessionFile === undefined || sessionFile === null || sessionFile === '')
194
- return undefined;
199
+ return {};
200
+ let lines;
195
201
  try {
196
202
  if (!existsSync(sessionFile))
197
- return undefined;
198
- const raw = readFileSync(sessionFile, 'utf8');
199
- const parts = [];
200
- let total = 0;
201
- for (const line of raw.split('\n')) {
202
- // Cheap prefilter: skip every line that isn't a user-role message before the
203
- // (relatively costly) JSON.parse. Pi writes compact JSON (no spaces), but
204
- // tolerate the spaced form too.
205
- if (line === '' || (line.indexOf('"role":"user"') === -1 && line.indexOf('"role": "user"') === -1))
206
- continue;
207
- let rec;
208
- try {
209
- rec = JSON.parse(line);
210
- }
211
- catch {
212
- continue;
213
- }
214
- if (rec.type !== 'message' || rec.message?.role !== 'user')
215
- continue;
216
- const text = extractUserText(rec.message.content);
217
- if (text === '')
218
- continue;
219
- const capped = text.length > CONVO_MSG_CAP ? text.slice(0, CONVO_MSG_CAP) : text;
220
- parts.push(capped);
221
- total += capped.length + 1;
222
- if (total >= CONVO_CAP)
223
- break;
203
+ return {};
204
+ lines = readFileSync(sessionFile, 'utf8').split('\n');
205
+ }
206
+ catch {
207
+ return {};
208
+ }
209
+ // Forward pass: every user prompt, joined + capped.
210
+ const parts = [];
211
+ let total = 0;
212
+ for (const line of lines) {
213
+ // Cheap prefilter: skip every line that isn't a user-role message before the
214
+ // (relatively costly) JSON.parse. Pi writes compact JSON (no spaces), but
215
+ // tolerate the spaced form too.
216
+ if (line === '' || (line.indexOf('"role":"user"') === -1 && line.indexOf('"role": "user"') === -1))
217
+ continue;
218
+ let rec;
219
+ try {
220
+ rec = JSON.parse(line);
224
221
  }
225
- if (parts.length === 0)
226
- return undefined;
222
+ catch {
223
+ continue;
224
+ }
225
+ if (rec.type !== 'message' || rec.message?.role !== 'user')
226
+ continue;
227
+ const text = extractUserText(rec.message.content);
228
+ if (text === '')
229
+ continue;
230
+ const capped = text.length > CONVO_MSG_CAP ? text.slice(0, CONVO_MSG_CAP) : text;
231
+ parts.push(capped);
232
+ total += capped.length + 1;
233
+ if (total >= CONVO_CAP)
234
+ break;
235
+ }
236
+ let prompts;
237
+ if (parts.length > 0) {
227
238
  const joined = parts.join('\n');
228
- return joined.length > CONVO_CAP ? joined.slice(0, CONVO_CAP) : joined;
239
+ prompts = joined.length > CONVO_CAP ? joined.slice(0, CONVO_CAP) : joined;
240
+ }
241
+ // Backward pass: the newest assistant message carrying text.
242
+ let lastAssistant;
243
+ for (let i = lines.length - 1; i >= 0; i--) {
244
+ const line = lines[i];
245
+ if (line === '' || (line.indexOf('"role":"assistant"') === -1 && line.indexOf('"role": "assistant"') === -1))
246
+ continue;
247
+ let rec;
248
+ try {
249
+ rec = JSON.parse(line);
250
+ }
251
+ catch {
252
+ continue;
253
+ }
254
+ if (rec.type !== 'message' || rec.message?.role !== 'assistant')
255
+ continue;
256
+ const text = extractUserText(rec.message.content);
257
+ if (text === '')
258
+ continue;
259
+ lastAssistant = text.length > CONVO_MSG_CAP ? text.slice(0, CONVO_MSG_CAP) : text;
260
+ break;
261
+ }
262
+ return { prompts, lastAssistant };
263
+ }
264
+ /** Is the node GENUINELY mid-turn right now? The `busy` marker (touched on
265
+ * agent_start, removed on agent_end) AND-ed with broker-pid liveness, so a stale
266
+ * marker from a crashed pi reads false. This is the live "generating?" signal
267
+ * — distinct from `status: 'active'`, which only means the engine never closed
268
+ * (an active node is usually dormant between turns). Read directly off disk
269
+ * (mirrors telemetry) to avoid inverting the canvas→runtime dependency. */
270
+ function isStreaming(nodeId, piPid) {
271
+ if (!isPidAlive(piPid))
272
+ return false;
273
+ try {
274
+ return existsSync(join(jobDir(nodeId), 'busy'));
229
275
  }
230
276
  catch {
231
- return undefined;
277
+ return false;
232
278
  }
233
279
  }
234
280
  /** Concatenate the `text` blocks of one pi user message's content. Content is
@@ -269,26 +315,114 @@ export function dashboardRows(rootId) {
269
315
  }];
270
316
  });
271
317
  }
272
- /** One row per node across the entire canvas. */
318
+ /** "Most recent activity" sort key (epoch ms) read CHEAPLY: a `statSync` (no file
319
+ * read, no parse) of the node's job/telemetry.json — rewritten on every turn_end at
320
+ * a deterministic canvas-home path, so it tracks last-message recency without a meta
321
+ * read. Falls back to `created`'s epoch when a node never ran a turn (no telemetry).
322
+ * Never throws. */
323
+ function sessionMtime(nodeId, created) {
324
+ try {
325
+ const st = statSync(join(jobDir(nodeId), 'telemetry.json'), { throwIfNoEntry: false });
326
+ if (st !== undefined)
327
+ return st.mtimeMs;
328
+ }
329
+ catch {
330
+ /* fall through to created */
331
+ }
332
+ const t = Date.parse(created);
333
+ return Number.isNaN(t) ? 0 : t;
334
+ }
335
+ /**
336
+ * CHEAP boot builder — one row per node across the entire canvas, with only the
337
+ * fields that need no per-node meta/telemetry/session read. Two db queries total:
338
+ * `listNodes()` + `listFocuses()` (the focus set replaces 1473 `getFocusByNode`
339
+ * calls). For each node:
340
+ * - identity/status/kind/mode/cwd/created/lifecycle straight off the db row;
341
+ * - `name` is the db HANDLE only (the informative label needs meta.description —
342
+ * fold it in lazily via {@link enrichRow}/{@link enrichRows});
343
+ * - `viewed` from the one focus-set;
344
+ * - `streaming` only for LIVE nodes (active|idle — the only ones that can be
345
+ * mid-turn); dormant rows are always false (skips an isPidAlive + statSync);
346
+ * - `mtimeMs` via a cheap telemetry statSync (no read) for the attention sort;
347
+ * - `ctx_tokens`/`asks` left 0 and `goal`/`prompts`/`lastAssistant` undefined —
348
+ * all deferred to the lazy enrichment API below.
349
+ *
350
+ * This is the first-frame path: paint from it immediately, then enrich the visible
351
+ * viewport on demand. See {@link enrichRow}, {@link enrichRows}, {@link loadPreview}.
352
+ */
273
353
  export function dashboardRowsAll() {
274
- return listNodes().flatMap((row) => {
275
- const tel = readNodeTelemetry(row.node_id);
276
- // listNodes() returns the db projection (no description); read the meta to
277
- // get the full label. Falls back to the row name if the meta is gone.
278
- const meta = getNode(row.node_id);
279
- return [{
280
- node_id: row.node_id,
281
- name: meta !== null ? fullName(meta) : row.name,
282
- status: row.status,
283
- kind: row.kind,
284
- mode: row.mode,
285
- ctx_tokens: tel.tokens_in ?? 0,
286
- asks: countAsks(row.node_id),
287
- cwd: row.cwd,
288
- created: row.created,
289
- lifecycle: row.lifecycle,
290
- goal: readGoalText(row.node_id),
291
- prompts: readConversationPrompts(meta?.pi_session_file),
292
- }];
354
+ const focusedNodeIds = new Set(listFocuses().map((f) => f.node_id));
355
+ return listNodes().map((row) => {
356
+ const live = row.status === 'active' || row.status === 'idle';
357
+ return {
358
+ node_id: row.node_id,
359
+ name: row.name, // handle only; enrichRow upgrades to fullName (meta.description)
360
+ status: row.status,
361
+ kind: row.kind,
362
+ mode: row.mode,
363
+ ctx_tokens: 0, // lazy: enrichRow
364
+ asks: 0, // lazy: enrichRow / enrichRows
365
+ cwd: row.cwd,
366
+ created: row.created,
367
+ lifecycle: row.lifecycle,
368
+ mtimeMs: sessionMtime(row.node_id, row.created),
369
+ streaming: live ? isStreaming(row.node_id, row.pi_pid) : false,
370
+ viewed: focusedNodeIds.has(row.node_id),
371
+ };
293
372
  });
294
373
  }
374
+ // ---------------------------------------------------------------------------
375
+ // Lazy enrichment API — fold the expensive per-node reads into rows on demand,
376
+ // for the VISIBLE viewport / top tiers only (never all 1473 up front). Each
377
+ // function mutates the row in place (the browse tree holds the row reference, so
378
+ // a later flush re-renders the upgraded data) and is idempotent via a guard flag
379
+ // so progressive paint can call it for the viewport every keystroke for free.
380
+ // ---------------------------------------------------------------------------
381
+ /** Fold the cheap-boot row's deferred fields in for ONE row: the full label
382
+ * (meta.description → fullName), ctx tokens (telemetry.json), and ⚑ asks. One
383
+ * meta read + one telemetry read + one ask scan. Idempotent (no-op once enriched).
384
+ * For a batch, prefer {@link enrichRows} — it scans each cwd's ask inbox once. */
385
+ export function enrichRow(row) {
386
+ if (row.enriched === true)
387
+ return row;
388
+ const meta = getNode(row.node_id);
389
+ if (meta !== null)
390
+ row.name = fullName(meta);
391
+ row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
392
+ row.asks = countAsks(row.node_id);
393
+ row.enriched = true;
394
+ return row;
395
+ }
396
+ /** Batch {@link enrichRow}: enrich every not-yet-enriched row, scanning each
397
+ * distinct cwd's ask inbox exactly ONCE (via `asksForNodes`) instead of per row.
398
+ * Use this for a whole viewport / the full forest; mutates each row in place. */
399
+ export function enrichRows(rows) {
400
+ const todo = rows.filter((r) => r.enriched !== true);
401
+ if (todo.length === 0)
402
+ return;
403
+ const asks = asksForNodes(todo.map((r) => r.node_id));
404
+ for (const row of todo) {
405
+ const meta = getNode(row.node_id);
406
+ if (meta !== null)
407
+ row.name = fullName(meta);
408
+ row.ctx_tokens = readNodeTelemetry(row.node_id).tokens_in ?? 0;
409
+ row.asks = asks[row.node_id] ?? 0;
410
+ row.enriched = true;
411
+ }
412
+ }
413
+ /** Load the SELECTED row's preview text: the spawn `goal` (initial-prompt.md) plus
414
+ * the whole-conversation `prompts` and the `lastAssistant` reply — the latter two
415
+ * folded into ONE session-file read (see {@link readSessionParts}). Mutates the row
416
+ * in place; idempotent. Call only for the cursor row (and lazily/in-background to
417
+ * warm the prompt super-search corpus), never on the boot path. */
418
+ export function loadPreview(row) {
419
+ if (row.previewLoaded === true)
420
+ return row;
421
+ row.goal = readGoalText(row.node_id);
422
+ const meta = getNode(row.node_id);
423
+ const { prompts, lastAssistant } = readSessionParts(meta?.pi_session_file);
424
+ row.prompts = prompts;
425
+ row.lastAssistant = lastAssistant;
426
+ row.previewLoaded = true;
427
+ return row;
428
+ }
@@ -1,10 +1,15 @@
1
1
  import type { SpawnChildOpts } from '../runtime/spawn.js';
2
- /** What a node is doing right now. `active` means the engine process is live on
3
- * its hosta tmux pane OR a headless broker (`host_kind`) NOT that it holds
4
- * a window or that anyone is watching it (attachment is a separate axis: the
5
- * `focuses` table for tmux, the broker's viewer set for headless). UI shows
6
- * active+idle; `done` is hidden but revivable; `canceled` is a user-closed node
7
- * (also hidden, also revivable not a fault); only `dead` is a fault. */
2
+ /** What a node is doing right now. `active` means the engine process a detached
3
+ * headless brokeris live, NOT that it holds a window or that anyone is
4
+ * watching it (attachment is a separate axis: the `focuses` table tracks each
5
+ * node's one viewer pane, and the broker records its helloed-viewer count).
6
+ * Crucially, `active` does NOT mean the node is GENERATING right now: a live
7
+ * engine sits dormant between turns (waiting on a child, a human, or just left
8
+ * open), so `active` ≠ busy — it only means the process was never closed. To tell
9
+ * whether an active node is actually working, check its pi session-file mtime or
10
+ * CPU, not its status. UI shows active+idle; `done` is hidden but revivable;
11
+ * `canceled` is a user-closed node (also hidden, also revivable — not a fault);
12
+ * only `dead` is a fault. */
8
13
  export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
9
14
  /** Does stopping finalize the node? terminal = worker (finalizes on push --final);
10
15
  * resident = manager/orchestrator (stays dormant, woken by inbox). */
@@ -84,16 +89,6 @@ export interface NodeIdentity {
84
89
  fork_from?: string | null;
85
90
  /** New subscriptions this node opens default to passive when true. */
86
91
  passive_default?: boolean;
87
- /** REVIVE-HOME — the tmux session a node is (re)opened into when it must
88
- * generate but is NOT focused (the durable revive target, distinct from the
89
- * live LOCATION held by the runtime `tmux_session`). Set once at birth
90
- * (managed child → the shared backstage `nodeSession()`; inline root → the
91
- * adopted caller session; independent `--root` → the caller session), and
92
- * rewritten only by demote-recycle. Durable identity (like `cwd`), never
93
- * touched by a focus swap — this is what keeps a background revive off the
94
- * user's session. Legacy metas omit it; readers default to
95
- * `tmux_session ?? nodeSession()` (see `homeSessionOf`). */
96
- home_session?: string;
97
92
  /** The pi session id for `--session <id>` revival. */
98
93
  pi_session_id?: string | null;
99
94
  /** Absolute path to pi's session `.jsonl` file, captured at session_start via
@@ -126,13 +121,11 @@ export interface NodeRuntime {
126
121
  * Optional on the hydrated view (a fresh construction omits it); the row
127
122
  * column is always present, defaulting null. */
128
123
  intent?: ExitIntent;
129
- /** OS pid of the live pi process, recorded on boot (stophook session_start).
130
- * The daemon's authoritative liveness signal: an inline root runs pi as a
131
- * child of a persistent login shell, so its tmux window outlives a dead pi —
132
- * window-existence alone can't detect the death, but a dead pid can. Cleared
133
- * to null by a window-backed relaunch (reviveNode) until the fresh pi
134
- * re-records it; left intact by an in-place respawn (reviveInPlace) so a
135
- * failed respawn surfaces as a dead pid. */
124
+ /** OS pid of the live broker process, recorded on boot (stophook session_start).
125
+ * The daemon's authoritative liveness signal every node is a detached broker,
126
+ * so `isPidAlive(pi_pid)` is the sole liveness check. Cleared to null by
127
+ * reviveNode until the fresh broker re-records it, so the relaunch gap reads as
128
+ * a dead pid (guarded by the daemon's revive grace, not a double-spawn). */
136
129
  pi_pid?: number | null;
137
130
  /** Presence: the tmux session (its root's home) and window this node renders
138
131
  * in while active. Cleared when the node goes done/dead and its window closes.
@@ -118,10 +118,7 @@ function mergeConfig(partial) {
118
118
  ? Math.floor(rawMaxPanes)
119
119
  : defaults.max_panes_per_window;
120
120
  const canvasNav = mergeCanvasNav(partial.canvasNav);
121
- // The merge drops unknown keys, so `headless` must be carried explicitly or it
122
- // is lost on every read-modify-write of config.json.
123
- const headless = typeof partial.headless === 'boolean' ? partial.headless : defaults.headless;
124
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav, headless };
121
+ return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav };
125
122
  }
126
123
  export function updateConfig(scope, mutate) {
127
124
  const cfg = readConfig(scope);
@@ -1,4 +1,5 @@
1
1
  import { join, relative, sep } from 'node:path';
2
+ import { readdirSync } from 'node:fs';
2
3
  import { pathExists, readText, walkFiles } from './fs-utils.js';
3
4
  import { parseFrontmatterGeneric } from './frontmatter.js';
4
5
  import { listInstalledPlugins, parseSkillQualifier } from './resolver.js';
@@ -33,19 +34,49 @@ export function listMemoryDocs(scope) {
33
34
  if (!dir || !pathExists(dir))
34
35
  return [];
35
36
  const docs = [];
36
- for (const file of walkFiles(dir, (n) => n.endsWith('.md'))) {
37
- const name = relative(dir, file).replace(/\.md$/i, '').split(sep).join('/');
38
- if (!name)
37
+ // Walk: a dir containing SKILL.md is a single substrate bundle — emit ONE doc
38
+ // named by the dir's relative path, sourced from its SKILL.md, and STOP
39
+ // descending (deeper files are bundle assets, not docs). Every other dir
40
+ // yields its flat *.md files (topical subdirs / INDEX.md) as before.
41
+ const found = [];
42
+ const stack = [dir];
43
+ while (stack.length) {
44
+ const d = stack.pop();
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(d, { withFileTypes: true });
48
+ }
49
+ catch {
39
50
  continue;
51
+ }
52
+ const hasSkill = entries.some((e) => e.isFile() && e.name === 'SKILL.md');
53
+ if (hasSkill) {
54
+ const name = relative(dir, d).split(sep).join('/');
55
+ if (name)
56
+ found.push({ path: join(d, 'SKILL.md'), name });
57
+ continue; // stop descent — deeper dirs are assets
58
+ }
59
+ for (const e of entries) {
60
+ if (e.isDirectory())
61
+ stack.push(join(d, e.name));
62
+ else if (e.isFile() && e.name.endsWith('.md')) {
63
+ const file = join(d, e.name);
64
+ const name = relative(dir, file).replace(/\.md$/i, '').split(sep).join('/');
65
+ if (name)
66
+ found.push({ path: file, name });
67
+ }
68
+ }
69
+ }
70
+ for (const { path, name } of found) {
40
71
  // COLLECTION layer: the strict frontmatter parser throws on invalid YAML.
41
72
  // Isolate one malformed doc with a clear scoped notice + skip, so a single
42
73
  // bad file can't brick `memory list`/`find` or the substrate boot render.
43
74
  try {
44
- docs.push(loadMemoryDoc(name, scope, file));
75
+ docs.push(loadMemoryDoc(name, scope, path));
45
76
  }
46
77
  catch (e) {
47
78
  const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
48
- warn(`invalid frontmatter in ${file}: ${msg}`);
79
+ warn(`invalid frontmatter in ${path}: ${msg}`);
49
80
  }
50
81
  }
51
82
  return docs.sort((a, b) => a.name.localeCompare(b.name));
@@ -105,6 +136,13 @@ function findMemoryMatches(name, scope) {
105
136
  matches.push(loadMemoryDoc(name, s, path));
106
137
  continue;
107
138
  }
139
+ // Bundle marker is authoritative: a `<name>/SKILL.md` resolves the bare
140
+ // dir name to its bundle doc, and wins over INDEX.md if both exist.
141
+ const skillPath = join(dir, ...name.split('/'), 'SKILL.md');
142
+ if (pathExists(skillPath)) {
143
+ matches.push(loadMemoryDoc(name, s, skillPath));
144
+ continue;
145
+ }
108
146
  const indexPath = join(dir, ...name.split('/'), 'INDEX.md');
109
147
  if (pathExists(indexPath)) {
110
148
  matches.push(loadMemoryDoc(name, s, indexPath));
@@ -56,6 +56,6 @@ export declare function buildWakeBearings(origin: WakeOrigin): string;
56
56
  * any copied-in persona) followed by the `<crtr-context>` bearings block. Base
57
57
  * framing rides for EVERY node; the across-cycles context-dir note is added
58
58
  * ONLY for an orchestrator (by mode) — the one node whose dir a future cycle
59
- * resumes from. The `<references>` block carries the substrate reference docs
59
+ * resumes from. The `<knowledge>` block carries the substrate knowledge docs
60
60
  * + node-local docs. */
61
61
  export declare function buildContextBearings(nodeId: string): string;
@@ -19,15 +19,15 @@
19
19
  // one place other nodes on the canvas can read from, so it is for documents
20
20
  // worth a shared reference, NOT a task tracker, and NOT a "future memory-wiped
21
21
  // you" stash (a terminal worker has no future cycle — that framing only makes
22
- // sense once a node is a resident orchestrator). The `<references>` block
23
- // (substrate reference docs + node-local docs) rides into the context message.
22
+ // sense once a node is a resident orchestrator). The `<knowledge>` block
23
+ // (substrate knowledge docs + node-local docs) rides into the context message.
24
24
  //
25
25
  // Orchestrator addendum (gated on orchestrator MODE): the dir ALSO survives
26
26
  // refresh cycles, so it is where a future cycle of the orchestrator resumes.
27
27
  // This across-cycles note is the ONE thing a terminal worker's bearings drop.
28
28
  import { contextDir, getNode, fullName } from '../canvas/index.js';
29
29
  import { cadenceDisplay } from '../wake.js';
30
- import { renderReferencesBlock } from '../substrate/index.js';
30
+ import { renderKnowledgeBlock } from '../substrate/index.js';
31
31
  /** Base framing — present for every node. No path baked in: the caller carries
32
32
  * the dir in the `<crtr-context dir="…">` attribute. */
33
33
  export const BASE_CONTEXT_NOTE = 'This is your context directory — durable scratch space on disk, and the one place the other ' +
@@ -145,7 +145,7 @@ export function buildWakeBearings(origin) {
145
145
  * any copied-in persona) followed by the `<crtr-context>` bearings block. Base
146
146
  * framing rides for EVERY node; the across-cycles context-dir note is added
147
147
  * ONLY for an orchestrator (by mode) — the one node whose dir a future cycle
148
- * resumes from. The `<references>` block carries the substrate reference docs
148
+ * resumes from. The `<knowledge>` block carries the substrate knowledge docs
149
149
  * + node-local docs. */
150
150
  export function buildContextBearings(nodeId) {
151
151
  const identity = buildIdentityAssertion(nodeId);
@@ -155,11 +155,11 @@ export function buildContextBearings(nodeId) {
155
155
  // Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
156
156
  if (node?.mode === 'orchestrator')
157
157
  parts.push(orchestratorContextNote(nodeId));
158
- // The substrate references block: eligible `reference` docs at their
158
+ // The substrate knowledge block: eligible `knowledge` docs at their
159
159
  // system-prompt rung + node-local memory docs; dropped (returns '') when
160
160
  // nothing is eligible.
161
- const references = renderReferencesBlock(nodeId);
162
- if (references !== '')
163
- parts.push(references);
161
+ const knowledge = renderKnowledgeBlock(nodeId);
162
+ if (knowledge !== '')
163
+ parts.push(knowledge);
164
164
  return `${identity}\n<crtr-context dir="${dir}">\n${parts.join('\n')}\n</crtr-context>`;
165
165
  }
@@ -0,0 +1,16 @@
1
+ /** Machine-global host dir. Deliberately NOT under `CRTR_HOME` (tests override
2
+ * that to an isolated temp): the branded binary is a per-machine artifact keyed
3
+ * to the installed node, shared across every canvas home. */
4
+ export declare function brandedHostDir(): string;
5
+ export declare function brandedHostBin(): string;
6
+ /** Ensure the branded host binary exists and matches the live node; return its
7
+ * absolute path. Idempotent and self-healing. Throws on a genuine build failure
8
+ * (disk full / permissions) — branding is load-bearing for FDA, so a failure is
9
+ * surfaced, never silently masked by falling back to `node`. */
10
+ export declare function ensureBrandedHost(): string;
11
+ /** The path the engine (broker / daemon) should be launched from. On macOS in a
12
+ * compiled (production) build this is the branded host; otherwise — non-darwin
13
+ * (TCC is macOS-only), source/tsx (dev + the test suite: never copy 70 MB into
14
+ * the real ~/.crouter), or an explicit `CRTR_BRANDED_HOST=0` opt-out — it is the
15
+ * live node. These are environment gates, not error fallbacks. */
16
+ export declare function hostExecPath(): string;
@@ -0,0 +1,127 @@
1
+ // branded-host.ts — a crouter-branded copy of the `node` binary, so the
2
+ // long-lived engine processes (brokers + the crtrd daemon) show "crouter" in
3
+ // macOS' TCC / Full Disk Access list instead of the generic "node".
4
+ //
5
+ // WHY a real copied binary (not a symlink, not process.title): macOS keys a
6
+ // Full Disk Access entry on the Mach-O binary's *filename* — not argv[0], not
7
+ // process.title, and symlinks resolve to their realpath. So the ONLY way to show
8
+ // a branded name is to launch the engine from a real Mach-O file whose filename
9
+ // is the brand. We copy the node launcher to `~/.crouter/host/crouter`.
10
+ //
11
+ // WHY self-contained (libnode beside it): a Homebrew node is a small (~70 KB)
12
+ // launcher that dynamically loads `@rpath/libnode.<abi>.dylib` (~70 MB). node's
13
+ // LC_RPATH includes `@loader_path`, so a copy of that dylib placed *beside* the
14
+ // branded binary resolves with no DYLD_* env var (which SIP can strip) and no
15
+ // dependency on the Cellar path surviving — even after a `brew uninstall` of the
16
+ // old keg, the branded copy keeps running. A statically-linked node (nodejs.org)
17
+ // has no separate libnode; we just copy the binary. The remaining absolute
18
+ // `/opt/homebrew/opt/...` deps resolve by their baked-in absolute paths.
19
+ //
20
+ // SELF-HEAL: ensureBrandedHost() rebuilds the copy whenever the live node drifts
21
+ // (a `brew upgrade node` moves realpath(execPath) to a new Cellar version) or the
22
+ // copy is missing. The drift check is a cheap marker compare — no subprocess, no
23
+ // copy — on the happy path.
24
+ import { copyFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync, realpathSync, unlinkSync, } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+ import { CRTR_DIR_NAME } from '../../types.js';
28
+ /** The filename the FDA / TCC entry is shown under. */
29
+ const BRANDED_BIN_NAME = 'crouter';
30
+ /** Machine-global host dir. Deliberately NOT under `CRTR_HOME` (tests override
31
+ * that to an isolated temp): the branded binary is a per-machine artifact keyed
32
+ * to the installed node, shared across every canvas home. */
33
+ export function brandedHostDir() {
34
+ return join(homedir(), CRTR_DIR_NAME, 'host');
35
+ }
36
+ export function brandedHostBin() {
37
+ return join(brandedHostDir(), BRANDED_BIN_NAME);
38
+ }
39
+ function markerPath() {
40
+ return join(brandedHostDir(), '.source.json');
41
+ }
42
+ /** Locate the `libnode.<abi>.dylib` a Homebrew node loads via `@rpath`. node's
43
+ * rpaths are `@loader_path` and `@loader_path/../lib`, so check the binary's own
44
+ * dir first, then the sibling `lib/`. Returns the absolute path, or null for a
45
+ * statically-linked node that has no separate libnode. */
46
+ function findLibnode(nodeBin) {
47
+ const binDir = dirname(nodeBin);
48
+ for (const dir of [binDir, join(binDir, '..', 'lib')]) {
49
+ if (!existsSync(dir))
50
+ continue;
51
+ const hit = readdirSync(dir).find((f) => f.startsWith('libnode.') && f.endsWith('.dylib'));
52
+ if (hit !== undefined)
53
+ return join(dir, hit);
54
+ }
55
+ return null;
56
+ }
57
+ /** Copy `src` → `dst` via a unique temp + atomic rename, so concurrent rebuilds
58
+ * (two brokers spawning at once) never observe a half-written file. */
59
+ function atomicCopy(src, dst, mode) {
60
+ const tmp = `${dst}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
61
+ copyFileSync(src, tmp);
62
+ if (mode !== undefined)
63
+ chmodSync(tmp, mode);
64
+ renameSync(tmp, dst);
65
+ }
66
+ function readMarker() {
67
+ try {
68
+ return JSON.parse(readFileSync(markerPath(), 'utf8'));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** Ensure the branded host binary exists and matches the live node; return its
75
+ * absolute path. Idempotent and self-healing. Throws on a genuine build failure
76
+ * (disk full / permissions) — branding is load-bearing for FDA, so a failure is
77
+ * surfaced, never silently masked by falling back to `node`. */
78
+ export function ensureBrandedHost() {
79
+ const dst = brandedHostBin();
80
+ const src = realpathSync(process.execPath);
81
+ // If THIS process is already running as the branded host (the daemon or a
82
+ // broker spawning a child), don't rebuild from ourselves — we can't see the
83
+ // real node from here, and the live process proves the copy works. Drift is
84
+ // healed by the next real-`node` CLI invocation.
85
+ if (existsSync(dst) && realpathSync(dst) === src)
86
+ return dst;
87
+ const marker = readMarker();
88
+ const libnodeSrc = findLibnode(src);
89
+ const libnodeName = libnodeSrc !== null ? libnodeSrc.split('/').pop() : null;
90
+ const fresh = existsSync(dst) &&
91
+ marker !== null &&
92
+ marker.source === src &&
93
+ marker.libnode === libnodeName &&
94
+ (libnodeName === null || existsSync(join(brandedHostDir(), libnodeName)));
95
+ if (fresh)
96
+ return dst;
97
+ // (Re)build. Clear a stale libnode of a different ABI so the dir stays clean.
98
+ mkdirSync(brandedHostDir(), { recursive: true });
99
+ if (marker?.libnode != null && marker.libnode !== libnodeName) {
100
+ try {
101
+ unlinkSync(join(brandedHostDir(), marker.libnode));
102
+ }
103
+ catch {
104
+ /* best-effort */
105
+ }
106
+ }
107
+ if (libnodeSrc !== null && libnodeName !== null) {
108
+ atomicCopy(libnodeSrc, join(brandedHostDir(), libnodeName));
109
+ }
110
+ atomicCopy(src, dst, 0o755);
111
+ writeFileSync(markerPath(), JSON.stringify({ source: src, libnode: libnodeName }));
112
+ return dst;
113
+ }
114
+ /** The path the engine (broker / daemon) should be launched from. On macOS in a
115
+ * compiled (production) build this is the branded host; otherwise — non-darwin
116
+ * (TCC is macOS-only), source/tsx (dev + the test suite: never copy 70 MB into
117
+ * the real ~/.crouter), or an explicit `CRTR_BRANDED_HOST=0` opt-out — it is the
118
+ * live node. These are environment gates, not error fallbacks. */
119
+ export function hostExecPath() {
120
+ if (process.env['CRTR_BRANDED_HOST'] === '0')
121
+ return process.execPath;
122
+ if (process.platform !== 'darwin')
123
+ return process.execPath;
124
+ if (import.meta.url.endsWith('.ts'))
125
+ return process.execPath;
126
+ return ensureBrandedHost();
127
+ }