@crouton-kit/crouter 0.3.18 → 0.3.19

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 (257) 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/orchestrator.md +1 -1
  12. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  13. package/dist/builtin-personas/lifecycle/resident.md +1 -1
  14. package/dist/builtin-personas/lifecycle/terminal.md +5 -2
  15. package/dist/builtin-personas/orchestration-kernel.md +15 -24
  16. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  17. package/dist/builtin-personas/runtime-base.md +6 -3
  18. package/dist/builtin-personas/spec/orchestrator.md +1 -1
  19. package/dist/builtin-personas/waiting.md +8 -0
  20. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
  21. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
  22. package/dist/builtin-views/_lib/states.mjs +161 -0
  23. package/dist/builtin-views/canvas/client.mjs +303 -0
  24. package/dist/builtin-views/canvas/view.mjs +576 -0
  25. package/dist/builtin-views/git-pr/client.mjs +440 -0
  26. package/dist/builtin-views/git-pr/view.mjs +675 -0
  27. package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
  28. package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
  29. package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
  30. package/dist/builtin-views/inbox/view.mjs +889 -0
  31. package/dist/builtin-views/linkedin/client.mjs +610 -0
  32. package/dist/builtin-views/linkedin/view.mjs +1171 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +2 -0
  34. package/dist/clients/attach/attach-cmd.js +354 -0
  35. package/dist/clients/attach/chat-view.d.ts +77 -0
  36. package/dist/clients/attach/chat-view.js +450 -0
  37. package/dist/clients/attach/clipboard-image.d.ts +16 -0
  38. package/dist/clients/attach/clipboard-image.js +113 -0
  39. package/dist/clients/attach/config-load.d.ts +31 -0
  40. package/dist/clients/attach/config-load.js +113 -0
  41. package/dist/clients/attach/extension-dialogs.d.ts +29 -0
  42. package/dist/clients/attach/extension-dialogs.js +101 -0
  43. package/dist/clients/attach/input-controller.d.ts +54 -0
  44. package/dist/clients/attach/input-controller.js +204 -0
  45. package/dist/clients/attach/slash-commands.d.ts +36 -0
  46. package/dist/clients/attach/slash-commands.js +200 -0
  47. package/dist/clients/attach/view-socket.d.ts +48 -0
  48. package/dist/clients/attach/view-socket.js +126 -0
  49. package/dist/commands/attention.js +3 -3
  50. package/dist/commands/canvas-prune.js +1 -1
  51. package/dist/commands/daemon.js +3 -2
  52. package/dist/commands/human/prompts.js +1 -1
  53. package/dist/commands/human/queue.js +43 -8
  54. package/dist/commands/human/shared.d.ts +2 -0
  55. package/dist/commands/memory/find.d.ts +1 -0
  56. package/dist/commands/memory/find.js +180 -0
  57. package/dist/commands/memory/lint.d.ts +1 -0
  58. package/dist/commands/memory/lint.js +140 -0
  59. package/dist/commands/memory/list.d.ts +1 -0
  60. package/dist/commands/memory/list.js +79 -0
  61. package/dist/commands/memory/read.js +103 -0
  62. package/dist/commands/memory/shared.d.ts +30 -0
  63. package/dist/commands/memory/shared.js +122 -0
  64. package/dist/commands/memory/write.d.ts +1 -0
  65. package/dist/commands/memory/write.js +85 -0
  66. package/dist/commands/memory.d.ts +2 -0
  67. package/dist/commands/memory.js +27 -0
  68. package/dist/commands/node.d.ts +3 -2
  69. package/dist/commands/node.js +607 -61
  70. package/dist/commands/pkg/market-manage.js +1 -1
  71. package/dist/commands/push.js +6 -6
  72. package/dist/commands/revive.js +1 -1
  73. package/dist/commands/skill/author.js +1 -1
  74. package/dist/commands/skill/shared.d.ts +1 -8
  75. package/dist/commands/skill/shared.js +2 -55
  76. package/dist/commands/skill.js +9 -14
  77. package/dist/commands/sys/doctor.js +1 -1
  78. package/dist/commands/sys/update.js +1 -1
  79. package/dist/commands/view-cycle.d.ts +2 -0
  80. package/dist/commands/view-cycle.js +125 -0
  81. package/dist/commands/view-list.d.ts +2 -0
  82. package/dist/commands/view-list.js +66 -0
  83. package/dist/commands/view-new.d.ts +2 -0
  84. package/dist/commands/view-new.js +70 -0
  85. package/dist/commands/view-pick.d.ts +2 -0
  86. package/dist/commands/view-pick.js +119 -0
  87. package/dist/commands/view-run.d.ts +2 -0
  88. package/dist/commands/view-run.js +191 -0
  89. package/dist/commands/view.d.ts +2 -0
  90. package/dist/commands/view.js +29 -0
  91. package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
  92. package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
  93. package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
  94. package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
  95. package/dist/core/__tests__/cascade-close.test.js +12 -2
  96. package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
  97. package/dist/core/__tests__/child-death-wake.test.js +245 -0
  98. package/dist/core/__tests__/context-intro.test.js +76 -62
  99. package/dist/core/__tests__/daemon-boot.test.js +14 -5
  100. package/dist/core/__tests__/daemon-liveness.test.js +34 -9
  101. package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
  102. package/dist/core/__tests__/detach-focus.test.js +206 -0
  103. package/dist/core/__tests__/draw-style.test.d.ts +1 -0
  104. package/dist/core/__tests__/draw-style.test.js +258 -0
  105. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
  106. package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
  107. package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
  108. package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
  109. package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
  110. package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
  111. package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
  112. package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
  113. package/dist/core/__tests__/helpers/harness.d.ts +9 -0
  114. package/dist/core/__tests__/helpers/harness.js +111 -1
  115. package/dist/core/__tests__/home-session.test.js +41 -8
  116. package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
  117. package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
  118. package/dist/core/__tests__/kickoff.test.js +37 -3
  119. package/dist/core/__tests__/live-mutation.test.js +50 -33
  120. package/dist/core/__tests__/memory.test.js +23 -115
  121. package/dist/core/__tests__/placement-focus.test.js +5 -0
  122. package/dist/core/__tests__/placement-teardown.test.js +54 -11
  123. package/dist/core/__tests__/relaunch.test.js +4 -3
  124. package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
  125. package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
  126. package/dist/core/__tests__/spawn-root.test.js +10 -0
  127. package/dist/core/__tests__/spike-harness.test.js +1 -0
  128. package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
  129. package/dist/core/__tests__/wake-bearings.test.js +156 -0
  130. package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
  131. package/dist/core/__tests__/wake-origin.test.js +110 -0
  132. package/dist/core/bootstrap.js +1 -1
  133. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  134. package/dist/core/canvas/browse/app.js +24 -2
  135. package/dist/core/canvas/browse/model.d.ts +38 -2
  136. package/dist/core/canvas/browse/model.js +134 -10
  137. package/dist/core/canvas/browse/render.d.ts +6 -12
  138. package/dist/core/canvas/browse/render.js +72 -104
  139. package/dist/core/canvas/canvas.js +15 -22
  140. package/dist/core/canvas/db.js +46 -0
  141. package/dist/core/canvas/index.d.ts +1 -0
  142. package/dist/core/canvas/index.js +1 -0
  143. package/dist/core/canvas/pid.d.ts +4 -0
  144. package/dist/core/canvas/pid.js +23 -0
  145. package/dist/core/canvas/render.d.ts +11 -2
  146. package/dist/core/canvas/render.js +69 -0
  147. package/dist/core/canvas/types.d.ts +85 -3
  148. package/dist/core/canvas/wakeups.d.ts +76 -0
  149. package/dist/core/canvas/wakeups.js +185 -0
  150. package/dist/core/config.js +4 -1
  151. package/dist/core/frontmatter.js +37 -124
  152. package/dist/core/memory-resolver.d.ts +49 -0
  153. package/dist/core/memory-resolver.js +141 -0
  154. package/dist/core/personas/loader.d.ts +7 -0
  155. package/dist/core/personas/loader.js +36 -5
  156. package/dist/core/personas/resolve.js +13 -3
  157. package/dist/core/predicate.d.ts +63 -0
  158. package/dist/core/predicate.js +189 -0
  159. package/dist/core/resolver.js +26 -5
  160. package/dist/core/runtime/bearings.d.ts +53 -12
  161. package/dist/core/runtime/bearings.js +132 -59
  162. package/dist/core/runtime/broker-cli.d.ts +1 -0
  163. package/dist/core/runtime/broker-cli.js +46 -0
  164. package/dist/core/runtime/broker-protocol.d.ts +332 -0
  165. package/dist/core/runtime/broker-protocol.js +153 -0
  166. package/dist/core/runtime/broker-sdk.d.ts +48 -0
  167. package/dist/core/runtime/broker-sdk.js +72 -0
  168. package/dist/core/runtime/broker.d.ts +55 -0
  169. package/dist/core/runtime/broker.js +1128 -0
  170. package/dist/core/runtime/close.js +35 -6
  171. package/dist/core/runtime/host.d.ts +53 -0
  172. package/dist/core/runtime/host.js +186 -0
  173. package/dist/core/runtime/kickoff.d.ts +2 -1
  174. package/dist/core/runtime/kickoff.js +91 -5
  175. package/dist/core/runtime/launch.d.ts +45 -2
  176. package/dist/core/runtime/launch.js +65 -2
  177. package/dist/core/runtime/lifecycle.js +23 -6
  178. package/dist/core/runtime/memory.d.ts +2 -42
  179. package/dist/core/runtime/memory.js +11 -162
  180. package/dist/core/runtime/nodes.d.ts +33 -0
  181. package/dist/core/runtime/nodes.js +59 -1
  182. package/dist/core/runtime/persona.js +21 -11
  183. package/dist/core/runtime/pi-vendored.d.ts +18 -0
  184. package/dist/core/runtime/pi-vendored.js +49 -0
  185. package/dist/core/runtime/placement.d.ts +32 -14
  186. package/dist/core/runtime/placement.js +206 -52
  187. package/dist/core/runtime/promote.d.ts +0 -6
  188. package/dist/core/runtime/promote.js +1 -12
  189. package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
  190. package/dist/core/runtime/{demote.js → recycle.js} +27 -11
  191. package/dist/core/runtime/reset.js +8 -6
  192. package/dist/core/runtime/revive.d.ts +2 -0
  193. package/dist/core/runtime/revive.js +34 -34
  194. package/dist/core/runtime/spawn.d.ts +19 -0
  195. package/dist/core/runtime/spawn.js +75 -22
  196. package/dist/core/runtime/stop-guard.d.ts +1 -1
  197. package/dist/core/runtime/stop-guard.js +6 -1
  198. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  199. package/dist/core/runtime/tmux-chrome.js +1 -1
  200. package/dist/core/runtime/tmux.d.ts +28 -0
  201. package/dist/core/runtime/tmux.js +80 -6
  202. package/dist/core/scope.d.ts +11 -0
  203. package/dist/core/scope.js +39 -0
  204. package/dist/core/spawn.d.ts +5 -0
  205. package/dist/core/spawn.js +20 -2
  206. package/dist/core/substrate/gate.d.ts +13 -0
  207. package/dist/core/substrate/gate.js +21 -0
  208. package/dist/core/substrate/index.d.ts +7 -0
  209. package/dist/core/substrate/index.js +18 -0
  210. package/dist/core/substrate/on-read.d.ts +14 -0
  211. package/dist/core/substrate/on-read.js +292 -0
  212. package/dist/core/substrate/render.d.ts +25 -0
  213. package/dist/core/substrate/render.js +256 -0
  214. package/dist/core/substrate/schema.d.ts +76 -0
  215. package/dist/core/substrate/schema.js +124 -0
  216. package/dist/core/substrate/session-cache.d.ts +30 -0
  217. package/dist/core/substrate/session-cache.js +77 -0
  218. package/dist/core/substrate/subject.d.ts +41 -0
  219. package/dist/core/substrate/subject.js +54 -0
  220. package/dist/core/tui/contract.d.ts +83 -0
  221. package/dist/core/tui/contract.js +8 -0
  222. package/dist/core/tui/draw.d.ts +96 -0
  223. package/dist/core/tui/draw.js +339 -0
  224. package/dist/core/tui/host.d.ts +29 -0
  225. package/dist/core/tui/host.js +379 -0
  226. package/dist/core/tui/loader.d.ts +16 -0
  227. package/dist/core/tui/loader.js +94 -0
  228. package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
  229. package/dist/core/wake.d.ts +86 -0
  230. package/dist/core/wake.js +308 -0
  231. package/dist/daemon/crtrd.d.ts +29 -4
  232. package/dist/daemon/crtrd.js +662 -46
  233. package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
  234. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
  235. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
  236. package/dist/pi-extensions/canvas-commands.d.ts +3 -0
  237. package/dist/pi-extensions/canvas-commands.js +10 -0
  238. package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
  239. package/dist/pi-extensions/canvas-context-intro.js +55 -15
  240. package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
  241. package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
  242. package/dist/pi-extensions/canvas-nav.d.ts +3 -0
  243. package/dist/pi-extensions/canvas-nav.js +87 -23
  244. package/dist/pi-extensions/canvas-stophook.js +17 -8
  245. package/dist/pi-extensions/canvas-view.d.ts +21 -0
  246. package/dist/pi-extensions/canvas-view.js +75 -0
  247. package/dist/prompts/skill.js +19 -26
  248. package/dist/prompts/view.d.ts +7 -0
  249. package/dist/prompts/view.js +101 -0
  250. package/dist/types.d.ts +4 -0
  251. package/dist/types.js +1 -0
  252. package/package.json +9 -4
  253. package/dist/commands/skill/find.d.ts +0 -4
  254. package/dist/commands/skill/find.js +0 -257
  255. package/dist/commands/skill/read.js +0 -91
  256. /package/dist/commands/{skill → memory}/read.d.ts +0 -0
  257. /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
