railmux 0.4.0.dev28__tar.gz → 0.4.0.dev29__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 (181) hide show
  1. {railmux-0.4.0.dev28/src/railmux.egg-info → railmux-0.4.0.dev29}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev29/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_install_log.py +21 -4
  4. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_msys2.py +279 -5
  5. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29/src/railmux.egg-info}/PKG-INFO +1 -1
  6. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_install_log.py +36 -2
  7. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_msys2.py +264 -3
  8. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tools/windows_archive_smoke.py +37 -1
  9. railmux-0.4.0.dev28/src/railmux/__init__.py +0 -1
  10. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/LICENSE +0 -0
  11. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/MANIFEST.in +0 -0
  12. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/README.md +0 -0
  13. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/pyproject.toml +0 -0
  14. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/setup.cfg +0 -0
  15. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/__main__.py +0 -0
  16. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/atomic_file.py +0 -0
  17. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/background_index.py +0 -0
  18. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/cli.py +0 -0
  19. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/codex_index.py +0 -0
  20. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/config.py +0 -0
  21. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/config_cli.py +0 -0
  22. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/diagnostics.py +0 -0
  23. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/discovery.py +0 -0
  24. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/display_transport.py +0 -0
  25. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/entrypoint.py +0 -0
  26. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/fast_display_client.py +0 -0
  27. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/fast_display_history.py +0 -0
  28. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/fast_display_input.py +0 -0
  29. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/fast_display_protocol.py +0 -0
  30. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/fast_display_server.py +0 -0
  31. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/favorites.py +0 -0
  32. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/fuzzy.py +0 -0
  33. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/help_workspace.py +0 -0
  34. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/launcher.py +0 -0
  35. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/legacy_sessions.py +0 -0
  36. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/local_clipboard.py +0 -0
  37. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/local_open.py +0 -0
  38. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/models.py +0 -0
  39. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/modes.py +0 -0
  40. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/mouse_manager.py +0 -0
  41. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/orphan_marker.py +0 -0
  42. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/pane_surface.py +0 -0
  43. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/path_codec.py +0 -0
  44. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/provider_paths.py +0 -0
  45. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/remote_config.py +0 -0
  46. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/renames.py +0 -0
  47. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/restart_state.py +0 -0
  48. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/runtime_config.py +0 -0
  49. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/scroll_agent.py +0 -0
  50. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/scroll_manager.py +0 -0
  51. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/selection_isolation.py +0 -0
  52. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/self_update.py +0 -0
  53. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/session_cache.py +0 -0
  54. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/session_index.py +0 -0
  55. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/session_lease.py +0 -0
  56. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/setting_contracts.py +0 -0
  57. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/settings.py +0 -0
  58. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ssh_args.py +0 -0
  59. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ssh_compat.py +0 -0
  60. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ssh_display_diagnostics.py +0 -0
  61. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ssh_doctor.py +0 -0
  62. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/system_deps.py +0 -0
  63. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/terminal_status.py +0 -0
  64. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/tmux_binding_manager.py +0 -0
  65. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/tmux_ctl.py +0 -0
  66. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/tmux_health.py +0 -0
  67. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/tmux_server.py +0 -0
  68. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/tool_panes.py +0 -0
  69. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/transcript.py +0 -0
  70. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/__init__.py +0 -0
  71. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/_widgets.py +0 -0
  72. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/app.py +0 -0
  73. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/keymap.py +0 -0
  74. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/modals.py +0 -0
  75. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/projects_pane.py +0 -0
  76. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/running_pane.py +0 -0
  77. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/sessions_pane.py +0 -0
  78. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/sidebar.py +0 -0
  79. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/statusbar.py +0 -0
  80. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/ui/workspace.py +0 -0
  81. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_attach_relay.py +0 -0
  82. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_bootstrap.py +0 -0
  83. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_pacman.py +0 -0
  84. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_tmux_lifecycle.py +0 -0
  85. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux/windows_ui_transition.py +0 -0
  86. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux.egg-info/SOURCES.txt +0 -0
  87. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux.egg-info/dependency_links.txt +0 -0
  88. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux.egg-info/entry_points.txt +0 -0
  89. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux.egg-info/requires.txt +0 -0
  90. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/src/railmux.egg-info/top_level.txt +0 -0
  91. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_agent_workspace.py +0 -0
  92. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_atomic_file.py +0 -0
  93. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_background_index.py +0 -0
  94. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_cli.py +0 -0
  95. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_codex_delete.py +0 -0
  96. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_codex_index.py +0 -0
  97. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_codex_lineage.py +0 -0
  98. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_codex_transcript.py +0 -0
  99. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_config.py +0 -0
  100. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_config_cli.py +0 -0
  101. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_denested_transport_benchmark.py +0 -0
  102. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_diagnostics.py +0 -0
  103. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_discovery.py +0 -0
  104. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_display_transport.py +0 -0
  105. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_fast_full_window.py +0 -0
  106. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_focus_highlight.py +0 -0
  107. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_fullscreen_binding.py +0 -0
  108. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_fuzzy.py +0 -0
  109. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_help_agent.py +0 -0
  110. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_help_workspace.py +0 -0
  111. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_history_cleanup.py +0 -0
  112. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_keymap.py +0 -0
  113. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_launcher.py +0 -0
  114. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_layout_profile.py +0 -0
  115. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_legacy_sessions.py +0 -0
  116. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_local_clipboard.py +0 -0
  117. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_local_open.py +0 -0
  118. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_modals.py +0 -0
  119. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_mode_selection.py +0 -0
  120. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_modes.py +0 -0
  121. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_mouse_manager.py +0 -0
  122. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_new_project.py +0 -0
  123. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_options.py +0 -0
  124. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_orphan_recovery.py +0 -0
  125. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_pane_surface.py +0 -0
  126. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_path_codec.py +0 -0
  127. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_projects_pane.py +0 -0
  128. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_provider_paths.py +0 -0
  129. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_release_notes.py +0 -0
  130. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_release_workflow.py +0 -0
  131. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_remote_config.py +0 -0
  132. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_renames.py +0 -0
  133. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_restart_state.py +0 -0
  134. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_running_pane.py +0 -0
  135. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_running_sort.py +0 -0
  136. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_runtime_config.py +0 -0
  137. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_scroll_agent.py +0 -0
  138. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_scroll_manager.py +0 -0
  139. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_selection_isolation.py +0 -0
  140. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_self_update.py +0 -0
  141. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_session_cache.py +0 -0
  142. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_session_index.py +0 -0
  143. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_session_lease.py +0 -0
  144. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_sessions_pane.py +0 -0
  145. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_setting_contracts.py +0 -0
  146. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_settings.py +0 -0
  147. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_sidebar.py +0 -0
  148. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_soft_quit.py +0 -0
  149. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_ssh_args.py +0 -0
  150. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_ssh_compat.py +0 -0
  151. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_ssh_display_benchmark.py +0 -0
  152. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_ssh_display_diagnostics.py +0 -0
  153. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_ssh_doctor.py +0 -0
  154. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_status.py +0 -0
  155. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_statusbar.py +0 -0
  156. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_system_deps.py +0 -0
  157. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_terminal_size.py +0 -0
  158. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_terminal_status.py +0 -0
  159. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_termux_local_touch.py +0 -0
  160. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tmux_binding_manager.py +0 -0
  161. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tmux_ctl.py +0 -0
  162. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tmux_health.py +0 -0
  163. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tmux_integration.py +0 -0
  164. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tmux_server.py +0 -0
  165. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tmux_status.py +0 -0
  166. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_tool_panes.py +0 -0
  167. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_transcript.py +0 -0
  168. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_utf8_state.py +0 -0
  169. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_web_demo_recorder.py +0 -0
  170. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_wheel_smoke.py +0 -0
  171. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_widgets.py +0 -0
  172. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_attach_relay.py +0 -0
  173. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_bootstrap.py +0 -0
  174. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_pacman.py +0 -0
  175. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_tmux_lifecycle.py +0 -0
  176. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_ui_transition.py +0 -0
  177. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_windows_wrapper_contract.py +0 -0
  178. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tests/test_yolo_prompt.py +0 -0
  179. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tools/denested_transport_benchmark.py +0 -0
  180. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/tools/release_notes.py +0 -0
  181. {railmux-0.4.0.dev28 → railmux-0.4.0.dev29}/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.dev28
