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.
- {swarph_cli-0.9.4/src/swarph_cli.egg-info → swarph_cli-0.9.5}/PKG-INFO +2 -2
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/pyproject.toml +2 -2
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/spawn.py +111 -15
- {swarph_cli-0.9.4 → swarph_cli-0.9.5/src/swarph_cli.egg-info}/PKG-INFO +2 -2
- swarph_cli-0.9.5/tests/test_spawn_windows_relaunch.py +168 -0
- swarph_cli-0.9.4/tests/test_spawn_windows_relaunch.py +0 -129
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/LICENSE +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/README.md +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/setup.cfg +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/init.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/commands/watchdog.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/requires.txt +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_cell_loader.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_import_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_init_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_main.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.9.4 → swarph_cli-0.9.5}/tests/test_spawn_command.py +0 -0
- {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
|
-
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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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.
|
|
493
|
-
|
|
494
|
-
True (the caller should exit this console).
|
|
495
|
-
|
|
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
|
|
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
|
-
*
|
|
506
|
-
— the TUI works there
|
|
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
|
-
|
|
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 (
|
|
649
|
-
# TUI works, so no warning
|
|
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
|
|
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
|
-
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|