railmux 0.4.0.dev8__tar.gz → 0.4.0.dev10__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 (175) hide show
  1. {railmux-0.4.0.dev8/src/railmux.egg-info → railmux-0.4.0.dev10}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev10/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/cli.py +26 -2
  4. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/codex_index.py +2 -2
  5. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/discovery.py +142 -0
  6. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/fast_display_server.py +1 -1
  7. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/mouse_manager.py +1 -1
  8. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/orphan_marker.py +2 -2
  9. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/provider_paths.py +39 -0
  10. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/restart_state.py +8 -2
  11. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/tmux_binding_manager.py +53 -14
  12. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/tmux_ctl.py +145 -43
  13. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/app.py +71 -2
  14. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/windows_install_log.py +28 -8
  15. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/windows_msys2.py +142 -4
  16. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/windows_pacman.py +176 -0
  17. railmux-0.4.0.dev10/src/railmux/windows_tmux_lifecycle.py +259 -0
  18. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10/src/railmux.egg-info}/PKG-INFO +1 -1
  19. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux.egg-info/SOURCES.txt +2 -0
  20. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_discovery.py +113 -0
  21. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_fullscreen_binding.py +41 -0
  22. railmux-0.4.0.dev10/tests/test_provider_paths.py +88 -0
  23. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_restart_state.py +30 -0
  24. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_soft_quit.py +6 -0
  25. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tmux_binding_manager.py +39 -5
  26. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tmux_ctl.py +36 -0
  27. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tmux_integration.py +17 -2
  28. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_windows_install_log.py +34 -0
  29. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_windows_msys2.py +68 -7
  30. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_windows_pacman.py +96 -0
  31. railmux-0.4.0.dev10/tests/test_windows_tmux_lifecycle.py +140 -0
  32. railmux-0.4.0.dev8/src/railmux/__init__.py +0 -1
  33. railmux-0.4.0.dev8/tests/test_provider_paths.py +0 -29
  34. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/LICENSE +0 -0
  35. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/MANIFEST.in +0 -0
  36. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/README.md +0 -0
  37. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/pyproject.toml +0 -0
  38. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/setup.cfg +0 -0
  39. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/__main__.py +0 -0
  40. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/atomic_file.py +0 -0
  41. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/background_index.py +0 -0
  42. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/config.py +0 -0
  43. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/config_cli.py +0 -0
  44. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/diagnostics.py +0 -0
  45. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/display_transport.py +0 -0
  46. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/entrypoint.py +0 -0
  47. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/fast_display_client.py +0 -0
  48. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/fast_display_history.py +0 -0
  49. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/fast_display_input.py +0 -0
  50. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/fast_display_protocol.py +0 -0
  51. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/favorites.py +0 -0
  52. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/fuzzy.py +0 -0
  53. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/help_workspace.py +0 -0
  54. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/launcher.py +0 -0
  55. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/legacy_sessions.py +0 -0
  56. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/local_clipboard.py +0 -0
  57. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/local_open.py +0 -0
  58. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/models.py +0 -0
  59. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/modes.py +0 -0
  60. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/pane_surface.py +0 -0
  61. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/path_codec.py +0 -0
  62. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/remote_config.py +0 -0
  63. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/renames.py +0 -0
  64. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/runtime_config.py +0 -0
  65. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/scroll_agent.py +0 -0
  66. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/scroll_manager.py +0 -0
  67. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/selection_isolation.py +0 -0
  68. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/self_update.py +0 -0
  69. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/session_cache.py +0 -0
  70. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/session_index.py +0 -0
  71. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/setting_contracts.py +0 -0
  72. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/settings.py +0 -0
  73. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ssh_args.py +0 -0
  74. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ssh_compat.py +0 -0
  75. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ssh_display_diagnostics.py +0 -0
  76. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ssh_doctor.py +0 -0
  77. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/system_deps.py +0 -0
  78. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/terminal_status.py +0 -0
  79. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/tmux_health.py +0 -0
  80. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/tmux_server.py +0 -0
  81. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/tool_panes.py +0 -0
  82. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/transcript.py +0 -0
  83. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/__init__.py +0 -0
  84. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/_widgets.py +0 -0
  85. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/keymap.py +0 -0
  86. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/modals.py +0 -0
  87. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/projects_pane.py +0 -0
  88. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/running_pane.py +0 -0
  89. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/sessions_pane.py +0 -0
  90. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/sidebar.py +0 -0
  91. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/statusbar.py +0 -0
  92. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/ui/workspace.py +0 -0
  93. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux/windows_bootstrap.py +0 -0
  94. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux.egg-info/dependency_links.txt +0 -0
  95. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux.egg-info/entry_points.txt +0 -0
  96. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux.egg-info/requires.txt +0 -0
  97. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/src/railmux.egg-info/top_level.txt +0 -0
  98. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_agent_workspace.py +0 -0
  99. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_atomic_file.py +0 -0
  100. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_background_index.py +0 -0
  101. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_cli.py +0 -0
  102. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_codex_delete.py +0 -0
  103. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_codex_index.py +0 -0
  104. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_codex_lineage.py +0 -0
  105. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_codex_transcript.py +0 -0
  106. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_config.py +0 -0
  107. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_config_cli.py +0 -0
  108. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_denested_transport_benchmark.py +0 -0
  109. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_diagnostics.py +0 -0
  110. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_display_transport.py +0 -0
  111. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_fast_full_window.py +0 -0
  112. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_focus_highlight.py +0 -0
  113. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_fuzzy.py +0 -0
  114. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_help_agent.py +0 -0
  115. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_help_workspace.py +0 -0
  116. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_history_cleanup.py +0 -0
  117. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_keymap.py +0 -0
  118. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_launcher.py +0 -0
  119. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_layout_profile.py +0 -0
  120. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_legacy_sessions.py +0 -0
  121. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_local_clipboard.py +0 -0
  122. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_local_open.py +0 -0
  123. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_modals.py +0 -0
  124. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_mode_selection.py +0 -0
  125. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_modes.py +0 -0
  126. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_mouse_manager.py +0 -0
  127. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_new_project.py +0 -0
  128. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_options.py +0 -0
  129. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_orphan_recovery.py +0 -0
  130. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_pane_surface.py +0 -0
  131. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_path_codec.py +0 -0
  132. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_projects_pane.py +0 -0
  133. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_release_notes.py +0 -0
  134. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_release_workflow.py +0 -0
  135. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_remote_config.py +0 -0
  136. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_renames.py +0 -0
  137. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_running_pane.py +0 -0
  138. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_running_sort.py +0 -0
  139. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_runtime_config.py +0 -0
  140. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_scroll_agent.py +0 -0
  141. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_scroll_manager.py +0 -0
  142. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_selection_isolation.py +0 -0
  143. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_self_update.py +0 -0
  144. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_session_cache.py +0 -0
  145. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_session_index.py +0 -0
  146. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_sessions_pane.py +0 -0
  147. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_setting_contracts.py +0 -0
  148. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_settings.py +0 -0
  149. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_sidebar.py +0 -0
  150. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_ssh_args.py +0 -0
  151. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_ssh_compat.py +0 -0
  152. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_ssh_display_benchmark.py +0 -0
  153. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_ssh_display_diagnostics.py +0 -0
  154. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_ssh_doctor.py +0 -0
  155. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_status.py +0 -0
  156. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_statusbar.py +0 -0
  157. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_system_deps.py +0 -0
  158. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_terminal_size.py +0 -0
  159. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_terminal_status.py +0 -0
  160. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_termux_local_touch.py +0 -0
  161. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tmux_health.py +0 -0
  162. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tmux_server.py +0 -0
  163. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tmux_status.py +0 -0
  164. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_tool_panes.py +0 -0
  165. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_transcript.py +0 -0
  166. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_utf8_state.py +0 -0
  167. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_web_demo_recorder.py +0 -0
  168. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_wheel_smoke.py +0 -0
  169. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_widgets.py +0 -0
  170. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_windows_bootstrap.py +0 -0
  171. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_windows_wrapper_contract.py +0 -0
  172. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tests/test_yolo_prompt.py +0 -0
  173. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tools/denested_transport_benchmark.py +0 -0
  174. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/tools/release_notes.py +0 -0
  175. {railmux-0.4.0.dev8 → railmux-0.4.0.dev10}/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.dev8
