@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
@@ -542,7 +542,8 @@ export async function parseArgv(params, tokens, options) {
542
542
  const missing = [];
543
543
  for (const p of params) {
544
544
  const key = flagNameToKey(p.name);
545
- if (p.required && (result[key] === undefined || result[key] === null)) {
545
+ const missingRequiredBool = p.kind === 'flag' && p.type === 'bool' && !provided.has(key);
546
+ if (p.required && (missingRequiredBool || result[key] === undefined || result[key] === null)) {
546
547
  const display = p.kind === 'positional'
547
548
  ? `positional ${p.name.toUpperCase()}`
548
549
  : p.kind === 'flag'
@@ -1,4 +1,4 @@
1
- import type { Scope, ScopeConfig, ScopeState, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig } from '../types.js';
1
+ import type { PageComponentRegistration, Scope, ScopeConfig, ScopeState, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig } from '../types.js';
2
2
  import { type BindingId } from './keybindings/catalog.js';
3
3
  export declare function configPath(scope: Scope): string | null;
4
4
  export declare function statePath(scope: Scope): string | null;
@@ -26,6 +26,52 @@ export declare function ensureScopeInitialized(scope: Scope, root: string): void
26
26
  /** Normalize the user-owned list of attach actions allowed to bridge through an
27
27
  * occupied tmux root binding. Unknown and non-attach ids have no effect. */
28
28
  export declare function normalizeTmuxPassthrough(raw: unknown): BindingId[];
29
+ /** One malformed `bin` declaration. The read path retains these as doctor
30
+ * findings rather than letting malformed config break a node launch. */
31
+ export interface BinDeclarationIssue {
32
+ name: string;
33
+ message: string;
34
+ }
35
+ /** Validate raw `bin` declaration shape without touching the filesystem.
36
+ * Install uses these findings as a hard gate for plugin manifests; scope
37
+ * resolution reports them through `crtr sys doctor` and continues. */
38
+ export declare function binDeclarationIssues(raw: unknown): BinDeclarationIssue[];
39
+ /** Normalize one raw `bin` block (a scope `config.json`'s or a plugin
40
+ * manifest's) to name → root-relative path. Invalid entries never become
41
+ * commands; `binDeclarationIssues` keeps them visible to Doctor. */
42
+ export declare function normalizeBin(raw: unknown): Record<string, string>;
43
+ /** STRICT install-time validation for a plugin-declared `bin` block. Target
44
+ * filesystem state is checked later by Doctor because it can change after
45
+ * install. */
46
+ export declare function invalidPluginBinReasons(raw: unknown): string[];
47
+ /** Normalize a plugin manifest's advisory executable requirements. Invalid
48
+ * declarations stay out of the read path; source installs reject them through
49
+ * `invalidPluginRequiresReasons`. */
50
+ export declare function normalizeRequires(raw: unknown): Record<string, string>;
51
+ /** STRICT install-time validation for a plugin's advisory `requires` block.
52
+ * A missing executable is advisory; only malformed declarations fail install. */
53
+ export declare function invalidPluginRequiresReasons(raw: unknown): string[];
54
+ /** The ACTIVE page-component catalog every authoring, validation, daemon,
55
+ * inbox, and TUI consumer resolves: the user scope's own `page_components`
56
+ * entries plus the contributions of every ENABLED plugin installed under that
57
+ * same root, plugin name-sorted for determinism (directory listing order is
58
+ * filesystem-dependent). Installing, updating, or removing a plugin therefore
59
+ * adds, changes, or retires its components with no config edit.
60
+ *
61
+ * User scope only, deliberately: this catalog is read from the daemon, the
62
+ * CLI, and the inbox TUI, whose ambient cwds differ, and a page slot whose
63
+ * validity depended on which directory a process happened to start in would
64
+ * validate in one surface and fail in another. `page_components` has always
65
+ * been a user-scope block (`readConfig('user')`); its plugin channel keeps
66
+ * that boundary.
67
+ *
68
+ * Throws on a duplicate kind or derived JSX tag across sources, and on a
69
+ * plugin whose block is malformed — matching the existing catalog validation
70
+ * style, where a bad `page_components` block already fails config read rather
71
+ * than silently dropping a component the product expects to be authorable.
72
+ * Install-time validation (`invalidPageComponentsReasons`) is what keeps a
73
+ * malformed block from ever reaching this path. */
74
+ export declare function resolvePageComponents(): PageComponentRegistration[];
29
75
  /** STRICT install-time validation for a plugin-declared `kinds` block —
30
76
  * the loud counterpart to `mergeKinds`'s silent read-time dropping. Read
31
77
  * paths must never throw on bad config, but an INSTALL delivering a bad
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from 'node:path';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
- import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
3
+ import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, MOUSE_MODE_DEFAULTS, WHIP_MESSAGE_MODES, DEFAULT_WHIP_MESSAGES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
4
4
  import { BINDING_CATALOG, BINDING_IDS, isAttachPaneBinding } from './keybindings/catalog.js';
5
5
  import { emitEvent } from './events/emit.js';
6
6
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
@@ -10,7 +10,7 @@ import { profileRoot } from './profiles/manifest.js';
10
10
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
11
11
  import { SCOPE_CONFIG_KEYS } from './user-settings.js';
12
12
  import { normalizeWorkingGerunds } from '../shared/working-activity.js';
13
- import { normalizePageComponents } from './human/page-catalog.js';
13
+ import { mergePageComponentLayers, normalizePageComponents } from './human/page-catalog.js';
14
14
  function configPathFor(root) {
15
15
  return join(root, CONFIG_FILE);
16
16
  }
@@ -266,6 +266,101 @@ function mergeSpawnEnv(raw, base = []) {
266
266
  }
267
267
  return { allow: [...new Set([...base, ...extra])] };
268
268
  }
269
+ /** The one bare-command-name rule, shared by the read path (`normalizeBin`)
270
+ * and the strict install gate (`invalidPluginBinReasons`): a name a shell can
271
+ * invoke verbatim and a filesystem can hold as a single symlink entry — no
272
+ * separator, no leading dot, no whitespace, no quoting ever required. */
273
+ const BIN_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
274
+ /** Bare names crouter owns on every node's PATH. A contribution may not take
275
+ * one: shadowing the CLI a node drives its own canvas with breaks the runtime
276
+ * contract rather than extending it. */
277
+ const RESERVED_BIN_NAMES = new Set(['crtr', 'crtrd']);
278
+ /** The one bare-command-name validator shared by `bin` and plugin `requires`.
279
+ * `bin` alone reserves crouter's own commands; a plugin may require any safe
280
+ * executable name because it does not contribute or shadow that command. */
281
+ function bareCommandNameIssue(name, field) {
282
+ if (field === 'bin' && RESERVED_BIN_NAMES.has(name))
283
+ return `${field}.${name} is reserved by crouter — pick another bare command name`;
284
+ if (!BIN_NAME_PATTERN.test(name))
285
+ return `${field}.${name} is not a safe bare command name (expected ${BIN_NAME_PATTERN.source})`;
286
+ return undefined;
287
+ }
288
+ /** Validate raw `bin` declaration shape without touching the filesystem.
289
+ * Install uses these findings as a hard gate for plugin manifests; scope
290
+ * resolution reports them through `crtr sys doctor` and continues. */
291
+ export function binDeclarationIssues(raw) {
292
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
293
+ return [{ name: 'bin', message: 'bin must be a JSON object mapping bare command name to a relative path' }];
294
+ }
295
+ const issues = [];
296
+ for (const [name, target] of Object.entries(raw)) {
297
+ const nameIssue = bareCommandNameIssue(name, 'bin');
298
+ if (nameIssue !== undefined)
299
+ issues.push({ name, message: nameIssue });
300
+ if (typeof target !== 'string' || target.trim() === '') {
301
+ issues.push({ name, message: `bin.${name} must be a non-empty relative path to an executable inside the contributor root` });
302
+ }
303
+ }
304
+ return issues;
305
+ }
306
+ /** Normalize one raw `bin` block (a scope `config.json`'s or a plugin
307
+ * manifest's) to name → root-relative path. Invalid entries never become
308
+ * commands; `binDeclarationIssues` keeps them visible to Doctor. */
309
+ export function normalizeBin(raw) {
310
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
311
+ return {};
312
+ const out = {};
313
+ for (const [name, target] of Object.entries(raw)) {
314
+ if (bareCommandNameIssue(name, 'bin') !== undefined)
315
+ continue;
316
+ if (typeof target !== 'string' || target.trim() === '')
317
+ continue;
318
+ out[name] = target;
319
+ }
320
+ return out;
321
+ }
322
+ /** STRICT install-time validation for a plugin-declared `bin` block. Target
323
+ * filesystem state is checked later by Doctor because it can change after
324
+ * install. */
325
+ export function invalidPluginBinReasons(raw) {
326
+ return binDeclarationIssues(raw).map((issue) => issue.message);
327
+ }
328
+ /** Normalize a plugin manifest's advisory executable requirements. Invalid
329
+ * declarations stay out of the read path; source installs reject them through
330
+ * `invalidPluginRequiresReasons`. */
331
+ export function normalizeRequires(raw) {
332
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
333
+ return {};
334
+ const out = {};
335
+ for (const [name, hint] of Object.entries(raw)) {
336
+ if (bareCommandNameIssue(name, 'requires') !== undefined)
337
+ continue;
338
+ if (typeof hint !== 'string' || hint.trim() === '' || /[\r\n]/.test(hint))
339
+ continue;
340
+ out[name] = hint;
341
+ }
342
+ return out;
343
+ }
344
+ /** STRICT install-time validation for a plugin's advisory `requires` block.
345
+ * A missing executable is advisory; only malformed declarations fail install. */
346
+ export function invalidPluginRequiresReasons(raw) {
347
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
348
+ return ['requires must be a JSON object mapping a bare executable name to a one-line install hint'];
349
+ }
350
+ const reasons = [];
351
+ for (const [name, hint] of Object.entries(raw)) {
352
+ const nameIssue = bareCommandNameIssue(name, 'requires');
353
+ if (nameIssue !== undefined)
354
+ reasons.push(nameIssue);
355
+ if (typeof hint !== 'string' || hint.trim() === '') {
356
+ reasons.push(`requires.${name} must be a non-empty one-line install hint`);
357
+ }
358
+ else if (/[\r\n]/.test(hint)) {
359
+ reasons.push(`requires.${name} must be a one-line install hint`);
360
+ }
361
+ }
362
+ return reasons;
363
+ }
269
364
  /** Collect the valid `KindConfig` fields of one raw `kinds.<name>` entry —
270
365
  * every field is optional here, `whenToUse` included; mistyped fields are
271
366
  * dropped, never thrown (an invalid field in config.json must not break
@@ -341,6 +436,40 @@ function layerPluginKinds(scope, scopeRootPath, base) {
341
436
  out = mergeKinds(plugin.manifest.kinds, out);
342
437
  return out;
343
438
  }
439
+ /** The ACTIVE page-component catalog every authoring, validation, daemon,
440
+ * inbox, and TUI consumer resolves: the user scope's own `page_components`
441
+ * entries plus the contributions of every ENABLED plugin installed under that
442
+ * same root, plugin name-sorted for determinism (directory listing order is
443
+ * filesystem-dependent). Installing, updating, or removing a plugin therefore
444
+ * adds, changes, or retires its components with no config edit.
445
+ *
446
+ * User scope only, deliberately: this catalog is read from the daemon, the
447
+ * CLI, and the inbox TUI, whose ambient cwds differ, and a page slot whose
448
+ * validity depended on which directory a process happened to start in would
449
+ * validate in one surface and fail in another. `page_components` has always
450
+ * been a user-scope block (`readConfig('user')`); its plugin channel keeps
451
+ * that boundary.
452
+ *
453
+ * Throws on a duplicate kind or derived JSX tag across sources, and on a
454
+ * plugin whose block is malformed — matching the existing catalog validation
455
+ * style, where a bad `page_components` block already fails config read rather
456
+ * than silently dropping a component the product expects to be authorable.
457
+ * Install-time validation (`invalidPageComponentsReasons`) is what keeps a
458
+ * malformed block from ever reaching this path. */
459
+ export function resolvePageComponents() {
460
+ const layers = [{ origin: 'config.json', components: readConfig('user').page_components }];
461
+ const root = scopeRoot('user');
462
+ if (root !== null) {
463
+ const plugins = listInstalledPluginsInRoot('user', root)
464
+ .filter((plugin) => plugin.enabled && plugin.manifest.page_components !== undefined)
465
+ .sort((a, b) => a.name.localeCompare(b.name));
466
+ for (const plugin of plugins) {
467
+ const label = `plugin "${plugin.name}" page_components`;
468
+ layers.push({ origin: `plugin "${plugin.name}"`, components: normalizePageComponents(plugin.manifest.page_components, label) });
469
+ }
470
+ }
471
+ return mergePageComponentLayers(layers);
472
+ }
344
473
  const KIND_CONFIG_KEYS = new Set(['whenToUse', 'model', 'orchestratorModel', 'tools', 'extensions', 'availableTo']);
345
474
  /** STRICT install-time validation for a plugin-declared `kinds` block —
346
475
  * the loud counterpart to `mergeKinds`'s silent read-time dropping. Read
@@ -480,8 +609,11 @@ function mergeConfig(partial) {
480
609
  : normalizeWorkingGerunds(partial.working_gerunds);
481
610
  const whip_mode = typeof partial.whip_mode === 'boolean' ? partial.whip_mode : defaults.whip_mode;
482
611
  const page_components = normalizePageComponents(partial.page_components);
483
- const html_surface = typeof partial.html_surface === 'boolean' ? partial.html_surface : defaults.html_surface;
612
+ const page_surface = typeof partial.page_surface === 'boolean' ? partial.page_surface : defaults.page_surface;
484
613
  const whip_messages = normalizeWhipMessages(partial.whip_messages);
614
+ const whip_message_mode = WHIP_MESSAGE_MODES.includes(partial.whip_message_mode)
615
+ ? partial.whip_message_mode
616
+ : defaults.whip_message_mode;
485
617
  const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
486
618
  ? partial.mouse_mode_default
487
619
  : defaults.mouse_mode_default;
@@ -501,7 +633,8 @@ function mergeConfig(partial) {
501
633
  const kinds = mergeKinds(partial.kinds);
502
634
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
503
635
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
504
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, html_surface, whip_messages, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
636
+ const bin = normalizeBin(partial.bin);
637
+ return { bin, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
505
638
  }
506
639
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
507
640
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
package/dist/core/help.js CHANGED
@@ -223,7 +223,7 @@ function paramDesc(p) {
223
223
  // flag
224
224
  const f = p;
225
225
  if (f.type === 'bool')
226
- return `optional boolean. Presence means true. ${f.constraint}`.trim();
226
+ return `${req} boolean. Presence means true. ${f.constraint}`.trim();
227
227
  const dflt = f.default !== undefined ? ` Default: ${String(f.default)}.` : '';
228
228
  const choices = f.type === 'enum' && f.choices !== undefined
229
229
  ? ` One of: ${f.choices.join(', ')}.`
@@ -14,3 +14,7 @@ test('page component registrations accept legacy names and documented objects',
14
14
  assert.throws(() => normalizePageComponents([{ kind: 'approval', display: 'yes' }]), /page_components\[0\]\.display must be a boolean/);
15
15
  assert.throws(() => normalizePageComponents([{ kind: 'approval', unexpected: true }]), /unknown field\(s\): unexpected/);
16
16
  });
17
+ test('page component registrations reserve derived built-in and product JSX tags', () => {
18
+ assert.throws(() => normalizePageComponents(['user-question']), /derives JSX tag "UserQuestion", which collides with a built-in page component/);
19
+ assert.throws(() => normalizePageComponents(['foo-bar', 'foo--bar']), /derives JSX tag "FooBar", which duplicates another product page component/);
20
+ });
@@ -5,67 +5,73 @@ import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  const home = mkdtempSync(join(tmpdir(), 'crtr-page-tickets-'));
7
7
  process.env['CRTR_HOME'] = home;
8
- const { atomicWriteJson, pageManifestPath, pagePath, progressPath, responsePath } = await import('../convention.js');
8
+ const { atomicWriteJson, bindTicketReplyRoute, pageManifestPath, pagePath, progressPath, responsePath } = await import('../convention.js');
9
9
  const { claimTicket } = await import('../claim.js');
10
10
  const { scanInbox, scanPageHistory } = await import('../scan.js');
11
11
  const { ticketDir } = await import('../root.js');
12
- const { cancelTicket, completePage, readPageProgress, readPageTicket, preparePage, readTicketResult, submitPage, takeoverAndCompletePage, writePageProgress, } = await import('../tickets.js');
12
+ const { cancelTicket, completePage, readPageProgress, readPageTicket, preparePage, readTicketResult, submitPage, takeoverAndCompletePage, validatePageTicketResponses, writePageProgress, } = await import('../tickets.js');
13
13
  const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
14
14
  function sourceFile(name, body) {
15
15
  const path = join(sources, name);
16
16
  writeFileSync(path, body);
17
17
  return path;
18
18
  }
19
- const askPage = sourceFile('ask.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Rollout scope"><meta name="crtr-page-subtitle" content="Pick tonight’s rollout"><meta name="crtr-page-placement" content="inline"></head><body><p>Which rollout do you want?</p><crtr-pages><crtr-page><crtr-options id="rollout"><script type="application/json">{"mode":"single","options":[{"id":"canary","label":"Canary first"},{"id":"full","label":"Full"}]}</script></crtr-options></crtr-page></crtr-pages></body></html>');
20
- const displayPage = sourceFile('display.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Nightly report"></head><body><h1>All green</h1></body></html>');
21
- function submit(id, source, dialect, blockedSince, nodeId = 'node-1', mode = 'ask') {
22
- const dir = submitPage({ dir: ticketDir(id), sourceFile: source, dialect, source: { nodeId, blockedSince } }).dir;
23
- atomicWriteJson(join(dir, 'run.json'), { mode });
19
+ const askPage = sourceFile('ask.tsx', `export default function Rollout() {
20
+ return (
21
+ <Page title="Rollout scope" subtitle="Pick tonight\u2019s rollout">
22
+ <p>Which rollout do you want?</p>
23
+ <UserQuestion id="rollout" label="Rollout lane" body="Canary limits the blast radius." mode="single" options={[{ id: 'canary', label: 'Canary first' }, { id: 'full', label: 'Full' }]} />
24
+ </Page>
25
+ );
26
+ }
27
+ `);
28
+ const displayPage = sourceFile('display.tsx', `export default function Nightly() {
29
+ return (
30
+ <Page title="Nightly report">
31
+ <h1>All green</h1>
32
+ </Page>
33
+ );
34
+ }
35
+ `);
36
+ function submit(id, source, emittedAt, nodeId = 'node-1', replyExpected = true) {
37
+ const dir = submitPage({ dir: ticketDir(id), sourceFile: source, source: { nodeId, emittedAt }, delivery: { placement: 'inline', inbox: replyExpected, reply: replyExpected } }).dir;
38
+ if (replyExpected)
39
+ bindTicketReplyRoute(dir, id);
24
40
  return dir;
25
41
  }
26
- test('submitPage publishes the authored source, the derived manifest and the blockedSince stamp', () => {
27
- const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'html', source: { nodeId: 'node-1' } });
42
+ test('submitPage publishes the authored source, the derived manifest and the emittedAt stamp', () => {
43
+ const submitted = submitPage({ dir: ticketDir('ask'), sourceFile: askPage, source: { nodeId: 'node-1' }, delivery: { placement: 'inline', inbox: true, reply: true } });
28
44
  assert.equal(submitted.kind, 'page');
29
45
  assert.equal(submitted.id, 'ask');
30
- assert.equal(readFileSync(pagePath(submitted.dir, 'html'), 'utf8'), readFileSync(askPage, 'utf8'));
46
+ assert.equal(readFileSync(pagePath(submitted.dir), 'utf8'), readFileSync(askPage, 'utf8'));
31
47
  const manifest = JSON.parse(readFileSync(pageManifestPath(submitted.dir), 'utf8'));
32
- assert.equal(manifest['schema'], 'crtr.page/v1');
33
- assert.equal(manifest['document'], 'page.html');
34
- assert.equal(manifest['placement'], 'inline');
48
+ assert.equal(manifest['schema'], 'crtr.page/v2');
49
+ assert.equal(manifest['document'], 'page.tsx');
50
+ assert.deepEqual(manifest['delivery'], { placement: 'inline', inbox: true, reply: true });
35
51
  const source = manifest['source'];
36
52
  assert.equal(source.nodeId, 'node-1');
37
- assert.ok(Date.parse(source.blockedSince) > 0, 'submission stamps blockedSince');
38
- assert.throws(() => submitPage({ dir: ticketDir('ask'), sourceFile: askPage, dialect: 'html' }), /already exists/);
39
- atomicWriteJson(join(submitted.dir, 'run.json'), { mode: 'ask' });
53
+ assert.ok(Date.parse(source.emittedAt) > 0, 'submission stamps emittedAt');
54
+ // A ticket's request is replaceable in place — what `human send --replace`
55
+ // does. Republishing over the same directory revises the page and leaves one
56
+ // ticket behind, rather than refusing or raising a second one.
57
+ const stamp = { nodeId: 'node-replace', emittedAt: '2026-01-05T00:00:00.000Z' };
58
+ const first = submitPage({ dir: ticketDir('revisable'), sourceFile: askPage, source: stamp });
59
+ const revised = submitPage({ dir: ticketDir('revisable'), sourceFile: displayPage, source: stamp });
60
+ assert.equal(revised.id, first.id);
61
+ assert.equal(revised.manifest.title, 'Nightly report');
62
+ assert.equal(readFileSync(pagePath(revised.dir), 'utf8'), readFileSync(displayPage, 'utf8'));
63
+ assert.deepEqual(scanPageHistory('node-replace').map((entry) => entry.id), ['revisable'], 'a replaced page is one ticket, not two');
64
+ bindTicketReplyRoute(submitted.dir, 'ask');
40
65
  const listed = scanInbox().find((item) => item.id === 'ask');
41
66
  assert.equal(listed?.kind, 'page');
42
67
  assert.equal(listed?.kind === 'page' ? listed.awaitsResponse : undefined, true);
43
68
  assert.deepEqual(listed?.kind === 'page' ? listed.slotKinds : undefined, ['options']);
44
69
  });
45
- function tableSourcePage(dataFile) {
46
- return `<!doctype html><html><head><meta name="crtr-page-title" content="Data"><meta name="crtr-page-placement" content="inline"></head><body><crtr-pages><crtr-page><crtr-table id="rows"><script type="application/json">${JSON.stringify({ columns: [{ id: 'name', label: 'Name' }], source: dataFile, rowSelect: 'single' })}</script></crtr-table></crtr-page></crtr-pages></body></html>`;
47
- }
48
- test('source data is embedded into the submitted manifest', () => {
49
- const data = sourceFile('rows.json', JSON.stringify([{ id: 'one', cells: { name: 'One' } }]));
50
- const page = sourceFile('source-table.html', tableSourcePage(data));
51
- const submitted = submitPage({ dir: ticketDir('source-table'), sourceFile: page, dialect: 'html' });
52
- const config = submitted.manifest.slots[0].config;
53
- assert.deepEqual(config.rows, [{ id: 'one', cells: { name: 'One' } }]);
54
- assert.equal('source' in config, false);
55
- });
56
- test('invalid source data fails before a ticket is written', () => {
57
- const cases = [
58
- ['missing', join(sources, 'missing.json'), /does not exist/],
59
- ['invalid-json', sourceFile('invalid.json', '{nope'), /not valid JSON/],
60
- ['invalid-shape', sourceFile('invalid-shape.json', JSON.stringify([{ cells: { name: 'Missing id' } }])), /source data is invalid/],
61
- ];
62
- for (const [id, data, expected] of cases) {
63
- const page = sourceFile(`${id}.html`, tableSourcePage(data));
64
- const dir = ticketDir(`source-${id}`);
65
- assert.throws(() => preparePage({ sourceFile: page, dialect: 'html' }), expected);
66
- assert.throws(() => submitPage({ dir, sourceFile: page, dialect: 'html' }), expected);
67
- assert.equal(existsSync(dir), false, `${id} source validation writes no ticket`);
68
- }
70
+ test('an HTML page derives a slotless manifest from declared metadata', () => {
71
+ const report = sourceFile('report.html', '<!doctype html><html><head><meta content="Summary" name="description"><title>Report</title></head><body>done</body></html>');
72
+ const prepared = preparePage({ sourceFile: report });
73
+ assert.equal(prepared.module, undefined);
74
+ assert.deepEqual(prepared.manifest, { schema: 'crtr.page/v2', dialect: 'html', title: 'Report', subtitle: 'Summary', delivery: { placement: 'inline', inbox: false, reply: false }, document: 'page.html', steps: 1, slots: [], source: prepared.manifest.source });
69
75
  });
70
76
  test('progress round-trips through validation and rejects malformed saved work', () => {
71
77
  const dir = ticketDir('ask');
@@ -76,6 +82,11 @@ test('progress round-trips through validation and rejects malformed saved work',
76
82
  assert.throws(() => writePageProgress(dir, { unknown: { selectedOptionIds: [], comments: [] } }), /unknown/);
77
83
  atomicWriteJson(progressPath(dir), { kind: 'page', responses: { rollout: 'not an object' }, savedAt: new Date().toISOString() });
78
84
  assert.throws(() => readPageProgress(dir), /malformed/, 'malformed progress is an error, never silently discarded');
85
+ // Saved work can outlive the slot it answered, because the agent may replace
86
+ // the page under it. The stale entry is dropped on read so the ticket stays
87
+ // readable; every entry the current page still declares hydrates.
88
+ atomicWriteJson(progressPath(dir), { kind: 'page', responses: { rollout: { selectedOptionIds: ['canary'], comments: [] }, retired: { selectedOptionIds: ['gone'], comments: [] } }, savedAt: new Date().toISOString() });
89
+ assert.deepEqual(readPageProgress(dir), { rollout: { selectedOptionIds: ['canary'], comments: [] } });
79
90
  writePageProgress(dir, { rollout: { selectedOptionIds: ['canary'], comments: [] } });
80
91
  });
81
92
  test('readPageTicket derives pending, resolved and canceled state and carries the document', () => {
@@ -101,20 +112,22 @@ test('readPageTicket derives pending, resolved and canceled state and carries th
101
112
  assert.equal(resolved.state, 'resolved');
102
113
  assert.equal(resolved.progress, null);
103
114
  assert.equal(scanInbox().some((item) => item.id === 'ask'), false, 'a resolved page leaves the pending list');
104
- const canceledDir = submit('canceled', askPage, 'html', '2026-01-03T00:00:00.000Z');
115
+ const canceledDir = submit('canceled', askPage, '2026-01-03T00:00:00.000Z');
105
116
  cancelTicket(canceledDir, { reason: 'no longer needed' });
106
117
  assert.equal(readPageTicket(canceledDir).state, 'canceled');
107
118
  });
108
- test('page scan derives response expectation from the ticket mode', () => {
109
- const dir = submit('display', displayPage, 'html', '2026-01-04T00:00:00.000Z');
119
+ test('page scan derives response expectation from manifest delivery', () => {
120
+ const dir = submit('display', displayPage, '2026-01-04T00:00:00.000Z');
110
121
  const ticket = readPageTicket(dir);
111
- assert.equal(ticket.manifest.dialect, 'html');
122
+ assert.equal(ticket.manifest.dialect, 'jsx');
112
123
  assert.equal(ticket.manifest.slots.length, 0);
113
124
  const display = scanInbox().find((item) => item.id === 'display');
114
- assert.equal(display?.kind === 'page' ? display.awaitsResponse : undefined, true, 'an ask waits even when its page has no response-bearing slots');
115
- submit('notify-with-options', askPage, 'html', '2026-01-04T00:00:01.000Z', 'node-1', 'notify');
116
- const notify = scanInbox().find((item) => item.id === 'notify-with-options');
117
- assert.equal(notify?.kind === 'page' ? notify.awaitsResponse : undefined, false, 'a notify never waits even when its page has a response-bearing slot');
125
+ assert.equal(display?.kind === 'page' ? display.awaitsResponse : undefined, true, 'a reply-bearing page waits even with no response-bearing slots');
126
+ const announcementDir = submit('announcement-with-options', askPage, '2026-01-04T00:00:01.000Z', 'node-1', false);
127
+ const announcement = scanInbox().find((item) => item.id === 'announcement-with-options');
128
+ assert.equal(announcement, undefined, 'a passive page stays out of the inbox');
129
+ assert.equal(readPageTicket(announcementDir).state, 'passive');
130
+ assert.throws(() => takeoverAndCompletePage(announcementDir, {}, { host: 'crtrd', pid: process.pid }), /page_has_no_response_lifecycle/);
118
131
  const acknowledged = takeoverAndCompletePage(dir, {}, { host: 'crtrd', pid: process.pid });
119
132
  assert.equal(acknowledged.won, true);
120
133
  const written = JSON.parse(readFileSync(responsePath(dir), 'utf8'));
@@ -123,11 +136,11 @@ test('page scan derives response expectation from the ticket mode', () => {
123
136
  assert.equal(written['summary'], '');
124
137
  });
125
138
  test('scanPageHistory returns every page of one node, oldest first, regardless of terminal file', () => {
126
- submit('older', askPage, 'html', '2026-01-01T00:00:00.000Z');
127
- submit('other-node', askPage, 'html', '2026-01-02T00:00:00.000Z', 'node-2');
139
+ submit('older', askPage, '2026-01-01T00:00:00.000Z');
140
+ submit('other-node', askPage, '2026-01-02T00:00:00.000Z', 'node-2');
128
141
  const history = scanPageHistory('node-1');
129
- assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'notify-with-options', 'ask']);
130
- assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'pending', 'resolved']);
142
+ assert.deepEqual(history.map((entry) => entry.id), ['older', 'canceled', 'display', 'announcement-with-options', 'ask']);
143
+ assert.deepEqual(history.map((entry) => entry.state), ['pending', 'canceled', 'resolved', 'passive', 'resolved']);
131
144
  assert.deepEqual(scanPageHistory('node-2').map((entry) => entry.id), ['other-node']);
132
145
  assert.deepEqual(scanPageHistory('node-missing'), []);
133
146
  });