@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
@@ -5,12 +5,14 @@ import { defineLeaf } from '../../core/command.js';
5
5
  import { notFound, usage, general, network } from '../../core/errors.js';
6
6
  import { findMarketplaceByName, findPluginByName, listAllPlugins } from '../../core/resolver.js';
7
7
  import { pluginsDir, ensureProjectScopeRoot, userScopeRoot, resolveScopeArg, projectScopeRoot, requireScopeRoot, } from '../../core/scope.js';
8
- import { updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons } from '../../core/config.js';
8
+ import { updateConfig, updateState, ensureScopeInitialized, invalidPluginKindsReasons, invalidPluginBinReasons, invalidPluginRequiresReasons, normalizeRequires } from '../../core/config.js';
9
+ import { invalidPageComponentsReasons } from '../../core/human/page-catalog.js';
9
10
  import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink, atomicWriteJson } from '../../core/fs-utils.js';
10
- import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
11
+ import { clone, pull, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
11
12
  import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
12
13
  import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
13
14
  import { normalizeEndpoint, validateEndpoint, validateHttpPluginTransport } from '../../core/command-plugins/endpoint.js';
15
+ import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
14
16
  import { validatePluginBundle } from '../../core/command-plugins/bundle.js';
15
17
  import { fetchHttpPluginBundle } from '../../core/command-plugins/transport/http-fetch.js';
16
18
  import { SUBTREE_NAMES, coreCommandPaths } from '../../build-root.js';
@@ -45,6 +47,15 @@ function validateHttpInstall(name, endpoint, authEnv) {
45
47
  throw usage(`invalid HTTP plugin transport: ${transportValidation.errors.join('; ')}`);
46
48
  return transportValidation.transport;
47
49
  }
50
+ /** Fold structured validation issues into the thrown message so every surface
51
+ * (prose and --json) names the exact offender — the error payload renderer
52
+ * carries only message/received/next scalars, so an `issues` array left in
53
+ * details alone never reaches the caller. The first issue's own remedy becomes
54
+ * the `Next:` line. */
55
+ function invalidBundleError(summary, issues) {
56
+ const lines = issues.map((i) => `- ${i.code}${i.path !== undefined ? ` at ${i.path}` : ''}: ${i.message} (received ${JSON.stringify(i.received)}, expected ${i.expected})`);
57
+ return general(`${summary}:\n${lines.join('\n')}`, { issues, ...(issues.length > 0 ? { next: issues[0].next } : {}) });
58
+ }
48
59
  /** Fail a SOURCE install (git/local/marketplace) loudly when the plugin's
49
60
  * authored manifest declares an invalid `kinds` block — the same install-time
50
61
  * strictness `validatePluginBundle` applies to an archive's bundle.json. The
@@ -55,7 +66,56 @@ function assertManifestKindsValid(manifest, where) {
55
66
  return;
56
67
  const reasons = invalidPluginKindsReasons(manifest.kinds);
57
68
  if (reasons.length > 0) {
58
- throw general(`plugin manifest at ${where} declares an invalid kinds block`, { issues: reasons });
69
+ throw general(`plugin manifest at ${where} declares an invalid kinds block:\n${reasons.map((r) => `- ${r}`).join('\n')}`, { issues: reasons });
70
+ }
71
+ }
72
+ /** Fail a SOURCE install (git/local/marketplace) loudly when the plugin's
73
+ * authored manifest declares an invalid `bin` block. The read side
74
+ * (`normalizeBin`) drops invalid entries silently, so without this the plugin
75
+ * would install cleanly and its bare command would simply never appear on any
76
+ * node's PATH. */
77
+ function assertManifestBinValid(manifest, where) {
78
+ if (manifest.bin === undefined)
79
+ return;
80
+ const reasons = invalidPluginBinReasons(manifest.bin);
81
+ if (reasons.length > 0) {
82
+ throw general(`plugin manifest at ${where} declares an invalid bin block:\n${reasons.map((r) => `- ${r}`).join('\n')}`, { issues: reasons });
83
+ }
84
+ }
85
+ /** Fail a source install or update only when `requires` itself is malformed.
86
+ * A well-formed requirement missing from PATH stays advisory and becomes an
87
+ * install warning after the plugin is registered. */
88
+ function assertManifestRequiresValid(manifest, where) {
89
+ if (manifest.requires === undefined)
90
+ return;
91
+ const reasons = invalidPluginRequiresReasons(manifest.requires);
92
+ if (reasons.length > 0) {
93
+ throw general(`plugin manifest at ${where} declares an invalid requires block:\n${reasons.map((r) => `- ${r}`).join('\n')}`, { issues: reasons });
94
+ }
95
+ }
96
+ /** Advisory diagnostics for the PATH an ordinary node launched here receives.
97
+ * `buildBrokerEnv` includes contributed bare binaries, so one plugin can
98
+ * satisfy another plugin's requirement without either being installed globally. */
99
+ function missingRequirementWarnings(manifest) {
100
+ if (manifest.requires === undefined)
101
+ return [];
102
+ const env = buildBrokerEnv({ argv: [], env: { CRTR_NODE_CWD: process.cwd(), CRTR_PROFILE_ID: process.env['CRTR_PROFILE_ID'] ?? '' } });
103
+ return Object.entries(normalizeRequires(manifest.requires))
104
+ .filter(([name]) => resolvePathExecutable(name, env, process.cwd()) === null)
105
+ .map(([name, hint]) => `Warning: plugin "${manifest.name}" requires "${name}" on PATH — ${hint}`);
106
+ }
107
+ /** Fail a SOURCE install (git/local/marketplace) loudly when the plugin's
108
+ * authored manifest declares an invalid `page_components` block — the same
109
+ * install-time strictness `validatePluginBundle` applies to an archive's
110
+ * bundle.json. `resolvePageComponents` throws on a malformed block, so an
111
+ * unchecked install would break every page-authoring surface at read time
112
+ * instead of at the install that introduced it. */
113
+ function assertManifestPageComponentsValid(manifest, where) {
114
+ if (manifest.page_components === undefined)
115
+ return;
116
+ const reasons = invalidPageComponentsReasons(manifest.page_components);
117
+ if (reasons.length > 0) {
118
+ throw general(`plugin manifest at ${where} declares an invalid page_components block:\n${reasons.map((r) => `- ${r}`).join('\n')}`, { issues: reasons });
59
119
  }
60
120
  }
61
121
  function invocationTransport(bundle) {
@@ -83,7 +143,7 @@ async function validateStagedBundle(name, scope, root, manifest) {
83
143
  const staged = { name, scope, root, manifest, enabled: true };
84
144
  const validation = validatePluginCommands(staged, new Set(SUBTREE_NAMES), await coreCommandPaths());
85
145
  if (validation.issues.length > 0) {
86
- throw general(`bundle staged package for "${name}" is invalid`, { issues: validation.issues });
146
+ throw invalidBundleError(`bundle staged package for "${name}" is invalid`, validation.issues);
87
147
  }
88
148
  }
89
149
  function isSourceInstalled(plugin) {
@@ -120,7 +180,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
120
180
  }
121
181
  const validated = await validatePluginBundle(fetched.raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: await coreCommandPaths() });
122
182
  if (validated.bundle === undefined)
123
- throw general(`HTTP plugin bundle for "${name}" is invalid`, { issues: validated.issues });
183
+ throw invalidBundleError(`HTTP plugin bundle for "${name}" is invalid`, validated.issues);
124
184
  const version = createHash('sha256').update(fetched.raw).digest('hex').slice(0, 12);
125
185
  const manifest = {
126
186
  name,
@@ -133,6 +193,11 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
133
193
  // what registers them: readMergedLaunchConfig reads kinds from installed
134
194
  // plugin MANIFESTS, never from bundle.json directly.
135
195
  ...(validated.bundle.kinds !== undefined ? { kinds: validated.bundle.kinds } : {}),
196
+ // Page-component contributions from the archive's bundle.json, likewise
197
+ // already validated. `resolvePageComponents` reads the MANIFEST, so this
198
+ // copy is what makes the components authorable — and dropping it on a
199
+ // later archive that no longer declares them is what retires them.
200
+ ...(validated.bundle.page_components !== undefined ? { page_components: validated.bundle.page_components } : {}),
136
201
  };
137
202
  const tmpRoot = join(scopeRootPath, 'tmp');
138
203
  const staging = join(tmpRoot, `${name}.${process.pid}`);
@@ -177,7 +242,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
177
242
  const commands = await commandReport(name, scope);
178
243
  if (commands === undefined)
179
244
  throw general(`bundle plugin "${name}" has no staged command report`);
180
- return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, commands };
245
+ return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, pageComponents: (validated.bundle.page_components ?? []).length, commands };
181
246
  }
