railmux 0.3.5.dev1__tar.gz → 0.3.6.dev1__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 (162) hide show
  1. {railmux-0.3.5.dev1/src/railmux.egg-info → railmux-0.3.6.dev1}/PKG-INFO +13 -1
  2. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/README.md +12 -0
  3. railmux-0.3.6.dev1/src/railmux/__init__.py +1 -0
  4. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_history.py +11 -9
  5. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/models.py +3 -0
  6. railmux-0.3.6.dev1/src/railmux/session_lease.py +554 -0
  7. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/app.py +458 -45
  8. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/running_pane.py +4 -0
  9. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/sessions_pane.py +10 -3
  10. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1/src/railmux.egg-info}/PKG-INFO +13 -1
  11. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/SOURCES.txt +2 -0
  12. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_delete.py +4 -0
  13. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_fast_full_window.py +43 -11
  14. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_focus_highlight.py +4 -0
  15. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_orphan_recovery.py +40 -1
  16. railmux-0.3.6.dev1/tests/test_session_lease.py +491 -0
  17. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_sessions_pane.py +15 -0
  18. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_terminal_size.py +12 -0
  19. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_status.py +57 -8
  20. railmux-0.3.5.dev1/src/railmux/__init__.py +0 -1
  21. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/LICENSE +0 -0
  22. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/MANIFEST.in +0 -0
  23. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/pyproject.toml +0 -0
  24. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/setup.cfg +0 -0
  25. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/__main__.py +0 -0
  26. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/atomic_file.py +0 -0
  27. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/background_index.py +0 -0
  28. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/cli.py +0 -0
  29. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/codex_index.py +0 -0
  30. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/config.py +0 -0
  31. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/config_cli.py +0 -0
  32. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/diagnostics.py +0 -0
  33. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/discovery.py +0 -0
  34. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/display_transport.py +0 -0
  35. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_client.py +0 -0
  36. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_input.py +0 -0
  37. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_protocol.py +0 -0
  38. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fast_display_server.py +0 -0
  39. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/favorites.py +0 -0
  40. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/fuzzy.py +0 -0
  41. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/help_workspace.py +0 -0
  42. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/launcher.py +0 -0
  43. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/legacy_sessions.py +0 -0
  44. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/local_clipboard.py +0 -0
  45. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/local_open.py +0 -0
  46. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/modes.py +0 -0
  47. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/mouse_manager.py +0 -0
  48. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/orphan_marker.py +0 -0
  49. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/pane_surface.py +0 -0
  50. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/path_codec.py +0 -0
  51. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/remote_config.py +0 -0
  52. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/renames.py +0 -0
  53. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/restart_state.py +0 -0
  54. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/runtime_config.py +0 -0
  55. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/scroll_agent.py +0 -0
  56. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/scroll_manager.py +0 -0
  57. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/selection_isolation.py +0 -0
  58. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/self_update.py +0 -0
  59. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/session_cache.py +0 -0
  60. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/session_index.py +0 -0
  61. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/setting_contracts.py +0 -0
  62. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/settings.py +0 -0
  63. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_args.py +0 -0
  64. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_compat.py +0 -0
  65. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_display_diagnostics.py +0 -0
  66. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ssh_doctor.py +0 -0
  67. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/system_deps.py +0 -0
  68. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/terminal_status.py +0 -0
  69. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_binding_manager.py +0 -0
  70. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_ctl.py +0 -0
  71. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_health.py +0 -0
  72. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tmux_server.py +0 -0
  73. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/tool_panes.py +0 -0
  74. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/transcript.py +0 -0
  75. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/__init__.py +0 -0
  76. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/_widgets.py +0 -0
  77. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/keymap.py +0 -0
  78. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/modals.py +0 -0
  79. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/projects_pane.py +0 -0
  80. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/sidebar.py +0 -0
  81. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/statusbar.py +0 -0
  82. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux/ui/workspace.py +0 -0
  83. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/dependency_links.txt +0 -0
  84. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/entry_points.txt +0 -0
  85. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/requires.txt +0 -0
  86. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/src/railmux.egg-info/top_level.txt +0 -0
  87. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_agent_workspace.py +0 -0
  88. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_atomic_file.py +0 -0
  89. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_background_index.py +0 -0
  90. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_cli.py +0 -0
  91. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_index.py +0 -0
  92. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_lineage.py +0 -0
  93. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_codex_transcript.py +0 -0
  94. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_config.py +0 -0
  95. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_config_cli.py +0 -0
  96. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_denested_transport_benchmark.py +0 -0
  97. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_diagnostics.py +0 -0
  98. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_discovery.py +0 -0
  99. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_display_transport.py +0 -0
  100. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_fullscreen_binding.py +0 -0
  101. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_fuzzy.py +0 -0
  102. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_help_agent.py +0 -0
  103. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_help_workspace.py +0 -0
  104. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_history_cleanup.py +0 -0
  105. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_keymap.py +0 -0
  106. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_launcher.py +0 -0
  107. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_layout_profile.py +0 -0
  108. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_legacy_sessions.py +0 -0
  109. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_local_clipboard.py +0 -0
  110. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_local_open.py +0 -0
  111. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_modals.py +0 -0
  112. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_mode_selection.py +0 -0
  113. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_modes.py +0 -0
  114. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_mouse_manager.py +0 -0
  115. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_new_project.py +0 -0
  116. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_options.py +0 -0
  117. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_pane_surface.py +0 -0
  118. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_path_codec.py +0 -0
  119. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_projects_pane.py +0 -0
  120. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_release_notes.py +0 -0
  121. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_release_workflow.py +0 -0
  122. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_remote_config.py +0 -0
  123. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_renames.py +0 -0
  124. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_restart_state.py +0 -0
  125. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_running_pane.py +0 -0
  126. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_running_sort.py +0 -0
  127. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_runtime_config.py +0 -0
  128. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_scroll_agent.py +0 -0
  129. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_scroll_manager.py +0 -0
  130. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_selection_isolation.py +0 -0
  131. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_self_update.py +0 -0
  132. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_session_cache.py +0 -0
  133. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_session_index.py +0 -0
  134. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_setting_contracts.py +0 -0
  135. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_settings.py +0 -0
  136. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_sidebar.py +0 -0
  137. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_soft_quit.py +0 -0
  138. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_args.py +0 -0
  139. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_compat.py +0 -0
  140. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_display_benchmark.py +0 -0
  141. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_display_diagnostics.py +0 -0
  142. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_ssh_doctor.py +0 -0
  143. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_status.py +0 -0
  144. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_statusbar.py +0 -0
  145. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_system_deps.py +0 -0
  146. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_terminal_status.py +0 -0
  147. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_termux_local_touch.py +0 -0
  148. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_binding_manager.py +0 -0
  149. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_ctl.py +0 -0
  150. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_health.py +0 -0
  151. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_integration.py +0 -0
  152. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tmux_server.py +0 -0
  153. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_tool_panes.py +0 -0
  154. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_transcript.py +0 -0
  155. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_utf8_state.py +0 -0
  156. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_web_demo_recorder.py +0 -0
  157. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_wheel_smoke.py +0 -0
  158. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_widgets.py +0 -0
  159. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tests/test_yolo_prompt.py +0 -0
  160. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tools/denested_transport_benchmark.py +0 -0
  161. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tools/release_notes.py +0 -0
  162. {railmux-0.3.5.dev1 → railmux-0.3.6.dev1}/tools/wheel_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: railmux
