swarph-cli 0.9.4__tar.gz → 0.9.5__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.4/src/swarph_cli.egg-info → swarph_cli-0.9.5}/PKG-INFO +2 -2
  2. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/pyproject.toml +2 -2
  3. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/__init__.py +1 -1
  4. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/spawn.py +111 -15
  5. {swarph_cli-0.9.4 → swarph_cli-0.9.5/src/swarph_cli.egg-info}/PKG-INFO +2 -2
  6. swarph_cli-0.9.5/tests/test_spawn_windows_relaunch.py +168 -0
  7. swarph_cli-0.9.4/tests/test_spawn_windows_relaunch.py +0 -129
  8. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/LICENSE +0 -0
  9. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/README.md +0 -0
  10. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/setup.cfg +0 -0
  11. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/caller.py +0 -0
  12. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/cell.py +0 -0
  13. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/__init__.py +0 -0
  14. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/chat.py +0 -0
  15. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/daemon.py +0 -0
  16. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/hook_output.py +0 -0
  17. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/import_session.py +0 -0
  18. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/init.py +0 -0
  19. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/install_hook.py +0 -0
  20. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/memory_sync.py +0 -0
  21. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/mesh.py +0 -0
  22. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/onboard.py +0 -0
  23. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/ratify.py +0 -0
  24. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/watchdog.py +0 -0
  25. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/main.py +0 -0
  26. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/parsers/__init__.py +0 -0
  27. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/parsers/claude.py +0 -0
  28. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
  29. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
  30. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
  31. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
  32. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  33. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  34. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/requires.txt +0 -0
  35. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/top_level.txt +0 -0
  36. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_cell_loader.py +0 -0
  37. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_chat_command.py +0 -0
  38. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_claude_parser.py +0 -0
  39. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_daemon_command.py +0 -0
  40. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_hook_output.py +0 -0
  41. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_import_command.py +0 -0
  42. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_init_command.py +0 -0
  43. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_install_hook.py +0 -0
  44. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_main.py +0 -0
  45. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_memory_sync.py +0 -0
  46. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_mesh_command.py +0 -0
  47. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_mesh_sidecar.py +0 -0
  48. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_onboard_command.py +0 -0
  49. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_ratify_command.py +0 -0
  50. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_smoke_chat.py +0 -0
  51. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_smoke_one_shot.py +0 -0
  52. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_smoke_phase_5_5.py +0 -0
  53. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_spawn_command.py +0 -0
  54. {swarph_cli-0.9.4 → swarph_cli-0.9.5}/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.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.
