railmux 0.4.0.dev15__tar.gz → 0.4.0.dev17__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 (178) hide show
  1. {railmux-0.4.0.dev15/src/railmux.egg-info → railmux-0.4.0.dev17}/PKG-INFO +29 -12
  2. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/README.md +28 -11
  3. railmux-0.4.0.dev17/src/railmux/__init__.py +1 -0
  4. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/fast_display_client.py +142 -40
  5. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/fast_display_history.py +11 -9
  6. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/fast_display_server.py +17 -3
  7. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/models.py +3 -0
  8. railmux-0.4.0.dev17/src/railmux/session_lease.py +554 -0
  9. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/tmux_binding_manager.py +5 -2
  10. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/tmux_ctl.py +34 -59
  11. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/tmux_server.py +15 -1
  12. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/app.py +639 -128
  13. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/running_pane.py +4 -0
  14. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/sessions_pane.py +10 -3
  15. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17/src/railmux.egg-info}/PKG-INFO +29 -12
  16. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux.egg-info/SOURCES.txt +2 -0
  17. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_cli.py +3 -1
  18. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_codex_delete.py +4 -0
  19. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_codex_lineage.py +49 -1
  20. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_fast_full_window.py +184 -14
  21. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_focus_highlight.py +8 -14
  22. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_fullscreen_binding.py +206 -0
  23. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_orphan_recovery.py +40 -1
  24. railmux-0.4.0.dev17/tests/test_session_lease.py +491 -0
  25. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_sessions_pane.py +15 -0
  26. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_soft_quit.py +10 -5
  27. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_terminal_size.py +12 -0
  28. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tmux_binding_manager.py +25 -0
  29. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tmux_ctl.py +24 -10
  30. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tmux_server.py +3 -1
  31. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tmux_status.py +57 -8
  32. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_attach_relay.py +2 -2
  33. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_msys2.py +2 -1
  34. railmux-0.4.0.dev15/src/railmux/__init__.py +0 -1
  35. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/LICENSE +0 -0
  36. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/MANIFEST.in +0 -0
  37. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/pyproject.toml +0 -0
  38. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/setup.cfg +0 -0
  39. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/__main__.py +0 -0
  40. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/atomic_file.py +0 -0
  41. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/background_index.py +0 -0
  42. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/cli.py +0 -0
  43. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/codex_index.py +0 -0
  44. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/config.py +0 -0
  45. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/config_cli.py +0 -0
  46. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/diagnostics.py +0 -0
  47. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/discovery.py +0 -0
  48. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/display_transport.py +0 -0
  49. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/entrypoint.py +0 -0
  50. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/fast_display_input.py +0 -0
  51. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/fast_display_protocol.py +0 -0
  52. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/favorites.py +0 -0
  53. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/fuzzy.py +0 -0
  54. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/help_workspace.py +0 -0
  55. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/launcher.py +0 -0
  56. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/legacy_sessions.py +0 -0
  57. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/local_clipboard.py +0 -0
  58. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/local_open.py +0 -0
  59. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/modes.py +0 -0
  60. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/mouse_manager.py +0 -0
  61. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/orphan_marker.py +0 -0
  62. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/pane_surface.py +0 -0
  63. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/path_codec.py +0 -0
  64. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/provider_paths.py +0 -0
  65. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/remote_config.py +0 -0
  66. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/renames.py +0 -0
  67. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/restart_state.py +0 -0
  68. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/runtime_config.py +0 -0
  69. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/scroll_agent.py +0 -0
  70. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/scroll_manager.py +0 -0
  71. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/selection_isolation.py +0 -0
  72. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/self_update.py +0 -0
  73. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/session_cache.py +0 -0
  74. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/session_index.py +0 -0
  75. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/setting_contracts.py +0 -0
  76. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/settings.py +0 -0
  77. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ssh_args.py +0 -0
  78. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ssh_compat.py +0 -0
  79. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ssh_display_diagnostics.py +0 -0
  80. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ssh_doctor.py +0 -0
  81. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/system_deps.py +0 -0
  82. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/terminal_status.py +0 -0
  83. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/tmux_health.py +0 -0
  84. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/tool_panes.py +0 -0
  85. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/transcript.py +0 -0
  86. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/__init__.py +0 -0
  87. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/_widgets.py +0 -0
  88. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/keymap.py +0 -0
  89. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/modals.py +0 -0
  90. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/projects_pane.py +0 -0
  91. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/sidebar.py +0 -0
  92. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/statusbar.py +0 -0
  93. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/ui/workspace.py +0 -0
  94. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/windows_attach_relay.py +0 -0
  95. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/windows_bootstrap.py +0 -0
  96. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/windows_install_log.py +0 -0
  97. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/windows_msys2.py +0 -0
  98. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/windows_pacman.py +0 -0
  99. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux/windows_tmux_lifecycle.py +0 -0
  100. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux.egg-info/dependency_links.txt +0 -0
  101. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux.egg-info/entry_points.txt +0 -0
  102. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux.egg-info/requires.txt +0 -0
  103. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/src/railmux.egg-info/top_level.txt +0 -0
  104. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_agent_workspace.py +0 -0
  105. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_atomic_file.py +0 -0
  106. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_background_index.py +0 -0
  107. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_codex_index.py +0 -0
  108. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_codex_transcript.py +0 -0
  109. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_config.py +0 -0
  110. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_config_cli.py +0 -0
  111. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_denested_transport_benchmark.py +0 -0
  112. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_diagnostics.py +0 -0
  113. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_discovery.py +0 -0
  114. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_display_transport.py +0 -0
  115. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_fuzzy.py +0 -0
  116. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_help_agent.py +0 -0
  117. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_help_workspace.py +0 -0
  118. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_history_cleanup.py +0 -0
  119. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_keymap.py +0 -0
  120. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_launcher.py +0 -0
  121. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_layout_profile.py +0 -0
  122. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_legacy_sessions.py +0 -0
  123. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_local_clipboard.py +0 -0
  124. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_local_open.py +0 -0
  125. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_modals.py +0 -0
  126. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_mode_selection.py +0 -0
  127. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_modes.py +0 -0
  128. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_mouse_manager.py +0 -0
  129. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_new_project.py +0 -0
  130. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_options.py +0 -0
  131. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_pane_surface.py +0 -0
  132. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_path_codec.py +0 -0
  133. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_projects_pane.py +0 -0
  134. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_provider_paths.py +0 -0
  135. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_release_notes.py +0 -0
  136. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_release_workflow.py +0 -0
  137. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_remote_config.py +0 -0
  138. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_renames.py +0 -0
  139. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_restart_state.py +0 -0
  140. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_running_pane.py +0 -0
  141. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_running_sort.py +0 -0
  142. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_runtime_config.py +0 -0
  143. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_scroll_agent.py +0 -0
  144. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_scroll_manager.py +0 -0
  145. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_selection_isolation.py +0 -0
  146. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_self_update.py +0 -0
  147. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_session_cache.py +0 -0
  148. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_session_index.py +0 -0
  149. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_setting_contracts.py +0 -0
  150. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_settings.py +0 -0
  151. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_sidebar.py +0 -0
  152. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_ssh_args.py +0 -0
  153. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_ssh_compat.py +0 -0
  154. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_ssh_display_benchmark.py +0 -0
  155. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_ssh_display_diagnostics.py +0 -0
  156. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_ssh_doctor.py +0 -0
  157. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_status.py +0 -0
  158. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_statusbar.py +0 -0
  159. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_system_deps.py +0 -0
  160. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_terminal_status.py +0 -0
  161. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_termux_local_touch.py +0 -0
  162. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tmux_health.py +0 -0
  163. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tmux_integration.py +0 -0
  164. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_tool_panes.py +0 -0
  165. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_transcript.py +0 -0
  166. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_utf8_state.py +0 -0
  167. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_web_demo_recorder.py +0 -0
  168. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_wheel_smoke.py +0 -0
  169. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_widgets.py +0 -0
  170. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_bootstrap.py +0 -0
  171. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_install_log.py +0 -0
  172. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_pacman.py +0 -0
  173. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_tmux_lifecycle.py +0 -0
  174. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_windows_wrapper_contract.py +0 -0
  175. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tests/test_yolo_prompt.py +0 -0
  176. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tools/denested_transport_benchmark.py +0 -0
  177. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tools/release_notes.py +0 -0
  178. {railmux-0.4.0.dev15 → railmux-0.4.0.dev17}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev15
