@crouton-kit/crouter 0.3.18 → 0.3.20

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 (289) hide show
  1. package/dist/build-root.js +6 -0
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
  5. package/dist/builtin-memory/crouter-development/personas.md +117 -0
  6. package/dist/builtin-memory/crouter-development/plugins.md +163 -0
  7. package/dist/builtin-memory/design.md +60 -0
  8. package/dist/builtin-memory/development.md +116 -0
  9. package/dist/builtin-memory/planning.md +66 -0
  10. package/dist/builtin-memory/spec.md +99 -0
  11. package/dist/builtin-personas/design/PERSONA.md +1 -0
  12. package/dist/builtin-personas/design/orchestrator.md +2 -1
  13. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  14. package/dist/builtin-personas/developer/orchestrator.md +2 -1
  15. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  16. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  17. package/dist/builtin-personas/general/PERSONA.md +1 -0
  18. package/dist/builtin-personas/general/orchestrator.md +4 -0
  19. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  20. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  21. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  22. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/orchestrator.md +2 -1
  24. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  26. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  27. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  28. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  29. package/dist/builtin-personas/review/PERSONA.md +1 -0
  30. package/dist/builtin-personas/review/orchestrator.md +4 -0
  31. package/dist/builtin-personas/runtime-base.md +6 -3
  32. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  33. package/dist/builtin-personas/spec/orchestrator.md +2 -1
  34. package/dist/builtin-personas/waiting.md +8 -0
  35. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  36. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  37. package/dist/builtin-views/_lib/states.mjs +161 -0
  38. package/dist/builtin-views/canvas/client.mjs +303 -0
  39. package/dist/builtin-views/canvas/view.mjs +576 -0
  40. package/dist/builtin-views/git-pr/client.mjs +440 -0
  41. package/dist/builtin-views/git-pr/view.mjs +675 -0
  42. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  43. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  44. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  45. package/dist/builtin-views/inbox/view.mjs +889 -0
  46. package/dist/builtin-views/linkedin/client.mjs +610 -0
  47. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  48. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  49. package/dist/clients/attach/attach-cmd.js +354 -0
  50. package/dist/clients/attach/chat-view.d.ts +77 -0
  51. package/dist/clients/attach/chat-view.js +450 -0
  52. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  53. package/dist/clients/attach/clipboard-image.js +113 -0
  54. package/dist/clients/attach/config-load.d.ts +31 -0
  55. package/dist/clients/attach/config-load.js +113 -0
  56. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  57. package/dist/clients/attach/extension-dialogs.js +101 -0
  58. package/dist/clients/attach/input-controller.d.ts +54 -0
  59. package/dist/clients/attach/input-controller.js +204 -0
  60. package/dist/clients/attach/slash-commands.d.ts +36 -0
  61. package/dist/clients/attach/slash-commands.js +200 -0
  62. package/dist/clients/attach/view-socket.d.ts +48 -0
  63. package/dist/clients/attach/view-socket.js +126 -0
  64. package/dist/commands/attention.js +3 -3
  65. package/dist/commands/canvas-prune.js +1 -1
  66. package/dist/commands/daemon.js +3 -2
  67. package/dist/commands/human/prompts.js +1 -1
  68. package/dist/commands/human/queue.js +54 -8
  69. package/dist/commands/human/shared.d.ts +2 -0
  70. package/dist/commands/memory/find.d.ts +1 -0
  71. package/dist/commands/memory/find.js +180 -0
  72. package/dist/commands/memory/lint.d.ts +1 -0
  73. package/dist/commands/memory/lint.js +140 -0
  74. package/dist/commands/memory/list.d.ts +1 -0
  75. package/dist/commands/memory/list.js +79 -0
  76. package/dist/commands/memory/read.js +103 -0
  77. package/dist/commands/memory/shared.d.ts +30 -0
  78. package/dist/commands/memory/shared.js +122 -0
  79. package/dist/commands/memory/write.d.ts +1 -0
  80. package/dist/commands/memory/write.js +85 -0
  81. package/dist/commands/memory.d.ts +2 -0
  82. package/dist/commands/memory.js +27 -0
  83. package/dist/commands/node.d.ts +3 -2
  84. package/dist/commands/node.js +610 -61
  85. package/dist/commands/pkg/market-manage.js +1 -1
  86. package/dist/commands/push.js +6 -6
  87. package/dist/commands/revive.js +1 -1
  88. package/dist/commands/skill/author.js +1 -1
  89. package/dist/commands/skill/shared.d.ts +1 -8
  90. package/dist/commands/skill/shared.js +2 -55
  91. package/dist/commands/skill.js +9 -14
  92. package/dist/commands/sys/doctor.js +1 -1
  93. package/dist/commands/sys/update.js +1 -1
  94. package/dist/commands/view-cycle.d.ts +2 -0
  95. package/dist/commands/view-cycle.js +125 -0
  96. package/dist/commands/view-list.d.ts +2 -0
  97. package/dist/commands/view-list.js +66 -0
  98. package/dist/commands/view-new.d.ts +2 -0
  99. package/dist/commands/view-new.js +70 -0
  100. package/dist/commands/view-pick.d.ts +2 -0
  101. package/dist/commands/view-pick.js +119 -0
  102. package/dist/commands/view-run.d.ts +2 -0
  103. package/dist/commands/view-run.js +191 -0
  104. package/dist/commands/view.d.ts +2 -0
  105. package/dist/commands/view.js +29 -0
  106. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  107. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  108. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  109. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  110. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  111. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  112. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  114. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  116. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  117. package/dist/core/__tests__/broker-lifecycle.test.js +87 -0
  118. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  119. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  120. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  121. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  122. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  123. package/dist/core/__tests__/cascade-close.test.js +12 -2
  124. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  125. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  126. package/dist/core/__tests__/context-intro.test.js +76 -62
  127. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  128. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  129. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  130. package/dist/core/__tests__/detach-focus.test.js +206 -0
  131. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  132. package/dist/core/__tests__/draw-style.test.js +258 -0
  133. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  134. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  135. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  136. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  137. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  138. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  139. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  140. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  141. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  142. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  143. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  144. package/dist/core/__tests__/helpers/harness.js +111 -1
  145. package/dist/core/__tests__/home-session.test.js +41 -8
  146. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  147. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  148. package/dist/core/__tests__/kickoff.test.js +37 -3
  149. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  150. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  151. package/dist/core/__tests__/live-mutation.test.js +6 -132
  152. package/dist/core/__tests__/memory.test.js +23 -115
  153. package/dist/core/__tests__/placement-focus.test.js +5 -0
  154. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  155. package/dist/core/__tests__/relaunch.test.js +4 -3
  156. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  157. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  158. package/dist/core/__tests__/spawn-root.test.js +10 -0
  159. package/dist/core/__tests__/spike-harness.test.js +1 -0
  160. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  161. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  162. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  163. package/dist/core/__tests__/wake-origin.test.js +110 -0
  164. package/dist/core/bootstrap.js +1 -1
  165. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  166. package/dist/core/canvas/browse/app.js +24 -2
  167. package/dist/core/canvas/browse/model.d.ts +38 -2
  168. package/dist/core/canvas/browse/model.js +134 -10
  169. package/dist/core/canvas/browse/render.d.ts +6 -12
  170. package/dist/core/canvas/browse/render.js +72 -104
  171. package/dist/core/canvas/canvas.js +16 -23
  172. package/dist/core/canvas/db.js +46 -0
  173. package/dist/core/canvas/index.d.ts +1 -0
  174. package/dist/core/canvas/index.js +1 -0
  175. package/dist/core/canvas/pid.d.ts +4 -0
  176. package/dist/core/canvas/pid.js +23 -0
  177. package/dist/core/canvas/render.d.ts +11 -2
  178. package/dist/core/canvas/render.js +69 -0
  179. package/dist/core/canvas/types.d.ts +91 -3
  180. package/dist/core/canvas/wakeups.d.ts +76 -0
  181. package/dist/core/canvas/wakeups.js +185 -0
  182. package/dist/core/config.js +4 -1
  183. package/dist/core/frontmatter.js +37 -124
  184. package/dist/core/memory-resolver.d.ts +49 -0
  185. package/dist/core/memory-resolver.js +141 -0
  186. package/dist/core/personas/loader.d.ts +7 -0
  187. package/dist/core/personas/loader.js +36 -5
  188. package/dist/core/personas/resolve.js +13 -3
  189. package/dist/core/predicate.d.ts +63 -0
  190. package/dist/core/predicate.js +189 -0
  191. package/dist/core/resolver.js +26 -5
  192. package/dist/core/runtime/bearings.d.ts +53 -12
  193. package/dist/core/runtime/bearings.js +132 -59
  194. package/dist/core/runtime/broker-cli.d.ts +1 -0
  195. package/dist/core/runtime/broker-cli.js +46 -0
  196. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  197. package/dist/core/runtime/broker-protocol.js +153 -0
  198. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  199. package/dist/core/runtime/broker-sdk.js +72 -0
  200. package/dist/core/runtime/broker.d.ts +55 -0
  201. package/dist/core/runtime/broker.js +1128 -0
  202. package/dist/core/runtime/close.js +35 -6
  203. package/dist/core/runtime/host.d.ts +53 -0
  204. package/dist/core/runtime/host.js +186 -0
  205. package/dist/core/runtime/kickoff.d.ts +2 -1
  206. package/dist/core/runtime/kickoff.js +91 -5
  207. package/dist/core/runtime/launch.d.ts +54 -4
  208. package/dist/core/runtime/launch.js +85 -5
  209. package/dist/core/runtime/lifecycle.js +23 -6
  210. package/dist/core/runtime/memory.d.ts +2 -42
  211. package/dist/core/runtime/memory.js +11 -162
  212. package/dist/core/runtime/nodes.d.ts +37 -0
  213. package/dist/core/runtime/nodes.js +60 -1
  214. package/dist/core/runtime/persona.js +21 -11
  215. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  216. package/dist/core/runtime/pi-vendored.js +49 -0
  217. package/dist/core/runtime/placement.d.ts +32 -14
  218. package/dist/core/runtime/placement.js +206 -52
  219. package/dist/core/runtime/promote.d.ts +0 -6
  220. package/dist/core/runtime/promote.js +4 -12
  221. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  222. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  223. package/dist/core/runtime/reset.js +11 -8
  224. package/dist/core/runtime/revive.d.ts +2 -0
  225. package/dist/core/runtime/revive.js +34 -34
  226. package/dist/core/runtime/spawn.d.ts +23 -0
  227. package/dist/core/runtime/spawn.js +77 -23
  228. package/dist/core/runtime/stop-guard.d.ts +1 -1
  229. package/dist/core/runtime/stop-guard.js +6 -1
  230. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  231. package/dist/core/runtime/tmux-chrome.js +1 -1
  232. package/dist/core/runtime/tmux.d.ts +28 -0
  233. package/dist/core/runtime/tmux.js +80 -6
  234. package/dist/core/scope.d.ts +11 -0
  235. package/dist/core/scope.js +39 -0
  236. package/dist/core/spawn.d.ts +5 -0
  237. package/dist/core/spawn.js +20 -2
  238. package/dist/core/substrate/gate.d.ts +13 -0
  239. package/dist/core/substrate/gate.js +21 -0
  240. package/dist/core/substrate/index.d.ts +7 -0
  241. package/dist/core/substrate/index.js +18 -0
  242. package/dist/core/substrate/on-read.d.ts +14 -0
  243. package/dist/core/substrate/on-read.js +292 -0
  244. package/dist/core/substrate/render.d.ts +25 -0
  245. package/dist/core/substrate/render.js +256 -0
  246. package/dist/core/substrate/schema.d.ts +76 -0
  247. package/dist/core/substrate/schema.js +124 -0
  248. package/dist/core/substrate/session-cache.d.ts +30 -0
  249. package/dist/core/substrate/session-cache.js +77 -0
  250. package/dist/core/substrate/subject.d.ts +41 -0
  251. package/dist/core/substrate/subject.js +54 -0
  252. package/dist/core/tui/contract.d.ts +83 -0
  253. package/dist/core/tui/contract.js +8 -0
  254. package/dist/core/tui/draw.d.ts +96 -0
  255. package/dist/core/tui/draw.js +339 -0
  256. package/dist/core/tui/host.d.ts +29 -0
  257. package/dist/core/tui/host.js +379 -0
  258. package/dist/core/tui/loader.d.ts +16 -0
  259. package/dist/core/tui/loader.js +94 -0
  260. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  261. package/dist/core/wake.d.ts +86 -0
  262. package/dist/core/wake.js +308 -0
  263. package/dist/daemon/crtrd.d.ts +29 -4
  264. package/dist/daemon/crtrd.js +662 -46
  265. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  266. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  267. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  268. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  269. package/dist/pi-extensions/canvas-commands.js +10 -0
  270. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  271. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  272. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  273. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  274. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  275. package/dist/pi-extensions/canvas-nav.js +87 -23
  276. package/dist/pi-extensions/canvas-stophook.js +17 -8
  277. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  278. package/dist/pi-extensions/canvas-view.js +75 -0
  279. package/dist/prompts/skill.js +19 -26
  280. package/dist/prompts/view.d.ts +7 -0
  281. package/dist/prompts/view.js +101 -0
  282. package/dist/types.d.ts +4 -0
  283. package/dist/types.js +1 -0
  284. package/package.json +9 -4
  285. package/dist/commands/skill/find.d.ts +0 -4
  286. package/dist/commands/skill/find.js +0 -257
  287. package/dist/commands/skill/read.js +0 -91
  288. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  289. /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-dialogs.test.ts