3
+ Version: 0.4.0.dev10
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
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0.dev10"
@@ -20,16 +20,20 @@ from railmux.runtime_config import (
20
20
  )
21
21
  from railmux import tmux_health
22
22
  from railmux import tmux_server
23
+ from railmux import windows_tmux_lifecycle
23
24
  from railmux.system_deps import ensure_tmux_available
24
25
  from railmux.ssh_args import AppendSshArgument, ExtendSshArguments
25
26
 
26
27
 
27
- def _show_startup_message() -> None:
28
+ def _show_startup_message(
29
+ detail: str = "Reconnecting sessions and panes…",
30
+ ) -> None:
28
31
  """Paint immediate feedback before App performs its initial discovery."""
29
32
  if not sys.stdout.isatty():
30
33
  return
31
34
  size = shutil.get_terminal_size((80, 24))
32
- sys.stdout.write(render_startup_surface(size.columns, size.lines))
35
+ sys.stdout.write(render_startup_surface(
36
+ size.columns, size.lines, detail=detail))
33
37
  sys.stdout.flush()
34
38
 
35
39
 
@@ -123,7 +127,16 @@ def _run_tmux_client_with_watchdog(
123
127
  file=sys.stderr,
124
128
  )
125
129
  return 2
130
+ # tmux may leave the client TTY in raw mode even after its process
131
+ # exits. Restore it before a bounded recovery probe or user message.
132
+ _restore_terminal(attributes)
126
133
  returncode = process.returncode or 0