3
+ Version: 0.4.0.dev17
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
@@ -170,7 +170,7 @@ for focus and layout limits.
170
170
  |--------|--------|
171
171
  | Left-click (non-running) | Preview session history in the Target pane |
172
172
  | Left-click (running) | Switch the Target pane to that session |
173
- | Wheel up over a live agent | Open its canonical current-branch history |
173
+ | Wheel over a live agent | Scroll its live terminal history (native locally, Railmux-managed over `railmux ssh`) |
174
174
  | Double-click | Open/attach in the Target pane and move focus there |
175
175
  | Right-click | Context menu (Open, Preview, Info, Rename, Star, Copy title, Kill, Term, Delete) |
176
176
 
@@ -373,16 +373,21 @@ sessions move ahead of the other results.
373
373
 
374
374
  For a stopped session, left-click or press `␣` to view conversation history in
375
375
  the Target pane without starting or resuming the agent. For a running session,
376
- press `␣`, choose **Preview**, or wheel up over its live pane. Preview is
377
- read-only: it cannot send a message or change the session. User and assistant
378
- messages, tool calls, and abbreviated tool output are colour-coded, while
379
- internal context and encrypted reasoning are hidden. Codex rewind lineages and
380
- Claude Code `parentUuid` branches are projected onto the provider's current
381
- conversation: the retained prefix and replacement suffix remain visible, while
382
- the abandoned suffix is hidden. When Railmux observes a running Codex session
383
- advance after rewind or steering, it resets only the current terminal view
384
- before Codex redraws. Retained tmux scrollback is not erased, and managed
385
- history continues from the canonical rollout without its abandoned suffix.
376
+ press `␣` or choose **Preview**. Preview is read-only: it cannot send a message
377
+ or change the session. User and assistant messages, tool calls, and abbreviated
378
+ tool output are colour-coded, while internal context and encrypted reasoning
379
+ are hidden. Codex rewind lineages and Claude Code `parentUuid` branches are
380
+ projected onto the provider's current conversation: the retained prefix and
381
+ replacement suffix remain visible, while the abandoned suffix is hidden.
382
+
383
+ Scrolling a live agent does not enter Preview. Direct `railmux` uses the
384
+ terminal/tmux scrolling behavior; `railmux ssh` keeps its own bounded,
385
+ per-pane history and normally preserves the live terminal's captured styling.
386
+ When Railmux confirms that a running Codex session advanced after rewind or
387
+ steering, it resets only the current terminal view before Codex redraws and
388
+ invalidates that pane's SSH history generation. Retained tmux scrollback is not
389
+ erased; the SSH history for that branched generation uses the canonical
390
+ rollout so its abandoned suffix stays hidden.
386
391
 
