@north-light/crouter 0.3.197 → 0.3.199

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 (339) hide show
  1. package/dist/api/client.d.ts +19 -19
  2. package/dist/api/client.js +29 -37
  3. package/dist/api/dto/broker-ops.d.ts +3 -1
  4. package/dist/api/dto/broker.d.ts +13 -2
  5. package/dist/api/dto/broker.js +4 -4
  6. package/dist/api/dto/human.d.ts +6 -5
  7. package/dist/api/dto/inbox.d.ts +126 -33
  8. package/dist/api/dto/nodes.d.ts +8 -2
  9. package/dist/api/dto/profiles.d.ts +23 -0
  10. package/dist/api/dto/profiles.js +2 -2
  11. package/dist/api/dto/worktree.d.ts +3 -5
  12. package/dist/api/routes.d.ts +3 -5
  13. package/dist/api/routes.js +4 -6
  14. package/dist/builtin-memory/00-runtime-base.md +8 -8
  15. package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
  16. package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +6 -6
  18. package/dist/builtin-memory/05-kinds/design/00-base.md +1 -1
  19. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +2 -2
  20. package/dist/builtin-memory/05-kinds/review/00-base.md +2 -2
  21. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  22. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -1
  23. package/dist/builtin-memory/insights/capture.md +2 -2
  24. package/dist/builtin-memory/insights/init.md +3 -3
  25. package/dist/builtin-memory/internal/INDEX.md +1 -1
  26. package/dist/builtin-memory/internal/memory-loading.md +1 -1
  27. package/dist/builtin-memory/internal/nodes-and-canvas.md +3 -3
  28. package/dist/builtin-memory/internal/plugins.md +59 -2
  29. package/dist/builtin-memory/internal/storage-tiers.md +3 -1
  30. package/dist/builtin-memory/spec/roadmap.md +2 -2
  31. package/dist/builtin-memory/testing.md +1 -1
  32. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
  33. package/dist/clients/attach/chrome/bash-jobs.js +3 -3
  34. package/dist/clients/attach/render/chat-view.d.ts +29 -9
  35. package/dist/clients/attach/render/chat-view.js +64 -35
  36. package/dist/clients/attach/render/markdown-source.js +0 -6
  37. package/dist/clients/attach/render/page-block.d.ts +34 -0
  38. package/dist/clients/attach/render/page-block.js +226 -0
  39. package/dist/clients/attach/render/tool-calls.js +16 -2
  40. package/dist/clients/attach/session/whip.d.ts +2 -0
  41. package/dist/clients/attach/session/whip.js +2 -0
  42. package/dist/clients/attach/viewer.js +1419 -982
  43. package/dist/clients/conversation/projection.js +18 -3
  44. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
  45. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -14
  46. package/dist/clients/inbox/controller.js +10 -16
  47. package/dist/clients/inbox/page-adapter.d.ts +3 -4
  48. package/dist/clients/inbox/page-adapter.js +3 -4
  49. package/dist/clients/inbox/resolve.d.ts +1 -1
  50. package/dist/clients/inbox/resolve.js +3 -3
  51. package/dist/clients/inbox/review/document-surface.js +3 -1
  52. package/dist/clients/inbox/review/state.d.ts +2 -1
  53. package/dist/clients/inbox/review/state.js +5 -16
  54. package/dist/clients/inbox/tui/input.js +37 -19
  55. package/dist/clients/inbox/tui/panel.js +5 -31
  56. package/dist/clients/inbox/tui/render.d.ts +11 -0
  57. package/dist/clients/inbox/tui/render.js +101 -36
  58. package/dist/clients/inbox/tui/slots.d.ts +4 -4
  59. package/dist/clients/inbox/tui/slots.js +19 -44
  60. package/dist/clients/inbox/tui/types.d.ts +6 -10
  61. package/dist/clients/inbox/tui.js +2 -2
  62. package/dist/commands/__tests__/human.test.js +118 -24
  63. package/dist/commands/__tests__/node-message.test.js +14 -0
  64. package/dist/commands/attention.js +4 -4
  65. package/dist/commands/canvas-browse.js +1 -1
  66. package/dist/commands/canvas-snapshot.js +1 -1
  67. package/dist/commands/canvas.js +1 -1
  68. package/dist/commands/dashboard.js +1 -1
  69. package/dist/commands/human/{doc.d.ts → components.d.ts} +1 -1
  70. package/dist/commands/human/components.js +77 -0
  71. package/dist/commands/human/feedback.d.ts +2 -0
  72. package/dist/commands/human/feedback.js +108 -0
  73. package/dist/commands/human/prompts.d.ts +17 -8
  74. package/dist/commands/human/prompts.js +217 -226
  75. package/dist/commands/human/queue.js +57 -88
  76. package/dist/commands/human/shared.d.ts +4 -15
  77. package/dist/commands/human/shared.js +19 -61
  78. package/dist/commands/human.js +32 -19
  79. package/dist/commands/memory/origin.js +2 -2
  80. package/dist/commands/node/bash.js +4 -4
  81. package/dist/commands/node/inspect.js +3 -3
  82. package/dist/commands/node/lifecycle.js +1 -1
  83. package/dist/commands/node/message.js +2 -2
  84. package/dist/commands/node-worktree.js +8 -8
  85. package/dist/commands/pkg/browse.js +1 -1
  86. package/dist/commands/pkg/plugin-manage.js +129 -10
  87. package/dist/commands/profile/delete.js +41 -15
  88. package/dist/commands/profile.js +1 -1
  89. package/dist/commands/push.js +1 -1
  90. package/dist/commands/surface/node/focus.js +29 -14
  91. package/dist/commands/surface-edit.js +3 -3
  92. package/dist/commands/surface-inbox.d.ts +2 -0
  93. package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
  94. package/dist/commands/surface.js +2 -1
  95. package/dist/commands/sys/doctor.js +60 -3
  96. package/dist/commands/sys/feedback.js +79 -25
  97. package/dist/commands/sys/settings.js +1 -1
  98. package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
  99. package/dist/core/__tests__/daemon-boot.test.js +7 -4
  100. package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
  101. package/dist/core/__tests__/extension-abort.test.js +5 -2
  102. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
  103. package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
  104. package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
  105. package/dist/core/__tests__/human-deliver.test.js +41 -45
  106. package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
  107. package/dist/core/__tests__/plugin-page-components.test.js +157 -0
  108. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
  109. package/dist/core/__tests__/seam/dormancy-release.test.js +103 -4
  110. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
  111. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -16
  112. package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
  113. package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
  114. package/dist/core/__tests__/serial/worktree.test.js +24 -198
  115. package/dist/core/__tests__/session-cycles.test.js +22 -0
  116. package/dist/core/__tests__/stop-guard.test.js +4 -4
  117. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
  118. package/dist/core/bash-jobs.d.ts +6 -0
  119. package/dist/core/bash-jobs.js +10 -0
  120. package/dist/core/canvas/__tests__/attention.test.js +8 -5
  121. package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
  122. package/dist/core/canvas/attention.js +8 -6
  123. package/dist/core/canvas/browse/app.d.ts +1 -1
  124. package/dist/core/canvas/browse/app.js +21 -3
  125. package/dist/core/canvas/browse/model.d.ts +8 -2
  126. package/dist/core/canvas/browse/model.js +11 -4
  127. package/dist/core/canvas/browse/render.d.ts +4 -0
  128. package/dist/core/canvas/browse/render.js +5 -1
  129. package/dist/core/canvas/canvas.d.ts +33 -0
  130. package/dist/core/canvas/canvas.js +92 -0
  131. package/dist/core/canvas/crons.d.ts +9 -0
  132. package/dist/core/canvas/crons.js +51 -0
  133. package/dist/core/canvas/node-order.d.ts +22 -1
  134. package/dist/core/canvas/node-order.js +26 -1
  135. package/dist/core/canvas/render-source.d.ts +5 -0
  136. package/dist/core/canvas/render-source.js +1 -0
  137. package/dist/core/canvas/types.d.ts +9 -3
  138. package/dist/core/command-plugins/bundle.d.ts +6 -1
  139. package/dist/core/command-plugins/bundle.js +26 -13
  140. package/dist/core/command.js +8 -12
  141. package/dist/core/config.d.ts +47 -1
  142. package/dist/core/config.js +138 -4
  143. package/dist/core/help.d.ts +3 -0
  144. package/dist/core/help.js +1 -1
  145. package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
  146. package/dist/core/human/__tests__/page-catalog.test.js +20 -0
  147. package/dist/core/human/__tests__/page-tickets.test.js +68 -34
  148. package/dist/core/human/__tests__/page.test.js +60 -119
  149. package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -16
  150. package/dist/core/human/answer-text.d.ts +3 -0
  151. package/dist/core/human/answer-text.js +98 -0
  152. package/dist/core/human/answer.d.ts +53 -0
  153. package/dist/core/human/answer.js +119 -0
  154. package/dist/core/human/component-docs.d.ts +18 -0
  155. package/dist/core/human/component-docs.js +434 -0
  156. package/dist/core/human/convention.d.ts +17 -3
  157. package/dist/core/human/convention.js +36 -5
  158. package/dist/core/human/feedback-companion.d.ts +20 -0
  159. package/dist/core/human/feedback-companion.js +98 -0
  160. package/dist/core/human/feedback.d.ts +65 -0
  161. package/dist/core/human/feedback.js +116 -0
  162. package/dist/core/human/page-catalog.d.ts +56 -5
  163. package/dist/core/human/page-catalog.js +151 -22
  164. package/dist/core/human/page-errors.d.ts +4 -0
  165. package/dist/core/human/page-errors.js +7 -0
  166. package/dist/core/human/page-eval.d.ts +20 -0
  167. package/dist/core/human/page-eval.js +123 -0
  168. package/dist/core/human/page-schema.d.ts +48 -116
  169. package/dist/core/human/page-schema.js +100 -72
  170. package/dist/core/human/page-synth.d.ts +2 -4
  171. package/dist/core/human/page-synth.js +20 -7
  172. package/dist/core/human/page.d.ts +13 -21
  173. package/dist/core/human/page.js +147 -391
  174. package/dist/core/human/review-schema.d.ts +1 -0
  175. package/dist/core/human/review-schema.js +1 -1
  176. package/dist/core/human/root.d.ts +6 -2
  177. package/dist/core/human/root.js +12 -4
  178. package/dist/core/human/scan.d.ts +3 -3
  179. package/dist/core/human/scan.js +21 -21
  180. package/dist/core/human/summary.d.ts +2 -6
  181. package/dist/core/human/summary.js +3 -59
  182. package/dist/core/human/tickets.d.ts +22 -15
  183. package/dist/core/human/tickets.js +90 -28
  184. package/dist/core/human/types.d.ts +14 -2
  185. package/dist/core/keybindings/catalog.d.ts +2 -2
  186. package/dist/core/keybindings/catalog.js +1 -0
  187. package/dist/core/preview-registry.js +8 -10
  188. package/dist/core/profiles/default-binding.d.ts +4 -0
  189. package/dist/core/profiles/default-binding.js +24 -0
  190. package/dist/core/profiles/deletion-reservation.d.ts +8 -0
  191. package/dist/core/profiles/deletion-reservation.js +38 -0
  192. package/dist/core/profiles/manifest.d.ts +3 -0
  193. package/dist/core/profiles/manifest.js +12 -0
  194. package/dist/core/profiles/select.js +2 -2
  195. package/dist/core/review/realize.js +12 -4
  196. package/dist/core/review/store.d.ts +6 -0
  197. package/dist/core/review/store.js +31 -0
  198. package/dist/core/runtime/bearings.d.ts +0 -4
  199. package/dist/core/runtime/bearings.js +4 -62
  200. package/dist/core/runtime/bin-contributions.d.ts +44 -0
  201. package/dist/core/runtime/bin-contributions.js +186 -0
  202. package/dist/core/runtime/boot-root.js +6 -1
  203. package/dist/core/runtime/broker/client-registry.d.ts +4 -0
  204. package/dist/core/runtime/broker/client-registry.js +13 -1
  205. package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
  206. package/dist/core/runtime/broker/extension-abort.js +4 -2
  207. package/dist/core/runtime/broker/fault-retry.js +4 -0
  208. package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
  209. package/dist/core/runtime/broker/frame-dispatch.js +5 -6
  210. package/dist/core/runtime/broker/read-ops.d.ts +1 -1
  211. package/dist/core/runtime/broker/read-ops.js +2 -2
  212. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  213. package/dist/core/runtime/broker/rebind.js +6 -1
  214. package/dist/core/runtime/broker-extension-render.js +19 -0
  215. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  216. package/dist/core/runtime/broker.d.ts +1 -1
  217. package/dist/core/runtime/broker.js +25 -12
  218. package/dist/core/runtime/busy.d.ts +4 -3
  219. package/dist/core/runtime/busy.js +4 -3
  220. package/dist/core/runtime/front-door-env.d.ts +5 -0
  221. package/dist/core/runtime/front-door-env.js +16 -0
  222. package/dist/core/runtime/front-door.d.ts +1 -5
  223. package/dist/core/runtime/front-door.js +2 -5
  224. package/dist/core/runtime/host.d.ts +1 -1
  225. package/dist/core/runtime/invocation.d.ts +6 -0
  226. package/dist/core/runtime/invocation.js +10 -0
  227. package/dist/core/runtime/kickoff.js +34 -0
  228. package/dist/core/runtime/launch.d.ts +2 -6
  229. package/dist/core/runtime/node-read.js +4 -1
  230. package/dist/core/runtime/nodes.js +2 -0
  231. package/dist/core/runtime/promote.d.ts +1 -0
  232. package/dist/core/runtime/promote.js +3 -1
  233. package/dist/core/runtime/session-cycles.d.ts +6 -0
  234. package/dist/core/runtime/session-cycles.js +15 -1
  235. package/dist/core/runtime/session-visibility.d.ts +17 -8
  236. package/dist/core/runtime/session-visibility.js +19 -10
  237. package/dist/core/runtime/spawn-env.d.ts +9 -1
  238. package/dist/core/runtime/spawn-env.js +62 -2
  239. package/dist/core/runtime/spawn.js +4 -4
  240. package/dist/core/runtime/stop-guard.d.ts +2 -2
  241. package/dist/core/runtime/stop-guard.js +2 -2
  242. package/dist/core/runtime/stop-signals.d.ts +1 -1
  243. package/dist/core/runtime/stop-signals.js +2 -2
  244. package/dist/core/runtime/tmux-bindings.js +1 -1
  245. package/dist/core/session-model/session-state.d.ts +4 -3
  246. package/dist/core/session-model/session-state.js +14 -1
  247. package/dist/core/user-settings.d.ts +36 -2
  248. package/dist/core/user-settings.js +56 -14
  249. package/dist/core/worktree.d.ts +15 -45
  250. package/dist/core/worktree.js +59 -261
  251. package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
  252. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
  253. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  254. package/dist/daemon/api/handlers/broker-ops.js +9 -5
  255. package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
  256. package/dist/daemon/api/handlers/feedback-comments.js +207 -0
  257. package/dist/daemon/api/handlers/human.js +24 -20
  258. package/dist/daemon/api/handlers/inbox.d.ts +10 -0
  259. package/dist/daemon/api/handlers/inbox.js +48 -109
  260. package/dist/daemon/api/handlers/profiles.js +11 -29
  261. package/dist/daemon/api/map.d.ts +6 -1
  262. package/dist/daemon/api/map.js +23 -0
  263. package/dist/daemon/api/server.js +3 -1
  264. package/dist/daemon/companion-retire.d.ts +2 -0
  265. package/dist/daemon/companion-retire.js +33 -0
  266. package/dist/daemon/cron-run.js +29 -17
  267. package/dist/daemon/crtrd.js +1 -1
  268. package/dist/daemon/fleet.d.ts +22 -10
  269. package/dist/daemon/fleet.js +43 -24
  270. package/dist/daemon/human/finish.d.ts +12 -6
  271. package/dist/daemon/human/finish.js +134 -54
  272. package/dist/daemon/human/sweep.js +9 -3
  273. package/dist/daemon/profile-delete.d.ts +7 -0
  274. package/dist/daemon/profile-delete.js +306 -0
  275. package/dist/daemon/reconcilers/broker-supervision.js +14 -9
  276. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  277. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  278. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  279. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  280. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  281. package/dist/daemon/review/deliver.js +3 -3
  282. package/dist/daemon/review/finish.d.ts +0 -2
  283. package/dist/daemon/review/finish.js +1 -29
  284. package/dist/daemon/review/sweep.js +12 -1
  285. package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
  286. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  287. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  288. package/dist/pi-extensions/canvas-bash-valve.js +31 -13
  289. package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
  290. package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
  291. package/dist/pi-extensions/canvas-review-boundary.js +18 -9
  292. package/dist/pi-extensions/canvas-stophook.js +4 -4
  293. package/dist/shared/generated-context.js +2 -2
  294. package/dist/types.d.ts +61 -5
  295. package/dist/types.js +8 -2
  296. package/package.json +4 -4
  297. package/runtime.lock.json +5 -33
  298. package/scripts/install-runtime.mjs +19 -24
  299. package/dist/builtin-memory/init.md +0 -38
  300. package/dist/builtin-memory/plan.md +0 -19
  301. package/dist/builtin-memory/spec.md +0 -19
  302. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  303. package/dist/clients/attach/render/html-markdown.js +0 -206
  304. package/dist/commands/human/doc.js +0 -144
  305. package/dist/commands/human/inbox.d.ts +0 -2
  306. package/dist/core/human/__tests__/page-render.test.js +0 -49
  307. package/dist/core/human/markdown-html.d.ts +0 -8
  308. package/dist/core/human/markdown-html.js +0 -581
  309. package/dist/core/human/page-render.d.ts +0 -3
  310. package/dist/core/human/page-render.js +0 -131
  311. package/dist/pages/bundle.css +0 -1
  312. package/dist/pages/bundle.js +0 -1347
  313. package/dist/pages/comments.d.ts +0 -43
  314. package/dist/pages/comments.js +0 -125
  315. package/dist/pages/elements/cards.d.ts +0 -17
  316. package/dist/pages/elements/cards.js +0 -802
  317. package/dist/pages/elements/chart.js +0 -648
  318. package/dist/pages/elements/options.d.ts +0 -21
  319. package/dist/pages/elements/options.js +0 -629
  320. package/dist/pages/elements/pages.d.ts +0 -24
  321. package/dist/pages/elements/pages.js +0 -355
  322. package/dist/pages/elements/slot.d.ts +0 -21
  323. package/dist/pages/elements/slot.js +0 -105
  324. package/dist/pages/elements/table.d.ts +0 -19
  325. package/dist/pages/elements/table.js +0 -935
  326. package/dist/pages/elements/text.d.ts +0 -25
  327. package/dist/pages/elements/text.js +0 -968
  328. package/dist/pages/entry.d.ts +0 -8
  329. package/dist/pages/entry.js +0 -8
  330. package/dist/pages/host.d.ts +0 -158
  331. package/dist/pages/host.js +0 -182
  332. package/dist/pages/register.d.ts +0 -2
  333. package/dist/pages/register.js +0 -3
  334. package/dist/pages/slot-config.d.ts +0 -36
  335. package/dist/pages/slot-config.js +0 -50
  336. package/dist/pages/types.d.ts +0 -98
  337. package/dist/pages/types.js +0 -23
  338. /package/dist/{core/human/__tests__/page-render.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
  339. /package/dist/{pages/elements/chart.d.ts → core/__tests__/plugin-page-components.test.d.ts} +0 -0
@@ -6,15 +6,15 @@ import assert from 'node:assert/strict';
6
6
  import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
- import { Readable } from 'node:stream';
10
9
  import { createApiServer } from '../../daemon/api/server.js';
11
10
  import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
12
- import { humanAsk, humanNotify } from '../../commands/human/prompts.js';
11
+ import { humanSend } from '../../commands/human/prompts.js';
13
12
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
14
13
  import { readInboxSince } from '../feed/inbox.js';
15
14
  import { resolveRef } from '../canvas/history.js';
16
15
  import { apiSocketPath } from '../canvas/paths.js';
17
16
  import { spawnNode } from '../runtime/nodes.js';
17
+ import { bindTicketReplyRoute } from '../human/convention.js';
18
18
  import { ticketDir } from '../human/root.js';
19
19
  import { readTicketResult, submitPage } from '../human/tickets.js';
20
20
  let home;
@@ -28,18 +28,6 @@ async function waitForSocket(path) {
28
28
  }
29
29
  throw new Error(`api socket never bound at ${path}`);
30
30
  }
