@north-light/crouter 0.3.198 → 0.3.200

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 (316) 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__/helpers/harness.js +17 -55
  99. package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
  100. package/dist/core/__tests__/human-deliver.test.js +31 -26
  101. package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
  102. package/dist/core/__tests__/plugin-page-components.test.js +157 -0
  103. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +4 -5
  104. package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
  105. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
  106. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
  107. package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
  108. package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
  109. package/dist/core/__tests__/serial/worktree.test.js +24 -198
  110. package/dist/core/__tests__/session-cycles.test.js +22 -0
  111. package/dist/core/__tests__/stop-guard.test.js +4 -4
  112. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
  113. package/dist/core/bash-jobs.d.ts +6 -0
  114. package/dist/core/bash-jobs.js +10 -0
  115. package/dist/core/canvas/__tests__/attention.test.js +8 -5
  116. package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
  117. package/dist/core/canvas/attention.js +8 -6
  118. package/dist/core/canvas/browse/model.d.ts +8 -2
  119. package/dist/core/canvas/browse/model.js +11 -4
  120. package/dist/core/canvas/canvas.d.ts +7 -0
  121. package/dist/core/canvas/canvas.js +37 -0
  122. package/dist/core/canvas/crons.d.ts +9 -0
  123. package/dist/core/canvas/crons.js +51 -0
  124. package/dist/core/canvas/node-order.d.ts +22 -1
  125. package/dist/core/canvas/node-order.js +26 -1
  126. package/dist/core/canvas/render-source.d.ts +5 -0
  127. package/dist/core/canvas/render-source.js +1 -0
  128. package/dist/core/canvas/types.d.ts +9 -3
  129. package/dist/core/command-plugins/bundle.d.ts +6 -1
  130. package/dist/core/command-plugins/bundle.js +26 -13
  131. package/dist/core/command.js +2 -1
  132. package/dist/core/config.d.ts +47 -1
  133. package/dist/core/config.js +137 -4
  134. package/dist/core/help.js +1 -1
  135. package/dist/core/human/__tests__/page-catalog.test.js +4 -0
  136. package/dist/core/human/__tests__/page-tickets.test.js +65 -52
  137. package/dist/core/human/__tests__/page.test.js +56 -98
  138. package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
  139. package/dist/core/human/answer-text.js +3 -3
  140. package/dist/core/human/answer.d.ts +6 -4
  141. package/dist/core/human/answer.js +4 -10
  142. package/dist/core/human/component-docs.d.ts +11 -7
  143. package/dist/core/human/component-docs.js +361 -88
  144. package/dist/core/human/convention.d.ts +17 -3
  145. package/dist/core/human/convention.js +36 -5
  146. package/dist/core/human/feedback-companion.d.ts +20 -0
  147. package/dist/core/human/feedback-companion.js +98 -0
  148. package/dist/core/human/feedback.d.ts +65 -0
  149. package/dist/core/human/feedback.js +116 -0
  150. package/dist/core/human/page-catalog.d.ts +40 -5
  151. package/dist/core/human/page-catalog.js +102 -30
  152. package/dist/core/human/page-errors.d.ts +4 -0
  153. package/dist/core/human/page-errors.js +7 -0
  154. package/dist/core/human/page-eval.d.ts +20 -0
  155. package/dist/core/human/page-eval.js +123 -0
  156. package/dist/core/human/page-schema.d.ts +23 -191
  157. package/dist/core/human/page-schema.js +60 -89
  158. package/dist/core/human/page-synth.d.ts +1 -1
  159. package/dist/core/human/page-synth.js +15 -18
  160. package/dist/core/human/page.d.ts +12 -27
  161. package/dist/core/human/page.js +142 -474
  162. package/dist/core/human/root.d.ts +6 -2
  163. package/dist/core/human/root.js +12 -4
  164. package/dist/core/human/scan.d.ts +3 -4
  165. package/dist/core/human/scan.js +16 -15
  166. package/dist/core/human/summary.d.ts +1 -2
  167. package/dist/core/human/summary.js +2 -2
  168. package/dist/core/human/tickets.d.ts +20 -14
  169. package/dist/core/human/tickets.js +91 -31
  170. package/dist/core/human/types.d.ts +10 -2
  171. package/dist/core/preview-registry.js +8 -10
  172. package/dist/core/profiles/default-binding.d.ts +4 -0
  173. package/dist/core/profiles/default-binding.js +24 -0
  174. package/dist/core/profiles/deletion-reservation.d.ts +8 -0
  175. package/dist/core/profiles/deletion-reservation.js +38 -0
  176. package/dist/core/profiles/manifest.d.ts +3 -0
  177. package/dist/core/profiles/manifest.js +12 -0
  178. package/dist/core/profiles/select.js +2 -2
  179. package/dist/core/review/realize.js +3 -3
  180. package/dist/core/review/store.d.ts +6 -0
  181. package/dist/core/review/store.js +31 -0
  182. package/dist/core/runtime/bearings.d.ts +0 -4
  183. package/dist/core/runtime/bearings.js +4 -62
  184. package/dist/core/runtime/bin-contributions.d.ts +44 -0
  185. package/dist/core/runtime/bin-contributions.js +186 -0
  186. package/dist/core/runtime/broker/client-registry.d.ts +4 -0
  187. package/dist/core/runtime/broker/client-registry.js +13 -1
  188. package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
  189. package/dist/core/runtime/broker/extension-abort.js +4 -2
  190. package/dist/core/runtime/broker/fault-retry.js +4 -0
  191. package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
  192. package/dist/core/runtime/broker/frame-dispatch.js +5 -6
  193. package/dist/core/runtime/broker/read-ops.d.ts +1 -1
  194. package/dist/core/runtime/broker/read-ops.js +2 -2
  195. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  196. package/dist/core/runtime/broker/rebind.js +6 -1
  197. package/dist/core/runtime/broker-extension-render.js +19 -0
  198. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  199. package/dist/core/runtime/broker.d.ts +1 -1
  200. package/dist/core/runtime/broker.js +17 -6
  201. package/dist/core/runtime/front-door-env.d.ts +5 -0
  202. package/dist/core/runtime/front-door-env.js +16 -0
  203. package/dist/core/runtime/front-door.d.ts +1 -5
  204. package/dist/core/runtime/front-door.js +2 -5
  205. package/dist/core/runtime/host.d.ts +1 -1
  206. package/dist/core/runtime/invocation.d.ts +6 -0
  207. package/dist/core/runtime/invocation.js +10 -0
  208. package/dist/core/runtime/kickoff.js +34 -0
  209. package/dist/core/runtime/launch.d.ts +2 -6
  210. package/dist/core/runtime/node-read.js +4 -1
  211. package/dist/core/runtime/nodes.js +2 -0
  212. package/dist/core/runtime/promote.d.ts +1 -0
  213. package/dist/core/runtime/promote.js +3 -1
  214. package/dist/core/runtime/session-cycles.d.ts +6 -0
  215. package/dist/core/runtime/session-cycles.js +15 -1
  216. package/dist/core/runtime/session-visibility.d.ts +17 -8
  217. package/dist/core/runtime/session-visibility.js +19 -10
  218. package/dist/core/runtime/spawn-env.d.ts +9 -1
  219. package/dist/core/runtime/spawn-env.js +62 -2
  220. package/dist/core/runtime/stop-guard.d.ts +2 -2
  221. package/dist/core/runtime/stop-guard.js +2 -2
  222. package/dist/core/runtime/stop-signals.d.ts +1 -1
  223. package/dist/core/runtime/stop-signals.js +2 -2
  224. package/dist/core/runtime/tmux-bindings.js +1 -1
  225. package/dist/core/session-model/session-state.d.ts +4 -3
  226. package/dist/core/session-model/session-state.js +14 -1
  227. package/dist/core/user-settings.d.ts +21 -2
  228. package/dist/core/user-settings.js +32 -11
  229. package/dist/core/worktree.d.ts +15 -45
  230. package/dist/core/worktree.js +59 -261
  231. package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
  232. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
  233. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  234. package/dist/daemon/api/handlers/broker-ops.js +9 -5
  235. package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
  236. package/dist/daemon/api/handlers/feedback-comments.js +207 -0
  237. package/dist/daemon/api/handlers/human.js +24 -20
  238. package/dist/daemon/api/handlers/inbox.d.ts +10 -0
  239. package/dist/daemon/api/handlers/inbox.js +39 -115
  240. package/dist/daemon/api/handlers/profiles.js +11 -29
  241. package/dist/daemon/api/map.d.ts +6 -1
  242. package/dist/daemon/api/map.js +23 -0
  243. package/dist/daemon/api/server.js +3 -1
  244. package/dist/daemon/companion-retire.d.ts +2 -0
  245. package/dist/daemon/companion-retire.js +33 -0
  246. package/dist/daemon/cron-run.js +14 -16
  247. package/dist/daemon/human/finish.d.ts +12 -7
  248. package/dist/daemon/human/finish.js +101 -38
  249. package/dist/daemon/human/sweep.js +5 -1
  250. package/dist/daemon/profile-delete.d.ts +7 -0
  251. package/dist/daemon/profile-delete.js +306 -0
  252. package/dist/daemon/reconcilers/broker-supervision.js +3 -1
  253. package/dist/daemon/review/deliver.js +3 -3
  254. package/dist/daemon/review/finish.d.ts +0 -2
  255. package/dist/daemon/review/finish.js +1 -29
  256. package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
  257. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  258. package/dist/pi-extensions/canvas-bash-valve.js +19 -5
  259. package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
  260. package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
  261. package/dist/pi-extensions/canvas-review-boundary.js +18 -9
  262. package/dist/pi-extensions/canvas-stophook.js +4 -4
  263. package/dist/shared/generated-context.js +2 -2
  264. package/dist/types.d.ts +44 -2
  265. package/dist/types.js +7 -2
  266. package/package.json +6 -5
  267. package/runtime.lock.json +5 -33
  268. package/dist/builtin-memory/init.md +0 -38
  269. package/dist/builtin-memory/plan.md +0 -19
  270. package/dist/builtin-memory/spec.md +0 -19
  271. package/dist/commands/human/doc.d.ts +0 -2
  272. package/dist/commands/human/doc.js +0 -91
  273. package/dist/commands/human/html.js +0 -81
  274. package/dist/commands/human/inbox.d.ts +0 -2
  275. package/dist/core/human/__tests__/html-markdown.test.js +0 -52
  276. package/dist/core/human/__tests__/page-render.test.js +0 -66
  277. package/dist/core/human/html-markdown.d.ts +0 -3
  278. package/dist/core/human/html-markdown.js +0 -288
  279. package/dist/core/human/page-render.d.ts +0 -4
  280. package/dist/core/human/page-render.js +0 -105
  281. package/dist/pages/bundle.css +0 -1
  282. package/dist/pages/bundle.js +0 -1778
  283. package/dist/pages/comments.d.ts +0 -72
  284. package/dist/pages/comments.js +0 -176
  285. package/dist/pages/controls.d.ts +0 -98
  286. package/dist/pages/controls.js +0 -261
  287. package/dist/pages/elements/cards.d.ts +0 -28
  288. package/dist/pages/elements/cards.js +0 -915
  289. package/dist/pages/elements/chart.d.ts +0 -23
  290. package/dist/pages/elements/chart.js +0 -664
  291. package/dist/pages/elements/options.d.ts +0 -30
  292. package/dist/pages/elements/options.js +0 -742
  293. package/dist/pages/elements/pages.d.ts +0 -30
  294. package/dist/pages/elements/pages.js +0 -377
  295. package/dist/pages/elements/slot.d.ts +0 -26
  296. package/dist/pages/elements/slot.js +0 -112
  297. package/dist/pages/elements/table.d.ts +0 -36
  298. package/dist/pages/elements/table.js +0 -1043
  299. package/dist/pages/elements/text.d.ts +0 -31
  300. package/dist/pages/elements/text.js +0 -1175
  301. package/dist/pages/entry.d.ts +0 -8
  302. package/dist/pages/entry.js +0 -8
  303. package/dist/pages/host.d.ts +0 -134
  304. package/dist/pages/host.js +0 -165
  305. package/dist/pages/readonly.d.ts +0 -24
  306. package/dist/pages/readonly.js +0 -31
  307. package/dist/pages/register.d.ts +0 -2
  308. package/dist/pages/register.js +0 -3
  309. package/dist/pages/responses.d.ts +0 -37
  310. package/dist/pages/responses.js +0 -56
  311. package/dist/pages/slot-config.d.ts +0 -36
  312. package/dist/pages/slot-config.js +0 -50
  313. package/dist/pages/types.d.ts +0 -98
  314. package/dist/pages/types.js +0 -23
  315. /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
  316. /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
