@north-light/crouter 0.3.198 → 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 (314) hide show
  1. package/dist/api/client.d.ts +18 -18
  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 +81 -34
  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 +6 -6
  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 +2 -2
  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/clients/attach/chrome/bash-jobs.js +3 -3
  33. package/dist/clients/attach/render/chat-view.d.ts +26 -1
  34. package/dist/clients/attach/render/chat-view.js +61 -11
  35. package/dist/clients/attach/render/page-block.d.ts +34 -0
  36. package/dist/clients/attach/render/page-block.js +226 -0
  37. package/dist/clients/attach/session/whip.d.ts +2 -0
  38. package/dist/clients/attach/session/whip.js +2 -0
  39. package/dist/clients/attach/viewer.js +1419 -993
  40. package/dist/clients/conversation/projection.js +18 -3
  41. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
  42. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -21
  43. package/dist/clients/inbox/controller.js +10 -16
  44. package/dist/clients/inbox/page-adapter.d.ts +1 -3
  45. package/dist/clients/inbox/page-adapter.js +3 -4
  46. package/dist/clients/inbox/resolve.d.ts +1 -1
  47. package/dist/clients/inbox/resolve.js +3 -3
  48. package/dist/clients/inbox/review/document-surface.js +3 -1
  49. package/dist/clients/inbox/review/state.d.ts +2 -1
  50. package/dist/clients/inbox/review/state.js +5 -16
  51. package/dist/clients/inbox/tui/input.js +37 -19
  52. package/dist/clients/inbox/tui/panel.js +5 -31
  53. package/dist/clients/inbox/tui/render.d.ts +11 -0
  54. package/dist/clients/inbox/tui/render.js +71 -27
  55. package/dist/clients/inbox/tui/slots.d.ts +3 -1
  56. package/dist/clients/inbox/tui/slots.js +16 -12
  57. package/dist/clients/inbox/tui/types.d.ts +2 -7
  58. package/dist/clients/inbox/tui.js +2 -2
  59. package/dist/commands/__tests__/human.test.js +71 -114
  60. package/dist/commands/__tests__/node-message.test.js +14 -0
  61. package/dist/commands/attention.js +4 -4
  62. package/dist/commands/canvas-snapshot.js +1 -1
  63. package/dist/commands/canvas.js +1 -1
  64. package/dist/commands/dashboard.js +1 -1
  65. package/dist/commands/human/{html.d.ts → components.d.ts} +1 -1
  66. package/dist/commands/human/components.js +77 -0
  67. package/dist/commands/human/feedback.d.ts +2 -0
  68. package/dist/commands/human/feedback.js +108 -0
  69. package/dist/commands/human/prompts.d.ts +13 -5
  70. package/dist/commands/human/prompts.js +160 -97
  71. package/dist/commands/human/queue.js +57 -88
  72. package/dist/commands/human/shared.d.ts +2 -12
  73. package/dist/commands/human/shared.js +17 -11
  74. package/dist/commands/human.js +29 -21
  75. package/dist/commands/memory/origin.js +2 -2
  76. package/dist/commands/node/bash.js +4 -4
  77. package/dist/commands/node/inspect.js +3 -3
  78. package/dist/commands/node/lifecycle.js +1 -1
  79. package/dist/commands/node/message.js +2 -2
  80. package/dist/commands/node-worktree.js +8 -8
  81. package/dist/commands/pkg/browse.js +1 -1
  82. package/dist/commands/pkg/plugin-manage.js +129 -10
  83. package/dist/commands/profile/delete.js +41 -15
  84. package/dist/commands/profile.js +1 -1
  85. package/dist/commands/push.js +1 -1
  86. package/dist/commands/surface/node/focus.js +29 -14
  87. package/dist/commands/surface-edit.js +3 -3
  88. package/dist/commands/surface-inbox.d.ts +2 -0
  89. package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
  90. package/dist/commands/surface.js +2 -1
  91. package/dist/commands/sys/doctor.js +60 -3
  92. package/dist/commands/sys/feedback.js +79 -25
  93. package/dist/commands/sys/settings.js +1 -1
  94. package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
  95. package/dist/core/__tests__/extension-abort.test.js +5 -2
  96. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
  97. package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
  98. package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
  99. package/dist/core/__tests__/human-deliver.test.js +31 -26
  100. package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
  101. package/dist/core/__tests__/plugin-page-components.test.js +157 -0
  102. package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
  103. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
  104. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
  105. package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
  106. package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
  107. package/dist/core/__tests__/serial/worktree.test.js +24 -198
  108. package/dist/core/__tests__/session-cycles.test.js +22 -0
  109. package/dist/core/__tests__/stop-guard.test.js +4 -4
  110. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
  111. package/dist/core/bash-jobs.d.ts +6 -0
  112. package/dist/core/bash-jobs.js +10 -0
  113. package/dist/core/canvas/__tests__/attention.test.js +8 -5
  114. package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
  115. package/dist/core/canvas/attention.js +8 -6
  116. package/dist/core/canvas/browse/model.d.ts +8 -2
  117. package/dist/core/canvas/browse/model.js +11 -4
  118. package/dist/core/canvas/canvas.d.ts +7 -0
  119. package/dist/core/canvas/canvas.js +37 -0
  120. package/dist/core/canvas/crons.d.ts +9 -0
  121. package/dist/core/canvas/crons.js +51 -0
  122. package/dist/core/canvas/node-order.d.ts +22 -1
  123. package/dist/core/canvas/node-order.js +26 -1
  124. package/dist/core/canvas/render-source.d.ts +5 -0
  125. package/dist/core/canvas/render-source.js +1 -0
  126. package/dist/core/canvas/types.d.ts +9 -3
  127. package/dist/core/command-plugins/bundle.d.ts +6 -1
  128. package/dist/core/command-plugins/bundle.js +26 -13
  129. package/dist/core/command.js +2 -1
  130. package/dist/core/config.d.ts +47 -1
  131. package/dist/core/config.js +137 -4
  132. package/dist/core/help.js +1 -1
  133. package/dist/core/human/__tests__/page-catalog.test.js +4 -0
  134. package/dist/core/human/__tests__/page-tickets.test.js +65 -52
  135. package/dist/core/human/__tests__/page.test.js +56 -98
  136. package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
  137. package/dist/core/human/answer-text.js +3 -3
  138. package/dist/core/human/answer.d.ts +6 -4
  139. package/dist/core/human/answer.js +4 -10
  140. package/dist/core/human/component-docs.d.ts +11 -7
  141. package/dist/core/human/component-docs.js +361 -88
  142. package/dist/core/human/convention.d.ts +17 -3
  143. package/dist/core/human/convention.js +36 -5
  144. package/dist/core/human/feedback-companion.d.ts +20 -0
  145. package/dist/core/human/feedback-companion.js +98 -0
  146. package/dist/core/human/feedback.d.ts +65 -0
  147. package/dist/core/human/feedback.js +116 -0
  148. package/dist/core/human/page-catalog.d.ts +40 -5
  149. package/dist/core/human/page-catalog.js +102 -30
  150. package/dist/core/human/page-errors.d.ts +4 -0
  151. package/dist/core/human/page-errors.js +7 -0
  152. package/dist/core/human/page-eval.d.ts +20 -0
  153. package/dist/core/human/page-eval.js +123 -0
  154. package/dist/core/human/page-schema.d.ts +23 -191
  155. package/dist/core/human/page-schema.js +60 -89
  156. package/dist/core/human/page-synth.d.ts +1 -1
  157. package/dist/core/human/page-synth.js +15 -18
  158. package/dist/core/human/page.d.ts +12 -27
  159. package/dist/core/human/page.js +142 -474
  160. package/dist/core/human/root.d.ts +6 -2
  161. package/dist/core/human/root.js +12 -4
  162. package/dist/core/human/scan.d.ts +3 -4
  163. package/dist/core/human/scan.js +16 -15
  164. package/dist/core/human/summary.d.ts +1 -2
  165. package/dist/core/human/summary.js +2 -2
  166. package/dist/core/human/tickets.d.ts +20 -14
  167. package/dist/core/human/tickets.js +91 -31
  168. package/dist/core/human/types.d.ts +10 -2
  169. package/dist/core/preview-registry.js +8 -10
  170. package/dist/core/profiles/default-binding.d.ts +4 -0
  171. package/dist/core/profiles/default-binding.js +24 -0
  172. package/dist/core/profiles/deletion-reservation.d.ts +8 -0
  173. package/dist/core/profiles/deletion-reservation.js +38 -0
  174. package/dist/core/profiles/manifest.d.ts +3 -0
  175. package/dist/core/profiles/manifest.js +12 -0
  176. package/dist/core/profiles/select.js +2 -2
  177. package/dist/core/review/realize.js +3 -3
  178. package/dist/core/review/store.d.ts +6 -0
  179. package/dist/core/review/store.js +31 -0
  180. package/dist/core/runtime/bearings.d.ts +0 -4
  181. package/dist/core/runtime/bearings.js +4 -62
  182. package/dist/core/runtime/bin-contributions.d.ts +44 -0
  183. package/dist/core/runtime/bin-contributions.js +186 -0
  184. package/dist/core/runtime/broker/client-registry.d.ts +4 -0
  185. package/dist/core/runtime/broker/client-registry.js +13 -1
  186. package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
  187. package/dist/core/runtime/broker/extension-abort.js +4 -2
  188. package/dist/core/runtime/broker/fault-retry.js +4 -0
  189. package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
  190. package/dist/core/runtime/broker/frame-dispatch.js +5 -6
  191. package/dist/core/runtime/broker/read-ops.d.ts +1 -1
  192. package/dist/core/runtime/broker/read-ops.js +2 -2
  193. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  194. package/dist/core/runtime/broker/rebind.js +6 -1
  195. package/dist/core/runtime/broker-extension-render.js +19 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  197. package/dist/core/runtime/broker.d.ts +1 -1
  198. package/dist/core/runtime/broker.js +17 -6
  199. package/dist/core/runtime/front-door-env.d.ts +5 -0
  200. package/dist/core/runtime/front-door-env.js +16 -0
  201. package/dist/core/runtime/front-door.d.ts +1 -5
  202. package/dist/core/runtime/front-door.js +2 -5
  203. package/dist/core/runtime/host.d.ts +1 -1
  204. package/dist/core/runtime/invocation.d.ts +6 -0
  205. package/dist/core/runtime/invocation.js +10 -0
  206. package/dist/core/runtime/kickoff.js +34 -0
  207. package/dist/core/runtime/launch.d.ts +2 -6
  208. package/dist/core/runtime/node-read.js +4 -1
  209. package/dist/core/runtime/nodes.js +2 -0
  210. package/dist/core/runtime/promote.d.ts +1 -0
  211. package/dist/core/runtime/promote.js +3 -1
  212. package/dist/core/runtime/session-cycles.d.ts +6 -0
  213. package/dist/core/runtime/session-cycles.js +15 -1
  214. package/dist/core/runtime/session-visibility.d.ts +17 -8
  215. package/dist/core/runtime/session-visibility.js +19 -10
  216. package/dist/core/runtime/spawn-env.d.ts +9 -1
  217. package/dist/core/runtime/spawn-env.js +62 -2
  218. package/dist/core/runtime/stop-guard.d.ts +2 -2
  219. package/dist/core/runtime/stop-guard.js +2 -2
  220. package/dist/core/runtime/stop-signals.d.ts +1 -1
  221. package/dist/core/runtime/stop-signals.js +2 -2
  222. package/dist/core/runtime/tmux-bindings.js +1 -1
  223. package/dist/core/session-model/session-state.d.ts +4 -3
  224. package/dist/core/session-model/session-state.js +14 -1
  225. package/dist/core/user-settings.d.ts +21 -2
  226. package/dist/core/user-settings.js +32 -11
  227. package/dist/core/worktree.d.ts +15 -45
  228. package/dist/core/worktree.js +59 -261
  229. package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
  230. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
  231. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  232. package/dist/daemon/api/handlers/broker-ops.js +9 -5
  233. package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
  234. package/dist/daemon/api/handlers/feedback-comments.js +207 -0
  235. package/dist/daemon/api/handlers/human.js +24 -20
  236. package/dist/daemon/api/handlers/inbox.d.ts +10 -0
  237. package/dist/daemon/api/handlers/inbox.js +39 -115
  238. package/dist/daemon/api/handlers/profiles.js +11 -29
  239. package/dist/daemon/api/map.d.ts +6 -1
  240. package/dist/daemon/api/map.js +23 -0
  241. package/dist/daemon/api/server.js +3 -1
  242. package/dist/daemon/companion-retire.d.ts +2 -0
  243. package/dist/daemon/companion-retire.js +33 -0
  244. package/dist/daemon/cron-run.js +14 -16
  245. package/dist/daemon/human/finish.d.ts +12 -7
  246. package/dist/daemon/human/finish.js +101 -38
  247. package/dist/daemon/human/sweep.js +5 -1
  248. package/dist/daemon/profile-delete.d.ts +7 -0
  249. package/dist/daemon/profile-delete.js +306 -0
  250. package/dist/daemon/reconcilers/broker-supervision.js +3 -1
  251. package/dist/daemon/review/deliver.js +3 -3
  252. package/dist/daemon/review/finish.d.ts +0 -2
  253. package/dist/daemon/review/finish.js +1 -29
  254. package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
  255. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  256. package/dist/pi-extensions/canvas-bash-valve.js +19 -5
  257. package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
  258. package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
  259. package/dist/pi-extensions/canvas-review-boundary.js +18 -9
  260. package/dist/pi-extensions/canvas-stophook.js +4 -4
  261. package/dist/shared/generated-context.js +2 -2
  262. package/dist/types.d.ts +44 -2
  263. package/dist/types.js +7 -2
  264. package/package.json +4 -4
  265. package/runtime.lock.json +5 -33
  266. package/dist/builtin-memory/init.md +0 -38
  267. package/dist/builtin-memory/plan.md +0 -19
  268. package/dist/builtin-memory/spec.md +0 -19
  269. package/dist/commands/human/doc.d.ts +0 -2
  270. package/dist/commands/human/doc.js +0 -91
  271. package/dist/commands/human/html.js +0 -81
  272. package/dist/commands/human/inbox.d.ts +0 -2
  273. package/dist/core/human/__tests__/html-markdown.test.js +0 -52
  274. package/dist/core/human/__tests__/page-render.test.js +0 -66
  275. package/dist/core/human/html-markdown.d.ts +0 -3
  276. package/dist/core/human/html-markdown.js +0 -288
  277. package/dist/core/human/page-render.d.ts +0 -4
  278. package/dist/core/human/page-render.js +0 -105
  279. package/dist/pages/bundle.css +0 -1
  280. package/dist/pages/bundle.js +0 -1778
  281. package/dist/pages/comments.d.ts +0 -72
  282. package/dist/pages/comments.js +0 -176
  283. package/dist/pages/controls.d.ts +0 -98
  284. package/dist/pages/controls.js +0 -261
  285. package/dist/pages/elements/cards.d.ts +0 -28
  286. package/dist/pages/elements/cards.js +0 -915
  287. package/dist/pages/elements/chart.d.ts +0 -23
  288. package/dist/pages/elements/chart.js +0 -664
  289. package/dist/pages/elements/options.d.ts +0 -30
  290. package/dist/pages/elements/options.js +0 -742
  291. package/dist/pages/elements/pages.d.ts +0 -30
  292. package/dist/pages/elements/pages.js +0 -377
  293. package/dist/pages/elements/slot.d.ts +0 -26
  294. package/dist/pages/elements/slot.js +0 -112
  295. package/dist/pages/elements/table.d.ts +0 -36
  296. package/dist/pages/elements/table.js +0 -1043
  297. package/dist/pages/elements/text.d.ts +0 -31
  298. package/dist/pages/elements/text.js +0 -1175
  299. package/dist/pages/entry.d.ts +0 -8
  300. package/dist/pages/entry.js +0 -8
  301. package/dist/pages/host.d.ts +0 -134
  302. package/dist/pages/host.js +0 -165
  303. package/dist/pages/readonly.d.ts +0 -24
  304. package/dist/pages/readonly.js +0 -31
  305. package/dist/pages/register.d.ts +0 -2
  306. package/dist/pages/register.js +0 -3
  307. package/dist/pages/responses.d.ts +0 -37
  308. package/dist/pages/responses.js +0 -56
  309. package/dist/pages/slot-config.d.ts +0 -36
  310. package/dist/pages/slot-config.js +0 -50
  311. package/dist/pages/types.d.ts +0 -98
  312. package/dist/pages/types.js +0 -23
  313. /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
  314. /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