134
+ if windows_tmux_lifecycle.recover_abandoned_socket():
135
+ print(
136
+ "info: cleaned an abandoned Windows tmux socket; Codex and "
137
+ "Claude session files were not changed",
138
+ file=sys.stderr,
139
+ )
127
140
  if returncode and expected_target is not None:
128
141
  try:
129
142
  current_target = tmux_server.discover_target(timeout=1.0)
@@ -364,6 +377,12 @@ def main(argv: list[str] | None = None) -> int:
364
377
  # Validate before any command can address a tmux server. ``tmux -V``
365
378
  # above is server-independent.
366
379
  tmux_server.socket_label()
380
+ if windows_tmux_lifecycle.recover_abandoned_socket():
381
+ print(
382
+ "info: cleaned an abandoned Windows tmux socket; Codex and "
383
+ "Claude session files were not changed",
384
+ file=sys.stderr,
385
+ )
367
386
  dedicated_target = tmux_server.discover_target()
368
387
  on_dedicated_server = tmux_server.is_current_server(dedicated_target)
369
388
  except tmux_server.TmuxServerError as exc:
@@ -441,6 +460,7 @@ def main(argv: list[str] | None = None) -> int:
441
460
  _show_startup_message()
442
461
  # Lazy import so non-TUI invocations (--version etc) don't pull urwid.
443
462
  from railmux.ui.app import App
463
+ from railmux.provider_paths import running_in_windows_wrapper
444
464
  app = App(
445
465
  claude_home=Path(args.claude_home),
446
466
  config=config,
@@ -451,6 +471,10 @@ def main(argv: list[str] | None = None) -> int:
451
471
  is_ssh_session() if args.scroll_coalescing is None
452
472
  else args.scroll_coalescing
453
473
  ),
474
+ startup_progress=(
475
+ _show_startup_message
476
+ if running_in_windows_wrapper() else None
477
+ ),
454
478
  )
455
479
  app.run()
456
480
  return 0
@@ -25,7 +25,7 @@ from railmux.models import (
25
25
  Project,
26
26
  SessionMeta,
27
27
  )
28
- from railmux.provider_paths import provider_path
28
+ from railmux.provider_paths import private_mode_is_safe, provider_path
29
29
  from railmux.renames import Renames
30
30
 
31
31
  # Codex has no reliable provider-neutral signal that distinguishes a long
@@ -461,7 +461,7 @@ class CodexIndex:
461
461
  if (
462
462
  not stat.S_ISREG(info.st_mode)
463
463
  or info.st_uid != os.getuid()
464
- or info.st_mode & 0o077
464
+ or not private_mode_is_safe(info.st_mode)
465
465
  or info.st_size > _PERSISTENT_CACHE_MAX_BYTES
466
466
  ):