3
- Version: 0.3.5.dev1
3
+ Version: 0.3.6.dev1
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
@@ -427,6 +427,18 @@ Railmux also indexes resumable Claude Code and Codex sessions started outside
427
427
  Railmux, so existing conversations appear in the sidebar and resume the same
428
428
  way. Non-resumable one-off runs such as `codex exec` are filtered out.
429
429
 
430
+ If the same Claude Code or Codex history root is shared across hosts, Railmux
431
+ uses a provider-session lease to prevent two machines from resuming the same
432
+ conversation concurrently. A remotely owned conversation remains in Sessions
433
+ as **running on HOST**, but is not presented as a locally attachable Running
434
+ entry; resume fails safely until that host's exact provider pane exits. The
435
+ lease follows detached agents across Soft Quit and covers every known Codex
436
+ rewind-lineage ID at resume; later rewinds retain a stable locked lineage
437
+ anchor. Delete also checks the shared lease immediately before
438
+ starting and refuses remotely owned history. The shared filesystem must provide
439
+ cross-host POSIX advisory-lock semantics; mounts configured with local-only or
440
+ disabled locking cannot provide this guarantee.
441
+
430
442
  ### Restarts and quitting
431
443
 
432
444
  Each opened agent runs in a detached tmux session, so switching sessions does
