swarph-cli 0.13.0__tar.gz → 0.13.2__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 (110) hide show
  1. {swarph_cli-0.13.0/src/swarph_cli.egg-info → swarph_cli-0.13.2}/PKG-INFO +1 -1
  2. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/pyproject.toml +1 -1
  3. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/spawn.py +227 -86
  5. {swarph_cli-0.13.0 → swarph_cli-0.13.2/src/swarph_cli.egg-info}/PKG-INFO +1 -1
  6. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_spawn_command.py +195 -83
  7. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_spawn_tmux_session.py +51 -14
  8. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/LICENSE +0 -0
  9. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/README.md +0 -0
  10. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/setup.cfg +0 -0
  11. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/caller.py +0 -0
  12. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/__init__.py +0 -0
  13. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/harden.py +0 -0
  14. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/lineage.py +0 -0
  15. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/liveness.py +0 -0
  16. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/manifest.py +0 -0
  17. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/paths.py +0 -0
  18. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/capture/verify.py +0 -0
  19. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/cell.py +0 -0
  20. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/__init__.py +0 -0
  21. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/add.py +0 -0
  22. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/cell.py +0 -0
  23. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/chat.py +0 -0
  24. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/compress.py +0 -0
  25. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/daemon.py +0 -0
  26. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/hook_output.py +0 -0
  27. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/hooks.py +0 -0
  28. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/import_session.py +0 -0
  29. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/init.py +0 -0
  30. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/install_hook.py +0 -0
  31. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/mcp_server.py +0 -0
  32. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/memory_sync.py +0 -0
  33. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/mesh.py +0 -0
  34. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/onboard.py +0 -0
  35. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/protocol_handler.py +0 -0
  36. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/ratify.py +0 -0
  37. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/security.py +0 -0
  38. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/commands/watchdog.py +0 -0
  39. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/compress/__init__.py +0 -0
  40. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/compress/levers.py +0 -0
  41. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/compress/marker.py +0 -0
  42. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/compress/verify.py +0 -0
  43. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/main.py +0 -0
  44. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/parsers/__init__.py +0 -0
  45. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/parsers/claude.py +0 -0
  46. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  47. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  48. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  49. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
  50. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  51. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  52. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli.egg-info/requires.txt +0 -0
  53. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/src/swarph_cli.egg-info/top_level.txt +0 -0
  54. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_add_security_gate.py +0 -0
  55. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_add.py +0 -0
  56. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_hash.py +0 -0
  57. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_lib.py +0 -0
  58. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_mcp.py +0 -0
  59. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_skill.py +0 -0
  60. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_tool.py +0 -0
  61. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_artifact_uri.py +0 -0
  62. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_capture_lineage.py +0 -0
  63. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_capture_liveness.py +0 -0
  64. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_capture_manifest.py +0 -0
  65. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_capture_paths.py +0 -0
  66. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_capture_security.py +0 -0
  67. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_cell_command_dispatch.py +0 -0
  68. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_cell_harden.py +0 -0
  69. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_cell_loader.py +0 -0
  70. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_cell_verify.py +0 -0
  71. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_chat_command.py +0 -0
  72. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_claude_parser.py +0 -0
  73. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_claude_tmux_template.py +0 -0
  74. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_compress_command.py +0 -0
  75. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_compress_levers.py +0 -0
  76. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_compress_marker.py +0 -0
  77. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_compress_verify.py +0 -0
  78. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_daemon_command.py +0 -0
  79. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_feature_to_uri.py +0 -0
  80. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_hook_output.py +0 -0
  81. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_hooks_add.py +0 -0
  82. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_hooks_bundle.py +0 -0
  83. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_hooks_init.py +0 -0
  84. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_hooks_lifecycle.py +0 -0
  85. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_hooks_merge.py +0 -0
  86. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_import_command.py +0 -0
  87. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_init_command.py +0 -0
  88. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_install_hook.py +0 -0
  89. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_main.py +0 -0
  90. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_mcp_server.py +0 -0
  91. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_memory_sync.py +0 -0
  92. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_mesh_command.py +0 -0
  93. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_mesh_sidecar.py +0 -0
  94. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_onboard_command.py +0 -0
  95. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_protocol_handler.py +0 -0
  96. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_ratify_command.py +0 -0
  97. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_security.py +0 -0
  98. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_session_path_role_gate.py +0 -0
  99. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_smoke_chat.py +0 -0
  100. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_smoke_one_shot.py +0 -0
  101. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_smoke_phase_5_5.py +0 -0
  102. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_spawn_live_pin.py +0 -0
  103. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_spawn_mitosis_lineage.py +0 -0
  104. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_spawn_windows_relaunch.py +0 -0
  105. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_watchdog.py +0 -0
  106. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_watchdog_dm_wake.py +0 -0
  107. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_watchdog_dm_wake_cooldown.py +0 -0
  108. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_watchdog_dm_wake_wiring.py +0 -0
  109. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/tests/test_watchdog_model_rung.py +0 -0
  110. {swarph_cli-0.13.0 → swarph_cli-0.13.2}/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.0