@@ -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 });
@@ -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
  });
@@ -8,12 +8,13 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createApiServer } from '../../daemon/api/server.js';
10
10
  import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
11
- import { humanAsk, humanNotify } from '../../commands/human/prompts.js';
11
+ import { humanSend } from '../../commands/human/prompts.js';
12
12
  import { getNode, listNodes, closeDb } from '../canvas/index.js';
13
13
  import { readInboxSince } from '../feed/inbox.js';
14
14
  import { resolveRef } from '../canvas/history.js';
15
15
  import { apiSocketPath } from '../canvas/paths.js';
16
16
  import { spawnNode } from '../runtime/nodes.js';
17
+ import { bindTicketReplyRoute } from '../human/convention.js';
17
18
  import { ticketDir } from '../human/root.js';
18
19
  import { readTicketResult, submitPage } from '../human/tickets.js';
19
20
  let home;
@@ -39,16 +40,17 @@ async function withMutedStdout(fn) {
39
40
  }
40
41
  function bridgeUnder() {
41
42
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
42
- 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;
43
44
  return { parent, bridge };
44
45
  }
45
46
  function submitBridgePage(bridge) {
46
47
  const dir = ticketDir(bridge);
47
48
  const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-deliver-page-'));
48
- const sourceFile = join(sourceDir, 'page.html');
49
- writeFileSync(sourceFile, '<!doctype html><html><head><meta name="crtr-page-title" content="Ship it?"><meta name="crtr-page-subtitle" content="Decide whether to ship now so the release can proceed."></head><body><crtr-pages><crtr-page><crtr-text id="question"><script type="application/json">{"initialText":"","editable":true}</script></crtr-text></crtr-page></crtr-pages></body></html>');
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');
50
51
  try {
51
- submitPage({ dir, sourceFile, dialect: 'html' });
52
+ bindTicketReplyRoute(dir, bridge);
53
+ submitPage({ dir, sourceFile, delivery: { placement: 'inline', inbox: true, reply: true } });
52
54
  }
53
55
  finally {
54
56
  rmSync(sourceDir, { recursive: true, force: true });
@@ -87,40 +89,41 @@ after(() => {
87
89
  delete process.env['CRTR_NODE_ID'];
88
90
  });
89
91
  describe('enqueue is producer-only', () => {
90
- test('ask preserves title and subtitle from the authored page', async () => {
92
+ test('send preserves title and subtitle from the authored page', async () => {
91
93
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
92
94
  process.env['CRTR_NODE_ID'] = parent;
93
- const page = join(home, 'release-decision.html');
94
- writeFileSync(page, '<!doctype html><html><head><meta name="crtr-page-title" content="Release decision"><meta name="crtr-page-subtitle" content="Decide whether to release now so the launch can proceed."></head><body><p>This first line stays page content.</p><crtr-pages><crtr-page><crtr-text id="question"><script type="application/json">{"initialText":"","editable":true}</script></crtr-text></crtr-page></crtr-pages></body></html>');
95
- await assert.rejects(() => humanAsk.run({}), /a page source is required/);
96
- await assert.rejects(() => humanAsk.run({ page, document: '<!doctype html><html></html>' }), /received both --page and a piped page document/);
97
- const result = (await humanAsk.run({ page, document: '' }));
98
- 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);
99
102
  assert.equal(typeof result['dir'], 'string');
100
103
  for (const forbidden of ['pane_id', 'render_pane_id', 'pid', 'worker_pid'])
101
- assert.ok(!(forbidden in result), `ask result must not expose ${forbidden}`);
104
+ assert.ok(!(forbidden in result), `send result must not expose ${forbidden}`);
102
105
  const manifest = JSON.parse(readFileSync(join(result['dir'], 'page.json'), 'utf8'));
103
106
  assert.equal(manifest.title, 'Release decision');
104
107
  assert.equal(manifest.subtitle, 'Decide whether to release now so the launch can proceed.');
105
- const document = readFileSync(join(result['dir'], 'page.html'), 'utf8');
108
+ const document = readFileSync(join(result['dir'], 'page.tsx'), 'utf8');
106
109
  assert.ok(document.includes('This first line stays page content.'));
107
110
  assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1);
108
111
  });
109
- test('ask materializes a piped page before validation and publication', async () => {
112
+ test('send materializes a piped page before validation and publication', async () => {
110
113
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
111
114
  process.env['CRTR_NODE_ID'] = parent;
112
- const document = '<!doctype html><html><head><meta name="crtr-page-title" content="Piped question"><meta name="crtr-page-subtitle" content="Answer the question to unblock this task."></head><body><crtr-pages><crtr-page><crtr-text id="question"><script type="application/json">{"initialText":""}</script></crtr-text></crtr-page></crtr-pages></body></html>';
113
- const result = (await humanAsk.run({ document }));
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 }));
114
117
  const manifest = JSON.parse(readFileSync(join(result['dir'], 'page.json'), 'utf8'));
115
118
  assert.equal(manifest.title, 'Piped question');
116
- assert.equal(readFileSync(join(result['dir'], 'page.html'), 'utf8'), document);
119
+ assert.equal(readFileSync(join(result['dir'], 'page.tsx'), 'utf8'), document);
117
120
  });
118
121
  });
119
122
  describe('terminal ticket delivery', () => {
120
123
  test('resolvePageTicket pushFinals the readable answer once and wakes the subscribed parent', async () => {
121
124
  const { parent, bridge } = bridgeUnder();
122
125
  const dir = submitBridgePage(bridge);
123
- const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true, comments: [] } });
126
+ const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
124
127
  assert.equal(result.kind, 'page');
125
128
  closeDb();
126
129
  assert.equal(getNode(bridge)?.status, 'done');
@@ -133,7 +136,7 @@ describe('terminal ticket delivery', () => {
133
136
  test('a replay against a terminal bridge is a no-op', async () => {
134
137
  const { parent, bridge } = bridgeUnder();
135
138
  submitBridgePage(bridge);
136
- await resolvePageTicket(bridge, { question: { text: 'yes', edited: true, comments: [] } });
139
+ await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
137
140
  assert.equal(await deliverTerminalResult(bridge), true);
138
141
  closeDb();
139
142
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
@@ -141,7 +144,7 @@ describe('terminal ticket delivery', () => {
141
144
  });
142
145
  test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
143
146
  const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
144
- 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;
145
148
  const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
146
149
  const { subscribe } = await import('../canvas/canvas.js');
147
150
  subscribe(other, bridge, true);
@@ -156,12 +159,14 @@ describe('terminal ticket delivery', () => {
156
159
  assert.match(notes[0].label, /canceled/);
157
160
  });
158
161
  });
159
- test('notify enqueues an authored page and creates no canvas node', async () => {
160
- const page = join(home, 'build-done.html');
161
- writeFileSync(page, '<!doctype html><html><head><meta name="crtr-page-title" content="Build done"><meta name="crtr-page-subtitle" content="The build passed, so the change is ready to review."></head><body><p>Build output is ready.</p></body></html>');
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');
162
165
  const before = listNodes().length;
163
- const result = (await humanNotify.run({ page }));
164
- 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');
165
170
  assert.equal(typeof result['dir'], 'string');
166
171
  assert.ok(existsSync(join(result['dir'], 'page.json')));
167
172
  assert.ok(readTicketResult(result['dir']) === null);