+ });
@@ -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;
@@ -7,7 +7,7 @@
7
7
  // - home_session round-trips through meta.json (it IS durable identity)
8
8
  // - the birth-session decision (`resolveBirthSession`) for the child /
9
9
  // inline-root / --root cases each site sets home_session from
10
- // - demote-recycle + relaunch (pane-recycle) births populate home_session
10
+ // - recycle + relaunch (pane-recycle) births populate home_session
11
11
  // - a legacy meta with NO home_session defaults to tmux_session ?? nodeSession()
12
12
  import { test, before, after, beforeEach } from 'node:test';
13
13
  import assert from 'node:assert/strict';
@@ -17,10 +17,10 @@ import { join } from 'node:path';
17
17
  import { createNode, getNode, updateNode } from '../canvas/canvas.js';
18
18
  import { nodeMetaPath } from '../canvas/paths.js';
19
19
  import { closeDb } from '../canvas/db.js';
20
- import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
20
+ import { resolveBirthSession, homeSessionOf, childBackstageOf } from '../runtime/nodes.js';
21
21
  import { nodeSession } from '../runtime/nodes.js';
22
22
  import { relaunchRoot } from '../runtime/reset.js';
23
- import { demoteNode } from '../runtime/demote.js';
23
+ import { recycleNode } from '../runtime/recycle.js';
24
24
  let home;