@@ -336,6 +336,21 @@ export function migrateLegacyPidIdentities(captureIdentity = (pid) => capturePid
336
336
  * yet — surfacing it would put a phantom empty conversation in the user's
337
337
  * sidebar. Claiming deletes the marker, which is what makes the node visible. */
338
338
  const NOT_A_SPARE = 'node_id NOT IN (SELECT node_id FROM warm_pool)';
339
+ /** Every hydrated node whose durable profile identity equals `profileId`.
340
+ * Unlike `listNodes`, this includes unclaimed warm-pool spares: profile
341
+ * deletion owns every matching row, whether or not it is user-visible. */
342
+ export function listNodesByProfile(profileId) {
343
+ const ids = openDb()
344
+ .prepare('SELECT node_id FROM nodes WHERE profile_id = ? ORDER BY created')
345
+ .all(profileId);
346
+ return ids.map(({ node_id }) => {
347
+ const meta = getNode(node_id);
348
+ if (meta === null) {
349
+ throw new Error(`profile deletion cannot hydrate indexed node ${node_id}`);
350
+ }
351
+ return meta;
352
+ });
353
+ }
339
354
  /** All rows, optionally filtered by status. Unclaimed warm spares are excluded. */
340
355
  export function listNodes(filter) {
341
356
  const db = openDb();
@@ -744,6 +759,28 @@ export function deleteNode(nodeId) {
744
759
  rmSync(nodeDir(nodeId), { recursive: true, force: true });
745
760
  return result;
746
761
  }
762
+ /** Hard-delete a validated set of nodes in one row transaction, then remove
763
+ * their directories. The caller must tear down engines and viewers first. */
764
+ export function deleteNodes(nodeIds) {
765
+ const ids = [...new Set(nodeIds)];
766
+ if (ids.length === 0)
767
+ return [];
768
+ const blocked = ids.filter(hasOpenManagedWorktree);
769
+ if (blocked.length > 0) {
770
+ throw new Error(`refusing to delete nodes with open managed worktrees: ${blocked.join(', ')}`);
771
+ }
772
+ const placeholders = ids.map(() => '?').join(',');
773
+ const deleted = withCanvasWrite((db) => {
774
+ const existing = db
775
+ .prepare(`SELECT node_id FROM nodes WHERE node_id IN (${placeholders}) ORDER BY created`)
776
+ .all(...ids).map(({ node_id }) => node_id);
777
+ db.prepare(`DELETE FROM nodes WHERE node_id IN (${placeholders})`).run(...ids);
778
+ return existing;
779
+ });
780
+ for (const id of deleted)
781
+ rmSync(nodeDir(id), { recursive: true, force: true });
782
+ return deleted;
783
+ }
747
784
  /** Retention sweep: remove TERMINAL nodes (status dead | done | canceled) whose
748
785
  * `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of
749
786
  * node rows + dirs. Open managed-worktree nodes are retained so their checkout
@@ -159,6 +159,15 @@ export declare function listCrons(scope: {
159
159
  export declare function cancelCron(cron_id: string, options?: {
160
160
  preserveRunningProcess?: boolean;
161
161
  }): boolean;
162
+ /** Cron ids a hard profile deletion will remove: matching execution profile,
163
+ * plus every row anchored to a node being deleted. */
164
+ export declare function cronIdsForProfileDeletion(profileId: string, anchorNodeIds: readonly string[]): string[];
165
+ /** Kill any leased matching/anchored runs, then delete their rows together. */
166
+ export declare function deleteCronsForProfile(profileId: string, anchorNodeIds: readonly string[]): string[];
167
+ /** Cron ids whose snapshotted profile will be detached. */
168
+ export declare function cronIdsForProfileDetach(profileId: string): string[];
169
+ /** Preserve matching crons while removing their deleted profile dependency. */
170
+ export declare function globalizeCronsForProfile(profileId: string): string[];
162
171
  /** DELETE one cron by id — one-shot consumption after its run settles.
163
172
  * Returns whether a live row was removed, so a consumer racing an explicit
164
173
  * cancel can no-op instead of double-settling. */
@@ -191,6 +191,57 @@ export function cancelCron(cron_id, options) {
191
191
  return true;
192
192
  });
193
193
  }
