swarph-cli 0.9.2__tar.gz → 0.9.4__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 (54) hide show
  1. {swarph_cli-0.9.2/src/swarph_cli.egg-info → swarph_cli-0.9.4}/PKG-INFO +2 -2
  2. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/pyproject.toml +2 -2
  3. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/cell.py +60 -8
  5. swarph_cli-0.9.4/src/swarph_cli/commands/init.py +237 -0
  6. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/memory_sync.py +9 -4
  7. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/mesh.py +22 -7
  8. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/spawn.py +9 -1
  9. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/main.py +1 -0
  10. {swarph_cli-0.9.2 → swarph_cli-0.9.4/src/swarph_cli.egg-info}/PKG-INFO +2 -2
  11. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli.egg-info/SOURCES.txt +2 -0
  12. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_cell_loader.py +103 -4
  13. swarph_cli-0.9.4/tests/test_init_command.py +83 -0
  14. swarph_cli-0.9.4/tests/test_memory_sync.py +92 -0
  15. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_mesh_command.py +21 -0
  16. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_mesh_sidecar.py +41 -2
  17. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_spawn_command.py +45 -3
  18. swarph_cli-0.9.2/tests/test_memory_sync.py +0 -57
  19. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/LICENSE +0 -0
  20. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/README.md +0 -0
  21. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/setup.cfg +0 -0
  22. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/caller.py +0 -0
  23. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/__init__.py +0 -0
  24. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/chat.py +0 -0
  25. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/daemon.py +0 -0
  26. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/hook_output.py +0 -0
  27. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/import_session.py +0 -0
  28. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/install_hook.py +0 -0
  29. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/onboard.py +0 -0
  30. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/ratify.py +0 -0
  31. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/commands/watchdog.py +0 -0
  32. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/parsers/__init__.py +0 -0
  33. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/parsers/claude.py +0 -0
  34. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  35. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  36. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  37. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  38. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  39. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli.egg-info/requires.txt +0 -0
  40. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/src/swarph_cli.egg-info/top_level.txt +0 -0
  41. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_chat_command.py +0 -0
  42. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_claude_parser.py +0 -0
  43. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_daemon_command.py +0 -0
  44. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_hook_output.py +0 -0
  45. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_import_command.py +0 -0
  46. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_install_hook.py +0 -0
  47. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_main.py +0 -0
  48. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_onboard_command.py +0 -0
  49. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_ratify_command.py +0 -0
  50. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_smoke_chat.py +0 -0
  51. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_smoke_one_shot.py +0 -0
  52. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_smoke_phase_5_5.py +0 -0
  53. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_spawn_windows_relaunch.py +0 -0
  54. {swarph_cli-0.9.2 → swarph_cli-0.9.4}/tests/test_watchdog.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.9.2
4
- Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider via a ProviderMembrane), cell.yaml, `swarph mesh` (send/inbox/register with per-peer tokens) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.2 makes the watchdog deploy-safe: no false A2 when no tmux server is reachable (root-cron), and F3 uses window/session activity so active sessions are correctly detected.
3
+ Version: 0.9.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.4: fixes session-pin resume across a changed cwd.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.9.2"
8
- description = "The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider via a ProviderMembrane), cell.yaml, `swarph mesh` (send/inbox/register with per-peer tokens) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.2 makes the watchdog deploy-safe: no false A2 when no tmux server is reachable (root-cron), and F3 uses window/session activity so active sessions are correctly detected."
7
+ version = "0.9.4"
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.4: fixes session-pin resume across a changed cwd."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.9.2"
19
+ __version__ = "0.9.4"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -252,6 +252,42 @@ def load_cell(path: Path) -> Cell:
252
252
  return cell
253
253
 
254
254
 
