@crouton-kit/crouter 0.3.28 → 0.3.29

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 (322) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +5 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  49. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  50. package/dist/clients/attach/attach-cmd.js +282 -46
  51. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  52. package/dist/clients/attach/auth-pickers.js +194 -0
  53. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  54. package/dist/clients/attach/canvas-panels.js +45 -17
  55. package/dist/clients/attach/chat-view.d.ts +27 -2
  56. package/dist/clients/attach/chat-view.js +98 -9
  57. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  58. package/dist/clients/attach/clipboard-image.js +150 -26
  59. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  60. package/dist/clients/attach/clipboard-text.js +39 -0
  61. package/dist/clients/attach/config-load.d.ts +54 -7
  62. package/dist/clients/attach/config-load.js +99 -3
  63. package/dist/clients/attach/context-message.d.ts +12 -0
  64. package/dist/clients/attach/context-message.js +72 -0
  65. package/dist/clients/attach/git-info.d.ts +20 -0
  66. package/dist/clients/attach/git-info.js +44 -0
  67. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  68. package/dist/clients/attach/graph-overlay.js +59 -19
  69. package/dist/clients/attach/input-controller.d.ts +75 -13
  70. package/dist/clients/attach/input-controller.js +305 -67
  71. package/dist/clients/attach/pickers.d.ts +58 -0
  72. package/dist/clients/attach/pickers.js +171 -0
  73. package/dist/clients/attach/slash-commands.d.ts +22 -0
  74. package/dist/clients/attach/slash-commands.js +91 -25
  75. package/dist/clients/attach/titled-editor.d.ts +33 -0
  76. package/dist/clients/attach/titled-editor.js +84 -0
  77. package/dist/clients/attach/view-socket.d.ts +18 -1
  78. package/dist/clients/attach/view-socket.js +70 -1
  79. package/dist/clients/web/dev-server.d.ts +7 -0
  80. package/dist/clients/web/dev-server.js +59 -0
  81. package/dist/clients/web/events.d.ts +14 -0
  82. package/dist/clients/web/events.js +151 -0
  83. package/dist/clients/web/server.d.ts +18 -0
  84. package/dist/clients/web/server.js +450 -0
  85. package/dist/clients/web/web-cmd.d.ts +2 -0
  86. package/dist/clients/web/web-cmd.js +120 -0
  87. package/dist/commands/canvas.js +1 -2
  88. package/dist/commands/chord.js +1 -1
  89. package/dist/commands/dashboard.js +5 -1
  90. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  91. package/dist/commands/memory/lint.js +14 -2
  92. package/dist/commands/memory/read.js +2 -2
  93. package/dist/commands/memory/shared.d.ts +7 -1
  94. package/dist/commands/memory/shared.js +18 -3
  95. package/dist/commands/memory/write.js +8 -4
  96. package/dist/commands/memory.js +4 -4
  97. package/dist/commands/node.d.ts +1 -1
  98. package/dist/commands/node.js +64 -65
  99. package/dist/commands/pkg.js +1 -2
  100. package/dist/commands/revive.js +11 -3
  101. package/dist/commands/sys/sync.d.ts +1 -0
  102. package/dist/commands/sys/sync.js +187 -0
  103. package/dist/commands/sys.js +3 -2
  104. package/dist/commands/view-cycle.js +2 -2
  105. package/dist/commands/view-list.js +8 -8
  106. package/dist/commands/view-new.js +21 -17
  107. package/dist/commands/view-pick.js +1 -1
  108. package/dist/commands/view-run.js +35 -14
  109. package/dist/commands/view.js +8 -6
  110. package/dist/commands/workspace.d.ts +2 -0
  111. package/dist/commands/workspace.js +161 -0
  112. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  114. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  116. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  117. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  118. package/dist/core/__tests__/close.test.js +14 -20
  119. package/dist/core/__tests__/context-intro.test.js +19 -19
  120. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  121. package/dist/core/__tests__/draw-style.test.js +1 -1
  122. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  123. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  124. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  125. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  126. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  127. package/dist/core/__tests__/focuses.test.js +25 -13
  128. package/dist/core/__tests__/fork.test.js +22 -5
  129. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  130. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  131. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  133. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  135. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  136. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  137. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  138. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  139. package/dist/core/__tests__/helpers/harness.js +19 -36
  140. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  141. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  142. package/dist/core/__tests__/lifecycle.test.js +1 -19
  143. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  144. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  145. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  146. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  147. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  148. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  149. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  150. package/dist/core/__tests__/reset.test.js +19 -79
  151. package/dist/core/__tests__/steer-note.test.js +9 -7
  152. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  153. package/dist/core/bootstrap.d.ts +0 -3
  154. package/dist/core/bootstrap.js +1 -143
  155. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  156. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  157. package/dist/core/canvas/browse/app.js +131 -8
  158. package/dist/core/canvas/browse/model.d.ts +23 -5
  159. package/dist/core/canvas/browse/model.js +68 -13
  160. package/dist/core/canvas/browse/render.d.ts +7 -1
  161. package/dist/core/canvas/browse/render.js +178 -47
  162. package/dist/core/canvas/canvas.js +1 -1
  163. package/dist/core/canvas/focuses.d.ts +4 -4
  164. package/dist/core/canvas/focuses.js +7 -7
  165. package/dist/core/canvas/nav-model.js +8 -7
  166. package/dist/core/canvas/render.d.ts +68 -9
  167. package/dist/core/canvas/render.js +195 -61
  168. package/dist/core/canvas/types.d.ts +16 -23
  169. package/dist/core/config.js +1 -4
  170. package/dist/core/memory-resolver.js +43 -5
  171. package/dist/core/runtime/bearings.d.ts +1 -1
  172. package/dist/core/runtime/bearings.js +8 -8
  173. package/dist/core/runtime/branded-host.d.ts +16 -0
  174. package/dist/core/runtime/branded-host.js +127 -0
  175. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  176. package/dist/core/runtime/broker.d.ts +4 -1
  177. package/dist/core/runtime/broker.js +494 -58
  178. package/dist/core/runtime/close.d.ts +12 -2
  179. package/dist/core/runtime/close.js +37 -16
  180. package/dist/core/runtime/front-door.js +6 -14
  181. package/dist/core/runtime/host.d.ts +14 -34
  182. package/dist/core/runtime/host.js +14 -50
  183. package/dist/core/runtime/launch.d.ts +12 -9
  184. package/dist/core/runtime/launch.js +27 -15
  185. package/dist/core/runtime/lifecycle.d.ts +1 -1
  186. package/dist/core/runtime/lifecycle.js +15 -19
  187. package/dist/core/runtime/nodes.d.ts +0 -55
  188. package/dist/core/runtime/nodes.js +6 -74
  189. package/dist/core/runtime/placement.d.ts +91 -335
  190. package/dist/core/runtime/placement.js +262 -840
  191. package/dist/core/runtime/promote.d.ts +2 -0
  192. package/dist/core/runtime/promote.js +24 -6
  193. package/dist/core/runtime/recap.d.ts +8 -0
  194. package/dist/core/runtime/recap.js +107 -0
  195. package/dist/core/runtime/recycle.js +25 -61
  196. package/dist/core/runtime/reset.d.ts +43 -37
  197. package/dist/core/runtime/reset.js +131 -218
  198. package/dist/core/runtime/revive.d.ts +9 -29
  199. package/dist/core/runtime/revive.js +40 -139
  200. package/dist/core/runtime/spawn.d.ts +15 -16
  201. package/dist/core/runtime/spawn.js +165 -194
  202. package/dist/core/runtime/surface-bg.d.ts +11 -0
  203. package/dist/core/runtime/surface-bg.js +68 -0
  204. package/dist/core/runtime/tmux.d.ts +13 -50
  205. package/dist/core/runtime/tmux.js +33 -87
  206. package/dist/core/scope.d.ts +1 -2
  207. package/dist/core/scope.js +1 -5
  208. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  209. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  210. package/dist/core/skill-sync/builtins.d.ts +42 -0
  211. package/dist/core/skill-sync/builtins.js +112 -0
  212. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  213. package/dist/core/skill-sync/claude-plugins.js +71 -0
  214. package/dist/core/skill-sync/engine.d.ts +42 -0
  215. package/dist/core/skill-sync/engine.js +633 -0
  216. package/dist/core/skill-sync/export.d.ts +23 -0
  217. package/dist/core/skill-sync/export.js +86 -0
  218. package/dist/core/skill-sync/manifest.d.ts +64 -0
  219. package/dist/core/skill-sync/manifest.js +181 -0
  220. package/dist/core/skill-sync/profile.d.ts +76 -0
  221. package/dist/core/skill-sync/profile.js +173 -0
  222. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  223. package/dist/core/skill-sync/snapshot.js +120 -0
  224. package/dist/core/substrate/index.d.ts +1 -1
  225. package/dist/core/substrate/index.js +1 -1
  226. package/dist/core/substrate/injected-store.js +3 -3
  227. package/dist/core/substrate/on-read.js +68 -6
  228. package/dist/core/substrate/render.d.ts +8 -11
  229. package/dist/core/substrate/render.js +29 -43
  230. package/dist/core/substrate/schema.d.ts +10 -3
  231. package/dist/core/substrate/schema.js +6 -3
  232. package/dist/core/tui/host.d.ts +12 -4
  233. package/dist/core/tui/host.js +280 -149
  234. package/dist/core/view/bridge.d.ts +10 -0
  235. package/dist/core/view/bridge.js +31 -0
  236. package/dist/core/view/chrome.d.ts +9 -0
  237. package/dist/core/view/chrome.js +22 -0
  238. package/dist/core/view/contract.d.ts +171 -0
  239. package/dist/core/view/contract.js +23 -0
  240. package/dist/core/view/loader.d.ts +31 -0
  241. package/dist/core/view/loader.js +188 -0
  242. package/dist/core/view/transport-local.d.ts +7 -0
  243. package/dist/core/view/transport-local.js +70 -0
  244. package/dist/core/view/transport.d.ts +4 -0
  245. package/dist/core/view/transport.js +15 -0
  246. package/dist/daemon/crtrd.d.ts +38 -27
  247. package/dist/daemon/crtrd.js +303 -376
  248. package/dist/daemon/manage.js +6 -1
  249. package/dist/index.js +1 -1
  250. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  251. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  252. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  253. package/dist/pi-extensions/canvas-nav.js +5 -0
  254. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  255. package/dist/pi-extensions/canvas-recap.js +236 -0
  256. package/dist/pi-extensions/canvas-resume.js +2 -1
  257. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  258. package/dist/pi-extensions/canvas-stophook.js +87 -101
  259. package/dist/pi-extensions/canvas-view.js +2 -1
  260. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  261. package/dist/pi-extensions/widget-order-bus.js +34 -0
  262. package/dist/prompts/view.d.ts +2 -2
  263. package/dist/prompts/view.js +145 -73
  264. package/dist/types.d.ts +1 -6
  265. package/dist/types.js +1 -3
  266. package/dist/web/ViewChrome.d.ts +7 -0
  267. package/dist/web/ViewChrome.js +28 -0
  268. package/dist/web/ViewPane.d.ts +39 -0
  269. package/dist/web/ViewPane.js +48 -0
  270. package/dist/web/index.d.ts +6 -0
  271. package/dist/web/index.js +16 -0
  272. package/dist/web/runtime.d.ts +39 -0
  273. package/dist/web/runtime.js +133 -0
  274. package/dist/web/states.d.ts +24 -0
  275. package/dist/web/states.js +24 -0
  276. package/dist/web/transport-http.d.ts +5 -0
  277. package/dist/web/transport-http.js +28 -0
  278. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  279. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  280. package/dist/web-client/index.html +13 -0
  281. package/package.json +20 -6
  282. package/dist/builtin-views/canvas/client.mjs +0 -303
  283. package/dist/builtin-views/canvas/view.mjs +0 -576
  284. package/dist/builtin-views/git-pr/client.mjs +0 -440
  285. package/dist/builtin-views/git-pr/view.mjs +0 -675
  286. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  287. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  288. package/dist/builtin-views/inbox/view.mjs +0 -889
  289. package/dist/builtin-views/linkedin/client.mjs +0 -610
  290. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  291. package/dist/commands/pkg/bridge.d.ts +0 -1
  292. package/dist/commands/pkg/bridge.js +0 -137
  293. package/dist/commands/skill/author.d.ts +0 -3
  294. package/dist/commands/skill/author.js +0 -140
  295. package/dist/commands/skill/shared.d.ts +0 -3
  296. package/dist/commands/skill/shared.js +0 -19
  297. package/dist/commands/skill.d.ts +0 -2
  298. package/dist/commands/skill.js +0 -21
  299. package/dist/commands/tmux-spread.d.ts +0 -2
  300. package/dist/commands/tmux-spread.js +0 -144
  301. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  302. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  303. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  304. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  305. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  306. package/dist/core/__tests__/home-session.test.js +0 -186
  307. package/dist/core/__tests__/relaunch.test.js +0 -335
  308. package/dist/core/bridge-map.d.ts +0 -19
  309. package/dist/core/bridge-map.js +0 -73
  310. package/dist/core/tui/contract.d.ts +0 -83
  311. package/dist/core/tui/contract.js +0 -8
  312. package/dist/core/tui/loader.d.ts +0 -16
  313. package/dist/core/tui/loader.js +0 -94
  314. package/dist/prompts/skill.d.ts +0 -2
  315. package/dist/prompts/skill.js +0 -650
  316. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  317. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  318. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
