swarph-cli 0.13.1__tar.gz → 0.13.3__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 (113) hide show
  1. {swarph_cli-0.13.1/src/swarph_cli.egg-info → swarph_cli-0.13.3}/PKG-INFO +1 -1
  2. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/pyproject.toml +1 -1
  3. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/chat.py +9 -5
  5. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/spawn.py +57 -17
  6. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/levers.py +7 -1
  7. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/verify.py +6 -1
  8. {swarph_cli-0.13.1 → swarph_cli-0.13.3/src/swarph_cli.egg-info}/PKG-INFO +1 -1
  9. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/SOURCES.txt +3 -0
  10. swarph_cli-0.13.3/tests/test_caller_meta_guard.py +117 -0
  11. swarph_cli-0.13.3/tests/test_compress_caller_convention.py +21 -0
  12. swarph_cli-0.13.3/tests/test_repl_caller_convention.py +43 -0
  13. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_command.py +7 -0
  14. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_tmux_session.py +51 -14
  15. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/LICENSE +0 -0
  16. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/README.md +0 -0
  17. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/setup.cfg +0 -0
  18. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/caller.py +0 -0
  19. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/__init__.py +0 -0
  20. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/harden.py +0 -0
  21. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/lineage.py +0 -0
  22. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/liveness.py +0 -0
  23. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/manifest.py +0 -0
  24. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/paths.py +0 -0
  25. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/capture/verify.py +0 -0
  26. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/cell.py +0 -0
  27. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/__init__.py +0 -0
  28. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/add.py +0 -0
  29. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/cell.py +0 -0
  30. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/compress.py +0 -0
  31. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/daemon.py +0 -0
  32. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/hook_output.py +0 -0
  33. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/hooks.py +0 -0
  34. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/import_session.py +0 -0
  35. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/init.py +0 -0
  36. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/install_hook.py +0 -0
  37. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/mcp_server.py +0 -0
  38. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/memory_sync.py +0 -0
  39. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/mesh.py +0 -0
  40. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/onboard.py +0 -0
  41. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/protocol_handler.py +0 -0
  42. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/ratify.py +0 -0
  43. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/security.py +0 -0
  44. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/commands/watchdog.py +0 -0
  45. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/__init__.py +0 -0
  46. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/compress/marker.py +0 -0
  47. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/main.py +0 -0
  48. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/parsers/__init__.py +0 -0
  49. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/parsers/claude.py +0 -0
  50. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  51. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  52. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  53. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  54. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  55. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/requires.txt +0 -0
  56. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/src/swarph_cli.egg-info/top_level.txt +0 -0
  57. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_add_security_gate.py +0 -0
  58. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_add.py +0 -0
  59. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_hash.py +0 -0
  60. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_lib.py +0 -0
  61. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_mcp.py +0 -0
  62. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_skill.py +0 -0
  63. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_tool.py +0 -0
  64. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_artifact_uri.py +0 -0
  65. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_lineage.py +0 -0
  66. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_liveness.py +0 -0
  67. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_manifest.py +0 -0
  68. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_paths.py +0 -0
  69. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_capture_security.py +0 -0
  70. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_command_dispatch.py +0 -0
  71. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_harden.py +0 -0
  72. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_loader.py +0 -0
  73. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_cell_verify.py +0 -0
  74. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_chat_command.py +0 -0
  75. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_claude_parser.py +0 -0
  76. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_claude_tmux_template.py +0 -0
  77. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_command.py +0 -0
  78. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_levers.py +0 -0
  79. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_marker.py +0 -0
  80. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_compress_verify.py +0 -0
  81. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_daemon_command.py +0 -0
  82. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_feature_to_uri.py +0 -0
  83. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hook_output.py +0 -0
  84. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_add.py +0 -0
  85. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_bundle.py +0 -0
  86. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_init.py +0 -0
  87. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_lifecycle.py +0 -0
  88. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_hooks_merge.py +0 -0
  89. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_import_command.py +0 -0
  90. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_init_command.py +0 -0
  91. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_install_hook.py +0 -0
  92. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_main.py +0 -0
  93. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_mcp_server.py +0 -0
  94. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_memory_sync.py +0 -0
  95. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_mesh_command.py +0 -0
  96. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_mesh_sidecar.py +0 -0
  97. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_onboard_command.py +0 -0
  98. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_protocol_handler.py +0 -0
  99. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_ratify_command.py +0 -0
  100. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_security.py +0 -0
  101. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_session_path_role_gate.py +0 -0
  102. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_smoke_chat.py +0 -0
  103. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_smoke_one_shot.py +0 -0
  104. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_smoke_phase_5_5.py +0 -0
  105. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_live_pin.py +0 -0
  106. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_mitosis_lineage.py +0 -0
  107. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_spawn_windows_relaunch.py +0 -0
  108. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog.py +0 -0
  109. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_dm_wake.py +0 -0
  110. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  111. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  112. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/tests/test_watchdog_model_rung.py +0 -0
  113. {swarph_cli-0.13.1 → swarph_cli-0.13.3}/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.13.1