255
+ def _write_session_sidecar(target: Path, session_id: str, cwd) -> None:
256
+ """Write the two-line ``{uuid, cwd}`` session sidecar atomically.
257
+
258
+ v0.9.4 (session-pin cwd-mismatch fix): the sidecar records the cwd
259
+ the session UUID was minted for so a later spawn from a DIFFERENT
260
+ cwd can detect the mismatch and re-pin a fresh UUID rather than
261
+ handing a project-scoped UUID to ``claude --resume`` from the wrong
262
+ project (which dies with "No conversation found with session ID").
263
+
264
+ Format (trailing-newline convention preserved):
265
+
266
+ <uuid>\\n
267
+ <cwd>\\n
268
+ """
269
+ target.parent.mkdir(parents=True, exist_ok=True)
270
+ _atomic_write_text(target, f"{session_id}\n{str(cwd)}\n")
271
+
272
+
273
+ def _read_session_sidecar(path: Path) -> tuple[Optional[str], Optional[str]]:
274
+ """Parse a session sidecar → ``(uuid, cwd)``.
275
+
276
+ Returns ``(None, None)`` for a missing/empty file. ``cwd`` is
277
+ ``None`` for a **legacy one-line** sidecar (bare UUID, pre-0.9.4)
278
+ — we cannot know which project that UUID belongs to.
279
+ """
280
+ if not path.exists():
281
+ return None, None
282
+ raw = path.read_text(encoding="utf-8")
283
+ lines = raw.splitlines()
284
+ if not lines:
285
+ return None, None
286
+ uuid_str = lines[0].strip() or None
287
+ cwd_str = lines[1].strip() if len(lines) >= 2 and lines[1].strip() else None
288
+ return uuid_str, cwd_str
289
+
290
+
255
291
  def load_or_create_session_id(
256
292
  role: str,
257
293
  cell: Cell,
@@ -266,6 +302,10 @@ def load_or_create_session_id(
266
302
  2. ``new_instance=True`` AND base sidecar exists — auto-suffix slot
267
303
  3. ``new_instance=True`` AND no base sidecar — fall through (degenerate)
268
304
  4. session_state_path(role) reused — default re-resume path
305
+ (v0.9.4: only when the sidecar's recorded cwd matches cell.cwd;
306
+ on cwd-mismatch a fresh UUID is minted + re-pinned for the new
307
+ cwd, because ``claude --resume`` is PROJECT-scoped and a pin
308
+ from another cwd dies with "No conversation found")
269
309
  5. mint new uuid4 + persist
270
310
 
271
311
  Caller-side discipline (mother iter-1 #986): the ``effective_role``
@@ -282,23 +322,33 @@ def load_or_create_session_id(
282
322
  sibling_role = next_free_slot_role(role)
283
323
  sibling_state = session_state_path(sibling_role)
284
324
  new_id = str(uuid.uuid4())
285
- sibling_state.parent.mkdir(parents=True, exist_ok=True)
286
- _atomic_write_text(sibling_state, new_id + "\n")
325
+ _write_session_sidecar(sibling_state, new_id, cell.cwd)
287
326
  return new_id, True, sibling_role
288
327
 
289
328
  state_file = session_state_path(role)
290
329
  if state_file.exists():
291
- existing = state_file.read_text(encoding="utf-8").strip()
292
- if existing:
330
+ existing_uuid, recorded_cwd = _read_session_sidecar(state_file)
331
+ if existing_uuid:
293
332
  try:
294
- return validate_uuid_str(existing), False, role
333
+ valid_uuid = validate_uuid_str(existing_uuid)
295
334
  except CellError:
296
335
  # Corrupted state — fall through and regenerate.
297
- pass
336
+ valid_uuid = None
337
+ if valid_uuid is not None:
338
+ if recorded_cwd is not None and recorded_cwd != str(cell.cwd):
339
+ # v0.9.4: pin belongs to a different project. A
340
+ # project-scoped --resume would die from this cwd, so
341
+ # mint a fresh (clean) UUID + re-pin for the new cwd.
342
+ fresh = str(uuid.uuid4())
343
+ _write_session_sidecar(state_file, fresh, cell.cwd)
344
+ return fresh, True, role
345
+ # Legacy (recorded_cwd is None) → reuse as-is; do NOT
346
+ # rewrite, since the UUID's true origin cwd is unknown.
347
+ # Matching cwd → reuse.
348
+ return valid_uuid, False, role
298
349
 
299
350
  new_id = str(uuid.uuid4())
300
- state_file.parent.mkdir(parents=True, exist_ok=True)
301
- _atomic_write_text(state_file, new_id + "\n")
351
+ _write_session_sidecar(state_file, new_id, cell.cwd)
302
352
  return new_id, True, role
303
353
 
304
354
 
@@ -352,6 +402,8 @@ __all__ = [
352
402
  "read_starter_prompt",
353
403
  "load_cell",
354
404
  "load_or_create_session_id",
405
+ "_read_session_sidecar",
406
+ "_write_session_sidecar",
355
407
  # Backward-compat aliases
356
408
  "_PEER_NAME_RE",
357
409
  "_VALID_SCHEMA_VERSIONS",
@@ -0,0 +1,237 @@
1
+ """``swarph init`` — interactive install wizard that scaffolds a validated
2
+ cell.yaml at the named registry path (``~/.config/swarph/cells/<name>.yaml``).
3
+
4
+ The default UX is a guided setup: name → LLM type → role/cwd/tmux → assisted
5
+ memory (y/n) → register (y/n) → confirm + write. Flags override any prompt, so
6
+ a fully-flagged invocation runs non-interactively (scripting / CI).
7
+
8
+ The cell dict is validated through ``swarph_shared.cell.parse_cell_dict`` BEFORE
9
+ writing — init never emits a cell.yaml that wouldn't parse. See
10
+ ``docs/superpowers/specs/2026-06-02-swarph-init-design.md``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import copy
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import yaml
22
+
23
+ from swarph_shared.cell import PEER_NAME_RE, VALID_PROVIDERS, parse_cell_dict
24
+ from swarph_cli.cell import CellError, cells_dir
25
+
26
+ _DEFAULT_GATEWAY = os.environ.get("MESH_GATEWAY_URL", "http://lab-ovh:8788")
27
+ _CODEX_SANDBOX_DEFAULT = "workspace-write"
28
+ _CODEX_SANDBOX_VALUES = ("workspace-write", "read-only")
29
+
30
+ # LLM type → (provider, blurb). The menu the wizard shows.
31
+ _LLM_CHOICES = [
32
+ ("claude", "Anthropic Claude — claude membrane"),
33
+ ("codex", "OpenAI / GPT — codex membrane (AGENTS.md)"),
34
+ ("antigravity", "Google Gemini — agy membrane (firejail)"),
35
+ ]
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Prompt helpers (interactive). All no-op when a flag already supplied the value.
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def _ask(prompt: str, default: Optional[str] = None) -> str:
43
+ suffix = f" [{default}]" if default else ""
44
+ try:
45
+ ans = input(f"{prompt}{suffix}: ").strip()
46
+ except EOFError:
47
+ ans = ""
48
+ return ans or (default or "")
49
+
50
+
51
+ def _ask_yn(prompt: str, default: bool = False) -> bool:
52
+ d = "Y/n" if default else "y/N"
53
+ try:
54
+ ans = input(f"{prompt} [{d}]: ").strip().lower()
55
+ except EOFError:
56
+ ans = ""
57
+ if not ans:
58
+ return default
59
+ return ans in ("y", "yes")
60
+
61
+
62
+ def _ask_llm() -> str:
63
+ print("\nSelect LLM type:")
64
+ for i, (prov, blurb) in enumerate(_LLM_CHOICES, 1):
65
+ print(f" {i}) {prov:12} {blurb}")
66
+ while True:
67
+ try:
68
+ ans = input("Choice [1-3]: ").strip()
69
+ except EOFError:
70
+ ans = "1"
71
+ if ans.isdigit() and 1 <= int(ans) <= len(_LLM_CHOICES):
72
+ return _LLM_CHOICES[int(ans) - 1][0]
73
+ if ans in VALID_PROVIDERS:
74
+ return ans
75
+ print(" (enter 1, 2, or 3)")
76
+
77
+
78
+ def _https_normalize(repo: str) -> tuple[str, bool]:
79
+ """Rewrite an SSH GitHub URL to HTTPS (this box auths via gh/HTTPS, not a
80
+ deploy key). Returns (url, rewritten)."""
81
+ if repo.startswith("git@github.com:"):
82
+ return "https://github.com/" + repo[len("git@github.com:"):], True
83
+ return repo, False
84
+
85
+
86
+ def _build_parser() -> argparse.ArgumentParser:
87
+ p = argparse.ArgumentParser(
88
+ prog="swarph init",
89
+ description="Interactive wizard: scaffold a cell.yaml at "
90
+ "~/.config/swarph/cells/<name>.yaml. Flags override prompts.",
91
+ )
92
+ p.add_argument("name", nargs="?", default=None,
93
+ help="kebab-case mesh peer name (prompted if omitted)")
94
+ p.add_argument("--provider", choices=sorted(VALID_PROVIDERS), default=None,
95
+ help="spawn provider/membrane (prompted if omitted)")
96
+ p.add_argument("--role", default=None)
97
+ p.add_argument("--cwd", default=None)
98
+ p.add_argument("--tmux", default=None)
99
+ p.add_argument("--cursor", default=None)
100
+ p.add_argument("--sandbox", default=None)
101
+ p.add_argument("--gateway", default=_DEFAULT_GATEWAY)
102
+ p.add_argument("--assisted-memory", dest="assisted_memory", default=None, metavar="REPO",
103
+ help="enable git-backed memory at REPO (SSH→HTTPS normalized)")
104
+ p.add_argument("--starter", default=None)
105
+ p.add_argument("--symlink-cwd", dest="symlink_cwd", action="store_true")
106
+ p.add_argument("--force", action="store_true")
107
+ p.add_argument("--non-interactive", "-y", dest="non_interactive", action="store_true",
108
+ help="never prompt; require all values via flags")
109
+ return p
110
+
111
+
112
+ def run_init(argv: list[str]) -> int:
113
+ args = _build_parser().parse_args(argv)
114
+ # Interactive unless explicitly disabled or stdin isn't a TTY.
115
+ interactive = (not args.non_interactive) and sys.stdin.isatty()
116
+
117
+ # ── name ──
118
+ name = args.name
119
+ if not name and interactive:
120
+ name = _ask("Cell / peer name (kebab-case)")
121
+ if not name:
122
+ print("swarph init: name required (positional or prompt)", file=sys.stderr)
123
+ return 2
124
+ if not PEER_NAME_RE.match(name):
125
+ print(f"swarph init: name {name!r} must match {PEER_NAME_RE.pattern} "
126
+ f"(kebab-case, no underscores)", file=sys.stderr)
127
+ return 2
128
+
129
+ # ── LLM type / provider ──
130
+ provider = args.provider
131
+ if not provider and interactive:
132
+ provider = _ask_llm()
133
+ if not provider:
134
+ print("swarph init: --provider required (or run interactively)", file=sys.stderr)
135
+ return 2
136
+
137
+ # ── role / cwd / tmux / cursor ──
138
+ role = args.role or (_ask("Role", name) if interactive else name)
139
+ cwd_raw = args.cwd or (_ask("Working dir (cwd)", str(Path.cwd())) if interactive else str(Path.cwd()))
140
+ cwd = Path(cwd_raw).expanduser().resolve()
141
+ tmux = args.tmux or (_ask("tmux session", name) if interactive else name)
142
+ cursor = args.cursor or f"/tmp/{name}-cursor.json"
143
+
144
+ # ── sandbox ──
145
+ if provider == "codex":
146
+ sandbox = args.sandbox or _CODEX_SANDBOX_DEFAULT
147
+ if sandbox not in _CODEX_SANDBOX_VALUES:
148
+ print(f"swarph init: codex --sandbox must be one of {_CODEX_SANDBOX_VALUES}, "
149
+ f"got {sandbox!r}", file=sys.stderr)
150
+ return 2
151
+ else:
152
+ sandbox = args.sandbox # antigravity default-on if None; claude ignores
153
+
154
+ cell: dict = {
155
+ "schema_version": "v1",
156
+ "name": name,
157
+ "role": role,
158
+ "provider": provider,
159
+ "cwd": str(cwd),
160
+ "tmux_session": tmux,
161
+ "cursor_path": cursor,
162
+ "mesh": {"gateway": args.gateway},
163
+ }
164
+ if sandbox is not None:
165
+ cell["sandbox"] = sandbox
166
+ if args.starter:
167
+ cell["starter_prompt_path"] = args.starter
168
+
169
+ # ── assisted memory (y/n) ──
170
+ am_repo = args.assisted_memory
171
+ if am_repo is None and interactive and _ask_yn("Use assisted memory (git-backed durable memory)?", False):
172
+ am_repo = _ask(" Memory git repo URL")
173
+ am_note = ""
174
+ if am_repo:
175
+ repo, rewritten = _https_normalize(am_repo)
176
+ cell["assisted_memory"] = {"enabled": True, "repo": repo, "interval_min": 15}
177
+ if rewritten:
178
+ am_note = (f" (repo normalized SSH→HTTPS: {repo}; ensure gh/HTTPS "
179
+ f"creds can reach it — a private repo needing SSH will fail otherwise)")
180
+
181
+ # ── validate BEFORE writing (parse_cell_dict mutates → deepcopy) ──
182
+ try:
183
+ parse_cell_dict(copy.deepcopy(cell))
184
+ except CellError as exc:
185
+ print(f"swarph init: refusing to write an invalid cell.yaml: {exc}", file=sys.stderr)
186
+ return 2
187
+
188
+ dest = cells_dir() / f"{name}.yaml"
189
+ if dest.exists() and not args.force:
190
+ if interactive and _ask_yn(f"{dest} exists — overwrite?", False):
191
+ pass
192
+ else:
193
+ print(f"swarph init: {dest} already exists (use --force)", file=sys.stderr)
194
+ return 2
195
+ dest.parent.mkdir(parents=True, exist_ok=True)
196
+ dest.write_text(yaml.safe_dump(cell, sort_keys=False), encoding="utf-8")
197
+ print(f"swarph init: wrote {dest}{am_note}")
198
+
199
+ # ── optional cwd symlink ──
200
+ want_symlink = args.symlink_cwd or (interactive and _ask_yn(
201
+ f"Symlink {cwd}/cell.yaml → registry (so `swarph spawn` works from that dir too)?", False))
202
+ if want_symlink:
203
+ _symlink_cwd(cwd, dest, args.force)
204
+
205
+ # Resolved summary (echo the derived cwd/tmux/cursor — those were the snags).
206
+ print(" resolved:")
207
+ print(f" provider={provider} role={role} sandbox={sandbox if sandbox is not None else '(default)'}")
208
+ print(f" cwd={cwd}")
209
+ print(f" tmux_session={cell['tmux_session']} cursor_path={cell['cursor_path']}")
210
+ if "assisted_memory" in cell:
211
+ print(f" assisted_memory.repo={cell['assisted_memory']['repo']} (enabled)")
212
+
213
+ # Registration is NOT done here (R1 token mint-once is a once-only secret —
214
+ # init scaffolding from the operator's context shouldn't capture another
215
+ # cell's token; the cell self-adopts from ITS OWN context per the adoption
216
+ # doc, which is the forge-clean path). Deferred follow-up: a careful
217
+ # single-process register that captures→mode-600→verifies the raw token.
218
+ print(f"\nready: swarph spawn {name}")
219
+ print(f"next: the cell self-registers + adopts its per-peer token from its "
220
+ f"OWN context (see SWARPH_PEER_TOKEN_ADOPTION.md); then `swarph ratify {name}`.")
221
+ return 0
222
+
223
+
224
+ def _symlink_cwd(cwd: Path, dest: Path, force: bool) -> None:
225
+ link = cwd / "cell.yaml"
226
+ try:
227
+ cwd.mkdir(parents=True, exist_ok=True)
228
+ if link.is_symlink() or link.exists():
229
+ if force:
230
+ link.unlink()
231
+ else:
232
+ print(f"swarph init: {link} exists; skip symlink (use --force)", file=sys.stderr)
233
+ return
234
+ link.symlink_to(dest)
235
+ print(f"swarph init: symlinked {link} → {dest}")
236
+ except OSError as exc:
237
+ print(f"swarph init: symlink failed (non-fatal): {exc}", file=sys.stderr)
@@ -39,7 +39,7 @@ def _get_files_to_sync(cell: Cell) -> list[tuple[str, Path]]:
39
39
  if (cell.cwd / "AGENTS.md").exists():
40
40
  files_to_sync.append(("AGENTS.md", cell.cwd / "AGENTS.md"))
41
41
 
42
- elif cell.provider in ("antigravity", "gemini"):
42
+ elif cell.provider == "antigravity":
43
43
  if (cell.cwd / "GEMINI.md").exists():
44
44
  files_to_sync.append(("GEMINI.md", cell.cwd / "GEMINI.md"))
45
45
  if (cell.cwd / "inbox-cursor.json").exists():
@@ -94,6 +94,7 @@ def perform_restore(cell: Cell) -> Optional[str]:
94
94
  subprocess.run(["git", "-C", str(repo_dir), "pull", "--ff-only", "origin", "main"], check=True, capture_output=True)
95
95
  except subprocess.CalledProcessError as exc:
96
96
  print(f"swarph: memory pull --ff-only failed (diverged/offline?): {exc}", file=sys.stderr)
97
+ return None
97
98
 
98
99
  # Walk the repo dir and copy everything back, EXCEPT .git and .gitignore
99
100
  for root, dirs, files in os.walk(repo_dir):
@@ -104,6 +105,10 @@ def perform_restore(cell: Cell) -> Optional[str]:
104
105
  continue
105
106
 
106
107
  src = Path(root) / f
108
+ if f in ("CLAUDE.md", "AGENTS.md", "GEMINI.md") and src.stat().st_size == 0:
109
+ print(f"swarph: SAFETY: remote {f} is empty. Refusing to restore and clobber local.", file=sys.stderr)
110
+ continue
111
+
107
112
  rel = src.relative_to(repo_dir)
108
113
 
109
114
  dest = None
@@ -115,9 +120,9 @@ def perform_restore(cell: Cell) -> Optional[str]:
115
120
  dest = Path.home() / ".claude" / rel
116
121
  elif cell.provider == "claude" and rel.parts[0] == "inbox-cursor":
117
122
  dest = Path.home() / ".claude" / "inbox-cursor"
118
- elif cell.provider in ("antigravity", "gemini") and rel.parts[0] == "tmp":
123
+ elif cell.provider == "antigravity" and rel.parts[0] == "tmp":
119
124
  dest = Path.home() / ".gemini" / rel
120
- elif cell.provider in ("antigravity", "gemini") and rel.parts[0] == "history":
125
+ elif cell.provider == "antigravity" and rel.parts[0] == "history":
121
126
  dest = Path.home() / ".gemini" / rel
122
127
 
123
128
  if dest:
@@ -168,7 +173,7 @@ def run_memory_sync(argv: list[str]) -> int:
168
173
  guard_file = cell.cwd / "CLAUDE.md"
169
174
  elif cell.provider == "codex":
170
175
  guard_file = cell.cwd / "AGENTS.md"
171
- elif cell.provider in ("antigravity", "gemini"):
176
+ elif cell.provider == "antigravity":
172
177
  guard_file = cell.cwd / "GEMINI.md"
173
178
 
174
179
  if guard_file and (not guard_file.exists() or guard_file.stat().st_size == 0):
@@ -241,7 +241,8 @@ def _run_send(args: argparse.Namespace) -> int:
241
241
  token,
242
242
  )
243
243
  if status < 200 or status >= 300:
244
- print(f"swarph mesh send: gateway {status}: {payload.get('detail', payload)}", file=sys.stderr)
244
+ detail = payload.get("detail", "<gateway error>")
245
+ print(f"swarph mesh send: gateway {status}: {detail}", file=sys.stderr)
245
246
  return 1
246
247
  print(
247
248
  f"sent id={payload.get('id')} from={payload.get('from_node')} "
@@ -259,7 +260,8 @@ def _run_inbox(args: argparse.Namespace) -> int:
259
260
  url = f"{args.gateway.rstrip('/')}/messages?{urllib.parse.urlencode(params)}"
260
261
  status, payload = _http_get_json(url, token)
261
262
  if status < 200 or status >= 300:
262
- print(f"swarph mesh inbox: gateway {status}: {payload.get('detail', payload)}", file=sys.stderr)
263
+ detail = payload.get("detail", "<gateway error>")
264
+ print(f"swarph mesh inbox: gateway {status}: {detail}", file=sys.stderr)
263
265
  return 1
264
266
  if args.json:
265
267
  print(json.dumps(payload, indent=2, sort_keys=True))
@@ -304,7 +306,8 @@ def _run_register(args: argparse.Namespace) -> int:
304
306
  token,
305
307
  )
306
308
  if status < 200 or status >= 300:
307
- print(f"swarph mesh register: gateway {status}: {payload.get('detail', payload)}", file=sys.stderr)
309
+ detail = payload.get("detail", "<gateway error>")
310
+ print(f"swarph mesh register: gateway {status}: {detail}", file=sys.stderr)
308
311
  return 1
309
312
  peer_token = payload.get("peer_token")
310
313
  token_status = payload.get("token_status")
@@ -329,7 +332,20 @@ def _default_sidecar_state_dir(self_name: str) -> Path:
329
332
  def _read_cursor(path: Path) -> dict:
330
333
  if not path.exists():
331
334
  return {"last_msg_id": 0, "last_wake_at": 0.0}
332
- return json.loads(path.read_text(encoding="utf-8"))
335
+ try:
336
+ cursor = json.loads(path.read_text(encoding="utf-8"))
337
+ except (OSError, json.JSONDecodeError) as exc:
338
+ print(
339
+ f"[mesh-sidecar] ignoring unreadable cursor {path}: {exc}",
340
+ file=sys.stderr,
341
+ flush=True,
342
+ )
343
+ return {"last_msg_id": 0, "last_wake_at": 0.0}
344
+ if not isinstance(cursor, dict):
345
+ return {"last_msg_id": 0, "last_wake_at": 0.0}
346
+ cursor.setdefault("last_msg_id", 0)
347
+ cursor.setdefault("last_wake_at", 0.0)
348
+ return cursor
333
349
 
334
350
 
335
351
  def _write_cursor_atomic(path: Path, cursor: dict) -> None:
@@ -447,12 +463,11 @@ def _sidecar_iteration(state: MeshSidecarState) -> None:
447
463
  if _tmux_wake(state.tmux_target):
448
464
  state.cursor["last_wake_at"] = now
449
465
  state.wakes_sent += 1
466
+ state.cursor["last_msg_id"] = new_last_id
467
+ _write_cursor_atomic(state.cursor_path, state.cursor)
450
468
  else:
451
469
  print("[mesh-sidecar] wake suppressed by idle guard", flush=True)
452
470
 
453
- state.cursor["last_msg_id"] = new_last_id
454
- _write_cursor_atomic(state.cursor_path, state.cursor)
455
-
456
471
 
457
472
  def _run_sidecar(args: argparse.Namespace) -> int:
458
473
  state_dir_arg = Path(args.state_dir).expanduser() if args.state_dir else None
@@ -941,8 +941,16 @@ def run_spawn(argv: Optional[list[str]] = None) -> int:
941
941
  inject_text = f"Your active task is in CURRENT_TASK.md — read it first:\n\n{current_task_text}"
942
942
  if cell.provider == "claude":
943
943
  spawn_argv.extend(["--append-system-prompt", inject_text])
944
- elif cell.provider in ("codex", "antigravity"):
944
+ elif cell.provider == "antigravity":
945
945
  spawn_argv.extend(["--prompt-interactive", inject_text])
946
+ elif cell.provider == "codex":
947
+ agents_md = cell.cwd / "AGENTS.md"
948
+ if agents_md.exists():
949
+ content = agents_md.read_text(encoding="utf-8")
950
+ if "CURRENT_TASK.md" not in content:
951
+ agents_md.write_text(inject_text + "\n\n" + content, encoding="utf-8")
952
+ else:
953
+ agents_md.write_text(inject_text, encoding="utf-8")
946
954
  except Exception as exc:
947
955
  print(f"swarph spawn: restore failed: {exc}", file=sys.stderr)
948
956
 
@@ -72,6 +72,7 @@ Spec: https://github.com/darw007d/hedge-fund-mcp/blob/main/research/swarph_cli/P
72
72
  # only for disambiguation against one-shot prompts (rare).
73
73
  _VERB_HANDLERS: dict[str, str] = {
74
74
  # verb keyword: dotted-path to handler function (lazy-imported)
75
+ "init": "swarph_cli.commands.init.run_init",
75
76
  "import": "swarph_cli.commands.import_session.run_import",
76
77
  "chat": "swarph_cli.commands.chat.run_chat",
77
78
  "onboard": "swarph_cli.commands.onboard.run_onboard",
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.9.2
4
- Summary: The `swarph` binary — multi-LLM CLI + mesh-gateway integration: multi-provider spawn (claude/codex/antigravity per cell.provider via a ProviderMembrane), cell.yaml, `swarph mesh` (send/inbox/register with per-peer tokens) + inbox sidecar, `assisted_memory` (git-backed durable memory), session import, watchdog. v0.9.2 makes the watchdog deploy-safe: no false A2 when no tmux server is reachable (root-cron), and F3 uses window/session activity so active sessions are correctly detected.
3
+ Version: 0.9.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.4: fixes session-pin resume across a changed cwd.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -16,6 +16,7 @@ src/swarph_cli/commands/chat.py
16
16
  src/swarph_cli/commands/daemon.py
17
17
  src/swarph_cli/commands/hook_output.py
18
18
  src/swarph_cli/commands/import_session.py
19
+ src/swarph_cli/commands/init.py
19
20
  src/swarph_cli/commands/install_hook.py
20
21
  src/swarph_cli/commands/memory_sync.py
21
22
  src/swarph_cli/commands/mesh.py
@@ -34,6 +35,7 @@ tests/test_claude_parser.py
34
35
  tests/test_daemon_command.py
35
36
  tests/test_hook_output.py
36
37
  tests/test_import_command.py
38
+ tests/test_init_command.py
37
39
  tests/test_install_hook.py
38
40
  tests/test_main.py
39
41
  tests/test_memory_sync.py
@@ -22,6 +22,8 @@ from swarph_cli.cell import (
22
22
  load_or_create_session_id,
23
23
  resolve_cell_path,
24
24
  session_state_path,
25
+ _read_session_sidecar,
26
+ _write_session_sidecar,
25
27
  )
26
28
 
27
29
 
@@ -264,7 +266,9 @@ def test_load_or_create_session_id_mints_and_persists(
264
266
  uuid.UUID(sid1) # raises if not a valid UUID
265
267
  sidecar = session_state_path("lab")
266
268
  assert sidecar.exists()
267
- assert sidecar.read_text().strip() == sid1
269
+ rec_uuid, rec_cwd = _read_session_sidecar(sidecar)
270
+ assert rec_uuid == sid1
271
+ assert rec_cwd == str(cell.cwd)
268
272
 
269
273
 
270
274
  def test_load_or_create_session_id_reuses_persisted_value(
@@ -287,7 +291,102 @@ def test_load_or_create_session_id_corrupted_sidecar_regenerates(
287
291
  sid, generated, _role = load_or_create_session_id("lab", cell)
288
292
  uuid.UUID(sid)
289
293
  assert generated is True
290
- assert sidecar.read_text().strip() == sid
294
+ rec_uuid, _rec_cwd = _read_session_sidecar(sidecar)
295
+ assert rec_uuid == sid
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # v0.9.4 — session-pin cwd-mismatch fix (project-scoped --resume)
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ def test_session_sidecar_fresh_mint_writes_two_line_uuid_and_cwd(
304
+ isolated_xdg, cell_yaml_factory, tmp_path
305
+ ):
306
+ """v0.9.4 — first mint records BOTH the uuid AND the cwd (cwd=A)."""
307
+ cell = load_cell(cell_yaml_factory()) # cwd == tmp_path (== A)
308
+ sid, gen, _role = load_or_create_session_id("lab", cell)
309
+ assert gen is True
310
+ uuid.UUID(sid)
311
+ rec_uuid, rec_cwd = _read_session_sidecar(session_state_path("lab"))
312
+ assert rec_uuid == sid
313
+ assert rec_cwd == str(tmp_path)
314
+ assert rec_cwd == str(cell.cwd)
315
+
316
+
317
+ def test_session_sidecar_same_cwd_reuses_same_uuid(
318
+ isolated_xdg, cell_yaml_factory, tmp_path
319
+ ):
320
+ """v0.9.4 — pre-pinned {uuid=U, cwd=A}; spawn from cwd=A reuses U."""
321
+ cell = load_cell(cell_yaml_factory()) # cwd == tmp_path (== A)
322
+ fixed = "550e8400-e29b-41d4-a716-446655440000"
323
+ _write_session_sidecar(session_state_path("lab"), fixed, str(tmp_path))
324
+ sid, gen, role = load_or_create_session_id("lab", cell)
325
+ assert sid == fixed
326
+ assert gen is False
327
+ assert role == "lab"
328
+
329
+
330
+ def test_session_sidecar_different_cwd_mints_fresh_and_repins(
331
+ isolated_xdg, cell_yaml_factory, tmp_path
332
+ ):
333
+ """v0.9.4 REGRESSION — the bug: pin minted for cwd=A, spawn from cwd=B.
334
+
335
+ A project-scoped ``claude --resume <U>`` from B dies with
336
+ "No conversation found". Fix: mint a FRESH (clean) uuid and re-pin
337
+ it for cwd=B.
338
+ """
339
+ cwd_b = tmp_path / "project_b"
340
+ cwd_b.mkdir()
341
+ cell = load_cell(cell_yaml_factory(cwd=str(cwd_b))) # cell.cwd == B
342
+ fixed = "550e8400-e29b-41d4-a716-446655440000" # pinned for cwd=A
343
+ _write_session_sidecar(session_state_path("lab"), fixed, str(tmp_path))
344
+
345
+ sid, gen, role = load_or_create_session_id("lab", cell)
346
+ assert gen is True
347
+ assert sid != fixed # genuine fresh UUID
348
+ uuid.UUID(sid)
349
+ assert role == "lab"
350
+ # Sidecar now re-pinned for cwd=B with the fresh UUID.
351
+ rec_uuid, rec_cwd = _read_session_sidecar(session_state_path("lab"))
352
+ assert rec_uuid == sid
353
+ assert rec_cwd == str(cwd_b)
354
+
355
+
356
+ def test_session_sidecar_legacy_one_line_is_reused_not_reminted(
357
+ isolated_xdg, cell_yaml_factory, tmp_path
358
+ ):
359
+ """v0.9.4 back-compat — legacy bare-uuid (no cwd) sidecar is reused
360
+ as-is regardless of cwd (origin cwd unknown; do NOT re-mint)."""
361
+ cell = load_cell(cell_yaml_factory())
362
+ fixed = "550e8400-e29b-41d4-a716-446655440000"
363
+ sidecar = session_state_path("lab")
364
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
365
+ sidecar.write_text(fixed + "\n") # legacy one-line format
366
+ sid, gen, role = load_or_create_session_id("lab", cell)
367
+ assert sid == fixed
368
+ assert gen is False
369
+ assert role == "lab"
370
+ # Not upgraded/rewritten — stays legacy one-line.
371
+ assert sidecar.read_text() == fixed + "\n"
372
+
373
+
374
+ def test_read_session_sidecar_parses_both_formats(tmp_path):
375
+ """v0.9.4 — _read_session_sidecar handles 2-line AND legacy 1-line."""
376
+ two_line = tmp_path / "two.session-id"
377
+ two_line.write_text("550e8400-e29b-41d4-a716-446655440000\n/home/x/proj\n")
378
+ assert _read_session_sidecar(two_line) == (
379
+ "550e8400-e29b-41d4-a716-446655440000",
380
+ "/home/x/proj",
381
+ )
382
+ one_line = tmp_path / "one.session-id"
383
+ one_line.write_text("550e8400-e29b-41d4-a716-446655440000\n")
384
+ assert _read_session_sidecar(one_line) == (
385
+ "550e8400-e29b-41d4-a716-446655440000",
386
+ None,
387
+ )
388
+ missing = tmp_path / "missing.session-id"
389
+ assert _read_session_sidecar(missing) == (None, None)
291
390
 
292
391
 
293
392
  def test_load_or_create_session_id_atomic_no_tempfile_left_behind(
@@ -435,8 +534,8 @@ def test_new_instance_with_existing_base_mints_into_slot_2(
435
534
  base_path = session_state_path("drop")
436
535
  sib_path = session_state_path("drop-2")
437
536
  assert base_path.exists() and sib_path.exists()
438
- assert base_path.read_text().strip() == sid_base
439
- assert sib_path.read_text().strip() == sid_sib
537
+ assert _read_session_sidecar(base_path)[0] == sid_base
538
+ assert _read_session_sidecar(sib_path)[0] == sid_sib
440
539
 
441
540
 
442
541
  def test_new_instance_third_call_lands_at_slot_3(
@@ -0,0 +1,83 @@
1
+ """Tests for `swarph init` (cell.yaml scaffolder). Non-interactive (-y / flag)
2
+ path + helpers; cells_dir() isolated via XDG_CONFIG_HOME."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from swarph_cli.commands.init import run_init, _https_normalize
10
+ from swarph_cli.cell import cells_dir, load_cell
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def isolated_cells(tmp_path, monkeypatch):
15
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
16
+ return tmp_path
17
+
18
+
19
+ def _run(*argv):
20
+ return run_init(list(argv))
21
+
22
+
23
+ def test_https_normalize():
24
+ assert _https_normalize("git@github.com:darw007d/x.git") == ("https://github.com/darw007d/x.git", True)
25
+ assert _https_normalize("https://github.com/darw007d/x.git") == ("https://github.com/darw007d/x.git", False)
26
+
27
+
28
+ def test_init_writes_validated_cell(tmp_path):
29
+ rc = _run("gpt-test", "--provider", "codex", "--cwd", str(tmp_path), "-y")
30
+ assert rc == 0
31
+ dest = cells_dir() / "gpt-test.yaml"
32
+ assert dest.exists()
33
+ c = load_cell(dest)
34
+ assert c.name == "gpt-test" and c.provider == "codex"
35
+ assert c.role == "gpt-test" # default = name
36
+ assert c.sandbox == "workspace-write" # codex default
37
+ assert c.extra["tmux_session"] == "gpt-test" # default = name
38
+ assert c.extra["cursor_path"] == "/tmp/gpt-test-cursor.json"
39
+
40
+
41
+ def test_init_rejects_underscore_name(tmp_path):
42
+ assert _run("bad_name", "--provider", "codex", "--cwd", str(tmp_path), "-y") == 2
43
+ assert not (cells_dir() / "bad_name.yaml").exists()
44
+
45
+
46
+ def test_init_requires_provider_when_noninteractive(tmp_path):
47
+ assert _run("c1", "--cwd", str(tmp_path), "-y") == 2
48
+
49
+
50
+ def test_init_refuses_existing_without_force(tmp_path):
51
+ assert _run("dup", "--provider", "codex", "--cwd", str(tmp_path), "-y") == 0
52
+ assert _run("dup", "--provider", "codex", "--cwd", str(tmp_path), "-y") == 2 # exists
53
+ assert _run("dup", "--provider", "codex", "--cwd", str(tmp_path), "-y", "--force") == 0
54
+
55
+
56
+ def test_init_assisted_memory_https_normalized(tmp_path):
57
+ rc = _run("mem", "--provider", "antigravity", "--cwd", str(tmp_path), "-y",
58
+ "--assisted-memory", "git@github.com:darw007d/mem.git")
59
+ assert rc == 0
60
+ c = load_cell(cells_dir() / "mem.yaml")
61
+ assert c.assisted_memory["enabled"] is True
62
+ assert c.assisted_memory["repo"] == "https://github.com/darw007d/mem.git"
63
+ assert c.assisted_memory["interval_min"] == 15
64
+
65
+
66
+ def test_init_antigravity_omits_sandbox(tmp_path):
67
+ rc = _run("agy-cell", "--provider", "antigravity", "--cwd", str(tmp_path), "-y")
68
+ assert rc == 0
69
+ c = load_cell(cells_dir() / "agy-cell.yaml")
70
+ assert c.sandbox is None # default-on, not written
71
+
72
+
73
+ def test_init_codex_bad_sandbox_rejected(tmp_path):
74
+ assert _run("c", "--provider", "codex", "--sandbox", "bogus", "--cwd", str(tmp_path), "-y") == 2
75
+
76
+
77
+ def test_init_symlink_cwd(tmp_path):
78
+ cwd = tmp_path / "cellwd"
79
+ rc = _run("linked", "--provider", "codex", "--cwd", str(cwd), "-y", "--symlink-cwd")
80
+ assert rc == 0
81
+ link = cwd / "cell.yaml"
82
+ assert link.is_symlink()
83
+ assert link.resolve() == (cells_dir() / "linked.yaml").resolve()
@@ -0,0 +1,92 @@
1
+ import pytest
2
+ import subprocess
3
+ from unittest.mock import patch, MagicMock
4
+ from pathlib import Path
5
+ from swarph_cli.commands.memory_sync import run_memory_sync, perform_restore
6
+ from swarph_cli.cell import Cell
7
+
8
+ def test_run_memory_sync_no_op_when_not_enabled(tmp_path):
9
+ cell = Cell(
10
+ name="test",
11
+ role="test",
12
+ cwd=tmp_path,
13
+ schema_version="v1",
14
+ session_id=None,
15
+ starter_prompt_path=None,
16
+ provider="claude",
17
+ sandbox=None,
18
+ lineage=None,
19
+ source_path=None,
20
+ assisted_memory={"enabled": False, "interval_min": 15},
21
+ extra={},
22
+ )
23
+ with patch("swarph_cli.commands.memory_sync.load_cell", return_value=cell):
24
+ assert run_memory_sync(["fake.yaml"]) == 0
25
+
26
+ def test_perform_restore_returns_none_when_not_enabled(tmp_path):
27
+ cell = Cell(
28
+ name="test",
29
+ role="test",
30
+ cwd=tmp_path,
31
+ schema_version="v1",
32
+ session_id=None,
33
+ starter_prompt_path=None,
34
+ provider="claude",
35
+ sandbox=None,
36
+ lineage=None,
37
+ source_path=None,
38
+ assisted_memory={"enabled": False, "interval_min": 15},
39
+ extra={},
40
+ )
41
+ assert perform_restore(cell) is None
42
+
43
+ def test_perform_restore_absent(tmp_path):
44
+ cell = Cell(
45
+ name="test",
46
+ role="test",
47
+ cwd=tmp_path,
48
+ schema_version="v1",
49
+ session_id=None,
50
+ starter_prompt_path=None,
51
+ provider="claude",
52
+ sandbox=None,
53
+ lineage=None,
54
+ source_path=None,
55
+ assisted_memory=None,
56
+ extra={},
57
+ )
58
+ assert perform_restore(cell) is None
59
+
60
+ def test_perform_restore_returns_none_on_pull_failure(tmp_path):
61
+ cell = Cell(
62
+ name="test", role="test", cwd=tmp_path, schema_version="v1", session_id=None,
63
+ starter_prompt_path=None, provider="claude", sandbox=None, lineage=None,
64
+ source_path=None, assisted_memory={"enabled": True, "repo": "test/repo"}, extra={},
65
+ )
66
+ with patch("swarph_cli.commands.memory_sync.get_memory_repo_path", return_value=tmp_path):
67
+ with patch("swarph_cli.commands.memory_sync._clone_if_missing", return_value=True):
68
+ with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git pull")):
69
+ assert perform_restore(cell) is None
70
+
71
+ def test_perform_restore_empty_guard(tmp_path):
72
+ repo_dir = tmp_path / "repo"
73
+ repo_dir.mkdir()
74
+ (repo_dir / "CLAUDE.md").write_text("")
75
+ (repo_dir / "CURRENT_TASK.md").write_text("Hello")
76
+
77
+ cell_dir = tmp_path / "cell"
78
+ cell_dir.mkdir()
79
+ (cell_dir / "CLAUDE.md").write_text("Old contents")
80
+
81
+ cell = Cell(
82
+ name="test", role="test", cwd=cell_dir, schema_version="v1", session_id=None,
83
+ starter_prompt_path=None, provider="claude", sandbox=None, lineage=None,
84
+ source_path=None, assisted_memory={"enabled": True, "repo": "test/repo"}, extra={},
85
+ )
86
+ with patch("swarph_cli.commands.memory_sync.get_memory_repo_path", return_value=repo_dir):
87
+ with patch("swarph_cli.commands.memory_sync._clone_if_missing", return_value=True):
88
+ with patch("subprocess.run"):
89
+ res = perform_restore(cell)
90
+ assert res == "Hello"
91
+ assert (cell_dir / "CLAUDE.md").read_text() == "Old contents"
92
+ assert (cell_dir / "CURRENT_TASK.md").read_text() == "Hello"
@@ -68,6 +68,27 @@ def test_mesh_send_gateway_error_is_nonsecret(monkeypatch, capsys):
68
68
  assert "secret-token" not in err
69
69
 
70
70
 
71
+ def test_mesh_gateway_error_without_detail_does_not_echo_payload(monkeypatch, capsys):
72
+ monkeypatch.setenv("SWARPH_SELF", "gpt-ops")
73
+ monkeypatch.setenv("MESH_GATEWAY_TOKEN", "secret-token")
74
+ monkeypatch.setattr(
75
+ mesh,
76
+ "_post_json",
77
+ lambda url, body, token, *, timeout=10.0: (
78
+ 403,
79
+ {"authorization": "secret-token", "body": {"peer_token": "minted-secret"}},
80
+ ),
81
+ )
82
+
83
+ rc = mesh.run_mesh(["send", "lab-ovh", "--kind", "answer", "--content", "x"])
84
+
85
+ assert rc == 1
86
+ err = capsys.readouterr().err
87
+ assert "<gateway error>" in err
88
+ assert "secret-token" not in err
89
+ assert "minted-secret" not in err
90
+
91
+
71
92
  def test_mesh_inbox_uses_to_query_and_unread(monkeypatch, capsys):
72
93
  monkeypatch.setenv("SWARPH_SELF", "gpt-ops")
73
94
  monkeypatch.setenv("MESH_GATEWAY_TOKEN", "tok")
@@ -116,7 +116,7 @@ def test_sidecar_filters_old_and_self_messages(tmp_path, monkeypatch):
116
116
  assert json.loads(log_lines[0])["id"] == 12
117
117
 
118
118
 
119
- def test_sidecar_idle_guard_suppresses_repeated_wake_but_advances_cursor(
119
+ def test_sidecar_idle_guard_suppresses_wake_without_advancing_cursor(
120
120
  tmp_path, monkeypatch, capsys
121
121
  ):
122
122
  state = _state(tmp_path)
@@ -132,12 +132,51 @@ def test_sidecar_idle_guard_suppresses_repeated_wake_but_advances_cursor(
132
132
  monkeypatch.setattr(mesh, "_tmux_wake", lambda target: (_ for _ in ()).throw(AssertionError("guarded")))
133
133
  monkeypatch.setattr(mesh.time, "time", lambda: 1000.0)
134
134
  mesh._sidecar_iteration(state)
135
- assert state.cursor["last_msg_id"] == 1
135
+ assert state.cursor["last_msg_id"] == 0
136
136
  assert state.cursor["last_wake_at"] == 995.0
137
137
  assert state.wakes_sent == 0
138
+ assert not (tmp_path / "cursor.json").exists()
138
139
  assert "wake suppressed" in capsys.readouterr().out
139
140
 
140
141
 
142
+ def test_sidecar_wakes_throttled_message_after_guard_window(tmp_path, monkeypatch):
143
+ state = _state(tmp_path)
144
+ state.cursor["last_wake_at"] = 995.0
145
+ now = 1000.0
146
+
147
+ monkeypatch.setattr(
148
+ mesh,
149
+ "_http_get_json",
150
+ lambda url, token, *, timeout=10.0: (
151
+ 200,
152
+ {"messages": [{"id": 1, "from_node": "lab-ovh", "content": "new"}]},
153
+ ),
154
+ )
155
+ wakes = []
156
+ monkeypatch.setattr(mesh, "_tmux_wake", lambda target: wakes.append(target) or True)
157
+ monkeypatch.setattr(mesh.time, "time", lambda: now)
158
+
159
+ mesh._sidecar_iteration(state)
160
+ assert wakes == []
161
+ assert state.cursor["last_msg_id"] == 0
162
+
163
+ now = 1060.0
164
+ mesh._sidecar_iteration(state)
165
+ assert wakes == ["gpt-ops-pane"]
166
+ assert state.cursor["last_msg_id"] == 1
167
+ assert state.cursor["last_wake_at"] == 1060.0
168
+
169
+
170
+ def test_sidecar_corrupt_cursor_defaults_without_crashing(tmp_path, capsys):
171
+ (tmp_path / "cursor.json").write_text("{", encoding="utf-8")
172
+
173
+ state = _state(tmp_path)
174
+
175
+ assert state.cursor["last_msg_id"] == 0
176
+ assert state.cursor["last_wake_at"] == 0.0
177
+ assert "ignoring unreadable cursor" in capsys.readouterr().err
178
+
179
+
141
180
  def test_sidecar_network_failure_records_disconnect(tmp_path, monkeypatch):
142
181
  state = _state(tmp_path)
143
182
  monkeypatch.setattr(
@@ -459,7 +459,7 @@ def test_run_spawn_new_instance_with_base_mints_sibling_slot(
459
459
  ):
460
460
  """v0.7 PR-B — `--new-instance` with base sidecar present allocates
461
461
  slot 2 AND persists. Sibling resumable via `swarph spawn <role>-2`."""
462
- from swarph_cli.cell import session_state_path
462
+ from swarph_cli.cell import session_state_path, _read_session_sidecar
463
463
 
464
464
  # First spawn establishes base slot
465
465
  run_spawn(argv=[str(fake_cell_yaml), "--dry-run", "--print-id"])
@@ -474,8 +474,8 @@ def test_run_spawn_new_instance_with_base_mints_sibling_slot(
474
474
 
475
475
  base_sidecar = session_state_path("lab-test")
476
476
  sibling_sidecar = session_state_path("lab-test-2")
477
- assert base_sidecar.read_text().strip() == base_uuid
478
- assert sibling_sidecar.read_text().strip() == sibling_uuid
477
+ assert _read_session_sidecar(base_sidecar)[0] == base_uuid
478
+ assert _read_session_sidecar(sibling_sidecar)[0] == sibling_uuid
479
479
 
480
480
  # Dry-run output shows the sibling slot label
481
481
  assert "lab-test-2" in captured.err
@@ -970,3 +970,45 @@ def test_run_spawn_antigravity_dry_run(tmp_path, isolated_xdg, capsys):
970
970
  captured = capsys.readouterr()
971
971
  assert "agy --sandbox --add-dir" in captured.out
972
972
  assert "provider: antigravity" in captured.err
973
+
974
+ def test_run_spawn_codex_assisted_memory_injects_agents_md(tmp_path, isolated_xdg, capsys, monkeypatch):
975
+ from swarph_cli.commands.spawn import run_spawn
976
+ import yaml
977
+
978
+ payload = {
979
+ "schema_version": "v1",
980
+ "name": "codex-test",
981
+ "role": "codex-test",
982
+ "cwd": str(tmp_path),
983
+ "provider": "codex",
984
+ "assisted_memory": {
985
+ "enabled": True,
986
+ "repo": "test-repo"
987
+ }
988
+ }
989
+ p = tmp_path / "cell.yaml"
990
+ p.write_text(yaml.safe_dump(payload), encoding="utf-8")
991
+
992
+ import os
993
+ os.chdir(tmp_path)
994
+
995
+ import swarph_cli.commands.memory_sync
996
+ monkeypatch.setattr(swarph_cli.commands.memory_sync, "perform_restore", lambda c: "Restore Task text")
997
+ monkeypatch.setattr("shutil.which", lambda name: "/bin/fake-codex")
998
+
999
+ exec_args = []
1000
+ def fake_execve(path, argv, env):
1001
+ exec_args.append((path, argv, env))
1002
+ monkeypatch.setattr("os.execve", fake_execve)
1003
+
1004
+ run_spawn([])
1005
+
1006
+ agents_md = tmp_path / "AGENTS.md"
1007
+ assert agents_md.exists()
1008
+ content = agents_md.read_text(encoding="utf-8")
1009
+ assert "Your active task is in CURRENT_TASK.md" in content
1010
+ assert "Restore Task text" in content
1011
+
1012
+ assert len(exec_args) == 1
1013
+ argv = exec_args[0][1]
1014
+ assert "--prompt-interactive" not in argv
@@ -1,57 +0,0 @@
1
- import pytest
2
- from unittest.mock import patch, MagicMock
3
- from pathlib import Path
4
- from swarph_cli.commands.memory_sync import run_memory_sync, perform_restore
5
- from swarph_cli.cell import Cell
6
-
7
- def test_run_memory_sync_no_op_when_not_enabled(tmp_path):
8
- cell = Cell(
9
- name="test",
10
- role="test",
11
- cwd=tmp_path,
12
- schema_version="v1",
13
- session_id=None,
14
- starter_prompt_path=None,
15
- provider="claude",
16
- sandbox=None,
17
- lineage=None,
18
- source_path=None,
19
- assisted_memory={"enabled": False, "interval_min": 15},
20
- extra={},
21
- )
22
- with patch("swarph_cli.commands.memory_sync.load_cell", return_value=cell):
23
- assert run_memory_sync(["fake.yaml"]) == 0
24
-
25
- def test_perform_restore_returns_none_when_not_enabled(tmp_path):
26
- cell = Cell(
27
- name="test",
28
- role="test",
29
- cwd=tmp_path,
30
- schema_version="v1",
31
- session_id=None,
32
- starter_prompt_path=None,
33
- provider="claude",
34
- sandbox=None,
35
- lineage=None,
36
- source_path=None,
37
- assisted_memory={"enabled": False, "interval_min": 15},
38
- extra={},
39
- )
40
- assert perform_restore(cell) is None
41
-
42
- def test_perform_restore_absent(tmp_path):
43
- cell = Cell(
44
- name="test",
45
- role="test",
46
- cwd=tmp_path,
47
- schema_version="v1",
48
- session_id=None,
49
- starter_prompt_path=None,
50
- provider="claude",
51
- sandbox=None,
52
- lineage=None,
53
- source_path=None,
54
- assisted_memory=None,
55
- extra={},
56
- )
57
- assert perform_restore(cell) is None
File without changes
File without changes
File without changes