@crouton-kit/crouter 0.3.28 → 0.3.29

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 (322) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +5 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  49. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  50. package/dist/clients/attach/attach-cmd.js +282 -46
  51. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  52. package/dist/clients/attach/auth-pickers.js +194 -0
  53. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  54. package/dist/clients/attach/canvas-panels.js +45 -17
  55. package/dist/clients/attach/chat-view.d.ts +27 -2
  56. package/dist/clients/attach/chat-view.js +98 -9
  57. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  58. package/dist/clients/attach/clipboard-image.js +150 -26
  59. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  60. package/dist/clients/attach/clipboard-text.js +39 -0
  61. package/dist/clients/attach/config-load.d.ts +54 -7
  62. package/dist/clients/attach/config-load.js +99 -3
  63. package/dist/clients/attach/context-message.d.ts +12 -0
  64. package/dist/clients/attach/context-message.js +72 -0
  65. package/dist/clients/attach/git-info.d.ts +20 -0
  66. package/dist/clients/attach/git-info.js +44 -0
  67. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  68. package/dist/clients/attach/graph-overlay.js +59 -19
  69. package/dist/clients/attach/input-controller.d.ts +75 -13
  70. package/dist/clients/attach/input-controller.js +305 -67
  71. package/dist/clients/attach/pickers.d.ts +58 -0
  72. package/dist/clients/attach/pickers.js +171 -0
  73. package/dist/clients/attach/slash-commands.d.ts +22 -0
  74. package/dist/clients/attach/slash-commands.js +91 -25
  75. package/dist/clients/attach/titled-editor.d.ts +33 -0
  76. package/dist/clients/attach/titled-editor.js +84 -0
  77. package/dist/clients/attach/view-socket.d.ts +18 -1
  78. package/dist/clients/attach/view-socket.js +70 -1
  79. package/dist/clients/web/dev-server.d.ts +7 -0
  80. package/dist/clients/web/dev-server.js +59 -0
  81. package/dist/clients/web/events.d.ts +14 -0
  82. package/dist/clients/web/events.js +151 -0
  83. package/dist/clients/web/server.d.ts +18 -0
  84. package/dist/clients/web/server.js +450 -0
  85. package/dist/clients/web/web-cmd.d.ts +2 -0
  86. package/dist/clients/web/web-cmd.js +120 -0
  87. package/dist/commands/canvas.js +1 -2
  88. package/dist/commands/chord.js +1 -1
  89. package/dist/commands/dashboard.js +5 -1
  90. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  91. package/dist/commands/memory/lint.js +14 -2
  92. package/dist/commands/memory/read.js +2 -2
  93. package/dist/commands/memory/shared.d.ts +7 -1
  94. package/dist/commands/memory/shared.js +18 -3
  95. package/dist/commands/memory/write.js +8 -4
  96. package/dist/commands/memory.js +4 -4
  97. package/dist/commands/node.d.ts +1 -1
  98. package/dist/commands/node.js +64 -65
  99. package/dist/commands/pkg.js +1 -2
  100. package/dist/commands/revive.js +11 -3
  101. package/dist/commands/sys/sync.d.ts +1 -0
  102. package/dist/commands/sys/sync.js +187 -0
  103. package/dist/commands/sys.js +3 -2
  104. package/dist/commands/view-cycle.js +2 -2
  105. package/dist/commands/view-list.js +8 -8
  106. package/dist/commands/view-new.js +21 -17
  107. package/dist/commands/view-pick.js +1 -1
  108. package/dist/commands/view-run.js +35 -14
  109. package/dist/commands/view.js +8 -6
  110. package/dist/commands/workspace.d.ts +2 -0
  111. package/dist/commands/workspace.js +161 -0
  112. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  114. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  116. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  117. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  118. package/dist/core/__tests__/close.test.js +14 -20
  119. package/dist/core/__tests__/context-intro.test.js +19 -19
  120. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  121. package/dist/core/__tests__/draw-style.test.js +1 -1
  122. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  123. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  124. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  125. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  126. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  127. package/dist/core/__tests__/focuses.test.js +25 -13
  128. package/dist/core/__tests__/fork.test.js +22 -5
  129. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  130. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  131. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  133. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  135. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  136. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  137. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  138. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  139. package/dist/core/__tests__/helpers/harness.js +19 -36
  140. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  141. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  142. package/dist/core/__tests__/lifecycle.test.js +1 -19
  143. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  144. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  145. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  146. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  147. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  148. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  149. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  150. package/dist/core/__tests__/reset.test.js +19 -79
  151. package/dist/core/__tests__/steer-note.test.js +9 -7
  152. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  153. package/dist/core/bootstrap.d.ts +0 -3
  154. package/dist/core/bootstrap.js +1 -143
  155. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  156. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  157. package/dist/core/canvas/browse/app.js +131 -8
  158. package/dist/core/canvas/browse/model.d.ts +23 -5
  159. package/dist/core/canvas/browse/model.js +68 -13
  160. package/dist/core/canvas/browse/render.d.ts +7 -1
  161. package/dist/core/canvas/browse/render.js +178 -47
  162. package/dist/core/canvas/canvas.js +1 -1
  163. package/dist/core/canvas/focuses.d.ts +4 -4
  164. package/dist/core/canvas/focuses.js +7 -7
  165. package/dist/core/canvas/nav-model.js +8 -7
  166. package/dist/core/canvas/render.d.ts +68 -9
  167. package/dist/core/canvas/render.js +195 -61
  168. package/dist/core/canvas/types.d.ts +16 -23
  169. package/dist/core/config.js +1 -4
  170. package/dist/core/memory-resolver.js +43 -5
  171. package/dist/core/runtime/bearings.d.ts +1 -1
  172. package/dist/core/runtime/bearings.js +8 -8
  173. package/dist/core/runtime/branded-host.d.ts +16 -0
  174. package/dist/core/runtime/branded-host.js +127 -0
  175. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  176. package/dist/core/runtime/broker.d.ts +4 -1
  177. package/dist/core/runtime/broker.js +494 -58
  178. package/dist/core/runtime/close.d.ts +12 -2
  179. package/dist/core/runtime/close.js +37 -16
  180. package/dist/core/runtime/front-door.js +6 -14
  181. package/dist/core/runtime/host.d.ts +14 -34
  182. package/dist/core/runtime/host.js +14 -50
  183. package/dist/core/runtime/launch.d.ts +12 -9
  184. package/dist/core/runtime/launch.js +27 -15
  185. package/dist/core/runtime/lifecycle.d.ts +1 -1
  186. package/dist/core/runtime/lifecycle.js +15 -19
  187. package/dist/core/runtime/nodes.d.ts +0 -55
  188. package/dist/core/runtime/nodes.js +6 -74
  189. package/dist/core/runtime/placement.d.ts +91 -335
  190. package/dist/core/runtime/placement.js +262 -840
  191. package/dist/core/runtime/promote.d.ts +2 -0
  192. package/dist/core/runtime/promote.js +24 -6
  193. package/dist/core/runtime/recap.d.ts +8 -0
  194. package/dist/core/runtime/recap.js +107 -0
  195. package/dist/core/runtime/recycle.js +25 -61
  196. package/dist/core/runtime/reset.d.ts +43 -37
  197. package/dist/core/runtime/reset.js +131 -218
  198. package/dist/core/runtime/revive.d.ts +9 -29
  199. package/dist/core/runtime/revive.js +40 -139
  200. package/dist/core/runtime/spawn.d.ts +15 -16
  201. package/dist/core/runtime/spawn.js +165 -194
  202. package/dist/core/runtime/surface-bg.d.ts +11 -0
  203. package/dist/core/runtime/surface-bg.js +68 -0
  204. package/dist/core/runtime/tmux.d.ts +13 -50
  205. package/dist/core/runtime/tmux.js +33 -87
  206. package/dist/core/scope.d.ts +1 -2
  207. package/dist/core/scope.js +1 -5
  208. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  209. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  210. package/dist/core/skill-sync/builtins.d.ts +42 -0
  211. package/dist/core/skill-sync/builtins.js +112 -0
  212. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  213. package/dist/core/skill-sync/claude-plugins.js +71 -0
  214. package/dist/core/skill-sync/engine.d.ts +42 -0
  215. package/dist/core/skill-sync/engine.js +633 -0
  216. package/dist/core/skill-sync/export.d.ts +23 -0
  217. package/dist/core/skill-sync/export.js +86 -0
  218. package/dist/core/skill-sync/manifest.d.ts +64 -0
  219. package/dist/core/skill-sync/manifest.js +181 -0
  220. package/dist/core/skill-sync/profile.d.ts +76 -0
  221. package/dist/core/skill-sync/profile.js +173 -0
  222. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  223. package/dist/core/skill-sync/snapshot.js +120 -0
  224. package/dist/core/substrate/index.d.ts +1 -1
  225. package/dist/core/substrate/index.js +1 -1
  226. package/dist/core/substrate/injected-store.js +3 -3
  227. package/dist/core/substrate/on-read.js +68 -6
  228. package/dist/core/substrate/render.d.ts +8 -11
  229. package/dist/core/substrate/render.js +29 -43
  230. package/dist/core/substrate/schema.d.ts +10 -3
  231. package/dist/core/substrate/schema.js +6 -3
  232. package/dist/core/tui/host.d.ts +12 -4
  233. package/dist/core/tui/host.js +280 -149
  234. package/dist/core/view/bridge.d.ts +10 -0
  235. package/dist/core/view/bridge.js +31 -0
  236. package/dist/core/view/chrome.d.ts +9 -0
  237. package/dist/core/view/chrome.js +22 -0
  238. package/dist/core/view/contract.d.ts +171 -0
  239. package/dist/core/view/contract.js +23 -0
  240. package/dist/core/view/loader.d.ts +31 -0
  241. package/dist/core/view/loader.js +188 -0
  242. package/dist/core/view/transport-local.d.ts +7 -0
  243. package/dist/core/view/transport-local.js +70 -0
  244. package/dist/core/view/transport.d.ts +4 -0
  245. package/dist/core/view/transport.js +15 -0
  246. package/dist/daemon/crtrd.d.ts +38 -27
  247. package/dist/daemon/crtrd.js +303 -376
  248. package/dist/daemon/manage.js +6 -1
  249. package/dist/index.js +1 -1
  250. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  251. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  252. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  253. package/dist/pi-extensions/canvas-nav.js +5 -0
  254. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  255. package/dist/pi-extensions/canvas-recap.js +236 -0
  256. package/dist/pi-extensions/canvas-resume.js +2 -1
  257. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  258. package/dist/pi-extensions/canvas-stophook.js +87 -101
  259. package/dist/pi-extensions/canvas-view.js +2 -1
  260. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  261. package/dist/pi-extensions/widget-order-bus.js +34 -0
  262. package/dist/prompts/view.d.ts +2 -2
  263. package/dist/prompts/view.js +145 -73
  264. package/dist/types.d.ts +1 -6
  265. package/dist/types.js +1 -3
  266. package/dist/web/ViewChrome.d.ts +7 -0
  267. package/dist/web/ViewChrome.js +28 -0
  268. package/dist/web/ViewPane.d.ts +39 -0
  269. package/dist/web/ViewPane.js +48 -0
  270. package/dist/web/index.d.ts +6 -0
  271. package/dist/web/index.js +16 -0
  272. package/dist/web/runtime.d.ts +39 -0
  273. package/dist/web/runtime.js +133 -0
  274. package/dist/web/states.d.ts +24 -0
  275. package/dist/web/states.js +24 -0
  276. package/dist/web/transport-http.d.ts +5 -0
  277. package/dist/web/transport-http.js +28 -0
  278. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  279. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  280. package/dist/web-client/index.html +13 -0
  281. package/package.json +20 -6
  282. package/dist/builtin-views/canvas/client.mjs +0 -303
  283. package/dist/builtin-views/canvas/view.mjs +0 -576
  284. package/dist/builtin-views/git-pr/client.mjs +0 -440
  285. package/dist/builtin-views/git-pr/view.mjs +0 -675
  286. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  287. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  288. package/dist/builtin-views/inbox/view.mjs +0 -889
  289. package/dist/builtin-views/linkedin/client.mjs +0 -610
  290. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  291. package/dist/commands/pkg/bridge.d.ts +0 -1
  292. package/dist/commands/pkg/bridge.js +0 -137
  293. package/dist/commands/skill/author.d.ts +0 -3
  294. package/dist/commands/skill/author.js +0 -140
  295. package/dist/commands/skill/shared.d.ts +0 -3
  296. package/dist/commands/skill/shared.js +0 -19
  297. package/dist/commands/skill.d.ts +0 -2
  298. package/dist/commands/skill.js +0 -21
  299. package/dist/commands/tmux-spread.d.ts +0 -2
  300. package/dist/commands/tmux-spread.js +0 -144
  301. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  302. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  303. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  304. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  305. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  306. package/dist/core/__tests__/home-session.test.js +0 -186
  307. package/dist/core/__tests__/relaunch.test.js +0 -335
  308. package/dist/core/bridge-map.d.ts +0 -19
  309. package/dist/core/bridge-map.js +0 -73
  310. package/dist/core/tui/contract.d.ts +0 -83
  311. package/dist/core/tui/contract.js +0 -8
  312. package/dist/core/tui/loader.d.ts +0 -16
  313. package/dist/core/tui/loader.js +0 -94
  314. package/dist/prompts/skill.d.ts +0 -2
  315. package/dist/prompts/skill.js +0 -650
  316. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  317. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  318. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