25
25
  function node(id, over = {}) {
26
26
  return {
@@ -35,7 +35,7 @@ function node(id, over = {}) {
35
35
  ...over,
36
36
  };
37
37
  }
38
- /** Make ensureDaemon (called by demoteNode) a no-op by faking a live daemon
38
+ /** Make ensureDaemon (called by recycleNode) a no-op by faking a live daemon
39
39
  * pidfile pointing at THIS test process — so no real daemon is ever spawned. */
40
40
  function fakeLiveDaemon() {
41
41
  writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
@@ -106,6 +106,39 @@ test('homeSessionOf: unknown node falls back to the backstage', () => {
106
106
  assert.equal(homeSessionOf('ghost'), nodeSession());
107
107
  });
108
108
  // ---------------------------------------------------------------------------
109
+ // childBackstageOf — the session a node's CHILDREN spawn into (their
110
+ // CRTR_ROOT_SESSION). REGRESSION for the front-door-root subtree-exile bug:
111
+ // a refreshed inline root (home_session = a USER session it adopted) was
112
+ // sourcing children's CRTR_ROOT_SESSION from home_session, so every yield
113
+ // re-pointed its entire subtree into the user's working session. A root's
114
+ // children must always flow to the shared backstage `nodeSession()`, never the
115
+ // user session, while a managed child's children inherit its backstage
116
+ // home_session. (Bug: node mq2u219p spawned all 13 children into `cli`.)
117
+ // ---------------------------------------------------------------------------
118
+ test('childBackstageOf: a managed child uses its backstage home_session', () => {
119
+ // A child's home_session is ALWAYS the backstage it was born into; its live
120
+ // tmux_session may be a user session (focus taint) but must NOT leak to kids.
121
+ createNode(node('child', { parent: 'p', home_session: 'crtr', tmux_session: 'user-sess' }));
122
+ assert.equal(childBackstageOf('child'), 'crtr', 'children inherit the backstage, not the tainted LOCATION');
123
+ });
124
+ test('childBackstageOf: a front-door ROOT routes children to the backstage, NOT its adopted user session', () => {
125
+ // The bug: an inline root adopts the user's session as home_session ('cli').
126
+ // Sourcing children's CRTR_ROOT_SESSION from home_session exiled the whole
127
+ // subtree into 'cli' on every refresh-yield. A root (parent === null) must
128
+ // route children to nodeSession() instead.
129
+ createNode(node('root', { parent: null, home_session: 'cli', tmux_session: 'cli' }));
130
+ assert.equal(childBackstageOf('root'), nodeSession(), "a root's children flow to the backstage");
131
+ assert.notEqual(childBackstageOf('root'), 'cli', 'NEVER the user session the root pane adopted');
132
+ });
133
+ test('childBackstageOf: a root honors CRTR_NODE_SESSION for the backstage', () => {
134
+ process.env['CRTR_NODE_SESSION'] = 'my-backstage';
135
+ createNode(node('root', { parent: null, home_session: 'cli' }));
136
+ assert.equal(childBackstageOf('root'), 'my-backstage', 'the backstage default is env-overridable');
137
+ });
138
+ test('childBackstageOf: unknown node falls back to the backstage', () => {
139
+ assert.equal(childBackstageOf('ghost'), nodeSession());
140
+ });
141
+ // ---------------------------------------------------------------------------
109
142
  // The birth-session decision each site sets home_session from. Pure, so the
110
143
  // child / inline-root / --root births are testable without a live tmux (the
111
144
  // real spawnChild/bootRoot are tmux + pi + process.exit coupled).
@@ -130,7 +163,7 @@ test('birth: the inline front door (bootRoot) homes to its adopted session', ()
130
163
  });
131
164
  // ---------------------------------------------------------------------------
132
165
  // Pane-recycle births populate home_session. relaunchRoot (option C) is fully
133
- // unit-testable (injected respawn, no tmux); demoteNode runs to completion with
166
+ // unit-testable (injected respawn, no tmux); recycleNode runs to completion with
134
167
  // no tmux (respawn dispatch just fails) once the daemon spawn is neutralized.
135
168
  // ---------------------------------------------------------------------------
136
169
  test('relaunch birth: the fresh root homes to the recycled pane session', () => {
@@ -141,13 +174,13 @@ test('relaunch birth: the fresh root homes to the recycled pane session', () =>
141
174
  assert.ok(res !== null, 'relaunchRoot minted a fresh root');
142
175
  assert.equal(getNode(res.newNodeId)?.home_session, 'crtr', 'fresh root homes to the recycled pane session');
143
176
  });
144
- test('demote birth: the recycled root populates home_session (backstage when no pane location)', async () => {
177
+ test('recycle birth: the recycled root populates home_session (backstage when no pane location)', async () => {
145
178
  createNode(node('M', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@3' }));
146
179
  fakeLiveDaemon(); // createNode ensured the home dir; now neutralize ensureDaemon
147
180
  // No live tmux → paneLocation('%0') is null → home_session defaults to the
148
181
  // backstage. The respawn dispatch fails (no tmux), but the fresh root is still
149
182
  // born — and must carry a populated home_session.
150
- const res = await demoteNode('M', '%0');
151
- assert.ok(res.newRoot !== null, 'demote minted a fresh root');
183
+ const res = await recycleNode('M', '%0');
184
+ assert.ok(res.newRoot !== null, 'recycle minted a fresh root');
152
185
  assert.equal(getNode(res.newRoot)?.home_session, nodeSession(), 'recycled root homes to the backstage');
153
186
  });
@@ -0,0 +1,101 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/human-new-window-regression.test.ts
2
+ //
3
+ // BUG REGRESSION: `crtr human ask|approve|review|notify` opened NO pane at all.
4
+ //
5
+ // spawnAndDetach's 'new-window' placement passed a tmux PANE id straight to
6
+ // `new-window -t` (`new-window -d -a -t %<pane>`). tmux REJECTS a pane id for
7
+ // new-window — it exits 1 with "can't specify pane here"; only `split-window -t`
8
+ // accepts a pane. So once a node's watched window held >= max_panes_per_window
9
+ // panes, pickPlacement (shared.ts) chose 'new-window', spawnAndDetach FAILED,
10
+ // spawnHumanJob returned {spawned:false}, and the humanloop TUI never appeared.
11
+ // Deterministic for any user whose watched window is already at the pane cap.
12
+ //
13
+ // Regression from 829c1a1 ("land prompts in the watched node's session"), which
14
+ // began routing the TUI to the watched node's pane id but never converted it to
15
+ // the session:window form new-window needs.
16
+ //
17
+ // THE FIX: spawnAndDetach resolves the target pane to its session:window
18
+ // (`paneWindowTarget`) before `new-window -t`. This drives the REAL production
19
+ // spawnAndDetach against a REAL but isolated tmux session — no mocks — mirroring
20
+ // dead-pane-regression.test.ts.
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { hasTmux } from './helpers/harness.js';
25
+ import { spawnAndDetach } from '../spawn.js';
26
+ const SKIP = !hasTmux();
27
+ function tmux(args) {
28
+ const r = spawnSync('tmux', args, { encoding: 'utf8' });
29
+ return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
30
+ }
31
+ /** window id of a pane (across the server). '' on error. */
32
+ function windowOfPane(pane) {
33
+ return tmux(['display-message', '-p', '-t', pane, '#{window_id}']).out;
34
+ }
35
+ /** Every pane in the session, across all its windows. */
36
+ function sessionPanes(session) {
37
+ const r = tmux(['list-panes', '-s', '-t', session, '-F', '#{pane_id}']);
38
+ if (r.code !== 0)
39
+ return [];
40
+ return r.out.split('\n').filter((l) => l.trim() !== '');
41
+ }
42
+ test('human-new-window regression: spawnAndDetach new-window into a watched-pane SPAWNS (no longer "can\'t specify pane here")', { skip: SKIP ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
43
+ const session = `crtr-newwin-${process.pid}-${Date.now().toString(36)}`;
44
+ const origTmux = process.env['TMUX'];
45
+ let tmuxOverridden = false;
46
+ try {
47
+ // --- isolated session on whatever server the current env targets -------
48
+ const created = tmux(['new-session', '-d', '-s', session, '-c', process.cwd(), 'sleep 100000']);
49
+ assert.equal(created.code, 0, `create isolated session failed: ${created.err}`);
50
+ const info = tmux([
51
+ 'display-message', '-p', '-t', `${session}:`,
52
+ '#{pane_id}\t#{window_id}\t#{socket_path}',
53
+ ]);
54
+ assert.equal(info.code, 0, `display-message failed: ${info.err}`);
55
+ const [basePane, baseWindow, socketPath] = info.out.split('\t');
56
+ assert.ok(basePane && baseWindow && socketPath, `parsed session info: ${info.out}`);
57
+ // ====================================================================
58
+ // CONTROL — prove the bug is REAL and the guard NON-VACUOUS: the pre-fix
59
+ // call shape (a PANE id straight into `new-window -t`) is REJECTED by tmux.
60
+ // ====================================================================
61
+ const ctl = tmux(['new-window', '-d', '-a', '-t', basePane, '-c', process.cwd(), 'sleep 100000']);
62
+ assert.notEqual(ctl.code, 0, 'CONTROL: new-window with a PANE id must FAIL (pre-fix call shape)');
63
+ assert.match(ctl.err, /can't specify pane here/i, `CONTROL: tmux rejects a pane id for new-window — got: ${ctl.err}`);
64
+ // spawnAndDetach gates on isInTmux() (Boolean(process.env.TMUX)) and shells
65
+ // `tmux` with no -L, so point TMUX at THIS session's socket — both our
66
+ // helpers and spawnAndDetach then drive the same server holding `session`.
67
+ process.env['TMUX'] = `${socketPath},0,0`;
68
+ tmuxOverridden = true;
69
+ // ====================================================================
70
+ // MAIN — the REAL production spawnAndDetach, 'new-window' placement,
71
+ // pinned to the watched PANE (exactly what detachHumanTui passes when
72
+ // pickPlacement returns 'new-window').
73
+ // ====================================================================
74
+ const res = spawnAndDetach({
75
+ command: 'sleep 100000', // long-lived stand-in for `crtr human _run`
76
+ cwd: process.cwd(),
77
+ placement: 'new-window',
78
+ detached: true, // don't switch the client (matches detachHumanTui)
79
+ killAfterSeconds: 0, // no self-kill of the originating pane
80
+ targetPane: basePane, // a PANE id — the exact value that used to break new-window
81
+ });
82
+ // (a) THE FIX: it SPAWNS (pre-fix this was 'spawn-failed' / no pane).
83
+ assert.equal(res.status, 'spawned', `spawnAndDetach should spawn a pane: ${res.message}`);
84
+ const pane = res.paneId;
85
+ assert.ok(pane && pane.startsWith('%'), `spawnAndDetach returned a %pane id: ${pane}`);
86
+ // (b) it landed in the SAME session as the watched pane (not stranded /
87
+ // leaked into the global current session), in a NEW window.
88
+ assert.ok(sessionPanes(session).includes(pane), 'spawned pane lives in the watched pane\'s session');
89
+ assert.notEqual(windowOfPane(pane), baseWindow, 'spawned pane is in a NEW window (new-window placement), not the watched pane\'s window');
90
+ }
91
+ finally {
92
+ tmux(['kill-session', '-t', session]);
93
+ if (tmuxOverridden) {
94
+ if (origTmux === undefined)
95
+ delete process.env['TMUX'];
96
+ else
97
+ process.env['TMUX'] = origTmux;
98
+ }
99
+ assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
100
+ }
101
+ });