railmux 0.3.3.dev3__tar.gz → 0.3.3.dev4__tar.gz

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 (161) hide show
  1. {railmux-0.3.3.dev3/src/railmux.egg-info → railmux-0.3.3.dev4}/PKG-INFO +4 -2
  2. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/README.md +3 -1
  3. railmux-0.3.3.dev4/src/railmux/__init__.py +1 -0
  4. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/fast_display_client.py +2 -2
  5. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/fast_display_history.py +12 -3
  6. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/fast_display_protocol.py +20 -7
  7. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/fast_display_server.py +26 -3
  8. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/tmux_ctl.py +49 -0
  9. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/app.py +89 -0
  10. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4/src/railmux.egg-info}/PKG-INFO +4 -2
  11. railmux-0.3.3.dev4/tests/test_codex_lineage.py +157 -0
  12. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_fast_full_window.py +125 -12
  13. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_release_workflow.py +5 -2
  14. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tmux_ctl.py +37 -0
  15. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tmux_integration.py +41 -0
  16. railmux-0.3.3.dev3/src/railmux/__init__.py +0 -1
  17. railmux-0.3.3.dev3/tests/test_codex_lineage.py +0 -64
  18. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/LICENSE +0 -0
  19. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/MANIFEST.in +0 -0
  20. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/pyproject.toml +0 -0
  21. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/setup.cfg +0 -0
  22. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/__main__.py +0 -0
  23. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/atomic_file.py +0 -0
  24. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/background_index.py +0 -0
  25. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/cli.py +0 -0
  26. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/codex_index.py +0 -0
  27. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/config.py +0 -0
  28. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/config_cli.py +0 -0
  29. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/diagnostics.py +0 -0
  30. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/discovery.py +0 -0
  31. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/display_transport.py +0 -0
  32. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/fast_display_input.py +0 -0
  33. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/favorites.py +0 -0
  34. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/fuzzy.py +0 -0
  35. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/help_workspace.py +0 -0
  36. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/launcher.py +0 -0
  37. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/legacy_sessions.py +0 -0
  38. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/local_clipboard.py +0 -0
  39. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/local_open.py +0 -0
  40. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/models.py +0 -0
  41. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/modes.py +0 -0
  42. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/mouse_manager.py +0 -0
  43. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/orphan_marker.py +0 -0
  44. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/pane_surface.py +0 -0
  45. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/path_codec.py +0 -0
  46. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/remote_config.py +0 -0
  47. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/renames.py +0 -0
  48. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/restart_state.py +0 -0
  49. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/runtime_config.py +0 -0
  50. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/scroll_agent.py +0 -0
  51. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/scroll_manager.py +0 -0
  52. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/selection_isolation.py +0 -0
  53. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/self_update.py +0 -0
  54. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/session_cache.py +0 -0
  55. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/session_index.py +0 -0
  56. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/setting_contracts.py +0 -0
  57. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/settings.py +0 -0
  58. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ssh_args.py +0 -0
  59. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ssh_compat.py +0 -0
  60. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ssh_display_diagnostics.py +0 -0
  61. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ssh_doctor.py +0 -0
  62. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/system_deps.py +0 -0
  63. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/terminal_status.py +0 -0
  64. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/tmux_binding_manager.py +0 -0
  65. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/tmux_health.py +0 -0
  66. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/tmux_server.py +0 -0
  67. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/tool_panes.py +0 -0
  68. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/transcript.py +0 -0
  69. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/__init__.py +0 -0
  70. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/_widgets.py +0 -0
  71. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/keymap.py +0 -0
  72. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/modals.py +0 -0
  73. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/projects_pane.py +0 -0
  74. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/running_pane.py +0 -0
  75. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/sessions_pane.py +0 -0
  76. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/sidebar.py +0 -0
  77. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/statusbar.py +0 -0
  78. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux/ui/workspace.py +0 -0
  79. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux.egg-info/SOURCES.txt +0 -0
  80. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux.egg-info/dependency_links.txt +0 -0
  81. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux.egg-info/entry_points.txt +0 -0
  82. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux.egg-info/requires.txt +0 -0
  83. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/src/railmux.egg-info/top_level.txt +0 -0
  84. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_agent_workspace.py +0 -0
  85. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_atomic_file.py +0 -0
  86. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_background_index.py +0 -0
  87. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_cli.py +0 -0
  88. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_codex_delete.py +0 -0
  89. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_codex_index.py +0 -0
  90. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_codex_transcript.py +0 -0
  91. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_config.py +0 -0
  92. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_config_cli.py +0 -0
  93. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_denested_transport_benchmark.py +0 -0
  94. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_diagnostics.py +0 -0
  95. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_discovery.py +0 -0
  96. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_display_transport.py +0 -0
  97. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_focus_highlight.py +0 -0
  98. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_fullscreen_binding.py +0 -0
  99. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_fuzzy.py +0 -0
  100. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_help_agent.py +0 -0
  101. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_help_workspace.py +0 -0
  102. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_history_cleanup.py +0 -0
  103. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_keymap.py +0 -0
  104. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_launcher.py +0 -0
  105. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_layout_profile.py +0 -0
  106. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_legacy_sessions.py +0 -0
  107. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_local_clipboard.py +0 -0
  108. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_local_open.py +0 -0
  109. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_modals.py +0 -0
  110. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_mode_selection.py +0 -0
  111. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_modes.py +0 -0
  112. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_mouse_manager.py +0 -0
  113. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_new_project.py +0 -0
  114. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_options.py +0 -0
  115. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_orphan_recovery.py +0 -0
  116. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_pane_surface.py +0 -0
  117. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_path_codec.py +0 -0
  118. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_projects_pane.py +0 -0
  119. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_release_notes.py +0 -0
  120. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_remote_config.py +0 -0
  121. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_renames.py +0 -0
  122. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_restart_state.py +0 -0
  123. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_running_pane.py +0 -0
  124. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_running_sort.py +0 -0
  125. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_runtime_config.py +0 -0
  126. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_scroll_agent.py +0 -0
  127. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_scroll_manager.py +0 -0
  128. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_selection_isolation.py +0 -0
  129. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_self_update.py +0 -0
  130. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_session_cache.py +0 -0
  131. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_session_index.py +0 -0
  132. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_sessions_pane.py +0 -0
  133. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_setting_contracts.py +0 -0
  134. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_settings.py +0 -0
  135. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_sidebar.py +0 -0
  136. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_soft_quit.py +0 -0
  137. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_ssh_args.py +0 -0
  138. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_ssh_compat.py +0 -0
  139. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_ssh_display_benchmark.py +0 -0
  140. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_ssh_display_diagnostics.py +0 -0
  141. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_ssh_doctor.py +0 -0
  142. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_status.py +0 -0
  143. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_statusbar.py +0 -0
  144. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_system_deps.py +0 -0
  145. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_terminal_size.py +0 -0
  146. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_terminal_status.py +0 -0
  147. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_termux_local_touch.py +0 -0
  148. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tmux_binding_manager.py +0 -0
  149. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tmux_health.py +0 -0
  150. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tmux_server.py +0 -0
  151. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tmux_status.py +0 -0
  152. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_tool_panes.py +0 -0
  153. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_transcript.py +0 -0
  154. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_utf8_state.py +0 -0
  155. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_web_demo_recorder.py +0 -0
  156. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_wheel_smoke.py +0 -0
  157. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_widgets.py +0 -0
  158. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tests/test_yolo_prompt.py +0 -0
  159. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tools/denested_transport_benchmark.py +0 -0
  160. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tools/release_notes.py +0 -0
  161. {railmux-0.3.3.dev3 → railmux-0.3.3.dev4}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.3.3.dev3
