@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
@@ -8,6 +8,7 @@ import { surfaceEditLeaf } from './surface-edit.js';
8
8
  import { registerSurfaceNode } from './surface-node.js';
9
9
  import { surfaceInspectLeaf } from './surface-inspect.js';
10
10
  import { surfaceReloadLeaf } from './surface-reload.js';
11
+ import { surfaceInboxLeaf } from './surface-inbox.js';
11
12
  export function registerSurface() {
12
13
  const attach = registerAttach('surface', 'important');
13
14
  return defineBranch({
@@ -22,6 +23,6 @@ export function registerSurface() {
22
23
  summary: 'open and manage screen-facing surfaces',
23
24
  model: 'Choose `node` for navigation among node viewers. The other commands open a broker attachment, node Inspector, subtree spread, reload, or adjacent editor pane.',
24
25
  },
25
- children: [registerSurfaceNode(), attach, surfaceInspectLeaf, surfaceReloadLeaf, tmuxSpreadLeaf, surfaceEditLeaf],
26
+ children: [registerSurfaceNode(), attach, surfaceInspectLeaf, surfaceReloadLeaf, tmuxSpreadLeaf, surfaceEditLeaf, surfaceInboxLeaf],
26
27
  });
27
28
  }
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { lstat, readFile, readdir, readlink } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { defineLeaf } from '../../core/command.js';
5
- import { readConfig, updateConfig } from '../../core/config.js';
5
+ import { normalizeRequires, readConfig, updateConfig } from '../../core/config.js';
6
6
  import { scopeRoot, listScopes, marketplacesDir, pluginsDir } from '../../core/scope.js';
7
7
  import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
8
8
  import { pathExists, listDirs, removePath } from '../../core/fs-utils.js';
@@ -13,6 +13,9 @@ import { readFault } from '../../core/runtime/fault.js';
13
13
  import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
14
14
  import { detectPackageManager } from './setup-core.js';
15
15
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
16
+ import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
17
+ import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
18
+ import { listAllPlugins } from '../../core/resolver.js';
16
19
  import { SUBTREE_NAMES, coreCommandPaths } from '../../build-root.js';
17
20
  import { processGeneration, processLocatedGeneration, runtimeManifest, selectedGeneration } from './shared.js';
18
21
  function pass(scope, name, message) {
@@ -138,6 +141,58 @@ async function runCommandPluginChecks(scopes) {
138
141
  }
139
142
  return results;
140
143
  }
