@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -33,7 +33,7 @@
33
33
  import { test, before, after } from 'node:test';
34
34
  import assert from 'node:assert/strict';
35
35
  import { spawnSync } from 'node:child_process';
36
- import { createHarness, hasTmux } from '../helpers/harness.js';
36
+ import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
37
37
  import { closeDb } from '../../canvas/db.js';
38
38
  import { getFocusByNode, listFocuses } from '../../canvas/focuses.js';
39
39
  import { nodeInPane } from '../../../commands/node.js';
@@ -50,13 +50,16 @@ after(async () => {
50
50
  });
51
51
  /** Split a throwaway pane in the harness session; return its %id. */
52
52
  function makePane() {
53
- const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
53
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
54
54
  const pane = (r.stdout ?? '').trim();
55
55
  assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
56
56
  return pane;
57
57
  }
58
58
  function tagPane(pane, value) {
59
- spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], { stdio: 'ignore' });
59
+ spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], {
60
+ stdio: 'ignore',
61
+ timeout: TMUX_TIMEOUT_MS,
62
+ });
60
63
  }
61
64
  /** Parse `crtr`'s human-list stdout (`- key: value` lines) into a map. */
62
65
  function parseList(stdout) {
@@ -70,13 +73,13 @@ function parseList(stdout) {
70
73
  }
71
74
  /** Read a pane's `@crtr_node` option ('' when unset / pane gone). */
72
75
  function paneTag(pane) {
73
- const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8' });
76
+ const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
74
77
  return (r.stdout ?? '').trim();
75
78
  }
76
79
  /** Every pane in the harness session whose `@crtr_node` tag names `id` — the live
77
80
  * on-screen viewer panes for that node. A correct dedup keeps this at exactly 1. */
78
81
  function taggedViewerPanes(session, id) {
79
- const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8' });
82
+ const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
80
83
  return (r.stdout ?? '')
81
84
  .split('\n')
82
85
  .map((l) => l.trim())
@@ -112,7 +115,7 @@ test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (u
112
115
  closeDb();
113
116
  assert.equal(nodeInPane(viewer), broker, 'tagged viewer pane → resolves the broker (the fix)');
114
117
  // A stale tag pointing at a DONE node is ignored (must be live: active/idle).
115
- spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore' });
118
+ spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS });
116
119
  });
117
120
  test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a fresh BROKER root', async (t) => {
118
121
  if (!hasTmux())
@@ -54,10 +54,13 @@ import assert from 'node:assert/strict';
54
54
  import { spawnSync } from 'node:child_process';
55
55
  import { mkdirSync, writeFileSync } from 'node:fs';
56
56
  import { join } from 'node:path';
57
- import { createHarness, hasTmux } from '../helpers/harness.js';
57
+ import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
58
58
  import { isPidAlive } from '../../canvas/pid.js';
59
59
  function sessionExists(session) {
60
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
60
+ return (spawnSync('tmux', ['has-session', '-t', session], {
61
+ stdio: 'ignore',
62
+ timeout: TMUX_TIMEOUT_MS,
63
+ }).status === 0);
61
64
  }
62
65
  function seedAssistantSession(sessionFile) {
63
66
  mkdirSync(join(sessionFile, '..'), { recursive: true });
@@ -27,12 +27,12 @@
27
27
  import { test } from 'node:test';
28
28
  import assert from 'node:assert/strict';
29
29
  import { spawnSync } from 'node:child_process';
30
- import { hasTmux } from '../helpers/harness.js';
30
+ import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
31
31
  import { spawnAndDetach } from '../../spawn.js';
32
32
  const SKIP = !hasTmux();
33
33
  // --- LOCAL tmux drivers (keyed on a raw %pane id, not a canvas node) --------
34
34
  function tmux(args) {
35
- const r = spawnSync('tmux', args, { encoding: 'utf8' });
35
+ const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
36
36
  return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
37
37
  }
38
38
  /** Every pane in the session (across windows) with its dead flag. */
@@ -146,6 +146,9 @@ test('dead-pane regression: spawnAndDetach into a remain-on-exit-ON window is DE
146
146
  else
147
147
  process.env['TMUX'] = origTmux;
148
148
  }
149
- assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
149
+ assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
150
+ stdio: 'ignore',
151
+ timeout: TMUX_TIMEOUT_MS,
152
+ }).status === 0, false, 'isolated session killed — no stray');
150
153
  }