@@ -1,212 +0,0 @@
1
- // Run with: node --import tsx/esm --test src/core/__tests__/placement-reconcile.test.ts
2
- //
3
- // STEP 3 of the placement/focus migration: pane-anchored reconciliation (Q6) —
4
- // the robustness step. A manual move-pane/join-pane/break-pane must NEVER read
5
- // as a node death; liveness becomes pane-existence, and reconcile makes crtr
6
- // FOLLOW a move instead of fighting it. Covers:
7
- // - reconcileDecision (PURE, no tmux): pane moved → cache FOLLOWS; pane gone →
8
- // LOCATION nulled; never returns a stale window; legacy no-pane + live window
9
- // → backfills the pane from paneOfWindow.
10
- // - reconcile (impure shell over a real temp-db row, tmux absent): exercises
11
- // the gone / no-op paths through setPresence.
12
- // - isNodePaneAlive: pane present → paneExists path; pane null + window →
13
- // windowAlive fallback (with a real, live tmux window when available).
14
- import { test, before, after, beforeEach } from 'node:test';
15
- import assert from 'node:assert/strict';
16
- import { mkdtempSync, rmSync } from 'node:fs';
17
- import { tmpdir } from 'node:os';
18
- import { join } from 'node:path';
19
- import { spawnSync } from 'node:child_process';
20
- import { createNode, getNode, getRow } from '../../canvas/canvas.js';
21
- import { closeDb } from '../../canvas/db.js';
22
- import { reconcileDecision, reconcile, isNodePaneAlive, } from '../../runtime/placement.js';
23
- let home;
24
- function node(id, over = {}) {
25
- return {
26
- node_id: id,
27
- name: id,
28
- created: new Date().toISOString(),
29
- cwd: '/tmp/work',
30
- kind: 'general',
31
- mode: 'base',
32
- lifecycle: 'terminal',
33
- status: 'active',
34
- ...over,
35
- };
36
- }
37
- function hasTmux() {
38
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
39
- }
40
- before(() => {
41
- home = mkdtempSync(join(tmpdir(), 'crtr-placement-'));
42
- process.env['CRTR_HOME'] = home;
43
- });
44
- beforeEach(() => {
45
- closeDb();
46
- rmSync(home, { recursive: true, force: true });
47
- });
48
- after(() => {
49
- closeDb();
50
- rmSync(home, { recursive: true, force: true });
51
- delete process.env['CRTR_HOME'];
52
- });
53
- // ---------------------------------------------------------------------------
54
- // reconcileDecision — the PURE robustness core (§2.4, Q6). No tmux.
55
- // ---------------------------------------------------------------------------
56
- const cached = (over = {}) => ({
57
- pane: '%5',
58
- tmux_session: 'crtr',
59
- window: '@1',
60
- ...over,
61
- });
62
- const probe = (over = {}) => ({
63
- paneLoc: null,
64
- windowPane: null,
65
- ...over,
66
- });
67
- test('reconcileDecision: pane MOVED → cache FOLLOWS to the new session/window (same pane id)', () => {
68
- const d = reconcileDecision(cached({ pane: '%5', tmux_session: 'crtr', window: '@1' }), probe({ paneLoc: { session: 'user-sess', window: '@9' } }));
69
- assert.deepEqual(d, { kind: 'follow', pane: '%5', tmux_session: 'user-sess', window: '@9' });
70
- });
71
- test('reconcileDecision: pane GONE (paneLocation null) → LOCATION nulled', () => {
72
- const d = reconcileDecision(cached({ pane: '%5' }), probe({ paneLoc: null }));
73
- assert.deepEqual(d, { kind: 'gone' });
74
- });
75
- test('reconcileDecision: pane present + cache already current → no-op (never a stale write)', () => {
76
- const d = reconcileDecision(cached({ pane: '%5', tmux_session: 'crtr', window: '@1' }), probe({ paneLoc: { session: 'crtr', window: '@1' } }));
77
- assert.deepEqual(d, { kind: 'none' });
78
- });
79
- test('reconcileDecision: NEVER returns a stale window — a follow always carries the LIVE location', () => {
80
- // Window renumbered under the same pane (@1 → @4); follow must report @4, the
81
- // live value, not the cached @1.
82
- const d = reconcileDecision(cached({ pane: '%5', tmux_session: 'crtr', window: '@1' }), probe({ paneLoc: { session: 'crtr', window: '@4' } }));
83
- assert.equal(d.kind, 'follow');
84
- assert.equal(d.window, '@4', 'follow carries the live window, never the stale cache');
85
- });
86
- test('reconcileDecision: legacy no-pane + live window → BACKFILLS the pane from paneOfWindow', () => {
87
- const d = reconcileDecision(cached({ pane: null, tmux_session: 'crtr', window: '@1' }), probe({ windowPane: '%7' }));
88
- assert.deepEqual(d, { kind: 'backfill', pane: '%7', tmux_session: 'crtr', window: '@1' });
89
- });
90
- test('reconcileDecision: no pane + no live window pane → no-op (nothing to anchor on)', () => {
91
- assert.deepEqual(reconcileDecision(cached({ pane: null, tmux_session: 'crtr', window: '@1' }), probe({ windowPane: null })), { kind: 'none' }, 'window has no resolvable active pane → nothing to backfill');
92
- });
93
- test('reconcileDecision: no pane + no window at all → no-op (a dormant/inline-root row)', () => {
94
- assert.deepEqual(reconcileDecision(cached({ pane: null, tmux_session: null, window: null }), probe({ windowPane: '%7' })), { kind: 'none' }, 'no window to anchor a backfill on → no-op even if a stray windowPane was read');
95
- });
96
- // ---------------------------------------------------------------------------
97
- // reconcile / isNodePaneAlive — the impure shells. These call the real tmux
98
- // driver, so the machine may have a live tmux server with arbitrary panes; we
99
- // must never assume a hardcoded pane/session id is dead. Two robust strategies:
100
- // • "absent" assertions use a UNIQUELY-named session that cannot exist
101
- // (windowAlive/paneOfWindow on it are deterministically empty — robust
102
- // whether or not a server is running).
103
- // • "present/dead-pane" assertions create a real session and a real pane,
104
- // killing it to get a guaranteed-dead `%id`. Gated on hasTmux().
105
- // ---------------------------------------------------------------------------
106
- /** A session name guaranteed not to exist on any server. */
107
- const ghostSession = `crtr-ghost-${process.pid}`;
108
- /** Run `fn` with a fresh detached session; tear it down after. */
109
- async function withSession(tag, fn) {
110
- const session = `crtr-placement-${process.pid}-${tag}`;
111
- spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
112
- try {
113
- await fn(session);
114
- }
115
- finally {
116
- spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
117
- }
118
- }
119
- function tmuxOut(args) {
120
- return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
121
- }
122
- /** A guaranteed-DEAD `%pane_id` inside a still-live `window` of `session`: split
123
- * a fresh pane, capture its id, kill it. The window survives via its original
124
- * pane, so the id is dead while its old window is alive. */
125
- function makeDeadPane(session, window) {
126
- const dead = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600']);
127
- spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
128
- return dead;
129
- }
130
- test('reconcile: an unknown node is a silent no-op', () => {
131
- assert.doesNotThrow(() => reconcile('ghost'));
132
- });
133
- test('reconcile: no pane + a non-existent window → no-op (cache left intact, not nulled)', () => {
134
- // A ghost session can never resolve an active pane → backfill cannot fire and
135
- // reconcile leaves the (already pane-less) cache exactly as it was.
136
- createNode(node('n', { pane: null, tmux_session: ghostSession, window: '@1' }));
137
- reconcile('n');
138
- const r = getRow('n');
139
- assert.equal(r?.pane, null, 'still no pane');
140
- assert.equal(r?.tmux_session, ghostSession, 'session left intact (no-op, not nulled)');
141
- assert.equal(r?.window, '@1', 'window left intact');
142
- });
143
- test('reconcile: a DEAD pane → LOCATION nulled (gone branch)', { skip: !hasTmux() }, async () => {
144
- await withSession('gone', async (session) => {
145
- const window = tmuxOut(['display-message', '-p', '-t', `${session}:`, '#{window_id}']);
146
- const dead = makeDeadPane(session, window);
147
- createNode(node('n', { pane: dead, tmux_session: session, window }));
148
- reconcile('n');
149
- const r = getRow('n');
150
- assert.equal(r?.pane, null, 'pane nulled');
151
- assert.equal(r?.tmux_session, null, 'session nulled');
152
- assert.equal(r?.window, null, 'window nulled');
153
- });
154
- });
155
- // ---------------------------------------------------------------------------
156
- // isNodePaneAlive — pane-existence primary, windowAlive fallback.
157
- // ---------------------------------------------------------------------------
158
- test('isNodePaneAlive: an unknown node id is not alive', () => {
159
- assert.equal(isNodePaneAlive('ghost'), false);
160
- });
161
- test('isNodePaneAlive: pane null + a non-existent window → not alive (windowAlive fallback false)', () => {
162
- createNode(node('legacy', { pane: null, tmux_session: ghostSession, window: '@1' }));
163
- assert.equal(isNodePaneAlive('legacy'), false, 'no pane + dead window → fallback false');
164
- });
165
- test('isNodePaneAlive: a DEAD pane is NOT alive (paneExists path), id or row alike', { skip: !hasTmux() }, async () => {
166
- await withSession('dead', async (session) => {
167
- const window = tmuxOut(['display-message', '-p', '-t', `${session}:`, '#{window_id}']);
168
- const dead = makeDeadPane(session, window);
169
- createNode(node('n', { pane: dead, tmux_session: session, window }));
170
- assert.equal(isNodePaneAlive('n'), false, 'a killed pane → not alive (by id)');
171
- assert.equal(isNodePaneAlive(getRow('n')), false, 'same verdict passed a NodeRow directly');
172
- });
173
- });
174
- // ---------------------------------------------------------------------------
175
- // Real-tmux behavior (gated): the windowAlive fallback + lazy backfill, and a
176
- // live pane FOLLOWING a manual move (the Q6 robustness core).
177
- // ---------------------------------------------------------------------------
178
- test('isNodePaneAlive: pane null + a LIVE window → alive via the windowAlive fallback (+ lazy backfill)', { skip: !hasTmux() }, async () => {
179
- await withSession('alive', async (session) => {
180
- const window = tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n')[0];
181
- createNode(node('legacy', { pane: null, tmux_session: session, window }));
182
- assert.equal(isNodePaneAlive('legacy'), true, 'no pane but a live window → alive (legacy fallback)');
183
- // reconcile lazily backfills that legacy row's pane from the live window.
184
- reconcile('legacy');
185
- const got = getNode('legacy');
186
- assert.ok(got?.pane != null && got.pane.startsWith('%'), 'reconcile backfilled the pane from paneOfWindow');
187
- assert.equal(got?.tmux_session, session, 'session preserved through the backfill');
188
- assert.equal(got?.window, window, 'window preserved through the backfill');
189
- });
190
- });
191
- test('isNodePaneAlive + reconcile: a LIVE pane is alive and FOLLOWS a join-pane move to another window', { skip: !hasTmux() }, async () => {
192
- await withSession('move', async (session) => {
193
- // Original window W0 and its active pane P.
194
- const w0 = tmuxOut(['display-message', '-p', '-t', `${session}:`, '#{window_id}']);
195
- const pane = tmuxOut(['display-message', '-p', '-t', `${session}:${w0}`, '#{pane_id}']);
196
- // A second window W1 to move P into.
197
- const w1 = tmuxOut(['new-window', '-d', '-P', '-F', '#{window_id}', '-t', `${session}:`, 'sleep 600']);
198
- assert.ok(pane.startsWith('%') && w0.startsWith('@') && w1.startsWith('@') && w0 !== w1);
199
- createNode(node('m', { pane, tmux_session: session, window: w0 }));
200
- assert.equal(isNodePaneAlive('m'), true, 'a live pane reads alive');
201
- // Manually join P into W1 — the Q6 robustness scenario. P's id survives; W0
202
- // (now empty) closes. This must NOT read as a death.
203
- spawnSync('tmux', ['join-pane', '-s', pane, '-t', `${session}:${w1}`], { stdio: 'ignore' });
204
- assert.equal(isNodePaneAlive('m'), true, 'a manual join-pane is NOT a death — the pane still exists');
205
- // reconcile FOLLOWS the move: same pane id, new window.
206
- reconcile('m');
207
- const got = getNode('m');
208
- assert.equal(got?.pane, pane, 'pane id is invariant across the move');
209
- assert.equal(got?.window, w1, 'window FOLLOWED the join-pane to W1');
210
- assert.equal(got?.tmux_session, session, 'still the same session');
211
- });
212
- });
@@ -1,238 +0,0 @@
1
- // Run with: node --import tsx/esm --test src/core/__tests__/placement-revive.test.ts
2
- //
3
- // STEP 5 of the placement/focus migration: placement-aware revive (§1.4) — THE
4
- // step that kills the "unbidden windows in my session" bug. Two proofs:
5
- //
6
- // 1. The PURE bug-death test (§5.1, the crown jewel): reviveTarget(focus,
7
- // focusPaneAlive, homeSession) — the target decision in isolation, no tmux.
8
- // The load-bearing assertion is the once-focused-now-unfocused CHILD: its
9
- // focus is gone (focusOf == null) and home_session = 'crtr' even though the
10
- // OLD meta.tmux_session was tainted to a user session → backstage 'crtr',
11
- // NOT the user session. That is the audit §F/H1 defect, structurally dead.
12
- //
13
- // 2. The gated real-tmux swap-out-skipped regression (§5.2, the definitive
14
- // proof): focus a node into a user session, terminate its pi (the focus
15
- // pane collapses — Step 5 has no remain-on-exit yet), wake it via
16
- // reviveIntoPlacement → assert ZERO new windows appear in the user session
17
- // across the whole sequence; the revived node lands in home_session.
18
- //
19
- // Plus: backstage-into-home_session (the taint is IGNORED), respawn-into-focus
20
- // (F3 resume in place, no new window), and reconcileFocus following a manual
21
- // move of the focus pane (Q4). The gated tests drive a benign `sleep` command,
22
- // never a real pi — reviveNode delegates the placement decision to exactly the
23
- // reviveIntoPlacement exercised here.
24
- import { test, before, after, beforeEach } from 'node:test';
25
- import assert from 'node:assert/strict';
26
- import { mkdtempSync, rmSync } from 'node:fs';
27
- import { tmpdir } from 'node:os';
28
- import { join } from 'node:path';
29
- import { spawnSync } from 'node:child_process';
30
- import { createNode, getNode } from '../../canvas/canvas.js';
31
- import { openFocusRow, getFocusByNode, getFocusById } from '../../canvas/focuses.js';
32
- import { closeDb } from '../../canvas/db.js';
33
- import { reviveTarget, reviveIntoPlacement, reconcileFocus } from '../../runtime/placement.js';
34
- import { reviveInPlace } from '../../runtime/revive.js';
35
- let home;
36
- function node(id, over = {}) {
37
- return {
38
- node_id: id,
39
- name: id,
40
- created: new Date().toISOString(),
41
- cwd: '/tmp/work',
42
- kind: 'developer',
43
- mode: 'base',
44
- lifecycle: 'terminal',
45
- status: 'active',
46
- ...over,
47
- };
48
- }
49
- function focus(over = {}) {
50
- return { focus_id: 'f1', pane: '%a', session: 'Suser', node_id: 'M', ...over };
51
- }
52
- function hasTmux() {
53
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
54
- }
55
- before(() => {
56
- home = mkdtempSync(join(tmpdir(), 'crtr-placement-revive-'));
57
- process.env['CRTR_HOME'] = home;
58
- });
59
- beforeEach(() => {
60
- closeDb();
61
- rmSync(home, { recursive: true, force: true });
62
- });
63
- after(() => {
64
- closeDb();
65
- rmSync(home, { recursive: true, force: true });
66
- delete process.env['CRTR_HOME'];
67
- });
68
- // ---------------------------------------------------------------------------
69
- // 1. The PURE bug-death test (§5.1) — reviveTarget in isolation. No tmux.
70
- // ---------------------------------------------------------------------------
71
- test('reviveTarget: a node on a LIVE focus → resume IN PLACE in that focus pane', () => {
72
- const d = reviveTarget(focus({ pane: '%a', session: 'Suser', node_id: 'M' }), true, 'crtr');
73
- assert.deepEqual(d, { kind: 'focus-pane', pane: '%a', session: 'Suser' });
74
- });
75
- test('reviveTarget: a non-focused CHILD (home_session=crtr) → BACKSTAGE crtr (never a user session)', () => {
76
- assert.deepEqual(reviveTarget(null, false, 'crtr'), { kind: 'backstage', session: 'crtr' });
77
- });
78
- test('reviveTarget: a ROOT (home_session = its own user session) → backstage THAT session (NOT the bug)', () => {
79
- // A root legitimately lives in its own adopted user session; reviving it there
80
- // is correct. The bug is a non-focused CHILD landing in a user session — not a
81
- // root reviving into its own.
82
- assert.deepEqual(reviveTarget(null, false, 'Suser-root'), { kind: 'backstage', session: 'Suser-root' });
83
- });
84
- test('THE BUG, structurally dead: a once-focused-now-unfocused CHILD → BACKSTAGE crtr, NOT the tainted user session', () => {
85
- // The exact audit §F/H1 scenario. The child WAS focused, so its OLD
86
- // meta.tmux_session was tainted to the user session — but the focus is gone
87
- // (focusOf == null) and reviveTarget keys on home_session ('crtr'), NEVER on
88
- // the tainted tmux_session. So the revive lands backstage in 'crtr'. THIS is
89
- // the assertion that proves the "unbidden windows" bug cannot recur: even with
90
- // a tainted tmux_session sitting in the row, the target is home_session only.
91
- const d = reviveTarget(null, false, 'crtr');
92
- assert.equal(d.kind, 'backstage');
93
- assert.equal(d.session, 'crtr');
94
- assert.notEqual(d.session, 'Suser', 'NOT the tainted user session');
95
- });
96
- test('A-MAJOR-1 dead (refresh-yield twin): a focused (tainted) child reviveInPlace propagates CRTR_ROOT_SESSION = home_session (backstage), so any child it spawns lands backstage — NOT the user session', () => {
97
- // The reviveInPlace (refresh-yield) counterpart to the reviveTarget crown
98
- // jewel above. A focused child M was tainted: meta.tmux_session = the USER
99
- // session (focus taints it), home_session = the backstage `crtr`. Pre-fix,
100
- // reviveInPlace re-execed M's pi with CRTR_ROOT_SESSION = meta.tmux_session =
101
- // the user session, so any child M then spawned opened its window in the user's
102
- // session (and re-tainted that grandchild's home_session). The fix sources
103
- // CRTR_ROOT_SESSION from home_session — the taint-immune backstage. We capture
104
- // the env reviveInPlace dispatches to the pane respawn and assert it carries
105
- // the backstage, never the tainted session. Fails against the pre-fix code.
106
- const back = `crtr-back-${process.pid}`;
107
- const user = `crtr-user-${process.pid}`; // the focus taint that must be ignored
108
- createNode(node('M', { home_session: back, tmux_session: user, window: '@7', pane: '%5' }));
109
- let captured;
110
- const spy = (opts) => {
111
- captured = opts.env;
112
- return true;
113
- };
114
- reviveInPlace('M', '%5', spy);
115
- assert.equal(captured?.['CRTR_ROOT_SESSION'], back, 'children spawn into the backstage home_session');
116
- assert.notEqual(captured?.['CRTR_ROOT_SESSION'], user, 'NEVER the tainted user session (A-MAJOR-1 dead)');
117
- // The node's own LOCATION is unchanged — the re-exec is in place, the pane
118
- // never moved, so it still sits in the (still-tainted) user session.
119
- assert.equal(getNode('M').tmux_session, user, 'LOCATION preserved (in-place re-exec); only the child env is hardened');
120
- });
121
- test('reviveTarget: a focus whose pane has COLLAPSED (focusPaneAlive=false) → backstage, not a dead pane', () => {
122
- // Step-5 limitation: a focused node that fully terminates has no remain-on-exit
123
- // yet, so its focus pane collapses. focusPaneAlive=false → backstage (home),
124
- // never the gone focus pane. Still SAFE: home_session, never a user session.
125
- const d = reviveTarget(focus({ pane: '%gone', session: 'Suser', node_id: 'M' }), false, 'crtr');
126
- assert.deepEqual(d, { kind: 'backstage', session: 'crtr' });
127
- });
128
- // ---------------------------------------------------------------------------
129
- // 2. Gated real-tmux placement (skip when tmux is absent). Each test isolates
130
- // two real sessions: `user` (the user's terminal) and `back` (the backstage that
131
- // stands in for `crtr`). We drive reviveIntoPlacement with a benign `sleep`
132
- // command, never a real pi.
133
- // ---------------------------------------------------------------------------
134
- function tmuxOut(args) {
135
- return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
136
- }
137
- function windowIds(session) {
138
- return tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n').filter((s) => s !== '');
139
- }
140
- /** Hold two real, isolated sessions (`user` + `back`) open for `fn`, exposing the
141
- * user session's first window + active pane, then tear both down. */
142
- async function withUserAndBackstage(tag, fn) {
143
- const user = `crtr-rev-user-${process.pid}-${tag}`;
144
- const back = `crtr-rev-back-${process.pid}-${tag}`;
145
- spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
146
- spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
147
- try {
148
- const userWindow = windowIds(user)[0];
149
- await fn({ user, back, userWindow });
150
- }
151
- finally {
152
- spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
153
- spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
154
- }
155
- }
156
- /** Open a real, live extra pane inside `session:window` (a stand-in focus pane)
157
- * running `sleep`, and return its durable `%pane_id`. */
158
- function liveFocusPane(session, window) {
159
- return tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600']);
160
- }
161
- const launch = (over = {}) => ({
162
- command: 'sleep 600',
163
- env: {},
164
- cwd: '/tmp',
165
- name: 'm',
166
- resuming: false,
167
- ...over,
168
- });
169
- test('reviveIntoPlacement (backstage): a non-focused node opens in home_session, IGNORING its tainted tmux_session', { skip: !hasTmux() }, async () => {
170
- await withUserAndBackstage('backstage', async ({ user, back }) => {
171
- // A child whose tmux_session was TAINTED to the user session (as focus would
172
- // have done) but which is NOT focused now. home_session = the backstage.
173
- createNode(node('M', { home_session: back, tmux_session: user, window: null, pane: null }));
174
- const userBefore = windowIds(user).length;
175
- const backBefore = windowIds(back).length;
176
- const placed = reviveIntoPlacement('M', launch());
177
- assert.equal(windowIds(user).length, userBefore, 'ZERO new windows in the user session');
178
- assert.equal(windowIds(back).length, backBefore + 1, 'exactly one new window in home_session (backstage)');
179
- assert.equal(placed.session, back, 'placed into home_session, not the tainted user session');
180
- assert.ok(placed.pane?.startsWith('%'), 'a durable pane id was recorded');
181
- const m = getNode('M');
182
- assert.equal(m.tmux_session, back, 'LOCATION repointed to the backstage; the taint is overwritten');
183
- assert.equal(m.pane, placed.pane, 'the row pane matches the opened pane');
184
- });
185
- });
186
- test('§5.2 swap-out-skipped regression: focus→terminate→wake puts ZERO new windows in the user session', { skip: !hasTmux() }, async () => {
187
- await withUserAndBackstage('regress', async ({ user, back, userWindow }) => {
188
- // The bug scenario, end to end:
189
- // 1. M is a child focused into the user session: a real focus pane in
190
- // `user`, a focus row on it, and (the taint) tmux_session=user.
191
- // 2. Its pi terminates → the focus pane COLLAPSES (Step 5 has no
192
- // remain-on-exit yet) — modelled by killing the focus pane.
193
- // 3. The daemon/reviveNode wakes M → reviveIntoPlacement.
194
- // The fix: M lands in home_session (backstage), NOT a new window in `user`.
195
- const focusPane = liveFocusPane(user, userWindow);
196
- createNode(node('M', { home_session: back, tmux_session: user, window: userWindow, pane: focusPane }));
197
- openFocusRow('f1', focusPane, user, 'M');
198
- const userBefore = windowIds(user).length;
199
- const backBefore = windowIds(back).length;
200
- // Terminate: the focus pane collapses (no remain-on-exit in Step 5).
201
- spawnSync('tmux', ['kill-pane', '-t', focusPane], { stdio: 'ignore' });
202
- const placed = reviveIntoPlacement('M', launch({ resuming: true }));
203
- assert.equal(windowIds(user).length, userBefore, 'ZERO new windows opened in the user session across the sequence');
204
- assert.equal(windowIds(back).length, backBefore + 1, 'the revived node landed in home_session (backstage)');
205
- assert.equal(placed.session, back, 'reviveIntoPlacement targeted home_session, not the tainted user session');
206
- assert.equal(getNode('M').tmux_session, back, 'LOCATION repointed to the backstage');
207
- });
208
- });
209
- test('reviveIntoPlacement (focus-pane): a node on a LIVE focus resumes IN PLACE — no new window anywhere', { skip: !hasTmux() }, async () => {
210
- await withUserAndBackstage('inplace', async ({ user, back, userWindow }) => {
211
- const focusPane = liveFocusPane(user, userWindow);
212
- createNode(node('M', { home_session: back, tmux_session: user, window: userWindow, pane: focusPane }));
213
- openFocusRow('f1', focusPane, user, 'M');
214
- const userBefore = windowIds(user).length;
215
- const backBefore = windowIds(back).length;
216
- const placed = reviveIntoPlacement('M', launch({ resuming: true }));
217
- assert.equal(placed.pane, focusPane, 'resumed into the SAME focus pane id (respawn-pane -k, no new pane)');
218
- assert.equal(windowIds(user).length, userBefore, 'no new window in the user session (resume in place)');
219
- assert.equal(windowIds(back).length, backBefore, 'no new window in the backstage either');
220
- assert.equal(placed.session, user, 'the focus pane lives in the user session — the F3 resume stays there (desired)');
221
- assert.equal(getFocusByNode('M')?.pane, focusPane, 'the focus row still anchors the same pane');
222
- });
223
- });
224
- test('reconcileFocus: follows a manual move of the focus pane to another session (Q4)', { skip: !hasTmux() }, async () => {
225
- await withUserAndBackstage('recfocus', async ({ user, back, userWindow }) => {
226
- // Focus pane starts in `user`; the row caches session=user.
227
- const focusPane = liveFocusPane(user, userWindow);
228
- openFocusRow('f1', focusPane, user, 'M');
229
- // The user moves the focus pane into the backstage session (join-pane). The
230
- // pane id survives; the row's cached session is now stale.
231
- const backWindow = windowIds(back)[0];
232
- spawnSync('tmux', ['join-pane', '-s', focusPane, '-t', `${back}:${backWindow}`], { stdio: 'ignore' });
233
- reconcileFocus('f1');
234
- const f = getFocusById('f1');
235
- assert.equal(f?.pane, focusPane, 'pane id is invariant across the move');
236
- assert.equal(f?.session, back, 'reconcileFocus FOLLOWED the move to the backstage session');
237
- });
238
- });