194
+ function profileCronWhere(profileId, anchorNodeIds) {
195
+ const anchors = [...new Set(anchorNodeIds)];
196
+ if (anchors.length === 0)
197
+ return { sql: 'profile = ?', params: [profileId] };
198
+ return {
199
+ sql: `profile = ? OR anchor_node IN (${anchors.map(() => '?').join(',')})`,
200
+ params: [profileId, ...anchors],
201
+ };
202
+ }
203
+ /** Cron ids a hard profile deletion will remove: matching execution profile,
204
+ * plus every row anchored to a node being deleted. */
205
+ export function cronIdsForProfileDeletion(profileId, anchorNodeIds) {
206
+ const where = profileCronWhere(profileId, anchorNodeIds);
207
+ return openDb()
208
+ .prepare(`SELECT cron_id FROM crons WHERE ${where.sql} ORDER BY created, cron_id`)
209
+ .all(...where.params).map(({ cron_id }) => cron_id);
210
+ }
211
+ /** Kill any leased matching/anchored runs, then delete their rows together. */
212
+ export function deleteCronsForProfile(profileId, anchorNodeIds) {
213
+ return withCanvasWrite((db) => {
214
+ const where = profileCronWhere(profileId, anchorNodeIds);
215
+ const rows = db
216
+ .prepare(`SELECT * FROM crons WHERE ${where.sql} ORDER BY created, cron_id`)
217
+ .all(...where.params).map(cronFrom);
218
+ for (const cron of rows) {
219
+ if (cron.run_state === 'running')
220
+ killLeasedCronRun(cron);
221
+ }
222
+ db.prepare(`DELETE FROM crons WHERE ${where.sql}`).run(...where.params);
223
+ return rows.map(({ cron_id }) => cron_id);
224
+ });
225
+ }
226
+ /** Cron ids whose snapshotted profile will be detached. */
227
+ export function cronIdsForProfileDetach(profileId) {
228
+ return openDb()
229
+ .prepare('SELECT cron_id FROM crons WHERE profile = ? ORDER BY created, cron_id')
230
+ .all(profileId).map(({ cron_id }) => cron_id);
231
+ }
232
+ /** Preserve matching crons while removing their deleted profile dependency. */
233
+ export function globalizeCronsForProfile(profileId) {
234
+ return withCanvasWrite((db) => {
235
+ const ids = db
236
+ .prepare('SELECT cron_id FROM crons WHERE profile = ? ORDER BY created, cron_id')
237
+ .all(profileId).map(({ cron_id }) => cron_id);
238
+ if (ids.length > 0) {
239
+ db.prepare("UPDATE crons SET profile = NULL, scope = 'global', updated = ? WHERE profile = ?")
240
+ .run(new Date().toISOString(), profileId);
241
+ }
242
+ return ids;
243
+ });
244
+ }
194
245
  /** DELETE one cron by id — one-shot consumption after its run settles.
195
246
  * Returns whether a live row was removed, so a consumer racing an explicit
196
247
  * cancel can no-op instead of double-settling. */