467
467
  return
@@ -1,13 +1,16 @@
1
1
  """Enumerate Claude project directories from ~/.claude/projects/."""
2
2
  from __future__ import annotations
3
3
 
4
+ import hashlib
4
5
  import json
5
6
  import os
7
+ import stat
6
8
  from pathlib import Path
7
9
 
8
10
  from railmux.atomic_file import atomic_write_text
9
11
  from railmux.models import Project
10
12
  from railmux.path_codec import decode, is_encoded_project_name
13
+ from railmux.provider_paths import private_mode_is_safe
11
14
  from railmux.session_index import _looks_like_uuid, _scan_session
12
15
 
13
16
 
@@ -19,6 +22,134 @@ _cache: dict[Path, tuple[int, list[Project]]] = {}
19
22
  _session_validity: dict[
20
23
  Path, dict[Path, tuple[tuple[int, int, int], bool]]
21
24
  ] = {}
25
+ _persistent_validity_loaded: set[Path] = set()
26
+ _persistent_validity_exact_only: set[Path] = set()
27
+ _persistent_validity_dirty: set[Path] = set()
28
+ _VALIDITY_CACHE_SCHEMA = 1
29
+ _VALIDITY_CACHE_LIMIT = 16 * 1024 * 1024
30
+ _VALIDITY_RECORD_LIMIT = 100_000
31
+
32
+
33
+ def _windows_validity_cache_enabled() -> bool:
34
+ return os.environ.get("RAILMUX_WINDOWS_RUNTIME") == "msys2"
35
+
36
+
37
+ def _validity_cache_file(claude_home: Path) -> Path:
38
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
39
+ identity = hashlib.sha256(
40
+ os.fsencode(os.path.abspath(claude_home))
41
+ ).hexdigest()[:16]
42
+ return base / "railmux" / f"claude-validity-{identity}.json"
43
+
44
+
45
+ def _load_persistent_validity(claude_home: Path) -> None:
46
+ if (
47
+ not _windows_validity_cache_enabled()
48
+ or claude_home in _persistent_validity_loaded
49
+ ):
50
+ return
51
+ _persistent_validity_loaded.add(claude_home)
52
+ path = _validity_cache_file(claude_home)
53
+ try:
54
+ info = path.lstat()
55
+ if (
56
+ not stat.S_ISREG(info.st_mode)
57
+ or info.st_uid != os.getuid()
58
+ or info.st_size > _VALIDITY_CACHE_LIMIT
59
+ or not private_mode_is_safe(info.st_mode)
60
+ ):
61
+ return
62
+ raw = json.loads(path.read_text(encoding="utf-8"))
63
+ except (OSError, UnicodeError, json.JSONDecodeError):
64
+ return
65
+ if not isinstance(raw, dict) or raw.get("schema") != _VALIDITY_CACHE_SCHEMA:
66
+ return
67
+ records = raw.get("records")
68
+ if not isinstance(records, list) or len(records) > _VALIDITY_RECORD_LIMIT:
69
+ return
70
+ projects_dir = claude_home / "projects"
71
+ loaded: list[Path] = []
72
+ try:
73
+ for record in records:
74
+ if not isinstance(record, list) or len(record) != 6:
75
+ raise ValueError("invalid validity record")
76
+ project, filename, inode, mtime_ns, size, valid = record
77
+ if (
78
+ not isinstance(project, str)
79
+ or not is_encoded_project_name(project)
80
+ or not isinstance(filename, str)
81
+ or Path(filename).name != filename
82
+ or not filename.endswith(".jsonl")
83
+ or not _looks_like_uuid(Path(filename).stem)
84
+ or not all(isinstance(value, int) and value >= 0
85
+ for value in (inode, mtime_ns, size))
86
+ or not isinstance(valid, bool)
87
+ ):
88
+ raise ValueError("invalid validity record")
89
+ project_dir = projects_dir / project
90
+ session_path = project_dir / filename
91
+ _session_validity.setdefault(project_dir, {})[session_path] = (
92
+ (inode, mtime_ns, size), valid,
93
+ )
94
+ loaded.append(session_path)
95
+ except ValueError:
96
+ for session_path in loaded:
97
+ cached = _session_validity.get(session_path.parent)
98
+ if cached is not None:
99
+ cached.pop(session_path, None)
100
+ if not cached:
101
+ _session_validity.pop(session_path.parent, None)
102
+ return
103
+ _persistent_validity_exact_only.update(loaded)
104
+
105
+
106
+ def _mark_persistent_validity_dirty(claude_dir: Path) -> None:
107
+ if _windows_validity_cache_enabled():
108
+ _persistent_validity_dirty.add(claude_dir.parent.parent)
109
+
110
+
111
+ def _save_persistent_validity(claude_home: Path) -> None:
112
+ if (
113
+ not _windows_validity_cache_enabled()
114
+ or claude_home not in _persistent_validity_dirty
115
+ ):
116
+ return
117
+ projects_dir = claude_home / "projects"
118
+ records: list[list[object]] = []
119
+ for project_dir, values in _session_validity.items():
120
+ if (
121
+ project_dir.parent != projects_dir
122
+ or not is_encoded_project_name(project_dir.name)
123
+ ):
124
+ continue
125
+ for session_path, (signature, valid) in values.items():
126
+ if session_path.parent != project_dir:
127
+ continue
128
+ inode, mtime_ns, size = signature
129
+ records.append([
130
+ project_dir.name,
131
+ session_path.name,
132
+ inode,
133
+ mtime_ns,
134
+ size,
135
+ valid,
136
+ ])
137
+ records.sort(key=lambda item: int(item[3]), reverse=True)
138
+ records = records[:_VALIDITY_RECORD_LIMIT]
139
+ path = _validity_cache_file(claude_home)
140
+ try:
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ atomic_write_text(
143
+ path,
144
+ json.dumps(
145
+ {"schema": _VALIDITY_CACHE_SCHEMA, "records": records},
146
+ separators=(",", ":"),
147
+ ),
148
+ )
149
+ os.chmod(path, 0o600)
150
+ except OSError:
151
+ return
152
+ _persistent_validity_dirty.discard(claude_home)
22
153
 