3
+ Version: 0.3.3.dev4
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -373,7 +373,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
373
373
  internal context and encrypted reasoning are hidden. Codex rewind lineages and
374
374
  Claude Code `parentUuid` branches are projected onto the provider's current
375
375
  conversation: the retained prefix and replacement suffix remain visible, while
376
- the abandoned suffix is hidden.
376
+ the abandoned suffix is hidden. When Railmux observes a running Codex session
377
+ advance to its rewind child, it also removes the abandoned terminal screen and
378
+ tmux scrollback before Codex redraws the live pane.
377
379
 
378
380
  Preview opens at the latest activity in `less`; large sessions are limited to
379
381
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -331,7 +331,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
331
331
  internal context and encrypted reasoning are hidden. Codex rewind lineages and
332
332
  Claude Code `parentUuid` branches are projected onto the provider's current
333
333
  conversation: the retained prefix and replacement suffix remain visible, while
334
- the abandoned suffix is hidden.
334
+ the abandoned suffix is hidden. When Railmux observes a running Codex session
335
+ advance to its rewind child, it also removes the abandoned terminal screen and
336
+ tmux scrollback before Codex redraws the live pane.
335
337
 
336
338
  Preview opens at the latest activity in `less`; large sessions are limited to
337
339
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -0,0 +1 @@
1
+ __version__ = "0.3.3.dev4"
@@ -1074,7 +1074,7 @@ class TerminalSurface:
1074
1074
  self,
1075
1075
  requested: TerminalMode,
1076
1076
  ) -> TerminalMode:
1077
- """Mirror only input-affecting modes explicitly carried by protocol v14."""
1077
+ """Mirror only input-affecting modes explicitly carried by protocol v15."""
1078
1078
  disabled = self.terminal_modes & ~requested
1079
1079
  enabled = requested & ~self.terminal_modes
1080
1080
  controls: list[bytes] = []