2
+ //
3
+ // Broker dialog handling — the §5.4 C2 forward-progress proof plus the T8 attach
4
+ // gates G5/G5b/G6. Split out of broker-lifecycle.test.ts (see its header for the
5
+ // full file map); the tests are the original acceptance gate, unchanged, on their
6
+ // own isolated harness. The attach-client helpers live in
7
+ // helpers/broker-clients.ts (the PRODUCTION ViewSocketClient, §0 one-writer: a
8
+ // viewer holds ONLY a socket).
9
+ import { test, before, after, afterEach } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createHarness, hasTmux } from './helpers/harness.js';
12
+ import { isPidAlive } from '../canvas/pid.js';
13
+ import { createAttachKit } from './helpers/broker-clients.js';
14
+ let h;
15
+ let root;
16
+ const kit = createAttachKit(() => h);
17
+ const { attach, attachUntil } = kit;
18
+ before(async () => {
19
+ if (!hasTmux())
20
+ return;
21
+ h = await createHarness({ sessionPrefix: 'crtr-brkdlg' });
22
+ root = h.spawnRoot('broker-dialogs suite root');
23
+ });
24
+ after(async () => {
25
+ if (h !== undefined)
26
+ await h.dispose();
27
+ });
28
+ afterEach(() => {
29
+ kit.closeAll();
30
+ });
31
+ const brokerPid = (id) => h.node(id).pi_pid;
32
+ // ===========================================================================
33
+ // C2 forward-progress (zero-viewer path) — an unattended blocking dialog
34
+ // resolves to its default (false) IMMEDIATELY: with no controller connected the
35
+ // broker's REAL makeBrokerUiContext falls back to noOp resolution, so the engine
36
+ // never deadlocks AND never waits on a per-dialog timeout (the design §5.4
37
+ // timeout premise is false). Red/green: the OLD broker armed a setTimeout and
38
+ // resolved only after `timeout` ms; the fixed broker resolves in ~0ms. (Supports
39
+ // acceptance item 7: the existing suite stays green; this only ADDS coverage.)
40
+ // ===========================================================================
41
+ test('C2 — unattended dialog resolves to its default IMMEDIATELY (noOp), never waits on a timeout', { skip: !hasTmux() }, async () => {
42
+ const id = await h.spawnHeadlessChild(root, 'headless worker — dialog');
43
+ const pid = h.node(id).pi_pid;
44
+ // Drive the fake engine to call uiContext.confirm(..., { timeout: 5000 }) with
45
+ // NO controller. C2 fix: makeBrokerUiContext resolves the default (false) AT
46
+ // ONCE — it does NOT arm/await the timeout. A generous < 2000ms bound is still a
47
+ // hard fail against the old ~5000ms timeout-wait while staying robust on slow CI.
48
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
49
+ const results = await h.waitFor(() => {
50
+ const r = h.dialogResults(id);
51
+ return r.length > 0 ? r : null;
52
+ }, { timeoutMs: 15_000, label: 'unattended dialog resolved' });
53
+ assert.equal(results[0].resolved, false, 'unattended confirm resolves to its default (false / deny)');
54
+ assert.ok(results[0].ms < 2000, `C2: resolved IMMEDIATELY (noOp), not after the 5000ms timeout — got ${results[0].ms}ms`);
55
+ assert.equal(isPidAlive(pid), true, 'the broker made forward progress (still alive, did not hang or exit)');
56
+ });
57
+ // ---------------------------------------------------------------------------
58
+ // G5 — dialog forward + answer. Guards: a blocking dialog reaches the controller
59
+ // as extension_ui_request and the controller's extension_ui_response unblocks the
60
+ // engine with ITS answer (not the default). Failure mode: a dialog the controller
61
+ // can't see/answer (silent deadlock).
62
+ // ---------------------------------------------------------------------------
63
+ test('G5 — controller receives an extension_ui_request, answers it, and the engine proceeds with that answer', { skip: !hasTmux() }, async () => {
64
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G5a');
65
+ const c = await attach(id, 'controller', 'g5-ctrl');
66
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: the controller answers first
67
+ const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G5 dialog forwarded to controller');
68
+ assert.equal(req.method, 'confirm', 'G5: the forwarded dialog is the confirm() the engine raised');
69
+ const reqId = req.id;
70
+ c.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
71
+ const results = await h.waitFor(() => {
72
+ const r = h.dialogResults(id);
73
+ return r.length > 0 ? r : null;
74
+ }, { label: 'G5 dialog resolved by the controller', timeoutMs: 15_000 });
75
+ assert.equal(results[0].resolved, true, 'G5: the engine proceeded with the controller answer (true), not the default (false)');
76
+ });
77
+ // ---------------------------------------------------------------------------
78
+ // G5 (mid-dialog attach) — Guards: a dialog raised under a prior controller stays
79
+ // pending across that controller's detach (M2) and is delivered to whoever takes
80
+ // control next via welcome.pending_dialog. Failure mode: a pending dialog lost on
81
+ // controller handoff.
82
+ // ---------------------------------------------------------------------------
83
+ test('G5 — a controller attaching MID-dialog receives the pending dialog via welcome.pending_dialog', { skip: !hasTmux() }, async () => {
84
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G5b');
85
+ const a = await attach(id, 'controller', 'g5b-A');
86
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the handoff
87
+ const reqA = await a.waitFrame((f) => f.type === 'extension_ui_request', 'G5b dialog forwarded to controller A');
88
+ const reqId = reqA.id;
89
+ a.send({ type: 'bye' });
90
+ a.close(); // M2: detach frees control but does NOT cancel the pending dialog
91
+ // Controller B takes control (retry covers the close→controllerId=null beat) and
92
+ // its welcome carries the still-pending dialog.
93
+ const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b controller B takes control with the pending dialog');
94
+ assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised under A');
95
+ assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
96
+ b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
97
+ const results = await h.waitFor(() => {
98
+ const r = h.dialogResults(id);
99
+ return r.length > 0 ? r : null;
100
+ }, { label: 'G5b dialog resolved by controller B', timeoutMs: 15_000 });
101
+ assert.equal(results[0].resolved, true, 'G5b: controller B answered the handed-off dialog and the engine proceeded');
102
+ });
103
+ // ---------------------------------------------------------------------------
104
+ // G6 — anti-deadlock. (a) a zero-viewer dialog resolves to its default AT ONCE
105
+ // (noOp). (b) an ATTENDED dialog the controller never answers resolves on a SHORT
106
+ // per-dialog broker timeout. Guards: the engine never hangs on a dialog with no
107
+ // answerer. Failure mode: a forever-blocked turn.
108
+ // ---------------------------------------------------------------------------
109
+ test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dialog resolves on the broker timeout', { skip: !hasTmux() }, async () => {
110
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G6');
111
+ const pid = brokerPid(id);
112
+ // (a) zero viewers → immediate noOp default (NOT the 5000ms timeout).
113
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
114
+ const r1 = await h.waitFor(() => (h.dialogResults(id).length >= 1 ? h.dialogResults(id) : null), { label: 'G6a zero-viewer dialog resolved', timeoutMs: 15_000 });
115
+ assert.equal(r1[0].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
116
+ assert.ok(r1[0].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[0].ms}ms`);
117
+ // (b) controller attached but silent → the broker resolves on the SHORT explicit
118
+ // per-dialog timeout (800ms), never the 120s default.
119
+ const c = await attach(id, 'controller', 'g6-ctrl');
120
+ h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
121
+ await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
122
+ const r2 = await h.waitFor(() => (h.dialogResults(id).length >= 2 ? h.dialogResults(id) : null), { label: 'G6b attended dialog resolved on timeout', timeoutMs: 15_000 });
123
+ assert.equal(r2[1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
124
+ assert.ok(r2[1].ms >= 600 && r2[1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[1].ms}ms`);
125
+ assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress on both dialogs (still alive)');
126
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-dormant-wake.test.ts
2
+ //
3
+ // Acceptance item 3 (DORMANT) of the headless-broker migration. Split out of
4
+ // broker-lifecycle.test.ts (see its header for the full file map); the test is
5
+ // the original acceptance gate, unchanged, on its own isolated harness.
6
+ import { test, before, after } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { createHarness, hasTmux } from './helpers/harness.js';
9
+ import { subscribe } from '../canvas/canvas.js';
10
+ import { appendInbox } from '../feed/inbox.js';
11
+ import { isPidAlive } from '../canvas/pid.js';
12
+ let h;
13
+ let root;
14
+ before(async () => {
15
+ if (!hasTmux())
16
+ return;
17
+ h = await createHarness({ sessionPrefix: 'crtr-brkdorm' });
18
+ // An active resident root: the spawn parent AND the live node a child can hold
19
+ // an active subscription to (so its natural stop classifies 'awaiting').
20
+ root = h.spawnRoot('broker-dormant suite root');
21
+ });
22
+ after(async () => {
23
+ if (h !== undefined)
24
+ await h.dispose();
25
+ });
26
+ // ===========================================================================
27
+ // Item 3 (DORMANT) — a natural stop while awaiting a live subscription idle-
28
+ // RELEASES the broker (it exits); the daemon's second pass revives it (RESUME)
29
+ // on the next unseen inbox entry.
30
+ // ===========================================================================
31
+ test('DORMANT wake — idle-release → broker exits → daemon pass-2 reviveNode(resume) on inbox', { skip: !hasTmux() }, async () => {
32
+ const id = await h.spawnHeadlessChild(root, 'headless worker — dormant wake');
33
+ const pid = h.node(id).pi_pid;
34
+ // Give the (terminal) child an ACTIVE subscription to the LIVE root, so the
35
+ // stop-guard classifies its natural stop as 'awaiting' (a dormant orchestrator
36
+ // awaiting a worker) → the stophook idle-releases it (paneless → no focus).
37
+ subscribe(id, root, true);
38
+ await h.stop(id, 'stop');
39
+ await h.waitForStatus(id, 'idle');
40
+ assert.equal(h.node(id).intent, 'idle-release', 'awaiting + unfocused → idle-release');
41
+ await h.waitFor(() => !isPidAlive(pid), { label: 'released broker process exited' });
42
+ // Daemon owns wake-on-message for a dormant (pi-dead) broker: an unseen inbox
43
+ // entry + a tick → pass 2 → reviveNode(resume:true) → a fresh broker boots.
44
+ appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'resume-me', data: { body: 'work after release' } });
45
+ await h.tick();
46
+ const boot2 = await h.awaitBoot(id, { minCount: 2 });
47
+ assert.equal(boot2.resuming, true, 'the dormant revive RESUMES the saved session');
48
+ const newPid = h.node(id).pi_pid;
49
+ assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
50
+ assert.notEqual(newPid, pid, 'the revived broker is a fresh process');
51
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-lifecycle.test.ts
2
+ //
3
+ // The broker-backed lifecycle suite — the acceptance-gate proof of the headless-
4
+ // broker migration (plan T11). It drives the REAL `crtr` CLI into an isolated
5
+ // REAL tmux session and spawns `--headless` nodes onto the REAL headless broker
6
+ // host (host.ts), which boots a REAL detached broker PROCESS (broker.ts) per
7
+ // node. The broker hosts a fake SDK engine (fixtures/fake-engine.ts) loaded via
8
+ // the CRTR_BROKER_ENGINE seam — NOTHING in the broker, the host, the stophook,
9
+ // the inbox-watcher, the stop-guard, or the daemon (superviseTick) is mocked.
10
+ // Every assertion reads straight off the canvas data layer.
11
+ //
12
+ // The suite is SPLIT across sibling files (one isolated harness each) so
13
+ // node:test's file-level parallelism applies — the tests themselves are the
14
+ // original acceptance gate, unchanged:
15
+ // THIS FILE — items 1–2 + 3 (LIVE): spawn, LIVE wake
16
+ // broker-dormant-wake.test.ts — item 3 (DORMANT): idle-release → daemon revive
17
+ // broker-crash-teardown.test.ts — items 4–6 + M-1/M-2: crash, teardown, boot failure
18
+ // broker-dialogs.test.ts — §5.4 C2 + gates G5/G5b/G6 (dialogs)
19
+ // broker-attach-stream.test.ts — gates G1/G1b/G2/G3 (drive + relay + snapshot)
20
+ // broker-attach-limits.test.ts — gates G4/G7/G8/G9 (arbitration + caps + one-writer)
21
+ //
22
+ // Acceptance items (plan §4) → the asserting test:
23
+ // 1 spawn --headless → host_kind='broker', null placement, live broker pid → "spawn"
24
+ // 2 supervised by broker-pid signal-0 (stophook recordPid = broker pid) → "spawn"
25
+ // 3 inbox wakes LIVE (in-broker watcher) AND DORMANT (idle-release → daemon) → "live wake" here + "dormant wake" (broker-dormant-wake.test.ts)
26
+ // 7 tmux stays default — the existing suite passes UNMODIFIED (this split only
27
+ // MOVES tests between files + extracts harness wiring; nothing is rewritten).
28
+ import { test, before, after } from 'node:test';
29
+ import assert from 'node:assert/strict';
30
+ import { createHarness, hasTmux } from './helpers/harness.js';
31
+ import { appendInbox } from '../feed/inbox.js';
32
+ import { isPidAlive } from '../canvas/pid.js';
33
+ let h;
34
+ let root;
35
+ before(async () => {
36
+ if (!hasTmux())
37
+ return;
38
+ h = await createHarness({ sessionPrefix: 'crtr-broker' });
39
+ root = h.spawnRoot('broker-suite root');
40
+ });
41
+ after(async () => {
42
+ if (h !== undefined)
43
+ await h.dispose();
44
+ });
45
+ // ===========================================================================
46
+ // Item 1 + 2 — spawn --headless: broker host, NULL placement, a live broker pid
47
+ // that IS the daemon's supervision signal (stophook recordPid(process.pid)).
48
+ // ===========================================================================
49
+ test('spawn --headless → broker host_kind, null placement, live broker pid; daemon leaves a healthy broker', { skip: !hasTmux() }, async () => {
50
+ const id = await h.spawnHeadlessChild(root, 'headless worker — spawn');
51
+ const node = h.node(id);
52
+ // Item 1: the broker host + a paneless placement.
53
+ assert.equal(node.host_kind, 'broker', "host_kind === 'broker'");
54
+ assert.equal(node.window ?? null, null, 'window is NULL (paneless broker)');
55
+ assert.equal(node.pane ?? null, null, 'pane is NULL (paneless broker)');
56
+ assert.equal(node.tmux_session ?? null, null, 'tmux_session is NULL (paneless broker)');
57
+ assert.ok(node.pi_pid != null, 'a broker pid was recorded as pi_pid');
58
+ assert.equal(isPidAlive(node.pi_pid), true, 'the broker pid is alive');
59
+ // Item 2: the supervision signal IS the broker pid — the stophook recorded
60
+ // process.pid (the broker's own pid) on session_start, == the boot proof pid.
61
+ const boot = await h.awaitBoot(id);
62
+ assert.equal(boot.pid, node.pi_pid, 'boot-proof pid (broker process.pid) === supervised pi_pid');
63
+ // The daemon supervises it by that pid alone: a healthy broker is left untouched.
64
+ const before = h.bootCount(id);
65
+ await h.tick();
66
+ assert.equal(h.status(id), 'active', 'a healthy broker stays active across a daemon tick');
67
+ assert.equal(h.node(id).pi_pid, node.pi_pid, 'pid unchanged — not revived');
68
+ assert.equal(h.bootCount(id), before, 'no reboot');
69
+ assert.equal(isPidAlive(node.pi_pid), true, 'broker still alive');
70
+ });
71
+ // ===========================================================================
72
+ // Item 3 (LIVE) — the in-broker inbox-watcher delivers an inbox push WITHOUT an
73
+ // exit or revive (the broker stays alive; pi.sendUserMessage → injected).
74
+ // ===========================================================================
75
+ test('LIVE wake — in-broker watcher injects an inbox push with no exit/revive', { skip: !hasTmux() }, async () => {
76
+ const id = await h.spawnHeadlessChild(root, 'headless worker — live wake');
77
+ const pid = h.node(id).pi_pid;
78
+ const boots = h.bootCount(id);
79
+ appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'live-wake', data: { body: 'do this live' } });
80
+ // The REAL in-broker watcher (800ms poll + 1000ms debounce) delivers via
81
+ // pi.sendUserMessage → fake-pi.injected.jsonl.
82
+ await h.awaitWake(id, { match: /do this live/, timeoutMs: 15_000 });
83
+ // No exit, no revive: same broker pid, still alive, no new boot.
84
+ assert.equal(h.bootCount(id), boots, 'no new boot — the live broker was not revived');
85
+ assert.equal(h.node(id).pi_pid, pid, 'pi_pid unchanged — the broker never exited');
86
+ assert.equal(isPidAlive(pid), true, 'the broker is still alive after the live wake');
87
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,166 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-sdk-wiring.test.ts
2
+ //
3
+ // REAL-SDK regression tests for the latent broker gaps the viewer-reuse scout
4
+ // (node mq5thyli) found (findings-viewer-reuse.md §Q2). These deliberately drive
5
+ // the ACTUAL `@earendil-works/pi-coding-agent` SDK — NOT the `CRTR_BROKER_ENGINE`
6
+ // fake the lifecycle suite uses — because C3 and C4 slipped past the Phase-3 gate
7
+ // PRECISELY because that gate mocked the SDK out. Each test exercises the exact
8
+ // production wiring (`buildBrokerSession` / `makeBrokerUiContext`) and would FAIL
9
+ // against the pre-fix broker:
10
+ //
11
+ // C3 — the broker drives the SERVICES path, so an extension-registered custom
12
+ // model provider is registered into the ModelRegistry and resolves onto
13
+ // the session. Plain createAgentSession never calls registerProvider, so a
14
+ // custom-provider node would get NO model.
15
+ // C4 — a project AGENTS.md is injected into the assembled system prompt (the
16
+ // services path loads project context; 0.78.1 has no project-trust gate so
17
+ // context is unconditionally loaded — see buildBrokerSession's C4 note).
18
+ // C2 — with ZERO viewers attached, the broker UI context resolves confirm() /
19
+ // select() / input() / editor() to deny/cancel IMMEDIATELY (noOp), instead
20
+ // of relying on a per-dialog timeout and hanging the agent turn forever.
21
+ //
22
+ // These run offline (no network/auth): registration + system-prompt assembly +
23
+ // dialog routing are all local. No tmux, no broker process — direct unit drives.
24
+ import { test } from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join, dirname } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
31
+ import { buildBrokerSession, makeBrokerUiContext } from '../runtime/broker.js';
32
+ // The REAL engine, assembled from broker-sdk's static SDK re-exports — bypasses
33
+ // the CRTR_BROKER_ENGINE seam entirely so these tests can never accidentally hit
34
+ // the fake (the whole point: prove the wiring the mock hid).
35
+ const realEngine = {
36
+ createAgentSessionServices,
37
+ createAgentSessionFromServices,
38
+ SessionManager,
39
+ VERSION,
40
+ };
41
+ const HERE = dirname(fileURLToPath(import.meta.url));
42
+ const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
43
+ function cfg(cwd, extra = {}) {
44
+ return { cwd, extensionPaths: [], ...extra };
45
+ }
46
+ // ===========================================================================
47
+ // C3 — the SERVICES path registers an extension's custom model provider, so the
48
+ // broker resolves a model that plain createAgentSession (no registerProvider)
49
+ // would never see.
50
+ // ===========================================================================
51
+ test('C3 — services path registers an extension model provider; the broker session gets it', async () => {
52
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3-'));
53
+ try {
54
+ const { session, services } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C3_EXT], model: 'c3prov/c3model' }));
55
+ try {
56
+ // The extension's provider was registered into the SERVICES registry (this
57
+ // is the registerProvider step plain createAgentSession skips).
58
+ const found = services.modelRegistry.find('c3prov', 'c3model');
59
+ assert.ok(found, 'C3: extension-registered model is present in the services ModelRegistry');
60
+ assert.equal(found.provider, 'c3prov', 'C3: provider name round-trips');
61
+ assert.equal(found.id, 'c3model', 'C3: model id round-trips');
62
+ // …and the broker resolved it onto the session AGAINST the services registry
63
+ // (a fresh ModelRegistry would have returned undefined → no model).
64
+ assert.equal(session.model?.id, 'c3model', 'C3: the broker session got the custom-provider model');
65
+ }
66
+ finally {
67
+ session.dispose();
68
+ }
69
+ }
70
+ finally {
71
+ rmSync(cwd, { recursive: true, force: true });
72
+ }
73
+ });
74
+ // ===========================================================================
75
+ // C4 — project context (AGENTS.md) is injected into the system prompt. The
76
+ // services path loads project context files; on the 0.78.1 pin there is no
77
+ // project-trust gate, so they load unconditionally (and buildBrokerSession does
78
+ // NOT re-introduce the CLI's headless trust resolver, which would drop them).
79
+ // ===========================================================================
80
+ test('C4 — a project AGENTS.md is injected into the assembled system prompt', async () => {
81
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-c4-'));
82
+ try {
83
+ const marker = 'C4_AGENTS_MARKER_' + Math.random().toString(36).slice(2);
84
+ writeFileSync(join(cwd, 'AGENTS.md'), `# Project rules\n\n${marker}\n`);
85
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd));
86
+ try {
87
+ assert.ok(session.systemPrompt.includes(marker), 'C4: project AGENTS.md content is present in the assembled system prompt');
88
+ }
89
+ finally {
90
+ session.dispose();
91
+ }
92
+ }
93
+ finally {
94
+ rmSync(cwd, { recursive: true, force: true });
95
+ }
96
+ });
97
+ // ===========================================================================
98
+ // C2 — zero-viewer dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
99
+ // hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
100
+ // makeBrokerUiContext (the exact ExtensionUIContext the SDK hands extensions),
101
+ // with a controller() that reports zero viewers.
102
+ // ===========================================================================
103
+ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately (noOp, no hang)', async () => {
104
+ const ctx = makeBrokerUiContext({
105
+ controller: () => null, // ZERO viewers attached
106
+ forward: () => {
107
+ throw new Error('C2: must NOT forward a dialog when no controller is attached');
108
+ },
109
+ pending: new Map(),
110
+ });
111
+ // confirm() WITH a large timeout: the fix must resolve at once (deny), NOT after
112
+ // the timeout — the old timeout-reliant path would have waited 60s (or, with no
113
+ // timeout, hung forever). Race against a 2s "HANG" sentinel to make a regression
114
+ // a hard failure rather than a slow pass.
115
+ const start = Date.now();
116
+ const confirmed = await Promise.race([
117
+ ctx.confirm('proceed?', 'really?', { timeout: 60_000 }),
118
+ new Promise((r) => setTimeout(() => r('HANG'), 2_000)),
119
+ ]);
120
+ assert.equal(confirmed, false, 'C2: confirm() denies (false) with zero viewers');
121
+ assert.ok(Date.now() - start < 1_000, 'C2: resolved immediately, not after the 60s timeout');
122
+ // The other blocking dialogs cancel to undefined — including editor(), which
123
+ // takes NO opts at all (so it could never carry a timeout to lean on).
124
+ assert.equal(await ctx.select('pick', ['a', 'b']), undefined, 'C2: select() cancels (undefined)');
125
+ assert.equal(await ctx.input('name'), undefined, 'C2: input() cancels (undefined)');
126
+ assert.equal(await ctx.editor('edit'), undefined, 'C2: editor() cancels (undefined)');
127
+ });
128
+ // ===========================================================================
129
+ // M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
130
+ // forwarded to a controller that then DETACHES must NOT be cancelled: it stays
131
+ // pending so a brief detach/reattach (or a handoff to another controller) does
132
+ // not lose an answerable dialog. The broker-side default timeout is the ONLY
133
+ // non-answer resolution (proven here with a short per-dialog timeout standing in
134
+ // for the 120s default). Guards against regressing to the over-eager cancel.
135
+ // ===========================================================================
136
+ test('M2 — a forwarded dialog stays pending on controller detach, resolving only on the broker-side timeout', async () => {
137
+ const pending = new Map();
138
+ let attached = true;
139
+ const ctx = makeBrokerUiContext({
140
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
141
+ controller: () => (attached ? { id: 'c1' } : null),
142
+ forward: () => {
143
+ /* a real controller would receive the request over its socket */
144
+ },
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ pending: pending,
147
+ });
148
+ // Controller attached → confirm() forwards + registers a pending dialog (with the
149
+ // request retained for welcome.pending_dialog / re-route, T4) and does NOT
150
+ // resolve yet. A short per-dialog timeout stands in for the 120s broker default.
151
+ const p = ctx.confirm('proceed?', 'really?', { timeout: 80 });
152
+ assert.equal(pending.size, 1, 'M2: a forwarded dialog is pending while a controller is attached');
153
+ const entry = [...pending.values()][0];
154
+ assert.ok(entry.request, 'M2: the pending entry retains the request (welcome/re-route need it)');
155
+ assert.equal(entry.cancel, undefined, 'M2: there is no cancel-on-detach path anymore');
156
+ // Controller detaches → the dialog STAYS pending (the broker no longer cancels it).
157
+ attached = false;
158
+ assert.equal(pending.size, 1, 'M2: detach does NOT cancel the in-flight dialog');
159
+ // Only the broker-side timeout resolves it, to the SAFE default (deny).
160
+ const resolved = await Promise.race([
161
+ p,
162
+ new Promise((r) => setTimeout(() => r('HANG'), 2_000)),
163
+ ]);
164
+ assert.equal(resolved, false, 'M2: resolves to deny on the broker-side timeout, never hangs');
165
+ assert.equal(pending.size, 0, 'M2: the pending registry is drained after the timeout');
166
+ });
@@ -0,0 +1,102 @@
1
+ // Tests for the canvas-node inbox watcher pi extension, part 2 — the
2
+ // refresh-yield HOLD path and idle delivery. Split out of
3
+ // canvas-inbox-watcher.test.ts (see its header) so node:test's file-level
4
+ // parallelism applies; the tests and the scaffold are moved here UNCHANGED.
5
+ //
6
+ // Run with: node --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher-hold.test.ts
7
+ import { test, describe, before, after, afterEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdirSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
13
+ import { appendInbox } from '../feed/inbox.js';
14
+ import { createNode, setIntent } from '../canvas/canvas.js';
15
+ import { closeDb } from '../canvas/db.js';
16
+ // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
17
+ // resolve+seed tick, a read tick, and the debounce window before asserting.
18
+ const TICK_MS = 800;
19
+ const DEBOUNCE_MS = 1000;
20
+ const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 500;
21
+ let origHome;
22
+ let origNode;
23
+ const homes = [];
24
+ const disposers = [];
25
+ /** Point CRTR_HOME at a fresh temp canvas root and bind CRTR_NODE_ID. */
26
+ function freshNode(nodeId) {
27
+ const home = join(tmpdir(), `crtr-canvas-watcher-${Date.now()}-${Math.random().toString(36).slice(2)}`);
28
+ mkdirSync(home, { recursive: true });
29
+ homes.push(home);
30
+ process.env['CRTR_HOME'] = home;
31
+ process.env['CRTR_NODE_ID'] = nodeId;
32
+ }
33
+ function makeFakePi() {
34
+ const handlers = {};
35
+ return {
36
+ injected: [],
37
+ on(e, h) { handlers[e] = h; },
38
+ sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
39
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
40
+ };
41
+ }
42
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
43
+ before(() => {
44
+ origHome = process.env['CRTR_HOME'];
45
+ origNode = process.env['CRTR_NODE_ID'];
46
+ });
47
+ afterEach(() => {
48
+ while (disposers.length > 0)
49
+ disposers.pop()();
50
+ });
51
+ after(() => {
52
+ if (origHome === undefined)
53
+ delete process.env['CRTR_HOME'];
54
+ else
55
+ process.env['CRTR_HOME'] = origHome;
56
+ if (origNode === undefined)
57
+ delete process.env['CRTR_NODE_ID'];
58
+ else
59
+ process.env['CRTR_NODE_ID'] = origNode;
60
+ for (const h of homes) {
61
+ try {
62
+ rmSync(h, { recursive: true, force: true });
63
+ }
64
+ catch { /* noop */ }
65
+ }
66
+ });
67
+ describe('canvas inbox watcher — hold + idle delivery', () => {
68
+ test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
69
+ freshNode('node-refresh');
70
+ closeDb(); // rebind the canvas db to this test's fresh home
71
+ const meta = {
72
+ node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
73
+ cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
74
+ status: 'active', intent: 'refresh',
75
+ };
76
+ createNode(meta);
77
+ const pi = makeFakePi();
78
+ disposers.push(registerCanvasInboxWatcher(pi));
79
+ // Streaming (mid-turn) when a child finishes — normally this would steer.
80
+ pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
81
+ await wait(TICK_MS + 100);
82
+ appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
83
+ await wait(SETTLE_MS);
84
+ assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
85
+ // The fresh pi clears intent on boot; the held entry must then be delivered
86
+ // — the cursor was never advanced, so nothing is lost.
87
+ setIntent('node-refresh', null);
88
+ await wait(SETTLE_MS);
89
+ assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
90
+ });
91
+ test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
92
+ freshNode('node-idle');
93
+ const pi = makeFakePi();
94
+ disposers.push(registerCanvasInboxWatcher(pi));
95
+ // No agent_start fired → watcher treats the node as idle.
96
+ await wait(TICK_MS + 100);
97
+ appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
98
+ await wait(SETTLE_MS);
99
+ assert.equal(pi.injected.length, 1);
100
+ assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
101
+ });
102
+ });
@@ -4,6 +4,8 @@
4
4
  //
