swarph-cli 0.19.0__tar.gz → 0.20.0__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 (147) hide show
  1. {swarph_cli-0.19.0/src/swarph_cli.egg-info → swarph_cli-0.20.0}/PKG-INFO +4 -1
  2. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/README.md +3 -0
  3. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/pyproject.toml +1 -1
  4. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/__init__.py +1 -1
  5. swarph_cli-0.20.0/src/swarph_cli/commands/install_multiplexer.py +59 -0
  6. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/spawn.py +8 -0
  7. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/main.py +1 -0
  8. swarph_cli-0.20.0/src/swarph_cli/multiplexer.py +138 -0
  9. {swarph_cli-0.19.0 → swarph_cli-0.20.0/src/swarph_cli.egg-info}/PKG-INFO +4 -1
  10. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/SOURCES.txt +4 -0
  11. swarph_cli-0.20.0/tests/test_install_multiplexer_command.py +77 -0
  12. swarph_cli-0.20.0/tests/test_multiplexer.py +144 -0
  13. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/LICENSE +0 -0
  14. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/setup.cfg +0 -0
  15. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/caller.py +0 -0
  16. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/__init__.py +0 -0
  17. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/harden.py +0 -0
  18. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/lineage.py +0 -0
  19. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/liveness.py +0 -0
  20. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/manifest.py +0 -0
  21. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/paths.py +0 -0
  22. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/capture/verify.py +0 -0
  23. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/cell.py +0 -0
  24. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/__init__.py +0 -0
  25. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/_gateway_client.py +0 -0
  26. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/add.py +0 -0
  27. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/brain.py +0 -0
  28. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/brain_ask.py +0 -0
  29. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/cell.py +0 -0
  30. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/channel.py +0 -0
  31. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/chat.py +0 -0
  32. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/compress.py +0 -0
  33. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/daemon.py +0 -0
  34. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/gateway.py +0 -0
  35. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/highlight.py +0 -0
  36. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/hook_output.py +0 -0
  37. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/hooks.py +0 -0
  38. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/import_session.py +0 -0
  39. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/init.py +0 -0
  40. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/install_hook.py +0 -0
  41. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/lane.py +0 -0
  42. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/mcp_server.py +0 -0
  43. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/memory_sync.py +0 -0
  44. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/mesh.py +0 -0
  45. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/onboard.py +0 -0
  46. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/protocol_handler.py +0 -0
  47. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/ratify.py +0 -0
  48. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/schedule.py +0 -0
  49. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/security.py +0 -0
  50. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/service.py +0 -0
  51. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/commands/watchdog.py +0 -0
  52. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/__init__.py +0 -0
  53. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/levers.py +0 -0
  54. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/marker.py +0 -0
  55. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/compress/verify.py +0 -0
  56. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/__init__.py +0 -0
  57. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/feature_registry.py +0 -0
  58. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/lanes_control.py +0 -0
  59. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/schema.sql +0 -0
  60. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/server.py +0 -0
  61. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/gateway/services_control.py +0 -0
  62. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/parsers/__init__.py +0 -0
  63. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/parsers/claude.py +0 -0
  64. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/service/__init__.py +0 -0
  65. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/service/app.py +0 -0
  66. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/service/providers.py +0 -0
  67. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  68. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  69. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  70. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  71. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  72. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/requires.txt +0 -0
  73. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  74. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_add_security_gate.py +0 -0
  75. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_add.py +0 -0
  76. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_hash.py +0 -0
  77. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_lib.py +0 -0
  78. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_mcp.py +0 -0
  79. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_skill.py +0 -0
  80. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_tool.py +0 -0
  81. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_artifact_uri.py +0 -0
  82. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_brain_ask_command.py +0 -0
  83. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_brain_command.py +0 -0
  84. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_caller_meta_guard.py +0 -0
  85. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_lineage.py +0 -0
  86. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_liveness.py +0 -0
  87. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_manifest.py +0 -0
  88. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_paths.py +0 -0
  89. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_capture_security.py +0 -0
  90. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_command_dispatch.py +0 -0
  91. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_harden.py +0 -0
  92. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_loader.py +0 -0
  93. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_cell_verify.py +0 -0
  94. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_channel_command.py +0 -0
  95. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_chat_command.py +0 -0
  96. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_claude_parser.py +0 -0
  97. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_claude_tmux_template.py +0 -0
  98. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_caller_convention.py +0 -0
  99. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_command.py +0 -0
  100. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_levers.py +0 -0
  101. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_marker.py +0 -0
  102. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_compress_verify.py +0 -0
  103. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_daemon_command.py +0 -0
  104. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_feature_to_uri.py +0 -0
  105. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_gateway_client.py +0 -0
  106. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_gateway_command.py +0 -0
  107. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_highlight_command.py +0 -0
  108. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hook_output.py +0 -0
  109. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_add.py +0 -0
  110. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_bundle.py +0 -0
  111. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_init.py +0 -0
  112. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_lifecycle.py +0 -0
  113. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_hooks_merge.py +0 -0
  114. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_import_command.py +0 -0
  115. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_init_command.py +0 -0
  116. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_install_hook.py +0 -0
  117. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_lane_command.py +0 -0
  118. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_main.py +0 -0
  119. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_mcp_server.py +0 -0
  120. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_memory_sync.py +0 -0
  121. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_mesh_command.py +0 -0
  122. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_mesh_sidecar.py +0 -0
  123. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_onboard_command.py +0 -0
  124. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_protocol_handler.py +0 -0
  125. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_ratify_command.py +0 -0
  126. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_repl_caller_convention.py +0 -0
  127. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_schedule_command.py +0 -0
  128. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_security.py +0 -0
  129. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_service_app.py +0 -0
  130. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_service_command.py +0 -0
  131. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_service_providers.py +0 -0
  132. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_session_path_role_gate.py +0 -0
  133. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_smoke_chat.py +0 -0
  134. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_smoke_one_shot.py +0 -0
  135. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_smoke_phase_5_5.py +0 -0
  136. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_command.py +0 -0
  137. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_live_pin.py +0 -0
  138. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_mitosis_lineage.py +0 -0
  139. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_tmux_session.py +0 -0
  140. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_spawn_windows_relaunch.py +0 -0
  141. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_version_consistency.py +0 -0
  142. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog.py +0 -0
  143. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_dm_wake.py +0 -0
  144. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  145. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  146. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_model_rung.py +0 -0
  147. {swarph_cli-0.19.0 → swarph_cli-0.20.0}/tests/test_watchdog_stale_peers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -51,6 +51,8 @@ pip install swarph-cli