@@ -2013,7 +2013,7 @@ def _finish_remote_attach(
2013
2013
  raise ProbeError("remote display helper failed before attaching")
2014
2014
 
2015
2015
  _stop_unstarted_remote(process)
2016
- # A current v14 helper holds the mutex only while registering its exact tmux
2016
+ # A current v15 helper holds the mutex only while registering its exact tmux
2017
2017
  # child. Give that ordinary race one fresh SSH process before presenting
2018
2018
  # the explicit legacy-lock takeover choice.
2019
2019
  time.sleep(_REMOTE_ATTACH_RETRY_DELAY)
@@ -207,7 +207,11 @@ class LocalHistoryView:
207
207
  restore_live = False
208
208
  for pane_id, viewport in tuple(self.viewports.items()):
209
209
  route = routes.get(pane_id)
210
- if route is None or not self._same_geometry(viewport.snapshot, route):
210
+ if (
211
+ route is None
212
+ or not self._same_geometry(viewport.snapshot, route)
213
+ or not self._history_source_matches(viewport.snapshot, route)
214
+ ):
211
215
  self.cancel_pane(pane_id)
212
216
  restore_live = True
213
217
  return HistoryAction(restore_live=restore_live)
@@ -217,10 +221,11 @@ class LocalHistoryView:
217
221
  left: HistorySnapshot,
218
222
  right: HistorySnapshot,
219
223
  ) -> bool:
220
- """Do not combine native, transcript, and undecided history."""
224
+ """Do not combine different providers or terminal generations."""
221
225
  return (
222
226
  left.transcript_backed == right.transcript_backed
223
227
  and left.history_choice_required == right.history_choice_required
228
+ and left.generation == right.generation
224
229
  )
225
230
 
226
231
  @staticmethod
@@ -813,7 +818,11 @@ class LocalHistoryView:
813
818
  ),
814
819
  None,
815
820
  )
816
- if route is None or not self._same_geometry(route, snapshot):
821
+ if (
822
+ route is None
823
+ or not self._same_geometry(route, snapshot)
824
+ or not self._history_source_matches(route, snapshot)
825
+ ):
817
826
  return HistoryAction()
818
827
  viewport = self.viewports.get(pending.pane_id)
819
828
  if viewport is None:
@@ -1,4 +1,4 @@
1
- """Private v14 framing for the coalesced full-window SSH display."""
1
+ """Private v15 framing for the coalesced full-window SSH display."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -8,9 +8,9 @@ from dataclasses import dataclass
8
8
  from enum import IntEnum, IntFlag
9
9
 
10
10
 
11
- DISPLAY_MAGIC = b"RMUXD14\x00"
12
- INPUT_MAGIC = b"RMUXK14\x00"
13
- PROTOCOL_VERSION = 14
11
+ DISPLAY_MAGIC = b"RMUXD15\x00"
12
+ INPUT_MAGIC = b"RMUXK15\x00"
13
+ PROTOCOL_VERSION = 15
14
14
  REMOTE_CONFIG_PROTOCOL = 1
15
15
  LENGTH_BYTES = 4
16
16
  REMOTE_HELLO_PREFIX = b"RAILMUX-REMOTE/1 "
@@ -28,10 +28,10 @@ MAX_HISTORY_PANES = 8
28
28
  MAX_CLIPBOARD_BYTES = 64 * 1024
29
29
  MAX_PATH_BYTES = 4096
30
30
  _UPDATE_METADATA = struct.Struct(">BIHHHHBHI")
31
- _HISTORY_METADATA = struct.Struct(">IIHHHHBI")
31
+ _HISTORY_METADATA = struct.Struct(">IIHHHHBQI")
32
32
  _HISTORY_REQUEST = struct.Struct(">IHHH")
33
33
  _HISTORY_BATCH_METADATA = struct.Struct(">II")
34
- _HISTORY_PANE_METADATA = struct.Struct(">IHHHHB")
34
+ _HISTORY_PANE_METADATA = struct.Struct(">IHHHHBQ")
35
35
  _PREFETCH_HISTORY_REQUEST = struct.Struct(">IH")
36
36
  _PATH_REQUEST = struct.Struct(">IIH")
37
37
  _PATH_RESULT = struct.Struct(">IBBH")
@@ -143,6 +143,7 @@ class HistorySnapshot:
143
143
  more_available: bool = False
144
144
  transcript_available: bool = False
145
145
  history_choice_required: bool = False
146
+ generation: int = 0
146
147
 
147
148
 
148
149
  @dataclass(frozen=True)
@@ -302,6 +303,7 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
302
303
  snapshot.more_available,
303
304
  snapshot.transcript_available,
304
305
  snapshot.history_choice_required,
306
+ snapshot.generation,
305
307
  )):
306
308
  raise ValueError("a rejected history response must be empty")
307
309
  pane_number = 0
@@ -317,6 +319,8 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
317
319
  raise ValueError("invalid history position")
318
320
  if len(snapshot.lines) < snapshot.height:
319
321
  raise ValueError("history snapshot is shorter than its viewport")
322
+ if not 0 <= snapshot.generation <= 0xFFFFFFFFFFFFFFFF:
323
+ raise ValueError("invalid history generation")
320
324
  raw_lines = _pack_history_lines(snapshot.lines)
321
325
  compressed = zlib.compress(raw_lines, level=3)
322
326
  metadata = _HISTORY_METADATA.pack(
@@ -327,6 +331,7 @@ def encode_history_snapshot(snapshot: HistorySnapshot) -> bytes:
327
331
  snapshot.width,
328
332
  snapshot.height,
329
333
  _history_capabilities(snapshot),
334
+ snapshot.generation,
330
335
  len(raw_lines),
331
336
  )
332
337
  payload = bytes((int(OutputKind.HISTORY),)) + metadata + compressed
@@ -360,6 +365,8 @@ def encode_history_batch(batch: HistoryBatch) -> bytes:
360
365
  raise ValueError("invalid history position")
361
366
  if len(snapshot.lines) < snapshot.height:
362
367
  raise ValueError("history snapshot is shorter than its viewport")
368
+ if not 0 <= snapshot.generation <= 0xFFFFFFFFFFFFFFFF:
369
+ raise ValueError("invalid history generation")
363
370
  packed_lines = _pack_history_lines(snapshot.lines)
364
371
  pane_metadata = _HISTORY_PANE_METADATA.pack(
365
372
  pane_number,
@@ -368,6 +375,7 @@ def encode_history_batch(batch: HistoryBatch) -> bytes:
368
375
  snapshot.width,
369
376
  snapshot.height,
370
377
  _history_capabilities(snapshot),
378
+ snapshot.generation,
371
379
  )
372
380
  total += len(pane_metadata) + len(packed_lines)
373
381
  if total > MAX_SCREEN_BYTES:
@@ -611,6 +619,7 @@ class ServerMessageDecoder:
611
619
  width,
612
620
  height,
613
621
  raw_capabilities,
622
+ generation,
614
623
  raw_size,
615
624
  ) = (
616
625
  _HISTORY_METADATA.unpack(body[:_HISTORY_METADATA.size])
@@ -628,6 +637,7 @@ class ServerMessageDecoder:
628
637
  height,
629
638
  lines,
630
639
  raw_capabilities,
640
+ generation,
631
641
  )):
632
642
  raise ValueError("invalid rejected history response")
633
643
  pane_id = None
@@ -662,6 +672,7 @@ class ServerMessageDecoder:
662
672
  history_choice_required=bool(
663
673
  raw_capabilities & _HISTORY_CHOICE_REQUIRED
664
674
  ),
675
+ generation=generation,
665
676
  ))
666
677
  continue
667
678
  if output_kind is OutputKind.HISTORY_BATCH:
@@ -691,6 +702,7 @@ class ServerMessageDecoder:
691
702
  width,
692
703
  height,
693
704
  raw_capabilities,
705
+ generation,
694
706
  ) = _HISTORY_PANE_METADATA.unpack(
695
707
  raw[offset:offset + _HISTORY_PANE_METADATA.size]
696
708
  )
@@ -729,6 +741,7 @@ class ServerMessageDecoder:
729
741
  history_choice_required=bool(
730
742
  raw_capabilities & _HISTORY_CHOICE_REQUIRED
731
743
  ),
744
+ generation=generation,
732
745
  ))
733
746
  if offset != len(raw):
734
747
  raise ValueError("trailing history batch data")
@@ -843,7 +856,7 @@ class ServerMessageDecoder:
843
856
 
844
857
 
845
858
  class ScreenUpdateDecoder:
846
- """Compatibility view which ignores v14 control-response messages."""
859
+ """Compatibility view which ignores v15 control-response messages."""
847
860
 
848
861
  def __init__(self) -> None:
849
862
  self._decoder = ServerMessageDecoder()
@@ -45,6 +45,7 @@ from railmux import (
45
45
  __version__,
46
46
  local_open,
47
47
  restart_state,
48
+ tmux_ctl,
48
49
  tmux_health,
49
50
  tmux_server,
50
51
  )
@@ -403,6 +404,7 @@ class _PaneGeometry:
403
404
  transcript_source: str | None = None
404
405
  transcript_backed: bool = False
405
406
  claude_history_policy: str = "ask"
407
+ history_generation: int = 0
406
408
 
407
409
 
408
410
  @dataclass(frozen=True)
@@ -421,6 +423,24 @@ _INFERRED_TRANSCRIPT_LIMIT = 8
421
423
  _TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024
422
424
  _SESSION_BINDING_OPTION = "@railmux_binding_v1"
423
425
  _SWAP_OPTIONS = ("@railmux_swap_primary", "@railmux_swap_secondary")
426
+ _HISTORY_GENERATION_RE = re.compile(
427
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
428
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
429
+ )
430
+
431
+
432
+ def _history_generation(marker: str) -> int:
433
+ """Map one validated provider UUID to a bounded opaque wire epoch."""
434
+ if not _HISTORY_GENERATION_RE.fullmatch(marker):
435
+ return 0
436
+ return int.from_bytes(
437
+ hashlib.blake2b(
438
+ marker.lower().encode("ascii"),
439
+ digest_size=8,
440
+ person=b"railmux-history",
441
+ ).digest(),
442
+ "big",
443
+ )
424
444
 
425
445
 
426
446
  _ANSI_FG = {
@@ -834,6 +854,7 @@ def _list_agent_panes(
834
854
  "#{history_size} #{alternate_on} #{mouse_any_flag} "
835
855
  f"#{{{tmux_server.HISTORY_SOURCE_OPTION}}} "
836
856
  f"#{{{tmux_server.TRANSCRIPT_SOURCE_OPTION}}} "
857
+ f"#{{{tmux_ctl.RAILMUX_HISTORY_GENERATION_OPTION}}} "
837
858
  f"#{{{TOOL_PANE_OPTION}}}",
838
859
  ),
839
860
  stderr=subprocess.DEVNULL,
@@ -849,7 +870,7 @@ def _list_agent_panes(
849
870
  for raw_row in output.splitlines():
850
871
  fields = raw_row.split(" ")
851
872
  if (
852
- len(fields) != 16
873
+ len(fields) != 17
853
874
  or fields[0] != session_id
854
875
  or fields[2] not in ("0", "1")
855
876
  or fields[3] not in ("0", "1")
@@ -880,7 +901,7 @@ def _list_agent_panes(
880
901
  ):
881
902
  return ()
882
903
  seen.add(pane_id)
883
- if is_tool_pane_marker(fields[15], outer_session_id=session_id):
904
+ if is_tool_pane_marker(fields[16], outer_session_id=session_id):
884
905
  continue
885
906
  history_server = None
886
907
  history_pane_id = None
@@ -931,6 +952,7 @@ def _list_agent_panes(
931
952
  transcript_source=transcript_marker,
932
953
  transcript_backed=transcript_backed,
933
954
  claude_history_policy=claude_history_policy,
955
+ history_generation=_history_generation(fields[15]),
934
956
  ),
935
957
  )
936
958
  )
@@ -1376,6 +1398,7 @@ def _capture_pane_history(
1376
1398
  else pane.history_size + pane.height > len(lines)
1377
1399
  )
1378
1400
  ),
1401
+ generation=pane.history_generation,
1379
1402
  )
1380
1403
 
1381
1404
 
@@ -1916,7 +1939,7 @@ def render_rows(screen: object) -> tuple[bytes, ...]:
1916
1939
 
1917
1940
 
1918
1941
  def terminal_modes_for_screen(screen: object) -> TerminalMode:
1919
- """Project pyte's private-mode set onto the bounded v14 wire allowlist."""
1942
+ """Project pyte's private-mode set onto the bounded v15 wire allowlist."""
1920
1943
  terminal_modes = TerminalMode.NONE