5
5
  // Focus: a finished node (push final → InboxEntry kind 'final') must STEER a
6
6
  // mid-stream subscriber, not queue behind its current turn as a follow-up.
7
+ // Part 2 — the refresh-yield HOLD path and idle delivery — lives in
8
+ // canvas-inbox-watcher-hold.test.ts (split for node:test file-level parallelism).
7
9
  import { test, describe, before, after, afterEach } from 'node:test';
8
10
  import assert from 'node:assert/strict';
9
11
  import { mkdirSync, rmSync } from 'node:fs';
@@ -11,8 +13,6 @@ import { tmpdir } from 'node:os';
11
13
  import { join } from 'node:path';
12
14
  import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
13
15
  import { appendInbox } from '../feed/inbox.js';
14
- import { createNode, setIntent } from '../canvas/canvas.js';
15
- import { closeDb } from '../canvas/db.js';
16
16
  // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
17
17
  // resolve+seed tick, a read tick, and the debounce window before asserting.
18
18
  const TICK_MS = 800;
@@ -88,38 +88,4 @@ describe('canvas inbox watcher — finished-node delivery', () => {
88
88
  assert.equal(pi.injected.length, 1);
89
89
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
90
90
  });
91
- test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
92
- freshNode('node-refresh');
93
- closeDb(); // rebind the canvas db to this test's fresh home
94
- const meta = {
95
- node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
96
- cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
97
- status: 'active', intent: 'refresh',
98
- };
99
- createNode(meta);
100
- const pi = makeFakePi();
101
- disposers.push(registerCanvasInboxWatcher(pi));
102
- // Streaming (mid-turn) when a child finishes — normally this would steer.
103
- pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
104
- await wait(TICK_MS + 100);
105
- appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
106
- await wait(SETTLE_MS);
107
- assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
108
- // The fresh pi clears intent on boot; the held entry must then be delivered
109
- // — the cursor was never advanced, so nothing is lost.
110
- setIntent('node-refresh', null);
111
- await wait(SETTLE_MS);
112
- assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
113
- });
114
- test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
115
- freshNode('node-idle');
116
- const pi = makeFakePi();
117
- disposers.push(registerCanvasInboxWatcher(pi));
118
- // No agent_start fired → watcher treats the node as idle.
119
- await wait(TICK_MS + 100);
120
- appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
121
- await wait(SETTLE_MS);
122
- assert.equal(pi.injected.length, 1);
123
- assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
124
- });
125
91
  });
