@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,198 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/frame-decoder-perf.test.ts
2
+ //
3
+ // Bug-regression suite for the FrameDecoder O(n²) typing-lag bug (2026-06-09).
4
+ //
5
+ // OBSERVED BUG: typing in `crtr attach` lagged after the headless-broker change.
6
+ // Root cause #1: FrameDecoder.push() did `this.buf += str` and re-scanned the
7
+ // WHOLE accumulated buffer (`this.buf.indexOf('\n')` + Buffer.byteLength over
8
+ // the consumed prefix) on every ~64 KiB socket chunk, so one large frame (the
9
+ // multi-MiB `welcome` snapshot, a big tool result) cost O(frame × chunks) —
10
+ // measured ~254 ms of event-loop stall decoding a 16 MiB frame, with the stall
11
+ // growing ~5× per size doubling. The decoder runs on BOTH the viewer and broker
12
+ // read paths, so the stall blocked keystroke handling directly.
13
+ //
14
+ // The fix holds the carried partial line as an array of chunk strings and scans
15
+ // each incoming chunk exactly once — amortized O(total bytes). Same 16 MiB frame:
16
+ // ~12 ms.
17
+ //
18
+ // These tests lock in (a) behavioral equivalence with the old decoder across
19
+ // randomized chunkings — frames, blank-line skip, malformed-JSON drop, multibyte
20
+ // splits, and both FrameOverflowError caps with identical byte accounting — and
21
+ // (b) the linear growth curve, so the quadratic shape cannot silently return.
22
+ import { test } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { StringDecoder } from 'node:string_decoder';
25
+ import { FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, } from '../runtime/broker-protocol.js';
26
+ // ---------------------------------------------------------------------------
27
+ // The PRE-FIX decoder, verbatim (git ed49e60), as the equivalence oracle.
28
+ // ---------------------------------------------------------------------------
29
+ class ReferenceFrameDecoder {
30
+ caps;
31
+ buf = '';
32
+ bufBytes = 0;
33
+ utf8 = new StringDecoder('utf8');
34
+ constructor(caps) {
35
+ this.caps = caps;
36
+ }
37
+ push(chunk) {
38
+ const str = typeof chunk === 'string' ? chunk : this.utf8.write(chunk);
39
+ this.buf += str;
40
+ this.bufBytes += Buffer.byteLength(str);
41
+ if (this.bufBytes > this.caps.maxTotalBytes) {
42
+ throw new FrameOverflowError('total', this.bufBytes, this.caps.maxTotalBytes);
43
+ }
44
+ const out = [];
45
+ let nl;
46
+ while ((nl = this.buf.indexOf('\n')) >= 0) {
47
+ const consumed = this.buf.slice(0, nl + 1);
48
+ this.bufBytes -= Buffer.byteLength(consumed);
49
+ const line = this.buf.slice(0, nl).trim();
50
+ this.buf = this.buf.slice(nl + 1);
51
+ if (line === '')
52
+ continue;
53
+ try {
54
+ out.push(JSON.parse(line));
55
+ }
56
+ catch {
57
+ /* drop malformed */
58
+ }
59
+ }
60
+ if (this.bufBytes > this.caps.maxLineBytes) {
61
+ throw new FrameOverflowError('line', this.bufBytes, this.caps.maxLineBytes);
62
+ }
63
+ return out;
64
+ }
65
+ }
66
+ // Deterministic PRNG so a failure reproduces.
67
+ let seed = 0xc0ffee;
68
+ const rnd = () => {
69
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
70
+ return seed / 0x7fffffff;
71
+ };
72
+ const randInt = (lo, hi) => lo + Math.floor(rnd() * (hi - lo + 1));
73
+ const MULTIBYTE = ['é', '漢', '🎉', '𝕏', 'ü', '한'];
74
+ const randText = (len) => {
75
+ let s = '';
76
+ while (s.length < len) {
77
+ s += rnd() < 0.2 ? MULTIBYTE[randInt(0, MULTIBYTE.length - 1)] : String.fromCharCode(randInt(97, 122));
78
+ }
79
+ return s;
80
+ };
81
+ function run(dec, stream, cuts) {
82
+ const frames = [];
83
+ let prev = 0;
84
+ try {
85
+ for (const cut of [...cuts, stream.length]) {
86
+ if (cut <= prev)
87
+ continue;
88
+ frames.push(...dec.push(stream.subarray(prev, cut)));
89
+ prev = cut;
90
+ }
91
+ return { frames, err: null };
92
+ }
93
+ catch (e) {
94
+ if (e instanceof FrameOverflowError)
95
+ return { frames, err: `${e.kind}:${e.bytes}:${e.cap}` };
96
+ throw e;
97
+ }
98
+ }
99
+ test('FrameDecoder — behavior-equivalent to the pre-fix decoder across randomized chunkings (frames, blank lines, malformed JSON, multibyte splits, both overflow caps)', () => {
100
+ for (let t = 0; t < 1500; t++) {
101
+ // Small caps in ~30% of trials exercise both FrameOverflowError kinds with
102
+ // identical byte accounting; generous caps exercise the happy path.
103
+ const caps = rnd() < 0.3
104
+ ? { maxLineBytes: randInt(50, 1500), maxTotalBytes: randInt(100, 4000) }
105
+ : { maxLineBytes: 1 << 20, maxTotalBytes: 1 << 21 };
106
+ const pieces = [];
107
+ for (let i = 0, n = randInt(1, 12); i < n; i++) {
108
+ const kind = rnd();
109
+ if (kind < 0.55)
110
+ pieces.push(JSON.stringify({ type: 'x', t: randText(randInt(0, 2000)) }) + '\n');
111
+ else if (kind < 0.7)
112
+ pieces.push('\n'); // blank-line skip
113
+ else if (kind < 0.8)
114
+ pieces.push(' \n'); // whitespace-only skip
115
+ else if (kind < 0.9)
116
+ pieces.push('{not json' + randText(randInt(0, 50)) + '\n'); // malformed drop
117
+ else
118
+ pieces.push(randText(randInt(0, 300))); // trailing partial, no newline
119
+ }
120
+ const stream = Buffer.from(pieces.join(''));
121
+ // Byte-level cut points — these can and do split multibyte chars.
122
+ const cuts = Array.from({ length: randInt(0, 20) }, () => randInt(0, stream.length)).sort((a, b) => a - b);
123
+ const expected = run(new ReferenceFrameDecoder(caps), stream, cuts);
124
+ const actual = run(new FrameDecoder(caps), stream, cuts);
125
+ assert.deepEqual(actual, expected, `trial ${t}: caps=${JSON.stringify(caps)} cuts=${JSON.stringify(cuts)} stream=${JSON.stringify(stream.toString('utf8').slice(0, 300))}`);
126
+ }
127
+ });
128
+ test('FrameDecoder — mixed string/Buffer pushes stay equivalent', () => {
129
+ for (let t = 0; t < 300; t++) {
130
+ const caps = { maxLineBytes: 1 << 20, maxTotalBytes: 1 << 21 };
131
+ const ref = new ReferenceFrameDecoder(caps);
132
+ const dec = new FrameDecoder(caps);
133
+ const refF = [];
134
+ const decF = [];
135
+ for (let i = 0, n = randInt(1, 10); i < n; i++) {
136
+ const s = rnd() < 0.5 ? JSON.stringify({ i, t: randText(randInt(0, 200)) }) + '\n' : randText(randInt(0, 100));
137
+ const input = rnd() < 0.5 ? s : Buffer.from(s);
138
+ refF.push(...ref.push(input));
139
+ decF.push(...dec.push(input));
140
+ }
141
+ assert.deepEqual(decF, refF, `mixed trial ${t}`);
142
+ }
143
+ });
144
+ test('FrameDecoder — a frame exactly AT each cap passes; one byte over throws (cap accounting exact)', () => {
145
+ // line cap: an unterminated partial exactly at maxLineBytes is fine…
146
+ {
147
+ const caps = { maxLineBytes: 100, maxTotalBytes: 1000 };
148
+ const dec = new FrameDecoder(caps);
149
+ assert.deepEqual(dec.push('x'.repeat(100)), []);
150
+ // …and the 101st byte trips it, reporting the full buffered size.
151
+ assert.throws(() => dec.push('x'), (e) => e instanceof FrameOverflowError && e.kind === 'line' && e.bytes === 101 && e.cap === 100);
152
+ }
153
+ // total cap: carry + chunk exactly at maxTotalBytes is fine; one over throws.
154
+ {
155
+ const caps = { maxLineBytes: 1000, maxTotalBytes: 100 };
156
+ const dec = new FrameDecoder(caps);
157
+ assert.deepEqual(dec.push('x'.repeat(60)), []);
158
+ assert.deepEqual(dec.push('x'.repeat(40 - 60 > 0 ? 0 : 40)), []); // 60 + 40 = 100, at cap
159
+ assert.throws(() => dec.push('x'), (e) => e instanceof FrameOverflowError && e.kind === 'total' && e.bytes === 101 && e.cap === 100);
160
+ }
161
+ // multibyte accounting: bytes, not chars (é = 2 bytes).
162
+ {
163
+ const caps = { maxLineBytes: 10, maxTotalBytes: 1000 };
164
+ const dec = new FrameDecoder(caps);
165
+ assert.throws(() => dec.push('é'.repeat(6)), // 12 bytes > 10, only 6 chars
166
+ (e) => e instanceof FrameOverflowError && e.kind === 'line' && e.bytes === 12);
167
+ }
168
+ });
169
+ test('FrameDecoder — large-frame decode is linear, not quadratic (the observed attach-lag regression)', () => {
170
+ // Decode a single N-MiB frame in 64 KiB chunks; compare per-MiB cost at 2 MiB
171
+ // vs 8 MiB. Quadratic shape ⇒ per-MiB cost grows ~4× per 4× size (measured
172
+ // 5.5×+ pre-fix); linear stays ~flat. Threshold 3× is far above linear noise
173
+ // and far below the quadratic signature, so this fails ONLY on a complexity
174
+ // regression, not on a slow CI box.
175
+ const CHUNK = 64 * 1024;
176
+ const decodeMs = (mib) => {
177
+ const overhead = Buffer.byteLength('{"type":"welcome","blob":""}\n');
178
+ const frame = Buffer.from(`{"type":"welcome","blob":"${'x'.repeat(mib * 1024 * 1024 - overhead)}"}\n`);
179
+ let best = Infinity;
180
+ for (let rep = 0; rep < 3; rep++) {
181
+ const dec = new FrameDecoder(CLIENT_READ_CAPS);
182
+ const t0 = performance.now();
183
+ let n = 0;
184
+ for (let off = 0; off < frame.length; off += CHUNK) {
185
+ n += dec.push(frame.subarray(off, Math.min(off + CHUNK, frame.length))).length;
186
+ }
187
+ const ms = performance.now() - t0;
188
+ assert.equal(n, 1, 'decoded exactly one frame');
189
+ if (ms < best)
190
+ best = ms;
191
+ }
192
+ return best;
193
+ };
194
+ decodeMs(1); // warmup
195
+ const perMibSmall = decodeMs(2) / 2;
196
+ const perMibLarge = decodeMs(8) / 8;
197
+ assert.ok(perMibLarge < perMibSmall * 3, `per-MiB decode cost grew superlinearly: ${perMibSmall.toFixed(2)}ms/MiB @2MiB → ${perMibLarge.toFixed(2)}ms/MiB @8MiB (quadratic regression)`);
198
+ });
@@ -0,0 +1,43 @@
1
+ import { type Socket } from 'node:net';
2
+ import type { Harness } from './harness.js';
3
+ import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
4
+ import { type BrokerToClient, type ClientToBroker, type ClientRole, type WelcomeFrame } from '../../runtime/broker-protocol.js';
5
+ export declare const delay: (ms: number) => Promise<void>;
6
+ export declare const tok: (s: string) => string;
7
+ export declare const frameHas: (f: BrokerToClient, token: string) => boolean;
8
+ export declare function brokerLogText(h: Harness, id: string): string;
9
+ /** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
10
+ * check). Exit-non-zero with empty stdout means "no holders". */
11
+ export declare function lsofHolders(path: string): number[] | null;
12
+ export interface Attached {
13
+ client: ViewSocketClient;
14
+ frames: BrokerToClient[];
15
+ welcome: WelcomeFrame;
16
+ send(frame: ClientToBroker): void;
17
+ waitFrame(pred: (f: BrokerToClient) => boolean, label: string, timeoutMs?: number): Promise<BrokerToClient>;
18
+ close(): void;
19
+ }
20
+ export interface RawClient {
21
+ socket: Socket;
22
+ frames: BrokerToClient[];
23
+ closed: () => boolean;
24
+ send(frame: ClientToBroker): void;
25
+ writeRaw(data: Buffer | string): void;
26
+ waitClosed(label: string, timeoutMs?: number): Promise<void>;
27
+ close(): void;
28
+ }
29
+ export interface AttachKit {
30
+ /** Connect a ViewSocketClient to a node's running broker, hello, await welcome. */
31
+ attach(id: string, role: ClientRole, clientId: string): Promise<Attached>;
32
+ /** Attach (as `role`) and retry until the welcome satisfies `pred`. */
33
+ attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
34
+ /** A raw node:net peer. read:false leaves the socket PAUSED (G8 stalled viewer). */
35
+ connectRaw(id: string, opts: {
36
+ read: boolean;
37
+ }): Promise<RawClient>;
38
+ /** Close every client opened since the last closeAll — wire into afterEach. */
39
+ closeAll(): void;
40
+ }
41
+ /** Build the attach kit against a lazily-resolved harness (the harness is
42
+ * created in the file's before() hook, after the kit is constructed). */
43
+ export declare function createAttachKit(getH: () => Harness): AttachKit;
@@ -0,0 +1,178 @@
1
+ // helpers/broker-clients.ts — the shared attach-client kit for the broker
2
+ // lifecycle/attach-gate suite (plan T8/T11). Extracted VERBATIM from the original
3
+ // single-file broker-lifecycle.test.ts when it was split into per-area files so
4
+ // node:test's file-level parallelism applies (each file holds its own isolated
5
+ // harness); the helpers are unchanged in behavior.
6
+ //
7
+ // The kit wraps the PRODUCTION attach client (pure node:net + the broker codec,
8
+ // no TUI) as the in-test controller/observer so the G1–G9 gate exercises the REAL
9
+ // client too (§0 one-writer: a viewer holds ONLY a socket), plus a raw node:net
10
+ // peer for the cases where the client lifecycle is awkward (G7 oversized line,
11
+ // G8 stalled viewer).
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createConnection } from 'node:net';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
17
+ import { CLIENT_READ_CAPS, FrameDecoder, encodeFrame, } from '../../runtime/broker-protocol.js';
18
+ export const delay = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ export const tok = (s) => `${s}-${Math.random().toString(36).slice(2, 8)}`;
20
+ export const frameHas = (f, token) => JSON.stringify(f).includes(token);
21
+ export function brokerLogText(h, id) {
22
+ try {
23
+ return readFileSync(join(h.home, 'nodes', id, 'job', 'broker.log'), 'utf8');
24
+ }
25
+ catch {
26
+ return '';
27
+ }
28
+ }
29
+ /** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
30
+ * check). Exit-non-zero with empty stdout means "no holders". */
31
+ export function lsofHolders(path) {
32
+ if (spawnSync('which', ['lsof'], { stdio: 'ignore' }).status !== 0)
33
+ return null;
34
+ const out = (spawnSync('lsof', ['-t', '--', path], { encoding: 'utf8' }).stdout ?? '').trim();
35
+ if (out === '')
36
+ return [];
37
+ return out
38
+ .split('\n')
39
+ .map((l) => Number(l.trim()))
40
+ .filter((n) => Number.isFinite(n));
41
+ }
42
+ /** Build the attach kit against a lazily-resolved harness (the harness is
43
+ * created in the file's before() hook, after the kit is constructed). */
44
+ export function createAttachKit(getH) {
45
+ const liveClients = [];
46
+ // Connect a ViewSocketClient to a node's running broker, hello, await welcome.
47
+ // awaitBoot returns once the boot proof is written — which is BEFORE the broker's
48
+ // server.listen() binds view.sock — so a fresh attach can momentarily race the
49
+ // bind; retry the connect on BrokerUnavailable until it is listening.
50
+ async function attach(id, role, clientId) {
51
+ const h = getH();
52
+ await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
53
+ const frames = [];
54
+ let client;
55
+ for (let attempt = 0;; attempt++) {
56
+ client = new ViewSocketClient(id);
57
+ client.on('frame', (f) => frames.push(f));
58
+ try {
59
+ await new Promise((resolve, reject) => {
60
+ client.once('connect', resolve);
61
+ client.once('error', reject);
62
+ client.connect();
63
+ });
64
+ break;
65
+ }
66
+ catch (err) {
67
+ client.close();
68
+ if (attempt >= 30)
69
+ throw err;
70
+ await delay(100);
71
+ }
72
+ }
73
+ client.on('error', () => { }); // post-connect error sink (never throw uncaught)
74
+ // Register cleanup the instant the socket is connected — BEFORE the hello/welcome
75
+ // round-trip — so a welcome timeout cannot leak a connected socket past the test.
76
+ liveClients.push({ close: () => client.close() });
77
+ const waitFrame = (pred, label, timeoutMs = 15_000) => h.waitFor(() => frames.find(pred) ?? null, { label, timeoutMs });
78
+ client.send({ type: 'hello', role, client_id: clientId });
79
+ let welcome;
80
+ try {
81
+ welcome = (await waitFrame((f) => f.type === 'welcome', `welcome for ${clientId}`));
82
+ }
83
+ catch (err) {
84
+ client.close();
85
+ throw err;
86
+ }
87
+ return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
88
+ }
89
+ // Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
90
+ // observable lands a beat after a prior action (G3 snapshot accrual, G5b control
91
+ // handoff after a controller detaches). Deterministic: it polls an observable.
92
+ async function attachUntil(id, role, clientId, pred, label) {
93
+ for (let attempt = 0;; attempt++) {
94
+ const a = await attach(id, role, `${clientId}-${attempt}`);
95
+ if (pred(a))
96
+ return a;
97
+ a.close();
98
+ if (attempt >= 40)
99
+ throw new Error(`attachUntil timed out: ${label}`);
100
+ await delay(150);
101
+ }
102
+ }
103
+ // A raw node:net peer. read:true decodes incoming frames; read:false leaves the
104
+ // socket PAUSED (no 'data' listener, never resumed) so it never drains — the
105
+ // stalled viewer (G8) whose backlog the broker must shed at the HWM.
106
+ async function connectRaw(id, opts) {
107
+ const h = getH();
108
+ await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
109
+ const frames = [];
110
+ const decoder = new FrameDecoder(CLIENT_READ_CAPS);
111
+ let isClosed = false;
112
+ const socket = await new Promise((resolve, reject) => {
113
+ const s = createConnection(h.brokerSock(id));
114
+ s.once('connect', () => resolve(s));
115
+ s.once('error', reject);
116
+ });
117
+ socket.on('close', () => {
118
+ isClosed = true;
119
+ });
120
+ socket.on('error', () => {
121
+ /* close follows */
122
+ });
123
+ if (opts.read) {
124
+ socket.on('data', (chunk) => {
125
+ try {
126
+ for (const f of decoder.push(chunk))
127
+ frames.push(f);
128
+ }
129
+ catch {
130
+ /* a client-side overflow is irrelevant here */
131
+ }
132
+ });
133
+ }
134
+ const rc = {
135
+ socket,
136
+ frames,
137
+ closed: () => isClosed,
138
+ send: (f) => {
139
+ try {
140
+ socket.write(encodeFrame(f));
141
+ }
142
+ catch {
143
+ /* dead */
144
+ }
145
+ },
146
+ writeRaw: (d) => {
147
+ try {
148
+ socket.write(d);
149
+ }
150
+ catch {
151
+ /* dead */
152
+ }
153
+ },
154
+ waitClosed: (label, timeoutMs = 15_000) => h.waitFor(() => (isClosed ? true : null), { label, timeoutMs }).then(() => undefined),
155
+ close: () => {
156
+ try {
157
+ socket.destroy();
158
+ }
159
+ catch {
160
+ /* ignore */
161
+ }
162
+ },
163
+ };
164
+ liveClients.push(rc);
165
+ return rc;
166
+ }
167
+ function closeAll() {
168
+ for (const c of liveClients.splice(0)) {
169
+ try {
170
+ c.close();
171
+ }
172
+ catch {
173
+ /* already gone */
174
+ }
175
+ }
176
+ }
177
+ return { attach, attachUntil, connectRaw, closeAll };
178
+ }
@@ -43,6 +43,8 @@ export interface Harness {
43
43
  spawnRoot(task: string, o?: SpawnOpts): string;
44
44
  spawnChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
45
45
  cli(nodeId: string | null, args: string[]): CliResult;
46
+ spawnHeadlessChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
47
+ spawnHeadlessChildNoBoot(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
46
48
  turn(nodeId: string, text?: string): Promise<void>;
47
49
  stop(nodeId: string, reason?: 'stop' | 'length' | 'aborted' | 'error'): Promise<void>;
48
50
  finish(nodeId: string, finalText: string): Promise<void>;
@@ -65,6 +67,13 @@ export interface Harness {
65
67
  paneAlive(nodeId: string): boolean;
66
68
  inbox(nodeId: string): InboxEntry[];
67
69
  injected(nodeId: string): Injected[];
70
+ fakeCmd(nodeId: string, cmd: Record<string, unknown>): void;
71
+ dialogResults(nodeId: string): {
72
+ resolved: unknown;
73
+ ms: number;
74
+ }[];
75
+ brokerSock(nodeId: string): string;
76
+ bootCount(nodeId: string): number;
68
77
  subscribers(nodeId: string): {
69
78
  node_id: string;
70
79
  active: boolean;
@@ -36,6 +36,11 @@ const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__/help
36
36
  const CROUTER = join(HERE, '..', '..', '..', '..'); // package root
37
37
  const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
38
38
  const FAKE_PI_HOST = join(HERE, '..', 'fixtures', 'fake-pi-host.ts');
39
+ // The in-process fake BrokerEngine (the SDK analog of the fake-pi vehicle). The
40
+ // REAL broker loads it via the CRTR_BROKER_ENGINE seam (broker-sdk.ts). Point at
41
+ // the absolute `.ts` path — the broker runs under tsx (see NODE_OPTIONS below),
42
+ // which resolves it.
43
+ const FAKE_ENGINE = join(HERE, '..', 'fixtures', 'fake-engine.ts');
39
44
  const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
40
45
  // A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
41
46
  // seam. Absolute paths so it works regardless of the spawned window's cwd.
@@ -53,6 +58,7 @@ const CANVAS_ENV_KEYS = [
53
58
  'CRTR_NODE_ID',
54
59
  'CRTR_HOME',
55
60
  'CRTR_ROOT_SESSION',
61
+ 'CRTR_SUBTREE',
56
62
  'CRTR_NODE_SESSION',
57
63
  'CRTR_PARENT_NODE_ID',
58
64
  'CRTR_FRONT_DOOR',
@@ -61,6 +67,10 @@ const CANVAS_ENV_KEYS = [
61
67
  'CRTR_LIFECYCLE',
62
68
  'CRTR_NODE_CWD',
63
69
  'CRTR_PI_BINARY',
70
+ // Scrubbed then re-added (controlled) in childEnv, mirroring CRTR_PI_BINARY —
71
+ // so the headless-broker seam can't leak the REAL engine into the isolated
72
+ // test, and the CLI subprocess that spawns a broker carries the FAKE one.
73
+ 'CRTR_BROKER_ENGINE',
64
74
  'TMUX',
65
75
  'TMUX_PANE',
66
76
  ];
@@ -99,7 +109,18 @@ export async function createHarness(opts = {}) {
99
109
  const origHome = process.env['CRTR_HOME'];
100
110
  const origPiBinary = process.env['CRTR_PI_BINARY'];
101
111
  const origNodeSession = process.env['CRTR_NODE_SESSION'];
102
- const home = mkdtempSync(join(tmpdir(), 'crtr-harness-home-'));
112
+ const origNodeOptions = process.env['NODE_OPTIONS'];
113
+ const origBrokerEngine = process.env['CRTR_BROKER_ENGINE'];
114
+ // The headless broker binds a unix socket at <home>/nodes/<id>/view.sock. A
115
+ // unix socket path (sun_path) has a ~104-char OS limit, and macOS's per-user
116
+ // $TMPDIR (/var/folders/<…>/T/, ~49 chars) alone pushes that path past the
117
+ // limit — server.listen() then fails EINVAL and the broker's loop drains
118
+ // (boots, never serves). NOT a production concern (~/.crouter/canvas/… is
119
+ // short); a test-only artifact of the long temp base. Use a SHORT base + short
120
+ // prefix for the canvas home so broker sockets fit. tmpHome (HOME) hosts no
121
+ // socket, so it can stay on the standard temp base.
122
+ const shortTmpBase = existsSync('/tmp') ? '/tmp' : tmpdir();
123
+ const home = mkdtempSync(join(shortTmpBase, 'crh-'));
103
124
  const tmpHome = mkdtempSync(join(tmpdir(), 'crtr-harness-HOME-'));
104
125
  const session = `${opts.sessionPrefix ?? 'crtr-harness'}-${process.pid}-${Date.now().toString(36)}`;
105
126
  // The harness reads/writes the isolated canvas in-process. CRTR_PI_BINARY in
@@ -108,7 +129,32 @@ export async function createHarness(opts = {}) {
108
129
  process.env['CRTR_HOME'] = home;
109
130
  process.env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
110
131
  delete process.env['CRTR_NODE_SESSION'];
132
+ // The headless broker (host.ts) launches `node <broker-cli.js> <id>` with a
133
+ // plain `process.execPath` (no tsx) and resolveBrokerEntry returns a `.js`
134
+ // path that, under the src test tree, only exists as `.ts`. Putting
135
+ // `--import <tsx/esm>` in the broker's NODE_OPTIONS makes the spawned broker
136
+ // run under tsx (resolving broker-cli.js→.ts AND the `.ts` fake engine). We set
137
+ // it on OUR process.env so it propagates to BOTH broker-launch callers: the
138
+ // in-process daemon revive (headlessBrokerHost.launch spreads {...process.env})
139
+ // and the initial `node new --headless` CLI subprocess (cleanBaseEnv copies it,
140
+ // since NODE_OPTIONS is deliberately NOT in CANVAS_ENV_KEYS). NODE_OPTIONS is
141
+ // read at process START, so setting it on the already-running harness does NOT
142
+ // re-trigger tsx here — it only propagates to spawned children (verified).
143
+ process.env['NODE_OPTIONS'] = [process.env['NODE_OPTIONS'], `--import ${TSX_ESM}`]
144
+ .filter((s) => s !== undefined && s !== '')
145
+ .join(' ');
146
+ process.env['CRTR_BROKER_ENGINE'] = FAKE_ENGINE;
111
147
  closeDb();
148
+ // Neutralize ensureDaemon (spawn.ts calls it on every `node new`): write a
149
+ // fake-live-daemon pidfile pointing at OUR (always-alive) pid, so
150
+ // isDaemonRunning() is true and no REAL crtrd is spawned against the isolated
151
+ // home. Required now that the broker's NODE_OPTIONS=--import tsx propagates to
152
+ // the crtrd spawn (pre-broker, the tsx-less `node crtrd-cli.js` spawn silently
153
+ // failed, so no daemon ever started); a stray real daemon would race the
154
+ // in-process superviseTick + the fixed-clock crash/grace scenarios. The harness
155
+ // drives the daemon pass itself via `tick()`. (fakeLiveDaemon pattern,
156
+ // home-session.test.ts.) home/ is rmSync'd in dispose.
157
+ writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
112
158
  // Pre-create the isolated session on the DEFAULT server so teardown always
113
159
  // has a target and `node new`'s ensureSession no-ops.
114
160
  // ISOLATION ASSUMPTION (see header + MINOR-6): isolation is by SESSION NAME on
@@ -136,6 +182,7 @@ export async function createHarness(opts = {}) {
136
182
  e['HOME'] = tmpHome;
137
183
  e['CRTR_NODE_SESSION'] = session;
138
184
  e['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
185
+ e['CRTR_BROKER_ENGINE'] = FAKE_ENGINE;
139
186
  if (nodeId !== null)
140
187
  e['CRTR_NODE_ID'] = nodeId;
141
188
  return e;
@@ -258,6 +305,42 @@ export async function createHarness(opts = {}) {
258
305
  await harness.awaitBoot(childId);
259
306
  return childId;
260
307
  },
308
+ async spawnHeadlessChild(parentId, task, o = {}) {
309
+ const before = new Set(nodeDirs());
310
+ const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER, '--headless'];
311
+ if (o.kind)
312
+ args.push('--kind', o.kind);
313
+ if (o.mode)
314
+ args.push('--mode', o.mode);
315
+ const res = cli(parentId, args);
316
+ if (res.code !== 0) {
317
+ throw new Error(`spawnHeadlessChild(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
318
+ }
319
+ const added = nodeDirs().filter((d) => !before.has(d));
320
+ if (added.length !== 1) {
321
+ throw new Error(`spawnHeadlessChild: expected exactly 1 new node dir, got [${added.join(', ')}]`);
322
+ }
323
+ const childId = added[0];
324
+ await harness.awaitBoot(childId);
325
+ return childId;
326
+ },
327
+ async spawnHeadlessChildNoBoot(parentId, task, o = {}) {
328
+ const before = new Set(nodeDirs());
329
+ const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER, '--headless'];
330
+ if (o.kind)
331
+ args.push('--kind', o.kind);
332
+ if (o.mode)
333
+ args.push('--mode', o.mode);
334
+ const res = cli(parentId, args);
335
+ if (res.code !== 0) {
336
+ throw new Error(`spawnHeadlessChildNoBoot(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
337
+ }
338
+ const added = nodeDirs().filter((d) => !before.has(d));
339
+ if (added.length !== 1) {
340
+ throw new Error(`spawnHeadlessChildNoBoot: expected exactly 1 new node dir, got [${added.join(', ')}]`);
341
+ }
342
+ return added[0];
343
+ },
261
344
  cli,
262
345
  async turn(nodeId, text = '') {
263
346
  const base = eventCount(nodeId, 'agent_end');
@@ -367,6 +450,25 @@ export async function createHarness(opts = {}) {
367
450
  return readInboxSince(nodeId);
368
451
  },
369
452
  injected,
453
+ fakeCmd(nodeId, cmd) {
454
+ sendCmd(nodeId, cmd);
455
+ },
456
+ dialogResults(nodeId) {
457
+ return readLines(join(nodeDir(nodeId), 'fake-pi.dialog.jsonl'))
458
+ .map((l) => {
459
+ try {
460
+ return JSON.parse(l);
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ })
466
+ .filter((x) => x !== null);
467
+ },
468
+ brokerSock(nodeId) {
469
+ return join(nodeDir(nodeId), 'view.sock');
470
+ },
471
+ bootCount,
370
472
  subscribers(nodeId) {
371
473
  closeDb();
372
474
  return subscribersOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
@@ -400,6 +502,14 @@ export async function createHarness(opts = {}) {
400
502
  delete process.env['CRTR_NODE_SESSION'];
401
503
  else
402
504
  process.env['CRTR_NODE_SESSION'] = origNodeSession;
505
+ if (origNodeOptions === undefined)
506
+ delete process.env['NODE_OPTIONS'];
507
+ else
508
+ process.env['NODE_OPTIONS'] = origNodeOptions;
509
+ if (origBrokerEngine === undefined)
510
+ delete process.env['CRTR_BROKER_ENGINE'];
511
+ else
512
+ process.env['CRTR_BROKER_ENGINE'] = origBrokerEngine;
403
513
  },
404
514
  };
405
515
  return harness;