1921
1944
  if 2004 << 5 in screen.mode:
1922
1945
  terminal_modes |= TerminalMode.BRACKETED_PASTE
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
  import re
11
+ import signal
11
12
  import shlex
12
13
  import shutil
13
14
  import subprocess
@@ -302,6 +303,12 @@ _ROLLOUT_UUID_RE = re.compile(
302
303
  )
303
304
 
304
305
 
306
+ def rollout_uuid_from_path(path: Path) -> str | None:
307
+ """Return the filename UUID for a canonical Codex rollout path."""
308
+ match = _ROLLOUT_UUID_RE.search(path.name)
309
+ return match.group(1) if match is not None else None
310
+
311
+
305
312
  def pane_pid_for_session(session_name: str) -> int | None:
306
313
  """Return the first pane's PID for *session_name*, or None.
307
314
 
@@ -1818,6 +1825,7 @@ _PREFIX_TARGET_MARKER = "railmux-target-toggle-v1"
1818
1825
  RAILMUX_CONTROLLER_OPTION = "@railmux_controller_pane"
1819
1826
  RAILMUX_TARGET_OPTION = "@railmux_target_pane"
1820
1827
  RAILMUX_HISTORY_PREVIEW_OPTION = "@railmux_history_preview_v1"
1828
+ RAILMUX_HISTORY_GENERATION_OPTION = "@railmux_history_generation_v1"
1821
1829
  # tmux names function keys only through F12. Private controller routes use
1822
1830
  # canonical xterm sequences in literal mode, so they cannot be confused with
1823
1831
  # user input forwarded to an agent pane.
@@ -3347,6 +3355,47 @@ def exact_pane_alive(identity: PaneIdentity) -> bool:
3347
3355
  )
3348
3356
 
3349
3357
 
3358
+ def reset_pane_history(identity: PaneIdentity) -> bool:
3359
+ """Reset visible bytes and scrollback for the exact observed live pane.
3360
+
3361
+ Revalidate the immutable session, pane process, and window before issuing
3362
+ one tmux command queue that resets the terminal model and clears history.
3363
+ Then send the pane's foreground process group an ordinary same-size
3364
+ ``SIGWINCH`` so its TUI repaints and reasserts terminal modes without a
3365
+ geometry change or injected keyboard input.
3366
+ """
3367
+ current = pane_identity(identity.pane_id)
3368
+ if (current is None or current.dead
3369
+ or current.pane_pid != identity.pane_pid
3370
+ or current.session_id != identity.session_id
3371
+ or current.window_id != identity.window_id):
3372
+ return False
3373
+ try:
3374
+ subprocess.check_call(
3375
+ [
3376
+ "tmux", "send-keys", "-R", "-t", identity.pane_id,
3377
+ ";", "clear-history", "-t", identity.pane_id,
3378
+ ],
3379
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
3380
+ )
3381
+ except (OSError, subprocess.CalledProcessError):
3382
+ return False
3383
+
3384
+ current = pane_identity(identity.pane_id)
3385
+ if (current is None or current.dead
3386
+ or current.pane_pid != identity.pane_pid
3387
+ or current.session_id != identity.session_id
3388
+ or current.window_id != identity.window_id):
3389
+ return True
3390
+ try:
3391
+ os.killpg(os.getpgid(current.pane_pid), signal.SIGWINCH)
3392
+ except (OSError, ValueError):
3393
+ # The presentation cache is already clean. A provider that exited or
3394
+ # cannot be signalled will repaint on its next ordinary terminal write.
3395
+ pass
3396
+ return True
3397
+
3398
+
3350
3399
  def kill_session_identity(identity: PaneIdentity) -> bool:
3351
3400
  """Kill an exact immutable tmux session, never a reused session name."""
3352
3401
  current = pane_identity(identity.pane_id)
@@ -365,6 +365,12 @@ class _Running:
365
365
  legacy_server: tmux_server.TmuxServerTarget | None = None
366
366
  legacy_session_id: str | None = None
367
367
  provider_session_id: str | None = None
368
+ # Last canonical Codex rollout observed for this live pane. Rewind creates
369
+ # a child rollout while leaving tmux's byte scrollback intact; tracking the
370
+ # provider generation lets the refresh path discard only that stale
371
+ # presentation cache when the exact live writer advances to its child.
372
+ codex_canonical_session_id: str | None = None
373
+ codex_history_generation_stamped: bool = False
368
374
 
369
375
  @property
370
376
  def is_placeholder(self) -> bool:
@@ -8530,6 +8536,9 @@ class App:
8530
8536
  meta = self._session_cache.get(
8531
8537
  r.project, r.logical_session_id or r.key)
8532
8538
  if meta is not None:
8539
+ if session_type == "codex":
8540
+ self._sync_codex_rewind_scrollback(
8541
+ r, meta, server, codex_rollout_probes)
8533
8542
  if meta.title:
8534
8543
  r.label = f"{meta.project.display_name}/{meta.display_title}"
8535
8544
  r.status = self._effective_status(
@@ -8548,6 +8557,86 @@ class App:
8548
8557
  self._maybe_resort_running()
8549
8558
  self._render_running_pane()
8550
8559
 
8560
+ def _sync_codex_rewind_scrollback(
8561
+ self,
8562
+ running: _Running,
8563
+ meta: SessionMeta,
8564
+ server: tmux_ctl.ServerSnapshot | None,
8565
+ codex_rollout_probes: dict[str, set[str] | None] | None,
8566
+ ) -> None:
8567
+ """Drop stale tmux bytes after an exact live Codex rewind transition.
8568
+
8569
+ The provider rollout is the history authority, but Codex keeps using
8570
+ the same terminal pane after a rewind. Its abandoned suffix can
8571
+ therefore survive in tmux scrollback even though the canonical JSONL
8572
+ is already correct. Baseline the first observed rollout, then clear
8573
+ only when the next canonical rollout names that baseline as its parent.
8574
+ On procfs systems the new rollout must also be open in this exact pane's
8575
+ process tree; an unavailable probe degrades to the provider lineage
8576
+ link, while a negative probe waits for a later refresh.
8577
+ """
8578
+ current_id = meta.session_id
8579
+ previous_id = running.codex_canonical_session_id
8580
+ if previous_id is None:
8581
+ running.codex_canonical_session_id = current_id
8582
+ self._stamp_codex_history_generation(running, current_id)
8583
+ return
8584
+ if current_id == previous_id:
8585
+ if not running.codex_history_generation_stamped:
8586
+ self._stamp_codex_history_generation(running, current_id)
8587
+ return
8588
+ if (running.is_legacy or meta.forked_from_id != previous_id
8589
+ or current_id not in self._codex_lineage_ids(previous_id)):
8590
+ return
8591
+
8592
+ rollout_id = tmux_ctl.rollout_uuid_from_path(meta.jsonl_path)
8593
+ if rollout_id != current_id:
8594
+ return
8595
+ probes = codex_rollout_probes if codex_rollout_probes is not None else {}
8596
+ if running.tmux_name in probes:
8597
+ open_ids = probes[running.tmux_name]
8598
+ else:
8599
+ open_ids = self._codex_rollout_ids(running, server)
8600
+ probes[running.tmux_name] = open_ids
8601
+ if open_ids is not None and current_id not in open_ids:
8602
+ return
8603
+
8604
+ identity = self._codex_real_pane_identity(running)
8605
+ if identity is None or not tmux_ctl.reset_pane_history(identity):
8606
+ return
8607
+ running.codex_canonical_session_id = current_id
8608
+ running.codex_history_generation_stamped = tmux_ctl.set_pane_user_option(
8609
+ identity.pane_id,
8610
+ tmux_ctl.RAILMUX_HISTORY_GENERATION_OPTION,
8611
+ current_id,
8612
+ )
8613
+
8614
+ def _codex_real_pane_identity(
8615
+ self, running: _Running,
8616
+ ) -> tmux_ctl.PaneIdentity | None:
8617
+ """Resolve a Codex pane across its detached home or swap location."""
8618
+ real_pane = self._display_transport().displayed_real_pane(
8619
+ running.tmux_name)
8620
+ if real_pane is not None:
8621
+ return tmux_ctl.pane_identity(real_pane)
8622
+ topology = tmux_ctl.session_topology(running.tmux_name)
8623
+ return topology.single_live_pane if topology is not None else None
8624
+
8625
+ def _stamp_codex_history_generation(
8626
+ self, running: _Running, session_id: str,
8627
+ ) -> None:
8628
+ """Publish a content-free epoch for full-window SSH history caches."""
8629
+ if running.is_legacy:
8630
+ return
8631
+ identity = self._codex_real_pane_identity(running)
8632
+ if identity is None:
8633
+ return
8634
+ running.codex_history_generation_stamped = tmux_ctl.set_pane_user_option(
8635
+ identity.pane_id,
8636
+ tmux_ctl.RAILMUX_HISTORY_GENERATION_OPTION,
8637
+ session_id,
8638
+ )
8639
+
8551
8640
  def _maybe_resort_running(self) -> None:
8552
8641
  """Re-order the Running registry by recency, at most once per minute.