182
247
  async function installHttpPlugin(name, endpoint, authEnv, scope) {
183
248
  const source = validateHttpInstall(name, endpoint, authEnv);
@@ -240,6 +305,15 @@ function refreshMarketplace(name, cache, opts) {
240
305
  cache.set(name, refreshed);
241
306
  return refreshed;
242
307
  }
308
+ /** Restore a source checkout after an updated manifest fails its install-time
309
+ * gate. `--merge` returns it to the previous commit without discarding local
310
+ * changes unrelated to the pull. */
311
+ function restoreSourceCheckout(root, previousSha) {
312
+ const restored = gitSync(['reset', '--merge', previousSha], root);
313
+ if (restored.status !== 0) {
314
+ throw general(`updated plugin manifest was rejected, but crouter could not restore the previous checkout at ${root}: ${restored.stderr.trim()}`);
315
+ }
316
+ }
243
317
  function updatePluginVersion(scope, name, version) {
244
318
  updateConfig(scope, (cfg) => {
245
319
  const entry = cfg.plugins[name];
@@ -276,6 +350,9 @@ function installFromGit(source, ref, scope) {
276
350
  }
277
351
  try {
278
352
  assertManifestKindsValid(manifest, `${source}/.crouter-plugin/plugin.json`);
353
+ assertManifestPageComponentsValid(manifest, `${source}/.crouter-plugin/plugin.json`);
354
+ assertManifestBinValid(manifest, `${source}/.crouter-plugin/plugin.json`);
355
+ assertManifestRequiresValid(manifest, `${source}/.crouter-plugin/plugin.json`);
279
356
  }
280
357
  catch (error) {
281
358
  removePath(tempDir);
@@ -307,6 +384,9 @@ function installFromLocal(source, scope) {
307
384
  throw notFound(`plugin manifest not found at ${sourcePath}/.crouter-plugin/plugin.json`);
308
385
  }
309
386
  assertManifestKindsValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
387
+ assertManifestPageComponentsValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
388
+ assertManifestBinValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
389
+ assertManifestRequiresValid(manifest, `${sourcePath}/.crouter-plugin/plugin.json`);
310
390
  const scopeRootPath = scope === 'project' ? ensureProjectScopeRoot() : userScopeRoot();
311
391
  ensureScopeInitialized(scope, scopeRootPath);
312
392
  const destDir = join(scopeRootPath, 'plugins', manifest.name);
@@ -356,6 +436,9 @@ export function installFromMarketplace(ref, scope) {
356
436
  }
357
437
  try {
358
438
  assertManifestKindsValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
439
+ assertManifestPageComponentsValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
440
+ assertManifestBinValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
441
+ assertManifestRequiresValid(pluginManifest, `${destPluginDir}/.crouter-plugin/plugin.json`);
359
442
  }
360
443
  catch (error) {
361
444
  removePath(destPluginDir);
@@ -377,10 +460,11 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
377
460
  const sourceInstalled = isSourceInstalled(plugin);
378
461
  if (!sourceInstalled && plugin.manifest.bundle !== undefined) {
379
462
  const replaced = await replaceBundlePlugin(plugin.name, plugin.manifest.bundle, plugin.scope, { enable: plugin.enabled });
380
- return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, commands: replaced.commands };
463
+ return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, pageComponents: replaced.pageComponents, commands: replaced.commands };
381
464
  }
382
465
  if (plugin.manifest.transport?.kind === 'http' && !sourceInstalled)
383
466
  rejectLegacyHttpPlugin(plugin.name);
467
+ const previousSha = currentSha(plugin.root);
384
468
  let contentUpdate = { updated: false, sha: currentSha(plugin.root) ?? '' };
385
469
  let marketplaceVersion;
386
470
  if (plugin.sourceMarketplace !== undefined) {
@@ -401,14 +485,44 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
401
485
  contentUpdate = pullGitCheckout(plugin.root, `plugin "${plugin.name}"`, opts);
402
486
  }
403
487
  const manifest = readPluginManifest(plugin.root);
404
- updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest?.version);
488
+ try {
489
+ if (manifest === null) {
490
+ throw general(`plugin manifest not found after updating ${plugin.root}: ${plugin.root}/.crouter-plugin/plugin.json`);
491
+ }
492
+ assertManifestKindsValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
493
+ assertManifestPageComponentsValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
494
+ assertManifestBinValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
495
+ assertManifestRequiresValid(manifest, `${plugin.root}/.crouter-plugin/plugin.json`);
496
+ }
497
+ catch (error) {
498
+ if (contentUpdate.updated && previousSha !== null && isGitRepo(plugin.root)) {
499
+ restoreSourceCheckout(plugin.root, previousSha);
500
+ // Marketplace plugins are symlinks into the shared marketplace checkout.
501
+ // Its cached manifest and pull state must move back with that checkout so
502
+ // later entries in this bulk update neither see the rejected revision nor
503
+ // record its versions as accepted.
504
+ if (plugin.sourceMarketplace !== undefined) {
505
+ const cached = marketplaceCache.get(plugin.sourceMarketplace);
506
+ if (cached !== undefined) {
507
+ marketplaceCache.set(plugin.sourceMarketplace, {
508
+ ...cached,
509
+ pull: { updated: false, sha: currentSha(cached.marketplace.root) ?? '' },
510
+ });
511
+ }
512
+ }
513
+ }
514
+ throw error;
515
+ }
516
+ updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest.version);
405
517
  const commands = await commandReport(plugin.name, plugin.scope);
518
+ const warnings = missingRequirementWarnings(manifest);
406
519
  return {
407
520
  name: plugin.name,
408
521
  ...(plugin.manifest.transport !== undefined ? { transport: plugin.manifest.transport.kind } : {}),
409
522
  updated: contentUpdate.updated,
410
523
  sha: contentUpdate.sha,
411
524
  ...(commands !== undefined ? { commands } : {}),
525
+ ...(warnings.length > 0 ? { warnings } : {}),
412
526
  };
413
527
  }
414
528
  export const pluginInstall = defineLeaf({
@@ -434,13 +548,16 @@ export const pluginInstall = defineLeaf({
434
548
  { name: 'version', type: 'string', required: false, constraint: 'First 12 hex characters of the fetched archive SHA-256 for an --endpoint install.' },
435
549
  { name: 'docs', type: 'integer', required: false, constraint: 'Memory document count written from an --endpoint archive.' },
436
550
  { name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
551
+ { name: 'pageComponents', type: 'integer', required: false, constraint: 'Page-component registration count declared by an --endpoint archive\u2019s bundle.json.' },
437
552
  { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
553
+ { name: 'warnings', type: 'string[]', required: false, constraint: 'Advisory missing PATH executable requirements declared by the installed plugin. The install still succeeds and leaves the plugin enabled.' },
438
554
  ],
439
555
  outputKind: 'object',
440
556
  effects: [
441
557
  'A ref install clones, links, or copies the plugin into the scope plugins directory and registers it with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
442
- 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, and any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds); a fetch or validation failure preserves the prior package.',
558
+ 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds), and any page components it declares (they join the active page-component catalog alongside the user config entries); a fetch or validation failure preserves the prior package.',
443
559
  'If the plugin declares a command manifest, its commands go live on the next crtr invocation. Exec commands run trusted local code only when explicitly invoked; HTTP commands call their declared endpoint only when explicitly invoked.',
560
+ 'A plugin manifest `requires` declaration is checked against the PATH a node bash receives after install. Missing executables emit a warning with the declared install hint but never fail the install, disable the plugin, or remove its bare-binary contributions.',
444
561
  ],
445
562
  },
446
563
  run: async (input) => {
@@ -471,10 +588,12 @@ export const pluginInstall = defineLeaf({
471
588
  : installFromGit(classified.value, gitRef, scope);
472
589
  const plugin = findPluginByName(installed.name, installed.scope);
473
590
  const commands = await commandReport(installed.name, installed.scope);
591
+ const warnings = plugin === null ? [] : missingRequirementWarnings(plugin.manifest);
474
592
  return {
475
593
  ...installed,
476
594
  ...(plugin?.manifest.transport !== undefined ? { transport: plugin.manifest.transport.kind } : {}),
477
595
  ...(commands !== undefined ? { commands } : {}),
596
+ ...(warnings.length > 0 ? { warnings } : {}),
478
597
  };
479
598
  },
480
599
  });
@@ -586,7 +705,7 @@ export const pluginUpdate = defineLeaf({
586
705
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
587
706
  ],
588
707
  output: [
589
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, commands?, error?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, and kinds is the declared kind-registry entry count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}.' },
708
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, warnings?, error?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. warnings lists advisory PATH requirements that remain absent after update.' },
590
709
  ],
591
710
  outputKind: 'object',
592
711
  effects: ['Archive plugins unconditionally re-fetch, validate, and replace their complete package; a named failure exits nonzero and preserves the prior package. Bulk update reports a failed archive plugin and continues. Source-installed plugins retain their source update, version, and last_updated behavior.'],
@@ -1,12 +1,12 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
- import { deleteProfile, loadProfileManifest } from '../../core/profiles/manifest.js';
2
+ import { cliClient, rethrowAsCliError } from '../api-client.js';
3
3
  export const deleteLeaf = defineLeaf({
4
4
  name: 'delete',
5
- description: 'remove a profile entirely',
6
- whenToUse: 'a profile is no longer needed — deletes its manifest AND its own memory store irreversibly. Does not touch the project directories it pointed at.',
5
+ description: 'force-delete a profile and its owned runtime state',
6
+ whenToUse: 'a profile is no longer needed and you intend either to remove every node and schedule pinned to it, or to preserve that state while detaching the profile identity',
7
7
  help: {
8
8
  name: 'profile delete',
9
- summary: 'remove a profile directory (manifest + memory store) after validating it exists',
9
+ summary: 'force-delete a profile and its owned state, or detach that state before removing the profile',
10
10
  params: [
11
11
  {
12
12
  kind: 'positional',
@@ -14,26 +14,52 @@ export const deleteLeaf = defineLeaf({
14
14
  required: true,
15
15
  constraint: 'Exact profile id, or a unique manifest name.',
16
16
  },
17
+ {
18
+ kind: 'flag',
19
+ name: 'force',
20
+ type: 'bool',
21
+ required: true,
22
+ constraint: 'Required confirmation for this irreversible operation.',
23
+ },
24
+ {
25
+ kind: 'flag',
26
+ name: 'detach',
27
+ type: 'bool',
28
+ required: false,
29
+ constraint: 'Preserve nodes and inbox/review history; clear their profile identity and globalize matching crons before removing the profile.',
30
+ },
17
31
  ],
18
32
  output: [
19
- { name: 'profile_id', type: 'string', required: true, constraint: 'The deleted profile\u2019s stable directory id.' },
20
- { name: 'name', type: 'string', required: true, constraint: 'The deleted profile\u2019s manifest name.' },
21
- { name: 'deleted', type: 'boolean', required: true, constraint: 'Always true on success.' },
22
- { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
33
+ { name: 'profile_id', type: 'string', required: true, constraint: 'The removed profile’s stable id.' },
34
+ { name: 'name', type: 'string', required: true, constraint: 'The removed profile’s manifest name.' },
35
+ { name: 'mode', type: 'string', required: true, constraint: '`deleted` for the destructive cascade; `detached` when --detach preserved nodes and history.' },
36
+ { name: 'nodes', type: 'number', required: true, constraint: 'Nodes hard-deleted or detached.' },
37
+ { name: 'crons', type: 'number', required: true, constraint: 'Crons deleted or globalized.' },
38
+ { name: 'tickets', type: 'number', required: true, constraint: 'Originating inbox ticket directories removed; zero in detach mode.' },
39
+ { name: 'reviews', type: 'number', required: true, constraint: 'Originating daemon review records removed; zero in detach mode.' },
40
+ { name: 'default_pins', type: 'number', required: true, constraint: 'Per-directory default profile pins cleared.' },
41
+ { name: 'accepted', type: 'boolean', required: false, constraint: 'True when the invoking node belongs to the profile and teardown begins after this result settles.' },
42
+ { name: 'grace_ms', type: 'number', required: false, constraint: 'Acknowledgement grace before an accepted self-deletion begins.' },
43
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next action.' },
23
44
  ],
24
45
  outputKind: 'object',
25
46
  effects: [
26
- 'Recursively removes `<profiles-root>/<profile_id>/` — the manifest and its own memory store. Irreversible. Invalidates the process scope cache.',
47
+ 'Always clears every per-directory default pin for the profile and removes the profile directory last. Blocks before mutation while any matching node owns an open managed worktree.',
48
+ 'Without --detach: tears down and hard-deletes every node whose profile_id matches, deletes matching or anchored crons and their in-flight runs, and removes originating inbox tickets plus review records. Cross-profile children are preserved.',
49
+ 'With --detach: preserves nodes, conversations, topology, inbox tickets, and reviews; rewrites node profile_id to null, restarts active brokers without the profile, and rewrites matching crons to global scope.',
27
50
  ],
28
51
  },
29
52
  run: async (input) => {
30
- const { profileId, manifest } = loadProfileManifest(input['profile']);
31
- deleteProfile(profileId);
53
+ const result = await cliClient().deleteProfile(input['profile'], {
54
+ force: input['force'],
55
+ detach: input['detach'],
56
+ caller_node_id: process.env['CRTR_NODE_ID'] ?? null,
57
+ }).catch(rethrowAsCliError);
32
58
  return {
33
- profile_id: profileId,
34
- name: manifest.name,
35
- deleted: true,
36
- follow_up: 'Run `crtr profile list` to see remaining profiles.',
59
+ ...result,
60
+ follow_up: result.accepted === true
61
+ ? 'The deletion was accepted; this node will stop after its result settles.'
62
+ : 'Run `crtr profile list` to see remaining profiles.',
37
63
  };
38
64
  },
39
65
  });
@@ -30,7 +30,7 @@ export function registerProfile() {
30
30
  help: {
31
31
  name: 'profile',
32
32
  summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
33
- model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — and every node created under the profile is pinned there, so cwd selects the profile and the profile then supplies where its nodes run. Only `node new --cwd` (or inheriting a spawner already elsewhere, notably inside a managed worktree) overrides that; the root profile has no home, so nodes under it stay in the directory that created them. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete` removes a profile\u2019s directory (manifest + its own memory store) entirely and does not touch the project directories it pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
33
+ model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — and every node created under the profile is pinned there, so cwd selects the profile and the profile then supplies where its nodes run. Only `node new --cwd` (or inheriting a spawner already elsewhere, notably inside a managed worktree) overrides that; the root profile has no home, so nodes under it stay in the directory that created them. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
34
34
  },
35
35
  children: [newLeaf, listLeaf, showLeaf, renameLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
36
36
  });
@@ -52,7 +52,7 @@ function makeTierLeaf(tier) {
52
52
  params: [
53
53
  { kind: 'stdin', name: 'body', required: true, constraint: "Report body (markdown). Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes." },
54
54
  ...(tier === 'final'
55
- ? [{ kind: 'flag', name: 'force', type: 'bool', required: false, default: false, constraint: 'Override the guard that blocks `push final` on a human-attended node (a resident with no one to report up to). Only pass this after the user explicitly confirms they want this node finished.' }]
55
+ ? [{ kind: 'flag', name: 'force', type: 'bool', required: false, default: false, constraint: 'Override the guard that blocks `push final` on a node the user is attending (a resident with no one to report up to). Only pass this after the user explicitly confirms they want this node finished.' }]
56
56
  : []),
57
57
  ],
58
58
  output: [
@@ -1,15 +1,16 @@
1
1
  // `crtr surface node focus` — bring one viewer forward.
2
2
  import { defineLeaf } from '../../../core/command.js';
3
3
  import { InputError } from '../../../core/io.js';
4
+ import { isFinishedWork, isLiveStatus } from '../../../core/canvas/node-order.js';
4
5
  import { cliClient, rethrowAsCliError } from '../../api-client.js';
5
6
  import { currentTmux, displayPopup, focusViewer, shellQuote } from './placement.js';
6
7
  export const surfaceNodeFocusLeaf = defineLeaf({
7
8
  name: 'focus',
8
9
  description: 'bring a node into view without accidentally reviving finished work',
9
- whenToUse: 'you want to bring a specific node into view — live nodes open or move their viewer beside your current pane; completed, canceled, or crashed nodes open their saved transcript instead. Use `node lifecycle revive` only when you deliberately want to relaunch a dormant agent. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
10
+ whenToUse: 'you want to bring a specific node into view — a live node, or a resident agent that merely stopped, opens (resuming its saved conversation in place) beside your current pane; FINISHED work — a terminal worker, or any node that pushed a final report — opens its saved transcript instead of being put back to work. Use `node lifecycle revive` to deliberately relaunch finished work anyway. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
10
11
  help: {
11
12
  name: 'surface node focus',
12
- summary: 'bring a node into view — attach to a live broker, or open a saved transcript for a non-live node without reviving it',
13
+ summary: 'bring a node into view — attach to a live broker, resume a resident agent that stopped, or open a saved transcript for finished work without reviving it',
13
14
  params: [
14
15
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
15
16
  { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Always open a NEW viewer SIDE-BY-SIDE with your current pane instead of moving/reusing the node\'s existing viewer. Two agents on screen at once (F4).' },
@@ -19,11 +20,11 @@ export const surfaceNodeFocusLeaf = defineLeaf({
19
20
  output: [
20
21
  { name: 'focused', type: 'boolean', required: true, constraint: 'True when the node\'s viewer was brought into view.' },
21
22
  { name: 'session', type: 'string', required: false, constraint: 'The tmux session the viewer lives in.' },
22
- { name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing a live node had to relaunch its broker; false for a saved-transcript inspection.' },
23
+ { name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to relaunch the node\'s broker (a live node whose engine was gone, or a stopped resident agent resumed in place); false when its viewer was already attached, and false for a saved-transcript inspection.' },
23
24
  { name: 'in_place', type: 'boolean', required: true, constraint: 'True when an existing viewer was navigated to (no new pane); false when a fresh viewer pane was opened or moved beside you.' },
24
25
  ],
25
26
  outputKind: 'object',
26
- effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For done/dead/canceled nodes: opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a live node: controls whether the viewer splits beside or replaces the caller pane.'],
27
+ effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For a resident node that stopped without finalizing: resumes its saved conversation in place (status returns to active) and opens its viewer — the same path as a live node.', 'For finished work (a terminal node, or any node with a final report): opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a live node: controls whether the viewer splits beside or replaces the caller pane.'],
27
28
  },
28
29
  run: async (input) => {
29
30
  const id = input['node'];
@@ -38,17 +39,31 @@ export const surfaceNodeFocusLeaf = defineLeaf({
38
39
  throw new InputError({
39
40
  error: 'not_focusable',
40
41
  message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
41
- next: `The pending question is queued for the human; see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
42
+ next: `The pending question is queued for the user; see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
42
43
  });
43
44
  }
44
- // Opening history is not resuming work. A terminal/canceled/crashed node has
45
- // no live broker to attach to, so show its persisted transcript in a popup;
46
- // `node lifecycle revive` remains the explicit state-changing operation.
47
- if (node.status !== 'active' && node.status !== 'idle') {
48
- const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
49
- const focused = pane !== undefined && pane !== ''
50
- ? displayPopup(pane, node.cwd, `crtr node inspect transcript ${shellQuote(id)}`)
51
- : false;
45
+ // Every focus needs a pane to place the viewer or popup into. Resolve it ONCE
46
+ // here and fail loudly when there is none: both downstream paths would
47
+ // otherwise return `focused: false` with no explanation, which reads as the
48
+ // command silently doing nothing.
49
+ const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
50
+ if (pane === undefined || pane === '') {
51
+ throw new InputError({
52
+ error: 'no_pane',
53
+ message: `no tmux pane to focus ${id} into — surfaces need a tmux server, and this call has neither --pane nor a caller pane.`,
54
+ next: `Run it from inside tmux, pass \`--pane <id>\`, or read the node without a surface: \`crtr node inspect transcript ${id}\`.`,
55
+ });
56
+ }
57
+ // Opening history is not resuming work — but a non-live STATUS does not by
58
+ // itself mean the node finished anything. Finished work (a terminal worker, or
59
+ // any node that pushed a final report) is archive: show its persisted
60
+ // transcript, because reviving it would set completed work back in motion, and
61
+ // `node lifecycle revive` remains the explicit way to do that on purpose.
62
+ // A RESIDENT node with no final report never finished — its `done` is just a
63
+ // clean broker exit — so it falls through to the live path below, where
64
+ // ensureAttach's true resume replays the conversation exactly as it stood.
65
+ if (!isLiveStatus(node.status) && isFinishedWork({ lifecycle: node.lifecycle, finalized: node.final_report != null })) {
66
+ const focused = displayPopup(pane, node.cwd, `crtr node inspect transcript ${shellQuote(id)}`);
52
67
  return { focused, session: null, revived: false, in_place: false };
53
68
  }
54
69
  // Local viewer tail: open/move the broker-backed viewer beside the caller.
@@ -57,7 +72,7 @@ export const surfaceNodeFocusLeaf = defineLeaf({
57
72
  const res = await focusViewer(id, {
58
73
  cwd: node.cwd,
59
74
  name: node.name,
60
- pane: input['pane'],
75
+ pane,
61
76
  newPane: input['newPane'] === true,
62
77
  inPlace: input['inPlace'] === true,
63
78
  });
@@ -69,10 +69,10 @@ const EDITOR_EXPR = '${EDITOR:-${VISUAL}}';
69
69
  export const surfaceEditLeaf = defineLeaf({
70
70
  name: 'edit',
71
71
  description: "open a file in the user's $EDITOR in a new pane beside the pane that opened this command",
72
- whenToUse: "you (an agent) want the human to SEE a file WHILE THEY'RE WATCHING YOU — open it in their $EDITOR in a new pane split beside this node's viewer pane, without them hunting for it. Works headless (the broker reaches the tmux server over its socket). Requires this node to be on screen (a viewer open) or a caller pane; a background node with no viewer gets a no_pane error — report the path instead and it opens beside you once watched",
72
+ whenToUse: "you (an agent) want the user to SEE a file WHILE THEY'RE WATCHING YOU — open it in their $EDITOR in a new pane split beside this node's viewer pane, without them hunting for it. Works headless (the broker reaches the tmux server over its socket). Requires this node to be on screen (a viewer open) or a caller pane; a background node with no viewer gets a no_pane error — report the path instead and it opens beside you once watched",
73
73
  help: {
74
74
  name: 'surface edit',
75
- summary: "Open <file> in a new pane running the user's $EDITOR (else $VISUAL), split beside THE PANE THAT OPENED THIS: an explicit --pane, the caller's own $TMUX_PANE, or this node's viewer pane (the pane where the human is watching it). Works from a HEADLESS broker (the tmux server is reached over its socket, no $TMUX needed). A background node with no viewer on screen has no pane to sit beside -> no_pane error. The split is -d, so focus is unchanged",
75
+ summary: "Open <file> in a new pane running the user's $EDITOR (else $VISUAL), split beside THE PANE THAT OPENED THIS: an explicit --pane, the caller's own $TMUX_PANE, or this node's viewer pane (the pane where the user is watching it). Works from a HEADLESS broker (the tmux server is reached over its socket, no $TMUX needed). A background node with no viewer on screen has no pane to sit beside -> no_pane error. The split is -d, so focus is unchanged",
76
76
  params: [
77
77
  {
78
78
  kind: 'positional',
@@ -126,7 +126,7 @@ export const surfaceEditLeaf = defineLeaf({
126
126
  throw new InputError({
127
127
  error: 'no_pane',
128
128
  message: 'no pane to open the editor beside — this node has no viewer on screen (it is running in the background).',
129
- next: 'Open a viewer on this node (`crtr surface node focus <id>` / `crtr surface attach`) and retry, pass --pane <pane-id>, or run from inside a tmux pane. To just point the human at a file, report its path instead.',
129
+ next: 'Open a viewer on this node (`crtr surface node focus <id>` / `crtr surface attach`) and retry, pass --pane <pane-id>, or run from inside a tmux pane. To just point the user at a file, report its path instead.',
130
130
  });
131
131
  }
132
132
  const command = `${EDITOR_EXPR} ${shellQuote(file)}`;
@@ -0,0 +1,2 @@
1
+ import type { LeafDef } from '../core/command.js';
2
+ export declare const surfaceInboxLeaf: LeafDef;
@@ -1,5 +1,11 @@
1
- import { defineBranch, defineLeaf } from '../../core/command.js';
2
- import { currentTmux, displayClientMessage, liveViewerPanes, paneLocation, selectPane, selectWindow, sendAttachControl, switchClient, } from '../../core/runtime/placement-tmux.js';
1
+ // `crtr surface inbox` — the tmux keybinding's target for the human inbox.
2
+ //
3
+ // A screen-facing viewer control, not a ticket verb: it delivers the private
4
+ // attach-control action to one live viewer and focuses it. The human reaches
5
+ // their own inbox through the inbox-toggle key (or `/inbox` in the viewer,
6
+ // which toggles in-process and never shells out here).
7
+ import { defineLeaf } from '../core/command.js';
8
+ import { currentTmux, displayClientMessage, liveViewerPanes, paneLocation, selectPane, selectWindow, sendAttachControl, switchClient, } from '../core/runtime/placement-tmux.js';
3
9
  const NO_VIEWER_MESSAGE = 'No crouter viewer is running — attach to a node first (crtr surface attach to <node>).';
4
10
  function tmuxSocketFromEnvironment() {
5
11
  const socket = process.env['TMUX']?.split(',')[0]?.trim();
@@ -40,12 +46,13 @@ function focusViewer(viewer, targetPane, clientSession) {
40
46
  selectWindow(location.session, location.window);
41
47
  selectPane(viewer.pane);
42
48
  }
43
- const humanInboxToggle = defineLeaf({
44
- name: 'toggle',
45
- description: 'deliver the inbox toggle to a live viewer',
46
- whenToUse: 'toggling the human inbox surface in a running crouter viewer; the selected viewer receives the private attach-control action and is focused afterward.',
49
+ export const surfaceInboxLeaf = defineLeaf({
50
+ name: 'inbox',
51
+ description: 'human inbox toggle dispatcher (bound to the inbox-toggle key; not run by hand)',
52
+ whenToUse: 'never directly — the inbox-toggle keybinding routes the user’s own request through it. An agent that wants a decision uses crtr human send; how the user reaches their inbox is theirs.',
53
+ tier: 'hidden',
47
54
  help: {
48
- name: 'human inbox toggle',
55
+ name: 'surface inbox',
49
56
  summary: 'deliver the human inbox toggle to a live viewer',
50
57
  guide: 'Selection prefers the target pane, then the resolved client session by newest viewer stamp, then newest globally; ties use pane id. The viewer decides whether the delivered toggle opens or closes the surface.',
51
58
  params: [
@@ -88,14 +95,3 @@ const humanInboxToggle = defineLeaf({
88
95
  return quiet ? undefined : outcome;
89
96
  },
90
97
  });
91
- export const humanInbox = defineBranch({
92
- name: 'inbox',
93
- description: 'deliver the human inbox toggle to a live viewer',
94
- whenToUse: 'answering, reviewing, or managing pending human tickets from a running crouter viewer.',
95
- help: {
96
- name: 'human inbox',
97
- summary: 'deliver the human inbox toggle to a live viewer',
98
- model: 'The toggle leaf sends a private viewer control action; it never hosts the inbox itself.',
99
- },
100
- children: [humanInboxToggle],
101
- });
@@ -8,6 +8,7 @@ import { surfaceEditLeaf } from './surface-edit.js';
8
8
  import { registerSurfaceNode } from './surface-node.js';
9
9
  import { surfaceInspectLeaf } from './surface-inspect.js';
10
10
  import { surfaceReloadLeaf } from './surface-reload.js';
11
+ import { surfaceInboxLeaf } from './surface-inbox.js';
11
12
  export function registerSurface() {
12
13
  const attach = registerAttach('surface', 'important');
13
14
  return defineBranch({
@@ -22,6 +23,6 @@ export function registerSurface() {
22
23
  summary: 'open and manage screen-facing surfaces',
23
24
  model: 'Choose `node` for navigation among node viewers. The other commands open a broker attachment, node Inspector, subtree spread, reload, or adjacent editor pane.',
24
25
  },
25
- children: [registerSurfaceNode(), attach, surfaceInspectLeaf, surfaceReloadLeaf, tmuxSpreadLeaf, surfaceEditLeaf],
26
+ children: [registerSurfaceNode(), attach, surfaceInspectLeaf, surfaceReloadLeaf, tmuxSpreadLeaf, surfaceEditLeaf, surfaceInboxLeaf],
26
27
  });
27
28
  }