3
+ Version: 0.13.3
4
4
  Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.13.1"
7
+ version = "0.13.3"
8
8
  description = "The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION)."
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.13.1"
19
+ __version__ = "0.13.2"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -35,7 +35,7 @@ import sys
35
35
  from dataclasses import dataclass, field
36
36
  from typing import Optional
37
37
 
38
- from swarph_cli.caller import default_caller
38
+ from swarph_cli.caller import _sanitize_username, default_caller
39
39
 
40
40
 
41
41
  _BANNER = """\
@@ -123,13 +123,17 @@ def _default_repl_caller() -> str:
123
123
  """``cli.repl.<user>`` — distinct from ``cli.oneshot.<user>`` so
124
124
  attribution rows distinguish REPL turns from one-shot calls.
125
125
 
126
- Falls back to the caller-convention default if env-derived user
127
- extraction fails."""
126
+ Reuses ``caller._sanitize_username`` (the SAME sanitizer ``default_caller``
127
+ uses) so the slug is guaranteed convention-valid: leading letter (``u_``
128
+ prefix if not), collapsed underscores, ``unknown`` on empty. The previous
129
+ inline slug omitted those guards, so a leading-digit username produced a
130
+ non-conforming ``cli.repl.3bob`` that crashed SwarphCall at runtime (#83).
131
+ Falls back to the caller-convention default if env-derived user extraction
132
+ fails."""
128
133
  user = os.environ.get("USER") or os.environ.get("LOGNAME")
129
134
  if not user:
130
135
  return default_caller()
131
- user_slug = "".join(c if c.isalnum() else "_" for c in user.lower())
132
- return f"cli.repl.{user_slug}"
136
+ return f"cli.repl.{_sanitize_username(user)}"
133
137
 
134
138
 
135
139
  def _print(msg: str = "", *, file=None) -> None:
@@ -28,6 +28,7 @@ import os
28
28
  import shutil
29
29
  import subprocess
30
30
  import sys
31
+ import time
31
32
  from pathlib import Path
32
33
  from typing import Optional
33
34
  from urllib.parse import quote
@@ -914,6 +915,54 @@ def _tmux_has_session(tmux: str, name: str) -> bool:
914
915
  return result.returncode == 0
915
916
 
916
917
 
