@north-light/crouter 0.3.198 → 0.3.199

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/dist/api/client.d.ts +18 -18
  2. package/dist/api/client.js +29 -37
  3. package/dist/api/dto/broker-ops.d.ts +3 -1
  4. package/dist/api/dto/broker.d.ts +13 -2
  5. package/dist/api/dto/broker.js +4 -4
  6. package/dist/api/dto/human.d.ts +6 -5
  7. package/dist/api/dto/inbox.d.ts +81 -34
  8. package/dist/api/dto/nodes.d.ts +8 -2
  9. package/dist/api/dto/profiles.d.ts +23 -0
  10. package/dist/api/dto/profiles.js +2 -2
  11. package/dist/api/dto/worktree.d.ts +3 -5
  12. package/dist/api/routes.d.ts +3 -5
  13. package/dist/api/routes.js +4 -6
  14. package/dist/builtin-memory/00-runtime-base.md +6 -6
  15. package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
  16. package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +6 -6
  18. package/dist/builtin-memory/05-kinds/design/00-base.md +1 -1
  19. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +2 -2
  20. package/dist/builtin-memory/05-kinds/review/00-base.md +2 -2
  21. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  22. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -1
  23. package/dist/builtin-memory/insights/capture.md +2 -2
  24. package/dist/builtin-memory/insights/init.md +3 -3
  25. package/dist/builtin-memory/internal/INDEX.md +1 -1
  26. package/dist/builtin-memory/internal/memory-loading.md +1 -1
  27. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  28. package/dist/builtin-memory/internal/plugins.md +59 -2
  29. package/dist/builtin-memory/internal/storage-tiers.md +3 -1
  30. package/dist/builtin-memory/spec/roadmap.md +2 -2
  31. package/dist/builtin-memory/testing.md +1 -1
  32. package/dist/clients/attach/chrome/bash-jobs.js +3 -3
  33. package/dist/clients/attach/render/chat-view.d.ts +26 -1
  34. package/dist/clients/attach/render/chat-view.js +61 -11
  35. package/dist/clients/attach/render/page-block.d.ts +34 -0
  36. package/dist/clients/attach/render/page-block.js +226 -0
  37. package/dist/clients/attach/session/whip.d.ts +2 -0
  38. package/dist/clients/attach/session/whip.js +2 -0
  39. package/dist/clients/attach/viewer.js +1419 -993
  40. package/dist/clients/conversation/projection.js +18 -3
  41. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
  42. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -21
  43. package/dist/clients/inbox/controller.js +10 -16
  44. package/dist/clients/inbox/page-adapter.d.ts +1 -3
  45. package/dist/clients/inbox/page-adapter.js +3 -4
  46. package/dist/clients/inbox/resolve.d.ts +1 -1
  47. package/dist/clients/inbox/resolve.js +3 -3
  48. package/dist/clients/inbox/review/document-surface.js +3 -1
  49. package/dist/clients/inbox/review/state.d.ts +2 -1
  50. package/dist/clients/inbox/review/state.js +5 -16
  51. package/dist/clients/inbox/tui/input.js +37 -19
  52. package/dist/clients/inbox/tui/panel.js +5 -31
  53. package/dist/clients/inbox/tui/render.d.ts +11 -0
  54. package/dist/clients/inbox/tui/render.js +71 -27
  55. package/dist/clients/inbox/tui/slots.d.ts +3 -1
  56. package/dist/clients/inbox/tui/slots.js +16 -12
  57. package/dist/clients/inbox/tui/types.d.ts +2 -7
  58. package/dist/clients/inbox/tui.js +2 -2
  59. package/dist/commands/__tests__/human.test.js +71 -114
  60. package/dist/commands/__tests__/node-message.test.js +14 -0
  61. package/dist/commands/attention.js +4 -4
  62. package/dist/commands/canvas-snapshot.js +1 -1
  63. package/dist/commands/canvas.js +1 -1
  64. package/dist/commands/dashboard.js +1 -1
  65. package/dist/commands/human/{html.d.ts → components.d.ts} +1 -1
  66. package/dist/commands/human/components.js +77 -0
  67. package/dist/commands/human/feedback.d.ts +2 -0
  68. package/dist/commands/human/feedback.js +108 -0
  69. package/dist/commands/human/prompts.d.ts +13 -5
  70. package/dist/commands/human/prompts.js +160 -97
  71. package/dist/commands/human/queue.js +57 -88
  72. package/dist/commands/human/shared.d.ts +2 -12
  73. package/dist/commands/human/shared.js +17 -11
  74. package/dist/commands/human.js +29 -21
  75. package/dist/commands/memory/origin.js +2 -2
  76. package/dist/commands/node/bash.js +4 -4
  77. package/dist/commands/node/inspect.js +3 -3
  78. package/dist/commands/node/lifecycle.js +1 -1
  79. package/dist/commands/node/message.js +2 -2
  80. package/dist/commands/node-worktree.js +8 -8
  81. package/dist/commands/pkg/browse.js +1 -1
  82. package/dist/commands/pkg/plugin-manage.js +129 -10
  83. package/dist/commands/profile/delete.js +41 -15
  84. package/dist/commands/profile.js +1 -1
  85. package/dist/commands/push.js +1 -1
  86. package/dist/commands/surface/node/focus.js +29 -14
  87. package/dist/commands/surface-edit.js +3 -3
  88. package/dist/commands/surface-inbox.d.ts +2 -0
  89. package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
  90. package/dist/commands/surface.js +2 -1
  91. package/dist/commands/sys/doctor.js +60 -3
  92. package/dist/commands/sys/feedback.js +79 -25
  93. package/dist/commands/sys/settings.js +1 -1
  94. package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
  95. package/dist/core/__tests__/extension-abort.test.js +5 -2
  96. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
  97. package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
  98. package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
  99. package/dist/core/__tests__/human-deliver.test.js +31 -26
  100. package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
  101. package/dist/core/__tests__/plugin-page-components.test.js +157 -0
  102. package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
  103. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
  104. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
  105. package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
  106. package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
  107. package/dist/core/__tests__/serial/worktree.test.js +24 -198
  108. package/dist/core/__tests__/session-cycles.test.js +22 -0
  109. package/dist/core/__tests__/stop-guard.test.js +4 -4
  110. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
  111. package/dist/core/bash-jobs.d.ts +6 -0
  112. package/dist/core/bash-jobs.js +10 -0
  113. package/dist/core/canvas/__tests__/attention.test.js +8 -5
  114. package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
  115. package/dist/core/canvas/attention.js +8 -6
  116. package/dist/core/canvas/browse/model.d.ts +8 -2
  117. package/dist/core/canvas/browse/model.js +11 -4
  118. package/dist/core/canvas/canvas.d.ts +7 -0
  119. package/dist/core/canvas/canvas.js +37 -0
  120. package/dist/core/canvas/crons.d.ts +9 -0
  121. package/dist/core/canvas/crons.js +51 -0
  122. package/dist/core/canvas/node-order.d.ts +22 -1
  123. package/dist/core/canvas/node-order.js +26 -1
  124. package/dist/core/canvas/render-source.d.ts +5 -0
  125. package/dist/core/canvas/render-source.js +1 -0
  126. package/dist/core/canvas/types.d.ts +9 -3
  127. package/dist/core/command-plugins/bundle.d.ts +6 -1
  128. package/dist/core/command-plugins/bundle.js +26 -13
  129. package/dist/core/command.js +2 -1
  130. package/dist/core/config.d.ts +47 -1
  131. package/dist/core/config.js +137 -4
  132. package/dist/core/help.js +1 -1
  133. package/dist/core/human/__tests__/page-catalog.test.js +4 -0
  134. package/dist/core/human/__tests__/page-tickets.test.js +65 -52
  135. package/dist/core/human/__tests__/page.test.js +56 -98
  136. package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
  137. package/dist/core/human/answer-text.js +3 -3
  138. package/dist/core/human/answer.d.ts +6 -4
  139. package/dist/core/human/answer.js +4 -10
  140. package/dist/core/human/component-docs.d.ts +11 -7
  141. package/dist/core/human/component-docs.js +361 -88
  142. package/dist/core/human/convention.d.ts +17 -3
  143. package/dist/core/human/convention.js +36 -5
  144. package/dist/core/human/feedback-companion.d.ts +20 -0
  145. package/dist/core/human/feedback-companion.js +98 -0
  146. package/dist/core/human/feedback.d.ts +65 -0
  147. package/dist/core/human/feedback.js +116 -0
  148. package/dist/core/human/page-catalog.d.ts +40 -5
  149. package/dist/core/human/page-catalog.js +102 -30
  150. package/dist/core/human/page-errors.d.ts +4 -0
  151. package/dist/core/human/page-errors.js +7 -0
  152. package/dist/core/human/page-eval.d.ts +20 -0
  153. package/dist/core/human/page-eval.js +123 -0
  154. package/dist/core/human/page-schema.d.ts +23 -191
  155. package/dist/core/human/page-schema.js +60 -89
  156. package/dist/core/human/page-synth.d.ts +1 -1
  157. package/dist/core/human/page-synth.js +15 -18
  158. package/dist/core/human/page.d.ts +12 -27
  159. package/dist/core/human/page.js +142 -474
  160. package/dist/core/human/root.d.ts +6 -2
  161. package/dist/core/human/root.js +12 -4
  162. package/dist/core/human/scan.d.ts +3 -4
  163. package/dist/core/human/scan.js +16 -15
  164. package/dist/core/human/summary.d.ts +1 -2
  165. package/dist/core/human/summary.js +2 -2
  166. package/dist/core/human/tickets.d.ts +20 -14
  167. package/dist/core/human/tickets.js +91 -31
  168. package/dist/core/human/types.d.ts +10 -2
  169. package/dist/core/preview-registry.js +8 -10
  170. package/dist/core/profiles/default-binding.d.ts +4 -0
  171. package/dist/core/profiles/default-binding.js +24 -0
  172. package/dist/core/profiles/deletion-reservation.d.ts +8 -0
  173. package/dist/core/profiles/deletion-reservation.js +38 -0
  174. package/dist/core/profiles/manifest.d.ts +3 -0
  175. package/dist/core/profiles/manifest.js +12 -0
  176. package/dist/core/profiles/select.js +2 -2
  177. package/dist/core/review/realize.js +3 -3
  178. package/dist/core/review/store.d.ts +6 -0
  179. package/dist/core/review/store.js +31 -0
  180. package/dist/core/runtime/bearings.d.ts +0 -4
  181. package/dist/core/runtime/bearings.js +4 -62
  182. package/dist/core/runtime/bin-contributions.d.ts +44 -0
  183. package/dist/core/runtime/bin-contributions.js +186 -0
  184. package/dist/core/runtime/broker/client-registry.d.ts +4 -0
  185. package/dist/core/runtime/broker/client-registry.js +13 -1
  186. package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
  187. package/dist/core/runtime/broker/extension-abort.js +4 -2
  188. package/dist/core/runtime/broker/fault-retry.js +4 -0
  189. package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
  190. package/dist/core/runtime/broker/frame-dispatch.js +5 -6
  191. package/dist/core/runtime/broker/read-ops.d.ts +1 -1
  192. package/dist/core/runtime/broker/read-ops.js +2 -2
  193. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  194. package/dist/core/runtime/broker/rebind.js +6 -1
  195. package/dist/core/runtime/broker-extension-render.js +19 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  197. package/dist/core/runtime/broker.d.ts +1 -1
  198. package/dist/core/runtime/broker.js +17 -6
  199. package/dist/core/runtime/front-door-env.d.ts +5 -0
  200. package/dist/core/runtime/front-door-env.js +16 -0
  201. package/dist/core/runtime/front-door.d.ts +1 -5
  202. package/dist/core/runtime/front-door.js +2 -5
  203. package/dist/core/runtime/host.d.ts +1 -1
  204. package/dist/core/runtime/invocation.d.ts +6 -0
  205. package/dist/core/runtime/invocation.js +10 -0
  206. package/dist/core/runtime/kickoff.js +34 -0
  207. package/dist/core/runtime/launch.d.ts +2 -6
  208. package/dist/core/runtime/node-read.js +4 -1
  209. package/dist/core/runtime/nodes.js +2 -0
  210. package/dist/core/runtime/promote.d.ts +1 -0
  211. package/dist/core/runtime/promote.js +3 -1
  212. package/dist/core/runtime/session-cycles.d.ts +6 -0
  213. package/dist/core/runtime/session-cycles.js +15 -1
  214. package/dist/core/runtime/session-visibility.d.ts +17 -8
  215. package/dist/core/runtime/session-visibility.js +19 -10
  216. package/dist/core/runtime/spawn-env.d.ts +9 -1
  217. package/dist/core/runtime/spawn-env.js +62 -2
  218. package/dist/core/runtime/stop-guard.d.ts +2 -2
  219. package/dist/core/runtime/stop-guard.js +2 -2
  220. package/dist/core/runtime/stop-signals.d.ts +1 -1
  221. package/dist/core/runtime/stop-signals.js +2 -2
  222. package/dist/core/runtime/tmux-bindings.js +1 -1
  223. package/dist/core/session-model/session-state.d.ts +4 -3
  224. package/dist/core/session-model/session-state.js +14 -1
  225. package/dist/core/user-settings.d.ts +21 -2
  226. package/dist/core/user-settings.js +32 -11
  227. package/dist/core/worktree.d.ts +15 -45
  228. package/dist/core/worktree.js +59 -261
  229. package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
  230. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
  231. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  232. package/dist/daemon/api/handlers/broker-ops.js +9 -5
  233. package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
  234. package/dist/daemon/api/handlers/feedback-comments.js +207 -0
  235. package/dist/daemon/api/handlers/human.js +24 -20
  236. package/dist/daemon/api/handlers/inbox.d.ts +10 -0
  237. package/dist/daemon/api/handlers/inbox.js +39 -115
  238. package/dist/daemon/api/handlers/profiles.js +11 -29
  239. package/dist/daemon/api/map.d.ts +6 -1
  240. package/dist/daemon/api/map.js +23 -0
  241. package/dist/daemon/api/server.js +3 -1
  242. package/dist/daemon/companion-retire.d.ts +2 -0
  243. package/dist/daemon/companion-retire.js +33 -0
  244. package/dist/daemon/cron-run.js +14 -16
  245. package/dist/daemon/human/finish.d.ts +12 -7
  246. package/dist/daemon/human/finish.js +101 -38
  247. package/dist/daemon/human/sweep.js +5 -1
  248. package/dist/daemon/profile-delete.d.ts +7 -0
  249. package/dist/daemon/profile-delete.js +306 -0
  250. package/dist/daemon/reconcilers/broker-supervision.js +3 -1
  251. package/dist/daemon/review/deliver.js +3 -3
  252. package/dist/daemon/review/finish.d.ts +0 -2
  253. package/dist/daemon/review/finish.js +1 -29
  254. package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
  255. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  256. package/dist/pi-extensions/canvas-bash-valve.js +19 -5
  257. package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
  258. package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
  259. package/dist/pi-extensions/canvas-review-boundary.js +18 -9
  260. package/dist/pi-extensions/canvas-stophook.js +4 -4
  261. package/dist/shared/generated-context.js +2 -2
  262. package/dist/types.d.ts +44 -2
  263. package/dist/types.js +7 -2
  264. package/package.json +4 -4
  265. package/runtime.lock.json +5 -33
  266. package/dist/builtin-memory/init.md +0 -38
  267. package/dist/builtin-memory/plan.md +0 -19
  268. package/dist/builtin-memory/spec.md +0 -19
  269. package/dist/commands/human/doc.d.ts +0 -2
  270. package/dist/commands/human/doc.js +0 -91
  271. package/dist/commands/human/html.js +0 -81
  272. package/dist/commands/human/inbox.d.ts +0 -2
  273. package/dist/core/human/__tests__/html-markdown.test.js +0 -52
  274. package/dist/core/human/__tests__/page-render.test.js +0 -66
  275. package/dist/core/human/html-markdown.d.ts +0 -3
  276. package/dist/core/human/html-markdown.js +0 -288
  277. package/dist/core/human/page-render.d.ts +0 -4
  278. package/dist/core/human/page-render.js +0 -105
  279. package/dist/pages/bundle.css +0 -1
  280. package/dist/pages/bundle.js +0 -1778
  281. package/dist/pages/comments.d.ts +0 -72
  282. package/dist/pages/comments.js +0 -176
  283. package/dist/pages/controls.d.ts +0 -98
  284. package/dist/pages/controls.js +0 -261
  285. package/dist/pages/elements/cards.d.ts +0 -28
  286. package/dist/pages/elements/cards.js +0 -915
  287. package/dist/pages/elements/chart.d.ts +0 -23
  288. package/dist/pages/elements/chart.js +0 -664
  289. package/dist/pages/elements/options.d.ts +0 -30
  290. package/dist/pages/elements/options.js +0 -742
  291. package/dist/pages/elements/pages.d.ts +0 -30
  292. package/dist/pages/elements/pages.js +0 -377
  293. package/dist/pages/elements/slot.d.ts +0 -26
  294. package/dist/pages/elements/slot.js +0 -112
  295. package/dist/pages/elements/table.d.ts +0 -36
  296. package/dist/pages/elements/table.js +0 -1043
  297. package/dist/pages/elements/text.d.ts +0 -31
  298. package/dist/pages/elements/text.js +0 -1175
  299. package/dist/pages/entry.d.ts +0 -8
  300. package/dist/pages/entry.js +0 -8
  301. package/dist/pages/host.d.ts +0 -134
  302. package/dist/pages/host.js +0 -165
  303. package/dist/pages/readonly.d.ts +0 -24
  304. package/dist/pages/readonly.js +0 -31
  305. package/dist/pages/register.d.ts +0 -2
  306. package/dist/pages/register.js +0 -3
  307. package/dist/pages/responses.d.ts +0 -37
  308. package/dist/pages/responses.js +0 -56
  309. package/dist/pages/slot-config.d.ts +0 -36
  310. package/dist/pages/slot-config.js +0 -50
  311. package/dist/pages/types.d.ts +0 -98
  312. package/dist/pages/types.js +0 -23
  313. /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
  314. /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