51
51
  swarph --version
52
52
  ```
53
53
 
54
+ > **System requirement — a terminal multiplexer.** The session verbs (`spawn`, `cell`, `watchdog`) drive a `tmux`-compatible multiplexer. On **Linux/macOS** install `tmux` (`apt install tmux` / `brew install tmux`). On **Windows** there is no native tmux — install [**psmux**](https://github.com/psmux/psmux) (a Rust tmux-for-Windows, MIT) with `swarph install-multiplexer` (fetches the pinned, checksum-verified binary into `~/.swarph/bin`) or `winget install marlocarlo.psmux`.
55
+
54
56
  **What it is:** a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent *cells* that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
55
57
 
56
58
  **Who it's for:** builders running more than one LLM who want them to *cooperate* instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the [`swarph-mesh`](https://github.com/BrainSurfing-tech/swarph-mesh) substrate library.
@@ -79,6 +81,7 @@ swarph highlight "<x>" log a highlight to the shared git-backed swarph timelin
79
81
  swarph spawn <role> launch a long-lived agent session as a named mesh cell
80
82
  swarph daemon foreground inbox-drain loop (the mesh doorbell)
81
83
  swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
84
+ swarph install-multiplexer fetch the Windows tmux multiplexer (psmux) — pinned + checksum-verified
82
85
  swarph add <uri> install a hook / MCP server / skill by content-addressed URI
83
86
  swarph hooks install Claude Code hooks as reusable artifacts
84
87
  swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
@@ -7,6 +7,8 @@ pip install swarph-cli
7
7
  swarph --version
8
8
  ```
9
9
 
