railmux 0.4.0.dev14__tar.gz → 0.4.0.dev15__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 (176) hide show
  1. {railmux-0.4.0.dev14/src/railmux.egg-info → railmux-0.4.0.dev15}/PKG-INFO +1 -1
  2. railmux-0.4.0.dev15/src/railmux/__init__.py +1 -0
  3. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/cli.py +177 -25
  4. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/diagnostics.py +2 -0
  5. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/tmux_health.py +2 -0
  6. railmux-0.4.0.dev15/src/railmux/windows_attach_relay.py +789 -0
  7. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15/src/railmux.egg-info}/PKG-INFO +1 -1
  8. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux.egg-info/SOURCES.txt +2 -0
  9. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_cli.py +113 -0
  10. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tmux_integration.py +75 -0
  11. railmux-0.4.0.dev15/tests/test_windows_attach_relay.py +271 -0
  12. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_windows_msys2.py +1 -1
  13. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_windows_wrapper_contract.py +1 -0
  14. railmux-0.4.0.dev14/src/railmux/__init__.py +0 -1
  15. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/LICENSE +0 -0
  16. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/MANIFEST.in +0 -0
  17. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/README.md +0 -0
  18. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/pyproject.toml +0 -0
  19. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/setup.cfg +0 -0
  20. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/__main__.py +0 -0
  21. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/atomic_file.py +0 -0
  22. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/background_index.py +0 -0
  23. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/codex_index.py +0 -0
  24. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/config.py +0 -0
  25. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/config_cli.py +0 -0
  26. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/discovery.py +0 -0
  27. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/display_transport.py +0 -0
  28. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/entrypoint.py +0 -0
  29. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/fast_display_client.py +0 -0
  30. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/fast_display_history.py +0 -0
  31. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/fast_display_input.py +0 -0
  32. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/fast_display_protocol.py +0 -0
  33. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/fast_display_server.py +0 -0
  34. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/favorites.py +0 -0
  35. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/fuzzy.py +0 -0
  36. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/help_workspace.py +0 -0
  37. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/launcher.py +0 -0
  38. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/legacy_sessions.py +0 -0
  39. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/local_clipboard.py +0 -0
  40. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/local_open.py +0 -0
  41. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/models.py +0 -0
  42. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/modes.py +0 -0
  43. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/mouse_manager.py +0 -0
  44. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/orphan_marker.py +0 -0
  45. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/pane_surface.py +0 -0
  46. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/path_codec.py +0 -0
  47. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/provider_paths.py +0 -0
  48. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/remote_config.py +0 -0
  49. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/renames.py +0 -0
  50. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/restart_state.py +0 -0
  51. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/runtime_config.py +0 -0
  52. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/scroll_agent.py +0 -0
  53. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/scroll_manager.py +0 -0
  54. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/selection_isolation.py +0 -0
  55. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/self_update.py +0 -0
  56. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/session_cache.py +0 -0
  57. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/session_index.py +0 -0
  58. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/setting_contracts.py +0 -0
  59. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/settings.py +0 -0
  60. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ssh_args.py +0 -0
  61. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ssh_compat.py +0 -0
  62. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ssh_display_diagnostics.py +0 -0
  63. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ssh_doctor.py +0 -0
  64. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/system_deps.py +0 -0
  65. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/terminal_status.py +0 -0
  66. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/tmux_binding_manager.py +0 -0
  67. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/tmux_ctl.py +0 -0
  68. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/tmux_server.py +0 -0
  69. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/tool_panes.py +0 -0
  70. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/transcript.py +0 -0
  71. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/__init__.py +0 -0
  72. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/_widgets.py +0 -0
  73. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/app.py +0 -0
  74. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/keymap.py +0 -0
  75. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/modals.py +0 -0
  76. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/projects_pane.py +0 -0
  77. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/running_pane.py +0 -0
  78. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/sessions_pane.py +0 -0
  79. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/sidebar.py +0 -0
  80. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/statusbar.py +0 -0
  81. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/ui/workspace.py +0 -0
  82. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/windows_bootstrap.py +0 -0
  83. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/windows_install_log.py +0 -0
  84. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/windows_msys2.py +0 -0
  85. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/windows_pacman.py +0 -0
  86. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux/windows_tmux_lifecycle.py +0 -0
  87. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux.egg-info/dependency_links.txt +0 -0
  88. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux.egg-info/entry_points.txt +0 -0
  89. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux.egg-info/requires.txt +0 -0
  90. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/src/railmux.egg-info/top_level.txt +0 -0
  91. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_agent_workspace.py +0 -0
  92. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_atomic_file.py +0 -0
  93. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_background_index.py +0 -0
  94. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_codex_delete.py +0 -0
  95. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_codex_index.py +0 -0
  96. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_codex_lineage.py +0 -0
  97. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_codex_transcript.py +0 -0
  98. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_config.py +0 -0
  99. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_config_cli.py +0 -0
  100. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_denested_transport_benchmark.py +0 -0
  101. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_diagnostics.py +0 -0
  102. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_discovery.py +0 -0
  103. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_display_transport.py +0 -0
  104. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_fast_full_window.py +0 -0
  105. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_focus_highlight.py +0 -0
  106. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_fullscreen_binding.py +0 -0
  107. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_fuzzy.py +0 -0
  108. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_help_agent.py +0 -0
  109. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_help_workspace.py +0 -0
  110. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_history_cleanup.py +0 -0
  111. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_keymap.py +0 -0
  112. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_launcher.py +0 -0
  113. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_layout_profile.py +0 -0
  114. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_legacy_sessions.py +0 -0
  115. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_local_clipboard.py +0 -0
  116. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_local_open.py +0 -0
  117. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_modals.py +0 -0
  118. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_mode_selection.py +0 -0
  119. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_modes.py +0 -0
  120. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_mouse_manager.py +0 -0
  121. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_new_project.py +0 -0
  122. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_options.py +0 -0
  123. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_orphan_recovery.py +0 -0
  124. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_pane_surface.py +0 -0
  125. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_path_codec.py +0 -0
  126. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_projects_pane.py +0 -0
  127. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_provider_paths.py +0 -0
  128. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_release_notes.py +0 -0
  129. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_release_workflow.py +0 -0
  130. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_remote_config.py +0 -0
  131. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_renames.py +0 -0
  132. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_restart_state.py +0 -0
  133. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_running_pane.py +0 -0
  134. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_running_sort.py +0 -0
  135. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_runtime_config.py +0 -0
  136. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_scroll_agent.py +0 -0
  137. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_scroll_manager.py +0 -0
  138. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_selection_isolation.py +0 -0
  139. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_self_update.py +0 -0
  140. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_session_cache.py +0 -0
  141. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_session_index.py +0 -0
  142. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_sessions_pane.py +0 -0
  143. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_setting_contracts.py +0 -0
  144. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_settings.py +0 -0
  145. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_sidebar.py +0 -0
  146. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_soft_quit.py +0 -0
  147. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_ssh_args.py +0 -0
  148. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_ssh_compat.py +0 -0
  149. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_ssh_display_benchmark.py +0 -0
  150. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_ssh_display_diagnostics.py +0 -0
  151. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_ssh_doctor.py +0 -0
  152. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_status.py +0 -0
  153. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_statusbar.py +0 -0
  154. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_system_deps.py +0 -0
  155. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_terminal_size.py +0 -0
  156. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_terminal_status.py +0 -0
  157. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_termux_local_touch.py +0 -0
  158. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tmux_binding_manager.py +0 -0
  159. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tmux_ctl.py +0 -0
  160. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tmux_health.py +0 -0
  161. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tmux_server.py +0 -0
  162. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tmux_status.py +0 -0
  163. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_tool_panes.py +0 -0
  164. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_transcript.py +0 -0
  165. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_utf8_state.py +0 -0
  166. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_web_demo_recorder.py +0 -0
  167. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_wheel_smoke.py +0 -0
  168. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_widgets.py +0 -0
  169. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_windows_bootstrap.py +0 -0
  170. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_windows_install_log.py +0 -0
  171. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_windows_pacman.py +0 -0
  172. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_windows_tmux_lifecycle.py +0 -0
  173. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tests/test_yolo_prompt.py +0 -0
  174. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tools/denested_transport_benchmark.py +0 -0
  175. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/tools/release_notes.py +0 -0
  176. {railmux-0.4.0.dev14 → railmux-0.4.0.dev15}/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.dev14