3
+ Version: 0.13.2
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.0"
7
+ version = "0.13.2"
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.0"
19
+ __version__ = "0.13.2"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -28,8 +28,10 @@ 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
34
+ from urllib.parse import quote
33
35
 
34
36
  from swarph_cli import __version__
35
37
  from swarph_cli.cell import (
@@ -490,89 +492,165 @@ def _scrubbed_codex_env() -> dict[str, str]:
490
492
  #: inside cwd)").
491
493
  _GROK_CELL_HOME_SUBDIR = ".grok-cell"
492
494
 
493
- #: Metered xAI billing keys popped explicitly (belt-and-suspenders on top of the
494
- #: canonical ``*_API_KEY`` / ``*_BASE_URL`` suffix sweep) so a grok cell can
495
- #: never be flipped off its $0 OIDC auth onto a metered endpoint. The suffix
496
- #: sweep already catches ``XAI_API_KEY`` / ``GROK_API_KEY``; the ``*_API_BASE``
497
- #: / ``*_API_HOST`` variants do NOT match a suffix, so they live here.
498
- _GROK_EXTRA_LEAK_KEYS = (
499
- "XAI_API_KEY",
500
- "GROK_API_KEY",
501
- "GROK_CODE_XAI_API_KEY",
502
- "XAI_API_BASE",
503
- "XAI_API_HOST",
504
- "GROK_API_HOST",
505
- )
495
+ #: Grok cell env: DENY-BY-DEFAULT for the grok/xai namespace. A cell has its own
496
+ #: ISOLATED HOME + file-based config, so it must inherit NOTHING grok-specific
497
+ #: from the operator's interactive env every ``GROK_*``/``XAI_*`` var is a
498
+ #: potential redirect: ``GROK_HOME``/``GROK_AUTH_PATH``/``GROK_AUTH`` override the
499
+ #: isolated HOME and auth path ENTIRELY (so an inherited one silently bypasses
500
+ #: the whole isolation scheme — grok honors GROK_HOME over $HOME);
501
+ #: ``GROK_AUTH_PROVIDER_COMMAND`` runs an arbitrary auth command;
502
+ #: ``GROK_MANAGED_CONFIG_URL`` + the ``GROK_OAUTH2_*`` / ``*_URL`` family redirect
503
+ #: endpoints. Enumerating them is whack-a-mole, so scrub the whole namespace and
504
+ #: let grok fall back to its built-in xAI $0 defaults + the symlinked auth.json.
505
+ #: Allowlist only a var the cell genuinely needs to RECEIVE from the parent —
506
+ #: none today.
507
+ _GROK_ENV_ALLOWLIST: frozenset = frozenset()
508
+
509
+
510
+ def _scrub_grok_namespace(env: dict) -> None:
511
+ for key in [
512
+ k for k in env
513
+ if (k.startswith("GROK_") or k.startswith("XAI_"))
514
+ and k not in _GROK_ENV_ALLOWLIST
515
+ ]:
516
+ env.pop(key, None)
517
+
518
+
519
+ #: Default grok sandbox profile. Built-in profiles: off, workspace, devbox,
520
+ #: read-only, strict. ``workspace`` confines filesystem writes to the cwd while
521
+ #: keeping network (mesh) reachable — empirically Landlock restrict_network=false
522
+ #: for workspace (strict/read-only request restrict_network=true, which blocks
523
+ #: child network on kernels with Landlock V4+/full seccomp TCP filtering; on
524
+ #: older kernels the seccomp-fallback localhost rules still let the mesh through).
525
+ #: Override via top-level ``sandbox`` in cell.yaml (``sandbox: off`` disables).
526
+ _GROK_DEFAULT_SANDBOX = "workspace"
506
527
 
507
528
 
508
529
  def _grok_env(cell: Cell) -> dict[str, str]:
509
530
  """Subscription/OIDC env for a local ``grok`` CELL session ($0 path).
510
531
 
511
532
  On top of the canonical billing scrub:
512
- * the explicit ``_GROK_EXTRA_LEAK_KEYS`` pop (metered xAI endpoints the
513
- suffix sweep misses), keeping grok on its $0 OIDC auth.
514
- * MESH_GATEWAY_TOKEN popped the per-cell auth CUTOVER: with the shared
515
- token absent the swarph resolver loads the cell's per-peer token file
516
- (symlinked into the isolated HOME at
517
- ``.grok-cell/.config/swarph/grok-researcher.peer_token``) instead of the
518
- cell riding the operator's shared token. (``_TOKEN`` is not a scrub
519
- suffix, so this MUST be explicit omitting it is the mint≠cutover gap.)
520
- * HOME → an ISOLATED dir inside the cell cwd (created if absent), with the
521
- operator's ``~/.grok/auth.json`` symlinked in for $0 OIDC, so the cell's
533
+ * DENY-BY-DEFAULT scrub of the whole ``GROK_*``/``XAI_*`` namespace (see
534
+ ``_scrub_grok_namespace``) closes the redirect class incl
535
+ ``GROK_HOME``/``GROK_AUTH_PATH`` (which would bypass the isolated HOME)
536
+ and ``GROK_AUTH_PROVIDER_COMMAND``; grok falls back to its built-in xAI
537
+ $0 defaults + the symlinked auth.json.
538
+ * HOME → an ISOLATED dir inside the cell cwd, with the operator's
539
+ ``~/.grok/auth.json`` symlinked to ``<HOME>/.grok/auth.json`` (grok reads
540
+ auth at ``$HOME/.grok/auth.json``strace-verified; the link MUST be in
541
+ the ``.grok`` subdir, not at ``$HOME/auth.json``), so the cell's
522
542
  sessions/memory/auth never mix with the operator's personal ``~/.grok``.
523
543
  * SWARPH_SPAWN marker — the tmux re-entry loop-guard (see
524
544
  ``_launch_via_tmux``) and the install-hook double-inject guard.
545
+
546
+ MESH_GATEWAY_TOKEN is deliberately NOT popped — same as the claude/codex/agy
547
+ membranes, the cell inherits the gateway token so its mesh DMs work out of
548
+ the box. (A per-peer-token cutover is a separate, explicit feature: it
549
+ requires placing the per-peer token file in the cell HOME, which this
550
+ membrane does not do, so popping the shared token here would silently mute
551
+ the cell on the mesh.)
525
552
  """
526
553
  env = scrub_env_for_subprocess()
527
- for key in _GROK_EXTRA_LEAK_KEYS:
528
- env.pop(key, None)
529
- env.pop("MESH_GATEWAY_TOKEN", None)
554
+ _scrub_grok_namespace(env)
530
555
  env["SWARPH_SPAWN"] = "1"
531
556
 
532
557
  cell_home = cell.cwd / _GROK_CELL_HOME_SUBDIR
533
- cell_home.mkdir(parents=True, exist_ok=True)
558
+ grok_dir = cell_home / ".grok"
559
+ grok_dir.mkdir(parents=True, exist_ok=True)
560
+ _link_grok_auth(grok_dir / "auth.json")
561
+ env["HOME"] = str(cell_home)
562
+ return env
563
+
564
+
565
+ def _link_grok_auth(link: Path) -> None:
566
+ """Symlink the operator's ``~/.grok/auth.json`` to ``link`` for $0 OIDC.
567
+
568
+ Robust + idempotent: skips when ``link`` already resolves to the operator
569
+ auth; replaces a STALE/foreign/dangling link (validated via ``is_symlink`` +
570
+ ``readlink``, which a dangling link's ``exists()`` reports False for);
571
+ never clobbers a real file; never crashes the spawn (best-effort — grok
572
+ falls back to its own auth flow on any failure).
573
+ """
574
+ op_auth = Path.home() / ".grok" / "auth.json"
575
+ if not op_auth.exists():
576
+ return
534
577
  try:
535
- op_auth = Path.home() / ".grok" / "auth.json"
536
- link = cell_home / "auth.json"
537
- if op_auth.exists() and not link.exists():
538
- link.symlink_to(op_auth)
578
+ if link.is_symlink():
579
+ if link.readlink() == op_auth:
580
+ return # already correct
581
+ link.unlink() # stale/foreign/dangling link → replace
582
+ elif link.exists():
583
+ return # a real file is present — do not clobber
584
+ link.symlink_to(op_auth)
539
585
  except OSError:
540
- # Best-effort: a pre-existing link / race / read-only HOME must not
541
- # crash the spawn — grok will fall back to its own auth flow.
542
586
  pass
543
- env["HOME"] = str(cell_home)
544
- return env
587
+
588
+
589
+ def _grok_session_exists(cell: Cell) -> bool:
590
+ """True if grok already has a session for this cell's (HOME, cwd).
591
+
592
+ grok keys sessions by url-encoded cwd under
593
+ ``$HOME/.grok/sessions/<quote(cwd)>/<uuid>/``. Used to decide genesis vs
594
+ continue: grok's ``--resume`` REQUIRES a pre-existing session (unlike claude
595
+ ``--session-id`` which MINTS one), so passing a swarph-minted UUID on genesis
596
+ errors "Session does not exist". Detecting a prior session lets us pass
597
+ ``--continue`` (resume most-recent for cwd) only when it's safe.
598
+ """
599
+ enc = quote(str(cell.cwd), safe="")
600
+ sdir = cell.cwd / _GROK_CELL_HOME_SUBDIR / ".grok" / "sessions" / enc
601
+ try:
602
+ return sdir.is_dir() and any(p.is_dir() for p in sdir.iterdir())
603
+ except OSError:
604
+ return False
545
605
 
546
606
 
547
607
  def _build_grok_argv(
548
608
  cell: Cell,
549
- session_id: Optional[str],
550
- effective_role: Optional[str],
609
+ no_starter: bool,
551
610
  passthrough: list[str],
552
611
  ) -> list[str]:
553
- """``grok --cwd <cwd> [--resume <id>] [--agent <role>] --always-approve``.
554
-
555
- Pinned-session (R5): ``--resume <session_id>`` attaches grok's native
556
- cwd-keyed session (``~/.grok/sessions/<encoded-cwd>/<uuid>``) so the cell
557
- keeps its continuity + identity + inbox across respawns the load-bearing
558
- reason grok ``uses_pinned_session`` (not fresh-per-spawn). ``--agent`` gives
559
- the cell its role identity. ``--always-approve`` is the cell autonomy
560
- posture (a mesh cell runs unattended the spawn-equivalent of codex ``-a``
561
- / agy ``--sandbox``); opt out via ``extra.always_approve: false``.
562
-
563
- No starter flag: grok's interactive mode has no system-prompt-override (the
564
- real flags ``--prompt-file`` / ``-p`` are single-turn/headless), and the
565
- cell's identity is carried by ``--agent`` + grok's own durable memory, so a
566
- swarph starter is neither needed nor expressible here.
612
+ """Build the ``grok`` cell argv. Grounded against real grok 0.2.54.
613
+
614
+ - ``--cwd <cwd>`` keys grok's cwd-scoped session + memory to the cell dir.
615
+ - ``--continue`` (resume most-recent session for the cwd) ONLY when a prior
616
+ grok session exists. grok mints + owns its own session UUIDs and
617
+ ``--resume`` REQUIRES an existing one, so on genesis we pass NO session
618
+ flag and let grok mint (a swarph-minted UUID would error "Session does not
619
+ exist"). grok's own durable cross-session memory carries identity either
620
+ way.
621
+ - ``--system-prompt-override <starter>`` carries the cell's swarph starter /
622
+ identity when one is configured (the real flag; NOT ``--agent``, which is
623
+ grok's agent-PROFILE selector an unknown swarph role name is silently
624
+ ignored, giving the cell no identity).
625
+ - ``--sandbox <profile>`` confines the cell (default ``workspace``); the
626
+ ONLY sibling that is both auto-approve AND unconfined is a security
627
+ escalation, and ``--sandbox`` is an independent axis from approval. Set
628
+ top-level ``sandbox: off`` in cell.yaml to disable.
629
+ - ``--always-approve`` (default on) is the unattended-cell autonomy posture;
630
+ opt out via top-level ``always_approve: false`` in cell.yaml.
567
631
  """
568
632
  argv = ["grok", "--cwd", str(cell.cwd)]
569
- if session_id:
570
- argv.extend(["--resume", session_id])
571
- role_value = effective_role if effective_role is not None else cell.role
572
- if role_value:
573
- argv.extend(["--agent", role_value])
633
+
634
+ if _grok_session_exists(cell):
635
+ argv.append("--continue")
636
+
637
+ if not no_starter and cell.starter_prompt_path:
638
+ starter = read_starter_prompt(cell)
639
+ if starter:
640
+ argv.extend(["--system-prompt-override", starter])
641
+
642
+ # cell.sandbox is the canonical field (codex uses it too); grok built-in
643
+ # profiles are off/workspace/devbox/read-only/strict. Default workspace
644
+ # (keeps mesh network; strict/read-only restrict it on Landlock V4+ kernels).
645
+ sandbox = getattr(cell, "sandbox", None)
646
+ if sandbox is None:
647
+ sandbox = _GROK_DEFAULT_SANDBOX
648
+ if sandbox and str(sandbox).lower() not in ("off", "false", "none"):
649
+ argv.extend(["--sandbox", str(sandbox)])
650
+
574
651
  if cell.extra.get("always_approve", True) is not False:
575
652
  argv.append("--always-approve")
653
+
576
654
  argv.extend(passthrough)
577
655
  return argv
578
656
 
@@ -837,6 +915,54 @@ def _tmux_has_session(tmux: str, name: str) -> bool:
837
915
  return result.returncode == 0
838
916
 
839
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
+
840
966
  def _launch_via_tmux(
841
967
  binary: str, argv: list[str], cwd: Path, session_name: str,
842
968
  ) -> bool:
@@ -901,24 +1027,15 @@ def _launch_via_tmux(
901
1027
  interactive = sys.stdout.isatty()
902
1028
 
903
1029
  if not _tmux_has_session(tmux, session_name):
904
- # Create the durable session detached. The command re-enters spawn so
905
- # the membrane env-scrub + starter injection apply inside the pane; the
906
- # inherited $TMUX (and SWARPH_SPAWN below) stop it from re-deciding.
907
- try:
908
- subprocess.run(
909
- [
910
- tmux, "new-session", "-d", "-s", session_name,
911
- "-c", str(cwd), "-e", "SWARPH_SPAWN=1",
912
- "swarph", "spawn", session_name,
913
- ],
914
- check=True,
915
- stdout=subprocess.DEVNULL,
916
- stderr=subprocess.PIPE,
917
- )
918
- 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):
919
1036
  print(
920
- f"swarph spawn: tmux session create failed ({exc}); falling "
921
- "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.",
922
1039
  file=sys.stderr,
923
1040
  )
924
1041
  return False
@@ -1219,18 +1336,18 @@ class GrokMembrane(ProviderMembrane):
1219
1336
  claude cell) so it is durable + supervisable and lands in its OWN session,
1220
1337
  never a window of another cell's session.
1221
1338
 
1222
- Pinned-session (``uses_pinned_session`` True): grok exposes a native
1223
- cwd-keyed session model under ``~/.grok/sessions/`` and a ``--resume <id>``
1224
- verb, so the cell pins its session UUID via the R5 store (like claude) and
1225
- ``--resume``\\s it on every respawn preserving the cell's continuity,
1226
- identity, and mesh inbox. (Continuity is the whole point of a persistent
1227
- cell; a fresh session each spawn would orphan grok's running identity.)
1339
+ Session model (``uses_pinned_session`` False): grok mints and OWNS its own
1340
+ cwd-keyed session UUIDs (``$HOME/.grok/sessions/<enc-cwd>/<uuid>``) and its
1341
+ ``--resume`` REQUIRES a pre-existing one unlike claude ``--session-id``,
1342
+ which mints. So the cell does NOT carry a swarph-pinned UUID; it relies on
1343
+ grok's own ``--continue`` (resume most-recent for the cwd) + grok's durable
1344
+ cross-session memory for continuity, and lets grok mint on genesis.
1228
1345
  """
1229
1346
 
1230
1347
  name = "grok"
1231
1348
 
1232
1349
  def uses_pinned_session(self) -> bool:
1233
- return True
1350
+ return False
1234
1351
 
1235
1352
  def build_argv(
1236
1353
  self,
@@ -1241,7 +1358,7 @@ class GrokMembrane(ProviderMembrane):
1241
1358
  passthrough: list[str],
1242
1359
  effective_role: Optional[str],
1243
1360
  ) -> list[str]:
1244
- return _build_grok_argv(cell, session_id, effective_role, passthrough)
1361
+ return _build_grok_argv(cell, no_starter, passthrough)
1245
1362
 
1246
1363
  def resolve_binary(self) -> Optional[str]:
1247
1364
  provider_bin = shutil.which("grok")
@@ -1278,11 +1395,23 @@ class GrokMembrane(ProviderMembrane):
1278
1395
  except OSError as exc:
1279
1396
  print(f"swarph spawn: cannot chdir to {cell.cwd}: {exc}", file=sys.stderr)
1280
1397
  return 1
1281
- # execve carries the isolated-HOME + billing-scrubbed env to grok
1282
- # without mutating this process's os.environ first (a failed exec leaves
1283
- # us intact).
1398
+ # Isolated-HOME + billing-scrubbed env carried to grok without mutating
1399
+ # this process's os.environ first (a failed exec leaves us intact).
1400
+ env = _grok_env(cell)
1401
+ # Per-OS launch — the SAME split as claude.launch (v0.12.1 fix): on
1402
+ # Windows os.exec* is emulated as spawn-and-exit (not a real replace),
1403
+ # which collapses the tmux pane (its root command exits, orphaning grok);
1404
+ # a BLOCKING subprocess.run keeps THIS process as the pane root with grok
1405
+ # as its child until grok exits. POSIX uses execve for a true in-place
1406
+ # replace so the pane keeps running grok under the same PID.
1407
+ if sys.platform == "win32":
1408
+ try:
1409
+ return subprocess.run([binary, *argv[1:]], env=env).returncode
1410
+ except OSError as exc:
1411
+ print(f"swarph spawn: launch failed: {exc}", file=sys.stderr)
1412
+ return 1
1284
1413
  try:
1285
- os.execve(binary, argv, _grok_env(cell))
1414
+ os.execve(binary, argv, env)
1286
1415
  except OSError as exc:
1287
1416
  print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
1288
1417
  return 1
@@ -1417,7 +1546,11 @@ def run_spawn(argv: Optional[list[str]] = None) -> int:
1417
1546
  file=sys.stderr,
1418
1547
  )
1419
1548
 
1420
- elif cell.provider == "antigravity":
1549
+ elif cell.provider in ("antigravity", "grok"):
1550
+ # Fresh-session providers (no swarph-pinned UUID): grok mints + owns its
1551
+ # own session ids (continuity via --continue + grok memory). Still want
1552
+ # the operator-typed slot-role as effective_role for the named tmux
1553
+ # session + future sibling slots.
1421
1554
  session_id = "(fresh-session-per-spawn, no pinned id)"
1422
1555
  was_generated = True
1423
1556
  sidecar_role = requested_role if requested_role else cell.role
@@ -1490,6 +1623,14 @@ def run_spawn(argv: Optional[list[str]] = None) -> int:
1490
1623
  inject_text = f"Your active task is in CURRENT_TASK.md — read it first:\n\n{current_task_text}"
1491
1624
  if cell.provider == "claude":
1492
1625
  spawn_argv.extend(["--append-system-prompt", inject_text])
1626
+ elif cell.provider == "grok":
1627
+ # --rules (extra rules appended to the system prompt), NOT a
1628
+ # second --system-prompt-override: grok's clap REJECTS a
1629
+ # repeated flag, and _build_grok_argv may already have emitted
1630
+ # --system-prompt-override for the starter → a second one
1631
+ # would refuse to launch. (claude concatenates repeated
1632
+ # --append-system-prompt; grok rejects-on-repeat — the delta.)
1633
+ spawn_argv.extend(["--rules", inject_text])
1493
1634
  elif cell.provider == "antigravity":
1494
1635
  spawn_argv.extend(["--prompt-interactive", inject_text])
1495
1636
  elif cell.provider == "codex":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.13.0
3
+ Version: 0.13.2
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