144
+ /**
145
+ * Report every bare-binary contribution (`bin` in a plugin manifest or a scope
146
+ * `config.json`) that failed to reach a node's PATH, plus the ones that did.
147
+ * Purely static — the declared executable is NEVER run, exactly as plugin
148
+ * command manifests are only ever validated. Every remediation is a
149
+ * human_action: fixing a contributor's declaration or its file is the owner's
150
+ * call, never doctor's. Only contributions from a scope in `scopes` are
151
+ * reported, so `--scope` narrows these checks too.
152
+ */
153
+ function runBinContributionChecks(scopes) {
154
+ const inScope = new Set(scopes);
155
+ const { bins, issues } = resolveBinContributions(process.cwd(), process.env['CRTR_PROFILE_ID'] || null);
156
+ const results = [];
157
+ for (const issue of issues) {
158
+ if (!inScope.has(issue.scope))
159
+ continue;
160
+ results.push(failCheck(issue.scope, `bin:${issue.name}`, `${issue.origin}: ${issue.message}`, {
161
+ kind: 'human_action',
162
+ description: `Fix the \`bin.${issue.name}\` declaration in ${issue.origin} — point it at an executable regular file inside that contributor's own root, or remove it. A same-precedence collision is resolved by renaming one of the claimants.`,
163
+ }));
164
+ }
165
+ for (const bin of bins.values()) {
166
+ if (!inScope.has(bin.scope))
167
+ continue;
168
+ results.push(pass(bin.scope, `bin:${bin.name}`, `${bin.origin} → ${bin.target}`));
169
+ }
170
+ return results;
171
+ }
172
+ /** Check each enabled plugin's advisory PATH requirements against the exact
173
+ * broker environment a node launched from this cwd/profile receives. */
174
+ function runRequiresChecks(scopes) {
175
+ const inScope = new Set(scopes);
176
+ const env = buildBrokerEnv({ argv: [], env: { CRTR_NODE_CWD: process.cwd(), CRTR_PROFILE_ID: process.env['CRTR_PROFILE_ID'] ?? '' } });
177
+ const results = [];
178
+ for (const plugin of listAllPlugins()) {
179
+ if (!inScope.has(plugin.scope) || !plugin.enabled || plugin.manifest.requires === undefined)
180
+ continue;
181
+ for (const [name, hint] of Object.entries(normalizeRequires(plugin.manifest.requires))) {
182
+ const target = resolvePathExecutable(name, env, process.cwd());
183
+ if (target !== null) {
184
+ results.push(pass(plugin.scope, `requires:${name}`, `plugin "${plugin.name}" → ${target}`));
185
+ }
186
+ else {
187
+ results.push(failCheck(plugin.scope, `requires:${name}`, `plugin "${plugin.name}" requires executable "${name}" on PATH`, {
188
+ kind: 'human_action',
189
+ description: hint,
190
+ }));
191
+ }
192
+ }
193
+ }
194
+ return results;
195
+ }
141
196
  async function inventory(root) {
142
197
  const files = [];
143
198
  async function walk(dir) {
@@ -422,12 +477,12 @@ export const sysDoctorLeaf = defineLeaf({
422
477
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
423
478
  ],
424
479
  output: [
425
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
480
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin manifest checks appear as plugin:<name>:commands (validated statically, never executed); their remediation is always a human_action (disable/update/remove the plugin). remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
426
481
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
427
482
  ],
428
483
  outputKind: 'object',
429
484
  effects: [
430
- 'Read-only unless --fix is passed. Runtime verification reads and hashes every selected/process generation file; plugin command manifests are validated statically — the plugin executable is never run, at any time.',
485
+ 'Read-only unless --fix is passed. Runtime verification reads and hashes every selected/process generation file; plugin command manifests, bare-binary (`bin`) declarations, and advisory `requires` PATH checks never execute plugin or contributed binaries.',
431
486
  'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories; canvas fault findings and plugin command findings stay read-only (--fix never chmods or rewrites third-party plugin content).',
432
487
  'Each non-pass result carries a structured `remediation` describing the fix action (absolute paths, exact config keys, or a human_action with its command) so callers can apply it directly without --fix.',
433
488
  ],
@@ -445,6 +500,8 @@ export const sysDoctorLeaf = defineLeaf({
445
500
  if (scopes.includes('user'))
446
501
  allResults.push(runNerdFontCheck());
447
502
  allResults.push(...await runCommandPluginChecks(scopes));
503
+ allResults.push(...runBinContributionChecks(scopes));
504
+ allResults.push(...runRequiresChecks(scopes));
448
505
  allResults.push(...await runFaultChecks());
449
506
  const processLocation = processGeneration();
450
507
  allResults.push(...await generationChecks('selected', selectedGeneration(), processLocation === null));
@@ -1,9 +1,18 @@
1
- // `crtr sys feedback` — file a GitHub issue against the crtr repo when the
2
- // harness itself misbehaves (a command errors unexpectedly, hangs, churns, or
3
- // acts contrary to its documented contract). The agent supplies one prose
4
- // description; everything else (version, node identity, recent activity) is
5
- // auto-collected and templated into the issue body. gh-less / unauthed hosts
6
- // fall back to a durable local file so the report is never lost.
1
+ // `crtr sys feedback` — report a crtr harness bug when the harness itself
2
+ // misbehaves (a command errors unexpectedly, hangs, churns, or acts contrary to
3
+ // its documented contract). The agent supplies one prose description; everything
4
+ // else (version, node identity, recent activity) is auto-collected and templated
5
+ // into the report body.
6
+ //
7
+ // WHERE the report goes is a property of the deployment, not of the agent: this
8
+ // command owns the report — its contract, its context collection, its templating
9
+ // — while the deployment owns delivery. A host with `gh` and a person behind it
10
+ // opens the issue directly; a hosted runtime (a Northlight guest VM, say) has no
11
+ // GitHub credential and shouldn't be given one, so it sets
12
+ // CRTR_FEEDBACK_ENDPOINT and its backend forwards the report with an identity
13
+ // crtr never has to hold. Exactly one sink is chosen per deployment — this is a
14
+ // configured provider, not a fallback chain. Delivery failure on either path
15
+ // still writes a durable local file so the report is never lost.
7
16
  import { spawnSync } from 'node:child_process';
8
17
  import { mkdirSync, writeFileSync } from 'node:fs';
9
18
  import { join } from 'node:path';
@@ -19,6 +28,12 @@ import { readPackageVersion } from './shared.js';
19
28
  /** Canonical repo for crtr harness feedback — feedback is always about the
20
29
  * tool, regardless of which project the reporting agent is working in. */
21
30
  const FEEDBACK_REPO = 'vallum-security/crouter';
31
+ /** Deployment-configured delivery. When CRTR_FEEDBACK_ENDPOINT names an HTTP
32
+ * endpoint, it is the sink and gh is not consulted; CRTR_FEEDBACK_TOKEN, when
33
+ * present, is sent as a bearer credential. */
34
+ const ENDPOINT_ENV = 'CRTR_FEEDBACK_ENDPOINT';
35
+ const TOKEN_ENV = 'CRTR_FEEDBACK_TOKEN';
36
+ const ENDPOINT_TIMEOUT_MS = 15_000;
22
37
  async function collectContext() {
23
38
  const env = process.env;
24
39
  const nodeId = env['CRTR_NODE_ID'] ?? null;
@@ -106,7 +121,47 @@ function buildBody(message, ctx) {
106
121
  }
107
122
  return parts.join('\n') + '\n';
108
123
  }
109
- /** Persist a report locally when gh can't file it, so nothing is lost. Returns
124
+ /** POST the report to the deployment's configured endpoint. The endpoint may
125
+ * answer with a `url` naming where the report landed; anything else about the
126
+ * response body is the backend's business, not ours. */
127
+ async function deliverToEndpoint(endpoint, title, body, message, ctx) {
128
+ const token = process.env[TOKEN_ENV];
129
+ try {
130
+ const res = await fetch(endpoint, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'content-type': 'application/json',
134
+ ...(token !== undefined && token !== '' ? { authorization: `Bearer ${token}` } : {}),
135
+ },
136
+ body: JSON.stringify({ title, body, message, context: ctx }),
137
+ signal: AbortSignal.timeout(ENDPOINT_TIMEOUT_MS),
138
+ });
139
+ if (!res.ok) {
140
+ const detail = (await res.text().catch(() => '')).trim().slice(0, 400);
141
+ return { error: `${ENDPOINT_ENV} returned HTTP ${res.status}${detail === '' ? '' : `: ${detail}`}` };
142
+ }
143
+ const payload = (await res.json().catch(() => null));
144
+ const url = typeof payload?.url === 'string' ? payload.url : undefined;
145
+ return url === undefined ? {} : { url };
146
+ }
147
+ catch (err) {
148
+ return { error: `${ENDPOINT_ENV} unreachable: ${err instanceof Error ? err.message : String(err)}` };
149
+ }
150
+ }
151
+ /** Open the GitHub issue directly with the host's own gh credential. */
152
+ function deliverToGitHub(title, body) {
153
+ const res = spawnSync('gh', ['issue', 'create', '--repo', FEEDBACK_REPO, '--title', title, '--body-file', '-'], { input: body, encoding: 'utf8' });
154
+ if (res.error === undefined && res.status === 0) {
155
+ const url = res.stdout.trim().split('\n').filter((l) => l.startsWith('http')).pop() ?? res.stdout.trim();
156
+ return { url };
157
+ }
158
+ return {
159
+ error: res.error !== undefined
160
+ ? `gh not available: ${res.error.message}`
161
+ : (res.stderr?.trim() || `gh exited ${res.status}`),
162
+ };
163
+ }
164
+ /** Persist a report locally when delivery fails, so nothing is lost. Returns
110
165
  * the absolute path written. */
111
166
  function saveLocally(title, body) {
112
167
  const dir = join(userScopeRoot(), 'feedback');
@@ -118,17 +173,17 @@ function saveLocally(title, body) {
118
173
  }
119
174
  export const sysFeedbackLeaf = defineLeaf({
120
175
  name: 'feedback',
121
- description: 'report a crtr harness bug as a GitHub issue',
122
- whenToUse: 'a crtr command itself misbehaves — it errors unexpectedly, hangs, churns, double-spawns, or acts contrary to its documented contract. NOT for your task work or decisions a human should make (that is `crtr human ask`); this reports the TOOL, filing an issue against the crtr repo with your node context auto-attached.',
176
+ description: 'report a crtr harness bug to whoever maintains this crtr install',
177
+ whenToUse: 'a crtr command itself misbehaves — it errors unexpectedly, hangs, churns, double-spawns, or acts contrary to its documented contract. NOT for your task work or decisions the user should make (that is `crtr human send`); this reports the TOOL, filing a report with the crtr maintainers with your node context auto-attached.',
123
178
  help: {
124
179
  name: 'sys feedback',
125
- summary: 'file a GitHub issue describing a crtr harness bug, with node context auto-attached',
180
+ summary: 'file a report describing a crtr harness bug, with node context auto-attached',
126
181
  params: [
127
182
  {
128
183
  kind: 'positional',
129
184
  name: 'message',
130
185
  required: true,
131
- constraint: 'What broke, in your own words: the command you ran, what you expected, what happened instead. Be concrete — this is the only part a human writes; everything else is auto-collected.',
186
+ constraint: 'What broke, in your own words: the command you ran, what you expected, what happened instead. Be concrete — this is the only part you write; everything else is auto-collected.',
132
187
  },
133
188
  {
134
189
  kind: 'flag',
@@ -140,14 +195,14 @@ export const sysFeedbackLeaf = defineLeaf({
140
195
  ],
141
196
  output: [
142
197
  { name: 'status', type: 'string', required: true, constraint: 'filed | saved-locally.' },
143
- { name: 'url', type: 'string', required: false, constraint: 'Issue URL when status is filed.' },
144
- { name: 'path', type: 'string', required: false, constraint: 'Local report path when status is saved-locally (gh unavailable).' },
198
+ { name: 'url', type: 'string', required: false, constraint: 'Where the report landed, when the sink names a location.' },
199
+ { name: 'path', type: 'string', required: false, constraint: 'Local report path when status is saved-locally.' },
145
200
  { name: 'error', type: 'string', required: false, constraint: 'Why filing fell back to local save.' },
146
201
  ],
147
202
  outputKind: 'object',
148
203
  effects: [
149
- `Opens a public GitHub issue on ${FEEDBACK_REPO} via gh.`,
150
- 'On gh failure (missing / unauthenticated), writes the report to ~/.crouter/feedback/ instead — never lost.',
204
+ `Sends the report where this install delivers harness feedback: the ${ENDPOINT_ENV} endpoint when the deployment sets one, otherwise a public GitHub issue on ${FEEDBACK_REPO} via gh.`,
205
+ 'On delivery failure, writes the report to ~/.crouter/feedback/ instead — never lost.',
151
206
  ],
152
207
  },
153
208
  run: async (input) => {
@@ -158,17 +213,16 @@ export const sysFeedbackLeaf = defineLeaf({
158
213
  const ctx = await collectContext();
159
214
  const title = deriveTitle(input['title'], message);
160
215
  const body = buildBody(message, ctx);
161
- const res = spawnSync('gh', ['issue', 'create', '--repo', FEEDBACK_REPO, '--title', title, '--body-file', '-'], { input: body, encoding: 'utf8' });
162
- if (res.error === undefined && res.status === 0) {
163
- const url = res.stdout.trim().split('\n').filter((l) => l.startsWith('http')).pop() ?? res.stdout.trim();
164
- return { status: 'filed', url };
216
+ const endpoint = process.env[ENDPOINT_ENV]?.trim();
217
+ const delivery = endpoint !== undefined && endpoint !== ''
218
+ ? await deliverToEndpoint(endpoint, title, body, message, ctx)
219
+ : deliverToGitHub(title, body);
220
+ if (delivery.error === undefined) {
221
+ return delivery.url === undefined ? { status: 'filed' } : { status: 'filed', url: delivery.url };
165
222
  }
166
- // gh missing, unauthenticated, or the API call failed — persist locally so
167
- // the report survives, and tell the agent how to escalate.
168
- const reason = res.error !== undefined
169
- ? `gh not available: ${res.error.message}`
170
- : (res.stderr?.trim() || `gh exited ${res.status}`);
223
+ // The sink rejected or was unreachable — persist locally so the report
224
+ // survives, and tell the agent why.
171
225
  const path = saveLocally(title, body);
172
- return { status: 'saved-locally', path, error: reason };
226
+ return { status: 'saved-locally', path, error: delivery.error };
173
227
  },
174
228
  });
@@ -9,7 +9,7 @@ import { BROKER_LIMIT_OVERRIDE_PRECEDENCE } from './setup.js';
9
9
  export const sysSettingsLeaf = defineLeaf({
10
10
  name: 'settings',
11
11
  description: 'open the settings page — providers, models, keybindings, display, panes, broker limits, updates',
12
- whenToUse: 'a human wants to change crouter configuration interactively. Agents should use sys config get/set, which is scriptable; sys setup installs a fresh machine.',
12
+ whenToUse: 'the user wants to change crouter configuration interactively. Agents should use sys config get/set, which is scriptable; sys setup installs a fresh machine.',
13
13
  help: {
14
14
  name: 'sys settings',
15
15
  summary: 'open the interactive settings page for an already-installed crouter',
@@ -104,7 +104,7 @@ test('an on-change cron that cancels itself still delivers its final output', as
104
104
  assert.equal(markSelfCanceledCronRun('self-cancel-delivery', readFileSync(runIdPath, 'utf8')), true);
105
105
  assert.equal(cancelCron('self-cancel-delivery', { preserveRunningProcess: true }), true);
106
106
  const record = await run;
107
- assert.equal(record?.delivered, 'delivered: human notify');
107
+ assert.equal(record?.delivered, 'delivered: human send --inbox');
108
108
  });
109
109
  test('cronLaunchesNode classifies by command verb and armed sink', () => {
110
110
  const of = (command, sink = '{}') => cronLaunchesNode({ command, sink });
@@ -131,8 +131,11 @@ test('an unplaced active node is left alone before it boots', async () => {
131
131
  // node with no fleet entry must NOT be spuriously revived by the tick. Pass-2
132
132
  // is the ONLY wake path for a released node, and it fires solely on an unseen
133
133
  // inbox entry — a null recorded pid plus a saved session must never read as a
134
- // "stranded relaunch" (that inference branch is deleted).
135
- test('idle-release node with a boot-cleared (null) pid and no unseen inbox stays dormant', async () => {
134
+ // "stranded relaunch" (that inference branch is deleted). With nothing left to
135
+ // wake it and no live subscriber awaiting its report, the same pass settles it
136
+ // as a terminal node torn down before finishing rather than leaving a
137
+ // permanently protected idle row.
138
+ test('idle-release node with a boot-cleared (null) pid and no unseen inbox is settled, never revived', async () => {
136
139
  createNode(node('D', {
137
140
  status: 'idle',
138
141
  intent: 'idle-release',
@@ -143,8 +146,8 @@ test('idle-release node with a boot-cleared (null) pid and no unseen inbox stays
143
146
  await superviseTick(t0); // first observation
144
147
  await superviseTick(t0 + 60_000); // well past the boot grace
145
148
  const after = getNode('D');
146
- assert.equal(after.status, 'idle', 'dormant idle-release node is left idle, not revived');
147
- assert.equal(after.intent, 'idle-release', 'intent unchanged — no revive fired');
149
+ assert.equal(after.status, 'canceled', 'a dormant terminal row nobody awaits becomes prunable canceled history');
150
+ assert.equal(after.intent, null, 'no revive fired — the row is settled, not relaunched');
148
151
  assert.equal(after.pi_pid, null, 'no fresh pid — reviveNode was never invoked');
149
152
  });
150
153
  // Daemon start guard: spawnDaemon must not report success before the daemon is
@@ -4,11 +4,7 @@
4
4
  // pure function mapping a known-dead node's durable row state to its recovery
5
5
  // action, used at exactly two call sites — the exit-policy job and the startup
6
6
  // recovery sweep — so exit recovery and startup recovery can never diverge.
7
- // This file pins every table row directly, replacing the scattered pins the
8
- // five deleted liveness-inference branches used to carry (grace-clock,
9
- // stranded-relaunch, daemon-liveness), plus the `retryResumeMode` policy pins
10
- // ported verbatim from the deleted grace-clock suite (the function is pure and
11
- // unchanged — only its caller moved into the table).
7
+ // This file pins every table row directly.
12
8
  import { test } from 'node:test';
13
9
  import assert from 'node:assert/strict';
14
10
  import { classifyDeadNode, retryResumeMode } from '../../daemon/fleet.js';
@@ -24,7 +20,7 @@ function row(over = {}) {
24
20
  };
25
21
  }
26
22
  // ===========================================================================
27
- // The nine table rows, in evaluation order (design flow B).
23
+ // The eight table rows, in evaluation order (design flow B).
28
24
  // ===========================================================================
29
25
  test('row 1: a deleted row (orphan of a pruned/closed node) is forgotten', () => {
30
26
  assert.equal(classifyDeadNode(null, false), 'forget');
@@ -44,42 +40,30 @@ test('row 4: intent=idle-release (chosen dormancy) takes no action — pass-2 in
44
40
  assert.equal(classifyDeadNode(row({ intent: 'idle-release' }), false), 'dormant');
45
41
  assert.equal(classifyDeadNode(row({ intent: 'idle-release', pi_session_id: 'sess' }), false), 'dormant', 'a saved session does not turn chosen dormancy into a crash');
46
42
  });
47
- test('row 5: a broker-certified clean abort resumes its persisted session', () => {
48
- assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), true, true), 'respawn-resume');
49
- assert.equal(classifyDeadNode(row({ pi_session_id: 'sess', cycle_pending: true }), true, true), 'respawn-resume', 'the clean-abort certificate proves this interrupted turn is resumable, even if a prior cycle was pending');
50
- assert.equal(classifyDeadNode(row(), true, true), 'respawn-cycle', 'a certificate without a saved session cannot turn a dirty busy death into a resume');
51
- });
52
- test('row 6: a set busy marker without clean-abort evidence is a dirty mid-turn crash — ALWAYS a recovery cycle', () => {
53
- assert.equal(classifyDeadNode(row(), true), 'respawn-cycle');
54
- assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), true), 'respawn-cycle');
55
- assert.equal(classifyDeadNode(row({ pi_session_id: 'sess', cycle_pending: true }), true), 'respawn-cycle');
56
- });
57
- test('row 7: a saved session with no crash markers resumes strictly; a pending cycle retries AS a cycle', () => {
43
+ test('row 5: every saved session resumes strictly unless its own pending cycle must be retried', () => {
58
44
  assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), false), 'respawn-resume');
59
- assert.equal(classifyDeadNode(row({ pi_session_id: 'sess', cycle_pending: true }), false), 'respawn-cycle');
60
- assert.equal(classifyDeadNode(row({ pi_session_id: 'sess', cycle_pending: false }), false), 'respawn-resume');
45
+ assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), true), 'respawn-resume', 'a dirty interrupted turn resumes in place; the respawn continuation re-drives it');
46
+ assert.equal(classifyDeadNode(row({ pi_session_id: 'sess', cycle_pending: false }), true), 'respawn-resume');
47
+ assert.equal(classifyDeadNode(row({ pi_session_id: 'sess', cycle_pending: true }), true), 'respawn-cycle', 'an interrupted unconfirmed cycling attempt still retries AS that cycle');
48
+ });
49
+ test('row 6: a sessionless node that died mid-turn relaunches fresh instead of becoming a boot failure', () => {
50
+ assert.equal(classifyDeadNode(row(), true), 'respawn-fresh');
61
51
  });
62
- test('row 8: an ordinary fork dead before binding its child session retries the immutable fork birth', () => {
52
+ test('row 7: an ordinary fork dead before binding its child session retries the immutable fork birth', () => {
63
53
  assert.equal(classifyDeadNode(row({ fork_from: '/reviews/branch-point.jsonl' }), false), 'respawn-fresh');
64
54
  });
65
- test('row 9: sessionless with no fork claim — a fresh launch died before session_start — is a boot failure', () => {
55
+ test('row 8: sessionless with no fork claim — a fresh launch died before session_start — is a boot failure', () => {
66
56
  assert.equal(classifyDeadNode(row(), false), 'boot-failure');
67
57
  });
68
58
  // ===========================================================================
69
- // retryResumeMode (pure) — ported verbatim from the deleted grace-clock suite.
70
- // A crash-retry retries AS A CYCLE (resume:false) when the dead attempt's own
71
- // launch was itself a cycling revive that never reached session_start
72
- // (cycle_pending), OR when a surviving busy marker proves the broker died
73
- // mid-turn. Idle crashes resume STRICTLY (resume:true). See canvas.test.ts's
74
- // updateNode/getNode round-trip for the companion guarantee that cycle_pending
75
- // survives the persistence the policy job reads it back from.
59
+ // retryResumeMode (pure): only an unconfirmed cycling launch must cycle again.
60
+ // An interrupted ordinary turn resumes strictly; `#respawn` adds the runtime
61
+ // continuation that re-drives the stopped work in the same conversation.
76
62
  // ===========================================================================
77
- test('retryResumeMode: interrupted turns and pending cycles recover as cycles; idle crashes resume strictly', () => {
78
- assert.equal(retryResumeMode({ cycle_pending: true }, false), false, 'a pending cycling attempt retries AS a cycle');
79
- assert.equal(retryResumeMode({ cycle_pending: false }, true), false, 'a surviving busy marker starts a recovery cycle');
80
- assert.equal(retryResumeMode({ cycle_pending: true }, true), false, 'both recovery signals still produce one cycle');
81
- assert.equal(retryResumeMode({ cycle_pending: false }, false), true, 'an idle ordinary crash resumes strictly');
82
- assert.equal(retryResumeMode({ cycle_pending: null }, false), true, 'cycle_pending=null idle crash resumes strictly');
83
- assert.equal(retryResumeMode({}, false), true, 'cycle_pending undefined idle crash resumes strictly');
84
- assert.equal(retryResumeMode(null, false), true, 'no meta and no busy marker resumes strictly');
63
+ test('retryResumeMode: pending cycles recover as cycles; every confirmed saved session resumes strictly', () => {
64
+ assert.equal(retryResumeMode({ cycle_pending: true }), false, 'a pending cycling attempt retries AS a cycle');
65
+ assert.equal(retryResumeMode({ cycle_pending: false }), true, 'an ordinary saved session resumes strictly');
66
+ assert.equal(retryResumeMode({ cycle_pending: null }), true, 'cycle_pending=null resumes strictly');
67
+ assert.equal(retryResumeMode({}), true, 'cycle_pending undefined resumes strictly');
68
+ assert.equal(retryResumeMode(null), true, 'no pending cycle resumes strictly');
85
69
  });
@@ -10,9 +10,12 @@ test('refresh extension abort clears Pi follow-ups before abort while an ordinar
10
10
  abortSessionFromExtension(session);
11
11
  assert.deepEqual(events, ['abort'], 'ordinary extension abort leaves queued messages in the current conversation');
12
12
  events.length = 0;
13
- runRefreshAbort(() => abortSessionFromExtension(session));
13
+ let refreshAborts = 0;
14
+ runRefreshAbort(() => abortSessionFromExtension(session, () => { refreshAborts += 1; }));
14
15
  assert.deepEqual(events, ['clear', 'abort'], 'refresh removes queued messages before stopping the discarded conversation');
16
+ assert.equal(refreshAborts, 1, 'refresh stages provenance with the host abort');
15
17
  events.length = 0;
16
- abortSessionFromExtension(session);
18
+ abortSessionFromExtension(session, () => { refreshAborts += 1; });
17
19
  assert.deepEqual(events, ['abort'], 'the refresh scope does not leak into a later critical abort');
20
+ assert.equal(refreshAborts, 1, 'an ordinary extension abort does not stage refresh provenance');
18
21
  });
@@ -188,8 +188,19 @@ declare class FakeSession {
188
188
  private syncTreeSnapshot;
189
189
  setSessionName(_name: string): void;
190
190
  prompt(text: string, _options?: unknown): Promise<void>;
191
- steer(_text: string): Promise<void>;
192
- followUp(_text: string): Promise<void>;
191
+ /** The unrun queues the welcome snapshot reports (`snapshot.queued`). Real
192
+ * pi holds a steer + followUp queue and drains it when the next turn runs;
193
+ * the fake records the same texts and clears them in emitTurn, so a client
194
+ * attaching mid-queue sees what a real broker would hand it. Both readers
195
+ * exist on the real AgentSession, so omitting them does not make the fake
196
+ * merely queueless — it makes buildSnapshot throw, and every attach test
197
+ * then times out waiting for a welcome the broker never built. */
198
+ private readonly steering;
199
+ private readonly followUps;
200
+ getSteeringMessages(): readonly string[];
201
+ getFollowUpMessages(): readonly string[];
202
+ steer(text: string): Promise<void>;
203
+ followUp(text: string): Promise<void>;
193
204
  /** In-memory recording only — no proof file, matching the offline in-process
194
205
  * contract broker-sdk-wiring.test.ts's REAL-SDK tests already use. Purely
195
206
  * defensive: broker.ts's driveEngine now calls `session.sendCustomMessage`
@@ -224,7 +224,22 @@ class SessionTree {
224
224
  buildSessionContext() {
225
225
  return { messages: this.branchMessages(), thinkingLevel: 'off', model: null };
226
226
  }
227
+ /** Every entry in the session file, active branch or abandoned tail alike —
228
+ * the real SessionManager.getEntries(). */
229
+ getEntries() {
230
+ return [...this.entries.values()];
231
+ }
232
+ /** The active branch root→leaf — the real SessionManager.getBranch(). The
233
+ * extensions that scan for their own marker read THIS, not the whole file:
234
+ * a new cycle must not see a previous branch's markers. */
235
+ getBranch() {
236
+ return this.branchIds().map((id) => this.entries.get(id)).filter((entry) => entry !== undefined);
237
+ }
227
238
  branchMessages() {
239
+ return this.branchIds().map((id) => this.entries.get(id)?.message).filter((m) => m !== undefined);
240
+ }
241
+ /** The active branch's entry ids, root→leaf. */
242
+ branchIds() {
228
243
  const ids = [];
229
244
  let cur = this.leafId;
230
245
  while (cur !== null) {
@@ -235,7 +250,7 @@ class SessionTree {
235
250
  cur = entry.parentId;
236
251
  }
237
252
  ids.reverse();
238
- return ids.map((id) => this.entries.get(id)?.message).filter((m) => m !== undefined);
253
+ return ids;
239
254
  }
240
255
  node(id) {
241
256
  const entry = this.entries.get(id);
@@ -446,10 +461,27 @@ class FakeSession {
446
461
  this.promptFailuresRemaining--;
447
462
  return this.emitTurn(text, { fail, runHooks: true });
448
463
  }
449
- steer(_text) {
464
+ /** The unrun queues the welcome snapshot reports (`snapshot.queued`). Real
465
+ * pi holds a steer + followUp queue and drains it when the next turn runs;
466
+ * the fake records the same texts and clears them in emitTurn, so a client
467
+ * attaching mid-queue sees what a real broker would hand it. Both readers
468
+ * exist on the real AgentSession, so omitting them does not make the fake
469
+ * merely queueless — it makes buildSnapshot throw, and every attach test
470
+ * then times out waiting for a welcome the broker never built. */
471
+ steering = [];
472
+ followUps = [];
473
+ getSteeringMessages() {
474
+ return this.steering;
475
+ }
476
+ getFollowUpMessages() {
477
+ return this.followUps;
478
+ }
479
+ steer(text) {
480
+ this.steering.push(text);
450
481
  return Promise.resolve();
451
482
  }
452
- followUp(_text) {
483
+ followUp(text) {
484
+ this.followUps.push(text);
453
485
  return Promise.resolve();
454
486
  }
455
487
  /** In-memory recording only — no proof file, matching the offline in-process
@@ -560,6 +592,9 @@ class FakeSession {
560
592
  // still climbs monotonically past the 32 MiB byte cap and is shed. Without the
561
593
  // pace the burst outruns the reader's event loop and sheds the fast viewer too.
562
594
  const yieldTick = () => pad > 0 ? new Promise((r) => setTimeout(r, 3)) : new Promise((r) => setImmediate(r));
595
+ // A turn consumes whatever was queued for it, as the real session does.
596
+ this.steering.length = 0;
597
+ this.followUps.length = 0;
563
598
  this.streaming = true;
564
599
  if (opts.runHooks === true) {
565
600
  await this.fire('agent_start', {}, this.buildCtx());
@@ -789,6 +824,7 @@ class FakeSession {
789
824
  CRTR_MODE: env['CRTR_MODE'] ?? null,
790
825
  CRTR_LIFECYCLE: env['CRTR_LIFECYCLE'] ?? null,
791
826
  CRTR_NODE_CWD: env['CRTR_NODE_CWD'] ?? null,
827
+ CRTR_PROFILE_ID: env['CRTR_PROFILE_ID'] ?? null,
792
828
  CRTR_HOME: env['CRTR_HOME'] ?? null,
793
829
  CRTR_PARENT_NODE_ID: env['CRTR_PARENT_NODE_ID'] ?? null,
794
830
  CRTR_ROOT_SESSION: env['CRTR_ROOT_SESSION'] ?? null,
@@ -829,6 +865,14 @@ class FakeSession {
829
865
  getSessionId: () => this.sm.getSessionId(),
830
866
  getSessionFile: () => this.sm.getSessionFile(),
831
867
  getLeafId: () => this.turnEndLeafId ?? this.tree.getLeafId(),
868
+ // The session-scanning extensions (context-intro, review-boundary,
869
+ // recap) read the branch at session_start to decide whether their own
870
+ // marker is already present. Both exist on the real SessionManager, so
871
+ // a fake missing them is not a degraded fixture but a broken one: the
872
+ // handler throws, session_start never completes, and every attach test
873
+ // times out waiting for a welcome that the broker never sends.
874
+ getBranch: () => this.tree.getBranch(),
875
+ getEntries: () => this.tree.getEntries(),
832
876
  },
833
877
  getContextUsage: () => ({ tokens: 1000 }),
834
878
  // Wired to the broker's shutdownHandler: the stophook calls ctx.shutdown()
@@ -1,16 +1,19 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/human-cancel-guard.test.ts
2
2
  //
3
- // N2 — humanCancel on an already-canceled (but unresolved) interaction node must
4
- // short-circuit to {canceled:false, reason:'already_resolved'} instead of falling
5
- // through to transition('finalize'), which is illegal from status='canceled' and
6
- // would throw. Guards the one-line hardening in queue.ts's status guard.
3
+ // N2 — humanCancel on a ticket the store has already settled must short-circuit to
4
+ // {canceled:false, reason:'already_resolved'} rather than throwing. A second cancel
5
+ // is the ordinary race (the human submits while the agent retracts), so the client
6
+ // guard in queue.ts turns the daemon's 409 into an idempotent no-op.
7
7
  import { test, before, after, beforeEach, afterEach } from 'node:test';
8
8
  import assert from 'node:assert/strict';
9
9
  import { mkdtempSync, mkdirSync, rmSync, existsSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
- import { createNode, setStatus } from '../canvas/canvas.js';
12
+ import { createNode } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
+ import { nodeDir } from '../canvas/paths.js';
15
+ import { atomicWriteJson, bindTicketReplyRoute, pageManifestPath } from '../human/convention.js';
16
+ import { cancelTicket } from '../human/tickets.js';
14
17
  import { humanCancel } from '../../commands/human/queue.js';
15
18
  import { createApiServer } from '../../daemon/api/server.js';
16
19
  import { apiSocketPath } from '../canvas/paths.js';
@@ -35,12 +38,29 @@ function node(id) {
35
38
  name: id,
36
39
  created: new Date().toISOString(),
37
40
  cwd: '/tmp/work',
38
- kind: 'general',
41
+ kind: 'human',
39
42
  mode: 'base',
40
43
  lifecycle: 'terminal',
41
44
  status: 'active',
42
45
  };
43
46
  }
47
+ /** Publish a reply-bearing page ticket in the bridge node's directory, exactly as
48
+ * `human send` does: reply route first, then the page manifest. */
49
+ function publishTicket(id) {
50
+ const dir = nodeDir(id);
51
+ mkdirSync(dir, { recursive: true });
52
+ bindTicketReplyRoute(dir, id);
53
+ atomicWriteJson(pageManifestPath(dir), {
54
+ schema: 'crtr.page/v2',
55
+ dialect: 'jsx',
56
+ title: id,
57
+ subtitle: 'Acknowledge this ticket so the request can proceed.',
58
+ delivery: { placement: 'inline', inbox: true, reply: true },
59
+ document: 'page.tsx',
60
+ steps: 1,
61
+ slots: [],
62
+ });
63
+ }
44
64
  before(() => {
45
65
  home = mkdtempSync(join(tmpdir(), 'crtr-humancancel-'));
46
66
  process.env['CRTR_HOME'] = home;
@@ -60,12 +80,13 @@ after(() => {
60
80
  rmSync(home, { recursive: true, force: true });
61
81
  delete process.env['CRTR_HOME'];
62
82
  });
63
- test('cancel on an already-canceled node is a no-op, never throws on the finalize', async () => {
83
+ test('cancel on an already-canceled ticket is a no-op, never throws on the finalize', async () => {
64
84
  const id = 'canceledJob';
65
85
  createNode(node(id));
66
- setStatus(id, 'canceled'); // canceled but no response.json written yet
67
- const res = (await humanCancel.run({ job_id: id }));
86
+ publishTicket(id);
87
+ cancelTicket(nodeDir(id), { actor: 'human' }); // settled by the first cancel
88
+ const res = (await humanCancel.run({ ticket_id: id }));
68
89
  assert.equal(res['canceled'], false);
69
90
  assert.equal(res['reason'], 'already_resolved');
70
- assert.equal(res['job_id'], id);
91
+ assert.equal(res['ticket_id'], id);
71
92
  });