8553
8642
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.3.3.dev3
3
+ Version: 0.3.3.dev4
4
4
  Summary: Terminal UI to navigate, resume, and start Claude Code and Codex sessions
5
5
  Author-email: Rightglow <zhang.taian@foxmail.com>
6
6
  License-Expression: MIT
@@ -373,7 +373,9 @@ messages, tool calls, and abbreviated tool output are colour-coded, while
373
373
  internal context and encrypted reasoning are hidden. Codex rewind lineages and
374
374
  Claude Code `parentUuid` branches are projected onto the provider's current
375
375
  conversation: the retained prefix and replacement suffix remain visible, while
376
- the abandoned suffix is hidden.
376
+ the abandoned suffix is hidden. When Railmux observes a running Codex session
377
+ advance to its rewind child, it also removes the abandoned terminal screen and
378
+ tmux scrollback before Codex redraws the live pane.
377
379
 
378
380
  Preview opens at the latest activity in `less`; large sessions are limited to
379
381
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -0,0 +1,157 @@
1
+ """App-level identity tests for Codex rewind/fork lineages."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock
6
+
7
+ from railmux import tmux_ctl
8
+ from railmux.favorites import Favorites
9
+ from railmux.models import Project, SessionMeta
10
+ from railmux.ui.app import App, _Running
11
+
12
+
13
+ class _LineageIndex:
14
+ def lineage_ids(self, session_id: str, *, refresh: bool = True):
15
+ if session_id in {"root", "middle", "leaf"}:
16
+ return frozenset({"root", "middle", "leaf"})
17
+ return frozenset()
18
+
19
+
20
+ def _app_with_running_root() -> tuple[App, _Running]:
21
+ app = App.__new__(App)
22
+ project = Project(Path("/project"), "-project", Path(), 1, 0.0)
23
+ running = _Running(
24
+ key="root",
25
+ tmux_name="cx-root",
26
+ label="project/conversation",
27
+ project=project,
28
+ session_type="codex",
29
+ )
30
+ app._running = {"root": running}
31
+ app._codex_index = _LineageIndex()
32
+ return app, running
33
+
34
+
35
+ def test_codex_fork_uuid_resolves_to_existing_running_owner() -> None:
36
+ app, running = _app_with_running_root()
37
+
38
+ assert app._by_session_id("leaf") is running
39
+ assert app._by_session_id("middle") is running
40
+ assert app._by_session_id("unrelated") is None
41
+
42
+
43
+ def test_running_ids_include_every_codex_lineage_alias() -> None:
44
+ app, _ = _app_with_running_root()
45
+
46
+ assert app._running_session_ids() == {"root", "middle", "leaf"}
47
+
48
+
49
+ def test_live_writer_matches_any_rewind_alias() -> None:
50
+ app, _ = _app_with_running_root()
51
+
52
+ assert app._codex_writer_matches("root", {"leaf"})
53
+ assert not app._codex_writer_matches("root", {"unrelated"})
54
+
55
+
56
+ def test_existing_root_favorite_marks_visible_leaf(
57
+ tmp_path, monkeypatch,
58
+ ) -> None:
59
+ favorites_path = tmp_path / "favorites.json"
60
+ monkeypatch.setattr(
61
+ "railmux.favorites._favorites_path", lambda: favorites_path)
62
+ app, _ = _app_with_running_root()
63
+ app._favorites = Favorites()
64
+ app._favorites.set_many({"root"}, True)
65
+
66
+ assert app._favorite_ids_for_view() == {"root", "middle", "leaf"}
67
+
68
+
69
+ def test_live_rewind_clears_only_exact_new_rollout_scrollback(
70
+ monkeypatch, tmp_path,
71
+ ) -> None:
72
+ root_id = "019fc605-5188-7212-bc48-ea023fe8b73c"
73
+ leaf_id = "019fc7c1-a27c-7ae0-9937-7570552a112a"
74
+ project = Project(tmp_path, "-project", Path(), 1, 0.0)
75
+ running = _Running(
76
+ key=root_id,
77
+ tmux_name="cx-live",
78
+ label="project/conversation",
79
+ project=project,
80
+ session_type="codex",
81
+ codex_canonical_session_id=root_id,
82
+ )
83
+ meta = SessionMeta(
84
+ project=project,
85
+ session_id=leaf_id,
86
+ jsonl_path=tmp_path / (
87
+ f"rollout-2026-08-03T13-13-03-{leaf_id}.jsonl"),
88
+ title="current",
89
+ message_count=2,
90
+ token_total=0,
91
+ last_mtime=2.0,
92
+ session_type="codex",
93
+ forked_from_id=root_id,
94
+ )
95
+ identity = tmux_ctl.PaneIdentity(
96
+ "%9", 123, "railmux", "$4", "@5", False, 80, 24)
97
+ app = App.__new__(App)
98
+ app._codex_lineage_ids = MagicMock(return_value={root_id, leaf_id})
99
+ app._codex_rollout_ids = MagicMock(return_value={root_id})
100
+ transport = MagicMock()
101
+ transport.displayed_real_pane.return_value = "%9"
102
+ app._display_transport = MagicMock(return_value=transport)
103
+ monkeypatch.setattr(tmux_ctl, "pane_identity", lambda _pane: identity)
104
+ cleared = MagicMock(return_value=True)
105
+ monkeypatch.setattr(tmux_ctl, "reset_pane_history", cleared)
106
+ stamped = MagicMock(return_value=True)
107
+ monkeypatch.setattr(tmux_ctl, "set_pane_user_option", stamped)
108
+ probes: dict[str, set[str] | None] = {}
109
+
110
+ # An indexed child is not enough: on procfs the exact live writer must
111
+ # first expose that rollout through its open file descriptors.
112
+ app._sync_codex_rewind_scrollback(running, meta, None, probes)
113
+ cleared.assert_not_called()
114
+ assert running.codex_canonical_session_id == root_id
115
+
116
+ probes.clear()
117
+ app._codex_rollout_ids.return_value = {root_id, leaf_id}
118
+ app._sync_codex_rewind_scrollback(running, meta, None, probes)
119
+
120
+ cleared.assert_called_once_with(identity)
121
+ assert running.codex_canonical_session_id == leaf_id
122
+ stamped.assert_called_once_with(
123
+ "%9", tmux_ctl.RAILMUX_HISTORY_GENERATION_OPTION, leaf_id)
124
+
125
+
126
+ def test_first_codex_generation_only_establishes_scrollback_baseline(
127
+ monkeypatch, tmp_path,
128
+ ) -> None:
129
+ session_id = "019fc605-5188-7212-bc48-ea023fe8b73c"
130
+ project = Project(tmp_path, "-project", Path(), 1, 0.0)
131
+ running = _Running(
132
+ key=session_id,
133
+ tmux_name="cx-live",
134
+ label="project/conversation",
135
+ project=project,
136
+ session_type="codex",
137
+ )
138
+ meta = SessionMeta(
139
+ project=project,
140
+ session_id=session_id,
141
+ jsonl_path=tmp_path / (
142
+ f"rollout-2026-08-03T13-09-08-{session_id}.jsonl"),
143
+ title="root",
144
+ message_count=1,
145
+ token_total=0,
146
+ last_mtime=1.0,
147
+ session_type="codex",
148
+ )
149
+ app = App.__new__(App)
150
+ app._stamp_codex_history_generation = MagicMock()
151
+ cleared = MagicMock(return_value=True)
152
+ monkeypatch.setattr(tmux_ctl, "reset_pane_history", cleared)
153
+
154
+ app._sync_codex_rewind_scrollback(running, meta, None, {})
155
+
156
+ assert running.codex_canonical_session_id == session_id
157
+ cleared.assert_not_called()