3
+ Version: 0.4.0.dev29
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.dev29"
@@ -60,6 +60,7 @@ _MSYS2_RESTART_MARKER = (
60
60
  "to complete this update all msys2 processes including this terminal "
61
61
  "will be closed"
62
62
  )
63
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
63
64
  _EXTRACTION_PERCENT_RE = re.compile(r"(?:^|\s)([0-9]{1,3})%\s")
64
65
  _PACKAGE_COUNT_RE = re.compile(r"^Packages \(([0-9]+)\)")
65
66
  _PACKAGE_DOWNLOAD_RE = re.compile(r"^\s*([^ ]+) downloading\.\.\.\s*$")
@@ -91,7 +92,12 @@ def install_log_path(
91
92
  )
92
93
 
93
94
 
94
- def _prune_install_logs(directory: Path, *, keep: int = _LOG_KEEP_COUNT) -> None:
95
+ def _prune_install_logs(
96
+ directory: Path,
97
+ *,
98
+ keep: int = _LOG_KEEP_COUNT,
99
+ preserve: Path | None = None,
100
+ ) -> None:
95
101
  candidates: list[Path] = []
96
102
  try:
97
103
  entries = list(directory.iterdir())
@@ -107,7 +113,11 @@ def _prune_install_logs(directory: Path, *, keep: int = _LOG_KEEP_COUNT) -> None
107
113
  )