@@ -1,3 +1,24 @@
1
- import type { NodeStatus } from './types.js';
1
+ import type { NodeStatus, Lifecycle } from './types.js';
2
2
  /** Sort live work before terminal work; unknown statuses sort last. */
3
3
  export declare function statusRank(status: NodeStatus | undefined): number;
4
+ /** True when the node has a broker the daemon supervises — the statuses that
5
+ * mean "a live engine is (or should be) running right now". */
6
+ export declare function isLiveStatus(status: NodeStatus | undefined): boolean;
7
+ /** True when a node's work is FINISHED — a terminal one-shot worker, or any node
8
+ * that pushed a final report.
9
+ *
10
+ * This is deliberately NOT "is it running": a non-live status alone conflates two
11
+ * unrelated situations. Finished work is archive — opening it is inspection, and
12
+ * relaunching it puts a completed worker back to work (the stop-guard reprompts a
13
+ * terminal node that never pushed a final). A RESIDENT node with no final report
14
+ * is the opposite: its `done` is only `markCleanExitDone` recording a clean broker
15
+ * exit, which the stop-guard itself rules a legitimate stop (`reason: 'dormant'`),
16
+ * so it never finished anything and resuming it is simply reopening a conversation.
17
+ *
18
+ * `lifecycle` is optional because DashboardRow only carries it on the full browser
19
+ * snapshot; absent, this answers `true` (treat as finished), keeping the caller on
20
+ * the conservative inspect-don't-revive path. */
21
+ export declare function isFinishedWork(node: {
22
+ lifecycle?: Lifecycle;
23
+ finalized: boolean;
24
+ }): boolean;
@@ -1,4 +1,7 @@
1
- // node-order.ts — canonical live-first ordering for canvas node statuses.
1
+ // node-order.ts — canonical ordering and status/lifecycle classification for
2
+ // canvas nodes. Pure (type-only imports) so every surface — the browser model,
3
+ // command leaves, renderers — can share one answer to "is this node live?" and
4
+ // "did this node finish?" without reaching the db.
2
5
  /** Sort live work before terminal work; unknown statuses sort last. */