151
154
  });
@@ -26,18 +26,23 @@ import assert from 'node:assert/strict';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { mkdirSync, writeFileSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
- import { createHarness, hasTmux } from '../helpers/harness.js';
29
+ import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
30
30
  import { getNode } from '../../canvas/canvas.js';
31
31
  import { getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
32
32
  import { closeDb } from '../../canvas/db.js';
33
33
  import { isPidAlive } from '../../canvas/pid.js';
34
34
  const SKIP = !hasTmux() ? 'tmux unavailable' : false;
35
35
  function sessionExists(session) {
36
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
36
+ return (spawnSync('tmux', ['has-session', '-t', session], {
37
+ stdio: 'ignore',
38
+ timeout: TMUX_TIMEOUT_MS,
39
+ }).status === 0);
37
40
  }
38
41
  function paneExistsReal(pane) {
39
- return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], { encoding: 'utf8' }).stdout ?? '').trim() ===
40
- pane);
42
+ return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
43
+ encoding: 'utf8',
44
+ timeout: TMUX_TIMEOUT_MS,
45
+ }).stdout ?? '').trim() === pane);
41
46
  }
42
47
  // Split a throwaway pane in the harness session; return its %id. The caller pane
43
48
  // `node focus` opens the viewer beside (a09b71f: spawn no longer auto-opens one).
@@ -49,7 +54,7 @@ function seedAssistantSession(sessionFile) {
49
54
  writeFileSync(sessionFile, JSON.stringify({ type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] } }) + '\n');
50
55
  }
51
56
  function makePane(session) {
52
- const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
57
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
53
58
  const pane = (r.stdout ?? '').trim();
54
59
  assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
55
60
  return pane;
@@ -21,11 +21,11 @@
21
21
  import { test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { spawnSync } from 'node:child_process';
24
- import { hasTmux } from '../helpers/harness.js';
24
+ import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
25
25
  import { spawnAndDetach } from '../../spawn.js';
26
26
  const SKIP = !hasTmux();
27
27
  function tmux(args) {
28
- const r = spawnSync('tmux', args, { encoding: 'utf8' });
28
+ const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
29
29
  return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
30
30
  }
31
31
  /** window id of a pane (across the server). '' on error. */
@@ -96,6 +96,9 @@ test('human-new-window regression: spawnAndDetach new-window into a watched-pane
96
96
  else
97
97
  process.env['TMUX'] = origTmux;
98
98
  }
99
- assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
99
+ assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
100
+ stdio: 'ignore',
101
+ timeout: TMUX_TIMEOUT_MS,
102
+ }).status === 0, false, 'isolated session killed — no stray');
100
103
  }
101
104
  });
@@ -32,12 +32,12 @@ import { tmpdir } from 'node:os';
32
32
  import { join, dirname } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { createRequire } from 'node:module';
35
- import { hasTmux } from '../helpers/harness.js';
35
+ import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
36
36
  const CROUTER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
37
37
  const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
38
38
  const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
39
39
  function tmux(args) {
40
- const r = spawnSync('tmux', args, { encoding: 'utf8' });
40
+ const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
41
41
  return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
42
42
  }