23
154
 
24
155
  def invalidate_session(path: Path) -> None:
@@ -27,6 +158,8 @@ def invalidate_session(path: Path) -> None:
27
158
  if cached is None:
28
159
  return
29
160
  cached.pop(path, None)
161
+ _persistent_validity_exact_only.discard(path)
162
+ _mark_persistent_validity_dirty(path.parent)
30
163
  if not cached:
31
164
  _session_validity.pop(path.parent, None)
32
165
 
@@ -64,6 +197,7 @@ def list_projects(claude_home: Path) -> list[Project]:
64
197
  session counts and recency track files created, changed, or removed below
65
198
  the cached top-level directory.
66
199
  """
200
+ _load_persistent_validity(claude_home)
67
201
  projects_dir = claude_home / "projects"
68
202
  if not projects_dir.is_dir():
69
203
  return []
@@ -79,6 +213,7 @@ def list_projects(claude_home: Path) -> list[Project]:
79
213
  # without re-decoding every project name.
80
214
  refreshed = _refresh_activity(cached[1])
81
215
  _cache[claude_home] = (parent_mtime, refreshed)
216
+ _save_persistent_validity(claude_home)
82
217
  return refreshed
83
218
 
84
219
  # Full scan needed.
@@ -153,6 +288,7 @@ def list_projects(claude_home: Path) -> list[Project]:
153
288
 
154
289
  results.sort(key=lambda p: p.last_activity_ts, reverse=True)
155
290
  _cache[claude_home] = (parent_mtime, results)
291
+ _save_persistent_validity(claude_home)
156
292
  return results
157
293
 
158
294
 
@@ -198,6 +334,7 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
198
334
  if cached is not None and cached[0] == signature:
199
335
  valid = cached[1]
200
336
  elif (cached is not None and cached[1]
337
+ and path not in _persistent_validity_exact_only
201
338
  and cached[0][0] == stat.st_ino
202
339
  and stat.st_size > cached[0][2]):
203
340
  # Claude session JSONLs are append-only. Once a file is valid,
@@ -208,9 +345,12 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
208
345
  # explicitly via ``invalidate_session``).
209
346
  valid = True
210
347
  validity[path] = (signature, True)
348
+ _mark_persistent_validity_dirty(claude_dir)
211
349
  else:
212
350
  valid = _scan_session(temp_project, path) is not None
213
351
  validity[path] = (signature, valid)
352
+ _persistent_validity_exact_only.discard(path)
353
+ _mark_persistent_validity_dirty(claude_dir)
214
354
  if not valid:
215
355
  continue
216
356
  count += 1
@@ -219,6 +359,8 @@ def _count_and_latest_mtime(claude_dir: Path, real_path: Path) -> tuple[int, flo
219
359
  latest = m
220
360
  for stale in set(validity) - current_paths:
221
361
  del validity[stale]
362
+ _persistent_validity_exact_only.discard(stale)
363
+ _mark_persistent_validity_dirty(claude_dir)
222
364
  if not validity:
223
365
  _session_validity.pop(claude_dir, None)
224
366
  return count, latest
@@ -1493,7 +1493,7 @@ def _acquire_display_lock(
1493
1493
  if (
1494
1494
  not stat.S_ISREG(info.st_mode)
1495
1495
  or info.st_uid != os.getuid()
1496
- or info.st_mode & 0o077
1496
+ or not restart_state.private_mode_is_safe(info.st_mode)
1497
1497
  ):
1498
1498
  raise OSError("unsafe display lock")
1499
1499
  deadline = time.monotonic() + max(0.0, timeout)
@@ -60,7 +60,7 @@ class RootWheelForwardingManager:
60
60
  info = self._state_path.lstat()
61
61
  if (not stat.S_ISREG(info.st_mode)
62
62
  or info.st_uid != os.getuid()
63
- or info.st_mode & 0o077
63
+ or not restart_state.private_mode_is_safe(info.st_mode)
64
64
  or info.st_size > _MAX_STATE_BYTES):
65
65
  return None
66
66
  raw = json.loads(self._state_path.read_text(encoding="utf-8"))
@@ -231,7 +231,7 @@ def claim_owner(
231
231
  parent = lock_path.parent.stat()
232
232
  if (not stat.S_ISDIR(parent.st_mode)
233
233
  or parent.st_uid != os.getuid()
234
- or parent.st_mode & 0o077):
234
+ or not restart_state.private_mode_is_safe(parent.st_mode)):
235
235
  return None
236
236
  flags = os.O_CREAT | os.O_RDWR
237
237
  if hasattr(os, "O_NOFOLLOW"):
@@ -243,7 +243,7 @@ def claim_owner(
243
243
  info = os.fstat(fd)
244
244
  if (not stat.S_ISREG(info.st_mode)
245
245
  or info.st_uid != os.getuid()
246
- or info.st_mode & 0o077):
246
+ or not restart_state.private_mode_is_safe(info.st_mode)):
247
247
  return None
248
248
  try:
249
249
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -1,13 +1,17 @@
1
1
  """Translate provider-owned native Windows paths inside the MSYS2 preview."""
2
2
  from __future__ import annotations
3
3
 
4
+ import json
4
5
  import os
5
6
  import re
7
+ import stat
8
+ import sys
6
9
  from collections.abc import Mapping
7
10
  from pathlib import Path
8
11
 
9
12
 
10
13
  _DRIVE_PATH = re.compile(r"^(?:\\\\\?\\)?([A-Za-z]):[\\/](.*)\Z")
14
+ _MANAGED_RUNTIME_MARKER = Path("/railmux-runtime.json")
11
15
 
12
16
 
13
17
  def running_in_windows_wrapper(environ: Mapping[str, str] = os.environ) -> bool:
@@ -15,6 +19,41 @@ def running_in_windows_wrapper(environ: Mapping[str, str] = os.environ) -> bool:
15
19
  return environ.get("RAILMUX_WINDOWS_RUNTIME") == "msys2"
16
20
 
17
21
 
22
+ def running_in_managed_windows_wrapper(
23
+ environ: Mapping[str, str] = os.environ,
24
+ ) -> bool:
25
+ """Verify the installed MSYS2 runtime, not merely its environment hint."""
26
+ if sys.platform != "cygwin" or not running_in_windows_wrapper(environ):
27
+ return False
28
+ marker = _MANAGED_RUNTIME_MARKER
29
+ try:
30
+ info = marker.lstat()
31
+ if (
32
+ not stat.S_ISREG(info.st_mode)
33
+ or info.st_uid != os.getuid()
34
+ or info.st_mode & 0o022
35
+ or info.st_size > 4096
36
+ ):
37
+ return False
38
+ payload = json.loads(marker.read_text(encoding="utf-8"))
39
+ except (OSError, UnicodeError, json.JSONDecodeError):
40
+ return False
41
+ from railmux import __version__
42
+ runtime_id = environ.get("RAILMUX_MSYS2_RUNTIME_ID")
43
+ return isinstance(runtime_id, str) and bool(runtime_id) and payload == {
44
+ "schema": 1,
45
+ "runtime": runtime_id,
46
+ "railmux": __version__,
47
+ }
48
+
49
+
50
+ def private_mode_is_safe(mode: int) -> bool:
51
+ """Accept strict POSIX modes or the verified MSYS2 noacl projection."""
52
+ return not mode & 0o077 or (
53
+ running_in_managed_windows_wrapper() and not mode & 0o022
54
+ )
55
+
56
+
18
57
  def provider_path(
19
58
  raw: str,
20
59
  *,
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from railmux import tmux_ctl
18
18
  from railmux.atomic_file import atomic_write_text
19
19
  from railmux.config import default_config_path
20
+ from railmux.provider_paths import private_mode_is_safe
20
21
 
21
22
 
22
23
  SCHEMA_VERSION = 1
@@ -164,10 +165,15 @@ def legacy_state_path() -> Path:
164
165
  def _ensure_private_dir(path: Path) -> None:
165
166
  """Create and verify a Railmux-owned 0700 runtime directory."""
166
167
  path.mkdir(mode=0o700, parents=True, exist_ok=True)
167
- info = path.stat()
168
+ info = path.lstat()
169
+ # MSYS2's default noacl projection reports user-owned NTFS directories as
170
+ # 0755 even after chmod(0700). The managed runtime itself lives below the
171
+ # Windows user's LocalAppData ACL. Accept that projection only on the real
172
+ # Cygwin/MSYS Python platform, and still reject symlinks, foreign owners,
173
+ # and any group/world-writable directory. POSIX platforms retain 0700.
168
174
  if (not stat.S_ISDIR(info.st_mode)
169
175
  or info.st_uid != os.getuid()
170
- or info.st_mode & 0o077):
176
+ or not private_mode_is_safe(info.st_mode)):
171
177
  raise OSError("Railmux runtime state directory is not private")
172
178
 
173
179
 
@@ -14,8 +14,14 @@ from railmux import restart_state, tmux_ctl
14
14
  from railmux.atomic_file import atomic_write_text
15
15
 
16
16
 
17
- _VERSION = 8
17
+ _VERSION = 9
18
18
  _KEYS = ("F8", "F9")
19
+ _STATUS_CLICK_KEYS = (
20
+ "MouseDown1Status",
21
+ "MouseDown1Control0",
22
+ "MouseDown1Control1",
23
+ "MouseDown1Control2",
24
+ )
19
25
  _MAX_STATE_BYTES = 64 * 1024
20
26
 
21
27
 
@@ -79,7 +85,7 @@ class SharedTmuxBindingManager:
79
85
  info = self._state_path.lstat()
80
86
  if (not stat.S_ISREG(info.st_mode)
81
87
  or info.st_uid != os.getuid()
82
- or info.st_mode & 0o077
88
+ or not restart_state.private_mode_is_safe(info.st_mode)
83
89
  or info.st_size > _MAX_STATE_BYTES):
84
90
  return None
85
91
  raw = json.loads(self._state_path.read_text(encoding="utf-8"))
@@ -114,12 +120,18 @@ class SharedTmuxBindingManager:
114
120
  and all(value is None or isinstance(value, str)
115
121
  for value in right_click_backup.values())
116
122
  )
117
- status_click_valid = (
123
+ legacy_status_click_valid = (
118
124
  isinstance(status_click_backup, dict)
119
125
  and set(status_click_backup) == {"MouseDown1Status"}
120
126
  and all(value is None or isinstance(value, str)
121
127
  for value in status_click_backup.values())
122
128
  )
129
+ status_click_valid = (
130
+ isinstance(status_click_backup, dict)
131
+ and set(status_click_backup) == set(_STATUS_CLICK_KEYS)
132
+ and all(value is None or isinstance(value, str)
133
+ for value in status_click_backup.values())
134
+ )
123
135
  termux_tap_valid = (
124
136
  isinstance(termux_tap_backup, dict)
125
137
  and set(termux_tap_backup) == {"MouseDown1Pane"}
@@ -149,7 +161,13 @@ class SharedTmuxBindingManager:
149
161
  or (not selection_hook_managed
150
162
  and selection_hook_index is not None)))
151
163
  or (raw["version"] >= 5
152
- and (not status_click_valid
164
+ and ((raw["version"] < 9
165
+ and not (
166
+ legacy_status_click_valid
167
+ or status_click_valid
168
+ ))
169
+ or (raw["version"] >= 9
170
+ and not status_click_valid)
153
171
  or not isinstance(status_click_managed, bool)))
154
172
  or (raw["version"] >= 8
155
173
  and (not termux_tap_valid
@@ -293,6 +311,29 @@ class SharedTmuxBindingManager:
293
311
  or {"MouseDown1Pane": None}
294
312
  )
295
313
  upgraded = True
314
+ if state["version"] < 9:
315
+ if state["status_click_managed"]:
316
+ if set(state["status_click_backup"]) != set(
317
+ _STATUS_CLICK_KEYS
318
+ ):
319
+ status_click_backup = (
320
+ tmux_ctl.upgrade_root_status_click_binding(
321
+ state["status_click_backup"],
322
+ state["token"],
323
+ )
324
+ )
325
+ if status_click_backup is None:
326
+ return False
327
+ state["status_click_backup"] = (
328
+ status_click_backup)
329
+ else:
330
+ state["status_click_backup"] = {
331
+ key: None for key in _STATUS_CLICK_KEYS
332
+ }
333
+ # tmux 3.7 routes control ranges through dedicated
334
+ # MouseDown1ControlN keys. Capture all originals before
335
+ # reinstalling the shared status-click lease.
336
+ upgraded = True
296
337
  if upgraded:
297
338
  state["version"] = _VERSION
298
339
  state["phase"] = "installing"
@@ -341,17 +382,14 @@ class SharedTmuxBindingManager:
341
382
  )
342
383
  if state["status_click_managed"]:
343
384
  current_status = (
344
- tmux_ctl.read_root_status_click_binding()
345
- ["MouseDown1Status"]
346
- )
347
- safe = (
348
- safe
349
- and tmux_ctl.root_status_click_binding_is_original_or_owned(
350
- current_status,
351
- state["status_click_backup"].get(
352
- "MouseDown1Status"),
385
+ tmux_ctl.read_root_status_click_binding())
386
+ safe = safe and all(
387
+ tmux_ctl.root_status_click_binding_is_original_or_owned(
388
+ current_status.get(key),
389
+ state["status_click_backup"].get(key),
353
390
  token,
354
391
  )
392
+ for key in _STATUS_CLICK_KEYS
355
393
  )
356
394
  if state["termux_tap_managed"]:
357
395
  current_tap = (
@@ -543,7 +581,8 @@ class SharedTmuxBindingManager:
543
581
  "selection_hook_index": selection_hook_index,
544
582
  "status_click_managed": status_click_backup is not None,
545
583
  "status_click_backup": (
546
- status_click_backup or {"MouseDown1Status": None}),
584
+ status_click_backup
585
+ or {key: None for key in _STATUS_CLICK_KEYS}),
547
586
  "termux_tap_managed": termux_tap_backup is not None,
548
587
  "termux_tap_backup": (
549
588
  termux_tap_backup or {"MouseDown1Pane": None}),