@@ -385,6 +385,18 @@ Railmux also indexes resumable Claude Code and Codex sessions started outside
385
385
  Railmux, so existing conversations appear in the sidebar and resume the same
386
386
  way. Non-resumable one-off runs such as `codex exec` are filtered out.
387
387
 
388
+ If the same Claude Code or Codex history root is shared across hosts, Railmux
389
+ uses a provider-session lease to prevent two machines from resuming the same
390
+ conversation concurrently. A remotely owned conversation remains in Sessions
391
+ as **running on HOST**, but is not presented as a locally attachable Running
392
+ entry; resume fails safely until that host's exact provider pane exits. The
393
+ lease follows detached agents across Soft Quit and covers every known Codex
394
+ rewind-lineage ID at resume; later rewinds retain a stable locked lineage
395
+ anchor. Delete also checks the shared lease immediately before
396
+ starting and refuses remotely owned history. The shared filesystem must provide
397
+ cross-host POSIX advisory-lock semantics; mounts configured with local-only or
398
+ disabled locking cannot provide this guarantee.
399
+
388
400
  ### Restarts and quitting
389
401
 
390
402
  Each opened agent runs in a detached tmux session, so switching sessions does
@@ -0,0 +1 @@
1
+ __version__ = "0.3.6.dev1"
@@ -287,15 +287,17 @@ class LocalHistoryView:
287
287
  # intermediate output above the current screen.
288
288
  lines = incoming.lines
289
289
  else:
290
- # A full-screen TUI redraw can leave no trustworthy text
291
- # anchor. Preserve the known history and replace only the
292
- # mutable live viewport. Appending every unaligned 300-line
293
- # prefetch would duplicate a full screen every three seconds.
294
- live_count = min(incoming.height, len(incoming.lines))
295
- retained = previous.lines[
296
- : -min(previous.height, len(previous.lines))
297
- ]
298
- lines = retained + incoming.lines[-live_count:]
290
+ # Never splice two captures whose timelines cannot be aligned.
291
+ # Retaining old history and replacing only the live viewport
292
+ # looked conservative, but it silently omitted every row
293
+ # produced between those two pieces. That false seam appeared
294
+ # as discontinuous SSH history until returning to the bottom
295
+ # forced a fresh deep capture. Keep the newest internally
296
+ # contiguous hot capture instead; the first wheel-up requests
297
+ # a cumulative deep page and can recover older rows safely.
298
+ # An already-frozen viewport owns its immutable snapshot and
299
+ # therefore remains undisturbed by this cache replacement.
300
+ lines = incoming.lines
299
301
  else:
300
302
  start = min(0, delta)
301
303
  incoming_end = delta + len(incoming.lines)
@@ -75,6 +75,9 @@ class SessionMeta:
75
75
  # conversation without changing the provider-owned UUID used for resume.
76
76
  # Always None for Claude sessions and older Codex rollouts.
77
77
  forked_from_id: str | None = None
78
+ # A Railmux lease observed in the same shared provider history root. This
79
+ # is a read-time UI overlay, never provider transcript metadata.
80
+ remote_owner: str | None = None
78
81
 
79
82
  @property
80
83
  def display_title(self) -> str:
@@ -0,0 +1,554 @@
1
+ """Cross-host provider-session leases for shared Codex/Claude homes.
2
+
3
+ The provider history root is the sharing authority: if two hosts see the same
4
+ rollout/transcript files, they also see the same Railmux lease files. Advisory
5
+ locks are held by a small process tied to the exact provider pane lifetime, so
6
+ Soft Quit can leave the provider running without silently releasing its lease.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import errno
12
+ import fcntl
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import selectors
17
+ import socket
18
+ import stat
19
+ import subprocess
20
+ import sys
21
+ import threading
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Sequence
27
+
28
+
29
+ SCHEMA_VERSION = 1
30
+ LEASE_DIRECTORY = "railmux-session-leases-v1"
31
+ _MAX_OWNER_BYTES = 4096
32
+ _READY_TIMEOUT_S = 3.0
33
+ _PROCFS_POLL_INTERVAL_S = 0.25
34
+ _FALLBACK_POLL_INTERVAL_S = 2.0
35
+ _PROBE_RETRY_WINDOW_S = 0.2
36
+ _STALE_LOCAL_RETRY_WINDOW_S = 0.75
37
+ _RETRY_INTERVAL_S = 0.05
38
+ _MODE_MASKING_ERRNOS = frozenset(
39
+ {errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP)}
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class LeaseOwner:
45
+ provider: str
46
+ session_id: str
47
+ host: str
48
+ instance: str
49
+ pane_id: str | None = None
50
+ pane_pid: int | None = None
51
+ process_start: str | None = None
52
+
53
+ @property
54
+ def display_host(self) -> str:
55
+ return self.host or "another host"
56
+
57
+
58
+ class LeaseError(RuntimeError):
59
+ """The shared lease authority could not be used safely."""
60
+
61
+
62
+ class LeaseConflict(LeaseError):
63
+ def __init__(self, owner: LeaseOwner | None) -> None:
64
+ self.owner = owner
65
+ host = owner.display_host if owner is not None else "another host"
66
+ super().__init__(f"session is already running on {host}")
67
+
68
+
69
+ @dataclass
70
+ class LeaseClaim:
71
+ root: Path
72
+ provider: str
73
+ session_ids: tuple[str, ...]
74
+ instance: str
75
+ files: tuple[tuple[str, Path, int], ...]
76
+ _detached: bool = False
77
+
78
+ def close(self) -> None:
79
+ if self._detached:
80
+ return
81
+ for _session_id, _path, fd in self.files:
82
+ # Lease paths are persistent rendezvous points. Unlinking a
83
+ # locked file creates a second-inode race on shared filesystems:
84
+ # another host can create and lock the same pathname before this
85
+ # fd is closed. An unlocked stale record is explicitly inactive.
86
+ try:
87
+ fcntl.flock(fd, fcntl.LOCK_UN)
88
+ except OSError:
89
+ pass
90
+ try:
91
+ os.close(fd)
92
+ except OSError:
93
+ pass
94
+ self._detached = True
95
+
96
+ def detach(self) -> None:
97
+ """Close parent copies after a holder inherited the locked fds."""
98
+ if self._detached:
99
+ return
100
+ for _session_id, _path, fd in self.files:
101
+ try:
102
+ os.close(fd)
103
+ except OSError:
104
+ pass
105
+ self._detached = True
106
+
107
+
108
+ def _bounded_host() -> str:
109
+ try:
110
+ host = socket.gethostname().strip()
111
+ except OSError:
112
+ host = ""
113
+ host = "".join(c if c.isalnum() or c in ".-_" else "-" for c in host)
114
+ return host[:128] or "unknown-host"
115
+
116
+
117
+ def _lease_directory(provider_root: Path) -> Path:
118
+ return provider_root / LEASE_DIRECTORY
119
+
120
+
121
+ def _lease_path(provider_root: Path, provider: str, session_id: str) -> Path:
122
+ material = f"{provider}\0{session_id}".encode("utf-8")
123
+ digest = hashlib.sha256(material).hexdigest()
124
+ return _lease_directory(provider_root) / f"{provider}-{digest}.lock"
125
+
126
+
127
+ def process_start_token(pid: int) -> str | None:
128
+ """Best available immutable birth token for one local process."""
129
+ if pid <= 0:
130
+ return None
131
+ try:
132
+ raw = Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
133
+ _head, separator, tail = raw.rpartition(")")
134
+ fields = tail.strip().split() if separator else []
135
+ start_ticks = fields[19]
136
+ if start_ticks.isdecimal():
137
+ return f"proc:{start_ticks}"
138
+ except (OSError, UnicodeError, IndexError):
139
+ pass
140
+ try:
141
+ started = subprocess.check_output(
142
+ ["ps", "-o", "lstart=", "-o", "comm=", "-p", str(pid)],
143
+ stderr=subprocess.DEVNULL,
144
+ text=True,
145
+ timeout=1.0,
146
+ ).strip()
147
+ except (OSError, subprocess.SubprocessError, UnicodeError):
148
+ return None
149
+ return (
150
+ f"ps:{hashlib.sha256(started.encode('utf-8')).hexdigest()}"
151
+ if started else None
152
+ )
153
+
154
+
155
+ def _process_matches(pid: int, token: str | None) -> bool:
156
+ if token is None:
157
+ return False
158
+ return process_start_token(pid) == token
159
+
160
+
161
+ def owner_is_stale_local(owner: LeaseOwner | None) -> bool:
162
+ """Whether an owner record proves its exact same-host pane has exited."""
163
+ return bool(
164
+ owner is not None
165
+ and owner.host == _bounded_host()
166
+ and owner.pane_pid is not None
167
+ and owner.process_start is not None
168
+ and not _process_matches(owner.pane_pid, owner.process_start)
169
+ )
170
+
171
+
172
+ def _ensure_lease_directory(provider_root: Path) -> Path:
173
+ directory = _lease_directory(provider_root)
174
+ try:
175
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
176
+ # lstat keeps a pre-created symlink from redirecting the predictable
177
+ # rendezvous directory outside the provider root.
178
+ info = directory.lstat()
179
+ except OSError as exc:
180
+ raise LeaseError(
181
+ "shared session lease directory is unavailable") from exc
182
+ if (
183
+ not stat.S_ISDIR(info.st_mode)
184
+ or info.st_uid != os.getuid()
185
+ ):
186
+ raise LeaseError("shared session lease directory is unsafe")
187
+ try:
188
+ # Repair a permissive directory on ordinary POSIX filesystems. DrvFs,
189
+ # CIFS, and other mode-masking stores may accept chmod while continuing
190
+ # to report their mount-wide mode; type/owner/no-symlink checks still
191
+ # apply there instead of disabling the lease entirely.
192
+ directory.chmod(0o700)
193
+ except OSError as exc:
194
+ if exc.errno not in _MODE_MASKING_ERRNOS:
195
+ raise LeaseError(
196
+ "shared session lease directory is unavailable") from exc
197
+ return directory
198
+
199
+
200
+ def _decode_owner(raw: bytes) -> LeaseOwner | None:
201
+ if not raw or len(raw) > _MAX_OWNER_BYTES:
202
+ return None
203
+ try:
204
+ value = json.loads(raw.decode("utf-8"))
205
+ except (UnicodeError, json.JSONDecodeError, ValueError):
206
+ return None
207
+ if not isinstance(value, dict) or value.get("version") != SCHEMA_VERSION:
208
+ return None
209
+ provider = value.get("provider")
210
+ session_id = value.get("session_id")
211
+ host = value.get("host")
212
+ instance = value.get("instance")
213
+ if not all(isinstance(item, str) and item for item in (
214
+ provider, session_id, host, instance,
215
+ )):
216
+ return None
217
+ pane_id = value.get("pane_id")
218
+ pane_pid = value.get("pane_pid")
219
+ process_start = value.get("process_start")
220
+ return LeaseOwner(
221
+ provider=provider[:32],
222
+ session_id=session_id[:128],
223
+ host=host[:128],
224
+ instance=instance[:128],
225
+ pane_id=pane_id[:64] if isinstance(pane_id, str) else None,
226
+ pane_pid=(pane_pid if isinstance(pane_pid, int)
227
+ and not isinstance(pane_pid, bool) and pane_pid > 0 else None),
228
+ process_start=(process_start[:128]
229
+ if isinstance(process_start, str) else None),
230
+ )
231
+
232
+
233
+ def _read_owner_fd(fd: int) -> LeaseOwner | None:
234
+ try:
235
+ return _decode_owner(os.pread(fd, _MAX_OWNER_BYTES + 1, 0))
236
+ except OSError:
237
+ return None
238
+
239
+
240
+ def _write_owner_fd(fd: int, payload: dict) -> None:
241
+ data = json.dumps(
242
+ payload, separators=(",", ":"), sort_keys=True,
243
+ ).encode("utf-8")
244
+ if len(data) > _MAX_OWNER_BYTES:
245
+ raise LeaseError("session lease owner record is too large")
246
+ try:
247
+ os.lseek(fd, 0, os.SEEK_SET)
248
+ os.ftruncate(fd, 0)
249
+ if os.write(fd, data) != len(data):
250
+ raise OSError("short session lease owner write")
251
+ # The descriptor remains open for the provider lifetime. Flush the
252
+ # diagnostic record now so another NFS client that observes the lock
253
+ # does not have to wait for close before it can name the owner.
254
+ os.fsync(fd)
255
+ except OSError as exc:
256
+ raise LeaseError("could not publish session lease owner") from exc
257
+
258
+
259
+ def _open_lease(path: Path) -> int:
260
+ flags = os.O_CREAT | os.O_RDWR
261
+ if hasattr(os, "O_NOFOLLOW"):
262
+ flags |= os.O_NOFOLLOW
263
+ try:
264
+ fd = os.open(path, flags, 0o600)
265
+ info = os.fstat(fd)
266
+ except OSError as exc:
267
+ raise LeaseError("could not open shared session lease") from exc
268
+ if (
269
+ not stat.S_ISREG(info.st_mode)
270
+ or info.st_uid != os.getuid()
271
+ ):
272
+ os.close(fd)
273
+ raise LeaseError("shared session lease file is unsafe")
274
+ try:
275
+ os.fchmod(fd, 0o600)
276
+ except OSError as exc:
277
+ if exc.errno not in _MODE_MASKING_ERRNOS:
278
+ os.close(fd)
279
+ raise LeaseError("shared session lease file is unavailable") from exc
280
+ return fd
281
+
282
+
283
+ def _acquire_once(
284
+ provider_root: Path,
285
+ provider: str,
286
+ ids: tuple[str, ...],
287
+ ) -> LeaseClaim:
288
+ instance = uuid.uuid4().hex
289
+ files: list[tuple[str, Path, int]] = []
290
+ try:
291
+ for session_id in ids:
292
+ path = _lease_path(provider_root, provider, session_id)
293
+ fd = _open_lease(path)
294
+ try:
295
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
296
+ except BlockingIOError as exc:
297
+ owner = _read_owner_fd(fd)
298
+ os.close(fd)
299
+ raise LeaseConflict(owner) from exc
300
+ except OSError as exc:
301
+ os.close(fd)
302
+ raise LeaseError(
303
+ "shared session locking is unavailable") from exc
304
+ files.append((session_id, path, fd))
305
+ for session_id, _path, fd in files:
306
+ _write_owner_fd(fd, {
307
+ "version": SCHEMA_VERSION,
308
+ "provider": provider,
309
+ "session_id": session_id,
310
+ "host": _bounded_host(),
311
+ "instance": instance,
312
+ "phase": "reserving",
313
+ })
314
+ return LeaseClaim(
315
+ provider_root, provider, ids, instance, tuple(files))
316
+ except BaseException:
317
+ for _session_id, _path, fd in files:
318
+ try:
319
+ os.close(fd)
320
+ except OSError:
321
+ pass
322
+ raise
323
+
324
+
325
+ def acquire(
326
+ provider_root: Path,
327
+ provider: str,
328
+ session_ids: Sequence[str],
329
+ ) -> LeaseClaim:
330
+ """Atomically reserve every alias needed for one logical conversation.
331
+
332
+ Read-only owner probes briefly take shared advisory locks. Retry a bounded
333
+ contention window so one such probe cannot be mistaken for a durable
334
+ writer; a proven dead same-host pane receives the slightly longer window
335
+ needed for its independent holder to observe process exit.
336
+ """
337
+ ids = tuple(sorted(set(session_ids)))
338
+ if provider not in {"claude", "codex"} or not ids:
339
+ raise LeaseError("invalid provider session lease identity")
340
+ if any(not session_id or len(session_id) > 128 for session_id in ids):
341
+ raise LeaseError("invalid provider session id for lease")
342
+ _ensure_lease_directory(provider_root)
343
+ started = time.monotonic()
344
+ while True:
345
+ try:
346
+ return _acquire_once(provider_root, provider, ids)
347
+ except LeaseConflict as exc:
348
+ retry_window = (
349
+ _STALE_LOCAL_RETRY_WINDOW_S
350
+ if owner_is_stale_local(exc.owner)
351
+ else _PROBE_RETRY_WINDOW_S
352
+ )
353
+ if time.monotonic() - started >= retry_window:
354
+ raise
355
+ time.sleep(_RETRY_INTERVAL_S)
356
+
357
+
358
+ def active_owner(
359
+ provider_root: Path,
360
+ provider: str,
361
+ session_ids: Sequence[str],
362
+ ) -> LeaseOwner | None:
363
+ """Return a currently locked owner for any alias, without mutation."""
364
+ for session_id in sorted(set(session_ids)):
365
+ path = _lease_path(provider_root, provider, session_id)
366
+ # NFS flock emulation may require write access even for a shared probe.
367
+ # O_RDWR does not mutate the file; the non-blocking lock result remains
368
+ # the sole active-owner authority. Shared probes do not serialize each
369
+ # other, while acquire retries their very short collision window.
370
+ flags = os.O_RDWR
371
+ if hasattr(os, "O_NOFOLLOW"):
372
+ flags |= os.O_NOFOLLOW
373
+ try:
374
+ fd = os.open(path, flags)
375
+ except FileNotFoundError:
376
+ continue
377
+ except OSError as exc:
378
+ raise LeaseError("shared session lease state is unavailable") from exc
379
+ try:
380
+ try:
381
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
382
+ except BlockingIOError:
383
+ return _read_owner_fd(fd) or LeaseOwner(
384
+ provider, session_id, "another host", "unknown")
385
+ except OSError as exc:
386
+ raise LeaseError(
387
+ "shared session locking is unavailable") from exc
388
+ else:
389
+ try:
390
+ fcntl.flock(fd, fcntl.LOCK_UN)
391
+ except OSError as exc:
392
+ raise LeaseError(
393
+ "shared session locking is unavailable") from exc
394
+ finally:
395
+ os.close(fd)
396
+ return None
397
+
398
+
399
+ def owner_matches_pane(
400
+ owner: LeaseOwner,
401
+ pane_id: str,
402
+ pane_pid: int,
403
+ *,
404
+ process_start: str | None = None,
405
+ ) -> bool:
406
+ current_start = process_start or process_start_token(pane_pid)
407
+ return bool(
408
+ owner.host == _bounded_host()
409
+ and owner.pane_id == pane_id
410
+ and owner.pane_pid == pane_pid
411
+ and owner.process_start == current_start
412
+ )
413
+
414
+
415
+ def start_holder(claim: LeaseClaim, *, pane_id: str, pane_pid: int) -> bool:
416
+ """Transfer one acquired claim to an independent pane-lifetime holder."""
417
+ token = process_start_token(pane_pid)
418
+ if not pane_id.startswith("%") or token is None:
419
+ claim.close()
420
+ return False
421
+ argv = [
422
+ sys.executable,
423
+ "-m",
424
+ "railmux.session_lease",
425
+ "hold",
426
+ "--provider",
427
+ claim.provider,
428
+ "--instance",
429
+ claim.instance,
430
+ "--pane-id",
431
+ pane_id,
432
+ "--pane-pid",
433
+ str(pane_pid),
434
+ "--process-start",
435
+ token,
436
+ ]
437
+ for session_id, _path, fd in claim.files:
438
+ argv.extend(("--lease", f"{session_id}:{fd}"))
439
+ try:
440
+ process = subprocess.Popen(
441
+ argv,
442
+ stdin=subprocess.DEVNULL,
443
+ stdout=subprocess.PIPE,
444
+ stderr=subprocess.DEVNULL,
445
+ pass_fds=tuple(fd for _session_id, _path, fd in claim.files),
446
+ start_new_session=True,
447
+ )
448
+ except OSError:
449
+ claim.close()
450
+ return False
451
+ assert process.stdout is not None
452
+ selector = selectors.DefaultSelector()
453
+ selector.register(process.stdout, selectors.EVENT_READ)
454
+ ready = False
455
+ try:
456
+ if selector.select(_READY_TIMEOUT_S):
457
+ ready = process.stdout.readline().strip() == b"ready"
458
+ finally:
459
+ selector.close()
460
+ process.stdout.close()
461
+ if not ready:
462
+ try:
463
+ process.terminate()
464
+ except OSError:
465
+ pass
466
+ try:
467
+ process.wait(timeout=1.0)
468
+ except subprocess.TimeoutExpired:
469
+ try:
470
+ process.kill()
471
+ except OSError:
472
+ pass
473
+ try:
474
+ process.wait(timeout=1.0)
475
+ except subprocess.TimeoutExpired:
476
+ pass
477
+ claim.close()
478
+ return False
479
+ claim.detach()
480
+ # The holder may outlive Railmux after Soft Quit. While Railmux remains
481
+ # alive, reap it asynchronously when its provider pane eventually exits.
482
+ threading.Thread(
483
+ target=process.wait,
484
+ name="railmux-session-lease-reaper",
485
+ daemon=True,
486
+ ).start()
487
+ return True
488
+
489
+
490
+ def _hold(args: argparse.Namespace) -> int:
491
+ files: list[tuple[str, Path | None, int]] = []
492
+ try:
493
+ for raw in args.lease:
494
+ session_id, separator, raw_fd = raw.rpartition(":")
495
+ if not separator or not raw_fd.isdecimal() or not session_id:
496
+ return 2
497
+ fd = int(raw_fd)
498
+ os.fstat(fd)
499
+ files.append((session_id, None, fd))
500
+ if not files or not _process_matches(args.pane_pid, args.process_start):
501
+ return 2
502
+ for session_id, _path, fd in files:
503
+ _write_owner_fd(fd, {
504
+ "version": SCHEMA_VERSION,
505
+ "provider": args.provider,
506
+ "session_id": session_id,
507
+ "host": _bounded_host(),
508
+ "instance": args.instance,
509
+ "phase": "running",
510
+ "pane_id": args.pane_id,
511
+ "pane_pid": args.pane_pid,
512
+ "process_start": args.process_start,
513
+ })
514
+ print("ready", flush=True)
515
+ poll_interval = (
516
+ _PROCFS_POLL_INTERVAL_S
517
+ if args.process_start.startswith("proc:")
518
+ else _FALLBACK_POLL_INTERVAL_S
519
+ )
520
+ while _process_matches(args.pane_pid, args.process_start):
521
+ time.sleep(poll_interval)
522
+ return 0
523
+ except (OSError, LeaseError):
524
+ return 1
525
+ finally:
526
+ # Persistent empty files avoid directory churn on NFS. The advisory
527
+ # lock, not stale JSON content, is the active-lease authority.
528
+ for _session_id, _path, fd in files:
529
+ try:
530
+ os.close(fd)
531
+ except OSError:
532
+ pass
533
+
534
+
535
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
536
+ parser = argparse.ArgumentParser(add_help=False)
537
+ sub = parser.add_subparsers(dest="command", required=True)
538
+ hold = sub.add_parser("hold", add_help=False)
539
+ hold.add_argument("--provider", choices=("claude", "codex"), required=True)
540
+ hold.add_argument("--instance", required=True)
541
+ hold.add_argument("--pane-id", required=True)
542
+ hold.add_argument("--pane-pid", type=int, required=True)
543
+ hold.add_argument("--process-start", required=True)
544
+ hold.add_argument("--lease", action="append", default=[])
545
+ return parser.parse_args(argv)
546
+
547
+
548
+ def main(argv: Sequence[str] | None = None) -> int:
549
+ args = parse_args(argv)
550
+ return _hold(args) if args.command == "hold" else 2
551
+
552
+
553
+ if __name__ == "__main__":
554
+ raise SystemExit(main())