108
114
  except OSError:
109
115
  return
110
- for stale in candidates[max(keep, 1) :]:
116
+ preserved = preserve if preserve in candidates else None
117
+ others = [candidate for candidate in candidates if candidate != preserved]
118
+ other_limit = max(keep, 1) - (1 if preserved is not None else 0)
119
+ stale_candidates = others[max(other_limit, 0) :]
120
+ for stale in stale_candidates:
111
121
  try:
112
122
  stale.unlink()
113
123
  except OSError:
@@ -147,12 +157,15 @@ class InstallReporter:
147
157
  self._pip_network_failure = False
148
158
  self._hard_failed_hosts: set[str] = set()
149
159
  self._msys2_restart_requested = False
160
+ self._msys2_restart_lines: deque[str] = deque(maxlen=3)
150
161
 
151
162
  def __enter__(self) -> InstallReporter:
152
163
  self.path.parent.mkdir(parents=True, exist_ok=True)
153
164
  self._log = self.path.open("x", encoding="utf-8", newline="\n")
154
165
  self._write_log("Railmux Windows runtime installation\n")
155
- _prune_install_logs(self.path.parent)
166
+ # Windows may defer the visible last-write timestamp of an open file.
167
+ # Never let retention mistake the active evidence log for an old one.
168
+ _prune_install_logs(self.path.parent, preserve=self.path)
156
169
  return self
157
170
 
158
171
  def __exit__(self, _exc_type, _exc, _traceback) -> None:
@@ -215,6 +228,7 @@ class InstallReporter:
215
228
  self._pip_network_failure = False
216
229
  self._hard_failed_hosts.clear()
217
230
  self._msys2_restart_requested = False
231
+ self._msys2_restart_lines.clear()
218
232
  self._write_log(f"\n--- {label} ---\n")
219
233
 
220
234
  def command_output(self, output: bytes | str | None) -> None:
@@ -265,7 +279,10 @@ class InstallReporter:
265
279
  if line.strip():
266
280
  self._tail.append(line)
267
281
  lowered = line.lower()