3
6
  export function statusRank(status) {
4
7
  switch (status) {
@@ -10,3 +13,25 @@ export function statusRank(status) {
10
13
  default: return 5;
11
14
  }
12
15
  }
16
+ /** True when the node has a broker the daemon supervises — the statuses that
17
+ * mean "a live engine is (or should be) running right now". */
18
+ export function isLiveStatus(status) {
19
+ return status === 'active' || status === 'idle';
20
+ }
21
+ /** True when a node's work is FINISHED — a terminal one-shot worker, or any node
22
+ * that pushed a final report.
23
+ *
24
+ * This is deliberately NOT "is it running": a non-live status alone conflates two
25
+ * unrelated situations. Finished work is archive — opening it is inspection, and
26
+ * relaunching it puts a completed worker back to work (the stop-guard reprompts a
27
+ * terminal node that never pushed a final). A RESIDENT node with no final report
28
+ * is the opposite: its `done` is only `markCleanExitDone` recording a clean broker
29
+ * exit, which the stop-guard itself rules a legitimate stop (`reason: 'dormant'`),
30
+ * so it never finished anything and resuming it is simply reopening a conversation.
31
+ *
32
+ * `lifecycle` is optional because DashboardRow only carries it on the full browser
33
+ * snapshot; absent, this answers `true` (treat as finished), keeping the caller on
34
+ * the conservative inspect-don't-revive path. */
35
+ export function isFinishedWork(node) {
36
+ return node.lifecycle !== 'resident' || node.finalized;
37
+ }
@@ -34,6 +34,11 @@ export interface DashboardRow {
34
34
  * agent you come back to. Drives the browser's resident-only lifecycle filter.
35
35
  * Only populated by dashboardRowsAll (the browser snapshot). */
36
36
  lifecycle?: Lifecycle;
37
+ /** True when the node pushed a final report. With `lifecycle` this separates
38
+ * work that FINISHED from a resident agent that merely stopped, which the
39
+ * status alone cannot (see `isFinishedWork` in node-order.ts). Only populated
40
+ * by dashboardRowsAll (the browser snapshot). */
41
+ finalized?: boolean;
37
42
  /** "Most recent activity" sort key — epoch ms. The cheap boot stats the node's
38
43
  * job/telemetry.json (rewritten on every turn_end at a DETERMINISTIC path, so
39
44
  * no meta read), falling back to `created`'s epoch when a node never ran a turn.
@@ -372,6 +372,7 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
372
372
  profile_id: row.profile_id,
373
373
  created: row.created,
374
374
  lifecycle: row.lifecycle,
375
+ finalized: row.final_report != null,
375
376
  mtimeMs: sessionMtime(row.node_id, row.created, remote),
376
377
  streaming: !remote && hanging === null && live ? isStreaming(row.node_id, row.pi_pid) : false,
377
378
  hanging,
@@ -49,6 +49,9 @@ export interface LaunchSpec {
49
49
  }
50
50
  export interface ManagedWorktree {
51
51
  state: 'open' | 'closed';
52
+ /** A closed worktree remains registered until its owner has exited, so Git
53
+ * never deletes the caller's live checkout beneath it. */
54
+ cleanup?: 'pending' | 'complete';
52
55
  path: string;
53
56
  branch: string;
54
57
  repo_root: string;
@@ -57,10 +60,13 @@ export interface ManagedWorktree {
57
60
  created: string;
58
61
  closed?: string;
59
62
  }
60
- /** Immutable review-companion provenance. The origin session/leaf/model
61
- * coordinates remain canonical in the review row rather than being duplicated
62
- * into node metadata. */
63
+ /** Immutable companion provenance. The origin session/leaf/model coordinates
64
+ * remain canonical in the review row rather than being duplicated into node
65
+ * metadata. `kind` names the surface the companion serves: absent or `review`
66
+ * is a human document review; `page_feedback` binds a page ticket's feedback
67
+ * conversation, with review_id = ticket id and target_file = the page source. */
63
68
  export interface ReviewBinding {
69
+ kind?: 'review' | 'page_feedback';
64
70
  review_id: string;
65
71
  origin_node_id: string;
66
72
  branch_file: string;
@@ -1,5 +1,5 @@
1
1
  import { type ValidatedCommandManifest } from '../command-manifests/manifest.js';
2
- import type { KindConfig } from '../../types.js';
2
+ import type { KindConfig, PageComponentRegistration } from '../../types.js';
3
3
  export interface BundleIssue {
4
4
  code: string;
5
5
  path?: string;
@@ -15,6 +15,11 @@ export interface ValidatedBundle {
15
15
  * manifest, where `readMergedLaunchConfig` layers it — see
16
16
  * `PluginManifest.kinds`. */
17
17
  kinds?: Record<string, Partial<KindConfig>>;
18
+ /** Page-component contributions declared in `bundle.json` (optional). The
19
+ * installer copies this validated block into the synthesized plugin
20
+ * manifest, where `resolvePageComponents` reads it — see
21
+ * `PluginManifest.page_components`. */
22
+ page_components?: PageComponentRegistration[];
18
23
  commands: ValidatedCommandManifest;
19
24
  commandsBytes: Uint8Array;
20
25
  directories: ReadonlyArray<{
@@ -1,6 +1,7 @@
1
1
  import { Parser } from 'tar';
2
2
  import { validateCommandManifest } from '../command-manifests/manifest.js';
3
3
  import { invalidPluginKindsReasons } from '../config.js';
4
+ import { invalidPageComponentsReasons, normalizePageComponents } from '../human/page-catalog.js';
4
5
  function issue(code, message, received, expected, next, path) {
5
6
  return { code, message, received, expected, next, ...(path !== undefined ? { path } : {}) };
6
7
  }
@@ -143,24 +144,35 @@ function parseBundleMetadata(bytes) {
143
144
  };
144
145
  }
145
146
  const record = raw;
146
- const unknownKeys = Object.keys(record).filter((key) => key !== 'bundleVersion' && key !== 'kinds');
147
+ const unknownKeys = Object.keys(record).filter((key) => key !== 'bundleVersion' && key !== 'kinds' && key !== 'page_components');
147
148
  if (record['bundleVersion'] !== 1 || unknownKeys.length > 0) {
148
149
  return {
149
- issue: bundleInvalid('bundle.json must contain bundleVersion 1 and at most a kinds block', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optionally kinds', 'Set bundle.json to {"bundleVersion":1} plus an optional kinds object.', 'bundle.json'),
150
+ issue: bundleInvalid('bundle.json must contain bundleVersion 1 and at most kinds and page_components blocks', JSON.stringify(raw), 'an object with bundleVersion set to 1 and optionally kinds and page_components', 'Set bundle.json to {"bundleVersion":1} plus an optional kinds object and page_components array.', 'bundle.json'),
150
151
  };
151
152
  }
152
- if (record['kinds'] === undefined)
153
- return {};
154
- // Install-time strictness: a bad kinds block fails the install loudly here,
155
- // because the read side (`mergeKinds`) drops invalid entries silently and
156
- // would otherwise ship a kind that never registers.
157
- const reasons = invalidPluginKindsReasons(record['kinds']);
158
- if (reasons.length > 0) {
159
- return {
160
- issue: bundleInvalid('bundle.json kinds block is invalid', reasons.join('; '), 'each entry a full KindConfig (whenToUse required) or a sparse patch of launch fields', 'Fix the kinds entries in bundle.json.', 'bundle.json'),
161
- };
153
+ // Install-time strictness: a bad block fails the install loudly here, because
154
+ // the read side (`mergeKinds`) drops invalid kind entries silently and would
155
+ // otherwise ship a kind that never registers.
156
+ if (record['kinds'] !== undefined) {
157
+ const reasons = invalidPluginKindsReasons(record['kinds']);
158
+ if (reasons.length > 0) {
159
+ return {
160
+ issue: bundleInvalid('bundle.json kinds block is invalid', reasons.join('; '), 'each entry a full KindConfig (whenToUse required) or a sparse patch of launch fields', 'Fix the kinds entries in bundle.json.', 'bundle.json'),
161
+ };
162
+ }
162
163
  }
163
- return { kinds: record['kinds'] };
164
+ if (record['page_components'] !== undefined) {
165
+ const reasons = invalidPageComponentsReasons(record['page_components']);
166
+ if (reasons.length > 0) {
167
+ return {
168
+ issue: bundleInvalid('bundle.json page_components block is invalid', reasons.join('; '), 'an array of page component registrations, each a kind string or {kind, description?, useWhen?, doc?, display?}', 'Fix the page_components entries in bundle.json.', 'bundle.json'),
169
+ };
170
+ }
171
+ }
172
+ return {
173
+ ...(record['kinds'] !== undefined ? { kinds: record['kinds'] } : {}),
174
+ ...(record['page_components'] !== undefined ? { page_components: normalizePageComponents(record['page_components']) } : {}),
175
+ };
164
176
  }
165
177
  function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
166
178
  let raw;
@@ -206,6 +218,7 @@ export async function validatePluginBundle(archive, options) {
206
218
  bundle: {
207
219
  bundleVersion: 1,
208
220
  ...(metadata.kinds !== undefined ? { kinds: metadata.kinds } : {}),
221
+ ...(metadata.page_components !== undefined ? { page_components: metadata.page_components } : {}),
209
222
  commands: commandValidation.commands,
210
223
  commandsBytes: commands.bytes,
211
224
  directories: parsed.members
@@ -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
+ });