10
+ > **System requirement — a terminal multiplexer.** The session verbs (`spawn`, `cell`, `watchdog`) drive a `tmux`-compatible multiplexer. On **Linux/macOS** install `tmux` (`apt install tmux` / `brew install tmux`). On **Windows** there is no native tmux — install [**psmux**](https://github.com/psmux/psmux) (a Rust tmux-for-Windows, MIT) with `swarph install-multiplexer` (fetches the pinned, checksum-verified binary into `~/.swarph/bin`) or `winget install marlocarlo.psmux`.
11
+
10
12
  **What it is:** a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent *cells* that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
11
13
 
12
14
  **Who it's for:** builders running more than one LLM who want them to *cooperate* instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the [`swarph-mesh`](https://github.com/BrainSurfing-tech/swarph-mesh) substrate library.
@@ -35,6 +37,7 @@ swarph highlight "<x>" log a highlight to the shared git-backed swarph timelin
35
37
  swarph spawn <role> launch a long-lived agent session as a named mesh cell
36
38
  swarph daemon foreground inbox-drain loop (the mesh doorbell)
37
39
  swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
40
+ swarph install-multiplexer fetch the Windows tmux multiplexer (psmux) — pinned + checksum-verified
38
41
  swarph add <uri> install a hook / MCP server / skill by content-addressed URI
39
42
  swarph hooks install Claude Code hooks as reusable artifacts
40
43
  swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.19.0"
7
+ version = "0.20.0"
8
8
  description = "The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.19.0"
19
+ __version__ = "0.20.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,59 @@
1
+ """``swarph install-multiplexer`` — fetch the checksum-verified psmux binary.
2
+
3
+ swarph's session machinery needs a ``tmux``-compatible multiplexer on PATH. On
4
+ Linux/macOS that's native ``tmux`` (install via apt/brew). On Windows there's no
5
+ pip/npm package, so this verb fetches the pinned, SHA-256-verified **psmux**
6
+ release zip and drops its ``tmux.exe`` into ``--dir`` (default
7
+ ``~/.swarph/bin``). The download is verified before anything is written.
8
+
9
+ Usage:
10
+ swarph install-multiplexer [--dir DIR] [--force] [--version 3.3.6]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import sys
18
+
19
+ from swarph_cli.multiplexer import (
20
+ PSMUX_VERSION,
21
+ find_multiplexer,
22
+ install_psmux,
23
+ )
24
+
25
+
26
+ def run_install_multiplexer(argv: list) -> int:
27
+ p = argparse.ArgumentParser(
28
+ prog="swarph install-multiplexer",
29
+ description="Fetch the checksum-verified psmux (tmux-for-Windows) binary.")
30
+ p.add_argument("--dir", default=os.path.expanduser("~/.swarph/bin"),
31
+ help="install directory (default ~/.swarph/bin)")
32
+ p.add_argument("--force", action="store_true",
33
+ help="reinstall even if a multiplexer is already on PATH")
34
+ p.add_argument("--version", default=PSMUX_VERSION,
35
+ help=f"psmux version to fetch (default {PSMUX_VERSION})")
36
+ args = p.parse_args(argv)
37
+
38
+ if sys.platform != "win32":
39
+ print("swarph install-multiplexer fetches the Windows psmux binary. On "
40
+ "Linux/macOS, tmux is the native multiplexer — install it via your "
41
+ "package manager (`apt install tmux` / `brew install tmux`).",
42
+ file=sys.stderr)
43
+ return 0
44
+
45
+ if not args.force:
46
+ present = find_multiplexer()
47
+ if present:
48
+ print(f"multiplexer already present at {present}")
49
+ return 0
50
+
51
+ try:
52
+ path = install_psmux(args.dir, version=args.version)
53
+ except (RuntimeError, ValueError, OSError) as err:
54
+ print(f"swarph install-multiplexer: {err}", file=sys.stderr)
55
+ return 1
56
+
57
+ print(f"installed psmux {args.version} -> {path}; "
58
+ f"add {args.dir} to your PATH")
59
+ return 0
@@ -1029,6 +1029,14 @@ def _launch_via_tmux(
1029
1029
  return False
1030
1030
  tmux = shutil.which("tmux")
1031
1031
  if not tmux:
1032
+ # No tmux-compatible multiplexer on PATH — fall back (Windows: WT rescue
1033
+ # / in-place). Surface a soft, actionable hint when interactive (Windows
1034
+ # users typically need psmux); stay silent on headless/respawn paths so
1035
+ # the watchdog/CI logs aren't spammed.
1036
+ if sys.stdout.isatty():
1037
+ from swarph_cli.multiplexer import find_multiplexer, multiplexer_hint
1038
+ if find_multiplexer() is None:
1039
+ print(f"swarph spawn: {multiplexer_hint()}", file=sys.stderr)
1032
1040
  return False
1033
1041
 
1034
1042
  interactive = sys.stdout.isatty()
@@ -98,6 +98,7 @@ _VERB_HANDLERS: dict[str, str] = {
98
98
  "channel": "swarph_cli.commands.channel.run_channel",
99
99
  "schedule": "swarph_cli.commands.schedule.run_schedule",
100
100
  "lane": "swarph_cli.commands.lane.run_lane",
101
+ "install-multiplexer": "swarph_cli.commands.install_multiplexer.run_install_multiplexer",
101
102
  "highlight": "swarph_cli.commands.highlight.run_highlight",
102
103
  # Future: "list-peers", "list-adapters", etc.
103
104
  }
@@ -0,0 +1,138 @@
1
+ """Multiplexer detection + the checksum-verified psmux fetcher.
2
+
3
+ swarph's session machinery (spawn/cell/watchdog) shells out to a ``tmux``-
4
+ compatible binary. On Linux/macOS that's ``tmux`` from the package manager; on
5
+ Windows it's **psmux** (https://github.com/psmux/psmux — a Rust tmux-for-Windows,
6
+ MIT) which installs a ``tmux`` command on PATH. psmux isn't on pip/npm — it ships
7
+ as per-arch GitHub release zips, so we fetch the pinned, **checksum-verified**
8
+ binary ourselves.
9
+
10
+ Security core (``install_psmux``): the order is download -> verify SHA-256 ->
11
+ (reject on mismatch, OR extract). A bad download NEVER reaches extraction, and a
12
+ malicious archive member name can never escape ``dest_dir`` (zip-slip guard:
13
+ only ``os.path.basename`` is ever honored). Nothing is executed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import io
20
+ import os
21
+ import platform
22
+ import shutil
23
+ import sys
24
+ import tempfile
25
+ import urllib.request
26
+ import zipfile
27
+
28
+ # --- pinned psmux release (v3.3.6, GitHub-attested digests) -----------------
29
+
30
+ PSMUX_VERSION = "3.3.6"
31
+ _URL = "https://github.com/psmux/psmux/releases/download/v3.3.6/{filename}"
32
+ _ASSETS = { # arch -> (filename, sha256)
33
+ "x64": ("psmux-v3.3.6-windows-x64.zip", "a56a890ea0829567818b9a368f16dcbd39c087f27328573df17c10dd39618947"),
34
+ "arm64": ("psmux-v3.3.6-windows-arm64.zip", "bf1e8bb9d624a2fe5cb09f6b5bb84e7f516cfd022efb677a2439f846a39f8f92"),
35
+ "x86": ("psmux-v3.3.6-windows-x86.zip", "9b387fe03cbcc1ef671a5c3fbddaafe383089d82cea03c567980d8ed3de9ba82"),
36
+ }
37
+
38
+ # binaries we accept out of the archive (basename, case-insensitive); the first
39
+ # is the primary whose path we return.
40
+ _BINARIES = ("tmux.exe", "psmux.exe", "pmux.exe")
41
+
42
+
43
+ def find_multiplexer() -> str | None:
44
+ """Return the path of the first tmux-compatible multiplexer on PATH, else None."""
45
+ for name in ("tmux", "psmux", "pmux"):
46
+ found = shutil.which(name)
47
+ if found:
48
+ return found
49
+ return None
50
+
51
+
52
+ def multiplexer_hint() -> str:
53
+ """A one-line, OS-aware install hint for a missing multiplexer."""
54
+ if sys.platform == "win32":
55
+ return ("no tmux-compatible multiplexer found — run "
56
+ "`swarph install-multiplexer` (fetches psmux) or "
57
+ "`winget install marlocarlo.psmux`")
58
+ return ("no tmux found — install it via your package manager "
59
+ "(e.g. `apt install tmux` / `brew install tmux`)")
60
+
61
+
62
+ def _detect_arch() -> str:
63
+ """Map ``platform.machine()`` to an ``_ASSETS`` key; raise ValueError otherwise."""
64
+ machine = platform.machine()
65
+ m = machine.lower()
66
+ if m in ("amd64", "x86_64"):
67
+ return "x64"
68
+ if m in ("arm64", "aarch64"):
69
+ return "arm64"
70
+ if m in ("x86", "i686", "i386"):
71
+ return "x86"
72
+ raise ValueError(f"unsupported architecture for psmux: {machine!r}")
73
+
74
+
75
+ def _default_opener(url: str) -> bytes:
76
+ with urllib.request.urlopen(url) as resp: # noqa: S310 (pinned github.com URL)
77
+ return resp.read()
78
+
79
+
80
+ def install_psmux(dest_dir, *, version=PSMUX_VERSION, opener=None) -> str:
81
+ """Fetch + verify + extract the pinned psmux binary into ``dest_dir``.
82
+
83
+ Order is load-bearing: download -> SHA-256 verify -> (reject OR extract). A
84
+ checksum mismatch raises BEFORE anything is written, and only the basename of
85
+ an archive member is ever honored (zip-slip guard). Returns the path to the
86
+ extracted primary binary (``tmux.exe``). Nothing is executed.
87
+ """
88
+ opener = opener or _default_opener
89
+
90
+ # 1. resolve the pinned asset for this arch
91
+ arch = _detect_arch()
92
+ filename, expected_sha = _ASSETS[arch]
93
+
94
+ # 2. download
95
+ data = opener(_URL.format(filename=filename))
96
+
97
+ # 3. verify BEFORE touching the filesystem — a bad download stops here
98
+ actual_sha = hashlib.sha256(data).hexdigest()
99
+ if actual_sha != expected_sha:
100
+ raise RuntimeError(
101
+ f"psmux checksum mismatch for {filename}: "
102
+ f"expected {expected_sha}, got {actual_sha} — refusing to extract")
103
+
104
+ # 4. only now open the verified archive and extract the binaries
105
+ os.makedirs(dest_dir, exist_ok=True)
106
+ written: dict[str, str] = {}
107
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
108
+ for member in zf.namelist():
109
+ base = os.path.basename(member)
110
+ # zip-slip guard: never honor a path component; skip anything that
111
+ # isn't a plain basename (no separators, no traversal).
112
+ if not base or base != member or ".." in member \
113
+ or "/" in member or "\\" in member:
114
+ continue
115
+ if base.lower() not in _BINARIES:
116
+ continue
117
+ target = os.path.join(dest_dir, base)
118
+ # atomic write: temp file in dest_dir, then os.replace
119
+ fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".psmux-")
120
+ try:
121
+ with os.fdopen(fd, "wb") as fh:
122
+ fh.write(zf.read(member))
123
+ os.replace(tmp, target)
124
+ except BaseException:
125
+ if os.path.exists(tmp):
126
+ os.remove(tmp)
127
+ raise
128
+ written[base.lower()] = target
129
+
130
+ if not written:
131
+ raise RuntimeError(
132
+ f"no psmux binary ({'/'.join(_BINARIES)}) found in {filename}")
133
+
134
+ # return the primary (tmux.exe) if present, else whatever we extracted
135
+ for name in _BINARIES:
136
+ if name in written:
137
+ return written[name]
138
+ return next(iter(written.values()))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.19.0
3
+ Version: 0.20.0
4
4
  Summary: The `swarph` binary — a multi-LLM CLI and mesh-gateway client + bundled server (`swarph gateway`): one-shot prompts across providers, interactive `chat`, multi-provider `spawn` (claude/codex/antigravity via a ProviderMembrane with subprocess billing-scrub), `mesh` send/inbox/register, `brain-ask` over the swarph-brain (gbrain) semantic memory, git-backed `assisted_memory`, session `import`, and a stranded-session `watchdog`.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -51,6 +51,8 @@ pip install swarph-cli
