swarph-cli 0.9.0__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {swarph_cli-0.9.0/src/swarph_cli.egg-info → swarph_cli-0.9.2}/PKG-INFO +3 -3
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/pyproject.toml +6 -3
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/__init__.py +1 -1
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/import_session.py +35 -9
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/spawn.py +72 -29
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/watchdog.py +82 -35
- {swarph_cli-0.9.0 → swarph_cli-0.9.2/src/swarph_cli.egg-info}/PKG-INFO +3 -3
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/requires.txt +1 -1
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_cell_loader.py +1 -1
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_import_command.py +32 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_spawn_command.py +36 -1
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_watchdog.py +109 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/LICENSE +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/README.md +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/setup.cfg +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/cell.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/hook_output.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/install_hook.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/memory_sync.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/mesh.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/main.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/systemd/swarph-watchdog.default +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/systemd/swarph-watchdog.service +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli/systemd/swarph-watchdog.timer +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/SOURCES.txt +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_hook_output.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_install_hook.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_main.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_memory_sync.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_mesh_command.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_mesh_sidecar.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_smoke_one_shot.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_smoke_phase_5_5.py +0 -0
- {swarph_cli-0.9.0 → swarph_cli-0.9.2}/tests/test_spawn_windows_relaunch.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 spawn (claude/codex/antigravity per cell.provider
|
|
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.
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/darw007d/swarph-cli
|
|
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
|
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Dist: swarph-mesh>=0.5.0
|
|
28
|
-
Requires-Dist: swarph-shared>=0.3.
|
|
28
|
+
Requires-Dist: swarph-shared>=0.3.1
|
|
29
29
|
Requires-Dist: PyYAML>=6.0
|
|
30
30
|
Provides-Extra: dev
|
|
31
31
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -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 spawn (claude/codex/antigravity per cell.provider
|
|
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."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -37,7 +37,10 @@ dependencies = [
|
|
|
37
37
|
# cell.yaml-format-home RESOLVED). swarph-cli's cell.py now
|
|
38
38
|
# re-exports data shapes from swarph_shared.cell + keeps file
|
|
39
39
|
# I/O + sidecar + slot allocation locally.
|
|
40
|
-
|
|
40
|
+
# >=0.3.1 is load-bearing: 0.3.1 is the first release whose
|
|
41
|
+
# scrub_env_for_subprocess closes the ANTHROPIC_AUTH_TOKEN/*_BASE_URL
|
|
42
|
+
# billing-redirect class that the membrane env builders delegate to.
|
|
43
|
+
"swarph-shared>=0.3.1",
|
|
41
44
|
# Phase 7 spawn — cell.yaml parser. PyYAML 6.x is the standard.
|
|
42
45
|
# Stays a swarph-cli dep since file I/O is operator-tooling-layer
|
|
43
46
|
# concern; swarph-shared cell module is pure-stdlib.
|
|
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
import argparse
|
|
31
31
|
import json
|
|
32
32
|
import os
|
|
33
|
+
import re
|
|
33
34
|
import sys
|
|
34
35
|
from datetime import datetime, timezone
|
|
35
36
|
from pathlib import Path
|
|
@@ -110,20 +111,45 @@ def _detect_format(path: Path) -> Optional[str]:
|
|
|
110
111
|
return None
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
def _safe_session_filename(raw: str) -> str:
|
|
115
|
+
"""Reduce an arbitrary session identifier to a SAFE basename confined to
|
|
116
|
+
the sessions dir.
|
|
117
|
+
|
|
118
|
+
The identifier may come from the parsed JSONL (``session_id``), which is
|
|
119
|
+
untrusted when importing a foreign file: an absolute value (``/etc/cron.d/
|
|
120
|
+
evil``) would make ``base / name`` discard ``base`` entirely, and ``../``
|
|
121
|
+
would escape it (adversarial-sweep path-traversal). Strip directory
|
|
122
|
+
components and whitelist the charset so the result is always a plain
|
|
123
|
+
filename inside the sessions dir.
|
|
124
|
+
"""
|
|
125
|
+
# ``Path(...).name`` drops any directory components, incl a leading "/".
|
|
126
|
+
base_name = Path(str(raw)).name
|
|
127
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", base_name)
|
|
128
|
+
# Reject names that are empty or pure dots ("", ".", "..", ...).
|
|
129
|
+
if not safe.strip("."):
|
|
130
|
+
safe = "imported-session"
|
|
131
|
+
return safe
|
|
132
|
+
|
|
133
|
+
|
|
113
134
|
def _swarph_native_path(target_session: Optional[str], result: ImportResult) -> Path:
|
|
114
|
-
"""Resolve the target swarph-native session file path."""
|
|
135
|
+
"""Resolve the target swarph-native session file path (path-traversal-safe)."""
|
|
115
136
|
base = Path.home() / ".swarph" / "sessions"
|
|
116
137
|
base.mkdir(parents=True, exist_ok=True)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
or Path(result.report.source_path).stem
|
|
123
|
-
)
|
|
138
|
+
raw = target_session or (
|
|
139
|
+
result.metadata.get("session_id")
|
|
140
|
+
or Path(result.report.source_path).stem
|
|
141
|
+
)
|
|
142
|
+
name = _safe_session_filename(raw)
|
|
124
143
|
if not name.endswith(".jsonl"):
|
|
125
144
|
name = f"{name}.jsonl"
|
|
126
|
-
|
|
145
|
+
out = base / name
|
|
146
|
+
# Defense in depth: the sanitized name has no separators, so this always
|
|
147
|
+
# holds — but assert the write target is a direct child of the sessions dir.
|
|
148
|
+
if out.resolve().parent != base.resolve():
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"import: refusing to write session outside {base} (got {out})"
|
|
151
|
+
)
|
|
152
|
+
return out
|
|
127
153
|
|
|
128
154
|
|
|
129
155
|
def _write_swarph_native_session(
|
|
@@ -42,6 +42,7 @@ from swarph_cli.cell import (
|
|
|
42
42
|
read_starter_prompt,
|
|
43
43
|
resolve_cell_path,
|
|
44
44
|
)
|
|
45
|
+
from swarph_shared.subprocess_env import scrub_env_for_subprocess
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
_BANNER = """\
|
|
@@ -79,11 +80,13 @@ Anything after a literal `--` is passed through to the provider CLI unchanged
|
|
|
79
80
|
"""
|
|
80
81
|
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
# Codex-specific org-scoping keys NOT covered by the shared billing denylist
|
|
84
|
+
# (scrub_env_for_subprocess already strips OPENAI_API_KEY / OPENAI_API_BASE /
|
|
85
|
+
# OPENAI_BASE_URL via its explicit set + *_BASE_URL/*_API_KEY suffix sweep, and
|
|
86
|
+
# CODEX_API_KEY via the *_API_KEY suffix). These two route billing to a specific
|
|
87
|
+
# org rather than redirect the endpoint, so they live as a codex-layer extra on
|
|
88
|
+
# top of the canonical scrub.
|
|
89
|
+
_CODEX_EXTRA_LEAK_KEYS = (
|
|
87
90
|
"OPENAI_ORG_ID",
|
|
88
91
|
"OPENAI_ORGANIZATION",
|
|
89
92
|
)
|
|
@@ -323,13 +326,30 @@ def _codex_sandbox(cell: Cell) -> str:
|
|
|
323
326
|
return sandbox
|
|
324
327
|
|
|
325
328
|
|
|
329
|
+
def _claude_env() -> dict[str, str]:
|
|
330
|
+
"""Subscription-billing env for an interactive ``claude`` session.
|
|
331
|
+
|
|
332
|
+
The canonical billing-redirect scrub plus the SWARPH_SPAWN marker. Without
|
|
333
|
+
this an ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_AUTH_TOKEN`` set in the parent
|
|
334
|
+
env (an identity proxy / metered relay) would be inherited by the spawned
|
|
335
|
+
``claude`` and silently flip it off subscription auth to a metered endpoint
|
|
336
|
+
while still reporting ``cost_usd`` 0.0 — the adversarial-sweep CRIT.
|
|
337
|
+
"""
|
|
338
|
+
env = scrub_env_for_subprocess()
|
|
339
|
+
env["SWARPH_SPAWN"] = "1"
|
|
340
|
+
return env
|
|
341
|
+
|
|
342
|
+
|
|
326
343
|
def _agy_env() -> dict[str, str]:
|
|
327
|
-
"""
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
"""Subscription-billing env for an ``agy`` (antigravity/Gemini) session.
|
|
345
|
+
|
|
346
|
+
Delegates to the shared scrub, which strips the full billing-redirect class
|
|
347
|
+
(GEMINI_API_KEY / GOOGLE_API_KEY / GEMINI_BASE_URL / GOOGLE_APPLICATION_
|
|
348
|
+
CREDENTIALS / GOOGLE_CLOUD_PROJECT / VERTEX_*) — a superset of the four GCP
|
|
349
|
+
keys this previously popped by hand.
|
|
350
|
+
"""
|
|
351
|
+
env = scrub_env_for_subprocess()
|
|
352
|
+
env["SWARPH_SPAWN"] = "1"
|
|
333
353
|
return env
|
|
334
354
|
|
|
335
355
|
|
|
@@ -373,11 +393,14 @@ def _build_codex_argv(cell: Cell, passthrough: list[str]) -> list[str]:
|
|
|
373
393
|
|
|
374
394
|
|
|
375
395
|
def _scrubbed_codex_env() -> dict[str, str]:
|
|
376
|
-
env
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
396
|
+
"""Subscription-billing env for a ``codex`` (GPT) session.
|
|
397
|
+
|
|
398
|
+
The shared billing-redirect scrub plus the codex-specific org-scoping keys
|
|
399
|
+
(see ``_CODEX_EXTRA_LEAK_KEYS``) that the shared denylist does not cover.
|
|
400
|
+
"""
|
|
401
|
+
env = scrub_env_for_subprocess()
|
|
402
|
+
for key in _CODEX_EXTRA_LEAK_KEYS:
|
|
403
|
+
env.pop(key, None)
|
|
381
404
|
env["SWARPH_SPAWN"] = "1"
|
|
382
405
|
return env
|
|
383
406
|
|
|
@@ -648,14 +671,14 @@ class ClaudeMembrane(ProviderMembrane):
|
|
|
648
671
|
print(f"swarph spawn: cannot chdir to {cell.cwd}: {exc}", file=sys.stderr)
|
|
649
672
|
return 1
|
|
650
673
|
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
#
|
|
654
|
-
#
|
|
655
|
-
#
|
|
656
|
-
|
|
674
|
+
# Exec with the billing-redirect-scrubbed env (NOT raw inherited env) so
|
|
675
|
+
# a parent-set ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN can't silently
|
|
676
|
+
# flip the spawned claude off subscription billing. SWARPH_SPAWN=1 (set
|
|
677
|
+
# in _claude_env) tells a `swarph install-hook` SessionStart hook the
|
|
678
|
+
# prompt was already injected via --append-system-prompt, so it skips
|
|
679
|
+
# double-injection. execve carries exactly this env to the child.
|
|
657
680
|
try:
|
|
658
|
-
os.
|
|
681
|
+
os.execve(binary, argv, _claude_env())
|
|
659
682
|
except OSError as exc:
|
|
660
683
|
print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
|
|
661
684
|
return 1
|
|
@@ -723,12 +746,10 @@ class AntigravityMembrane(ProviderMembrane):
|
|
|
723
746
|
)
|
|
724
747
|
|
|
725
748
|
def launch(self, cell: Cell, binary: str, argv: list[str]) -> int:
|
|
726
|
-
env
|
|
727
|
-
os.environ
|
|
728
|
-
os.environ.update(env)
|
|
729
|
-
os.environ["SWARPH_SPAWN"] = "1"
|
|
749
|
+
# execve carries exactly the scrubbed env to the child without mutating
|
|
750
|
+
# this process's os.environ first (so a failed exec leaves us intact).
|
|
730
751
|
try:
|
|
731
|
-
os.
|
|
752
|
+
os.execve(binary, argv, _agy_env())
|
|
732
753
|
except OSError as exc:
|
|
733
754
|
print(f"swarph spawn: exec failed: {exc}", file=sys.stderr)
|
|
734
755
|
return 1
|
|
@@ -741,6 +762,17 @@ MEMBRANES: dict[str, ProviderMembrane] = {
|
|
|
741
762
|
"antigravity": AntigravityMembrane(),
|
|
742
763
|
}
|
|
743
764
|
|
|
765
|
+
# Defensive coupling: MEMBRANES must stay in lockstep with the shared provider
|
|
766
|
+
# whitelist. A future VALID_PROVIDERS entry without a matching membrane would
|
|
767
|
+
# otherwise surface as a raw KeyError at spawn time — fail loud at import instead.
|
|
768
|
+
from swarph_shared.cell import VALID_PROVIDERS as _VALID_PROVIDERS # noqa: E402
|
|
769
|
+
|
|
770
|
+
if set(MEMBRANES) != _VALID_PROVIDERS:
|
|
771
|
+
raise RuntimeError(
|
|
772
|
+
f"MEMBRANES {sorted(MEMBRANES)} out of sync with VALID_PROVIDERS "
|
|
773
|
+
f"{sorted(_VALID_PROVIDERS)} — add the missing provider membrane."
|
|
774
|
+
)
|
|
775
|
+
|
|
744
776
|
|
|
745
777
|
def run_spawn(argv: Optional[list[str]] = None) -> int:
|
|
746
778
|
if argv is None:
|
|
@@ -781,7 +813,18 @@ def run_spawn(argv: Optional[list[str]] = None) -> int:
|
|
|
781
813
|
print(f"swarph spawn: {exc}", file=sys.stderr)
|
|
782
814
|
return 1
|
|
783
815
|
|
|
784
|
-
membrane = MEMBRANES
|
|
816
|
+
membrane = MEMBRANES.get(cell.provider)
|
|
817
|
+
if membrane is None:
|
|
818
|
+
# Defense-in-depth: VALID_PROVIDERS (in load_cell) normally rejects
|
|
819
|
+
# unknown providers first, and _validate_routing covers the routing-field
|
|
820
|
+
# path — but a routing-less cell of an unmembraned provider would have hit
|
|
821
|
+
# a raw KeyError here. Fail clean instead (silent-failure-hunter #4).
|
|
822
|
+
print(
|
|
823
|
+
f"swarph spawn: provider {cell.provider!r} is not supported by this "
|
|
824
|
+
f"spawn membrane (have: {', '.join(sorted(MEMBRANES))}).",
|
|
825
|
+
file=sys.stderr,
|
|
826
|
+
)
|
|
827
|
+
return 1
|
|
785
828
|
|
|
786
829
|
session_id: Optional[str]
|
|
787
830
|
was_generated = False
|
|
@@ -358,28 +358,70 @@ def _gateway_recent_recovery_event(
|
|
|
358
358
|
return None
|
|
359
359
|
|
|
360
360
|
|
|
361
|
+
def _pid_under(pid: int, ancestors: set, _max_depth: int = 40) -> bool:
|
|
362
|
+
"""Walk the PPID chain up from ``pid``; True if any ancestor is in
|
|
363
|
+
``ancestors``. Reads ``/proc/<pid>/stat`` (Linux). The ``comm`` field can
|
|
364
|
+
contain spaces/parens, so parse PPID after the final ``)``."""
|
|
365
|
+
cur = pid
|
|
366
|
+
for _ in range(_max_depth):
|
|
367
|
+
if cur in ancestors:
|
|
368
|
+
return True
|
|
369
|
+
if cur <= 1:
|
|
370
|
+
return False
|
|
371
|
+
try:
|
|
372
|
+
with open(f"/proc/{cur}/stat", encoding="utf-8") as f:
|
|
373
|
+
data = f.read()
|
|
374
|
+
after = data[data.rfind(")") + 2:].split()
|
|
375
|
+
cur = int(after[1]) # stat field 4 (PPID): state, ppid, ...
|
|
376
|
+
except (OSError, ValueError, IndexError):
|
|
377
|
+
return False
|
|
378
|
+
return cur in ancestors
|
|
379
|
+
|
|
380
|
+
|
|
361
381
|
def _process_alive(tmux_session: str) -> bool:
|
|
362
|
-
"""Detect if a claude process is running
|
|
382
|
+
"""Detect if a claude process is running INSIDE the named tmux session.
|
|
363
383
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
384
|
+
Scopes to the session's pane PIDs (and their descendants) rather than a
|
|
385
|
+
host-wide ``pgrep claude``: on a multi-session host, an unrelated cell's
|
|
386
|
+
claude would otherwise mask THIS session's death and suppress the A2 alert
|
|
387
|
+
(adversarial-sweep MED). Best-effort; falls back to True (assume alive) on
|
|
388
|
+
detection error so a broken detector never false-fires A2.
|
|
368
389
|
"""
|
|
369
390
|
try:
|
|
370
|
-
|
|
391
|
+
panes = subprocess.run(
|
|
392
|
+
["tmux", "list-panes", "-t", tmux_session, "-F", "#{pane_pid}"],
|
|
393
|
+
capture_output=True, text=True, timeout=5,
|
|
394
|
+
)
|
|
395
|
+
if panes.returncode != 0:
|
|
396
|
+
# list-panes fails for TWO very different reasons:
|
|
397
|
+
# (a) the tmux server is reachable but this session is genuinely
|
|
398
|
+
# gone → really dead → False (let A2 fire).
|
|
399
|
+
# (b) there is no tmux server reachable from THIS uid (e.g. the
|
|
400
|
+
# watchdog runs as root while sessions live under ubuntu's
|
|
401
|
+
# /tmp/tmux-1000 socket) → we simply CAN'T determine liveness
|
|
402
|
+
# via tmux → must NOT false-fire A2.
|
|
403
|
+
# Distinguish by probing the server itself.
|
|
404
|
+
server = subprocess.run(
|
|
405
|
+
["tmux", "list-sessions"],
|
|
406
|
+
capture_output=True, text=True, timeout=5,
|
|
407
|
+
)
|
|
408
|
+
if server.returncode != 0:
|
|
409
|
+
return True # no reachable tmux server here → can't tell → assume alive
|
|
410
|
+
return False # server reachable, session absent → genuinely dead
|
|
411
|
+
pane_pids = {int(p) for p in panes.stdout.split() if p.strip().isdigit()}
|
|
412
|
+
if not pane_pids:
|
|
413
|
+
return True # ambiguous (session with no pane pids) → don't false-fire
|
|
414
|
+
|
|
415
|
+
pg = subprocess.run(
|
|
371
416
|
["pgrep", "-f", "claude"],
|
|
372
417
|
capture_output=True, text=True, timeout=5,
|
|
373
418
|
)
|
|
374
|
-
if
|
|
375
|
-
return False # no claude process anywhere
|
|
376
|
-
|
|
377
|
-
#
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
# design (cursor staleness is the PRIMARY).
|
|
381
|
-
return bool(result.stdout.strip())
|
|
382
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
419
|
+
if pg.returncode != 0:
|
|
420
|
+
return False # no claude process anywhere on the host
|
|
421
|
+
claude_pids = [int(p) for p in pg.stdout.split() if p.strip().isdigit()]
|
|
422
|
+
# Alive only if a claude process is a descendant of THIS session's panes.
|
|
423
|
+
return any(_pid_under(cpid, pane_pids) for cpid in claude_pids)
|
|
424
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
|
|
383
425
|
return True # assume alive on detection error
|
|
384
426
|
|
|
385
427
|
|
|
@@ -406,34 +448,39 @@ def _tmux_send_keys(name: str, text: str) -> bool:
|
|
|
406
448
|
|
|
407
449
|
|
|
408
450
|
def _pane_activity_age_sec(name: str) -> Optional[int]:
|
|
409
|
-
"""Age in seconds since the
|
|
410
|
-
|
|
411
|
-
Reads tmux
|
|
412
|
-
epoch
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
451
|
+
"""Age in seconds since the target session's most recent tmux activity.
|
|
452
|
+
|
|
453
|
+
Reads tmux activity-timestamp format vars and uses the MOST RECENT
|
|
454
|
+
(max epoch) across pane / window / session. ``#{pane_activity}`` is only
|
|
455
|
+
populated when monitor-activity is on (empty on a default tmux 3.x), so
|
|
456
|
+
relying on it alone made F3 a no-op — it returned None and never
|
|
457
|
+
suppressed A1 for a genuinely-active session (adversarial-deploy finding
|
|
458
|
+
2026-06-02). ``#{window_activity}`` / ``#{session_activity}`` are tracked
|
|
459
|
+
unconditionally and give the same "is this session alive right now" signal.
|
|
460
|
+
|
|
461
|
+
Used by F3 (mother #1087) as a third AND-gate input to distinguish (a) a
|
|
462
|
+
session genuinely stalled from (b) one actively working in a long bash
|
|
463
|
+
block where cursor-mtime (last turn-end) is stale but the session is alive.
|
|
464
|
+
|
|
465
|
+
Returns None only when NO activity timestamp is parseable (tmux missing /
|
|
466
|
+
session absent), so the caller falls through to the legacy AND-gate.
|
|
425
467
|
"""
|
|
426
468
|
try:
|
|
427
469
|
result = subprocess.run(
|
|
428
|
-
["tmux", "display", "-p", "-t", name,
|
|
470
|
+
["tmux", "display", "-p", "-t", name,
|
|
471
|
+
"#{pane_activity}|#{window_activity}|#{session_activity}"],
|
|
429
472
|
capture_output=True, text=True, timeout=5,
|
|
430
473
|
)
|
|
431
474
|
if result.returncode != 0:
|
|
432
475
|
return None
|
|
433
|
-
|
|
434
|
-
|
|
476
|
+
epochs = []
|
|
477
|
+
for tok in result.stdout.strip().split("|"):
|
|
478
|
+
tok = tok.strip()
|
|
479
|
+
if tok.isdigit():
|
|
480
|
+
epochs.append(int(tok))
|
|
481
|
+
if not epochs:
|
|
435
482
|
return None
|
|
436
|
-
return max(0, _now() -
|
|
483
|
+
return max(0, _now() - max(epochs))
|
|
437
484
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
|
|
438
485
|
return None
|
|
439
486
|
|
|
@@ -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 spawn (claude/codex/antigravity per cell.provider
|
|
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.
|
|
5
5
|
Author: Pierre Samson, Claude Opus
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/darw007d/swarph-cli
|
|
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
|
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Dist: swarph-mesh>=0.5.0
|
|
28
|
-
Requires-Dist: swarph-shared>=0.3.
|
|
28
|
+
Requires-Dist: swarph-shared>=0.3.1
|
|
29
29
|
Requires-Dist: PyYAML>=6.0
|
|
30
30
|
Provides-Extra: dev
|
|
31
31
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
@@ -194,7 +194,7 @@ def test_load_cell_top_level_must_be_mapping(tmp_path):
|
|
|
194
194
|
|
|
195
195
|
def test_load_cell_invalid_peer_name_rejected(cell_yaml_factory):
|
|
196
196
|
path = cell_yaml_factory(name="UPPER_CASE")
|
|
197
|
-
with pytest.raises(CellError, match="kebab
|
|
197
|
+
with pytest.raises(CellError, match="kebab-case"):
|
|
198
198
|
load_cell(path)
|
|
199
199
|
|
|
200
200
|
|
|
@@ -286,3 +286,35 @@ def test_swarph_native_path_falls_back_to_filename_stem(tmp_path, monkeypatch):
|
|
|
286
286
|
)
|
|
287
287
|
p = _swarph_native_path(None, result)
|
|
288
288
|
assert p.name == "no-session-id.jsonl"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_swarph_native_path_rejects_path_traversal(tmp_path, monkeypatch):
|
|
292
|
+
"""Adversarial-sweep MED: a malicious imported JSONL whose session_id is a
|
|
293
|
+
traversal or absolute path must NOT escape the sessions dir on write."""
|
|
294
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
295
|
+
from swarph_cli.parsers.claude import ImportReport, ImportResult
|
|
296
|
+
|
|
297
|
+
sessions = tmp_path / ".swarph" / "sessions"
|
|
298
|
+
for evil in ("../../../../etc/cron.d/evil", "/etc/cron.d/evil",
|
|
299
|
+
"../../.bashrc", "a/b/c"):
|
|
300
|
+
result = ImportResult(
|
|
301
|
+
messages=[],
|
|
302
|
+
report=ImportReport(source_path="/x/foo.jsonl"),
|
|
303
|
+
metadata={"session_id": evil},
|
|
304
|
+
)
|
|
305
|
+
p = _swarph_native_path(None, result)
|
|
306
|
+
# Always a direct child of the sessions dir — never escapes.
|
|
307
|
+
assert p.resolve().parent == sessions.resolve(), f"{evil!r} -> {p}"
|
|
308
|
+
assert "/" not in p.name.replace(".jsonl", "")
|
|
309
|
+
assert ".." not in p.name
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_swarph_native_path_explicit_target_also_sanitized(tmp_path, monkeypatch):
|
|
313
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
314
|
+
from swarph_cli.parsers.claude import ImportReport, ImportResult
|
|
315
|
+
|
|
316
|
+
result = ImportResult(messages=[], report=ImportReport(source_path="/x/foo.jsonl"),
|
|
317
|
+
metadata={"session_id": "S-1"})
|
|
318
|
+
p = _swarph_native_path("/etc/passwd", result)
|
|
319
|
+
assert p.parent == tmp_path / ".swarph" / "sessions"
|
|
320
|
+
assert p.name == "passwd.jsonl"
|
|
@@ -901,19 +901,54 @@ def test_build_agy_argv_sandbox_false(tmp_path):
|
|
|
901
901
|
|
|
902
902
|
|
|
903
903
|
def test_agy_env_scrub(monkeypatch):
|
|
904
|
-
"""Verify _agy_env
|
|
904
|
+
"""Verify _agy_env scrubs the full billing-redirect class via the shared
|
|
905
|
+
scrub — including the GEMINI_API_KEY/GOOGLE_API_KEY/GEMINI_BASE_URL keys the
|
|
906
|
+
old hand-rolled four-key pop missed."""
|
|
905
907
|
from swarph_cli.commands.spawn import _agy_env
|
|
906
908
|
|
|
907
909
|
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/some/path.json")
|
|
908
910
|
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "project-123")
|
|
909
911
|
monkeypatch.setenv("VERTEX_PROJECT", "v-project")
|
|
910
912
|
monkeypatch.setenv("VERTEX_LOCATION", "us-central1")
|
|
913
|
+
# Previously NOT scrubbed by _agy_env's four-key pop — now covered:
|
|
914
|
+
monkeypatch.setenv("GEMINI_API_KEY", "leak")
|
|
915
|
+
monkeypatch.setenv("GOOGLE_API_KEY", "leak")
|
|
916
|
+
monkeypatch.setenv("GEMINI_BASE_URL", "https://metered.example")
|
|
917
|
+
monkeypatch.setenv("KEEP_ME", "ok")
|
|
911
918
|
|
|
912
919
|
env = _agy_env()
|
|
913
920
|
assert "GOOGLE_APPLICATION_CREDENTIALS" not in env
|
|
914
921
|
assert "GOOGLE_CLOUD_PROJECT" not in env
|
|
915
922
|
assert "VERTEX_PROJECT" not in env
|
|
916
923
|
assert "VERTEX_LOCATION" not in env
|
|
924
|
+
assert "GEMINI_API_KEY" not in env
|
|
925
|
+
assert "GOOGLE_API_KEY" not in env
|
|
926
|
+
assert "GEMINI_BASE_URL" not in env
|
|
927
|
+
assert env["KEEP_ME"] == "ok"
|
|
928
|
+
assert env["SWARPH_SPAWN"] == "1"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def test_claude_env_scrubs_billing_redirect(monkeypatch):
|
|
932
|
+
"""CRIT regression (adversarial-sweep 2026-06-01): the interactive claude
|
|
933
|
+
membrane must NOT inherit ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL from the
|
|
934
|
+
parent env — they would silently flip the spawned claude off subscription
|
|
935
|
+
auth to a metered/relay endpoint while cost_usd still reports 0.0."""
|
|
936
|
+
from swarph_cli.commands.spawn import _claude_env
|
|
937
|
+
|
|
938
|
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-leak")
|
|
939
|
+
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "tok-leak")
|
|
940
|
+
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://metered.relay.example")
|
|
941
|
+
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
|
942
|
+
monkeypatch.setenv("KEEP_ME", "ok")
|
|
943
|
+
|
|
944
|
+
env = _claude_env()
|
|
945
|
+
assert "ANTHROPIC_API_KEY" not in env
|
|
946
|
+
assert "ANTHROPIC_AUTH_TOKEN" not in env
|
|
947
|
+
assert "ANTHROPIC_BASE_URL" not in env
|
|
948
|
+
# Non-billing env survives so the claude TUI still works.
|
|
949
|
+
assert "PATH" in env
|
|
950
|
+
assert env["KEEP_ME"] == "ok"
|
|
951
|
+
assert env["SWARPH_SPAWN"] == "1"
|
|
917
952
|
|
|
918
953
|
|
|
919
954
|
def test_run_spawn_antigravity_dry_run(tmp_path, isolated_xdg, capsys):
|
|
@@ -132,6 +132,7 @@ def test_stale_cursor_alive_process_unread_dms_fires_a1(
|
|
|
132
132
|
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
133
133
|
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
|
|
134
134
|
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
135
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
|
|
135
136
|
patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
|
|
136
137
|
rc = run_watchdog(argv=[
|
|
137
138
|
"--check", "--cell", "lab",
|
|
@@ -279,6 +280,7 @@ def test_a1_fires_at_most_once_per_stale_window(
|
|
|
279
280
|
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
280
281
|
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=3), \
|
|
281
282
|
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
283
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
|
|
282
284
|
patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
|
|
283
285
|
# First invocation — A1 fires
|
|
284
286
|
rc1 = run_watchdog(argv=[
|
|
@@ -322,6 +324,7 @@ def test_a1_rearms_after_cursor_advance(
|
|
|
322
324
|
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
323
325
|
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=2), \
|
|
324
326
|
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
327
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
|
|
325
328
|
patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True) as send_mock:
|
|
326
329
|
# First A1 fires
|
|
327
330
|
run_watchdog(argv=[
|
|
@@ -482,6 +485,7 @@ def test_a2_escalation_clears_a1_marker(
|
|
|
482
485
|
with patch("swarph_cli.commands.watchdog._process_alive", return_value=True), \
|
|
483
486
|
patch("swarph_cli.commands.watchdog._gateway_unread_count", return_value=5), \
|
|
484
487
|
patch("swarph_cli.commands.watchdog._tmux_session_exists", return_value=True), \
|
|
488
|
+
patch("swarph_cli.commands.watchdog._pane_activity_age_sec", return_value=None), \
|
|
485
489
|
patch("swarph_cli.commands.watchdog._tmux_send_keys", return_value=True):
|
|
486
490
|
# First fire — record marker
|
|
487
491
|
run_watchdog(argv=[
|
|
@@ -712,3 +716,108 @@ def test_resolve_swarph_bin_relative_with_slash_resolves_to_absolute(tmp_path, m
|
|
|
712
716
|
f"resolver returned non-absolute path: {resolved!r}"
|
|
713
717
|
)
|
|
714
718
|
assert resolved == str(fake)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# ---------------------------------------------------------------------------
|
|
722
|
+
# _process_alive — session-scoped (adversarial-sweep MED, watchdog.py:361)
|
|
723
|
+
# ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
import os
|
|
726
|
+
import swarph_cli.commands.watchdog as _wd
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class _R:
|
|
730
|
+
def __init__(self, returncode, stdout=""):
|
|
731
|
+
self.returncode = returncode
|
|
732
|
+
self.stdout = stdout
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _fake_run_factory(panes_rc, panes_out, pgrep_rc, pgrep_out, sessions_rc=0):
|
|
736
|
+
"""Mock subprocess.run distinguishing tmux list-panes vs list-sessions."""
|
|
737
|
+
def fake_run(cmd, **kw):
|
|
738
|
+
if cmd[0] == "tmux" and cmd[1] == "list-panes":
|
|
739
|
+
return _R(panes_rc, panes_out)
|
|
740
|
+
if cmd[0] == "tmux" and cmd[1] == "list-sessions":
|
|
741
|
+
return _R(sessions_rc, "" if sessions_rc else "lab: 1 windows\n")
|
|
742
|
+
if cmd[0] == "pgrep":
|
|
743
|
+
return _R(pgrep_rc, pgrep_out)
|
|
744
|
+
return _R(1, "")
|
|
745
|
+
return fake_run
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def test_pid_under_walks_proc_ancestry():
|
|
749
|
+
pid = os.getpid()
|
|
750
|
+
assert _wd._pid_under(pid, {pid}) is True # self
|
|
751
|
+
assert _wd._pid_under(pid, {os.getppid()}) is True # parent
|
|
752
|
+
assert _wd._pid_under(pid, {999999999}) is False # bogus ancestor
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_process_alive_ignores_host_wide_claude(monkeypatch):
|
|
756
|
+
"""A claude process that is NOT a descendant of THIS session's panes must
|
|
757
|
+
NOT count as alive (the old host-wide pgrep masked dead sessions)."""
|
|
758
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
759
|
+
_fake_run_factory(0, "1000\n", 0, "2000\n"))
|
|
760
|
+
monkeypatch.setattr(_wd, "_pid_under", lambda pid, anc, **k: False)
|
|
761
|
+
assert _wd._process_alive("mysess") is False
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def test_process_alive_true_when_claude_under_session(monkeypatch):
|
|
765
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
766
|
+
_fake_run_factory(0, "1000\n", 0, "2000\n"))
|
|
767
|
+
monkeypatch.setattr(_wd, "_pid_under", lambda pid, anc, **k: True)
|
|
768
|
+
assert _wd._process_alive("mysess") is True
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def test_process_alive_false_when_session_absent_but_server_up(monkeypatch):
|
|
772
|
+
"""Server reachable + this session genuinely gone → dead (A2 may fire)."""
|
|
773
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
774
|
+
_fake_run_factory(1, "", 0, "2000\n", sessions_rc=0))
|
|
775
|
+
assert _wd._process_alive("ghost") is False
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def test_process_alive_assumes_alive_when_no_tmux_server(monkeypatch):
|
|
779
|
+
"""Regression (deploy 2026-06-02): the watchdog runs as ROOT, whose tmux
|
|
780
|
+
socket is empty — list-panes AND list-sessions both fail. We CANNOT
|
|
781
|
+
determine liveness via tmux, so must assume alive, NOT false-fire an A2
|
|
782
|
+
respawn of a session that's actually alive under ubuntu's tmux."""
|
|
783
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
784
|
+
_fake_run_factory(1, "", 0, "2000\n", sessions_rc=1))
|
|
785
|
+
assert _wd._process_alive("lab") is True
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def test_process_alive_false_when_no_claude_anywhere(monkeypatch):
|
|
789
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
790
|
+
_fake_run_factory(0, "1000\n", 1, ""))
|
|
791
|
+
assert _wd._process_alive("mysess") is False
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# ---------------------------------------------------------------------------
|
|
795
|
+
# _pane_activity_age_sec — fallback across pane/window/session (F3 fix 2026-06-02)
|
|
796
|
+
# ---------------------------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def test_pane_activity_age_falls_back_when_pane_empty(monkeypatch):
|
|
800
|
+
"""pane_activity is empty without monitor-activity (tmux 3.x). F3 must fall
|
|
801
|
+
back to window/session activity, NOT return None — returning None made F3 a
|
|
802
|
+
no-op and let A1 fire against a genuinely-active session."""
|
|
803
|
+
recent = _wd._now() - 30
|
|
804
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
805
|
+
lambda cmd, **kw: _R(0, f"|{recent}|{recent - 5}\n"))
|
|
806
|
+
age = _wd._pane_activity_age_sec("lab")
|
|
807
|
+
assert age is not None
|
|
808
|
+
assert 25 <= age <= 40 # ~30s, allowing for execution slack
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def test_pane_activity_age_none_when_all_blank(monkeypatch):
|
|
812
|
+
monkeypatch.setattr(_wd.subprocess, "run", lambda cmd, **kw: _R(0, "||\n"))
|
|
813
|
+
assert _wd._pane_activity_age_sec("lab") is None
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def test_pane_activity_age_takes_most_recent(monkeypatch):
|
|
817
|
+
"""Uses the MAX (most recent) epoch across the three vars."""
|
|
818
|
+
now = _wd._now()
|
|
819
|
+
# pane empty, window 500s ago, session 10s ago → most recent = 10s
|
|
820
|
+
monkeypatch.setattr(_wd.subprocess, "run",
|
|
821
|
+
lambda cmd, **kw: _R(0, f"|{now - 500}|{now - 10}\n"))
|
|
822
|
+
age = _wd._pane_activity_age_sec("lab")
|
|
823
|
+
assert age is not None and age <= 20
|
|
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
|