@@ -0,0 +1,386 @@
1
+ // Compiled API seam for the profile-deletion lifecycle. It drives the real CLI
2
+ // and daemon HTTP handlers against one isolated canvas, including real broker
3
+ // processes for detach/restart and deferred self-deletion behavior.
4
+ import { request as httpRequest } from 'node:http';
5
+ import { after, before, test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { ApiError, CrtrClient } from '../../../../api/index.js';
10
+ import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harness.js';
11
+ import { updateNode } from '../../../../core/canvas/canvas.js';
12
+ import { armCron, getCron } from '../../../../core/canvas/crons.js';
13
+ import { closeDb } from '../../../../core/canvas/db.js';
14
+ import { getDefaultProfileId, setDefaultProfileId, } from '../../../../core/profiles/default-binding.js';
15
+ import { loadProfileManifest } from '../../../../core/profiles/manifest.js';
16
+ import { createComment, getComment, listReviewEvents } from '../../../../core/review/comments.js';
17
+ import { commitBindingReview, getReview } from '../../../../core/review/store.js';
18
+ import { apiSocketPath, nodeDir, nodesRoot, reviewDir, } from '../../../../core/canvas/paths.js';
19
+ import { isPidAlive } from '../../../../core/canvas/pid.js';
20
+ import { spawnNode } from '../../../../core/runtime/nodes.js';
21
+ let h;
22
+ let client;
23
+ before(async () => {
24
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-profile-delete' });
25
+ client = new CrtrClient({ socketPath: apiSocketPath(), autostart: false });
26
+ });
27
+ after(async () => {
28
+ await h?.dispose();
29
+ });
30
+ function managedWorktree() {
31
+ return {
32
+ state: 'open',
33
+ path: join(h.agentHome, 'open-worktree'),
34
+ branch: 'test/profile-delete-blocker',
35
+ repo_root: process.cwd(),
36
+ base_ref: 'HEAD',
37
+ base_sha: '0000000000000000000000000000000000000000',
38
+ created: new Date().toISOString(),
39
+ };
40
+ }
41
+ function armTestCron(args) {
42
+ armCron({
43
+ cron_id: args.id,
44
+ name: args.id,
45
+ created_by: null,
46
+ command: 'true',
47
+ fire_at: '2099-01-01T00:00:00.000Z',
48
+ recur: null,
49
+ tz: null,
50
+ expires_at: null,
51
+ anchor_node: args.anchor ?? null,
52
+ cancel_on_wake: false,
53
+ cwd: process.cwd(),
54
+ env_json: null,
55
+ profile: args.profile,
56
+ scope: 'profile',
57
+ run_timeout_s: 30,
58
+ overlap: 'skip',
59
+ on_output: 'silent',
60
+ sink: '{}',
61
+ tier: 'normal',
62
+ });
63
+ }
64
+ function seedTicket(dir, sourceNodeId) {
65
+ mkdirSync(dir, { recursive: true });
66
+ writeFileSync(join(dir, 'page.json'), JSON.stringify({ source: { nodeId: sourceNodeId } }));
67
+ writeFileSync(join(dir, 'reply-route.json'), JSON.stringify({ nodeId: sourceNodeId }));
68
+ }
69
+ function reviewRow(reviewId, originNodeId, sourceFile) {
70
+ const dir = reviewDir(reviewId);
71
+ return {
72
+ review_id: reviewId,
73
+ idempotency_key: `request-${reviewId}`,
74
+ origin_kind: 'inline',
75
+ origin_node_id: originNodeId,
76
+ bridge_node_id: null,
77
+ companion_node_id: `companion-${reviewId}`,
78
+ review_dir: dir,
79
+ file: sourceFile,
80
+ origin_session_file: join(h.agentHome, `${reviewId}-origin.jsonl`),
81
+ origin_session_id: `session-${reviewId}`,
82
+ origin_leaf_id: `leaf-${reviewId}`,
83
+ model_spec: 'anthropic/sonnet',
84
+ branch_file: join(dir, 'branch-point.jsonl'),
85
+ baseline_sha256: 'baseline',
86
+ baseline_bytes: 6,
87
+ anchor_base_sha256: 'anchor',
88
+ anchor_base_bytes: Buffer.from('source'),
89
+ state: 'binding',
90
+ created: new Date().toISOString(),
91
+ captured_at: new Date().toISOString(),
92
+ opened_at: null,
93
+ submit_requested_at: null,
94
+ output_path: null,
95
+ subtitle: null,
96
+ approved_at: null,
97
+ approved_sha256: null,
98
+ changed: null,
99
+ comments_total: null,
100
+ comments_unresolved: null,
101
+ result_json: null,
102
+ canceled_at: null,
103
+ cancel_reason: null,
104
+ cancel_actor: null,
105
+ delivery_entry_id: null,
106
+ delivered_at: null,
107
+ delivery_error: null,
108
+ projected_at: null,
109
+ projection_error: null,
110
+ };
111
+ }
112
+ function seedReview(reviewId, originNodeId) {
113
+ const sourceFile = join(h.agentHome, `${reviewId}-source.md`);
114
+ const dir = reviewDir(reviewId);
115
+ writeFileSync(sourceFile, 'source\n');
116
+ mkdirSync(dir, { recursive: true });
117
+ writeFileSync(join(dir, 'branch-point.jsonl'), '{}\n');
118
+ commitBindingReview(reviewRow(reviewId, originNodeId, sourceFile));
119
+ const { comment } = createComment({
120
+ review_id: reviewId,
121
+ text: 'keep or delete with its review',
122
+ anchor: { line: 1, end_line: 1 },
123
+ line_text: 'source',
124
+ doc_hash: 'baseline',
125
+ actor: { kind: 'node', node_id: originNodeId },
126
+ operation_id: `comment-${reviewId}`,
127
+ });
128
+ return { sourceFile, commentId: comment.comment_id };
129
+ }
130
+ async function rawDelete(profile, body) {
131
+ const payload = JSON.stringify(body);
132
+ return new Promise((resolve, reject) => {
133
+ const req = httpRequest({
134
+ socketPath: apiSocketPath(),
135
+ method: 'DELETE',
136
+ path: `/v1/profiles/${encodeURIComponent(profile)}`,
137
+ headers: {
138
+ 'content-type': 'application/json',
139
+ 'content-length': Buffer.byteLength(payload),
140
+ },
141
+ }, (res) => {
142
+ const chunks = [];
143
+ res.on('data', (chunk) => chunks.push(chunk));
144
+ res.on('end', () => {
145
+ const text = Buffer.concat(chunks).toString('utf8');
146
+ resolve({
147
+ status: res.statusCode ?? 0,
148
+ body: text === '' ? {} : JSON.parse(text),
149
+ });
150
+ });
151
+ });
152
+ req.on('error', reject);
153
+ req.end(payload);
154
+ });
155
+ }
156
+ test('forced profile deletion owns the complete daemon lifecycle', { timeout: 90_000 }, async () => {
157
+ // Required confirmation is enforced by both the command parser and the API.
158
+ const forceProfile = await client.ensureProfile('delete-force-required');
159
+ const help = h.cli(null, ['profile', 'delete', '-h']);
160
+ assert.equal(help.code, 0);
161
+ assert.match(help.stdout, /--force\s+required boolean/, 'leaf help exposes --force as required');
162
+ const missingForce = h.cli(null, ['profile', 'delete', forceProfile.id]);
163
+ assert.notEqual(missingForce.code, 0, 'the CLI refuses deletion without --force');
164
+ assert.match(`${missingForce.stdout}\n${missingForce.stderr}`, /force/i);
165
+ await assert.rejects(client.deleteProfile(forceProfile.id, { force: false }), (error) => error instanceof ApiError && error.status === 400 && error.code === 'usage', 'the daemon independently rejects a request without force=true');
166
+ assert.equal((await client.getProfile(forceProfile.id)).id, forceProfile.id, 'force rejection does not remove the profile');
167
+ // An open managed worktree blocks before any profile-owned state mutates.
168
+ const blockedProfile = await client.ensureProfile('delete-blocked');
169
+ const blockedNode = h.spawnRoot('profile delete blocker', { id: 'blocked-profile-deadbeef' });
170
+ updateNode(blockedNode, {
171
+ profile_id: blockedProfile.id,
172
+ managed_worktree: managedWorktree(),
173
+ });
174
+ const blockedPin = join(h.agentHome, 'blocked-pin');
175
+ mkdirSync(blockedPin, { recursive: true });
176
+ setDefaultProfileId(blockedPin, blockedProfile.id);
177
+ armTestCron({ id: 'blocked-cron', profile: blockedProfile.id, anchor: blockedNode });
178
+ await assert.rejects(client.deleteProfile(blockedProfile.id, { force: true }), (error) => {
179
+ if (!(error instanceof ApiError) || error.status !== 400 || error.code !== 'usage')
180
+ return false;
181
+ const details = error.details;
182
+ return details?.error === 'open_managed_worktree';
183
+ }, 'an open managed worktree round-trips the blocker without mutation');
184
+ closeDb();
185
+ assert.equal(h.node(blockedNode)?.profile_id, blockedProfile.id);
186
+ assert.equal(getCron('blocked-cron')?.profile, blockedProfile.id);
187
+ assert.equal(getDefaultProfileId(blockedPin), blockedProfile.id);
188
+ assert.equal((await client.getProfile(blockedProfile.id)).id, blockedProfile.id);
189
+ // Hard deletion selects by profile identity, not by graph descent. It removes
190
+ // matching nodes plus profile/anchor crons, originating tickets and review
191
+ // audit state, while preserving a cross-profile child and the reviewed file.
192
+ const hardProfile = await client.ensureProfile('delete-hard');
193
+ const crossProfile = await client.ensureProfile('delete-cross-profile');
194
+ const hardNode = h.fabricateBrokerNode({
195
+ id: 'hard-owner-deadbeef',
196
+ parent: null,
197
+ status: 'done',
198
+ });
199
+ updateNode(hardNode, { profile_id: hardProfile.id });
200
+ const crossChild = h.fabricateBrokerNode({
201
+ id: 'cross-child-deadbeef',
202
+ parent: hardNode,
203
+ status: 'done',
204
+ });
205
+ updateNode(crossChild, { profile_id: crossProfile.id });
206
+ armTestCron({ id: 'hard-profile-cron', profile: hardProfile.id });
207
+ armTestCron({ id: 'hard-anchor-cron', profile: crossProfile.id, anchor: hardNode });
208
+ armTestCron({ id: 'cross-preserved-cron', profile: crossProfile.id, anchor: crossChild });
209
+ const hardPin = join(h.agentHome, 'hard-pin');
210
+ mkdirSync(hardPin, { recursive: true });
211
+ setDefaultProfileId(hardPin, hardProfile.id);
212
+ const standaloneTicket = join(nodesRoot(), 'hard-ticket-deadbeef');
213
+ seedTicket(standaloneTicket, hardNode);
214
+ seedTicket(nodeDir(crossChild), hardNode);
215
+ writeFileSync(join(nodeDir(crossChild), 'feedback.json'), '{}\n');
216
+ mkdirSync(join(nodeDir(crossChild), '.ticket-lock'));
217
+ writeFileSync(join(nodeDir(crossChild), 'cross-profile-state.txt'), 'preserve');
218
+ const hardReviewId = 'hard-review-deadbeef';
219
+ const hardReview = seedReview(hardReviewId, hardNode);
220
+ const hardResult = await client.deleteProfile(hardProfile.id, { force: true });
221
+ assert.deepEqual(hardResult, {
222
+ profile_id: hardProfile.id,
223
+ name: hardProfile.name,
224
+ mode: 'deleted',
225
+ nodes: 1,
226
+ crons: 2,
227
+ tickets: 2,
228
+ reviews: 1,
229
+ default_pins: 1,
230
+ });
231
+ closeDb();
232
+ assert.equal(h.node(hardNode), null, 'the matching node is hard-deleted');
233
+ assert.equal(h.node(crossChild)?.parent, hardNode, 'the cross-profile child and topology are preserved');
234
+ assert.equal(h.node(crossChild)?.profile_id, crossProfile.id);
235
+ assert.equal(getCron('hard-profile-cron'), null);
236
+ assert.equal(getCron('hard-anchor-cron'), null);
237
+ assert.equal(getCron('cross-preserved-cron')?.profile, crossProfile.id);
238
+ assert.equal(existsSync(standaloneTicket), false, 'standalone originating ticket directory is removed');
239
+ assert.equal(existsSync(join(nodeDir(crossChild), 'page.json')), false, 'ticket files are removed from a preserved node directory');
240
+ assert.equal(existsSync(join(nodeDir(crossChild), 'feedback.json')), false, 'default review output is removed with the ticket');
241
+ assert.equal(existsSync(join(nodeDir(crossChild), '.ticket-lock')), false, 'ticket lock residue is removed');
242
+ assert.equal(readFileSync(join(nodeDir(crossChild), 'cross-profile-state.txt'), 'utf8'), 'preserve');
243
+ assert.equal(getReview(hardReviewId), null);
244
+ assert.equal(getComment(hardReview.commentId), null);
245
+ assert.deepEqual(listReviewEvents(hardReviewId, {}), { events: [], next_since_seq: 0 });
246
+ assert.equal(existsSync(reviewDir(hardReviewId)), false, 'review-owned filesystem state is removed');
247
+ assert.equal(readFileSync(hardReview.sourceFile, 'utf8'), 'source\n', 'the reviewed source document is never removed');
248
+ assert.equal(getDefaultProfileId(hardPin), null);
249
+ await assert.rejects(client.getProfile(hardProfile.id), (error) => error instanceof ApiError && error.status === 404);
250
+ assert.throws(() => spawnNode({
251
+ kind: 'developer',
252
+ cwd: process.cwd(),
253
+ parent: null,
254
+ profile_id: hardProfile.id,
255
+ nodeId: 'hard-stale-birth-deadbeef',
256
+ }), /profile not found/, 'the final synchronous birth gate rejects a profile resolved before a completed hard deletion');
257
+ assert.equal(h.node('hard-stale-birth-deadbeef'), null);
258
+ // Detach preserves nodes, topology, ticket/review state, and conversations;
259
+ // matching brokers restart with no profile identity and matching crons become
260
+ // global rather than disappearing.
261
+ const detachProfile = await client.ensureProfile('delete-detach');
262
+ const detachNode = (await client.createNode({
263
+ kind: 'developer',
264
+ profile: detachProfile.id,
265
+ root: true,
266
+ cwd: process.cwd(),
267
+ prompt: 'detach lifecycle broker',
268
+ node_id: 'detach-live-deadbeef',
269
+ })).node_id;
270
+ await h.awaitBoot(detachNode);
271
+ const detachBoots = h.bootCount(detachNode);
272
+ const detachSessionFile = h.node(detachNode)?.pi_session_file;
273
+ assert.ok(detachSessionFile != null, 'the live detach fixture has a resumable conversation');
274
+ armTestCron({ id: 'detach-profile-cron', profile: detachProfile.id, anchor: detachNode });
275
+ const detachPin = join(h.agentHome, 'detach-pin');
276
+ mkdirSync(detachPin, { recursive: true });
277
+ setDefaultProfileId(detachPin, detachProfile.id);
278
+ const detachTicket = join(nodesRoot(), 'detach-ticket-deadbeef');
279
+ seedTicket(detachTicket, detachNode);
280
+ const detachReviewId = 'detach-review-deadbeef';
281
+ const detachReview = seedReview(detachReviewId, detachNode);
282
+ const detachPid = h.node(detachNode)?.pi_pid;
283
+ assert.ok(detachPid != null, 'the detach race fixture has a live broker pid');
284
+ process.kill(detachPid, 'SIGSTOP');
285
+ const detachDeletion = client.deleteProfile(detachProfile.id, { force: true, detach: true });
286
+ try {
287
+ await h.waitFor(() => h.node(detachNode)?.intent === 'idle-release', {
288
+ timeoutMs: 10_000,
289
+ intervalMs: 25,
290
+ label: 'detach reaches its broker-exit wait',
291
+ });
292
+ await assert.rejects(client.createNode({
293
+ kind: 'developer',
294
+ profile: detachProfile.id,
295
+ root: true,
296
+ cwd: process.cwd(),
297
+ prompt: 'must not escape the accepted detach plan',
298
+ node_id: 'detach-race-deadbeef',
299
+ }), (error) => {
300
+ if (!(error instanceof ApiError) || error.status !== 400 || error.code !== 'usage')
301
+ return false;
302
+ const details = error.details;
303
+ return details?.error === 'profile_deletion_in_progress';
304
+ }, 'a node whose async preflight crosses detach is rejected at the synchronous birth gate');
305
+ }
306
+ finally {
307
+ if (isPidAlive(detachPid))
308
+ process.kill(detachPid, 'SIGCONT');
309
+ }
310
+ const detachResult = await detachDeletion;
311
+ assert.deepEqual(detachResult, {
312
+ profile_id: detachProfile.id,
313
+ name: detachProfile.name,
314
+ mode: 'detached',
315
+ nodes: 1,
316
+ crons: 1,
317
+ tickets: 0,
318
+ reviews: 0,
319
+ default_pins: 1,
320
+ });
321
+ const restarted = await h.awaitBoot(detachNode, { minCount: detachBoots + 1 });
322
+ closeDb();
323
+ assert.equal(h.node(detachNode)?.profile_id, null);
324
+ assert.equal(h.node('detach-race-deadbeef'), null, 'no profile-scoped node escapes the fixed detach plan');
325
+ assert.equal(h.node(detachNode)?.pi_session_file, detachSessionFile, 'detach preserves the conversation file');
326
+ assert.equal(restarted.sessionFile, detachSessionFile, 'the restarted broker resumes that conversation file');
327
+ assert.equal(restarted.resuming, true, 'the previously live broker is restarted as a resume');
328
+ assert.equal(restarted.env['CRTR_PROFILE_ID'], '', 'the restarted broker no longer names the deleted profile');
329
+ assert.equal(getCron('detach-profile-cron')?.scope, 'global');
330
+ assert.equal(getCron('detach-profile-cron')?.profile, null);
331
+ assert.equal(existsSync(join(detachTicket, 'page.json')), true);
332
+ assert.equal(getReview(detachReviewId)?.origin_node_id, detachNode);
333
+ assert.ok(getComment(detachReview.commentId) !== null);
334
+ assert.equal(existsSync(reviewDir(detachReviewId)), true);
335
+ assert.equal(getDefaultProfileId(detachPin), null);
336
+ await assert.rejects(client.getProfile(detachProfile.id), (error) => error instanceof ApiError && error.status === 404);
337
+ // A node deleting its own profile receives HTTP 202 while its row and broker
338
+ // are still alive. The daemon performs the accepted hard deletion only after
339
+ // the response grace has elapsed.
340
+ const selfProfile = await client.ensureProfile('delete-self');
341
+ const selfNode = (await client.createNode({
342
+ kind: 'developer',
343
+ profile: selfProfile.id,
344
+ root: true,
345
+ cwd: process.cwd(),
346
+ prompt: 'self-delete lifecycle broker',
347
+ node_id: 'self-delete-deadbeef',
348
+ })).node_id;
349
+ await h.awaitBoot(selfNode);
350
+ const selfPid = h.node(selfNode)?.pi_pid;
351
+ assert.ok(selfPid != null && isPidAlive(selfPid), 'self-delete fixture starts with a live broker');
352
+ const accepted = await rawDelete(selfProfile.id, {
353
+ force: true,
354
+ caller_node_id: selfNode,
355
+ });
356
+ assert.equal(accepted.status, 202);
357
+ assert.deepEqual(accepted.body, {
358
+ profile_id: selfProfile.id,
359
+ name: selfProfile.name,
360
+ mode: 'deleted',
361
+ nodes: 1,
362
+ crons: 0,
363
+ tickets: 0,
364
+ reviews: 0,
365
+ default_pins: 0,
366
+ accepted: true,
367
+ grace_ms: 1000,
368
+ });
369
+ assert.equal(h.node(selfNode)?.profile_id, selfProfile.id, 'the 202 response settles before node mutation');
370
+ assert.equal(isPidAlive(selfPid), true, 'the 202 response settles before broker teardown');
371
+ updateNode(selfNode, { managed_worktree: managedWorktree() });
372
+ const repeatedAccepted = await rawDelete(selfProfile.id, {
373
+ force: true,
374
+ caller_node_id: selfNode,
375
+ });
376
+ updateNode(selfNode, { managed_worktree: null });
377
+ assert.equal(repeatedAccepted.status, 202, 'a retry returns the accepted result before observing new blockers');
378
+ assert.deepEqual(repeatedAccepted.body, accepted.body, 'a pending self-deletion is idempotent by profile identity');
379
+ await h.waitFor(() => h.node(selfNode) === null, {
380
+ timeoutMs: 15_000,
381
+ intervalMs: 25,
382
+ label: 'accepted self-deletion completes after acknowledgement grace',
383
+ });
384
+ assert.equal(loadProfileManifest(crossProfile.id).profileId, crossProfile.id, 'unrelated profiles remain resolvable');
385
+ await assert.rejects(client.getProfile(selfProfile.id), (error) => error instanceof ApiError && error.status === 404);
386
+ });
@@ -66,7 +66,7 @@ async function handleStop(ctx) {
66
66
  // that same fail-loud behavior here after the job control-plane mutation.
67
67
  assertNotFinalized(nodeId);
68
68
  const paths = bashJobPaths(dir, jobId);
69
- const body = `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`;
69
+ const body = `Background bash job ${jobId} was stopped by the user before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`;
70
70
  appendInbox(nodeId, {
71
71
  from: null,
72
72
  tier: 'urgent',
@@ -11,6 +11,7 @@ import { readCursor, readInboxSince, writeCursor } from '../../../core/feed/inbo
11
11
  import { clearYieldMessage } from '../../../core/runtime/kickoff.js';
12
12
  import { transition } from '../../../core/runtime/lifecycle.js';
13
13
  import { focusOf, tearDownNode } from '../../../core/runtime/placement.js';
14
+ import { hasAttachedBrokerViewers } from '../../../core/runtime/broker/client-registry.js';
14
15
  import { handleNewSession } from '../../../core/runtime/reset.js';
15
16
  import { evaluateStop } from '../../../core/runtime/stop-guard.js';
16
17
  import { clearFault } from '../../../core/runtime/fault.js';
@@ -46,7 +47,7 @@ function parseSettleBody(body) {
46
47
  const value = objectBody(body, 'broker settle');
47
48
  if (typeof value['stopReason'] !== 'string')
48
49
  throw usage('stopReason must be a string');
49
- for (const field of ['backgroundJobsRunning', 'pushedFinal', 'askedHuman']) {
50
+ for (const field of ['backgroundJobsRunning', 'pushedFinal', 'requestedHumanReply']) {
50
51
  if (typeof value[field] !== 'boolean')
51
52
  throw usage(`${field} must be a boolean`);
52
53
  }
@@ -97,6 +98,7 @@ function extensionNode(node) {
97
98
  profile_id: node.profile_id ?? null,
98
99
  ...(node.managed_worktree === undefined ? {} : { managed_worktree: node.managed_worktree === null ? null : {
99
100
  state: node.managed_worktree.state,
101
+ ...(node.managed_worktree.cleanup === undefined ? {} : { cleanup: node.managed_worktree.cleanup }),
100
102
  path: node.managed_worktree.path,
101
103
  branch: node.managed_worktree.branch,
102
104
  base_ref: node.managed_worktree.base_ref,
@@ -234,7 +236,7 @@ function settleBroker(nodeId, request) {
234
236
  // broker alive while never fabricating a replacement canvas row.
235
237
  if (request.pushedFinal)
236
238
  return { action: 'shutdown' };
237
- if (request.askedHuman)
239
+ if (request.requestedHumanReply)
238
240
  return { action: 'stay_dormant' };
239
241
  throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
240
242
  }
@@ -254,7 +256,7 @@ function settleBroker(nodeId, request) {
254
256
  clearFault(nodeId, { link: 'daemon→node' });
255
257
  return node.intent === 'refresh' ? { action: 'shutdown' } : { action: 'stay_dormant' };
256
258
  }
257
- const signals = { pushedFinal: request.pushedFinal, askedHuman: request.askedHuman };
259
+ const signals = { pushedFinal: request.pushedFinal, requestedHumanReply: request.requestedHumanReply };
258
260
  // Two current-state outcomes: terminal broker consequences, not Pi decisions.
259
261
  if (signals.pushedFinal || node.status === 'done') {
260
262
  clearFault(nodeId);
@@ -263,7 +265,7 @@ function settleBroker(nodeId, request) {
263
265
  setPresence(nodeId, { pane: null, tmux_session: null, window: null });
264
266
  return { action: 'shutdown' };
265
267
  }
266
- if (signals.askedHuman) {
268
+ if (signals.requestedHumanReply) {
267
269
  clearFault(nodeId);
268
270
  return { action: 'stay_dormant' };
269
271
  }
@@ -281,7 +283,9 @@ function settleBroker(nodeId, request) {
281
283
  if (decision.reason !== 'awaiting' && decision.reason !== 'scheduled') {
282
284
  return { action: 'stay_dormant' };
283
285
  }
284
- if (focusOf(nodeId) !== null || hasUnseenNonDeferredInbox(nodeId))
286
+ if (focusOf(nodeId) !== null
287
+ || hasAttachedBrokerViewers(nodeId)
288
+ || hasUnseenNonDeferredInbox(nodeId))
285
289
  return { action: 'stay_dormant' };
286
290
  transition(nodeId, 'release');
287
291
  return { action: 'shutdown' };
@@ -0,0 +1,2 @@
1
+ import type { RouteTable } from '../router.js';
2
+ export declare const feedbackCommentRoutes: RouteTable;
@@ -0,0 +1,207 @@
1
+ // Page feedback-comment routes — notes the human leaves on highlighted page
2
+ // content, addressed by the same opaque inbox ticket ids as the page routes.
3
+ // The first comment on a ticket realizes its companion conversation — a fork
4
+ // of the sending node's current state, bound to the ticket for good — and
5
+ // every comment is then delivered to that companion as the person's own turn,
6
+ // post-commit and best-effort. A sent comment is immutable: the only mutations
7
+ // are create and the companion's resolve. Feedback never enters the page
8
+ // response the sender reads at settle — it was already delivered; only
9
+ // unresolved items ride the completion as an addendum.
10
+ import { basename } from 'node:path';
11
+ import { ApiError } from '../../../api/index.js';
12
+ import { createPageFeedbackComment, readPageFeedback, resolvePageFeedbackComment, PageFeedbackCommentAlreadyResolvedError, PageTicketTerminalError, UnknownPageFeedbackCommentError, } from '../../../core/human/feedback.js';
13
+ import { discardFeedbackCompanion, pageDocumentSha256, realizeFeedbackCompanion } from '../../../core/human/feedback-companion.js';
14
+ import { parsePage } from '../../../core/human/page.js';
15
+ import { pageTicketState } from '../../../core/human/tickets.js';
16
+ import { deliverNodeMessage } from '../../messaging/node-message.js';
17
+ import { requireTicketId, resolveTicket } from './inbox.js';
18
+ import { invalidRequest, objectBody, optionalString, requiredString } from './validate.js';
19
+ import { toPageFeedbackDTO, toPageFeedbackCommentDTO } from '../map.js';
20
+ const COMMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
21
+ // ---------------------------------------------------------------------------
22
+ // Shared guards
23
+ // ---------------------------------------------------------------------------
24
+ /** Resolve a mutable page ticket: it must exist, be a page, and not have a
25
+ * terminal outcome. Passive pages never settle, so they stay commentable.
26
+ * A terminal ticket refuses every feedback action — settlement won. */
27
+ function requireMutablePageTicket(ctx) {
28
+ const ticketId = requireTicketId(ctx.params);
29
+ const resolved = resolveTicket(ticketId);
30
+ if (resolved === null)
31
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
32
+ if (resolved.kind === 'review') {
33
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no feedback comments');
34
+ }
35
+ const state = pageTicketState(resolved.dir);
36
+ if (state === 'resolved' || state === 'canceled') {
37
+ throw new ApiError(409, 'ticket_terminal', 'ticket already has a terminal outcome');
38
+ }
39
+ return resolved;
40
+ }
41
+ function requireCommentId(params) {
42
+ const commentId = params['comment_id'];
43
+ if (commentId === undefined || !COMMENT_ID.test(commentId)) {
44
+ throw new ApiError(400, 'invalid_request', 'invalid feedback comment id');
45
+ }
46
+ return commentId;
47
+ }
48
+ // ---------------------------------------------------------------------------
49
+ // Delivery — the person's turn in the companion conversation
50
+ // ---------------------------------------------------------------------------
51
+ /** The comment as the person's own words: the quoted page content and the
52
+ * note, nothing else. The companion's briefing rides its bearings and
53
+ * persona, never the human turn. */
54
+ function renderCommentTurn(comment) {
55
+ const quoted = comment.quote.split('\n').map((line) => `> ${line}`);
56
+ return [...quoted, '', comment.note].join('\n');
57
+ }
58
+ async function deliverCommentTurn(args) {
59
+ const target = args.mutation.feedback.companionNodeId;
60
+ if (target === undefined) {
61
+ // Comments exist only after a create recorded the companion, so a missing
62
+ // companion here is corrupted feedback state, not a deliverable situation.
63
+ throw new ApiError(500, 'internal', 'feedback has comments but no companion conversation');
64
+ }
65
+ try {
66
+ const delivery = await deliverNodeMessage({
67
+ node_id: target,
68
+ body: renderCommentTurn(args.mutation.comment),
69
+ label: args.title,
70
+ from: 'human',
71
+ mode: 'interactive',
72
+ data: { page_id: basename(args.dir), comment_id: args.mutation.comment.id, verb: 'create' },
73
+ });
74
+ return { target_node_id: target, status: 'delivered', via: delivery.via, woke: delivery.revived };
75
+ }
76
+ catch (error) {
77
+ // State was committed before delivery; a delivery failure remains a
78
+ // successful mutation.
79
+ return { target_node_id: target, status: 'failed', woke: false, error: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // POST /v1/human/inbox/:ticket_id/feedback-comments — create
84
+ // ---------------------------------------------------------------------------
85
+ function parseCreate(ctx) {
86
+ const violations = [];
87
+ const body = objectBody(ctx.body, ['quote', 'context', 'note'], violations);
88
+ const quote = requiredString(body, 'quote', violations);
89
+ const context = optionalString(body, 'context', violations);
90
+ const note = requiredString(body, 'note', violations);
91
+ if (violations.length > 0 || quote === undefined || note === undefined) {
92
+ invalidRequest('page feedback comment request', violations);
93
+ }
94
+ return { quote, ...(context === undefined ? {} : { context }), note };
95
+ }
96
+ async function handleCreate(ctx) {
97
+ const request = parseCreate(ctx);
98
+ const resolved = requireMutablePageTicket(ctx);
99
+ const manifest = parsePage(resolved.dir);
100
+ if (manifest.dialect !== 'jsx') {
101
+ throw new ApiError(409, 'page_dialect_unsupported', 'feedback comments exist only on TSX pages');
102
+ }
103
+ // Realize the companion before taking the ticket lock — forking is async and
104
+ // must not hold it. The store keeps an already-recorded companion, so a
105
+ // concurrent first comment wastes at most one idle fork (accepted
106
+ // single-human race). Any realization failure refuses the comment: no
107
+ // feedback is accepted without a visible conversation able to receive it.
108
+ let companionNodeId = readPageFeedback(resolved.dir).companionNodeId;
109
+ let firstCommentedSha256;
110
+ let realizedNow = false;
111
+ if (companionNodeId === undefined) {
112
+ const sender = manifest.source?.nodeId;
113
+ if (sender === undefined) {
114
+ throw new ApiError(409, 'ticket_has_no_sender', 'this page carries no sending node to deliver feedback to');
115
+ }
116
+ firstCommentedSha256 = pageDocumentSha256(resolved.dir);
117
+ try {
118
+ companionNodeId = await realizeFeedbackCompanion({
119
+ ticketId: resolved.ticketId,
120
+ dir: resolved.dir,
121
+ senderNodeId: sender,
122
+ title: manifest.title,
123
+ });
124
+ }
125
+ catch (error) {
126
+ throw new ApiError(409, 'fork_failed', `the sending node could not be forked: ${error instanceof Error ? error.message : String(error)}`);
127
+ }
128
+ realizedNow = true;
129
+ }
130
+ // The fork awaited its bind outside the ticket lock, so settlement may have
131
+ // won meanwhile: the create mutation re-checks the ticket under the lock and
132
+ // refuses after a terminal outcome. A companion realized by THIS request is
133
+ // discarded whenever its commit did not land — refusal, write failure, or a
134
+ // lost first-comment race (the recorded companion wins; ours is a stray).
135
+ let mutation;
136
+ try {
137
+ mutation = createPageFeedbackComment(resolved.dir, { ...request, companionNodeId, firstCommentedSha256 });
138
+ }
139
+ catch (error) {
140
+ if (realizedNow)
141
+ discardFeedbackCompanion(companionNodeId);
142
+ if (error instanceof PageTicketTerminalError) {
143
+ throw new ApiError(409, 'ticket_terminal', 'ticket already has a terminal outcome');
144
+ }
145
+ throw error;
146
+ }
147
+ if (realizedNow && mutation.feedback.companionNodeId !== companionNodeId) {
148
+ discardFeedbackCompanion(companionNodeId);
149
+ }
150
+ const body = {
151
+ comment: toPageFeedbackCommentDTO(mutation.comment),
152
+ feedback: toPageFeedbackDTO(mutation.feedback),
153
+ delivery: await deliverCommentTurn({ dir: resolved.dir, title: manifest.title, mutation }),
154
+ };
155
+ return { status: 200, body };
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // POST .../feedback-comments/:comment_id/resolve — the companion's report
159
+ // ---------------------------------------------------------------------------
160
+ function parseResolve(ctx) {
161
+ const violations = [];
162
+ const body = objectBody(ctx.body, ['node_id'], violations);
163
+ const nodeId = requiredString(body, 'node_id', violations);
164
+ if (violations.length > 0 || nodeId === undefined) {
165
+ invalidRequest('feedback resolve request', violations);
166
+ }
167
+ return nodeId;
168
+ }
169
+ async function handleResolve(ctx) {
170
+ const resolved = requireMutablePageTicket(ctx);
171
+ const commentId = requireCommentId(ctx.params);
172
+ const callerNodeId = parseResolve(ctx);
173
+ // Resolution is the bound companion's report that an item has been dealt
174
+ // with; no other node may retire the person's open item out from under the
175
+ // settle-time addendum.
176
+ const companionNodeId = readPageFeedback(resolved.dir).companionNodeId;
177
+ if (companionNodeId === undefined || callerNodeId !== companionNodeId) {
178
+ throw new ApiError(403, 'not_companion', "only the ticket's bound feedback companion resolves its comments");
179
+ }
180
+ let mutation;
181
+ try {
182
+ mutation = resolvePageFeedbackComment(resolved.dir, commentId);
183
+ }
184
+ catch (error) {
185
+ if (error instanceof UnknownPageFeedbackCommentError) {
186
+ throw new ApiError(404, 'unknown_comment', error.message);
187
+ }
188
+ if (error instanceof PageFeedbackCommentAlreadyResolvedError) {
189
+ throw new ApiError(409, 'comment_already_resolved', error.message);
190
+ }
191
+ if (error instanceof PageTicketTerminalError) {
192
+ throw new ApiError(409, 'ticket_terminal', 'ticket already has a terminal outcome');
193
+ }
194
+ throw error;
195
+ }
196
+ // Resolution appends no chat turn: the companion's own conversational report
197
+ // explains the result.
198
+ const body = {
199
+ comment: toPageFeedbackCommentDTO(mutation.comment),
200
+ feedback: toPageFeedbackDTO(mutation.feedback),
201
+ };
202
+ return { status: 200, body };
203
+ }
204
+ export const feedbackCommentRoutes = [
205
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/feedback-comments', handler: handleCreate },
206
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/feedback-comments/:comment_id/resolve', handler: handleResolve },
207
+ ];