387
392
  Preview opens at the latest activity in `less`; large sessions are limited to
388
393
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -423,6 +428,18 @@ Railmux also indexes resumable Claude Code and Codex sessions started outside
423
428
  Railmux, so existing conversations appear in the sidebar and resume the same
424
429
  way. Non-resumable one-off runs such as `codex exec` are filtered out.
425
430
 
431
+ If the same Claude Code or Codex history root is shared across hosts, Railmux
432
+ uses a provider-session lease to prevent two machines from resuming the same
433
+ conversation concurrently. A remotely owned conversation remains in Sessions
434
+ as **running on HOST**, but is not presented as a locally attachable Running
435
+ entry; resume fails safely until that host's exact provider pane exits. The
436
+ lease follows detached agents across Soft Quit and covers every known Codex
437
+ rewind-lineage ID at resume; later rewinds retain a stable locked lineage
438
+ anchor. Delete also checks the shared lease immediately before
439
+ starting and refuses remotely owned history. The shared filesystem must provide
440
+ cross-host POSIX advisory-lock semantics; mounts configured with local-only or
441
+ disabled locking cannot provide this guarantee.
442
+
426
443
  ### Restarts and quitting
427
444
 
428
445
  Each opened agent runs in a detached tmux session, so switching sessions does
@@ -127,7 +127,7 @@ for focus and layout limits.
127
127
  |--------|--------|
128
128
  | Left-click (non-running) | Preview session history in the Target pane |
129
129
  | Left-click (running) | Switch the Target pane to that session |
130
- | Wheel up over a live agent | Open its canonical current-branch history |
130
+ | Wheel over a live agent | Scroll its live terminal history (native locally, Railmux-managed over `railmux ssh`) |
131
131
  | Double-click | Open/attach in the Target pane and move focus there |
132
132
  | Right-click | Context menu (Open, Preview, Info, Rename, Star, Copy title, Kill, Term, Delete) |
133
133
 
@@ -330,16 +330,21 @@ sessions move ahead of the other results.
330
330
 
331
331
  For a stopped session, left-click or press `␣` to view conversation history in
332
332
  the Target pane without starting or resuming the agent. For a running session,