@@ -134,13 +134,23 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
134
134
  // ASSERT 3 — EXCLUSIVE-SUBTREE OWNERSHIP: nothing ABOVE (A) and nothing
135
135
  // SIDEWAYS (S, G) was reaped. close(B) reaped EXACTLY B's own subtree.
136
136
  // ===================================================================
137
- // Ancestor A — fully untouched: still the live resident root, inbox intact.
137
+ // Ancestor A — lifecycle untouched (still the live resident root), but A is
138
+ // the SURVIVING manager of the close-root B, so the wake doctrine fix WAKES
139
+ // it: a "child closed" notice is fanned to a manager OUTSIDE the closing set
140
+ // (D-1 — previously A heard nothing and a dormant A could hang forever).
138
141
  {
139
142
  const a = h.node(A);
140
143
  assert.equal(a.status, 'active', 'A (ancestor) still active — untouched by the cascade');
141
144
  assert.equal(a.lifecycle, 'resident', 'A still resident');
142
145
  assert.equal(a.intent ?? null, null, 'A intent untouched');
143
- assert.deepEqual(h.inbox(A), aInboxBefore, "A's inbox is byte-for-byte intact across the close (cascade touches only closed nodes' inboxes)");
146
+ // A's PRIOR entries are preserved; exactly ONE new child-closed entry
147
+ // from the closed child B is appended (the doctrine wake).
148
+ const aInbox = h.inbox(A);
149
+ assert.deepEqual(aInbox.slice(0, aInboxBefore.length), aInboxBefore, "A's prior inbox entries are preserved across the close");
150
+ const added = aInbox.slice(aInboxBefore.length);
151
+ assert.equal(added.length, 1, 'exactly one new entry appended to the surviving manager A');
152
+ assert.equal(added[0].from, B, 'the new entry is from the closed child B');
153
+ assert.match(added[0].label, /closed/i, 'the new entry tells A its child B was closed');
144
154
  }
145
155
  // Sideways subtree S→G — wholly alive: status active AND real panes alive.
146
156
  for (const id of sideways) {
@@ -0,0 +1 @@
1
+ export {};