@@ -0,0 +1,161 @@
1
+ // workspace.ts — `crtr workspace enter`: the agent-centric editor layout.
2
+ //
3
+ // Turns the current tmux window into an editor view for attached headless nodes:
4
+ // a narrow LEFT rail (the `workspace-sidebar` view — this graph + other live
5
+ // agents in this cwd, with ⚑ inbox flags) beside a CHAT pane (`crtr attach`).
6
+ // Selecting a node in the rail swaps it into the chat pane.
7
+ //
8
+ // Layout build (the only two driver acts, both through placement's seam):
9
+ // 1. splitWindow(here, before+size) → the rail pane, left of here, runs
10
+ // `crtr view run workspace-sidebar`.
11
+ // 2. `crtr node focus <node> --pane <here>` → swaps the chat node's attach
12
+ // viewer into here's slot (here's old content relocates to the backstage).
13
+ // The rail finds the chat pane by DISCOVERY (the `@crtr_node` tag attach sets),
14
+ // so it is robust to that swap — no launch-time pane id is threaded into it.
15
+ //
16
+ // Command shape: the path-walker forbids a flat top-level leaf (same constraint
17
+ // as attach), so this is a BRANCH wrapping one leaf: `crtr workspace enter`.
18
+ import { execFile } from 'node:child_process';
19
+ import { defineBranch, defineLeaf } from '../core/command.js';
20
+ import { InputError } from '../core/io.js';
21
+ import { getNode, listNodes } from '../core/canvas/index.js';
22
+ import { inTmux, currentTmux, splitWindow, piCommand } from '../core/runtime/placement.js';
23
+ const RAIL_WIDTH = 32;
24
+ /** Newest LIVE (active|idle) forest root started in `cwd` — the default node the
25
+ * chat pane attaches to when `--node` is omitted. A root has no parent inside
26
+ * the cwd-scoped set. Returns null when this cwd has no live root. */
27
+ function defaultChatNode(cwd) {
28
+ const rows = listNodes({ status: ['active', 'idle'] }).filter((r) => r.cwd === cwd);
29
+ if (rows.length === 0)
30
+ return null;
31
+ const ids = new Set(rows.map((r) => r.node_id));
32
+ const roots = rows.filter((r) => r.parent === null || !ids.has(r.parent));
33
+ const pool = roots.length > 0 ? roots : rows;
34
+ pool.sort((a, b) => (a.created < b.created ? 1 : -1)); // newest first
35
+ return pool[0].node_id;
36
+ }
37
+ const workspaceEnterLeaf = defineLeaf({
38
+ name: 'enter',
39
+ description: 'enter the editor view — a node rail beside a chat pane, in this window',
40
+ whenToUse: 'you want to work in the agent-centric editor layout: a left rail listing this graph + the other live agents in this working directory (with ⚑ inbox flags) beside a chat pane attached to a node. Selecting a node in the rail swaps it into the chat pane. Opens in the CURRENT tmux window. tmux-only.',
41
+ help: {
42
+ name: 'workspace enter',
43
+ summary: 'split the current window into a left node-rail (the workspace-sidebar view) + a chat pane (crtr attach); ↵ in the rail swaps a node into the chat. tmux-only.',
44
+ params: [
45
+ {
46
+ kind: 'flag',
47
+ name: 'node',
48
+ type: 'string',
49
+ required: false,
50
+ constraint: 'Node to attach in the chat pane. Default: the newest live root in this cwd (rail-only, empty chat, if none).',
51
+ },
52
+ {
53
+ kind: 'flag',
54
+ name: 'width',
55
+ type: 'int',
56
+ required: false,
57
+ default: RAIL_WIDTH,
58
+ constraint: `Rail width in columns (default ${RAIL_WIDTH}).`,
59
+ },
60
+ ],
61
+ output: [
62
+ { name: 'rail', type: 'string', required: false, constraint: 'tmux pane id of the rail.' },
63
+ { name: 'chat', type: 'string', required: false, constraint: 'node id attached in the chat pane (absent if none).' },
64
+ { name: 'note', type: 'string', required: false, constraint: 'Set only on the non-TTY/piped path.' },
65
+ ],
66
+ outputKind: 'object',
67
+ effects: [
68
+ 'Splits a narrow LEFT pane in the current window running the workspace-sidebar view.',
69
+ 'Swaps the chosen (or default) node into the current pane as a chat (crtr node focus), relocating the current pane to the backstage.',
70
+ 'Outside a TTY/tmux: prints a notice and exits 0 (no layout change).',
71
+ ],
72
+ },
73
+ run: async (input) => {
74
+ const node = input['node'];
75
+ const width = input['width'] ?? RAIL_WIDTH;
76
+ if (!process.stdout.isTTY) {
77
+ return { note: 'crtr workspace enter builds a tmux layout — run it in a tmux pane (a TTY), not a pipe.' };
78
+ }
79
+ if (!inTmux()) {
80
+ throw new InputError({
81
+ error: 'not_in_tmux',
82
+ message: 'crtr workspace is tmux-only — run it inside the crtr tmux session',
83
+ next: 'Open it from inside tmux.',
84
+ });
85
+ }
86
+ const here = currentTmux();
87
+ if (here === null) {
88
+ throw new InputError({
89
+ error: 'no_tmux_location',
90
+ message: 'could not resolve the current tmux pane',
91
+ next: 'Run this inside a tmux pane.',
92
+ });
93
+ }
94
+ // Resolve the chat node (explicit --node, else newest live root here).
95
+ let chatNode = null;
96
+ if (node !== undefined && node !== '') {
97
+ if (getNode(node) === null) {
98
+ throw new InputError({
99
+ error: 'not_found',
100
+ message: `no node: ${node}`,
101
+ received: node,
102
+ next: 'List nodes with `crtr node inspect list`.',
103
+ });
104
+ }
105
+ chatNode = node;
106
+ }
107
+ else {
108
+ chatNode = defaultChatNode(process.cwd());
109
+ }
110
+ // 1. The rail — a narrow split to the LEFT of here. It discovers the chat
111
+ // pane itself (the @crtr_node tag), so no pane id is threaded in.
112
+ const railCmd = piCommand(['view', 'run', 'workspace-sidebar', '--target', here.pane], 'crtr');
113
+ const rail = splitWindow(here.pane, {
114
+ cwd: process.cwd(),
115
+ env: {},
116
+ command: railCmd,
117
+ before: true,
118
+ size: Math.max(16, width),
119
+ });
120
+ if (rail === null) {
121
+ throw new InputError({
122
+ error: 'split_failed',
123
+ message: 'tmux could not split the rail pane',
124
+ next: 'Check the tmux server is reachable, then retry.',
125
+ });
126
+ }
127
+ // 2. The chat — swap the node's attach viewer into here's slot. Best-effort:
128
+ // the rail is already up, so a focus failure degrades to a rail-only view
129
+ // the user can drive (↵ a node) rather than aborting the whole layout.
130
+ if (chatNode !== null) {
131
+ await new Promise((resolve) => {
132
+ execFile('crtr', ['node', 'focus', chatNode, '--pane', here.pane], () => resolve());
133
+ });
134
+ }
135
+ return chatNode !== null ? { rail, chat: chatNode } : { rail };
136
+ },
137
+ render: (result) => {
138
+ if (result['note'] !== undefined)
139
+ return String(result['note']);
140
+ const chat = result['chat'];
141
+ return chat !== undefined
142
+ ? `Workspace ready — rail ${result['rail']}, chat attached to ${chat}.`
143
+ : `Workspace ready — rail ${result['rail']}. No live agent here yet; press ↵ on a node in the rail to open one.`;
144
+ },
145
+ });
146
+ export function registerWorkspace() {
147
+ return defineBranch({
148
+ name: 'workspace',
149
+ rootEntry: {
150
+ concept: 'the agent-centric editor view — a node rail beside a chat pane, in one tmux window',
151
+ desc: 'enter the editor layout for attached headless nodes',
152
+ useWhen: 'you want an editor-style workspace in tmux: a left rail of this graph + other live agents in this cwd (with ⚑ inbox flags) next to a chat pane attached to a node, where selecting a node in the rail swaps it into the chat. tmux-only.',
153
+ },
154
+ help: {
155
+ name: 'workspace',
156
+ summary: 'enter the agent-centric editor view (node rail + chat pane) in the current tmux window',
157
+ model: '`enter` splits the current window into a narrow left rail (the workspace-sidebar view — this graph + other live agents in this cwd, ⚑ inbox flags) and a chat pane (`crtr attach`) attached to a node (--node, or the newest live root here). ↵ in the rail swaps the selected node into the chat pane. tmux-only; piped it prints a notice and exits 0.',
158
+ },
159
+ children: [workspaceEnterLeaf],
160
+ });
161
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-double-spawn.test.ts
2
+ //
3
+ // BUG-REGRESSION (broker-universal-host cut, design §C "double-spawn verdict";
4
+ // taste/broker-is-the-host). Two coupled invariants the cut introduced:
5
+ //
6
+ // 1. A pre-cut row carries host_kind='tmux' (or null). While its RAW pi pid is
7
+ // still ALIVE the daemon must NEVER relaunch it — a relaunch would put a
8
+ // SECOND vehicle on the same .jsonl (the double-spawn). Liveness is the
9
+ // recorded engine pid alone; an alive pid is always 'leave'.
10
+ // 2. When that pid dies and the node revives, it comes back as a BROKER and the
11
+ // row's host_kind is lazily COERCED tmux→'broker' (reviveNode, revive.ts
12
+ // ~91-97) so inspect/history read honest values and the daemon never
13
+ // branches it back to a tmux host.
14
+ //
15
+ // Three legs, strongest→cheapest: the pure verdict (D1a), the direct reviveNode
16
+ // coerce (D1b), and the full daemon integration over a real live→dead pid (D1c).
17
+ // Regression check: if the cut's coerce is removed, D1b/D1c go RED (host_kind
18
+ // stays 'tmux'); if an alive pid is ever revived, D1c's "no relaunch" asserts go
19
+ // RED (cycles bumps / host_kind flips while the pid is still alive).
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import { spawn, spawnSync } from 'node:child_process';
23
+ import { createHeadlessHarness } from './helpers/harness.js';
24
+ import { createNode } from '../canvas/canvas.js';
25
+ import { closeDb } from '../canvas/db.js';
26
+ import { reviveNode } from '../runtime/revive.js';
27
+ import { livenessVerdict, isPidAlive } from '../../daemon/crtrd.js';
28
+ // A pid reaped (dead) by the time spawnSync returns — the "dead but supervised"
29
+ // pid the grace path judges.
30
+ function deadPid() {
31
+ const r = spawnSync('true', [], { stdio: 'ignore' });
32
+ return r.pid ?? 0x7ffffffe;
33
+ }
34
+ /** Fabricate a PRE-CUT host_kind='tmux' row directly (the harness's
35
+ * fabricateBrokerNode hardcodes host_kind:'broker', so we mirror its body here
36
+ * with host_kind:'tmux' — no harness edit). The result is a row the daemon
37
+ * supervises by pid exactly as a legacy tmux node would be. */
38
+ function fabricateTmuxNode(o) {
39
+ const meta = {
40
+ node_id: o.id,
41
+ name: o.id,
42
+ created: new Date().toISOString(),
43
+ cwd: process.cwd(),
44
+ host_kind: 'tmux',
45
+ kind: 'developer',
46
+ mode: 'base',
47
+ lifecycle: 'terminal',
48
+ parent: null,
49
+ pi_session_id: o.pi_session_id,
50
+ status: 'active',
51
+ intent: null,
52
+ pi_pid: o.pi_pid,
53
+ };
54
+ createNode(meta);
55
+ closeDb();
56
+ return o.id;
57
+ }
58
+ // REVIVE_GRACE_MS is 20_000 (crtrd.ts); pick offsets well inside / well past it.
59
+ const T_ALIVE = 6_000_000;
60
+ const T_DEAD = 6_100_000; // first dead observation
61
+ const T_PAST = T_DEAD + 25_000; // > 20s after first-observed-dead → revive
62
+ // ---------------------------------------------------------------------------
63
+ // D1a — pure verdict: an alive pid is NEVER relaunched (no double-spawn).
64
+ // ---------------------------------------------------------------------------
65
+ test('D1a livenessVerdict: an ALIVE pid is left alone (never a double-spawn); a DEAD pid pends then revives', () => {
66
+ assert.equal(livenessVerdict(true, null), 'leave', 'alive pid, first obs → leave');
67
+ assert.equal(livenessVerdict(true, 10_000_000), 'leave', 'alive pid is NEVER relaunched');
68
+ assert.equal(livenessVerdict(false, null), 'pending', 'dead, first obs → pending (double-spawn guard)');
69
+ assert.equal(livenessVerdict(false, 1_000), 'pending', 'dead, within grace → pending');
70
+ assert.equal(livenessVerdict(false, 10_000_000), 'revive', 'dead past grace → revive');
71
+ });
72
+ // ---------------------------------------------------------------------------
73
+ // D1b — direct reviveNode coerce: a tmux row flips to 'broker' on revive.
74
+ // ---------------------------------------------------------------------------
75
+ test('D1b reviveNode coerces a pre-cut host_kind=tmux row to broker', async () => {
76
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d1b' });
77
+ try {
78
+ const id = fabricateTmuxNode({ id: 'd1b-node', pi_pid: deadPid(), pi_session_id: 'sess-d1b' });
79
+ assert.equal(h.node(id).host_kind, 'tmux', 'precondition: row is a legacy tmux host');
80
+ // reviveNode's lazy coerce runs at the top, before any launch (revive.ts ~91-97).
81
+ reviveNode(id, { resume: true });
82
+ assert.equal(h.node(id).host_kind, 'broker', 'tmux→broker coerced on revive (the daemon never branches it back)');
83
+ }
84
+ finally {
85
+ await h.dispose();
86
+ }
87
+ });
88
+ // ---------------------------------------------------------------------------
89
+ // D1c — full integration: an ALIVE tmux pid is NOT relaunched; once it dies the
90
+ // node revives as a broker (host_kind coerced, cycles bumped).
91
+ // ---------------------------------------------------------------------------
92
+ test('D1c daemon: a live tmux pi is never double-spawned; on death it revives as a broker', { timeout: 30_000 }, async () => {
93
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d1c' });
94
+ // A real live subprocess whose pid stands in for the raw pi vehicle.
95
+ const child = spawn('sleep', ['300'], { stdio: 'ignore' });
96
+ const livePid = child.pid;
97
+ try {
98
+ assert.equal(isPidAlive(livePid), true, 'precondition: the raw pi pid is alive');
99
+ // pi_session_id SET so the dead-pid path routes the crash/grace revive, NOT
100
+ // the never-booted boot-failure branch.
101
+ const id = fabricateTmuxNode({ id: 'd1c-node', pi_pid: livePid, pi_session_id: 'sess-d1c' });
102
+ assert.equal(h.node(id).host_kind, 'tmux', 'precondition: pre-cut tmux host');
103
+ assert.equal(h.node(id).cycles ?? 0, 0, 'no revive yet');
104
+ // --- TICK while the pi is ALIVE: must be a no-op. The daemon never
105
+ // relaunches a live pid, so reviveNode is never called → NO coerce, NO
106
+ // cycle bump, pid unchanged. This is the double-spawn guard. ---
107
+ await h.tick(T_ALIVE);
108
+ {
109
+ const n = h.node(id);
110
+ assert.equal(n.host_kind, 'tmux', 'live pid: host_kind NOT coerced (reviveNode never ran → no double-spawn)');
111
+ assert.equal(n.cycles ?? 0, 0, 'live pid: NO revive (cycles still 0)');
112
+ assert.equal(n.pi_pid, livePid, 'live pid: pi_pid unchanged — no second vehicle launched');
113
+ }
114
+ // Kill the raw pi and wait until the daemon would observe it dead.
115
+ child.kill('SIGKILL');
116
+ await h.waitFor(() => (isPidAlive(livePid) ? null : true), { label: 'raw pi dead', timeoutMs: 10_000 });
117
+ // --- TICK: first dead observation → pending (within grace). No revive. ---
118
+ await h.tick(T_DEAD);
119
+ {
120
+ const n = h.node(id);
121
+ assert.equal(n.cycles ?? 0, 0, 'first-observed-dead: pends, no revive yet');
122
+ assert.equal(n.host_kind, 'tmux', 'still tmux until the revive actually fires');
123
+ }
124
+ // --- TICK past the grace: the crash/grace revive fires → reviveNode coerces
125
+ // host_kind tmux→broker and bumps cycles (observable instantly, the
126
+ // model effect lands before the detached broker spawn — grace-clock). ---
127
+ await h.tick(T_PAST);
128
+ {
129
+ const n = h.node(id);
130
+ assert.equal(n.cycles ?? 0, 1, 'past grace: revived → cycles bumped to 1');
131
+ assert.equal(n.host_kind, 'broker', 'past grace: host_kind coerced tmux→broker on revive');
132
+ }
133
+ }
134
+ finally {
135
+ try {
136
+ child.kill('SIGKILL');
137
+ }
138
+ catch {
139
+ /* already gone */
140
+ }
141
+ await h.dispose();
142
+ }
143
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-fork-seam.test.ts
2
+ //
3
+ // BUG-REGRESSION (broker-universal-host cut, design §B.broker; U1 report
4
+ // mq8ftenr-5d55313d). Before the cut, the headless broker had NO fork path — its
5
+ // session selection THREW `--fork is not supported by the headless broker`. After
6
+ // the cut EVERY node is a broker, so `node new --fork-from <id>` would always hit
7
+ // that throw. The fix routes the broker's fork through pi's REAL fork seam,
8
+ // SessionManager.forkFrom (broker.ts buildBrokerSession, the `forking` branch) —
9
+ // the same method pi's own `--fork` CLI flag uses.
10
+ //
11
+ // This test drives the BROKER's OWN fork branch (broker.ts buildBrokerSession,
12
+ // the `forking` branch ~1106) end to end: it calls `buildBrokerSession` with
13
+ // `cfg.forkFrom` set to a real source session .jsonl — the EXACT path a
14
+ // `node new --fork-from <id>` boot takes through the broker — and asserts the
15
+ // resulting live session is a genuine fork: a NEW session id (not a colliding
16
+ // copy) and a `parentSession` header pointing at the source, with the source's
17
+ // history copied in. Because it invokes `buildBrokerSession` (not
18
+ // `SessionManager.forkFrom` directly), it goes RED if broker.ts's fork branch
19
+ // reverts to `throw "--fork is not supported by the headless broker"` — it
20
+ // guards its own stated regression.
21
+ //
22
+ // Like the C3/C4 real-SDK wiring tests, it drives the REAL engine (broker-sdk's
23
+ // static SDK re-exports) — NOT the CRTR_BROKER_ENGINE fake the lifecycle suite
24
+ // uses, whose fixture omits forkFrom and so could never exercise this branch. It
25
+ // runs offline (fork + session/services assembly are all local: no network/auth).
26
+ //
27
+ // (fork.test.ts covers the pure argv/resolve layers — buildPiArgv emitting
28
+ // `--fork`, resolveForkSource turning a ref into a path. THIS file covers the
29
+ // broker runtime branch those layers feed.)
30
+ import { test, before, after } from 'node:test';
31
+ import assert from 'node:assert/strict';
32
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { join } from 'node:path';
35
+ import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
36
+ import { buildBrokerSession } from '../runtime/broker.js';
37
+ // The REAL engine — bypasses the CRTR_BROKER_ENGINE fake entirely (the whole
38
+ // point: the fake omits forkFrom, so only the real SDK can prove the branch).
39
+ const realEngine = {
40
+ createAgentSessionServices,
41
+ createAgentSessionFromServices,
42
+ SessionManager,
43
+ VERSION,
44
+ };
45
+ let dir;
46
+ before(() => {
47
+ dir = mkdtempSync(join(tmpdir(), 'crtr-fork-seam-'));
48
+ });
49
+ after(() => {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ });
52
+ /** A minimal but VALID pi session .jsonl (the exact on-disk shape v3 emits): a
53
+ * `session` header line + two `message` entries. This is what a node that has
54
+ * run a turn or two carries on disk — the source the broker forks from. */
55
+ function writeSourceSession(path, srcId) {
56
+ const lines = [
57
+ JSON.stringify({ type: 'session', version: 3, id: srcId, timestamp: '2026-06-10T20:27:32.891Z', cwd: dir }),
58
+ JSON.stringify({ type: 'message', id: 'a42170e1', parentId: null, timestamp: '2026-06-10T20:27:32.893Z', message: { role: 'user', content: 'source turn one' } }),
59
+ JSON.stringify({ type: 'message', id: 'f309b932', parentId: 'a42170e1', timestamp: '2026-06-10T20:27:32.893Z', message: { role: 'assistant', content: 'source reply one' } }),
60
+ ];
61
+ writeFileSync(path, lines.join('\n') + '\n');
62
+ return 2; // entry count (the two message lines)
63
+ }
64
+ test('D2 broker fork branch: buildBrokerSession(cfg.forkFrom) yields a NEW id + parentSession header, copying history (never throws "fork not supported")', async () => {
65
+ // 1. A real source session on disk.
66
+ const srcFile = join(dir, 'src.jsonl');
67
+ const srcId = '019eb338-029b-7536-b3eb-d33bd6f641a8';
68
+ const srcEntryCount = writeSourceSession(srcFile, srcId);
69
+ // 2. Drive the BROKER's fork branch: buildBrokerSession with cfg.forkFrom set —
70
+ // the same recipe a `node new --fork-from <id>` boot produces. The OLD broker
71
+ // THREW in this branch ("--fork is not supported by the headless broker"), so
72
+ // if broker.ts:~1106 reverts to that throw, this rejects and the test goes RED.
73
+ const cfg = { cwd: dir, extensionPaths: [], forkFrom: srcFile };
74
+ let session;
75
+ let resuming;
76
+ await assert.doesNotReject(async () => {
77
+ const built = await buildBrokerSession(realEngine, cfg);
78
+ session = built.session;
79
+ resuming = built.resuming;
80
+ }, 'the broker fork branch must NOT throw "fork is not supported by the headless broker"');
81
+ assert.ok(session !== undefined, 'buildBrokerSession returned a live session for the fork');
82
+ try {
83
+ // 3. A fork boots as a FRESH spawn (resuming=false) so the kickoff firstPrompt
84
+ // fires — the new node gets the source history AND a new task to start on.
85
+ assert.equal(resuming, false, 'a fork is a fresh spawn (resuming=false) so the kickoff fires');
86
+ // 4. Real fork metadata on the broker's selected SessionManager, not a
87
+ // colliding .jsonl copy.
88
+ const sm = session.sessionManager;
89
+ assert.notEqual(sm.getSessionId(), srcId, 'the fork is a NEW session id (not a copy that would collide on resume)');
90
+ const header = sm.getHeader();
91
+ assert.equal(header?.parentSession, srcFile, 'the fork header records parentSession pointing at the source .jsonl');
92
+ // 5. The source history is carried into the fork (fork copies, not empty
93
+ // start). The broker boot may append its own session-start entry, so the
94
+ // fork holds AT LEAST the source's messages — a naïve empty session would
95
+ // have zero.
96
+ assert.ok(sm.getEntries().length >= srcEntryCount, `the fork carries the source conversation history forward ` +
97
+ `(>= ${srcEntryCount} source entries; got ${sm.getEntries().length})`);
98
+ }
99
+ finally {
100
+ session.dispose();
101
+ }
102
+ });
@@ -107,6 +107,7 @@ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately
107
107
  throw new Error('C2: must NOT forward a dialog when no controller is attached');
108
108
  },
109
109
  pending: new Map(),
110
+ broadcast: () => { },
110
111
  });
111
112
  // confirm() WITH a large timeout: the fix must resolve at once (deny), NOT after
112
113
  // the timeout — the old timeout-reliant path would have waited 60s (or, with no
@@ -144,6 +145,7 @@ test('M2 — a forwarded dialog stays pending on controller detach, resolving on
144
145
  },
145
146
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
147
  pending: pending,
148
+ broadcast: () => { },
147
149
  });
148
150
  // Controller attached → confirm() forwards + registers a pending dialog (with the
149
151
  // request retained for welcome.pending_dialog / re-route, T4) and does NOT