31
- async function withPipedStdin(body, fn) {
32
- const original = process.stdin;
33
- const stdin = Readable.from([Buffer.from(body)]);
34
- Object.defineProperty(stdin, 'isTTY', { value: false, configurable: true });
35
- Object.defineProperty(process, 'stdin', { value: stdin, configurable: true });
36
- try {
37
- return await fn();
38
- }
39
- finally {
40
- Object.defineProperty(process, 'stdin', { value: original, configurable: true });
41
- }
42
- }
43
31
  async function withMutedStdout(fn) {
44
32
  const originalWrite = process.stdout.write;
45
33
  process.stdout.write = (() => true);
@@ -52,26 +40,17 @@ async function withMutedStdout(fn) {
52
40
  }
53
41
  function bridgeUnder() {
54
42
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
55
- const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
43
+ const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
56
44
  return { parent, bridge };
57
45
  }
58
46
  function submitBridgePage(bridge) {
59
47
  const dir = ticketDir(bridge);
60
48
  const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-deliver-page-'));
61
- const sourceFile = join(sourceDir, 'page.md');
62
- writeFileSync(sourceFile, [
63
- '---',
64
- 'title: Ship it?',
65
- 'subtitle: Decide whether to ship now so the release can proceed.',
66
- '---',
67
- '',
68
- '```slot:text',
69
- '{ "id": "question", "initialText": "", "editable": true }',
70
- '```',
71
- '',
72
- ].join('\n'));
49
+ const sourceFile = join(sourceDir, 'page.tsx');
50
+ writeFileSync(sourceFile, 'export default () => (\n <Page title="Ship it?" subtitle="Decide whether to ship now so the release can proceed.">\n <UserText id="question" initialText="" />\n </Page>\n);\n');
73
51
  try {
74
- submitPage({ dir, sourceFile, dialect: 'md' });
52
+ bindTicketReplyRoute(dir, bridge);
53
+ submitPage({ dir, sourceFile, delivery: { placement: 'inline', inbox: true, reply: true } });
75
54
  }
76
55
  finally {
77
56
  rmSync(sourceDir, { recursive: true, force: true });
@@ -110,41 +89,54 @@ after(() => {
110
89
  delete process.env['CRTR_NODE_ID'];
111
90
  });
112
91
  describe('enqueue is producer-only', () => {
113
- test('ask requires and preserves an explicit title instead of deriving one from the body', async () => {
92
+ test('send preserves title and subtitle from the authored page', async () => {
114
93
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
115
94
  process.env['CRTR_NODE_ID'] = parent;
116
- await assert.rejects(() => humanAsk.run({ question: 'This first line must never become the title.' }), /require a non-empty --title/);
117
- const result = (await humanAsk.run({ title: 'Release decision', subtitle: 'Decide whether to release now so the launch can proceed.', question: 'This first line must never become the title.' }));
118
- assert.equal(typeof result['job_id'], 'string');
95
+ const page = join(home, 'release-decision.tsx');
96
+ writeFileSync(page, 'export default () => (\n <Page title="Release decision" subtitle="Decide whether to release now so the launch can proceed.">\n <p>This first line stays page content.</p>\n <UserText id="question" initialText="" />\n </Page>\n);\n');
97
+ await assert.rejects(() => humanSend.run({}), /a page source is required/);
98
+ await assert.rejects(() => humanSend.run({ page, document: 'export default () => <Page title="Dup" />;' }), /received both --page and a piped page document/);
99
+ const result = (await humanSend.run({ page, document: '' }));
100
+ assert.equal(typeof result['page_id'], 'string');
101
+ assert.equal(result['reply'], true);
119
102
  assert.equal(typeof result['dir'], 'string');
120
103
  for (const forbidden of ['pane_id', 'render_pane_id', 'pid', 'worker_pid'])
121
- assert.ok(!(forbidden in result), `ask result must not expose ${forbidden}`);
104
+ assert.ok(!(forbidden in result), `send result must not expose ${forbidden}`);
122
105
  const manifest = JSON.parse(readFileSync(join(result['dir'], 'page.json'), 'utf8'));
123
106
  assert.equal(manifest.title, 'Release decision');
124
107
  assert.equal(manifest.subtitle, 'Decide whether to release now so the launch can proceed.');
125
- const document = readFileSync(join(result['dir'], 'page.md'), 'utf8');
126
- assert.ok(document.includes('This first line must never become the title.'));
127
- assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1, 'the synthesized question page carries one response slot');
108
+ const document = readFileSync(join(result['dir'], 'page.tsx'), 'utf8');
109
+ assert.ok(document.includes('This first line stays page content.'));
110
+ assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1);
111
+ });
112
+ test('send materializes a piped page before validation and publication', async () => {
113
+ const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
114
+ process.env['CRTR_NODE_ID'] = parent;
115
+ const document = 'export default () => (\n <Page title="Piped question" subtitle="Answer the question to unblock this task.">\n <UserText id="question" initialText="" />\n </Page>\n);\n';
116
+ const result = (await humanSend.run({ document }));
117
+ const manifest = JSON.parse(readFileSync(join(result['dir'], 'page.json'), 'utf8'));
118
+ assert.equal(manifest.title, 'Piped question');
119
+ assert.equal(readFileSync(join(result['dir'], 'page.tsx'), 'utf8'), document);
128
120
  });
129
121
  });
130
122
  describe('terminal ticket delivery', () => {
131
123
  test('resolvePageTicket pushFinals the readable answer once and wakes the subscribed parent', async () => {
132
124
  const { parent, bridge } = bridgeUnder();
133
125
  const dir = submitBridgePage(bridge);
134
- const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true, comments: [] } });
126
+ const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
135
127
  assert.equal(result.kind, 'page');
136
128
  closeDb();
137
129
  assert.equal(getNode(bridge)?.status, 'done');
138
130
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
139
131
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
140
132
  const body = reportBody(finals[0].ref);
141
- assert.ok(body.includes('"text":"yes, ship"'), 'the slot answer is inlined in the report');
133
+ assert.ok(body.includes('“yes, ship”'), 'the readable slot answer is inlined in the report');
142
134
  assert.ok(body.includes(join(dir, 'response.json')));
143
135
  });
144
136
  test('a replay against a terminal bridge is a no-op', async () => {
145
137
  const { parent, bridge } = bridgeUnder();
146
138
  submitBridgePage(bridge);
147
- await resolvePageTicket(bridge, { question: { text: 'yes', edited: true, comments: [] } });
139
+ await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
148
140
  assert.equal(await deliverTerminalResult(bridge), true);
149
141
  closeDb();
150
142
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
@@ -152,7 +144,7 @@ describe('terminal ticket delivery', () => {
152
144
  });
153
145
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
154
146
  const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
155
- const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
147
+ const bridge = spawnNode({ kind: 'human', parent: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
156
148
  const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
157
149
  const { subscribe } = await import('../canvas/canvas.js');
158
150
  subscribe(other, bridge, true);
@@ -162,15 +154,19 @@ describe('terminal ticket delivery', () => {
162
154
  assert.equal(getNode(bridge)?.status, 'done');
163
155
  assert.equal(readInboxSince(actor).length, 0, 'the canceling actor is not messaged');
164
156
  const notes = readInboxSince(other);
165
- assert.equal(notes.length, 1, 'the other waiter gets one deferred note');
166
- assert.equal(notes[0].tier, 'deferred');
157
+ assert.equal(notes.length, 1, 'the other waiter gets one waking note');
158
+ assert.equal(notes[0].tier, 'normal');
167
159
  assert.match(notes[0].label, /canceled/);
168
160
  });
169
161
  });
170
- test('notify enqueues a durable page and creates no canvas node', async () => {
162
+ test('a display-only page publishes passively and creates no canvas node', async () => {
163
+ const page = join(home, 'build-done.tsx');
164
+ writeFileSync(page, 'export default () => (\n <Page title="Build done" subtitle="The build passed, so the change is ready to review.">\n <p>Build output is ready.</p>\n </Page>\n);\n');
171
165
  const before = listNodes().length;
172
- const result = (await humanNotify.run({ title: 'Build done', subtitle: 'The build passed, so the change is ready to review.' }));
173
- assert.equal(result['queued'], true);
166
+ const result = (await humanSend.run({ page }));
167
+ assert.equal(result['reply'], false);
168
+ assert.equal(result['inbox'], false);
169
+ assert.equal(typeof result['page_id'], 'string');
174
170
  assert.equal(typeof result['dir'], 'string');
175
171
  assert.ok(existsSync(join(result['dir'], 'page.json')));
176
172
  assert.ok(readTicketResult(result['dir']) === null);
@@ -1,7 +1,7 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/human-node-not-supervised.test.ts
2
2
  //
3
3
  // BUG REGRESSION (issue #2 — "Headless node can't reach human-ask: human-kind
4
- // TUI dies at boot"): a headless orchestrator's every `crtr human ask` node died
4
+ // TUI dies at boot"): a headless orchestrator's every reply-bearing `crtr human send` bridge died
5
5
  // at boot with "pi vehicle exited before the session came up".
6
6
  //
7
7
  // ROOT CAUSE: a kind:'human' row is NOT a broker-hosted agent node — it is a
@@ -0,0 +1,157 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/plugin-page-components.test.ts
2
+ //
3
+ // Wiring pins for plugin-contributed page components — the layer whose absence
4
+ // is SILENT: every authoring, validation, daemon, inbox, and TUI consumer takes
5
+ // the catalog as a parameter, so if `resolvePageComponents` stopped reading
6
+ // installed plugins' manifest `page_components` blocks, every plugin-shipped
7
+ // component would simply stop being authorable with all suites green.
8
+ // Plus the archive path: `validatePluginBundle` must carry a valid bundle.json
9
+ // `page_components` block into `ValidatedBundle.page_components` (what the
10
+ // installer copies into the synthesized manifest) and reject an invalid one.
11
+ import { test, before, beforeEach, afterEach, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import * as tar from 'tar';
17
+ import { resetScopeCache } from '../scope.js';
18
+ import { resolvePageComponents } from '../config.js';
19
+ import { validatePluginBundle } from '../command-plugins/bundle.js';
20
+ let isolatedHome;
21
+ let isolatedCwd;
22
+ let prevHome;
23
+ let prevCwd;
24
+ before(() => {
25
+ prevHome = process.env.HOME;
26
+ prevCwd = process.cwd();
27
+ });
28
+ beforeEach(() => {
29
+ isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-page-components-home-')));
30
+ isolatedCwd = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-page-components-cwd-')));
31
+ process.env.HOME = isolatedHome;
32
+ process.chdir(isolatedCwd);
33
+ resetScopeCache();
34
+ });
35
+ afterEach(() => {
36
+ process.chdir(prevCwd);
37
+ rmSync(isolatedHome, { recursive: true, force: true });
38
+ rmSync(isolatedCwd, { recursive: true, force: true });
39
+ });
40
+ after(() => {
41
+ process.chdir(prevCwd);
42
+ process.env.HOME = prevHome;
43
+ resetScopeCache();
44
+ });
45
+ /** Plant a manifest-only plugin (page components need no commands or executable). */
46
+ function plantPlugin(scopeRootPath, name, pageComponents) {
47
+ const root = join(scopeRootPath, 'plugins', name, '.crouter-plugin');
48
+ mkdirSync(root, { recursive: true });
49
+ writeFileSync(join(root, 'plugin.json'), JSON.stringify({ name, version: '0.1.0', page_components: pageComponents }));
50
+ }
51
+ function userRoot() {
52
+ const root = join(isolatedHome, '.crouter');
53
+ mkdirSync(root, { recursive: true });
54
+ return root;
55
+ }
56
+ test('an enabled user-scope plugin contributes its page components; a disabled one contributes nothing', () => {
57
+ const root = userRoot();
58
+ plantPlugin(root, 'northlight', [
59
+ { kind: 'connector', description: 'Connector decision', useWhen: 'Use for a connector decision.', doc: 'Product contract.' },
60
+ { kind: 'applet-card', display: true },
61
+ ]);
62
+ plantPlugin(root, 'zzz-disabled', ['ghost']);
63
+ writeFileSync(join(root, 'config.json'), JSON.stringify({ plugins: { 'zzz-disabled': { enabled: false } } }));
64
+ const components = resolvePageComponents();
65
+ assert.deepEqual(components, [
66
+ { kind: 'connector', description: 'Connector decision', useWhen: 'Use for a connector decision.', doc: 'Product contract.' },
67
+ { kind: 'applet-card', display: true },
68
+ ]);
69
+ });
70
+ test('user config entries and plugin contributions resolve together, and a colliding kind is rejected', () => {
71
+ const root = userRoot();
72
+ plantPlugin(root, 'northlight', ['connector']);
73
+ writeFileSync(join(root, 'config.json'), JSON.stringify({ page_components: ['approval'] }));
74
+ assert.deepEqual(resolvePageComponents().map((component) => component.kind), ['approval', 'connector']);
75
+ writeFileSync(join(root, 'config.json'), JSON.stringify({ page_components: ['connector'] }));
76
+ assert.throws(() => resolvePageComponents(), /page component kind "connector" is registered by both config.json and plugin "northlight"/);
77
+ });
78
+ test('two plugins whose kinds derive one JSX tag are rejected, naming both sources', () => {
79
+ const root = userRoot();
80
+ plantPlugin(root, 'alpha', ['foo-bar']);
81
+ plantPlugin(root, 'beta', ['foo--bar']);
82
+ assert.throws(() => resolvePageComponents(), /kind "foo--bar" \(plugin "beta"\) derives JSX tag "FooBar", which duplicates kind "foo-bar" from plugin "alpha"/);
83
+ });
84
+ // --- archive path -----------------------------------------------------------
85
+ function minimalCommandsJson() {
86
+ return {
87
+ schemaVersion: 1,
88
+ mounts: [
89
+ {
90
+ parent: [],
91
+ node: {
92
+ kind: 'branch',
93
+ name: 'app',
94
+ description: 'application lifecycle and inspection',
95
+ whenToUse: 'you need to inspect an application',
96
+ rootEntry: {
97
+ concept: 'applications hosted by a crouter home',
98
+ description: 'application lifecycle and inspection',
99
+ whenToUse: 'you need to inspect an application',
100
+ },
101
+ summary: 'application lifecycle and inspection',
102
+ model: 'Applications are product resources owned by the selected home.',
103
+ children: [
104
+ {
105
+ kind: 'leaf',
106
+ name: 'show',
107
+ description: 'show one application',
108
+ whenToUse: 'inspect a single application by id',
109
+ tier: 'important',
110
+ summary: 'show one application details',
111
+ params: [{ kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' }],
112
+ output: [{ name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' }],
113
+ effects: ['None. Read-only.'],
114
+ rest: { method: 'GET', path: '/v1/apps/{app-id}', params: { 'app-id': { in: 'path' } } },
115
+ },
116
+ ],
117
+ },
118
+ },
119
+ ],
120
+ };
121
+ }
122
+ function makeArchive(bundleJson) {
123
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-components-tar-'));
124
+ try {
125
+ writeFileSync(join(dir, 'bundle.json'), JSON.stringify(bundleJson));
126
+ writeFileSync(join(dir, 'commands.json'), JSON.stringify(minimalCommandsJson()));
127
+ const pack = tar.create({ sync: true, cwd: dir, portable: true }, ['bundle.json', 'commands.json']);
128
+ const chunks = [];
129
+ let chunk;
130
+ while ((chunk = pack.read()) !== null)
131
+ chunks.push(chunk);
132
+ return new Uint8Array(Buffer.concat(chunks));
133
+ }
134
+ finally {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ }
138
+ test('validatePluginBundle carries a valid bundle.json page_components block into ValidatedBundle', async () => {
139
+ const archive = makeArchive({
140
+ bundleVersion: 1,
141
+ page_components: ['connector', { kind: 'applet-card', description: 'Applet card', display: true }],
142
+ });
143
+ const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
144
+ assert.deepEqual(validated.issues, []);
145
+ assert.deepEqual(validated.bundle?.page_components, [
146
+ { kind: 'connector' },
147
+ { kind: 'applet-card', description: 'Applet card', display: true },
148
+ ]);
149
+ });
150
+ test('validatePluginBundle rejects an invalid page_components block loudly', async () => {
151
+ const archive = makeArchive({ bundleVersion: 1, page_components: [{ kind: 'connector', use_when: 'typo field' }] });
152
+ const validated = await validatePluginBundle(archive, { reservedCoreNames: new Set(['node']) });
153
+ assert.equal(validated.bundle, undefined);
154
+ assert.equal(validated.issues.length, 1);
155
+ assert.match(validated.issues[0].message, /page_components block is invalid/);
156
+ assert.match(validated.issues[0].received, /use_when/);
157
+ });
@@ -2,9 +2,10 @@
2
2
  //
3
3
  // Daemon-epoch broker ownership, real-process crash and teardown. The broker's
4
4
  // exit event is the sole death authority: a SIGTERM of a streaming broker
5
- // aborts cleanly and resumes the saved session with a continuation (policy row
6
- // 5); a SIGKILL mid-turn leaves no certificate and starts a fresh cycle (row
7
- // 6). Each replacement starts only after the
5
+ // aborts cleanly and resumes the saved session with a continuation, and a
6
+ // SIGKILL mid-turn — no certificate, only the durable busy marker — resumes
7
+ // that same saved session in place and re-drives the stopped turn with the
8
+ // same continuation (both policy row 5). Each replacement starts only after the
8
9
  // prior process has exited, preserving one writer per session. A clean `node
9
10
  // lifecycle close` then tears the replacement down (shutdown frame → exit,
10
11
  // socket unlinked, status canceled, daemon leaves it).
@@ -77,7 +78,7 @@ test('SIGTERM of a busy broker aborts cleanly and resumes with a continuation',
77
78
  assert.notEqual(revivedPid, oldPid, 'the revived pid is distinct from the crashed one');
78
79
  assert.equal(isPidAlive(oldPid), false, 'one-writer: the crashed pid never resurrected');
79
80
  });
80
- test('SIGKILL of a busy broker stays a dirty recovery cycle; close tears down the replacement', { timeout: 30_000 }, async () => {
81
+ test('SIGKILL of a busy broker resumes the interrupted turn in place; close tears down the replacement', { timeout: 30_000 }, async () => {
81
82
  const id = await h.spawnHeadlessChild(root, 'headless worker — dirty crash+teardown');
82
83
  const boot1 = await h.awaitBoot(id);
83
84
  const boots = h.bootCount(id);
@@ -85,9 +86,10 @@ test('SIGKILL of a busy broker stays a dirty recovery cycle; close tears down th
85
86
  assert.equal(busyPid, boot1.pid, 'canvas pid identifies the broker that owns the dirty-crash session');
86
87
  assert.equal(isPidAlive(busyPid), true, 'busy broker is alive before SIGKILL');
87
88
  // This broker has only the durable busy marker — SIGKILL cannot run the abort
88
- // handler or write clean-abort evidence. Strict resume would only reopen the
89
- // transcript at an idle prompt; policy must select resume:false and inject the
90
- // normal fresh-cycle kickoff after this journey's first 5s backoff.
89
+ // handler or write clean-abort evidence. The saved transcript is still
90
+ // resumable (pi persists whole entries), so policy resumes it strictly and
91
+ // the respawn continuation re-drives the stopped turn, after this journey's
92
+ // first 5s backoff. A dirty death is not a reason to throw the branch away.
91
93
  markBusy(id);
92
94
  process.kill(busyPid, 'SIGKILL');
93
95
  await h.waitFor(() => !isPidAlive(busyPid), { label: 'mid-turn crashed broker pid is dead' });
@@ -96,11 +98,11 @@ test('SIGKILL of a busy broker stays a dirty recovery cycle; close tears down th
96
98
  // path, including a cycle; production distinguishes cycling with the argv
97
99
  // token and non-null kickoff. Assert those load-bearing inputs directly.
98
100
  const recoveryArgv = boot2.rawArgv;
99
- assert.ok(recoveryArgv.includes('--crtr-new-cycle'), 'a surviving busy marker selects --crtr-new-cycle');
100
- assert.match(boot2.prompt ?? '', /You have been revived fresh after a context refresh/, 'the recovery cycle receives a real roadmap\/feed\/context kickoff');
101
+ assert.ok(!recoveryArgv.includes('--crtr-new-cycle'), 'a surviving busy marker resumes the saved session — it never opens a fresh cycle');
102
+ assert.equal(boot2.prompt, RUNTIME_RESTART_CONTINUATION, 'the strict resume injects the runtime-restart continuation that re-drives the interrupted turn');
101
103
  const recoveryPid = h.node(id).pi_pid;
102
- assert.ok(recoveryPid != null && isPidAlive(recoveryPid), 'the recovery-cycle broker pid is alive');
103
- assert.notEqual(recoveryPid, busyPid, 'the recovery cycle uses a distinct broker pid');
104
+ assert.ok(recoveryPid != null && isPidAlive(recoveryPid), 'the recovered broker pid is alive');
105
+ assert.notEqual(recoveryPid, busyPid, 'the recovery uses a distinct broker pid');
104
106
  assert.equal(isPidAlive(busyPid), false, 'one-writer: the interrupted pid never resurrected');
105
107
  // --- Item 5: clean teardown of the (revived, live) broker. node lifecycle close marks
106
108
  // canceled BEFORE teardown (crash-safe order), then hostFor(broker).teardown
@@ -7,16 +7,22 @@
7
7
  // before the broker exits, and the daemon may only revive for a row strictly
8
8
  // after the committed cursor.
9
9
  // (2) The observed 57-broker / ~5.4GB leak — DAEMON SUPERVISION, not an agent
10
- // event, owns the unattended clock. A resident with no live obligation
11
- // completes into prunable history; one awaiting a child remains wakeable.
12
- // Both release their broker and fleet slot.
10
+ // event, owns the unattended clock. A node with no live obligation is
11
+ // terminalized into prunable history (a resident completed its
12
+ // conversation; a terminal node parked without a final was torn down
13
+ // before finishing); one awaiting a child remains wakeable; an unclaimed
14
+ // warm-pool spare is exempt, because the booted broker it holds is its
15
+ // entire purpose. All of them release their broker and fleet slot except
16
+ // the spare, which keeps both.
13
17
  //
14
18
  // Both drive a real broker process through the real watcher/supervision paths, so
15
19
  // they belong to the compiled seam tier; neither needs tmux.
16
20
  import { after, before, test } from 'node:test';
17
21
  import assert from 'node:assert/strict';
22
+ import { writeFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
18
24
  import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
19
- import { subscribe, updateNode } from '../../canvas/index.js';
25
+ import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
20
26
  import { isPidAlive } from '../../canvas/pid.js';
21
27
  import { reviveNode } from '../../runtime/revive.js';
22
28
  import { createHarness } from '../helpers/harness.js';
@@ -172,6 +178,99 @@ test('an unattended resident with no live obligation completes on the daemon clo
172
178
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
173
179
  }
174
180
  });
181
+ test('a connected broker viewer keeps an obligation-free resident live', { timeout: 30_000 }, async () => {
182
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
183
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
184
+ const nodeId = h.fabricateBrokerNode({
185
+ id: 'settled-with-viewer',
186
+ parent: root,
187
+ lifecycle: 'resident',
188
+ status: 'idle',
189
+ intent: 'idle-release',
190
+ });
191
+ updateNode(nodeId, {
192
+ launch: {
193
+ extensions: [],
194
+ tools: [],
195
+ systemPrompt: '',
196
+ env: {},
197
+ },
198
+ });
199
+ reviveNode(nodeId, { resume: true });
200
+ const boot = await h.awaitBoot(nodeId);
201
+ // This is the presence written by a helloed direct attach, which has no
202
+ // focuses-table row because it did not pass through the focus opener.
203
+ writeFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), JSON.stringify({ viewers: 1 }));
204
+ try {
205
+ const seenAt = 15_000;
206
+ await h.tick(seenAt);
207
+ await h.tick(seenAt + 1500);
208
+ assert.equal(h.fleet.has(nodeId), true, 'an attached viewer keeps the broker fleet-owned');
209
+ assert.equal(isPidAlive(boot.pid), true, 'an attached viewer keeps the broker process alive');
210
+ assert.ok(['active', 'idle'].includes(h.node(nodeId)?.status ?? ''), 'an attached viewer keeps the node live');
211
+ }
212
+ finally {
213
+ if (priorParkMs === undefined)
214
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
215
+ else
216
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
217
+ }
218
+ });
219
+ test('an already idle terminal node with no live obligation reconciles into canceled', async () => {
220
+ const nodeId = h.fabricateBrokerNode({
221
+ id: 'idle-terminal-without-obligation',
222
+ parent: root,
223
+ status: 'idle',
224
+ intent: 'idle-release',
225
+ pi_session_id: 'parked-terminal-session',
226
+ });
227
+ await h.tick();
228
+ assert.equal(h.node(nodeId)?.status, 'canceled', 'a terminal row that never pushed a final settles as torn down, not completed');
229
+ });
230
+ test('the unattended park clock cancels an obligation-free terminal node and spares the warm pool', { timeout: 40_000 }, async () => {
231
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
232
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
233
+ try {
234
+ const boot = async (id) => {
235
+ updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
236
+ reviveNode(id, { resume: true });
237
+ return (await h.awaitBoot(id)).pid;
238
+ };
239
+ const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
240
+ const awaiting = h.fabricateBrokerNode({ id: 'terminal-awaiting-child', parent: root, status: 'idle', intent: 'idle-release' });
241
+ const child = h.fabricateBrokerNode({ parent: awaiting, status: 'idle', intent: 'idle-release' });
242
+ subscribe(awaiting, child, true);
243
+ // Production spares mint as unparented residents, so this is the row shape
244
+ // the park clock actually meets in the fleet.
245
+ const spare = h.fabricateBrokerNode({ id: 'unclaimed-warm-spare', lifecycle: 'resident', status: 'idle', intent: 'idle-release' });
246
+ registerWarmSpare(spare, 'test-recipe-key');
247
+ const settledPid = await boot(settled);
248
+ const awaitingPid = await boot(awaiting);
249
+ const sparePid = await boot(spare);
250
+ const seenAt = 30_000;
251
+ await h.tick(seenAt);
252
+ await h.tick(seenAt + 1500);
253
+ await h.waitFor(() => h.node(settled)?.status === 'canceled' && !isPidAlive(settledPid), {
254
+ timeoutMs: 10_000,
255
+ intervalMs: 20,
256
+ label: 'unattended terminal cancellation and broker exit',
257
+ });
258
+ await h.awaitFleetExit(settled);
259
+ assert.equal(h.node(settled)?.status, 'canceled', 'a terminal node torn down without a final is prunable canceled history');
260
+ await h.waitFor(() => !isPidAlive(awaitingPid), { timeoutMs: 10_000, intervalMs: 20, label: 'awaiting terminal release' });
261
+ await h.awaitFleetExit(awaiting);
262
+ assert.equal(h.node(awaiting)?.status, 'idle', 'a live child keeps a terminal node eligible for an automatic wake');
263
+ assert.equal(h.fleet.has(spare), true, 'the park clock never reaps an unclaimed spare out from under the pool');
264
+ assert.equal(isPidAlive(sparePid), true, 'the spare keeps the booted broker that is its whole value');
265
+ assert.ok(['active', 'idle'].includes(h.node(spare)?.status ?? ''), 'the spare row stays claimable, left for reapStaleSpares to own');
266
+ }
267
+ finally {
268
+ if (priorParkMs === undefined)
269
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
270
+ else
271
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
272
+ }
273
+ });
175
274
  test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
176
275
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
177
276
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
@@ -150,7 +150,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
150
150
  // ('natural stop while awaiting a live worker → idle-release …').
151
151
  // ===================================================================
152
152
  {
153
- const stop = evaluateStop(B, { pushedFinal: false, askedHuman: false });
153
+ const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
154
154
  assert.equal(stop.action, 'allow', 'awaiting terminal B → allow (legit idle-release)');
155
155
  assert.equal(stop.reason, 'awaiting', "B classified awaiting on its ACTIVE live sub to C");
156
156
  }
@@ -225,7 +225,7 @@ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote,
225
225
  assert.deepEqual(drift?.from, { mode: 'base', lifecycle: 'terminal' }, 'drift.from = the acked base×terminal');
226
226
  assert.deepEqual(drift?.to, { mode: 'orchestrator', lifecycle: 'terminal' }, 'drift.to = the new orchestrator persona');
227
227
  assert.match(drift?.guidance ?? '', /ORCHESTRATOR/i, 'the steer carries the base→orchestrator guidance');
228
- const stop = evaluateStop(B, { pushedFinal: false, askedHuman: false });
228
+ const stop = evaluateStop(B, { pushedFinal: false, requestedHumanReply: false });
229
229
  assert.equal(stop.action, 'reprompt', 'orchestrator B with no live sub, no final → reprompt');
230
230
  assert.equal(stop.reason, 'stalled', 'classified stalled');
231
231
  assert.equal(stop.action === 'reprompt' ? stop.message : '', STALL_REPROMPT, 'the stall reprompt nudge');
@@ -11,6 +11,7 @@ import { ApiError, CrtrClient } from '../../../api/index.js';
11
11
  import { createApiServer } from '../../../daemon/api/server.js';
12
12
  import { apiSocketPath } from '../../canvas/paths.js';
13
13
  import { claimTicket, readTicketClaim } from '../../human/claim.js';
14
+ import { bindTicketReplyRoute } from '../../human/convention.js';
14
15
  import { ticketDir } from '../../human/root.js';
15
16
  import { submitPage, submitReview } from '../../human/tickets.js';
16
17
  import { spawnNode } from '../../runtime/nodes.js';
@@ -66,20 +67,14 @@ async function expectReviewSubmitGuidance(nodeId, body) {
66
67
  function submitBridgePage(bridge) {
67
68
  const dir = ticketDir(bridge);
68
69
  const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-e2e-page-'));
69
- const sourceFile = join(sourceDir, 'page.md');
70
- writeFileSync(sourceFile, [
71
- '---',
72
- 'title: Ship it?',
73
- 'subtitle: Decide whether to ship now so the release can proceed.',
74
- '---',
75
- '',
76
- '```slot:text',
77
- '{ "id": "question", "initialText": "", "editable": true }',
78
- '```',
79
- '',
80
- ].join('\n'));
70
+ const sourceFile = join(sourceDir, 'page.tsx');
71
+ writeFileSync(sourceFile, 'export default () => (\n <Page title="Ship it?" subtitle="Decide whether to ship now so the release can proceed.">\n <UserText id="question" initialText="" />\n </Page>\n);\n');
81
72
  try {
82
- submitPage({ dir, sourceFile, dialect: 'md' });
73
+ bindTicketReplyRoute(dir, bridge);
74
+ // The delivery `human send` derives for a terminal bridge page: a
75
+ // response-bearing slot gives it a reply route, and a terminal sender's page
76
+ // always projects to the inbox.
77
+ submitPage({ dir, sourceFile, delivery: { placement: 'inline', inbox: true, reply: true } });
83
78
  }
84
79
  finally {
85
80
  rmSync(sourceDir, { recursive: true, force: true });
@@ -110,6 +105,7 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
110
105
  const reviewDir = ticketDir(reviewBridge);
111
106
  const file = join(home, 'review.md');
112
107
  writeFileSync(file, '# Review\n');
108
+ bindTicketReplyRoute(reviewDir, reviewBridge);
113
109
  submitReview({ dir: reviewDir, review: { title: 'Review', subtitle: 'Review this file so implementation can proceed safely.', source: {}, file } });
114
110
  const reviewClaim = claimTicket(reviewDir);
115
111
  assert.ok(reviewClaim !== null);
@@ -118,10 +114,10 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
118
114
  });
119
115
  test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
120
116
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
121
- const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
117
+ const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
122
118
  const dir = submitBridgePage(bridge);
123
- const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true, comments: [] } } });
124
- assert.deepEqual(result, { delivered: true, kind: 'page' });
119
+ const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true } } });
120
+ assert.deepEqual(result, { completed: true, reply_delivered: true, kind: 'page' });
125
121
  assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
126
122
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
127
123
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
@@ -48,7 +48,7 @@ import { evaluateStop } from '../../runtime/stop-guard.js';
48
48
  function persona(m) {
49
49
  return { mode: m.mode, lifecycle: m.lifecycle };
50
50
  }
51
- const SIGNALS = { pushedFinal: false, askedHuman: false };
51
+ const SIGNALS = { pushedFinal: false, requestedHumanReply: false };
52
52
  // ===========================================================================
53
53
  // (a) LIFECYCLE FLIP — `crtr node config --lifecycle` on a LIVE node, both directions,
54
54
  // observing the idle-release DECISION change. A round-trip on ONE node
@@ -186,7 +186,7 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
186
186
  const defaultMenu = tmuxBinding(socket, 'root', 'M-c') ?? '';
187
187
  assert.match(defaultMenu, /display-menu/);
188
188
  // The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
189
- assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr human inbox toggle/);
189
+ assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr surface inbox/);
190
190
  const remapped = installTmuxBindings(resolveKeybindings({
191
191
  'crtr.tmux.menu.open': ['alt+shift+z'],
192
192
  'crtr.tmux.menu.graph': ['shift+z'],
@@ -242,11 +242,13 @@ test('tmux installer reads pair-array manifests and replaces owned pairs', (t) =
242
242
  try {
243
243
  // Current-scope releases wrote the pair array before wrappers were added.
244
244
  isolatedTmux(socket, ['set-option', '-s', '@crtr_keybindings_manifest', JSON.stringify([{ table: 'root', key: 'M-i' }])]);
245
+ // Seeded with the pre-move command path, so the assertion below distinguishes a
246
+ // genuine replacement from a no-op.
245
247
  isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-i', 'run-shell', '-b', 'crtr human inbox toggle --quiet']);
246
248
  const result = installTmuxBindings(resolveKeybindings());
247
249
  assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
248
250
  assert.ok(!result.diagnostics.some((d) => d.bindingId === 'crtr.tmux.inbox.toggle'), JSON.stringify(result.diagnostics));
249
- assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr human inbox toggle/);
251
+ assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /crtr surface inbox/);
250
252
  assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /background running bash/);
251
253
  }
252
254
  finally {