3
+ Version: 0.9.5
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
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.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."
7
+ version = "0.9.5"
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" }
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.4"
19
+ __version__ = "0.9.5"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -481,6 +481,92 @@ def _print_dry_run(
481
481
  print(" ".join(redacted))
482
482
 
483
483
 
484
+ def _console_is_genuine_wt() -> bool:
485
+ """True ONLY if the controlling terminal is positively a real Windows Terminal.
486
+
487
+ Confirms a genuine Windows Terminal by walking the parent-process chain (via the
488
+ Win32 toolhelp snapshot API) and checking whether any ancestor process executable
489
+ is ``WindowsTerminal.exe`` (case-insensitive). This is GROUND TRUTH, unlike the
490
+ ``WT_SESSION`` environment variable — which is inherited into child ``conhost``
491
+ consoles on corporate setups, and is set whenever a shell is launched from WT at
492
+ all, so it cannot distinguish "this console is a real WT" from "an ancestor once
493
+ was, but I'm now in a broken conhost".
494
+
495
+ Returns False on any non-win32 platform (the whole relaunch is win32-gated). The
496
+ ENTIRE Win32 body is wrapped in ``try/except Exception: return False`` so any
497
+ error — missing API, weird process state, access denied — fails SAFE toward
498
+ "not confirmed", which makes the caller RELAUNCH (the foolproof direction).
499
+
500
+ This helper is the ONLY piece that touches ctypes/Win32 and cannot be exercised
501
+ on a non-Windows box; the decision logic around it is fully unit-tested via mocks.
502
+ """
503
+ if sys.platform != "win32":
504
+ return False
505
+ try:
506
+ import ctypes
507
+ from ctypes import wintypes
508
+
509
+ TH32CS_SNAPPROCESS = 0x00000002
510
+ MAX_PATH = 260
511
+
512
+ class PROCESSENTRY32(ctypes.Structure):
513
+ _fields_ = [
514
+ ("dwSize", wintypes.DWORD),
515
+ ("cntUsage", wintypes.DWORD),
516
+ ("th32ProcessID", wintypes.DWORD),
517
+ ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)),
518
+ ("th32ModuleID", wintypes.DWORD),
519
+ ("cntThreads", wintypes.DWORD),
520
+ ("th32ParentProcessID", wintypes.DWORD),
521
+ ("pcPriClassBase", ctypes.c_long),
522
+ ("dwFlags", wintypes.DWORD),
523
+ ("szExeFile", ctypes.c_char * MAX_PATH),
524
+ ]
525
+
526
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
527
+ snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
528
+ INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
529
+ if not snapshot or snapshot == INVALID_HANDLE_VALUE:
530
+ return False
531
+ try:
532
+ # Build {pid: (ppid, exe_name_lower)} for every live process.
533
+ procs: dict[int, tuple[int, str]] = {}
534
+ entry = PROCESSENTRY32()
535
+ entry.dwSize = ctypes.sizeof(PROCESSENTRY32)
536
+ ok = kernel32.Process32First(snapshot, ctypes.byref(entry))
537
+ while ok:
538
+ try:
539
+ exe = entry.szExeFile.decode("utf-8", "replace").lower()
540
+ except Exception:
541
+ exe = ""
542
+ procs[int(entry.th32ProcessID)] = (
543
+ int(entry.th32ParentProcessID),
544
+ exe,
545
+ )
546
+ ok = kernel32.Process32Next(snapshot, ctypes.byref(entry))
547
+ finally:
548
+ kernel32.CloseHandle(snapshot)
549
+
550
+ # Walk from our own pid up through ppids; bounded + cycle-guarded.
551
+ pid = os.getpid()
552
+ seen: set[int] = set()
553
+ for _ in range(12):
554
+ if pid in seen or pid == 0:
555
+ break
556
+ seen.add(pid)
557
+ info = procs.get(pid)
558
+ if info is None:
559
+ break
560
+ ppid, _exe = info
561
+ parent = procs.get(ppid)
562
+ if parent is not None and parent[1] == "windowsterminal.exe":
563
+ return True
564
+ pid = ppid
565
+ return False
566
+ except Exception:
567
+ return False
568
+
569
+
484
570
  def _relaunch_in_windows_terminal(
485
571
  claude_bin: str, claude_argv: list[str], cwd: Path,
486
572
  ) -> bool:
@@ -489,24 +575,28 @@ def _relaunch_in_windows_terminal(
489
575
  On legacy Windows console (``conhost.exe``), Claude Code's Ink TUI breaks: the
490
576
  SGR terminator ``m`` leaks from the output stream into stdin, so pressing Enter
491
577
  inserts a literal ``m`` instead of submitting (see docs/WINDOWS_KNOWN_ISSUES.md).
492
- Windows Terminal handles VT-input correctly. If we're on conhost AND ``wt.exe``
493
- is available, relaunch the ``claude`` session inside Windows Terminal and return
494
- True (the caller should exit this console). Otherwise return False (caller
495
- proceeds in-place and warns).
578
+ Windows Terminal handles VT-input correctly. The DEFAULT is to relaunch (rescue):
579
+ unless we can POSITIVELY confirm we're already in a genuine Windows Terminal, we
580
+ pop a fresh WT window and return True (the caller should exit this console).
581
+
582
+ Genuine-WT detection is via process ancestry (``_console_is_genuine_wt``), NOT the
583
+ inheritable ``WT_SESSION`` env var — which wrongly looked like "already in good WT"
584
+ on corporate conhosts and on any shell launched from WT, leaving users stuck on a
585
+ broken console with no new window (live repro 2026-06-03 on workstation-lc).
586
+
587
+ Two env overrides:
588
+ * ``SWARPH_FORCE_WT=1`` — ALWAYS relaunch, even from a genuine Windows Terminal;
589
+ * ``SWARPH_WIN_ACK=1`` — NEVER relaunch (explicit "run here anyway" opt-out).
496
590
 
497
591
  No-op (returns False) when:
498
592
  * not Windows;
499
593
  * stdout is not an interactive TTY (CI / piped / redirected) — there is no
500
594
  human console to relaunch from, and a detached WT window would be wrong;
501
595
  * we are already inside a session WE spawned (``SWARPH_SPAWN`` set) — the
502
- reliable loop-guard: a relaunched session can never re-relaunch, regardless
503
- of how ``WT_SESSION`` behaves on this box;
596
+ reliable loop-guard: a relaunched session can never re-relaunch;
504
597
  * operator opted to stay put (``SWARPH_WIN_ACK=1``);
505
- * already inside Windows Terminal (``WT_SESSION`` set) AND not force-requested
506
- — the TUI works there. NOTE: some corporate setups INHERIT ``WT_SESSION``
507
- into child ``conhost`` consoles, so this is a comfort heuristic, not ground
508
- truth. Set ``SWARPH_FORCE_WT=1`` to relaunch anyway when you know you are on
509
- a broken conhost that carries an inherited ``WT_SESSION``;
598
+ * we positively confirm a genuine Windows Terminal via ancestry AND not
599
+ force-requested — the TUI works there, no redundant window;
510
600
  * ``wt.exe`` is not installed (e.g. locked-down corporate box) — caller warns.
511
601
  """
512
602
  if sys.platform != "win32":
@@ -517,7 +607,12 @@ def _relaunch_in_windows_terminal(
517
607
  return False
518
608
  if os.environ.get("SWARPH_WIN_ACK"):
519
609
  return False
520
- if os.environ.get("WT_SESSION") and not os.environ.get("SWARPH_FORCE_WT"):
610
+ # Skip the relaunch ONLY when we can positively confirm we're already in a
611
+ # genuine Windows Terminal (TUI works there) — verified by process ancestry,
612
+ # NOT the inheritable WT_SESSION env var. Default is to relaunch (rescue):
613
+ # if we can't confirm a good WT, we pop a fresh one. SWARPH_FORCE_WT forces
614
+ # relaunch even from a genuine WT.
615
+ if not os.environ.get("SWARPH_FORCE_WT") and _console_is_genuine_wt():
521
616
  return False
522
617
  wt = shutil.which("wt")
523
618
  if not wt:
@@ -645,13 +740,14 @@ class ClaudeMembrane(ProviderMembrane):
645
740
  return 0
646
741
 
647
742
  # Still in a broken console (conhost with no wt.exe, or operator acked).
648
- # Warn unless suppressed. Inside Windows Terminal (WT_SESSION set) the
649
- # TUI works, so no warning fires there.
743
+ # Warn unless suppressed. Inside a genuine Windows Terminal (confirmed by
744
+ # ancestry, NOT the inheritable WT_SESSION) the TUI works, so no warning
745
+ # fires there.
650
746
  if (
651
747
  sys.platform == "win32"
652
748
  and not no_banner
653
749
  and not os.environ.get("SWARPH_WIN_ACK")
654
- and not os.environ.get("WT_SESSION")
750
+ and not _console_is_genuine_wt()
655
751
  ):
656
752
  print(
657
753
  "swarph spawn: WARNING — legacy Windows console (conhost) and Windows "
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
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.
3
+ Version: 0.9.5
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
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -0,0 +1,168 @@
1
+ """Tests for `swarph spawn`'s Windows-Terminal conhost auto-relaunch.
2
+
3
+ On legacy Windows console (conhost.exe), Claude Code's Ink TUI breaks — the SGR
4
+ terminator 'm' leaks into stdin so Enter inserts a literal 'm'. `_relaunch_in_
5
+ windows_terminal` auto-relaunches the session in Windows Terminal (where the TUI
6
+ works) by DEFAULT, and only stays put when it can POSITIVELY confirm a genuine
7
+ Windows Terminal via process ancestry (`_console_is_genuine_wt`) — no longer the
8
+ inheritable `WT_SESSION` env var, which fooled the old heuristic (live repro
9
+ 2026-06-03 on workstation-lc: launching from WT set WT_SESSION → relaunch wrongly
10
+ skipped → no new window).
11
+
12
+ The ACTUAL wt.exe relaunch + the ctypes ancestry walk need a real native-Windows
13
+ box; these tests pin the DECISION logic (relaunch vs warn vs proceed) by mocking
14
+ `_console_is_genuine_wt` directly, so they run on any platform.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from unittest.mock import MagicMock
20
+
21
+ from swarph_cli.commands import spawn
22
+
23
+ CLAUDE_BIN = "/usr/bin/claude"
24
+ CLAUDE_ARGV = ["claude", "--name", "lab", "--session-id", "abc"]
25
+ CWD = Path("/home/ubuntu/lab")
26
+ WT = "C:\\Windows\\wt.exe"
27
+
28
+
29
+ class _FakeStdout:
30
+ """Minimal stand-in so tests can control sys.stdout.isatty() — the helper
31
+ only ever calls .isatty() on stdout (its prints go to stderr)."""
32
+
33
+ def __init__(self, tty):
34
+ self._tty = tty
35
+
36
+ def isatty(self):
37
+ return self._tty
38
+
39
+
40
+ def _call(monkeypatch, *, platform="win32", genuine_wt=False, wt_session=None,
41
+ win_ack=None, spawn_marker=None, force_wt=None, isatty=True,
42
+ wt_path=WT, popen=None):
43
+ """Drive `_relaunch_in_windows_terminal` with the genuine-WT detector mocked.
44
+
45
+ `genuine_wt` patches `_console_is_genuine_wt` directly (True/False) so we test
46
+ the DECISION matrix without touching ctypes. `wt_session` is still settable in
47
+ the env to prove the new logic IGNORES it (the headline bug fix).
48
+ """
49
+ monkeypatch.setattr(spawn.sys, "platform", platform)
50
+ monkeypatch.setattr(spawn.sys, "stdout", _FakeStdout(isatty))
51
+ for var, val in (("WT_SESSION", wt_session), ("SWARPH_WIN_ACK", win_ack),
52
+ ("SWARPH_SPAWN", spawn_marker), ("SWARPH_FORCE_WT", force_wt)):
53
+ if val is None:
54
+ monkeypatch.delenv(var, raising=False)
55
+ else:
56
+ monkeypatch.setenv(var, val)
57
+ monkeypatch.setattr(spawn, "_console_is_genuine_wt", lambda: genuine_wt)
58
+ monkeypatch.setattr(spawn.shutil, "which",
59
+ lambda name: wt_path if name == "wt" else None)
60
+ pop = popen or MagicMock()
61
+ monkeypatch.setattr(spawn.subprocess, "Popen", pop)
62
+ result = spawn._relaunch_in_windows_terminal(CLAUDE_BIN, CLAUDE_ARGV, CWD)
63
+ return result, pop
64
+
65
+
66
+ # --- the decision matrix -------------------------------------------------
67
+
68
+
69
+ def test_non_windows_never_relaunches(monkeypatch):
70
+ r, pop = _call(monkeypatch, platform="linux")
71
+ assert r is False
72
+ pop.assert_not_called()
73
+
74
+
75
+ def test_conhost_with_wt_relaunches_in_windows_terminal(monkeypatch):
76
+ # #1 the rescue: NOT a genuine WT (conhost) + wt.exe present => relaunch.
77
+ r, pop = _call(monkeypatch, genuine_wt=False)
78
+ assert r is True
79
+ pop.assert_called_once()
80
+ cmd = pop.call_args[0][0]
81
+ # exact shape: [wt, -d, <cwd>, --, claude_bin, *claude_flags]
82
+ assert cmd[:5] == [WT, "-d", str(CWD), "--", CLAUDE_BIN]
83
+ assert cmd[5:] == CLAUDE_ARGV[1:] # claude_argv[1:] passed through (not argv0)
84
+ # carries SWARPH_SPAWN=1 so the SessionStart hook doesn't double-inject
85
+ assert pop.call_args.kwargs["env"]["SWARPH_SPAWN"] == "1"
86
+
87
+
88
+ def test_genuine_wt_no_force_skips_relaunch(monkeypatch):
89
+ # #2 positively confirmed genuine WT (TUI works) + no force => no new window.
90
+ r, pop = _call(monkeypatch, genuine_wt=True)
91
+ assert r is False
92
+ pop.assert_not_called()
93
+
94
+
95
+ def test_inherited_wt_session_but_not_genuine_wt_relaunches(monkeypatch):
96
+ # #3 THE HEADLINE BUG FIX: WT_SESSION is set in the env (inherited into a
97
+ # broken conhost, OR set because the shell was launched from WT), but process
98
+ # ancestry says this is NOT a genuine WT. New logic IGNORES WT_SESSION and
99
+ # relaunches. The old WT_SESSION-keyed code would have wrongly SKIPPED here,
100
+ # leaving the user stuck on a broken console with no new window.
101
+ r, pop = _call(monkeypatch, genuine_wt=False, wt_session="inherited-guid")
102
+ assert r is True
103
+ pop.assert_called_once()
104
+
105
+
106
+ def test_force_wt_overrides_genuine_wt(monkeypatch):
107
+ # #4 SWARPH_FORCE_WT=1 forces the relaunch even from a confirmed genuine WT.
108
+ r, pop = _call(monkeypatch, genuine_wt=True, force_wt="1")
109
+ assert r is True
110
+ pop.assert_called_once()
111
+
112
+
113
+ def test_win_ack_opt_out_skips_even_on_conhost(monkeypatch):
114
+ # #5 SWARPH_WIN_ACK=1 => operator chose to run here; skip even on a broken
115
+ # conhost (genuine_wt=False) with wt present.
116
+ r, pop = _call(monkeypatch, genuine_wt=False, win_ack="1")
117
+ assert r is False
118
+ pop.assert_not_called()
119
+
120
+
121
+ def test_already_spawned_skips_relaunch(monkeypatch):
122
+ # #6 SWARPH_SPAWN set => inside a session we already spawned. Reliable
123
+ # loop-guard, independent of everything else — never re-relaunch.
124
+ r, pop = _call(monkeypatch, genuine_wt=False, spawn_marker="1")
125
+ assert r is False
126
+ pop.assert_not_called()
127
+
128
+
129
+ def test_non_interactive_stdout_skips_relaunch(monkeypatch):
130
+ # #7 CI / piped / redirected: no human console to relaunch from. Must NOT
131
+ # spawn a detached WT window even on conhost+wt.
132
+ r, pop = _call(monkeypatch, genuine_wt=False, isatty=False)
133
+ assert r is False
134
+ pop.assert_not_called()
135
+
136
+
137
+ def test_conhost_without_wt_falls_back_to_warning(monkeypatch):
138
+ # #8 conhost (not genuine WT) but wt.exe absent (locked-down box) => can't
139
+ # auto-fix; relaunch returns False and caller warns.
140
+ r, pop = _call(monkeypatch, genuine_wt=False, wt_path=None)
141
+ assert r is False
142
+ pop.assert_not_called()
143
+
144
+
145
+ def test_force_wt_still_loop_guarded_by_spawn_marker(monkeypatch):
146
+ # a persistent SWARPH_FORCE_WT must NOT cause infinite re-relaunch: the
147
+ # SWARPH_SPAWN marker on the relaunched tree wins over the force flag.
148
+ r, pop = _call(monkeypatch, genuine_wt=True, force_wt="1", spawn_marker="1")
149
+ assert r is False
150
+ pop.assert_not_called()
151
+
152
+
153
+ def test_popen_failure_falls_back_gracefully(monkeypatch):
154
+ # if launching wt.exe itself errors, proceed in-place (don't crash the spawn).
155
+ boom = MagicMock(side_effect=OSError("wt launch failed"))
156
+ r, _ = _call(monkeypatch, genuine_wt=False, popen=boom)
157
+ assert r is False
158
+
159
+
160
+ # --- the helper's own non-win32 guard (the one piece that can't be fully
161
+ # exercised on Linux; here we just confirm the fail-safe + no-raise) ----
162
+
163
+
164
+ def test_console_is_genuine_wt_false_off_win32(monkeypatch):
165
+ # On any non-win32 platform the helper returns False immediately and never
166
+ # touches ctypes — so it can't raise on this Linux box.
167
+ monkeypatch.setattr(spawn.sys, "platform", "linux")
168
+ assert spawn._console_is_genuine_wt() is False
@@ -1,129 +0,0 @@
1
- """Tests for `swarph spawn`'s Windows-Terminal conhost auto-relaunch.
2
-
3
- On legacy Windows console (conhost.exe), Claude Code's Ink TUI breaks — the SGR
4
- terminator 'm' leaks into stdin so Enter inserts a literal 'm'. `_relaunch_in_
5
- windows_terminal` auto-relaunches the session in Windows Terminal (where the TUI
6
- works) when on conhost + wt.exe is present. The ACTUAL wt.exe relaunch needs a real
7
- native-Windows box (workstation-lc seat-B); these tests pin the DECISION logic
8
- (relaunch vs warn vs proceed) via mocking, runnable on any platform.
9
- """
10
- from __future__ import annotations
11
-
12
- from pathlib import Path
13
- from unittest.mock import MagicMock
14
-
15
- from swarph_cli.commands import spawn
16
-
17
- CLAUDE_BIN = "/usr/bin/claude"
18
- CLAUDE_ARGV = ["claude", "--name", "lab", "--session-id", "abc"]
19
- CWD = Path("/home/ubuntu/lab")
20
- WT = "C:\\Windows\\wt.exe"
21
-
22
-
23
- class _FakeStdout:
24
- """Minimal stand-in so tests can control sys.stdout.isatty() — the helper
25
- only ever calls .isatty() on stdout (its prints go to stderr)."""
26
-
27
- def __init__(self, tty):
28
- self._tty = tty
29
-
30
- def isatty(self):
31
- return self._tty
32
-
33
-
34
- def _call(monkeypatch, *, platform="win32", wt_session=None, win_ack=None,
35
- spawn_marker=None, force_wt=None, isatty=True, wt_path=WT, popen=None):
36
- monkeypatch.setattr(spawn.sys, "platform", platform)
37
- monkeypatch.setattr(spawn.sys, "stdout", _FakeStdout(isatty))
38
- for var, val in (("WT_SESSION", wt_session), ("SWARPH_WIN_ACK", win_ack),
39
- ("SWARPH_SPAWN", spawn_marker), ("SWARPH_FORCE_WT", force_wt)):
40
- if val is None:
41
- monkeypatch.delenv(var, raising=False)
42
- else:
43
- monkeypatch.setenv(var, val)
44
- monkeypatch.setattr(spawn.shutil, "which",
45
- lambda name: wt_path if name == "wt" else None)
46
- pop = popen or MagicMock()
47
- monkeypatch.setattr(spawn.subprocess, "Popen", pop)
48
- result = spawn._relaunch_in_windows_terminal(CLAUDE_BIN, CLAUDE_ARGV, CWD)
49
- return result, pop
50
-
51
-
52
- def test_non_windows_never_relaunches(monkeypatch):
53
- r, pop = _call(monkeypatch, platform="linux")
54
- assert r is False
55
- pop.assert_not_called()
56
-
57
-
58
- def test_already_in_windows_terminal_no_relaunch(monkeypatch):
59
- # WT_SESSION set => already in Windows Terminal (TUI works); also the
60
- # loop-guard — a relaunched session can never re-relaunch.
61
- r, pop = _call(monkeypatch, wt_session="dead-beef-0001")
62
- assert r is False
63
- pop.assert_not_called()
64
-
65
-
66
- def test_operator_acked_stays_in_conhost(monkeypatch):
67
- # SWARPH_WIN_ACK=1 => operator chose conhost; don't interfere.
68
- r, pop = _call(monkeypatch, win_ack="1")
69
- assert r is False
70
- pop.assert_not_called()
71
-
72
-
73
- def test_conhost_without_wt_falls_back_to_warning(monkeypatch):
74
- # conhost but wt.exe absent (locked-down box) => can't auto-fix; caller warns.
75
- r, pop = _call(monkeypatch, wt_path=None)
76
- assert r is False
77
- pop.assert_not_called()
78
-
79
-
80
- def test_conhost_with_wt_relaunches_in_windows_terminal(monkeypatch):
81
- r, pop = _call(monkeypatch)
82
- assert r is True
83
- pop.assert_called_once()
84
- cmd = pop.call_args[0][0]
85
- # exact shape: [wt, -d, <cwd>, --, claude_bin, *claude_flags]
86
- assert cmd[:5] == [WT, "-d", str(CWD), "--", CLAUDE_BIN]
87
- assert cmd[5:] == CLAUDE_ARGV[1:] # claude_argv[1:] passed through (not argv0)
88
- # carries SWARPH_SPAWN=1 so the SessionStart hook doesn't double-inject
89
- assert pop.call_args.kwargs["env"]["SWARPH_SPAWN"] == "1"
90
-
91
-
92
- def test_popen_failure_falls_back_gracefully(monkeypatch):
93
- # if launching wt.exe itself errors, proceed in-place (don't crash the spawn).
94
- boom = MagicMock(side_effect=OSError("wt launch failed"))
95
- r, _ = _call(monkeypatch, popen=boom)
96
- assert r is False
97
-
98
-
99
- def test_non_interactive_stdout_skips_relaunch(monkeypatch):
100
- # CI / piped / redirected: no human console to relaunch from. Must NOT spawn a
101
- # detached WT window even on conhost+wt (workstation-lc's over-broad finding).
102
- r, pop = _call(monkeypatch, isatty=False)
103
- assert r is False
104
- pop.assert_not_called()
105
-
106
-
107
- def test_already_spawned_skips_relaunch(monkeypatch):
108
- # SWARPH_SPAWN set => we're inside a session we already spawned. Reliable
109
- # loop-guard, independent of WT_SESSION (which proved unreliable on inheriting
110
- # boxes) — a relaunched session can never re-relaunch.
111
- r, pop = _call(monkeypatch, spawn_marker="1")
112
- assert r is False
113
- pop.assert_not_called()
114
-
115
-
116
- def test_force_wt_overrides_inherited_wt_session(monkeypatch):
117
- # corporate boxes INHERIT WT_SESSION into child conhosts (commander hit this:
118
- # had to clear WT_SESSION by hand). SWARPH_FORCE_WT=1 relaunches anyway.
119
- r, pop = _call(monkeypatch, wt_session="inherited-guid", force_wt="1")
120
- assert r is True
121
- pop.assert_called_once()
122
-
123
-
124
- def test_force_wt_still_loop_guarded_by_spawn_marker(monkeypatch):
125
- # a persistent SWARPH_FORCE_WT must NOT cause infinite re-relaunch: the
126
- # SWARPH_SPAWN marker on the relaunched tree wins over the force flag.
127
- r, pop = _call(monkeypatch, wt_session="g", force_wt="1", spawn_marker="1")
128
- assert r is False
129
- pop.assert_not_called()
File without changes
File without changes
File without changes