333
- press `␣`, choose **Preview**, or wheel up over its live pane. Preview is
334
- read-only: it cannot send a message or change the session. User and assistant
335
- messages, tool calls, and abbreviated tool output are colour-coded, while
336
- internal context and encrypted reasoning are hidden. Codex rewind lineages and
337
- Claude Code `parentUuid` branches are projected onto the provider's current
338
- conversation: the retained prefix and replacement suffix remain visible, while
339
- the abandoned suffix is hidden. When Railmux observes a running Codex session
340
- advance after rewind or steering, it resets only the current terminal view
341
- before Codex redraws. Retained tmux scrollback is not erased, and managed
342
- history continues from the canonical rollout without its abandoned suffix.
333
+ press `␣` or choose **Preview**. Preview is read-only: it cannot send a message
334
+ or change the session. User and assistant messages, tool calls, and abbreviated
335
+ tool output are colour-coded, while internal context and encrypted reasoning
336
+ are hidden. Codex rewind lineages and Claude Code `parentUuid` branches are
337
+ projected onto the provider's current conversation: the retained prefix and
338
+ replacement suffix remain visible, while the abandoned suffix is hidden.
339
+
340
+ Scrolling a live agent does not enter Preview. Direct `railmux` uses the
341
+ terminal/tmux scrolling behavior; `railmux ssh` keeps its own bounded,
342
+ per-pane history and normally preserves the live terminal's captured styling.
343
+ When Railmux confirms that a running Codex session advanced after rewind or
344
+ steering, it resets only the current terminal view before Codex redraws and
345
+ invalidates that pane's SSH history generation. Retained tmux scrollback is not
346
+ erased; the SSH history for that branched generation uses the canonical
347
+ rollout so its abandoned suffix stays hidden.
343
348
 
344
349
  Preview opens at the latest activity in `less`; large sessions are limited to
345
350
  their latest 2,000 saved records. Press `/` to search, `n`/`N` to move between
@@ -380,6 +385,18 @@ Railmux also indexes resumable Claude Code and Codex sessions started outside
380
385
  Railmux, so existing conversations appear in the sidebar and resume the same
381
386
  way. Non-resumable one-off runs such as `codex exec` are filtered out.
382
387
 
388
+ If the same Claude Code or Codex history root is shared across hosts, Railmux
389
+ uses a provider-session lease to prevent two machines from resuming the same
390
+ conversation concurrently. A remotely owned conversation remains in Sessions
391
+ as **running on HOST**, but is not presented as a locally attachable Running
392
+ entry; resume fails safely until that host's exact provider pane exits. The
393
+ lease follows detached agents across Soft Quit and covers every known Codex
394
+ rewind-lineage ID at resume; later rewinds retain a stable locked lineage
395
+ anchor. Delete also checks the shared lease immediately before
396
+ starting and refuses remotely owned history. The shared filesystem must provide
397
+ cross-host POSIX advisory-lock semantics; mounts configured with local-only or
398
+ disabled locking cannot provide this guarantee.
399
+
383
400
  ### Restarts and quitting
384
401
 
385
402
  Each opened agent runs in a detached tmux session, so switching sessions does
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev17"
@@ -202,6 +202,14 @@ def full_repaint(screen: AppliedScreen) -> AppliedScreen:
202
202
  )
203
203
 
204
204
 
205
+ def selection_changed_rows(
206
+ before: tuple[SelectionSegment, ...],
207
+ after: tuple[SelectionSegment, ...],
208
+ ) -> set[int]:
209
+ """Rows that must be restored when one local highlight changes."""
210
+ return {row for row, _column, _text in (*before, *after)}
211
+
212
+
205
213
  def focus_in_frame_for_screen(screen: AppliedScreen | None) -> bytes | None:
206
214
  """Reassert focus only while the remote application requested events."""
207
215
  if screen is None or not screen.terminal_modes & TerminalMode.FOCUS_EVENTS:
@@ -637,6 +645,10 @@ class TerminalSurface:
637
645
  self.mouse_active = False
638
646
  self.mouse_suspended = False
639
647
  self.cursor_hidden = False
648
+ # Track the physical emulator cursor separately from the remote model.
649
+ # Re-sending DECSET/DECRST 25 on every 20 fps patch restarts the cursor
650
+ # animation in Windows Terminal and presents as a rapid flash.
651
+ self._painted_cursor_visible: bool | None = None
640
652
  self.terminal_modes = TerminalMode.NONE
641
653
  self.physical_size: os.terminal_size | None = None
642
654
  self._last_screen: AppliedScreen | None = None
@@ -694,6 +706,7 @@ class TerminalSurface:
694
706
  if interactive and not self.cursor_hidden:
695
707
  controls.append(b"\033[?25l")
696
708
  self.cursor_hidden = True