43
43
  function sessionPanes(session) {
@@ -1,5 +1,6 @@
1
1
  import type { NodeMeta, NodeStatus, Mode, Lifecycle, ExitIntent } from '../../canvas/types.js';
2
2
  import type { InboxEntry } from '../../feed/inbox.js';
3
+ export declare const TMUX_TIMEOUT_MS = 10000;
3
4
  /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
4
5
  export declare function hasTmux(): boolean;
5
6
  export interface WaitOpts {
@@ -45,12 +45,28 @@ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
45
45
  // A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
46
46
  // seam. Absolute paths so it works regardless of the spawned window's cwd.
47
47
  const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
48
+ // Every raw `tmux` call the harness/full-tier tests make against the shared
49
+ // DEFAULT server is synchronous (spawnSync). Node's spawnSync blocks the WHOLE
50
+ // worker (no event loop, no `node --test` timeout can preempt it) until the
51
+ // child exits — with no `timeout` option it can wait FOREVER. A wedged/CPU-
52
+ // starved tmux server (the acknowledged "intermittent Linux-only crash-revive
53
+ // wedge", CI-only) has been observed to hang one of these calls indefinitely,
54
+ // which permanently consumes one of the `--test-concurrency` slots; as more
55
+ // full-tier files each hit the SAME wedged server, every slot eventually gets
56
+ // consumed the same way and the whole suite flatlines with zero further TAP
57
+ // output (confirmed 3/3 in CI, see ci-full-suite-hang-blocker.md). TMUX_TIMEOUT_MS
58
+ // bounds every raw tmux spawnSync call so a wedge fails that ONE call (and the
59
+ // test it belongs to) fast instead of hanging the run.
60
+ export const TMUX_TIMEOUT_MS = 10_000;
48
61
  /** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
49
62
  export function hasTmux() {
50
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
63
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }).status === 0;
51
64
  }
52
65
  function tmuxSessionExists(session) {
53
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
66
+ return (spawnSync('tmux', ['has-session', '-t', session], {
67
+ stdio: 'ignore',
68
+ timeout: TMUX_TIMEOUT_MS,
69
+ }).status === 0);
54
70
  }
55
71
  // Every canvas/tmux var the harness itself runs under — scrubbed from each child
56
72
  // env so a spawned CLI cannot leak the REAL canvas into the isolated test.
@@ -172,14 +188,19 @@ export async function createHarness(opts = {}) {
172
188
  if (!headless) {
173
189
  spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
174
190
  stdio: 'ignore',
191
+ timeout: TMUX_TIMEOUT_MS,
175
192
  });
176
193
  // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane/process spawned
177
194
  // in this session inherits it — so any pi the runtime launches (boot, revive)
178
195
  // resolves to the fake-pi vehicle, not a real pi.
179
196
  spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
180
197
  stdio: 'ignore',
198
+ timeout: TMUX_TIMEOUT_MS,
199
+ });
200
+ spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], {
201
+ stdio: 'ignore',
202
+ timeout: TMUX_TIMEOUT_MS,
181
203
  });
182
- spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
183
204
  }
184
205
  const pidsToKill = new Set();
185
206
  let nextRootSeq = 0;
@@ -517,7 +538,10 @@ export async function createHarness(opts = {}) {
517
538
  return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
518
539
  },
519
540
  async dispose() {
520
- spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
541
+ spawnSync('tmux', ['kill-session', '-t', session], {
542
+ stdio: 'ignore',
543
+ timeout: TMUX_TIMEOUT_MS,
544
+ });
521
545
  // Best-effort: a superviseTick-triggered revive of a fabricated node fires
522
546
  // a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
523
547
  // below makes a mid-boot one self-terminate when its files vanish). Kill
@@ -62,7 +62,7 @@
62
62
  import { test, before, after } from 'node:test';
63
63
  import assert from 'node:assert/strict';
64
64
  import { spawn } from 'node:child_process';
65
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
65
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
66
66
  import { tmpdir } from 'node:os';
67
67
  import { join } from 'node:path';
68
68
  import { createHarness } from './helpers/harness.js';
@@ -180,9 +180,20 @@ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sle
180
180
  if (leader.pid == null)
181
181
  throw new Error('failed to spawn fake broker leader');
182
182
  trackPid(leader.pid);
183
- await h.waitFor(() => existsSync(pidFile), { label: `${tag}: descendant pid file written` });
184
- const descendantPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
185
- assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, `${tag}: parsed a real descendant pid`);
183
+ const descendantPid = await h.waitFor(() => {
184
+ try {
185
+ const record = readFileSync(pidFile, 'utf8');
186
+ if (!/^\d+\n$/.test(record))
187
+ return false;
188
+ const pid = Number(record.slice(0, -1));
189
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : false;
190
+ }
191
+ catch (error) {
192
+ if (error.code === 'ENOENT')
193
+ return false;
194
+ throw error;
195
+ }
196
+ }, { label: `${tag}: valid descendant pid written` });
186
197
  trackPid(descendantPid);
187
198
  await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
188
199
  return { leaderPid: leader.pid, descendantPid };
@@ -47,6 +47,7 @@ import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
47
47
  function persona(m) {
48
48
  return { mode: m.mode, lifecycle: m.lifecycle };
49
49
  }
50
+ const FULL_CLI_TEST_TIMEOUT_MS = 180_000;
50
51
  // ===========================================================================
51
52
  // (b) THE demote / recycle SPLIT — two DISTINCT verbs:
52
53
  // • `node lifecycle demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
@@ -60,9 +61,9 @@ function persona(m) {
60
61
  // ===========================================================================
61
62
  test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle recycle is FINISH+RECYCLE',
62
63
  // Drives several full-CLI subprocess spawns (each a cold tsx compile of the
63
- // whole CLI) + recycleNode; a 20s budget blew on the slower, concurrency-
64
- // oversubscribed CI runner though it passes in ~8s locally. 60s = safe margin.
65
- { timeout: 60_000 }, async () => {
64
+ // whole CLI) + recycleNode; this is a subprocess-heavy regression and gets a
65
+ // CI-safe per-test budget while the npm script isolates it at file concurrency 1.
66
+ { timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
66
67
  const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-demote' });
67
68
  try {
68
69
  const A = h.spawnRoot('resident root');
@@ -136,7 +137,7 @@ test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle
136
137
  // ===========================================================================
137
138
  test('A4: a base→orchestrator yield auto-promotes + sets refresh but leaves persona_ack PENDING (the steer is never delivered)',
138
139
  // Full-CLI subprocess spawn under load — see the sibling test's budget note.
139
- { timeout: 60_000 }, async () => {
140
+ { timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
140
141
  const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-a4' });
141
142
  try {
142
143
  const A = h.spawnRoot('resident root');
@@ -27,7 +27,7 @@ test('review still permits equal-or-stronger explicit models', () => {
27
27
  assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
28
28
  });
29
29
  test('explore remains cheap by default and may explicitly run light', () => {
30
- assert.equal(spec('explore'), normalizeModel('anthropic/light'));
30
+ assert.equal(spec('explore'), normalizeModel('openai/light'));
31
31
  assert.equal(spec('explore', 'light'), normalizeModel('light'));
32
32
  });
33
33
  // `node new --model` widened to the full spec grammar (provider/tier, exact
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/stream-watchdog.test.ts
2
+ //
3
+ // Unit-level contract test for StreamWatchdog (see stream-watchdog.ts): a
4
+ // single-timer dead-man's switch the broker pokes while a turn awaits the
5
+ // provider (disarmed during tool execution) and clears at agent_end. This
6
+ // locks the class's arm/clear/single-fire/token contract directly — no broker
7
+ // or fake-engine harness needed (that integration surface is deliberately out
8
+ // of scope; see
9
+ // the roadmap's Fix 3 test-scope note).
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { StreamWatchdog } from '../runtime/stream-watchdog.js';
13
+ const MS = 30;
14
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ test('poke() arms the watchdog, firing once after ms with no further poke', async () => {
16
+ let calls = 0;
17
+ const wd = new StreamWatchdog(() => calls++, MS);
18
+ wd.poke(0);
19
+ assert.equal(calls, 0, 'must not fire before ms elapses');
20
+ await sleep(MS * 2);
21
+ assert.equal(calls, 1, 'must fire exactly once after ms with no further poke');
22
+ });
23
+ test('repeated pokes rearm the deadline from the LAST poke, not the first', async () => {
24
+ // A cancel-without-reschedule bug (clear the old timer but never arm a new
25
+ // one) would also "never fire while poked", so proving no-fire mid-stream is
26
+ // not enough. Prove the deadline actually REARMS: no fire around the original
27
+ // deadline, then exactly one fire measured from the FINAL poke.
28
+ let calls = 0;
29
+ const wd = new StreamWatchdog(() => calls++, MS);
30
+ wd.poke(0);
31
+ // Re-poke well inside the window, several times, always resetting the clock.
32
+ // The last poke lands at ~t=2.5*MS, so the original t=MS deadline must pass
33
+ // with no fire.
34
+ for (let i = 0; i < 5; i++) {
35
+ await sleep(MS / 2);
36
+ wd.poke(0);
37
+ }
38
+ assert.equal(calls, 0, 'no fire around the original deadline — each poke rearmed it');
39
+ // From the final poke the timer must still be armed and fire exactly once.
40
+ await sleep(MS * 2);
41
+ assert.equal(calls, 1, 'exactly one fire, measured from the final poke — proves rearming');
42
+ });
43
+ test('clear() after poke prevents firing', async () => {
44
+ let calls = 0;
45
+ const wd = new StreamWatchdog(() => calls++, MS);
46
+ wd.poke(0);
47
+ wd.clear();
48
+ await sleep(MS * 2);
49
+ assert.equal(calls, 0, 'a cleared watchdog must never fire');
50
+ });
51
+ test('fires exactly once even if left armed well past ms', async () => {
52
+ let calls = 0;
53
+ const wd = new StreamWatchdog(() => calls++, MS);
54
+ wd.poke(0);
55
+ await sleep(MS * 5);
56
+ assert.equal(calls, 1, 'must fire only once, not repeatedly, past the deadline');
57
+ });
58
+ test('onStall receives the token of the arm that fired (generation binding)', async () => {
59
+ // The broker stamps each poke with the current session generation and no-ops
60
+ // a stall whose token is stale. This pins that the token travels with the
61
+ // arm: a re-poke supersedes the prior token, so a fire after a rebind carries
62
+ // the NEW generation — and an old timer that somehow survived would deliver
63
+ // the OLD one, which the broker guard rejects.
64
+ const tokens = [];
65
+ const wd = new StreamWatchdog((token) => tokens.push(token), MS);
66
+ wd.poke(1);
67
+ wd.poke(2); // supersedes generation 1 before it can fire
68
+ await sleep(MS * 2);
69
+ assert.deepEqual(tokens, [2], 'exactly one fire, carrying the most-recent arm\'s token');
70
+ });
@@ -18,8 +18,11 @@
18
18
  import { test } from 'node:test';
19
19
  import assert from 'node:assert/strict';
20
20
  import { readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { spawnSync } from 'node:child_process';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  import { dirname, join, basename } from 'node:path';
24
+ import { installTmuxBindings } from '../runtime/tmux-chrome.js';
25
+ import { resolveKeybindings } from '../keybindings/index.js';
23
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
27
  const SRC_ROOT = join(__dirname, '..', '..'); // .../src
25
28
  const TMUX_TS = join(SRC_ROOT, 'core', 'runtime', 'tmux.ts');
@@ -113,3 +116,72 @@ test('§5.1 lint: only placement.ts / tmux-chrome.ts import the tmux driver', ()
113
116
  .map((f) => f.slice(SRC_ROOT.length + 1));
114
117
  assert.deepEqual(offenders, [], `modules importing tmux.ts directly: ${offenders.join(', ')}`);
115
118
  });
119
+ function isolatedTmux(socket, args) {
120
+ const result = spawnSync('tmux', ['-S', socket, ...args], { encoding: 'utf8' });
121
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
122
+ }
123
+ function tmuxBinding(socket, table, key) {
124
+ const result = isolatedTmux(socket, ['list-keys', '-T', table]);
125
+ if (result.status !== 0)
126
+ return null;
127
+ return result.stdout.split('\n').find((line) => {
128
+ const match = line.match(/^bind-key\s+(?:-r\s+)?-T\s+\S+\s+(\S+)\s+/);
129
+ return match?.[1] === key;
130
+ }) ?? null;
131
+ }
132
+ test('tmux installer remaps owned pairs and preserves occupied user bindings', (t) => {
133
+ if (spawnSync('tmux', ['-V']).status !== 0) {
134
+ t.skip('tmux is unavailable');
135
+ return;
136
+ }
137
+ const name = `crtr-keybindings-${process.pid}-${Date.now()}`;
138
+ const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
139
+ assert.equal(boot.status, 0, boot.stderr);
140
+ const socketResult = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' });
141
+ const socket = socketResult.stdout.trim();
142
+ assert.notEqual(socket, '');
143
+ const priorTmux = process.env['TMUX'];
144
+ process.env['TMUX'] = `${socket},1,0`;
145
+ try {
146
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-]', 'run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`]);
147
+ const first = installTmuxBindings(resolveKeybindings());
148
+ assert.equal(first.ok, true, JSON.stringify(first.diagnostics));
149
+ assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-]'));
150
+ assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /display-menu/);
151
+ assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
152
+ const remapped = installTmuxBindings(resolveKeybindings({
153
+ 'crtr.tmux.menu.open': ['alt+shift+z'],
154
+ 'crtr.tmux.menu.graph': ['shift+z'],
155
+ 'crtr.tmux.node.next': [],
156
+ 'crtr.tmux.view.next': ['alt+z > n'],
157
+ 'crtr.tmux.view.previous': ['alt+z > p'],
158
+ }));
159
+ assert.equal(remapped.ok, true, JSON.stringify(remapped.diagnostics));
160
+ assert.equal(tmuxBinding(socket, 'root', 'M-c'), null);
161
+ assert.equal(tmuxBinding(socket, 'root', 'M-]'), null);
162
+ const menu = tmuxBinding(socket, 'root', 'M-Z') ?? '';
163
+ assert.match(menu, /graph view/);
164
+ assert.match(menu, /Z/);
165
+ const prefix = tmuxBinding(socket, 'root', 'M-z') ?? '';
166
+ assert.match(prefix, /switch-client/);
167
+ const again = installTmuxBindings(resolveKeybindings({
168
+ 'crtr.tmux.menu.open': ['alt+shift+y'],
169
+ 'crtr.tmux.node.next': [],
170
+ 'crtr.tmux.view.next': ['alt+z > n'],
171
+ 'crtr.tmux.view.previous': ['alt+z > p'],
172
+ }));
173
+ assert.equal(again.ok, true, JSON.stringify(again.diagnostics));
174
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-Z', 'display-message', 'user-owned']);
175
+ const occupied = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.menu.open': ['alt+shift+z'] }));
176
+ assert.equal(occupied.ok, true);
177
+ assert.ok(occupied.diagnostics.some((diagnostic) => diagnostic.code === 'occupied' && diagnostic.bindingId === 'crtr.tmux.menu.open'));
178
+ assert.match(tmuxBinding(socket, 'root', 'M-Z') ?? '', /user-owned/);
179
+ }
180
+ finally {
181
+ if (priorTmux === undefined)
182
+ delete process.env['TMUX'];
183
+ else
184
+ process.env['TMUX'] = priorTmux;
185
+ isolatedTmux(socket, ['kill-server']);
186
+ }
187
+ });
@@ -45,7 +45,7 @@ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
45
45
  await new Promise((r) => setTimeout(r, intervalMs));
46
46
  }
47
47
  }
48
- test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
48
+ test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async (t) => {
49
49
  const server = await startSseServer((res) => {
50
50
  res.writeHead(200, { 'content-type': 'text/event-stream' });
51
51
  res.write(': connected\n\n');
@@ -55,15 +55,18 @@ test('a listener fires once per known-kind SSE event, and never for a ping or an
55
55
  res.end();
56
56
  });
57
57
  const stream = new RemoteEventStream(server.url, 'tok');
58
+ // Register cleanup BEFORE any assertion runs — a thrown assertion must not
59
+ // leave the client's reconnect loop or the server's listening socket alive
60
+ // (see the CI-hang postmortem on rebuild-coalescer.test.ts).
61
+ t.after(() => { stream.stop(); return server.close(); });
58
62
  const seen = [];
59
63
  stream.subscribe((kind) => seen.push(kind));
60
64
  stream.start();
61
65
  await waitFor(() => seen.length > 0);
62
66
  stream.stop();
63
67
  assert.deepEqual(seen, ['nodes']);
64
- await server.close();
65
68
  });
66
- test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
69
+ test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async (t) => {
67
70
  const server = await startSseServer((res) => {
68
71
  res.writeHead(200, { 'content-type': 'text/event-stream' });
69
72
  res.end();
@@ -73,6 +76,7 @@ test('the SSE connection carries the token ONLY in the Authorization header —
73
76
  // no header, including Host, is permitted to carry it.
74
77
  const token = 'secret-sse-token-value';
75
78
  const stream = new RemoteEventStream(server.url, token);
79
+ t.after(() => { stream.stop(); return server.close(); });
76
80
  stream.start();
77
81
  await waitFor(() => server.requests.length > 0);
78
82
  stream.stop();
@@ -87,9 +91,8 @@ test('the SSE connection carries the token ONLY in the Authorization header —
87
91
  assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
88
92
  }
89
93
  assert.ok(!req.url.includes(token));
90
- await server.close();
91
94
  });
92
- test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
95
+ test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async (t) => {
93
96
  const server = await startSseServer((res) => {
94
97
  res.writeHead(500);
95
98
  res.end();
@@ -101,6 +104,7 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
101
104
  // wide enough that ordinary event-loop/scheduler jitter can't flip it.
102
105
  const BACKOFF = 200;
103
106
  const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
107
+ t.after(() => { stream.stop(); return server.close(); });
104
108
  stream.start();
105
109
  // t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
106
110
  await waitFor(() => server.hitCount() >= 1);
@@ -119,10 +123,8 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
119
123
  // this window; a cleared one (fixed) leaves it at 2.
120
124
  await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
121
125
  assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
122
- stream.stop();
123
- await server.close();
124
126
  });
125
- test('stop() halts the reconnect loop — no further requests after it is called', async () => {
127
+ test('stop() halts the reconnect loop — no further requests after it is called', async (t) => {
126
128
  const server = await startSseServer((res) => {
127
129
  // Every connection is refused immediately (no SSE headers, just a close)
128
130
  // so the client's loop falls into its backoff-and-retry path.
@@ -131,6 +133,7 @@ test('stop() halts the reconnect loop — no further requests after it is called
131
133
  });
132
134
  const BACKOFF = 20;
133
135
  const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
136
+ t.after(() => { stream.stop(); return server.close(); });
134
137
  stream.start();
135
138
  await waitFor(() => server.hitCount() >= 1);
136
139
  const hitsAtStop = server.hitCount();
@@ -140,5 +143,4 @@ test('stop() halts the reconnect loop — no further requests after it is called
140
143
  // continuing to retry in the background after stop() returned.
141
144
  await new Promise((r) => setTimeout(r, BACKOFF * 5));
142
145
  assert.equal(server.hitCount(), hitsAtStop);
143
- await server.close();
144
146
  });
@@ -140,7 +140,7 @@ function seedPoisonedLocalState() {
140
140
  ].join('\n'));
141
141
  return sf;
142
142
  }
143
- test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
143
+ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
144
144
  sessionFile = seedPoisonedLocalState();
145
145
  const server = await startMockCrtrServer((args) => {
146
146
  if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
@@ -164,6 +164,10 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
164
164
  }
165
165
  return { status: 500, body: {} };
166
166
  });
167
+ // Register the close BEFORE any assertion runs — a listening server left
168
+ // open by a thrown assertion keeps this test file's process alive forever
169
+ // (see the CI-hang postmortem on rebuild-coalescer.test.ts).
170
+ t.after(() => server.close());
167
171
  const source = new RemoteCanvasSource(server.url, 'tok');
168
172
  const rows = await dashboardRowsAllFromSource(source);
169
173
  assert.equal(rows.length, 1);
@@ -180,7 +184,6 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
180
184
  assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
181
185
  assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
182
186
  assert.equal(previewed.previewLoaded, true);
183
- await server.close();
184
187
  });
185
188
  // The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
186
189
  // branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
@@ -190,7 +193,7 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
190
193
  // test above never exercises it, so a regression that reintroduced local
191
194
  // telemetry/ask/fault reads into the static forest renderer specifically would
192
195
  // pass that test while still leaking poisoned local state here.
193
- test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
196
+ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
194
197
  const sf = seedPoisonedLocalState();
195
198
  const server = await startMockCrtrServer((args) => {
196
199
  if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
@@ -211,11 +214,11 @@ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) f
211
214
  }
212
215
  return { status: 500, body: {} };
213
216
  });
217
+ t.after(() => server.close());
214
218
  const source = new RemoteCanvasSource(server.url, 'tok');
215
219
  const out = await renderForestFromSource(source);
216
220
  assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
217
221
  assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
218
222
  assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
219
223
  assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
220
- await server.close();
221
224
  });