268
- if _MSYS2_RESTART_MARKER in lowered:
282
+ normalized = " ".join(_ANSI_ESCAPE_RE.sub("", lowered).split())
283
+ if normalized:
284
+ self._msys2_restart_lines.append(normalized)
285
+ if _MSYS2_RESTART_MARKER in " ".join(self._msys2_restart_lines):
269
286
  self._msys2_restart_requested = True
270
287
  if any(marker in lowered for marker in _NETWORK_ERROR_MARKERS):
271
288
  self._network_failure = True
@@ -120,6 +120,28 @@ _PACKAGE_INVENTORY_COMMAND = "set -o pipefail; pacman -Q | LC_ALL=C sort"
120
120
  _PACKAGE_INVENTORY_LIMIT = 1024 * 1024
121
121
  _CORE_RUNTIME_PACKAGES = ("tmux", "python", "python-pip")
122
122
  _MAX_BASE_UPDATE_PASSES = 3
123
+ _PACMAN_LOG_RELATIVE = Path("var") / "log" / "pacman.log"
124
+ _PACMAN_LOG_MAX_SIZE = 8 * 1024 * 1024
125
+ _PACMAN_LOG_APPEND_LIMIT = 2 * 1024 * 1024
126
+ _PACMAN_LOCAL_DB_ENTRY_LIMIT = 4096
127
+ _PACMAN_LOCAL_DESC_LIMIT = 64 * 1024
128
+ # Keep this aligned with alpm_pkg_is_core_package in the pacman build shipped
129
+ # by the pinned base. Any one of these packages makes MSYS2 close its processes.
130
+ _PACMAN_CORE_PACKAGES = frozenset(
131
+ {
132
+ "bash",
133
+ "filesystem",
134
+ "mintty",
135
+ "msys2-runtime",
136
+ "msys2-runtime-devel",
137
+ "pacman",
138
+ "pacman-mirrors",
139
+ }
140
+ )
141
+ _PACMAN_CORE_CHANGE_RE = re.compile(
142
+ r"\[ALPM] (?:upgraded|downgraded) ([^ ]+) \(([^ ]+) -> ([^)]+)\)",
143
+ re.IGNORECASE,
144
+ )
123
145
  _PRIVATE_GPG_SHUTDOWN_COMMAND = (
124
146
  "gpgconf --homedir /etc/pacman.d/gnupg --kill all"
125
147
  )
@@ -200,6 +222,16 @@ ArchiveProbe = Callable[[str, str, int], _ArchiveProbe]
200
222
  ArchiveResume = Callable[[str, str, int, int, Callable[[bytes], None]], None]
201
223
 
202
224
 
225
+ @dataclass(frozen=True)
226
+ class _PacmanLogCheckpoint:
227
+ existed: bool
228
+ size: int
229
+ identity: tuple[int, int] | None
230
+ tail_offset: int
231
+ tail_digest: str
232
+ core_versions: tuple[tuple[str, str], ...]
233
+
234
+
203
235
  def _format_download_size(size: int) -> str:
204
236
  if size >= 1024 * 1024:
205
237
  return f"{size / (1024 * 1024):.1f} MiB"