51
51
  swarph --version
52
52
  ```
53
53
 
54
+ > **System requirement — a terminal multiplexer.** The session verbs (`spawn`, `cell`, `watchdog`) drive a `tmux`-compatible multiplexer. On **Linux/macOS** install `tmux` (`apt install tmux` / `brew install tmux`). On **Windows** there is no native tmux — install [**psmux**](https://github.com/psmux/psmux) (a Rust tmux-for-Windows, MIT) with `swarph install-multiplexer` (fetches the pinned, checksum-verified binary into `~/.swarph/bin`) or `winget install marlocarlo.psmux`.
55
+
54
56
  **What it is:** a multi-provider LLM command line. Run one-shot prompts or an interactive REPL against any supported provider; spawn long-lived agent *cells* that persist across restarts and coordinate over a mesh; install hooks, MCP servers, and skills by content-addressed URI. An open, inspectable substrate — not a closed orchestration platform.
55
57
 
56
58
  **Who it's for:** builders running more than one LLM who want them to *cooperate* instead of sitting in separate tabs — multi-agent systems across vendors, agnostic by design. A thin client over the [`swarph-mesh`](https://github.com/BrainSurfing-tech/swarph-mesh) substrate library.
@@ -79,6 +81,7 @@ swarph highlight "<x>" log a highlight to the shared git-backed swarph timelin
79
81
  swarph spawn <role> launch a long-lived agent session as a named mesh cell
80
82
  swarph daemon foreground inbox-drain loop (the mesh doorbell)
81
83
  swarph watchdog detect + recover stranded agent sessions (cron- or systemd-driven)
84
+ swarph install-multiplexer fetch the Windows tmux multiplexer (psmux) — pinned + checksum-verified
82
85
  swarph add <uri> install a hook / MCP server / skill by content-addressed URI
83
86
  swarph hooks install Claude Code hooks as reusable artifacts
84
87
  swarph onboard / ratify bring a new peer into the mesh (mechanics + witness ratification)
@@ -5,6 +5,7 @@ src/swarph_cli/__init__.py
5
5
  src/swarph_cli/caller.py
6
6
  src/swarph_cli/cell.py
7
7
  src/swarph_cli/main.py
8
+ src/swarph_cli/multiplexer.py
8
9
  src/swarph_cli.egg-info/PKG-INFO
9
10
  src/swarph_cli.egg-info/SOURCES.txt
10
11
  src/swarph_cli.egg-info/dependency_links.txt
@@ -35,6 +36,7 @@ src/swarph_cli/commands/hooks.py
35
36
  src/swarph_cli/commands/import_session.py
36
37
  src/swarph_cli/commands/init.py
37
38
  src/swarph_cli/commands/install_hook.py
39
+ src/swarph_cli/commands/install_multiplexer.py
38
40
  src/swarph_cli/commands/lane.py
39
41
  src/swarph_cli/commands/mcp_server.py
40
42
  src/swarph_cli/commands/memory_sync.py
@@ -108,12 +110,14 @@ tests/test_hooks_merge.py
108
110
  tests/test_import_command.py
109
111
  tests/test_init_command.py
110
112
  tests/test_install_hook.py
113
+ tests/test_install_multiplexer_command.py
111
114
  tests/test_lane_command.py
112
115
  tests/test_main.py
113
116
  tests/test_mcp_server.py
114
117
  tests/test_memory_sync.py
115
118
  tests/test_mesh_command.py
116
119
  tests/test_mesh_sidecar.py
120
+ tests/test_multiplexer.py
117
121
  tests/test_onboard_command.py
118
122
  tests/test_protocol_handler.py
119
123
  tests/test_ratify_command.py
@@ -0,0 +1,77 @@
1
+ """Tests for ``swarph install-multiplexer`` — fetch the checksum-verified psmux
2
+ binary on Windows; on Linux/macOS it just points at the native tmux.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+
9
+ import pytest
10
+
11
+ from swarph_cli.commands import install_multiplexer as im
12
+
13
+
14
+ def test_install_multiplexer_registered_in_verb_handlers():
15
+ from swarph_cli.main import _VERB_HANDLERS
16
+ handler = _VERB_HANDLERS.get("install-multiplexer")
17
+ if handler is None:
18
+ pytest.skip("verb not yet registered by parent (main.py owned externally)")
19
+ assert handler == "swarph_cli.commands.install_multiplexer.run_install_multiplexer"
20
+
21
+
22
+ def test_non_windows_prints_native_tmux_hint(monkeypatch, capsys):
23
+ # FORCE the non-Windows branch regardless of host — CI runs on Windows too,
24
+ # where the unforced verb would take the Windows path (host-dependent green).
25
+ monkeypatch.setattr(im.sys, "platform", "linux")
26
+ rc = im.run_install_multiplexer([])
27
+ assert rc == 0
28
+ err = capsys.readouterr().err.lower()
29
+ assert "tmux" in err
30
+ assert "apt" in err or "brew" in err
31
+
32
+
33
+ def test_windows_already_present_returns_0(monkeypatch, capsys):
34
+ monkeypatch.setattr(im.sys, "platform", "win32")
35
+ monkeypatch.setattr(im, "find_multiplexer", lambda: "C:/tools/tmux.exe")
36
+ rc = im.run_install_multiplexer([])
37
+ assert rc == 0
38
+ assert "already present" in capsys.readouterr().out.lower()
39
+
40
+
41
+ def test_windows_installs_when_absent(monkeypatch, capsys):
42
+ monkeypatch.setattr(im.sys, "platform", "win32")
43
+ monkeypatch.setattr(im, "find_multiplexer", lambda: None)
44
+ monkeypatch.setattr(im, "install_psmux",
45
+ lambda d, **kw: "C:/Users/x/.swarph/bin/tmux.exe")
46
+ rc = im.run_install_multiplexer([])
47
+ assert rc == 0
48
+ assert "installed" in capsys.readouterr().out.lower()
49
+
50
+
51
+ def test_windows_force_reinstalls_even_if_present(monkeypatch, capsys):
52
+ calls = {}
53
+ monkeypatch.setattr(im.sys, "platform", "win32")
54
+ monkeypatch.setattr(im, "find_multiplexer", lambda: "C:/tools/tmux.exe")
55
+
56
+ def fake_install(d, **kw):
57
+ calls["hit"] = True
58
+ return "C:/Users/x/.swarph/bin/tmux.exe"
59
+
60
+ monkeypatch.setattr(im, "install_psmux", fake_install)
61
+ rc = im.run_install_multiplexer(["--force"])
62
+ assert rc == 0
63
+ assert calls.get("hit") is True
64
+ assert "installed" in capsys.readouterr().out.lower()
65
+
66
+
67
+ def test_windows_install_error_returns_1(monkeypatch, capsys):
68
+ monkeypatch.setattr(im.sys, "platform", "win32")
69
+ monkeypatch.setattr(im, "find_multiplexer", lambda: None)
70
+
71
+ def boom(d, **kw):
72
+ raise RuntimeError("psmux checksum mismatch")
73
+
74
+ monkeypatch.setattr(im, "install_psmux", boom)
75
+ rc = im.run_install_multiplexer([])
76
+ assert rc == 1
77
+ assert "checksum mismatch" in capsys.readouterr().err.lower()
@@ -0,0 +1,144 @@
1
+ """Tests for the multiplexer helper — detect tmux/psmux + the checksum-verified
2
+ psmux fetcher.
3
+
4
+ The load-bearing security property: ``install_psmux`` MUST verify the SHA-256 of
5
+ the downloaded bytes BEFORE it writes or extracts anything, and it MUST never
6
+ honor a path component inside the zip (zip-slip). A bad download or a malicious
7
+ archive name therefore never reaches the filesystem outside ``dest_dir``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import io
14
+ import os
15
+ import zipfile
16
+
17
+ import pytest
18
+
19
+ from swarph_cli import multiplexer as mx
20
+
21
+
22
+ # --- find_multiplexer -------------------------------------------------------
23
+
24
+ def test_find_multiplexer_returns_path_when_tmux_present(monkeypatch):
25
+ monkeypatch.setattr(mx.shutil, "which",
26
+ lambda name: "/usr/bin/tmux" if name == "tmux" else None)
27
+ assert mx.find_multiplexer() == "/usr/bin/tmux"
28
+
29
+
30
+ def test_find_multiplexer_returns_none_when_nothing_found(monkeypatch):
31
+ monkeypatch.setattr(mx.shutil, "which", lambda name: None)
32
+ assert mx.find_multiplexer() is None
33
+
34
+
35
+ def test_find_multiplexer_falls_back_to_psmux(monkeypatch):
36
+ monkeypatch.setattr(mx.shutil, "which",
37
+ lambda name: "/c/psmux.exe" if name == "psmux" else None)
38
+ assert mx.find_multiplexer() == "/c/psmux.exe"
39
+
40
+
41
+ # --- multiplexer_hint -------------------------------------------------------
42
+
43
+ def test_hint_windows_mentions_install_verb(monkeypatch):
44
+ monkeypatch.setattr(mx.sys, "platform", "win32")
45
+ hint = mx.multiplexer_hint()
46
+ assert "install-multiplexer" in hint and "psmux" in hint
47
+
48
+
49
+ def test_hint_posix_mentions_package_manager(monkeypatch):
50
+ monkeypatch.setattr(mx.sys, "platform", "linux")
51
+ hint = mx.multiplexer_hint()
52
+ assert "tmux" in hint and ("apt" in hint or "brew" in hint)
53
+
54
+
55
+ # --- _detect_arch -----------------------------------------------------------
56
+
57
+ @pytest.mark.parametrize("machine,expected", [
58
+ ("AMD64", "x64"), ("x86_64", "x64"),
59
+ ("ARM64", "arm64"), ("aarch64", "arm64"),
60
+ ("x86", "x86"), ("i686", "x86"), ("i386", "x86"),
61
+ ])
62
+ def test_detect_arch_maps_known_machines(monkeypatch, machine, expected):
63
+ monkeypatch.setattr(mx.platform, "machine", lambda: machine)
64
+ assert mx._detect_arch() == expected
65
+
66
+
67
+ def test_detect_arch_raises_on_unknown(monkeypatch):
68
+ monkeypatch.setattr(mx.platform, "machine", lambda: "sparc")
69
+ with pytest.raises(ValueError):
70
+ mx._detect_arch()
71
+
72
+
73
+ # --- install_psmux helpers --------------------------------------------------
74
+
75
+ def _zip_bytes(members):
76
+ """Build an in-memory zip: members is a list of (name, data)."""
77
+ buf = io.BytesIO()
78
+ with zipfile.ZipFile(buf, "w") as zf:
79
+ for name, data in members:
80
+ zf.writestr(name, data)
81
+ return buf.getvalue()
82
+
83
+
84
+ def _pin_arch(monkeypatch, filename, data):
85
+ """Force _detect_arch -> 'x64' and pin _ASSETS so the real sha matches."""
86
+ monkeypatch.setattr(mx, "_detect_arch", lambda: "x64")
87
+ sha = hashlib.sha256(data).hexdigest()
88
+ monkeypatch.setattr(mx, "_ASSETS", {"x64": (filename, sha)})
89
+ return sha
90
+
91
+
92
+ # --- install_psmux: success -------------------------------------------------
93
+
94
+ def test_install_psmux_success_writes_binary(monkeypatch, tmp_path):
95
+ data = _zip_bytes([("tmux.exe", b"FAKE")])
96
+ _pin_arch(monkeypatch, "psmux.zip", data)
97
+
98
+ out = mx.install_psmux(str(tmp_path), opener=lambda url: data)
99
+
100
+ assert os.path.basename(out).lower() == "tmux.exe"
101
+ assert os.path.isfile(out)
102
+ assert (tmp_path / "tmux.exe").read_bytes() == b"FAKE"
103
+
104
+
105
+ # --- install_psmux: checksum mismatch (the load-bearing security test) ------
106
+
107
+ def test_install_psmux_checksum_mismatch_writes_nothing(monkeypatch, tmp_path):
108
+ good = _zip_bytes([("tmux.exe", b"FAKE")])
109
+ _pin_arch(monkeypatch, "psmux.zip", good)
110
+ # opener returns DIFFERENT bytes whose hash won't match the pinned sha
111
+ tampered = _zip_bytes([("tmux.exe", b"EVIL-PAYLOAD")])
112
+
113
+ with pytest.raises(RuntimeError):
114
+ mx.install_psmux(str(tmp_path), opener=lambda url: tampered)
115
+
116
+ # nothing extracted/written: a bad download never reaches the filesystem
117
+ assert list(tmp_path.iterdir()) == []
118
+ assert not (tmp_path / "tmux.exe").exists()
119
+
120
+
121
+ # --- install_psmux: zip-slip ------------------------------------------------
122
+
123
+ def test_install_psmux_zip_slip_stays_in_dest(monkeypatch, tmp_path):
124
+ dest = tmp_path / "bin"
125
+ dest.mkdir()
126
+ # malicious member tries to escape dest_dir; hash matches so we test extract
127
+ data = _zip_bytes([("../evil.exe", b"FAKE"), ("tmux.exe", b"GOOD")])
128
+ _pin_arch(monkeypatch, "psmux.zip", data)
129
+
130
+ out = mx.install_psmux(str(dest), opener=lambda url: data)
131
+
132
+ # the escaped path was never honored
133
+ assert not (tmp_path / "evil.exe").exists()
134
+ assert not (dest.parent / "evil.exe").exists()
135
+ # the legitimate binary still lands inside dest
136
+ assert os.path.isfile(out)
137
+ assert (dest / "tmux.exe").exists()
138
+
139
+
140
+ def test_install_psmux_no_binary_in_zip_raises(monkeypatch, tmp_path):
141
+ data = _zip_bytes([("README.txt", b"hi")])
142
+ _pin_arch(monkeypatch, "psmux.zip", data)
143
+ with pytest.raises(RuntimeError):
144
+ mx.install_psmux(str(tmp_path), opener=lambda url: data)
File without changes
File without changes