3
+ Version: 0.4.0.dev15
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.dev15"
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import os
4
5
  import shutil
5
6
  import subprocess
6
7
  import sys
7
8
  import termios
9
+ import threading
8
10
  import time
11
+ import tty
9
12
  from dataclasses import replace
10
13
  from pathlib import Path
11
14
 
@@ -50,7 +53,20 @@ def _restore_terminal(attributes: list | None) -> None:
50
53
  pass
51
54
 
52
55
 
53
- def _stop_tmux_client(process: subprocess.Popen) -> None:
56
+ def _reset_terminal_modes(fd: int) -> None:
57
+ """Best-effort recovery when a relay ends before tmux's restore tail."""
58
+ try:
59
+ os.write(
60
+ fd,
61
+ b"\x1b[?1049l\x1b[?1l\x1b>\x1b[?1000l\x1b[?1002l"
62
+ b"\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l"
63
+ b"\x1b[?7h\x1b[?25h\x1b[0m\r",
64
+ )
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def _stop_tmux_client(process: object) -> None:
54
70
  if process.poll() is not None:
55
71
  return
56
72
  process.terminate()
@@ -75,20 +91,43 @@ def _run_tmux_client_with_watchdog(
75
91
  attributes = termios.tcgetattr(sys.stdin.fileno())
76
92
  except (OSError, termios.error):
77
93
  pass
94
+ started_at = time.monotonic()
78
95
  try:
79
- process = subprocess.Popen(argv, env=env)
96
+ process: object = subprocess.Popen(argv, env=env)
80
97
  except OSError as exc:
81
98
  print(f"error: could not start tmux client: {exc}", file=sys.stderr)
82
99
  return 2
83
- watchdog = tmux_health.FailureWatchdog.starting(
84
- time.monotonic(),
85
- interval=_LOCAL_WATCHDOG_INTERVAL,
86
- failure_limit=_LOCAL_WATCHDOG_FAILURES,
87
- )
88
- next_session_probe = watchdog.next_probe - watchdog.interval
89
- try:
100
+ def monitor_client(
101
+ monitor_started_at: float | None = None,
102
+ *,
103
+ asynchronous_probe: bool = False,
104
+ ) -> tuple[int, bool]:
105
+ nonlocal expected_target, expected_session_id
106
+ watchdog = tmux_health.FailureWatchdog.starting(
107
+ (
108
+ time.monotonic()
109
+ if monitor_started_at is None
110
+ else monitor_started_at
111
+ ),
112
+ interval=_LOCAL_WATCHDOG_INTERVAL,
113
+ failure_limit=_LOCAL_WATCHDOG_FAILURES,
114
+ )
115
+ next_session_probe = watchdog.next_probe - watchdog.interval
116
+ probe_thread: threading.Thread | None = None
117
+ probe_result: list[tmux_server.TmuxServerTarget | None] = []
118
+
119
+ def discover() -> tmux_server.TmuxServerTarget | None:
120
+ try:
121
+ return tmux_server.discover_target(timeout=1.0)
122
+ except tmux_server.TmuxServerError:
123
+ return None
124
+
90
125
  while process.poll() is None:
91
- time.sleep(0.25)
126
+ pump = getattr(process, "pump", None)
127
+ if pump is None:
128
+ time.sleep(0.25)
129
+ else:
130
+ pump(0.25)
92
131
  now = time.monotonic()
93
132
  if (
94
133
  expected_target is not None
@@ -100,10 +139,26 @@ def _run_tmux_client_with_watchdog(
100
139
  next_session_probe = now + 1.0
101
140
  if not watchdog.due(now):
102
141
  continue
103
- try:
104
- current_target = tmux_server.discover_target(timeout=1.0)
105
- except tmux_server.TmuxServerError:
106
- current_target = None
142
+ if asynchronous_probe:
143
+ if probe_thread is None:
144
+ probe_result.clear()
145
+
146
+ def run_probe() -> None:
147
+ probe_result.append(discover())
148
+
149
+ probe_thread = threading.Thread(
150
+ target=run_probe,
151
+ name="railmux-tmux-health",
152
+ daemon=True,
153
+ )
154
+ probe_thread.start()
155
+ continue
156
+ if probe_thread.is_alive():
157
+ continue
158
+ current_target = probe_result[0] if probe_result else None
159
+ probe_thread = None
160
+ else:
161
+ current_target = discover()
107
162
  if expected_target is None and current_target is not None:
108
163
  expected_target = current_target
109
164
  expected_session_id = tmux_server.target_session_id(
@@ -126,23 +181,94 @@ def _run_tmux_client_with_watchdog(
126
181
  "responding; run 'railmux doctor' for diagnostics",
127
182
  file=sys.stderr,
128
183
  )
129
- return 2
184
+ return 2, True
185
+ return process.returncode or 0, False
186
+
187
+ relay = None
188
+ relay_attempted = False
189
+ try:
190
+ returncode, watchdog_failed = monitor_client(started_at)
191
+ if watchdog_failed:
192
+ return returncode
193
+ failed_at = time.monotonic()
130
194
  # tmux may leave the client TTY in raw mode even after its process
131
195
  # exits. Restore it before a bounded recovery probe or user message.
132
196
  _restore_terminal(attributes)
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
- )
197
+ current_target = None
140
198
  if returncode and expected_target is not None:
141
199
  try:
142
200
  current_target = tmux_server.discover_target(timeout=1.0)
143
201
  except tmux_server.TmuxServerError:
144
202
  current_target = None
145
- if current_target != expected_target:
203
+ if current_target == expected_target and expected_session_id is None:
204
+ expected_session_id = tmux_server.target_session_id(
205
+ expected_target, "railmux", timeout=0.5)
206
+
207
+ from railmux.provider_paths import running_in_managed_windows_wrapper
208
+
209
+ managed_windows = running_in_managed_windows_wrapper()
210
+
211
+ can_relay = bool(
212
+ current_target == expected_target
213
+ and expected_session_id is not None
214
+ and failed_at - started_at < 5.0
215
+ and attributes is not None
216
+ and sys.stdout.isatty()
217
+ and managed_windows
218
+ )
219
+ if can_relay:
220
+ relay_attempted = True
221
+ from railmux.windows_attach_relay import (
222
+ WindowsAttachRelayError,
223
+ start_relay_client,
224
+ )
225
+
226
+ print(
227
+ "info: direct tmux attach was unavailable; reconnecting "
228
+ "through the Windows terminal bridge",
229
+ file=sys.stderr,
230
+ )
231
+ try:
232
+ tty.setraw(sys.stdin.fileno())
233
+ relay = start_relay_client(
234
+ target=expected_target,
235
+ session_id=expected_session_id,
236
+ environ=env,
237
+ stdin_fd=sys.stdin.fileno(),
238
+ stdout_fd=sys.stdout.fileno(),
239
+ )
240
+ process = relay
241
+ returncode, watchdog_failed = monitor_client(
242
+ asynchronous_probe=True)
243
+ if returncode:
244
+ _reset_terminal_modes(sys.stdout.fileno())
245
+ if watchdog_failed:
246
+ return returncode
247
+ except (OSError, termios.error, WindowsAttachRelayError):
248
+ _reset_terminal_modes(sys.stdout.fileno())
249
+ _restore_terminal(attributes)
250
+ outcome = (
251
+ "could not attach"
252
+ if relay is None
253
+ else "connection ended unexpectedly"
254
+ )
255
+ print(
256
+ f"error: the Windows terminal bridge {outcome}; the "
257
+ "existing workspace was left running; run "
258
+ "'railmux doctor' for diagnostics",
259
+ file=sys.stderr,
260
+ )
261
+ returncode = 2
262
+ finally:
263
+ _restore_terminal(attributes)
264
+
265
+ if returncode:
266
+ try:
267
+ current_target = tmux_server.discover_target(timeout=1.0)
268
+ except tmux_server.TmuxServerError:
269
+ current_target = None
270
+
271
+ if returncode and current_target != expected_target:
146
272
  clean_exit = bool(
147
273
  expected_session_id is not None
148
274
  and tmux_health.consume_clean_exit(
@@ -156,16 +282,41 @@ def _run_tmux_client_with_watchdog(
156
282
  reason="launcher-server-exit",
157
283
  consecutive_failures=1,
158
284
  )
285
+ elif (
286
+ returncode
287
+ and current_target == expected_target
288
+ and managed_windows
289
+ ):
290
+ tmux_health.record_incident(
291
+ component="launcher",
292
+ reason=(
293
+ "launcher-relay-failed"
294
+ if relay_attempted
295
+ else "launcher-attach-rejected"
296
+ ),
297
+ consecutive_failures=1,
298
+ )
299
+ # Routine post-exit removal of a proof-authorized last-session socket
300
+ # is housekeeping. Only pre-launch recovery is user-visible.
301
+ windows_tmux_lifecycle.recover_abandoned_socket()
159
302
  return returncode
160
303
  except KeyboardInterrupt:
161
304
  _stop_tmux_client(process)
305
+ if relay is not None:
306
+ _reset_terminal_modes(sys.stdout.fileno())
162
307
  return 130
163
308
  finally:
309
+ if relay is not None:
310
+ relay.close()
164
311
  _restore_terminal(attributes)
165
312
 
166
313
 
167
314
  def main(argv: list[str] | None = None) -> int:
168
315
  raw_args = list(sys.argv[1:] if argv is None else argv)
316
+ if raw_args and raw_args[0] == "_windows-attach-relay":
317
+ from railmux.windows_attach_relay import relay_server_main
318
+
319
+ return relay_server_main(raw_args[1:])
169
320
  if raw_args and raw_args[0] == "config":
170
321
  from railmux.config_cli import main as config_main
171
322
 
@@ -379,8 +530,9 @@ def main(argv: list[str] | None = None) -> int:
379
530
  tmux_server.socket_label()
380
531
  if windows_tmux_lifecycle.recover_abandoned_socket():
381
532
  print(
382
- "info: cleaned an abandoned Windows tmux socket; Codex and "
383
- "Claude session files were not changed",
533
+ "info: recovered an unresponsive Windows tmux socket left by "
534
+ "a previous session; Codex and Claude session files were not "
535
+ "changed",
384
536
  file=sys.stderr,
385
537
  )
386
538
  dedicated_target = tmux_server.discover_target()
@@ -426,6 +426,8 @@ def _incident_text(diagnostic: IncidentDiagnostic) -> str:
426
426
  if diagnostic.status == "none":
427
427
  return "none recorded"
428
428
  descriptions = {
429
+ "launcher-attach-rejected": "Windows tmux client attach rejected",
430
+ "launcher-relay-failed": "Windows terminal bridge failed",
429
431
  "launcher-watchdog-timeout": "local client watchdog timeout",
430
432
  "launcher-server-exit": "dedicated tmux server exited",
431
433
  "remote-display-watchdog-timeout": "SSH display watchdog timeout",
@@ -17,6 +17,8 @@ _CLEAN_EXIT_MAX_AGE = 30
17
17
  _SOFT_EXIT_SCHEMA_VERSION = 1
18
18
  _SOFT_EXIT_MAX_AGE = 30
19
19
  _INCIDENT_REASONS = {
20
+ "launcher-attach-rejected",
21
+ "launcher-relay-failed",
20
22
  "launcher-watchdog-timeout",
21
23
  "launcher-server-exit",
22
24
  "remote-display-watchdog-timeout",