918
+ def _tmux_create_session(tmux: str, name: str, cwd: Path) -> bool:
919
+ """Create the durable detached session that runs ``swarph spawn <name>``.
920
+
921
+ Robust against a STALE session registration. When a multiplexer server dies
922
+ uncleanly (operator closed the owning console, ``kill``, a crash) it can leave
923
+ the session NAME registered with no live server behind it. On the native-Windows
924
+ psmux build (``marlocarlo.psmux``) this poisons re-creation: ``new-session``
925
+ becomes a SILENT no-op — it returns rc=0 but creates nothing — so the name can
926
+ never come back. That is the exact failure that strands a peer's cell ("the
927
+ session got killed and won't return"; verified on workstation-lc 2026-06-18).
928
+ Real tmux on Linux/mac does not hit this, and the extra steps are a harmless
929
+ no-op there (the create succeeds first try and the loop breaks immediately).
930
+
931
+ So: clear any stale registration first (``kill-session`` — a no-op if the name
932
+ is truly absent), then create. Because psmux clears the stale lock
933
+ ASYNCHRONOUSLY (~1s observed), do NOT trust the create's exit code (rc=0 even on
934
+ the silent no-op): VERIFY with ``has-session`` and retry until it actually
935
+ materialises. Returns True once the session exists, False if it never appears.
936
+ """
937
+ create_cmd = [
938
+ tmux, "new-session", "-d", "-s", name,
939
+ "-c", str(cwd), "-e", "SWARPH_SPAWN=1",
940
+ "swarph", "spawn", name,
941
+ ]
942
+ # Clear a stale (server-less) registration; harmless if the name is truly absent.
943
+ try:
944
+ subprocess.run(
945
+ [tmux, "kill-session", "-t", f"={name}"],
946
+ stdout=subprocess.DEVNULL,
947
+ stderr=subprocess.DEVNULL,
948
+ )
949
+ except OSError:
950
+ return False
951
+ for attempt in range(6):
952
+ try:
953
+ subprocess.run(
954
+ create_cmd,
955
+ stdout=subprocess.DEVNULL,
956
+ stderr=subprocess.PIPE,
957
+ )
958
+ except OSError:
959
+ return False
960
+ if _tmux_has_session(tmux, name):
961
+ return True
962
+ time.sleep(0.4) # let psmux's async stale-lock clear catch up, then retry
963
+ return False
964
+
965
+
917
966
  def _launch_via_tmux(
918
967
  binary: str, argv: list[str], cwd: Path, session_name: str,
919
968
  ) -> bool:
@@ -978,24 +1027,15 @@ def _launch_via_tmux(
978
1027
  interactive = sys.stdout.isatty()
979
1028
 
980
1029
  if not _tmux_has_session(tmux, session_name):
981
- # Create the durable session detached. The command re-enters spawn so
982
- # the membrane env-scrub + starter injection apply inside the pane; the
983
- # inherited $TMUX (and SWARPH_SPAWN below) stop it from re-deciding.
984
- try:
985
- subprocess.run(
986
- [
987
- tmux, "new-session", "-d", "-s", session_name,
988
- "-c", str(cwd), "-e", "SWARPH_SPAWN=1",
989
- "swarph", "spawn", session_name,
990
- ],
991
- check=True,
992
- stdout=subprocess.DEVNULL,
993
- stderr=subprocess.PIPE,
994
- )
995
- except (OSError, subprocess.CalledProcessError) as exc:
1030
+ # Create the durable session detached. The command re-enters spawn so the
1031
+ # membrane env-scrub + starter injection apply inside the pane; the inherited
1032
+ # $TMUX (and SWARPH_SPAWN) stop it from re-deciding. _tmux_create_session
1033
+ # also clears a stale (server-less) registration that would otherwise make
1034
+ # psmux's new-session a silent no-op and strand the cell.
1035
+ if not _tmux_create_session(tmux, session_name, cwd):
996
1036
  print(
997
- f"swarph spawn: tmux session create failed ({exc}); falling "
998
- "back to the standard launch.",
1037
+ "swarph spawn: tmux session create failed (stale registration "
1038
+ "could not be cleared?); falling back to the standard launch.",
999
1039
  file=sys.stderr,
1000
1040
  )
1001
1041
  return False
@@ -4,6 +4,12 @@ from __future__ import annotations
4
4
  import re
5
5
  from dataclasses import dataclass
6
6
 
7
+ # Caller tag for the shorthand model call. MUST satisfy swarph_shared's
8
+ # caller convention (^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$ — dotted, not
9
+ # hyphenated), or SwarphCall(...) raises ValueError at runtime. See
10
+ # tests/test_compress_caller_convention.py.
11
+ SHORTHAND_CALLER = "swarph.compress"
12
+
7
13
 
8
14
  @dataclass
9
15
  class ArchivalResult:
@@ -41,7 +47,7 @@ async def shorthand(source: str, *, system_prompt: str = SHORTHAND_SYSTEM, chat=
41
47
  (messages, system_prompt, **kw)->resp with .text; defaults to swarph_mesh."""
42
48
  if chat is None:
43
49
  from swarph_mesh import ChatMessage, SwarphCall
44
- sc = SwarphCall(provider="claude", caller="swarph-compress")
50
+ sc = SwarphCall(provider="claude", caller=SHORTHAND_CALLER)
45
51
  async def chat(messages, system_prompt=None, **kw):
46
52
  return await sc.chat(messages=messages, system_prompt=system_prompt, **kw)
47
53
  msgs = [ChatMessage(role="user", content=source)]
@@ -4,6 +4,11 @@ from __future__ import annotations
4
4
  import gzip, re
5
5
  from pathlib import Path
6
6
 
7
+ # Caller tag for the adversarial verify-expand model call. MUST satisfy
8
+ # swarph_shared's dotted caller convention or SwarphCall raises at runtime.
9
+ # See tests/test_compress_caller_convention.py.
10
+ VERIFY_EXPAND_CALLER = "swarph.compress.verify"
11
+
7
12
  _LINK_RE = re.compile(r"\]\(([^)]+)\)")
8
13
 
9
14
 
@@ -67,7 +72,7 @@ async def verify_expand(source: str, compressed: str, *, chat=None) -> bool:
67
72
  fact in source not recoverable from compressed. One dropped fact -> False."""
68
73
  if chat is None:
69
74
  from swarph_mesh import ChatMessage, SwarphCall
70
- sc = SwarphCall(provider="claude", caller="swarph-compress-verify")
75
+ sc = SwarphCall(provider="claude", caller=VERIFY_EXPAND_CALLER)
71
76
  async def chat(messages, system_prompt=None, **kw):
72
77
  return await sc.chat(messages=messages, system_prompt=system_prompt, **kw)
73
78
  msgs = [ChatMessage(role="user", content=f"ORIGINAL:\n{source}\n\nCOMPRESSED:\n{compressed}")]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.13.1
3
+ Version: 0.13.3
4
4
  Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider `swarph spawn` (claude/codex/antigravity per cell.provider via a ProviderMembrane + subprocess billing-scrub), interactive `swarph init`, `swarph mesh` (send/inbox/register) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.5: foolproof Windows auto-relaunch (genuine-WT detection via process ancestry, not WT_SESSION).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
@@ -55,6 +55,7 @@ tests/test_artifact_mcp.py
55
55
  tests/test_artifact_skill.py
56
56
  tests/test_artifact_tool.py
57
57
  tests/test_artifact_uri.py
58
+ tests/test_caller_meta_guard.py
58
59
  tests/test_capture_lineage.py
59
60
  tests/test_capture_liveness.py
60
61
  tests/test_capture_manifest.py
@@ -67,6 +68,7 @@ tests/test_cell_verify.py
67
68
  tests/test_chat_command.py
68
69
  tests/test_claude_parser.py
69
70
  tests/test_claude_tmux_template.py
71
+ tests/test_compress_caller_convention.py
70
72
  tests/test_compress_command.py
71
73
  tests/test_compress_levers.py
72
74
  tests/test_compress_marker.py
@@ -90,6 +92,7 @@ tests/test_mesh_sidecar.py
90
92
  tests/test_onboard_command.py
91
93
  tests/test_protocol_handler.py
92
94
  tests/test_ratify_command.py
95
+ tests/test_repl_caller_convention.py
93
96
  tests/test_security.py
94
97
  tests/test_session_path_role_gate.py
95
98
  tests/test_smoke_chat.py
@@ -0,0 +1,117 @@
1
+ """Repo-wide caller-convention meta-guard (issue #81).
2
+
3
+ The #80 bug: a hyphenated caller tag (`swarph-compress`) was silently dead at
4
+ runtime because it fails swarph_shared's dotted `role.subrole` convention — and it
5
+ was masked because the levers/verify tests injected a fake chat and never built a
6
+ real SwarphCall. That fix added a per-caller regression test for the two compress
7
+ callers. THIS guard generalizes it: it discovers EVERY static caller tag across the
8
+ whole `swarph_cli` package by import-introspection and validates each, so the bug
9
+ class can't be re-introduced in ANY module without someone remembering to add a
10
+ per-caller test.
11
+
12
+ Design (converged lab+droplet, #81): import-introspection, not AST literal-walking.
13
+ The callers live in named `*_CALLER` constants — `caller=SHORTHAND_CALLER` is an
14
+ ast.Name, not a literal, so a naive literal-walk would miss exactly the callers #80
15
+ hardened. Introspection resolves the value for free and nudges the light convention
16
+ that caller tags live in discoverable `*_CALLER` constants.
17
+
18
+ NOT covered here (separate follow-up, flagged in the PR): the DYNAMIC default-caller
19
+ producers (`default_caller` / `_default_repl_caller`) do not guarantee an
20
+ [a-z]-leading slug, so a leading-digit/empty OS username yields a non-conforming
21
+ `cli.repl.<digit...>` tag — a real robustness bug in the sanitizers, out of scope
22
+ for this static meta-guard.
23
+ """
24
+ import importlib
25
+ import pkgutil
26
+
27
+ import swarph_cli
28
+ from swarph_shared.caller_convention import validate_caller
29
+
30
+
31
+ def _iter_caller_constants():
32
+ """Yield (module, attr, value) for every module-level ``*_CALLER`` str constant
33
+ across the swarph_cli package. Walks the package so a NEW module's caller
34
+ constant is covered automatically. A submodule that can't be imported in this
35
+ env (e.g. an optional dep) is skipped — importability is other tests' concern,
36
+ not this guard's."""
37
+ for mod in pkgutil.walk_packages(swarph_cli.__path__, swarph_cli.__name__ + "."):
38
+ try:
39
+ m = importlib.import_module(mod.name)
40
+ except Exception:
41
+ continue
42
+ for attr in dir(m):
43
+ if attr.endswith("_CALLER"):
44
+ val = getattr(m, attr, None)
45
+ if isinstance(val, str):
46
+ yield mod.name, attr, val
47
+
48
+
49
+ def test_all_caller_constants_conform():
50
+ """Every ``*_CALLER`` constant in the package satisfies the dotted convention."""
51
+ found = list(_iter_caller_constants())
52
+ # Sanity: the compress callers from #80 must be discovered, else the walk is broken
53
+ # (e.g. swarph_cli resolving to a stale install rather than this tree).
54
+ names = {f"{m.rsplit('.', 1)[-1]}.{a}" for m, a, _ in found}
55
+ assert "levers.SHORTHAND_CALLER" in names and "verify.VERIFY_EXPAND_CALLER" in names, (
56
+ f"meta-guard didn't discover the known compress callers; found: {sorted(names)}")
57
+ for modname, attr, val in found:
58
+ try:
59
+ validate_caller(val)
60
+ except ValueError as e:
61
+ raise AssertionError(
62
+ f"caller constant {modname}.{attr} = {val!r} violates the convention "
63
+ f"(dotted role.subrole) — would crash SwarphCall at runtime: {e}")
64
+
65
+
66
+ def test_default_caller_producers_conform_happy_path(monkeypatch):
67
+ """The default-caller producers emit a conforming tag for a normal username.
68
+ Username is injected so this is deterministic regardless of the CI runner's
69
+ real user (the adversarial leading-digit case is a flagged follow-up, not here)."""
70
+ monkeypatch.setenv("USER", "alice")
71
+ monkeypatch.setenv("LOGNAME", "alice")
72
+ from swarph_cli.main import default_caller
73
+ from swarph_cli.commands.chat import _default_repl_caller
74
+ for fn in (default_caller, _default_repl_caller):
75
+ val = fn()
76
+ try:
77
+ validate_caller(val)
78
+ except ValueError as e:
79
+ raise AssertionError(
80
+ f"{fn.__module__}.{fn.__name__}() -> {val!r} violates the convention: {e}")
81
+
82
+
83
+ def test_guard_catches_a_planted_bad_caller(monkeypatch):
84
+ """PIN the catch-property — a guard's catch must be COMMITTED, not remembered
85
+ (the very #80 lesson this PR generalizes; droplet review). Plant a hyphenated
86
+ `*_CALLER` on a real, discovered module and assert the guard both DISCOVERS it
87
+ and REJECTS it. monkeypatch auto-removes the plant so the real guard test stays
88
+ clean regardless of order."""
89
+ import pytest
90
+
91
+ import swarph_cli.compress.levers as lev
92
+ monkeypatch.setattr(lev, "PLANTED_BAD_CALLER", "swarph-bad-hyphen", raising=False)
93
+
94
+ # 1) introspection DISCOVERS the planted constant
95
+ found = {f"{m}.{a}": v for m, a, v in _iter_caller_constants()}
96
+ assert found.get("swarph_cli.compress.levers.PLANTED_BAD_CALLER") == "swarph-bad-hyphen", (
97
+ "the walk failed to discover a planted *_CALLER — discovery is broken")
98
+
99
+ # 2) the guard CATCHES it (fails loud, names the offending constant)
100
+ with pytest.raises(AssertionError, match="violates the convention"):
101
+ test_all_caller_constants_conform()
102
+
103
+
104
+ def test_no_modules_skipped_by_the_walk():
105
+ """The guard resiliently SKIPS an unimportable submodule (so an unrelated optional-
106
+ dep import error can't break it) — but a silent skip could hide a bad caller in a
107
+ module that's importable in prod yet not in this env. Assert NONE are skipped here,
108
+ so the walk is exhaustive; if this ever fails in CI, run with full extras."""
109
+ skipped = []
110
+ for mod in pkgutil.walk_packages(swarph_cli.__path__, swarph_cli.__name__ + "."):
111
+ try:
112
+ importlib.import_module(mod.name)
113
+ except Exception as e: # noqa: BLE001 — we want to surface ANY import failure
114
+ skipped.append((mod.name, repr(e)))
115
+ assert not skipped, (
116
+ f"caller meta-guard walk skipped modules (a bad caller in one could ship "
117
+ f"uncaught): {skipped}")
@@ -0,0 +1,21 @@
1
+ """Regression: the compress model-call caller tags MUST satisfy swarph_shared's
2
+ caller convention (dotted `role.subrole`, not hyphenated). The existing
3
+ levers/verify tests inject a fake `chat` and never construct a real SwarphCall,
4
+ so they passed while the production path crashed at SwarphCall(...) on a
5
+ hyphenated caller ('swarph-compress'). This test checks the tags directly so
6
+ the break can't silently regress again."""
7
+ from swarph_shared.caller_convention import validate_caller
8
+
9
+ from swarph_cli.compress.levers import SHORTHAND_CALLER
10
+ from swarph_cli.compress.verify import VERIFY_EXPAND_CALLER
11
+
12
+
13
+ def test_shorthand_caller_matches_convention():
14
+ # raises ValueError if it doesn't match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$
15
+ validate_caller(SHORTHAND_CALLER)
16
+ assert SHORTHAND_CALLER == "swarph.compress"
17
+
18
+
19
+ def test_verify_expand_caller_matches_convention():
20
+ validate_caller(VERIFY_EXPAND_CALLER)
21
+ assert VERIFY_EXPAND_CALLER == "swarph.compress.verify"
@@ -0,0 +1,43 @@
1
+ """Regression (#83): `_default_repl_caller` must emit a convention-valid caller for
2
+ ANY OS username — including the adversarial cases the old inline slug logic broke on.
3
+
4
+ #80 fixed a hyphenated STATIC caller constant. This is the same crash-class on the
5
+ DYNAMIC repl-default path: `_default_repl_caller` rolled its own slug
6
+ (`"".join(c if c.isalnum() else "_" ...)`) that — unlike `caller._sanitize_username`
7
+ which `default_caller` uses — did NOT guarantee a leading letter, collapse underscores,
8
+ or fall back on empty. So a leading-digit username produced `cli.repl.3bob`, which
9
+ fails `^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$` and would crash SwarphCall at runtime.
10
+ Fix: reuse `_sanitize_username` (single sanitizer, no divergence).
11
+ """
12
+ import pytest
13
+
14
+ from swarph_shared.caller_convention import validate_caller
15
+
16
+ from swarph_cli.commands.chat import _default_repl_caller
17
+
18
+
19
+ @pytest.mark.parametrize("username", [
20
+ "3bob", # leading digit — the reported crash
21
+ "9", # all-digit
22
+ "!!!", # all-special -> empty slug -> must fall back, not emit `cli.repl.`
23
+ "ALICE", # uppercase
24
+ "bob.smith", # dot (would otherwise split the segment)
25
+ "a-b-c", # hyphens (the #80 char)
26
+ "_", # underscore-only -> empty after strip
27
+ " ", # whitespace-only
28
+ ])
29
+ def test_repl_caller_conforms_for_adversarial_usernames(monkeypatch, username):
30
+ """The repl default caller validates clean for every adversarial username."""
31
+ monkeypatch.setenv("USER", username)
32
+ monkeypatch.delenv("LOGNAME", raising=False)
33
+ # raises ValueError if the produced tag violates the convention
34
+ validate_caller(_default_repl_caller())
35
+
36
+
37
+ def test_repl_caller_stays_distinct_from_oneshot(monkeypatch):
38
+ """Still `cli.repl.*` (not `cli.oneshot.*`) — the separate producer exists so
39
+ attribution distinguishes REPL turns from one-shot calls; the fix must not
40
+ collapse them."""
41
+ monkeypatch.setenv("USER", "alice")
42
+ monkeypatch.delenv("LOGNAME", raising=False)
43
+ assert _default_repl_caller().startswith("cli.repl.")
@@ -1323,6 +1323,13 @@ def test_run_spawn_grok_assisted_memory_no_double_system_prompt(
1323
1323
  monkeypatch.setattr("shutil.which", lambda name: "/bin/fake-grok")
1324
1324
  # neutralize the named-tmux launch so we reach launch()/execve in-test
1325
1325
  monkeypatch.setattr(spawn, "_launch_via_tmux", lambda *a, **k: False)
1326
+ # This asserts on the composed argv (no doubled --system-prompt-override),
1327
+ # which is platform-independent. Pin to a POSIX platform so launch() takes the
1328
+ # os.execve branch we mock below — otherwise on Windows it takes the
1329
+ # subprocess.run branch (v0.12.1 pane-collapse fix), the execve mock never
1330
+ # fires, and the real /bin/fake-grok exec fails (WinError 2). Matches the
1331
+ # platform-pinning convention of the launch tests above.
1332
+ monkeypatch.setattr(spawn.sys, "platform", "linux")
1326
1333
 
1327
1334
  exec_args = []
1328
1335
  monkeypatch.setattr("os.execve", lambda p, a, e: exec_args.append((p, a, e)))
@@ -59,19 +59,27 @@ class _FakeStdout:
59
59
 
60
60
  def _drive(monkeypatch, *, platform="win32", tmux=TMUX, wt=WT, in_tmux=None,
61
61
  spawn_marker=None, isatty=True, session_exists=False,
62
- new_session_exc=None):
62
+ new_session_exc=None, create_succeeds=True, create_succeeds_after=1):
63
63
  """Drive `_launch_via_tmux` with tmux/subprocess/execv mocked.
64
64
 
65
- `run` dispatches on the subcommand: `has-session` returns rc 0/1 per
66
- `session_exists`; `new-session` returns rc 0 (or raises `new_session_exc`);
67
- `attach` (Windows path only) returns rc 0. The POSIX attach is os.execv,
68
- mocked to raise `_ExecvReplaced` (simulating its never-returns replace).
65
+ `run` dispatches on the subcommand and is STATEFUL to model reality after the
66
+ stale-registration fix: `has-session` returns rc 0 once the session exists —
67
+ either pre-existing (`session_exists`) OR after a `new-session` "takes".
68
+ `_tmux_create_session` clears a stale reg (`kill-session`, rc 0 here) then
69
+ creates + VERIFIES via `has-session`, retrying because psmux clears the stale
70
+ lock asynchronously. `create_succeeds_after` models that delay: the session
71
+ only materialises on the Nth `new-session` call (1 = first try, the happy
72
+ path). `create_succeeds=False` models a create that never materialises (e.g. a
73
+ stale lock that won't clear) → the loop exhausts and returns False.
74
+ `new-session` raises `new_session_exc` when set. `attach` (Windows) returns rc
75
+ 0; POSIX attach is os.execv, mocked to raise `_ExecvReplaced`.
69
76
 
70
77
  Returns `(result, run, execv)`. `result` is the function's bool, or the
71
78
  `_EXECV` sentinel when the POSIX execv attach took over.
72
79
  """
73
80
  monkeypatch.setattr(spawn.sys, "platform", platform)
74
81
  monkeypatch.setattr(spawn.sys, "stdout", _FakeStdout(isatty))
82
+ monkeypatch.setattr(spawn.time, "sleep", lambda *_a, **_k: None) # no real wait
75
83
  for var, val in (("TMUX", in_tmux), ("SWARPH_SPAWN", spawn_marker)):
76
84
  if val is None:
77
85
  monkeypatch.delenv(var, raising=False)
@@ -83,12 +91,20 @@ def _drive(monkeypatch, *, platform="win32", tmux=TMUX, wt=WT, in_tmux=None,
83
91
 
84
92
  monkeypatch.setattr(spawn.shutil, "which", _which)
85
93
 
94
+ state = {"new_calls": 0, "created": False}
95
+
86
96
  def _run(cmd, **kwargs):
87
97
  if "has-session" in cmd:
88
- return MagicMock(returncode=0 if session_exists else 1)
89
- if "new-session" in cmd and new_session_exc is not None:
90
- raise new_session_exc
91
- return MagicMock(returncode=0)
98
+ exists = session_exists or state["created"]
99
+ return MagicMock(returncode=0 if exists else 1)
100
+ if "new-session" in cmd:
101
+ if new_session_exc is not None:
102
+ raise new_session_exc
103
+ state["new_calls"] += 1
104
+ if create_succeeds and state["new_calls"] >= create_succeeds_after:
105
+ state["created"] = True
106
+ return MagicMock(returncode=0)
107
+ return MagicMock(returncode=0) # kill-session, attach, ...
92
108
 
93
109
  run = MagicMock(side_effect=_run)
94
110
  monkeypatch.setattr(spawn.subprocess, "run", run)
@@ -108,6 +124,11 @@ def _cmds(run):
108
124
  return [c.args[0] for c in run.call_args_list]
109
125
 
110
126
 
127
+ def _killed_stale(run):
128
+ """True if the stale-registration clear (`kill-session`) was issued."""
129
+ return any("kill-session" in c for c in _cmds(run))
130
+
131
+
111
132
  def _attached_via_run(run):
112
133
  """True if the Windows blocking-subprocess.run attach fired."""
113
134
  return [TMUX, "attach", "-t", SESSION] in _cmds(run)
@@ -239,17 +260,33 @@ def test_existing_session_headless_is_a_noop_handoff(monkeypatch, platform):
239
260
 
240
261
 
241
262
  @pytest.mark.parametrize("platform", ["win32", "linux"])
242
- def test_create_failure_falls_through(monkeypatch, platform):
243
- # tmux new-session errors => return False so the caller drops to the standard
244
- # launch; never attach a half-created session.
245
- boom = subprocess.CalledProcessError(1, "tmux")
263
+ def test_create_never_materialises_falls_through(monkeypatch, platform):
264
+ # The session never appears (e.g. a psmux stale registration that won't clear:
265
+ # new-session returns rc 0 but creates nothing). The verify+retry loop exhausts
266
+ # => return False so the caller drops to the standard launch; never attach a
267
+ # phantom session. We also issued the stale-clear kill-session first.
246
268
  r, run, execv = _drive(monkeypatch, platform=platform,
247
- session_exists=False, new_session_exc=boom)
269
+ session_exists=False, create_succeeds=False)
248
270
  assert r is False
271
+ assert _killed_stale(run)
249
272
  assert not _attached_via_run(run)
250
273
  execv.assert_not_called()
251
274
 
252
275
 
276
+ @pytest.mark.parametrize("platform", ["win32", "linux", "darwin"])
277
+ def test_stale_registration_cleared_then_create_retried(monkeypatch, platform):
278
+ # The poisoned-name recovery path: kill-session clears the stale reg, but psmux
279
+ # clears the lock async so the FIRST new-session is a silent no-op; the session
280
+ # only materialises on the 2nd attempt. The verify loop must retry and succeed.
281
+ r, run, execv = _drive(monkeypatch, platform=platform, session_exists=False,
282
+ isatty=False, create_succeeds_after=2)
283
+ assert r is True # headless: created, claimed launch
284
+ assert _killed_stale(run) # stale reg cleared first
285
+ new_calls = [c for c in _cmds(run) if "new-session" in c]
286
+ assert len(new_calls) >= 2 # retried past the silent no-op
287
+ execv.assert_not_called()
288
+
289
+
253
290
  @pytest.mark.parametrize("platform", ["win32", "linux"])
254
291
  def test_create_oserror_falls_through(monkeypatch, platform):
255
292
  r, run, execv = _drive(
File without changes
File without changes
File without changes