@@ -1638,9 +1670,11 @@ def _run_checked(
1638
1670
  def _explain_windows_status(returncode: int, reporter: InstallReporter) -> None:
1639
1671
  if _NATIVE_WINDOWS and returncode & 0xFFFFFFFF == 0xC0000135:
1640
1672
  reporter.note(
1641
- "Windows reported STATUS_DLL_NOT_FOUND outside a confirmed MSYS2 "
1642
- "core-update restart. Check whether security software quarantined "
1643
- "a file in the private MSYS2 staging tree.",
1673
+ "Windows reported STATUS_DLL_NOT_FOUND, but this pass had neither "
1674
+ "pacman's restart announcement nor a newly completed core "
1675
+ "transaction with a matching package-database version change. "
1676
+ "The temporary base will not be published; a missing or "
1677
+ "quarantined staged DLL remains possible.",
1644
1678
  level="error",
1645
1679
  )
1646
1680
 
@@ -1676,6 +1710,215 @@ def _report_mirror_decision(
1676
1710
  )
1677
1711
 
1678
1712
 
1713
+ def _pacman_desc_value(text: str, field: str) -> str | None:
1714
+ lines = text.splitlines()
1715
+ marker = f"%{field}%"
1716
+ for index, line in enumerate(lines[:-1]):
1717
+ if line == marker and lines[index + 1]:
1718
+ return lines[index + 1]
1719
+ return None
1720
+
1721
+
1722
+ def _pacman_core_versions(root: Path) -> dict[str, str]:
1723
+ local_db = root / "var" / "lib" / "pacman" / "local"
1724
+ try:
1725
+ if _path_is_link_or_reparse(local_db) or not local_db.is_dir():
1726
+ raise RuntimeInstallError("the private pacman database is unsafe")
1727
+ entries = list(local_db.iterdir())
1728
+ except FileNotFoundError:
1729
+ return {}
1730
+ except OSError as exc:
1731
+ raise RuntimeInstallError("could not inspect the private pacman database") from exc
1732
+ if len(entries) > _PACMAN_LOCAL_DB_ENTRY_LIMIT:
1733
+ raise RuntimeInstallError("the private pacman database is unexpectedly large")
1734
+ versions: dict[str, str] = {}
1735
+ for entry in entries:
1736
+ if not any(
1737
+ entry.name.startswith(prefix)
1738
+ for prefix in (
1739
+ "bash-",
1740
+ "filesystem-",
1741
+ "mintty-",
1742
+ "msys2-runtime-",
1743
+ "pacman-",
1744
+ )
1745
+ ):
1746
+ continue
1747
+ desc = entry / "desc"
1748
+ try:
1749
+ if (
1750
+ _path_is_link_or_reparse(entry)
1751
+ or not entry.is_dir()
1752
+ or _path_is_link_or_reparse(desc)
1753
+ or not desc.is_file()
1754
+ or desc.stat().st_size > _PACMAN_LOCAL_DESC_LIMIT
1755
+ ):
1756
+ raise RuntimeInstallError("the private pacman database is unsafe")
1757
+ text = desc.read_text(encoding="utf-8")
1758
+ except RuntimeInstallError:
1759
+ raise
1760
+ except (OSError, UnicodeError) as exc:
1761
+ raise RuntimeInstallError(
1762
+ "could not read the private pacman database"
1763
+ ) from exc
1764
+ name = _pacman_desc_value(text, "NAME")
1765
+ version = _pacman_desc_value(text, "VERSION")
1766
+ if name is None or version is None or not _is_pacman_core_package(name):
1767
+ continue
1768
+ if name in versions:
1769
+ raise RuntimeInstallError(
1770
+ "the private pacman database contains duplicate core packages"
1771
+ )
1772
+ versions[name] = version
1773
+ return versions
1774
+
1775
+
1776
+ def _pacman_log_checkpoint(root: Path) -> _PacmanLogCheckpoint:
1777
+ """Pin the append-only pacman journal before a staged transaction."""
1778
+ path = root / _PACMAN_LOG_RELATIVE
1779
+ try:
1780
+ info = path.lstat()
1781
+ except FileNotFoundError:
1782
+ return _PacmanLogCheckpoint(
1783
+ False,
1784
+ 0,
1785
+ None,
1786
+ 0,
1787
+ hashlib.sha256().hexdigest(),
1788
+ tuple(sorted(_pacman_core_versions(root).items())),
1789
+ )
1790
+ except OSError as exc:
1791
+ raise RuntimeInstallError("could not inspect the private pacman log") from exc
1792
+ try:
1793
+ unsafe = _path_is_link_or_reparse(path)
1794
+ except OSError as exc:
1795
+ raise RuntimeInstallError("could not inspect the private pacman log") from exc
1796
+ if (
1797
+ unsafe
1798
+ or not stat.S_ISREG(info.st_mode)
1799
+ or info.st_size < 0
1800
+ or info.st_size > _PACMAN_LOG_MAX_SIZE
1801
+ ):
1802
+ raise RuntimeInstallError("the private pacman log is unsafe")
1803
+ tail_offset = max(0, info.st_size - 4096)
1804
+ try:
1805
+ with path.open("rb") as stream:
1806
+ stream.seek(tail_offset)
1807
+ tail = stream.read(info.st_size - tail_offset)
1808
+ except OSError as exc:
1809
+ raise RuntimeInstallError("could not read the private pacman log") from exc
1810
+ return _PacmanLogCheckpoint(
1811
+ True,
1812
+ info.st_size,
1813
+ (info.st_dev, info.st_ino),
1814
+ tail_offset,
1815
+ hashlib.sha256(tail).hexdigest(),
1816
+ tuple(sorted(_pacman_core_versions(root).items())),
1817
+ )
1818
+
1819
+
1820
+ def _is_pacman_core_package(name: str) -> bool:
1821
+ return name in _PACMAN_CORE_PACKAGES or name.startswith("msys2-runtime-")
1822
+
1823
+
1824
+ def _pacman_log_core_transaction(
1825
+ root: Path, checkpoint: _PacmanLogCheckpoint
1826
+ ) -> tuple[str, ...]:
1827
+ """Return core packages from one newly completed journal transaction."""
1828
+ path = root / _PACMAN_LOG_RELATIVE
1829
+ try:
1830
+ info = path.lstat()
1831
+ unsafe = _path_is_link_or_reparse(path)
1832
+ except OSError:
1833
+ return ()
1834
+ if (
1835
+ unsafe
1836
+ or not stat.S_ISREG(info.st_mode)
1837
+ or info.st_size < checkpoint.size
1838
+ or info.st_size > _PACMAN_LOG_MAX_SIZE
1839
+ or info.st_size - checkpoint.size > _PACMAN_LOG_APPEND_LIMIT
1840
+ ):
1841
+ return ()
1842
+ if checkpoint.existed and (info.st_dev, info.st_ino) != checkpoint.identity:
1843
+ return ()
1844
+ try:
1845
+ with path.open("rb") as stream:
1846
+ if checkpoint.existed:
1847
+ stream.seek(checkpoint.tail_offset)
1848
+ previous_tail = stream.read(checkpoint.size - checkpoint.tail_offset)
1849
+ if hashlib.sha256(previous_tail).hexdigest() != checkpoint.tail_digest:
1850
+ return ()
1851
+ stream.seek(checkpoint.size)
1852
+ appended = stream.read(_PACMAN_LOG_APPEND_LIMIT + 1)
1853
+ except OSError:
1854
+ return ()
1855
+ if not appended or len(appended) > _PACMAN_LOG_APPEND_LIMIT:
1856
+ return ()
1857
+
1858
+ core_started = False
1859
+ transaction_started = False
1860
+ changed: dict[str, tuple[str, str]] = {}
1861
+ for line in appended.decode("utf-8", errors="replace").splitlines():
1862
+ lowered = line.lower()
1863
+ if "[pacman] starting core system upgrade" in lowered:
1864
+ core_started = True
1865
+ transaction_started = False
1866
+ changed.clear()
1867
+ continue
1868
+ if not core_started:
1869
+ continue
1870
+ if "[alpm] transaction started" in lowered:
1871
+ transaction_started = True
1872
+ changed.clear()
1873
+ continue
1874
+ if not transaction_started:
1875
+ continue
1876
+ match = _PACMAN_CORE_CHANGE_RE.search(line)
1877
+ if match is not None and _is_pacman_core_package(match.group(1)):
1878
+ changed[match.group(1)] = (match.group(2), match.group(3))
1879
+ if "[alpm] transaction completed" in lowered:
1880
+ if changed:
1881
+ try:
1882
+ after = _pacman_core_versions(root)
1883
+ except RuntimeInstallError:
1884
+ return ()
1885
+ before = dict(checkpoint.core_versions)
1886
+ proven = tuple(
1887
+ sorted(
1888
+ name
1889
+ for name, (old_version, new_version) in changed.items()
1890
+ if before.get(name) == old_version
1891
+ and after.get(name) == new_version
1892
+ and old_version != new_version
1893
+ )
1894
+ )
1895
+ if proven:
1896
+ return proven
1897
+ transaction_started = False
1898
+ return ()
1899
+
1900
+
1901
+ def _restart_evidence(
1902
+ root: Path,
1903
+ checkpoint: _PacmanLogCheckpoint,
1904
+ reporter: InstallReporter,
1905
+ returncode: int,
1906
+ ) -> bool:
1907
+ if reporter.command_requested_msys2_restart:
1908
+ return True
1909
+ if returncode & 0xFFFFFFFF != 0xC0000135:
1910
+ return False
1911
+ packages = _pacman_log_core_transaction(root, checkpoint)
1912
+ if not packages:
1913
+ return False
1914
+ reporter.note(
1915
+ "The private pacman journal confirms a completed core update "
1916
+ f"({', '.join(packages)}); its final console prompt was not captured.",
1917
+ level="warning",
1918
+ )
1919
+ return True
1920
+
1921
+
1679
1922
  def _refresh_pacman_mirrors(
1680
1923
  root: Path,
1681
1924
  *,
@@ -1705,6 +1948,7 @@ def _run_pacman_with_recovery(
1705
1948
  mirror_optimizer: MirrorOptimizer,
1706
1949
  allow_core_restart: bool = False,
1707
1950
  ) -> bool:
1951
+ checkpoint = _pacman_log_checkpoint(root) if not packages else None
1708
1952
  argv = _bash_command(root, _pacman_command(packages=packages, relaxed=False), str(cache))
1709
1953
  returncode = _run_checked(
1710
1954
  argv,
@@ -1715,9 +1959,23 @@ def _run_pacman_with_recovery(
1715
1959
  progress="pacman",
1716
1960
  allow_failure=True,
1717
1961
  )
1718
- restart_requested = reporter.command_requested_msys2_restart
1962
+ restart_requested = (
1963
+ _restart_evidence(root, checkpoint, reporter, returncode)
1964
+ if checkpoint is not None
1965
+ else False
1966
+ )
1719
1967
  if returncode == 0:
1720
1968
  return restart_requested
1969
+ if not packages and restart_requested and not allow_core_restart:
1970
+ reporter.note(
1971
+ "MSYS2 requested another core restart on the final permitted "
1972
+ "update pass; the temporary base will not be published.",
1973
+ level="error",
1974
+ )
1975
+ reporter.command_failed(label, returncode)
1976
+ raise RuntimeInstallError(
1977
+ f"{label} exhausted the permitted core-update restarts"
1978
+ )
1721
1979
  if allow_core_restart and not packages and restart_requested:
1722
1980
  reporter.note(
1723
1981
  "MSYS2 replaced core runtime files and closed its own process as "
@@ -1759,6 +2017,7 @@ def _run_pacman_with_recovery(
1759
2017
  _pacman_command(packages=packages, relaxed=True),
1760
2018
  str(cache),
1761
2019
  )
2020
+ retry_checkpoint = _pacman_log_checkpoint(root) if not packages else None
1762
2021
  retry_returncode = _run_checked(
1763
2022
  retry_argv,
1764
2023
  env=env,
@@ -1768,9 +2027,24 @@ def _run_pacman_with_recovery(
1768
2027
  progress="pacman",
1769
2028
  allow_failure=True,
1770
2029
  )
1771
- restart_requested = reporter.command_requested_msys2_restart
2030
+ restart_requested = (
2031
+ _restart_evidence(root, retry_checkpoint, reporter, retry_returncode)
2032
+ if retry_checkpoint is not None
2033
+ else False
2034
+ )
1772
2035
  if retry_returncode == 0:
1773
2036
  return restart_requested
2037
+ if not packages and restart_requested and not allow_core_restart:
2038
+ reporter.note(
2039
+ "MSYS2 requested another core restart on the final permitted "
2040
+ "update pass; the temporary base will not be published.",
2041
+ level="error",
2042
+ )
2043
+ reporter.command_failed(f"{label} resilient retry", retry_returncode)
2044
+ raise RuntimeInstallError(
2045
+ f"{label} resilient retry exhausted the permitted "
2046
+ "core-update restarts"
2047
+ )
1774
2048
  if allow_core_restart and not packages and restart_requested:
1775
2049
  reporter.note(
1776
2050
  "MSYS2 replaced core runtime files and closed its own process as "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.4.0.dev28
3
+ Version: 0.4.0.dev29
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
@@ -7,7 +7,7 @@ import sys
7
7
 
8
8
  import pytest
9
9
 
10
- from railmux import windows_msys2
10
+ from railmux import windows_install_log, windows_msys2
11
11
  from railmux.windows_install_log import InstallReporter, install_log_path
12
12
  from railmux.windows_msys2 import _run_checked
13
13
 
@@ -80,6 +80,19 @@ def test_reporter_recognizes_only_the_explicit_msys2_core_restart(tmp_path):
80
80
  assert not reporter.command_requested_msys2_restart
81
81
 
82
82
 
83
+ def test_reporter_recognizes_a_wrapped_msys2_core_restart(tmp_path):
84
+ path = tmp_path / "install.log"
85
+ with InstallReporter(path, verbose=False, stream=io.StringIO()) as reporter:
86
+ reporter.command_started("update")
87
+ reporter.command_output(
88
+ ":: To complete this update all MSYS2 processes including\n"
89
+ "\n"
90
+ "this terminal will be closed. Confirm to proceed [Y/n]\n"
91
+ )
92
+
93
+ assert reporter.command_requested_msys2_restart
94
+
95
+
83
96
  def test_verbose_reporter_never_fails_on_a_legacy_windows_console_codec(tmp_path):
84
97
  stream = Cp1252Stream()
85
98
  path = tmp_path / "install-0.4.0.dev7-20260803T000000Z-5.log"
@@ -174,6 +187,25 @@ def test_reporter_retains_only_five_owned_logs(tmp_path):
174
187
  assert unrelated.read_text(encoding="utf-8") == "keep"
175
188
 
176
189
 
190
+ def test_log_retention_never_removes_the_active_oldest_timestamp(tmp_path):
191
+ logs = tmp_path / "logs"
192
+ logs.mkdir()
193
+ current = logs / "install-0.4.0.dev29-20260806T000000Z-99.log"
194
+ current.write_text("active", encoding="utf-8")
195
+ os.utime(current, ns=(1, 1))
196
+ for number in range(6):
197
+ path = logs / f"install-0.4.0.dev28-20260805T00000{number}Z-{number}.log"
198
+ path.write_text(str(number), encoding="utf-8")
199
+ os.utime(path, ns=(number + 10, number + 10))
200
+
201
+ windows_install_log._prune_install_logs(logs, preserve=current)
202
+
203
+ owned = sorted(logs.glob("install-*.log"))
204
+ assert len(owned) == 5
205
+ assert current in owned
206
+ assert current.read_text(encoding="utf-8") == "active"
207
+
208
+
177
209
  def test_log_path_is_private_versioned_and_utc(tmp_path, monkeypatch):
178
210
  monkeypatch.setattr(os, "getpid", lambda: 42)
179
211
 
@@ -232,7 +264,9 @@ def test_windows_missing_dll_status_is_explained(tmp_path, monkeypatch):
232
264
  )
233
265
 
234
266
  assert "STATUS_DLL_NOT_FOUND" in stream.getvalue()
235
- assert "security software" in path.read_text(encoding="utf-8")
267
+ assert "matching package-database version change" in path.read_text(
268
+ encoding="utf-8"
269
+ )
236
270
 
237
271
 
238
272
  def test_compact_reporter_surfaces_extraction_and_package_progress(tmp_path):