709
+ self._painted_cursor_visible = False
697
710
  if (
698
711
  interactive
699
712
  and self.mouse
@@ -793,6 +806,7 @@ class TerminalSurface:
793
806
  self.mouse_active = False
794
807
  self.mouse_suspended = False
795
808
  self.cursor_hidden = False
809
+ self._painted_cursor_visible = True
796
810
  self.interaction_active = True
797
811
 
798
812
  def show_local_status(
@@ -813,7 +827,8 @@ class TerminalSurface:
813
827
  self._local_status_level = level
814
828
  self._local_status_interruptible = interruptible
815
829
  self._local_status_expires_at = expires_at
816
- rendered = [self._render_local_status()]
830
+ restore_cursor = self._last_screen is not None
831
+ rendered = [self._render_local_status(hide_cursor=not restore_cursor)]
817
832
  if self._last_screen is not None:
818
833
  projection_top, visible_height = self._projection(
819
834
  self._last_screen.height
@@ -828,7 +843,7 @@ class TerminalSurface:
828
843
  self.stream.write(b"".join(rendered))
829
844
  self.stream.flush()
830
845
 
831
- def _render_local_status(self) -> bytes:
846
+ def _render_local_status(self, *, hide_cursor: bool = True) -> bytes:
832
847
  """Render and locate the current local status-right replacement."""
833
848
  if self._local_status_text is None:
834
849
  self._local_status_bounds = None
@@ -841,10 +856,14 @@ class TerminalSurface:
841
856
  if self.physical_size is not None:
842
857
  safe = safe[: self.physical_size.columns]
843
858
  self._local_status_bounds = (max(1, height), 1, max(1, len(safe)))
844
- return (
859
+ rendered = (
845
860
  f"\033[?7l\033[0m\033[{max(1, height)};1H\033[2K{safe}"
846
- "\033[0m\033[?7h\033[?25l"
861
+ "\033[0m\033[?7h"
847
862
  ).encode("utf-8")
863
+ if hide_cursor:
864
+ rendered += b"\033[?25l"
865
+ self._painted_cursor_visible = False
866
+ return rendered
848
867
  width = screen.width
849
868
  if self.physical_size is not None:
850
869
  width = min(width, self.physical_size.columns)
@@ -862,8 +881,8 @@ class TerminalSurface:
862
881
  "warning": b"\033[1;38;5;220m",
863
882
  "error": b"\033[1;38;5;196m",
864
883
  }.get(level, b"\033[38;5;231m")
865
- return b"".join(
866
- (
884
+ rendered = b"".join(
885
+ [
867
886
  b"\033[?7l\033[0m",
868
887
  f"\033[{max(1, height)};{reserved + 1}H".encode(),
869
888
  background,
@@ -871,9 +890,13 @@ class TerminalSurface:
871
890
  b"\033[K",
872
891
  f"\033[{max(1, height)};{column}H".encode(),
873
892
  safe.encode("utf-8"),
874
- b"\033[0m\033[?25l",
875
- )
893
+ b"\033[0m",
894
+ ]
876
895
  )
896
+ if hide_cursor:
897
+ rendered += b"\033[?25l"
898
+ self._painted_cursor_visible = False
899
+ return rendered
877
900
 
878
901
  def clear_local_status(self) -> None:
879
902
  """Forget local feedback before restoring the authoritative screen."""
@@ -991,6 +1014,7 @@ class TerminalSurface:
991
1014
  )
992
1015
  self.stream.write(b"".join(rendered))
993
1016
  self.stream.flush()
1017
+ self._painted_cursor_visible = False
994
1018
 
995
1019
  def claude_history_prompt_choice(
996
1020
  self,
@@ -1052,6 +1076,7 @@ class TerminalSurface:
1052
1076
  )
1053
1077
  self.stream.write(b"".join(rendered))
1054
1078
  self.stream.flush()
1079
+ self._painted_cursor_visible = False
1055
1080
 
1056
1081
  def path_open_prompt_choice(
1057
1082
  self,
@@ -1108,6 +1133,7 @@ class TerminalSurface:
1108
1133
  # scroll the alternate screen.
1109
1134
  self.stream.write(b"\033[0m\033[?7h\033[?25l")
1110
1135
  self.stream.flush()
1136
+ self._painted_cursor_visible = False
1111
1137
 
1112
1138
  @staticmethod
1113
1139
  def _cursor_is_covered(
@@ -1171,38 +1197,37 @@ class TerminalSurface:
1171
1197
  )
1172
1198
  )
1173
1199
 
1174
- @classmethod
1175
1200
  def _append_cursor(
1176
- cls,
1201
+ self,
1177
1202
  rendered: list[bytes],
1178
1203
  screen: AppliedScreen,
1179
1204
  overlays: tuple[tuple[HistorySnapshot, tuple[bytes, ...]], ...],
1180
1205
  *,
1181
1206
  projection_top: int = 0,
1182
1207
  visible_height: int | None = None,
1208
+ force_visibility: bool = False,
1183
1209
  ) -> None:
1184
1210
  cursor_in_projection = (
1185
1211
  visible_height is None
1186
1212
  or projection_top <= screen.cursor_y < projection_top + visible_height
1187
1213
  )
1188
- rendered.extend(
1189
- (
1190
- b"\033[0m\033[?7h",
1191
- (
1192
- f"\033[{screen.cursor_y - projection_top + 1};"
1193
- f"{screen.cursor_x + 1}H"
1194
- ).encode()
1195
- if cursor_in_projection
1196
- else b"\033[1;1H",
1197
- (
1198
- b"\033[?25h"
1199
- if screen.cursor_visible
1200
- and cursor_in_projection
1201
- and not cls._cursor_is_covered(screen, overlays)
1202
- else b"\033[?25l"
1203
- ),
1204
- )
1214
+ visible = bool(
1215
+ screen.cursor_visible
1216
+ and cursor_in_projection
1217
+ and not self._cursor_is_covered(screen, overlays)
1205
1218
  )
1219
+ rendered.extend((
1220
+ b"\033[0m\033[?7h",
1221
+ (
1222
+ f"\033[{screen.cursor_y - projection_top + 1};"
1223
+ f"{screen.cursor_x + 1}H"
1224
+ ).encode()
1225
+ if cursor_in_projection
1226
+ else b"\033[1;1H",
1227
+ ))
1228
+ if force_visibility or self._painted_cursor_visible is not visible:
1229
+ rendered.append(b"\033[?25h" if visible else b"\033[?25l")
1230
+ self._painted_cursor_visible = visible
1206
1231
 
1207
1232
  def paint(
1208
1233
  self,
@@ -1250,7 +1275,7 @@ class TerminalSurface:
1250
1275
  visible_height=visible_height,
1251
1276
  )
1252
1277
  if self._local_status_text is not None:
1253
- rendered.append(self._render_local_status())
1278
+ rendered.append(self._render_local_status(hide_cursor=False))
1254
1279
  # Status and selection painting move and may temporarily hide the
1255
1280
  # terminal cursor. Restoring the authoritative cursor must therefore
1256
1281
  # be the final operation in every complete paint.
@@ -1289,7 +1314,61 @@ class TerminalSurface:
1289
1314
  visible_height=visible_height,
1290
1315
  )
1291
1316
  if self._local_status_text is not None:
1292
- rendered.append(self._render_local_status())
1317
+ rendered.append(self._render_local_status(hide_cursor=False))
1318
+ self._append_cursor(
1319
+ rendered,
1320
+ screen,
1321
+ overlays,
1322
+ projection_top=projection_top,
1323
+ visible_height=visible_height,
1324
+ )
1325
+ self.stream.write(b"".join(rendered))
1326
+ self.stream.flush()
1327
+
1328
+ def paint_changed_rows(
1329
+ self,
1330
+ screen: AppliedScreen,
1331
+ overlays: tuple[tuple[HistorySnapshot, tuple[bytes, ...]], ...],
1332
+ selection: tuple[SelectionSegment, ...],
1333
+ changed_rows: set[int] | frozenset[int],
1334
+ ) -> None:
1335
+ """Restore and repaint only rows touched by a local highlight.
1336
+
1337
+ Hover, drag selection, and click flash are local overlays. Clearing the
1338
+ whole alternate screen for them is both unnecessary and visibly flashes
1339
+ in Windows Terminal. Restore each affected row from the authoritative
1340
+ live frame, then layer frozen history and the new highlight back on it.
1341
+ """
1342
+ self.start()
1343
+ self._last_screen = screen
1344
+ self._last_overlays = overlays
1345
+ projection_top, visible_height = self._projection(screen.height)
1346
+ rows = frozenset(
1347
+ row for row in changed_rows
1348
+ if projection_top <= row < projection_top + visible_height
1349
+ )
1350
+ rendered: list[bytes] = [b"\033[?7l"]
1351
+ for row in sorted(rows):
1352
+ rendered.extend((
1353
+ f"\033[{row - projection_top + 1};1H".encode(),
1354
+ b"\033[2K",
1355
+ screen.rows[row],
1356
+ ))
1357
+ self._append_overlay_rows(
1358
+ rendered,
1359
+ overlays,
1360
+ projection_top=projection_top,
1361
+ visible_height=visible_height,
1362
+ changed_rows=rows,
1363
+ )
1364
+ self._append_selection_segments(
1365
+ rendered,
1366
+ selection,
1367
+ projection_top=projection_top,
1368
+ visible_height=visible_height,
1369
+ )
1370
+ if self._local_status_text is not None and screen.height - 1 in rows:
1371
+ rendered.append(self._render_local_status(hide_cursor=False))
1293
1372
  self._append_cursor(
1294
1373
  rendered,
1295
1374
  screen,
@@ -1312,6 +1391,7 @@ class TerminalSurface:
1312
1391
  self._last_overlays,
1313
1392
  projection_top=projection_top,
1314
1393
  visible_height=visible_height,
1394
+ force_visibility=True,
1315
1395
  )
1316
1396
  self.stream.write(b"".join(rendered))
1317
1397
  self.stream.flush()
@@ -1333,6 +1413,7 @@ class TerminalSurface:
1333
1413
  self.mouse_active = False
1334
1414
  self.mouse_suspended = False
1335
1415
  self.cursor_hidden = False
1416
+ self._painted_cursor_visible = None
1336
1417
  self.clear_local_status()
1337
1418
  self._last_screen = None
1338
1419
  self._reconnect_status_screen = None
@@ -2627,11 +2708,14 @@ def run(args: argparse.Namespace) -> int:
2627
2708
  focused_pane_id=focused_pane_id,
2628
2709
  )
2629
2710
  )
2711
+ before = selection.segments()
2630
2712
  if selection.hover(hover_event, source) and latest_screen is not None:
2631
- surface.paint(
2632
- full_repaint(latest_screen),
2713
+ after = selection.segments()
2714
+ surface.paint_changed_rows(
2715
+ latest_screen,
2633
2716
  history.overlays(),
2634
- selection.segments(),
2717
+ after,
2718
+ selection_changed_rows(before, after),
2635
2719
  )
2636
2720
  return
2637
2721
  if path_open_prompt is not None:
@@ -2751,6 +2835,7 @@ def run(args: argparse.Namespace) -> int:
2751
2835
  # one persistent choice is still being confirmed.
2752
2836
  return
2753
2837
  if isinstance(part, SgrMouseEvent):
2838
+ selection_before = selection.segments()
2754
2839
  displayed_height = (
2755
2840
  latest_screen.height
2756
2841
  if latest_screen is not None
@@ -2807,10 +2892,12 @@ def run(args: argparse.Namespace) -> int:
2807
2892
  if selection.capturing or not selection.active:
2808
2893
  selection_clear_at = None
2809
2894
  if selection_action.repaint and latest_screen is not None:
2810
- surface.paint(
2811
- full_repaint(latest_screen),
2895
+ selection_after = selection.segments()
2896
+ surface.paint_changed_rows(
2897
+ latest_screen,
2812
2898
  history.overlays(),
2813
- selection.segments(),
2899
+ selection_after,
2900
+ selection_changed_rows(selection_before, selection_after),
2814
2901
  )
2815
2902
  for replay_event in selection_action.replay_events:
2816
2903
  if not claim_batched_wheel(replay_event, handled_wheels):
@@ -2835,6 +2922,7 @@ def run(args: argparse.Namespace) -> int:
2835
2922
  selection_clear_at = time.monotonic() + _SELECTION_HIGHLIGHT_SECONDS
2836
2923
  if selection_action.open_target is not None:
2837
2924
  target = selection_action.open_target
2925
+ flash_before = selection.segments()
2838
2926
  if (
2839
2927
  selection.flash(
2840
2928
  target,
@@ -2843,10 +2931,12 @@ def run(args: argparse.Namespace) -> int:
2843
2931
  )
2844
2932
  and latest_screen is not None
2845
2933
  ):
2846
- surface.paint(
2847
- full_repaint(latest_screen),
2934
+ flash_after = selection.segments()
2935
+ surface.paint_changed_rows(
2936
+ latest_screen,
2848
2937
  history.overlays(),
2849
- selection.segments(),
2938
+ flash_after,
2939
+ selection_changed_rows(flash_before, flash_after),
2850
2940
  )
2851
2941
  if target.kind == "url":
2852
2942
  show_open_result(local_open.open_url(target.value))
@@ -3301,6 +3391,7 @@ def run(args: argparse.Namespace) -> int:
3301
3391
  restore_local_status = surface.expire_local_status(now) or (
3302
3392
  history_info_until is not None and now >= history_info_until
3303
3393
  )
3394
+ timed_selection_before = selection.segments()
3304
3395
  clear_selection = (
3305
3396
  selection_clear_at is not None and now >= selection_clear_at
3306
3397
  )
@@ -3310,13 +3401,24 @@ def run(args: argparse.Namespace) -> int:
3310
3401
  selection.cancel()
3311
3402
  if restore_local_status:
3312
3403
  surface.clear_local_status()
3313
- if latest_screen is not None:
3404
+ timed_selection_after = selection.segments()
3405
+ if latest_screen is not None and restore_local_status:
3314
3406
  surface.paint(
3315
3407
  full_repaint(latest_screen),
3316
3408
  history.overlays(),
3317
- selection.segments(),
3409
+ timed_selection_after,
3318
3410
  )
3319
3411
  redraw_local_prompt()
3412
+ elif latest_screen is not None:
3413
+ surface.paint_changed_rows(
3414
+ latest_screen,
3415
+ history.overlays(),
3416
+ timed_selection_after,
3417
+ selection_changed_rows(
3418
+ timed_selection_before,
3419
+ timed_selection_after,
3420
+ ),
3421
+ )
3320
3422
  if restore_local_status:
3321
3423
  history_info_until = None
3322
3424
  if clear_selection:
@@ -287,15 +287,17 @@ class LocalHistoryView:
287
287
  # intermediate output above the current screen.
288
288
  lines = incoming.lines
289
289
  else:
290
- # A full-screen TUI redraw can leave no trustworthy text
291
- # anchor. Preserve the known history and replace only the
292
- # mutable live viewport. Appending every unaligned 300-line
293
- # prefetch would duplicate a full screen every three seconds.
294
- live_count = min(incoming.height, len(incoming.lines))
295
- retained = previous.lines[
296
- : -min(previous.height, len(previous.lines))
297
- ]
298
- lines = retained + incoming.lines[-live_count:]
290
+ # Never splice two captures whose timelines cannot be aligned.
291
+ # Retaining old history and replacing only the live viewport
292
+ # looked conservative, but it silently omitted every row
293
+ # produced between those two pieces. That false seam appeared
294
+ # as discontinuous SSH history until returning to the bottom
295
+ # forced a fresh deep capture. Keep the newest internally
296
+ # contiguous hot capture instead; the first wheel-up requests
297
+ # a cumulative deep page and can recover older rows safely.
298
+ # An already-frozen viewport owns its immutable snapshot and
299
+ # therefore remains undisturbed by this cache replacement.
300
+ lines = incoming.lines
299
301
  else:
300
302
  start = min(0, delta)
301
303
  incoming_end = delta + len(incoming.lines)
@@ -406,6 +406,7 @@ class _PaneGeometry:
406
406
  transcript_provider: str | None = None
407
407
  claude_history_policy: str = "ask"
408
408
  history_generation: int = 0
409
+ canonical_history: bool = False
409
410
 
410
411
 
411
412
  @dataclass(frozen=True)
@@ -432,11 +433,14 @@ _HISTORY_GENERATION_RE = re.compile(
432
433
 
433
434
  def _history_generation(marker: str) -> int:
434
435
  """Map one validated provider UUID to a bounded opaque wire epoch."""
436
+ generation_marker = marker
437
+ if marker.startswith(tmux_ctl.RAILMUX_CANONICAL_HISTORY_PREFIX):
438
+ marker = marker[len(tmux_ctl.RAILMUX_CANONICAL_HISTORY_PREFIX):]
435
439
  if not _HISTORY_GENERATION_RE.fullmatch(marker):
436
440
  return 0
437
441
  return int.from_bytes(
438
442
  hashlib.blake2b(
439
- marker.lower().encode("ascii"),
443
+ generation_marker.lower().encode("ascii"),
440
444
  digest_size=8,
441
445
  person=b"railmux-history",
442
446
  ).digest(),
@@ -937,6 +941,15 @@ def _list_agent_panes(
937
941
  if opened is not None:
938
942
  transcript_locator = opened[0]
939
943
  os.close(opened[1])
944
+ history_marker = fields[15]
945
+ canonical_history = bool(
946
+ transcript_backed
947
+ and transcript_locator is not None
948
+ and history_marker == (
949
+ f"{tmux_ctl.RAILMUX_CANONICAL_HISTORY_PREFIX}"
950
+ f"{transcript_locator.session_id}"
951
+ )
952
+ )
940
953
  rows.append(
941
954
  (
942
955
  fields[2] == "1",
@@ -960,7 +973,8 @@ def _list_agent_panes(
960
973
  else None
961
974
  ),
962
975
  claude_history_policy=claude_history_policy,
963
- history_generation=_history_generation(fields[15]),
976
+ history_generation=_history_generation(history_marker),
977
+ canonical_history=canonical_history,
964
978
  ),
965
979
  )
966
980
  )
@@ -1347,7 +1361,7 @@ def _capture_pane_history(
1347
1361
  pane.transcript_backed
1348
1362
  and pane.transcript_source
1349
1363
  and (
1350
- transcript_provider == "codex"
1364
+ (transcript_provider == "codex" and pane.canonical_history)
1351
1365
  or (
1352
1366
  transcript_provider == "claude"
1353
1367
  and pane.claude_history_policy == "local"
@@ -75,6 +75,9 @@ class SessionMeta:
75
75
  # conversation without changing the provider-owned UUID used for resume.
76
76
  # Always None for Claude sessions and older Codex rollouts.
77
77
  forked_from_id: str | None = None
78
+ # A Railmux lease observed in the same shared provider history root. This
79
+ # is a read-time UI overlay, never provider transcript metadata.
80
+ remote_owner: str | None = None
78
81
 
79
82
  @property
80
83
  def display_title(self) -> str: