- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt, docs/ into muxplex/ subdir in prep for packaging/sharing - Add brand assets to frontend/: favicon.ico, pwa-192/512.png, apple-touch-icon.png, wordmark-on-dark.svg - Update app: title → muxplex, header → wordmark SVG, brand color tokens in style.css, manifest.json updated with muxplex name and brand icons - Add design system: assets/branding/tokens.css (101 CSS custom properties), tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec) - Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG) - Add scripts/render-brand-assets.py for reproducible PNG generation - Add muxplex/README.md
103 KiB
Web-Tmux Dashboard — Phase 1: Backend Implementation Plan
Execution: Use the subagent-driven-development workflow to implement this plan.
Goal: Build the Python coordinator backend — session enumeration, state management, ttyd lifecycle, bell detection, and all JSON API endpoints — fully tested, with no frontend code.
Architecture: A FastAPI application runs a 2-second background poll loop that enumerates tmux sessions via tmux list-sessions, captures pane output via capture-pane, checks bell flags, and atomically writes state to ~/.local/share/tmux-web/state.json. A single ttyd process (one session at a time, port 7682) is spawned and killed via asyncio.create_subprocess_exec. All concurrent state mutations serialize through an asyncio.Lock.
Tech Stack: Python 3.12, FastAPI 0.115+, uvicorn, pytest 8+, pytest-asyncio 0.23+, httpx (for TestClient), standard library only beyond that.
File Map
All files live under /home/bkrabach/dev/web-tmux/. Create every directory and file listed — none exist yet.
/home/bkrabach/dev/web-tmux/
├── coordinator/
│ ├── __init__.py ← empty
│ ├── main.py ← FastAPI app, lifespan, all endpoints
│ ├── sessions.py ← tmux enumeration + capture-pane
│ ├── state.py ← state.json schema, atomic read/write, device management
│ ├── ttyd.py ← ttyd spawn, kill, orphan detection
│ ├── bells.py ← bell polling, unseen_count, clear rule
│ └── tests/
│ ├── __init__.py ← empty
│ ├── test_sessions.py
│ ├── test_state.py
│ ├── test_ttyd.py
│ ├── test_bells.py
│ ├── test_api.py
│ └── test_integration.py
├── requirements.txt
└── pyproject.toml
Task 0: Bell Flag Spike (Empirical — No TDD)
Purpose: Determine whether tmux display-message -t {session} -p "#{window_bell_flag}" clears the bell flag when read, or merely reads it. The bell implementation in Task 7 depends on this finding.
Files:
- Create:
coordinator/spike_bell_flag.py
Step 1: Create the spike script
Create /home/bkrabach/dev/web-tmux/coordinator/spike_bell_flag.py with this exact content:
#!/usr/bin/env python3
"""
Bell Flag Spike — run ONCE manually to determine tmux bell flag read behavior.
Usage:
python3 coordinator/spike_bell_flag.py
What this tests:
Does `tmux display-message -p "#{window_bell_flag}"` clear the flag when
read, or does the flag persist until the window is visited in tmux?
Expected result (almost certain): the flag persists. Reading it does NOT clear
it. The flag is cleared only when the window is marked active inside tmux.
"""
import subprocess
import time
SESSION = "bell-spike-test"
def run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip()
def main() -> None:
# 1. Create a test session
print("Creating test session...")
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-x", "80", "-y", "24"],
check=True)
time.sleep(0.2)
# 2. Send a bell to the session
print("Sending bell to session...")
subprocess.run(["tmux", "send-keys", "-t", SESSION, "printf '\\a'", "Enter"],
check=True)
time.sleep(0.5)
# 3. Read the bell flag (first read)
flag_read1 = run(["tmux", "display-message", "-t", SESSION, "-p",
"#{window_bell_flag}"])
print(f"Bell flag (1st read): '{flag_read1}'")
# 4. Read the bell flag immediately again (second read)
flag_read2 = run(["tmux", "display-message", "-t", SESSION, "-p",
"#{window_bell_flag}"])
print(f"Bell flag (2nd read): '{flag_read2}'")
# 5. Cleanup
subprocess.run(["tmux", "kill-session", "-t", SESSION])
# 6. Report
print()
if flag_read1 == "1" and flag_read2 == "1":
print("FINDING: Reading does NOT clear the flag. Both reads show '1'.")
print("Implementation: use in-memory _bell_seen dict to detect 0→1 transitions.")
elif flag_read1 == "1" and flag_read2 == "0":
print("FINDING: Reading CLEARS the flag. First read shows '1', second shows '0'.")
print("Implementation: each '1' is a new bell — no transition tracking needed.")
elif flag_read1 == "0":
print("WARNING: Bell flag not set after printf '\\a'. Try running manually:")
print(f" tmux send-keys -t {SESSION} \"printf '\\\\a'\" Enter")
print(" Then check: tmux display-message -t bell-spike-test -p '#{window_bell_flag}'")
else:
print(f"UNEXPECTED: read1={flag_read1!r}, read2={flag_read2!r}")
if __name__ == "__main__":
main()
Step 2: Run the spike
cd /home/bkrabach/dev/web-tmux
python3 coordinator/spike_bell_flag.py
Expected output:
Creating test session...
Sending bell to session...
Bell flag (1st read): '1'
Bell flag (2nd read): '1'
FINDING: Reading does NOT clear the flag. Both reads show '1'.
Implementation: use in-memory _bell_seen dict to detect 0→1 transitions.
Step 3: Record findings
The spike result tells us which branch to take in bells.py (Task 7). When you reach Task 7, you will put a comment at the top of coordinator/bells.py recording what the spike found. The plan assumes the almost-certain result: reading does NOT clear the flag.
Step 4: Commit the spike script
cd /home/bkrabach/dev/web-tmux
git add coordinator/spike_bell_flag.py
git commit -m "spike: bell flag read-behavior investigation script"
Task 1: Project Setup
Files:
- Create:
coordinator/__init__.py - Create:
coordinator/tests/__init__.py - Create:
requirements.txt - Create:
pyproject.toml
Step 1: Create directory skeleton
mkdir -p /home/bkrabach/dev/web-tmux/coordinator/tests
touch /home/bkrabach/dev/web-tmux/coordinator/__init__.py
touch /home/bkrabach/dev/web-tmux/coordinator/tests/__init__.py
Step 2: Create requirements.txt
Create /home/bkrabach/dev/web-tmux/requirements.txt:
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pytest>=8.0.0
pytest-asyncio>=0.23.0
Step 3: Create pyproject.toml
Create /home/bkrabach/dev/web-tmux/pyproject.toml:
[project]
name = "tmux-web-coordinator"
version = "0.1.0"
description = "Web dashboard coordinator for tmux sessions"
requires-python = ">=3.12"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["coordinator"]
[tool.pytest.ini_options]
testpaths = ["coordinator/tests"]
asyncio_mode = "auto"
addopts = "--import-mode=importlib"
Step 4: Install dependencies
cd /home/bkrabach/dev/web-tmux
pip install fastapi "uvicorn[standard]" httpx pytest pytest-asyncio
Expected output: Successfully installed fastapi-... uvicorn-... httpx-... pytest-... pytest-asyncio-...
Step 5: Verify pytest finds the test directory
cd /home/bkrabach/dev/web-tmux
pytest --collect-only
Expected output:
======================== no tests ran ========================
(No errors — just no tests yet.)
Step 6: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/__init__.py coordinator/tests/__init__.py requirements.txt pyproject.toml
git commit -m "feat: project setup — pyproject.toml, requirements, directory skeleton"
Task 2: state.py — State Schema + Empty Factories
Files:
- Create:
coordinator/state.py - Create:
coordinator/tests/test_state.py
Step 1: Write the failing tests
Create /home/bkrabach/dev/web-tmux/coordinator/tests/test_state.py:
"""Tests for coordinator/state.py — schema and empty factory functions."""
import pytest
def test_empty_state_has_required_top_level_keys():
from coordinator.state import empty_state
s = empty_state()
assert "active_session" in s
assert "session_order" in s
assert "sessions" in s
assert "devices" in s
def test_empty_state_active_session_is_none():
from coordinator.state import empty_state
s = empty_state()
assert s["active_session"] is None
def test_empty_state_session_order_is_empty_list():
from coordinator.state import empty_state
s = empty_state()
assert s["session_order"] == []
def test_empty_state_sessions_is_empty_dict():
from coordinator.state import empty_state
s = empty_state()
assert s["sessions"] == {}
def test_empty_state_devices_is_empty_dict():
from coordinator.state import empty_state
s = empty_state()
assert s["devices"] == {}
def test_empty_bell_has_required_keys():
from coordinator.state import empty_bell
b = empty_bell()
assert "last_fired_at" in b
assert "seen_at" in b
assert "unseen_count" in b
def test_empty_bell_last_fired_at_is_none():
from coordinator.state import empty_bell
b = empty_bell()
assert b["last_fired_at"] is None
def test_empty_bell_seen_at_is_none():
from coordinator.state import empty_bell
b = empty_bell()
assert b["seen_at"] is None
def test_empty_bell_unseen_count_is_zero():
from coordinator.state import empty_bell
b = empty_bell()
assert b["unseen_count"] == 0
def test_empty_device_has_required_keys():
from coordinator.state import empty_device
d = empty_device("d-abc123", "Test Device")
assert "label" in d
assert "viewing_session" in d
assert "view_mode" in d
assert "last_interaction_at" in d
assert "last_heartbeat_at" in d
def test_empty_device_label_set_correctly():
from coordinator.state import empty_device
d = empty_device("d-abc123", "Laptop Chrome")
assert d["label"] == "Laptop Chrome"
def test_empty_device_viewing_session_is_none():
from coordinator.state import empty_device
d = empty_device("d-abc123", "Test")
assert d["viewing_session"] is None
def test_empty_device_view_mode_is_grid():
from coordinator.state import empty_device
d = empty_device("d-abc123", "Test")
assert d["view_mode"] == "grid"
def test_empty_state_returns_independent_dicts():
"""Two calls to empty_state() must not share mutable objects."""
from coordinator.state import empty_state
s1 = empty_state()
s2 = empty_state()
s1["session_order"].append("main")
assert s2["session_order"] == [], "empty_state() must not share list objects"
Step 2: Run tests to verify they fail
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_state.py -v
Expected: ERROR collecting tests — ModuleNotFoundError: No module named 'coordinator.state'
Step 3: Create coordinator/state.py with schema and factories
Create /home/bkrabach/dev/web-tmux/coordinator/state.py:
"""
State management for the tmux-web coordinator.
State file location: ~/.local/share/tmux-web/state.json
(Override with TMUX_WEB_STATE_DIR env var for testing.)
State schema:
{
"active_session": str | None,
"session_order": list[str],
"sessions": {
"<name>": {
"bell": {
"last_fired_at": float | None,
"seen_at": float | None,
"unseen_count": int
}
}
},
"devices": {
"<device_id>": {
"label": str,
"viewing_session": str | None,
"view_mode": str, # "fullscreen" | "grid"
"last_interaction_at": float,
"last_heartbeat_at": float
}
}
}
Concurrency model:
- `state_lock`: asyncio.Lock — acquire before any read-modify-write cycle.
- `read_state()` / `write_state()`: acquire the lock internally; safe for
simple reads or writes that don't need to be atomic with each other.
- `load_state()` / `save_state()`: NO lock; use inside `async with state_lock`.
Atomic write pattern (os.replace — atomic on POSIX):
tmp = STATE_PATH.with_suffix('.tmp')
tmp.write_text(json.dumps(state, indent=2))
os.replace(tmp, STATE_PATH)
"""
import asyncio
import json
import os
import pathlib
import time
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_state_dir_override = os.environ.get("TMUX_WEB_STATE_DIR")
STATE_DIR: pathlib.Path = (
pathlib.Path(_state_dir_override)
if _state_dir_override
else pathlib.Path.home() / ".local" / "share" / "tmux-web"
)
STATE_PATH: pathlib.Path = STATE_DIR / "state.json"
# ---------------------------------------------------------------------------
# Concurrency
# ---------------------------------------------------------------------------
state_lock: asyncio.Lock = asyncio.Lock()
# ---------------------------------------------------------------------------
# Schema factories
# ---------------------------------------------------------------------------
def empty_state() -> dict:
"""Return a fresh, empty state dict. Each call returns independent objects."""
return {
"active_session": None,
"session_order": [],
"sessions": {},
"devices": {},
}
def empty_bell() -> dict:
"""Return a fresh bell sub-dict for one session."""
return {
"last_fired_at": None,
"seen_at": None,
"unseen_count": 0,
}
def empty_device(device_id: str, label: str) -> dict:
"""Return a fresh device sub-dict with defaults."""
now = time.time()
return {
"label": label,
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": now,
"last_heartbeat_at": now,
}
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_state.py -v
Expected:
PASSED coordinator/tests/test_state.py::test_empty_state_has_required_top_level_keys
PASSED coordinator/tests/test_state.py::test_empty_state_active_session_is_none
... (all 15 tests) ...
15 passed in 0.XXs
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/state.py coordinator/tests/test_state.py
git commit -m "feat: state.py — schema factories and empty_state/bell/device"
Task 3: state.py — Atomic Read/Write
Files:
- Modify:
coordinator/state.py - Modify:
coordinator/tests/test_state.py
Step 1: Add failing tests
Append this to /home/bkrabach/dev/web-tmux/coordinator/tests/test_state.py:
# ---------------------------------------------------------------------------
# Atomic read/write tests — require tmp_path fixture
# ---------------------------------------------------------------------------
import asyncio
import json
import pathlib
@pytest.fixture(autouse=True)
def use_tmp_state_dir(tmp_path, monkeypatch):
"""Redirect all state file operations to a temp directory."""
import coordinator.state as state_mod
monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
async def test_read_state_returns_empty_when_no_file():
from coordinator.state import read_state
result = await read_state()
assert result["active_session"] is None
assert result["session_order"] == []
async def test_write_then_read_roundtrip(tmp_path):
from coordinator.state import read_state, write_state
original = {
"active_session": "work",
"session_order": ["main", "work"],
"sessions": {},
"devices": {},
}
await write_state(original)
result = await read_state()
assert result["active_session"] == "work"
assert result["session_order"] == ["main", "work"]
async def test_write_creates_state_dir_if_missing(tmp_path, monkeypatch):
import coordinator.state as state_mod
deep_dir = tmp_path / "a" / "b" / "c"
monkeypatch.setattr(state_mod, "STATE_DIR", deep_dir)
monkeypatch.setattr(state_mod, "STATE_PATH", deep_dir / "state.json")
await state_mod.write_state(state_mod.empty_state())
assert (deep_dir / "state.json").exists()
async def test_write_is_atomic_no_tmp_file_left(tmp_path):
from coordinator.state import STATE_PATH, write_state
await write_state({"active_session": None, "session_order": [],
"sessions": {}, "devices": {}})
tmp = STATE_PATH.with_suffix(".tmp")
assert not tmp.exists(), ".tmp file must be cleaned up by os.replace()"
async def test_concurrent_writes_do_not_corrupt(tmp_path):
"""Two coroutines writing different states concurrently must not corrupt."""
from coordinator.state import read_state, write_state
state_a = {"active_session": "aaa", "session_order": ["aaa"],
"sessions": {}, "devices": {}}
state_b = {"active_session": "bbb", "session_order": ["bbb"],
"sessions": {}, "devices": {}}
async def write_a() -> None:
for _ in range(5):
await write_state(state_a)
async def write_b() -> None:
for _ in range(5):
await write_state(state_b)
await asyncio.gather(write_a(), write_b())
# Final state must be valid JSON that is one of the two known states
result = await read_state()
assert result["active_session"] in ("aaa", "bbb")
Step 2: Run tests to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_state.py::test_read_state_returns_empty_when_no_file -v
Expected: FAILED — ImportError: cannot import name 'read_state' from 'coordinator.state'
Step 3: Add load_state, save_state, read_state, write_state to coordinator/state.py
Append this to the bottom of /home/bkrabach/dev/web-tmux/coordinator/state.py (after the empty_device function):
# ---------------------------------------------------------------------------
# Low-level file I/O (no lock — use inside async with state_lock)
# ---------------------------------------------------------------------------
def load_state() -> dict:
"""Read state.json synchronously. Returns empty_state() if file missing."""
if not STATE_PATH.exists():
return empty_state()
try:
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return empty_state()
def save_state(state: dict) -> None:
"""Write state.json atomically (os.replace). STATE_DIR must exist."""
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
os.replace(tmp, STATE_PATH)
# ---------------------------------------------------------------------------
# Locked public API (safe to call from any async context)
# ---------------------------------------------------------------------------
async def read_state() -> dict:
"""Acquire state_lock, read state.json, return dict."""
async with state_lock:
return load_state()
async def write_state(state: dict) -> None:
"""Acquire state_lock, write state.json atomically."""
async with state_lock:
save_state(state)
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_state.py -v
Expected:
PASSED ...test_read_state_returns_empty_when_no_file
PASSED ...test_write_then_read_roundtrip
PASSED ...test_write_creates_state_dir_if_missing
PASSED ...test_write_is_atomic_no_tmp_file_left
PASSED ...test_concurrent_writes_do_not_corrupt
... (all tests) ...
20 passed in 0.XXs
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/state.py coordinator/tests/test_state.py
git commit -m "feat: state.py — atomic read/write with asyncio lock"
Task 4: state.py — Device Registration, Heartbeat, Pruning
Files:
- Modify:
coordinator/state.py - Modify:
coordinator/tests/test_state.py
Step 1: Add failing tests
Append this to /home/bkrabach/dev/web-tmux/coordinator/tests/test_state.py:
# ---------------------------------------------------------------------------
# Device registration and pruning tests
# ---------------------------------------------------------------------------
def test_register_device_adds_new_device():
from coordinator.state import empty_state, register_device
s = empty_state()
register_device(s, "d-abc", "Laptop", viewing_session=None,
view_mode="grid", last_interaction_at=1000.0)
assert "d-abc" in s["devices"]
assert s["devices"]["d-abc"]["label"] == "Laptop"
def test_register_device_updates_existing_device():
from coordinator.state import empty_state, register_device
s = empty_state()
register_device(s, "d-abc", "Laptop", viewing_session=None,
view_mode="grid", last_interaction_at=1000.0)
register_device(s, "d-abc", "Laptop", viewing_session="work",
view_mode="fullscreen", last_interaction_at=2000.0)
assert s["devices"]["d-abc"]["viewing_session"] == "work"
assert s["devices"]["d-abc"]["view_mode"] == "fullscreen"
assert s["devices"]["d-abc"]["last_interaction_at"] == 2000.0
def test_register_device_sets_heartbeat_timestamp():
import time
from coordinator.state import empty_state, register_device
before = time.time()
s = empty_state()
register_device(s, "d-abc", "Laptop", viewing_session=None,
view_mode="grid", last_interaction_at=1000.0)
after = time.time()
hb = s["devices"]["d-abc"]["last_heartbeat_at"]
assert before <= hb <= after
def test_prune_devices_removes_stale():
from coordinator.state import empty_state, prune_devices
s = empty_state()
s["devices"]["old"] = {
"label": "Old Device",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": 0.0,
"last_heartbeat_at": 0.0, # very old — epoch
}
removed = prune_devices(s, ttl_seconds=300.0)
assert "old" not in s["devices"]
assert "old" in removed
def test_prune_devices_keeps_fresh():
import time
from coordinator.state import empty_state, prune_devices
s = empty_state()
now = time.time()
s["devices"]["fresh"] = {
"label": "Fresh Device",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": now,
"last_heartbeat_at": now,
}
removed = prune_devices(s, ttl_seconds=300.0)
assert "fresh" in s["devices"]
assert removed == []
def test_prune_devices_returns_list_of_removed_ids():
from coordinator.state import empty_state, prune_devices
s = empty_state()
s["devices"]["stale1"] = {
"label": "A", "viewing_session": None, "view_mode": "grid",
"last_interaction_at": 0.0, "last_heartbeat_at": 0.0,
}
s["devices"]["stale2"] = {
"label": "B", "viewing_session": None, "view_mode": "grid",
"last_interaction_at": 0.0, "last_heartbeat_at": 0.0,
}
removed = prune_devices(s, ttl_seconds=300.0)
assert set(removed) == {"stale1", "stale2"}
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_state.py::test_register_device_adds_new_device -v
Expected: FAILED — ImportError: cannot import name 'register_device'
Step 3: Add register_device and prune_devices to coordinator/state.py
Append to the bottom of /home/bkrabach/dev/web-tmux/coordinator/state.py:
# ---------------------------------------------------------------------------
# Device management (operate on a state dict in-place)
# ---------------------------------------------------------------------------
def register_device(
state: dict,
device_id: str,
label: str,
viewing_session: str | None,
view_mode: str,
last_interaction_at: float,
) -> None:
"""Create or update a device entry. Always refreshes last_heartbeat_at."""
now = time.time()
if device_id not in state["devices"]:
state["devices"][device_id] = empty_device(device_id, label)
device = state["devices"][device_id]
device["label"] = label
device["viewing_session"] = viewing_session
device["view_mode"] = view_mode
device["last_interaction_at"] = last_interaction_at
device["last_heartbeat_at"] = now
def prune_devices(state: dict, ttl_seconds: float = 300.0) -> list[str]:
"""Remove devices silent for longer than ttl_seconds. Returns removed IDs."""
cutoff = time.time() - ttl_seconds
stale = [
device_id
for device_id, device in state["devices"].items()
if device["last_heartbeat_at"] < cutoff
]
for device_id in stale:
del state["devices"][device_id]
return stale
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_state.py -v
Expected: all tests pass (now ~26 tests).
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/state.py coordinator/tests/test_state.py
git commit -m "feat: state.py — device registration, heartbeat, pruning"
Task 5: sessions.py — tmux Session Enumeration
Files:
- Create:
coordinator/sessions.py - Create:
coordinator/tests/test_sessions.py
Step 1: Write failing tests
Create /home/bkrabach/dev/web-tmux/coordinator/tests/test_sessions.py:
"""Tests for coordinator/sessions.py — tmux session enumeration."""
from unittest.mock import AsyncMock, patch
import pytest
async def test_enumerate_sessions_parses_newline_output():
"""run_tmux returns 'main\nwork\nlogs\n' → enumerate returns ['main','work','logs']."""
with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
mock.return_value = "main\nwork\nlogs\n"
from coordinator.sessions import enumerate_sessions
result = await enumerate_sessions()
assert result == ["main", "work", "logs"]
async def test_enumerate_sessions_returns_empty_list_when_no_sessions():
"""When tmux has no sessions, run_tmux returns empty string."""
with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
mock.return_value = ""
from coordinator.sessions import enumerate_sessions
result = await enumerate_sessions()
assert result == []
async def test_enumerate_sessions_strips_whitespace():
with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
mock.return_value = " main \n work \n"
from coordinator.sessions import enumerate_sessions
result = await enumerate_sessions()
assert result == ["main", "work"]
async def test_enumerate_sessions_handles_tmux_error():
"""If run_tmux raises RuntimeError (tmux not running), return empty list."""
with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
mock.side_effect = RuntimeError("no server running")
from coordinator.sessions import enumerate_sessions
result = await enumerate_sessions()
assert result == []
async def test_run_tmux_calls_correct_command():
"""run_tmux('list-sessions', '-F', '#{session_name}') calls tmux with those args."""
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"main\n", b"")
mock_proc.returncode = 0
with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
from coordinator.sessions import run_tmux
await run_tmux("list-sessions", "-F", "#{session_name}")
called_args = mock_exec.call_args[0]
assert called_args[0] == "tmux"
assert "list-sessions" in called_args
assert "-F" in called_args
async def test_run_tmux_raises_on_nonzero_exit():
mock_proc = AsyncMock()
mock_proc.communicate.return_value = (b"", b"no server running on /tmp/tmux")
mock_proc.returncode = 1
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
from coordinator.sessions import run_tmux
with pytest.raises(RuntimeError, match="tmux.*failed"):
await run_tmux("list-sessions")
Step 2: Run tests to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_sessions.py -v
Expected: ERROR — ModuleNotFoundError: No module named 'coordinator.sessions'
Step 3: Create coordinator/sessions.py
Create /home/bkrabach/dev/web-tmux/coordinator/sessions.py:
"""
tmux session enumeration and capture-pane management.
Public API:
run_tmux(*args) — run any tmux command, return stdout as str
enumerate_sessions() — list running tmux session names
capture_pane(name, lines) — capture last N lines of a session's pane
snapshot_all(names) — capture all sessions concurrently
update_session_cache(names) — update in-memory session list + snapshots
get_session_list() — return cached session name list
get_snapshots() — return cached pane snapshots dict
In-memory cache (updated by poll loop, read by API endpoints):
_session_list: list[str] — last known session names
_snapshots: dict[str, str] — session_name → capture-pane text
"""
import asyncio
# ---------------------------------------------------------------------------
# In-memory cache (updated by poll loop)
# ---------------------------------------------------------------------------
_session_list: list[str] = []
_snapshots: dict[str, str] = {}
def get_session_list() -> list[str]:
"""Return the most recently cached list of session names."""
return list(_session_list)
def get_snapshots() -> dict[str, str]:
"""Return the most recently cached capture-pane snapshots."""
return dict(_snapshots)
async def update_session_cache(session_names: list[str]) -> None:
"""Update both _session_list and _snapshots. Called by the poll loop."""
global _session_list, _snapshots
_session_list = list(session_names)
_snapshots = await snapshot_all(session_names)
# ---------------------------------------------------------------------------
# tmux subprocess helpers
# ---------------------------------------------------------------------------
async def run_tmux(*args: str) -> str:
"""Run `tmux <args>`, return stdout. Raises RuntimeError on nonzero exit."""
proc = await asyncio.create_subprocess_exec(
"tmux",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"tmux {list(args)} failed (exit {proc.returncode}): "
f"{stderr.decode(errors='replace').strip()}"
)
return stdout.decode(errors="replace")
# ---------------------------------------------------------------------------
# Session enumeration
# ---------------------------------------------------------------------------
async def enumerate_sessions() -> list[str]:
"""Return list of running tmux session names. Returns [] if tmux not running."""
try:
output = await run_tmux("list-sessions", "-F", "#{session_name}")
except RuntimeError:
return []
names = [line.strip() for line in output.splitlines() if line.strip()]
return names
# ---------------------------------------------------------------------------
# Capture-pane
# ---------------------------------------------------------------------------
async def capture_pane(session_name: str, lines: int = 30) -> str:
"""
Return the last `lines` lines of the named session's active pane.
Uses: tmux capture-pane -p -t <name> -e 0 -l <lines>
-p → print to stdout
-e 0 → no escape sequences
-l → limit lines
Returns empty string if session not found or tmux not running.
"""
try:
output = await run_tmux(
"capture-pane", "-p", "-t", session_name, "-e", "0", "-l", str(lines)
)
except RuntimeError:
return ""
return output
async def snapshot_all(session_names: list[str]) -> dict[str, str]:
"""Capture pane output for all sessions concurrently. Returns name→text dict."""
if not session_names:
return {}
results = await asyncio.gather(
*(capture_pane(name) for name in session_names),
return_exceptions=True,
)
snapshots: dict[str, str] = {}
for name, result in zip(session_names, results):
if isinstance(result, Exception):
snapshots[name] = ""
else:
snapshots[name] = result
return snapshots
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_sessions.py -v
Expected:
PASSED ...test_enumerate_sessions_parses_newline_output
PASSED ...test_enumerate_sessions_returns_empty_list_when_no_sessions
PASSED ...test_enumerate_sessions_strips_whitespace
PASSED ...test_enumerate_sessions_handles_tmux_error
PASSED ...test_run_tmux_calls_correct_command
PASSED ...test_run_tmux_raises_on_nonzero_exit
6 passed in 0.XXs
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/sessions.py coordinator/tests/test_sessions.py
git commit -m "feat: sessions.py — tmux session enumeration and run_tmux helper"
Task 6: sessions.py — Capture-Pane Snapshots
Files:
- Modify:
coordinator/tests/test_sessions.py
(The implementation was already written in Task 5. This task adds tests for capture_pane and snapshot_all.)
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_sessions.py:
# ---------------------------------------------------------------------------
# capture_pane and snapshot_all tests
# ---------------------------------------------------------------------------
async def test_capture_pane_returns_output():
with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
mock.return_value = "line1\nline2\nline3\n"
from coordinator.sessions import capture_pane
result = await capture_pane("myapp")
assert "line1" in result
assert "line3" in result
async def test_capture_pane_returns_empty_string_on_error():
with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
mock.side_effect = RuntimeError("session not found")
from coordinator.sessions import capture_pane
result = await capture_pane("ghost-session")
assert result == ""
async def test_capture_pane_calls_correct_tmux_args():
"""Must pass -p -t <name> -e 0 -l <lines> to capture-pane."""
captured_args: list[tuple] = []
async def fake_run_tmux(*args: str) -> str:
captured_args.append(args)
return "output\n"
with patch("coordinator.sessions.run_tmux", side_effect=fake_run_tmux):
from coordinator.sessions import capture_pane
await capture_pane("dev-server", lines=30)
assert len(captured_args) == 1
args = captured_args[0]
assert "capture-pane" in args
assert "-p" in args
assert "-t" in args
assert "dev-server" in args
assert "-e" in args
assert "0" in args
assert "-l" in args
assert "30" in args
async def test_snapshot_all_returns_dict_keyed_by_name():
async def fake_capture(name: str, lines: int = 30) -> str:
return f"output-for-{name}"
with patch("coordinator.sessions.capture_pane", side_effect=fake_capture):
from coordinator.sessions import snapshot_all
result = await snapshot_all(["main", "work", "logs"])
assert result["main"] == "output-for-main"
assert result["work"] == "output-for-work"
assert result["logs"] == "output-for-logs"
async def test_snapshot_all_returns_empty_dict_for_empty_input():
from coordinator.sessions import snapshot_all
result = await snapshot_all([])
assert result == {}
async def test_snapshot_all_returns_empty_string_on_individual_failure():
"""If one session fails, its entry is '' and others succeed."""
async def fake_capture(name: str, lines: int = 30) -> str:
if name == "broken":
raise RuntimeError("gone")
return f"ok-{name}"
with patch("coordinator.sessions.capture_pane", side_effect=fake_capture):
from coordinator.sessions import snapshot_all
result = await snapshot_all(["main", "broken", "work"])
assert result["main"] == "ok-main"
assert result["broken"] == ""
assert result["work"] == "ok-work"
Step 2: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_sessions.py -v
Expected: all 12 tests pass.
Step 3: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/tests/test_sessions.py
git commit -m "test: sessions.py — capture-pane and snapshot_all tests"
Task 7: bells.py — Bell Flag Polling + unseen_count
Files:
- Create:
coordinator/bells.py - Create:
coordinator/tests/test_bells.py
Step 1: Write failing tests
Create /home/bkrabach/dev/web-tmux/coordinator/tests/test_bells.py:
"""Tests for coordinator/bells.py — bell detection and unseen_count."""
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture(autouse=True)
def reset_bell_seen():
"""Reset in-memory _bell_seen before each test to prevent state leakage."""
import coordinator.bells as bells_mod
bells_mod._bell_seen.clear()
yield
bells_mod._bell_seen.clear()
async def test_poll_bell_flag_returns_true_when_flag_is_1():
with patch("coordinator.bells.run_tmux", new_callable=AsyncMock) as mock:
mock.return_value = "1\n"
from coordinator.bells import poll_bell_flag
result = await poll_bell_flag("myapp")
assert result is True
async def test_poll_bell_flag_returns_false_when_flag_is_0():
with patch("coordinator.bells.run_tmux", new_callable=AsyncMock) as mock:
mock.return_value = "0\n"
from coordinator.bells import poll_bell_flag
result = await poll_bell_flag("myapp")
assert result is False
async def test_poll_bell_flag_returns_false_on_error():
with patch("coordinator.bells.run_tmux", new_callable=AsyncMock) as mock:
mock.side_effect = RuntimeError("no server")
from coordinator.bells import poll_bell_flag
result = await poll_bell_flag("myapp")
assert result is False
async def test_process_bell_flags_increments_unseen_count_on_new_bell():
"""0→1 transition: unseen_count goes from 0 to 1."""
from coordinator.state import empty_bell, empty_state
from coordinator.bells import process_bell_flags
s = empty_state()
s["sessions"]["myapp"] = {"bell": empty_bell()}
async def fake_poll(name: str) -> bool:
return True # flag is set
with patch("coordinator.bells.poll_bell_flag", side_effect=fake_poll):
changed = await process_bell_flags(["myapp"], s)
assert s["sessions"]["myapp"]["bell"]["unseen_count"] == 1
assert s["sessions"]["myapp"]["bell"]["last_fired_at"] is not None
assert changed is True
async def test_process_bell_flags_does_not_double_count_persistent_flag():
"""If flag stays '1' across two polls, unseen_count increments only once."""
from coordinator.state import empty_bell, empty_state
from coordinator.bells import process_bell_flags
s = empty_state()
s["sessions"]["myapp"] = {"bell": empty_bell()}
async def fake_poll(name: str) -> bool:
return True
with patch("coordinator.bells.poll_bell_flag", side_effect=fake_poll):
await process_bell_flags(["myapp"], s)
await process_bell_flags(["myapp"], s) # second poll — same flag
assert s["sessions"]["myapp"]["bell"]["unseen_count"] == 1
async def test_process_bell_flags_resets_tracking_when_flag_clears():
"""After flag goes 1→0, the next 1 is a new bell and must be counted."""
from coordinator.state import empty_bell, empty_state
from coordinator.bells import process_bell_flags
s = empty_state()
s["sessions"]["myapp"] = {"bell": empty_bell()}
# Poll 1: flag is 1 (new bell)
with patch("coordinator.bells.poll_bell_flag", return_value=AsyncMock(return_value=True)()):
pass # can't easily nest like this; use the approach below
call_count = 0
async def fake_poll_sequence(name: str) -> bool:
nonlocal call_count
call_count += 1
# First two calls: flag on; third call: flag off; fourth: flag on again
return call_count in (1, 4)
with patch("coordinator.bells.poll_bell_flag", side_effect=fake_poll_sequence):
await process_bell_flags(["myapp"], s) # count: 1 → bell fires (count=1)
await process_bell_flags(["myapp"], s) # count: 2 → flag off (count resets)
await process_bell_flags(["myapp"], s) # count: 3 — wait, this is wrong
# Simpler approach: call the function directly, manipulating _bell_seen
import coordinator.bells as bells_mod
s2 = empty_state()
s2["sessions"]["myapp"] = {"bell": empty_bell()}
bells_mod._bell_seen.clear()
# Simulate: first bell fires
bells_mod._bell_seen["myapp"] = False
with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
mock.return_value = True
await process_bell_flags(["myapp"], s2)
assert s2["sessions"]["myapp"]["bell"]["unseen_count"] == 1
# Simulate: flag clears
with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
mock.return_value = False
await process_bell_flags(["myapp"], s2)
# Simulate: new bell fires
with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
mock.return_value = True
await process_bell_flags(["myapp"], s2)
assert s2["sessions"]["myapp"]["bell"]["unseen_count"] == 2
async def test_process_bell_flags_no_change_returns_false():
"""If nothing changed, return False."""
from coordinator.state import empty_bell, empty_state
from coordinator.bells import process_bell_flags
s = empty_state()
s["sessions"]["quiet"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
mock.return_value = False
changed = await process_bell_flags(["quiet"], s)
assert changed is False
async def test_process_bell_flags_creates_bell_entry_if_missing():
"""If sessions[name] exists but has no 'bell' key, it should be created."""
from coordinator.state import empty_state
from coordinator.bells import process_bell_flags
s = empty_state()
s["sessions"]["myapp"] = {} # no 'bell' key
with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
mock.return_value = False
await process_bell_flags(["myapp"], s)
assert "bell" in s["sessions"]["myapp"]
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_bells.py -v
Expected: ERROR — ModuleNotFoundError: No module named 'coordinator.bells'
Step 3: Create coordinator/bells.py
Create /home/bkrabach/dev/web-tmux/coordinator/bells.py:
"""
Bell detection and acknowledgement for the tmux-web coordinator.
SPIKE FINDING (run coordinator/spike_bell_flag.py to confirm):
`tmux display-message -t {session} -p "#{window_bell_flag}"` does NOT clear
the bell flag. The flag persists until the window is visited inside tmux.
Consequence: we must track the PREVIOUS state of each session's bell flag
in-memory (_bell_seen dict) to detect 0→1 transitions and avoid double-counting.
Bell clear rule:
A session's bells are globally acknowledged (unseen_count reset) when:
1. The session is open in FULLSCREEN on any connected device, AND
2. That device had a user interaction within the last 60 seconds.
See: should_clear_bell(), apply_bell_clear_rule()
# NOTE FOR PHASE 2 (frontend):
The backtick (`) key returns from fullscreen to grid view.
Fullscreen ↔ grid transitions update view_mode in heartbeat payloads.
"""
import time
from coordinator.sessions import run_tmux
from coordinator.state import empty_bell
# ---------------------------------------------------------------------------
# In-memory state — tracks previous bell flag per session to detect transitions
# ---------------------------------------------------------------------------
_bell_seen: dict[str, bool] = {}
# ---------------------------------------------------------------------------
# Bell flag polling
# ---------------------------------------------------------------------------
async def poll_bell_flag(session_name: str) -> bool:
"""
Return True if the tmux window_bell_flag is set for this session.
Command: tmux display-message -t <name> -p "#{window_bell_flag}"
Returns: "1\n" or "0\n"
Returns False on any error (tmux not running, session gone, etc.).
"""
try:
output = await run_tmux(
"display-message", "-t", session_name, "-p", "#{window_bell_flag}"
)
return output.strip() == "1"
except RuntimeError:
return False
# ---------------------------------------------------------------------------
# Bell processing
# ---------------------------------------------------------------------------
async def process_bell_flags(session_names: list[str], state: dict) -> bool:
"""
Poll bell flags for all sessions, update state in-place.
Detects 0→1 transitions using _bell_seen. Increments unseen_count and
sets last_fired_at on new bells. Resets tracking when flag goes to 0.
Returns True if any bell state changed.
"""
changed = False
now = time.time()
for name in session_names:
# Ensure bell sub-dict exists
if "bell" not in state["sessions"].get(name, {}):
if name not in state["sessions"]:
state["sessions"][name] = {}
state["sessions"][name]["bell"] = empty_bell()
bell = state["sessions"][name]["bell"]
flag_set = await poll_bell_flag(name)
previously_seen = _bell_seen.get(name, False)
if flag_set and not previously_seen:
# 0 → 1 transition: new bell
bell["unseen_count"] += 1
bell["last_fired_at"] = now
_bell_seen[name] = True
changed = True
elif not flag_set:
# Flag is off — reset tracking so next 1 counts as new
if previously_seen:
_bell_seen[name] = False
# Do NOT decrement unseen_count — it's a count of unacknowledged bells
return changed
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_bells.py -v
Expected: all 8 tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/bells.py coordinator/tests/test_bells.py
git commit -m "feat: bells.py — bell flag polling and unseen_count tracking"
Task 8: bells.py — Active-Device Gate Rule (Bell Clear)
Files:
- Modify:
coordinator/bells.py - Modify:
coordinator/tests/test_bells.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_bells.py:
# ---------------------------------------------------------------------------
# Active-device gate rule tests
# ---------------------------------------------------------------------------
import time as time_module
def _make_state_with_bell(session: str, unseen: int, device_id: str = "d-abc",
view_mode: str = "fullscreen",
seconds_since_interaction: float = 10.0) -> dict:
"""Helper: build a minimal state dict for bell-clear tests."""
from coordinator.state import empty_state, empty_bell
s = empty_state()
s["sessions"][session] = {"bell": empty_bell()}
s["sessions"][session]["bell"]["unseen_count"] = unseen
s["sessions"][session]["bell"]["last_fired_at"] = time_module.time() - 5
now = time_module.time()
s["devices"][device_id] = {
"label": "Test Device",
"viewing_session": session,
"view_mode": view_mode,
"last_interaction_at": now - seconds_since_interaction,
"last_heartbeat_at": now,
}
return s
def test_should_clear_bell_returns_true_for_fullscreen_recent_interaction():
from coordinator.bells import should_clear_bell
s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
seconds_since_interaction=10.0)
assert should_clear_bell("myapp", s) is True
def test_should_clear_bell_returns_false_for_grid_mode():
from coordinator.bells import should_clear_bell
s = _make_state_with_bell("myapp", unseen=1, view_mode="grid",
seconds_since_interaction=10.0)
assert should_clear_bell("myapp", s) is False
def test_should_clear_bell_returns_false_when_interaction_too_old():
from coordinator.bells import should_clear_bell
s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
seconds_since_interaction=90.0) # > 60s threshold
assert should_clear_bell("myapp", s) is False
def test_should_clear_bell_returns_false_when_device_viewing_different_session():
from coordinator.bells import should_clear_bell
from coordinator.state import empty_state, empty_bell
s = empty_state()
s["sessions"]["myapp"] = {"bell": empty_bell()}
s["sessions"]["myapp"]["bell"]["unseen_count"] = 1
now = time_module.time()
s["devices"]["d-abc"] = {
"label": "Test",
"viewing_session": "other-session", # different session!
"view_mode": "fullscreen",
"last_interaction_at": now - 5,
"last_heartbeat_at": now,
}
assert should_clear_bell("myapp", s) is False
def test_should_clear_bell_returns_false_when_no_devices():
from coordinator.bells import should_clear_bell
from coordinator.state import empty_state, empty_bell
s = empty_state()
s["sessions"]["myapp"] = {"bell": empty_bell()}
s["sessions"]["myapp"]["bell"]["unseen_count"] = 1
assert should_clear_bell("myapp", s) is False
def test_apply_bell_clear_rule_clears_matching_sessions():
from coordinator.bells import apply_bell_clear_rule
s = _make_state_with_bell("myapp", unseen=3, view_mode="fullscreen",
seconds_since_interaction=5.0)
cleared = apply_bell_clear_rule(s)
assert "myapp" in cleared
assert s["sessions"]["myapp"]["bell"]["unseen_count"] == 0
assert s["sessions"]["myapp"]["bell"]["seen_at"] is not None
def test_apply_bell_clear_rule_skips_sessions_with_zero_unseen():
from coordinator.bells import apply_bell_clear_rule
from coordinator.state import empty_state, empty_bell
s = empty_state()
s["sessions"]["quiet"] = {"bell": empty_bell()}
# unseen_count is already 0 — nothing to clear
cleared = apply_bell_clear_rule(s)
assert "quiet" not in cleared
def test_apply_bell_clear_rule_returns_list_of_cleared_session_names():
from coordinator.bells import apply_bell_clear_rule
s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
seconds_since_interaction=5.0)
cleared = apply_bell_clear_rule(s)
assert isinstance(cleared, list)
assert "myapp" in cleared
def test_apply_bell_clear_rule_resets_bell_seen_tracking():
"""Clearing a bell must also reset _bell_seen so next bell is counted fresh."""
import coordinator.bells as bells_mod
from coordinator.bells import apply_bell_clear_rule
bells_mod._bell_seen["myapp"] = True
s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
seconds_since_interaction=5.0)
apply_bell_clear_rule(s)
assert bells_mod._bell_seen.get("myapp") is False
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_bells.py::test_should_clear_bell_returns_true_for_fullscreen_recent_interaction -v
Expected: FAILED — ImportError: cannot import name 'should_clear_bell'
Step 3: Add should_clear_bell and apply_bell_clear_rule to coordinator/bells.py
Append to the bottom of /home/bkrabach/dev/web-tmux/coordinator/bells.py:
# ---------------------------------------------------------------------------
# Bell clear rule — active-device gate
# ---------------------------------------------------------------------------
_INTERACTION_WINDOW_SECONDS: float = 60.0
def should_clear_bell(session_name: str, state: dict) -> bool:
"""
Return True if this session's bells should be globally cleared.
Rule: any connected device must be viewing this session in FULLSCREEN
AND have had a user interaction within the last 60 seconds.
"""
now = time.time()
for device in state["devices"].values():
if (
device["viewing_session"] == session_name
and device["view_mode"] == "fullscreen"
and device["last_interaction_at"] > now - _INTERACTION_WINDOW_SECONDS
):
return True
return False
def apply_bell_clear_rule(state: dict) -> list[str]:
"""
Check every session with unseen bells against the active-device gate rule.
For sessions that qualify, reset unseen_count to 0, set seen_at to now,
and reset _bell_seen so the next actual bell is counted fresh.
Returns the list of session names that were cleared.
"""
now = time.time()
cleared: list[str] = []
for name, session_data in state["sessions"].items():
bell = session_data.get("bell", {})
if bell.get("unseen_count", 0) == 0:
continue # nothing to clear
if should_clear_bell(name, state):
bell["unseen_count"] = 0
bell["seen_at"] = now
_bell_seen[name] = False # reset so next bell counts fresh
cleared.append(name)
return cleared
Step 4: Run all bell tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_bells.py -v
Expected: all 17 tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/bells.py coordinator/tests/test_bells.py
git commit -m "feat: bells.py — active-device gate rule and global bell clear"
Task 9: ttyd.py — Spawn ttyd Process
Files:
- Create:
coordinator/ttyd.py - Create:
coordinator/tests/test_ttyd.py
Step 1: Write failing tests
Create /home/bkrabach/dev/web-tmux/coordinator/tests/test_ttyd.py:
"""Tests for coordinator/ttyd.py — ttyd process lifecycle."""
import pathlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def use_tmp_pid_dir(tmp_path, monkeypatch):
"""Redirect PID file operations to a temp directory."""
import coordinator.ttyd as ttyd_mod
monkeypatch.setattr(ttyd_mod, "TTYD_PID_DIR", tmp_path)
monkeypatch.setattr(ttyd_mod, "TTYD_PID_PATH", tmp_path / "ttyd.pid")
async def test_spawn_ttyd_writes_pid_file(tmp_path):
import coordinator.ttyd as ttyd_mod
mock_proc = MagicMock()
mock_proc.pid = 12345
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
await ttyd_mod.spawn_ttyd("myapp")
pid_file = tmp_path / "ttyd.pid"
assert pid_file.exists()
assert pid_file.read_text().strip() == "12345"
async def test_spawn_ttyd_uses_correct_command():
import coordinator.ttyd as ttyd_mod
mock_proc = MagicMock()
mock_proc.pid = 99
with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
await ttyd_mod.spawn_ttyd("work")
args = mock_exec.call_args[0]
assert "ttyd" in args
assert "-W" in args
assert "-m" in args
assert "3" in args
assert "-p" in args
assert "7682" in args
assert "tmux" in args
assert "attach" in args
assert "-t" in args
assert "work" in args
async def test_spawn_ttyd_returns_process_object():
import coordinator.ttyd as ttyd_mod
mock_proc = MagicMock()
mock_proc.pid = 42
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
result = await ttyd_mod.spawn_ttyd("logs")
assert result is mock_proc
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_ttyd.py -v
Expected: ERROR — ModuleNotFoundError: No module named 'coordinator.ttyd'
Step 3: Create coordinator/ttyd.py with spawn
Create /home/bkrabach/dev/web-tmux/coordinator/ttyd.py:
"""
ttyd process lifecycle management.
One ttyd instance runs at a time on TTYD_PORT (7682). It is spawned when a
user connects to a session and killed when they disconnect or switch sessions.
PID file: ~/.local/share/tmux-web/ttyd.pid
(Override TTYD_PID_DIR with env var TMUX_WEB_STATE_DIR for testing.)
ttyd command:
ttyd -W -m 3 -p 7682 tmux attach -t <session_name>
-W writable mode
-m 3 allow up to 3 simultaneous connections
-p 7682 listen on port 7682
Orphan detection (on coordinator startup):
If a PID file exists from a previous run, the old ttyd process is still
running (ttyd is persistent, not tied to browser sessions). On startup,
coordinator reads the PID file and kills the orphaned process before
registering fresh state.
"""
import asyncio
import os
import pathlib
import signal
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
_pid_dir_override = os.environ.get("TMUX_WEB_STATE_DIR")
TTYD_PID_DIR: pathlib.Path = (
pathlib.Path(_pid_dir_override)
if _pid_dir_override
else pathlib.Path.home() / ".local" / "share" / "tmux-web"
)
TTYD_PID_PATH: pathlib.Path = TTYD_PID_DIR / "ttyd.pid"
TTYD_PORT: int = 7682
# ---------------------------------------------------------------------------
# Active process handle (in-memory, cleared when killed)
# ---------------------------------------------------------------------------
_active_process: asyncio.subprocess.Process | None = None
# ---------------------------------------------------------------------------
# Spawn
# ---------------------------------------------------------------------------
async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
"""
Spawn `ttyd -W -m 3 -p 7682 tmux attach -t <session_name>`.
Writes the PID to TTYD_PID_PATH. Stores the process handle in
_active_process. Caller is responsible for killing any existing
ttyd process before calling spawn (see kill_ttyd).
"""
global _active_process
TTYD_PID_DIR.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"ttyd",
"-W",
"-m", "3",
"-p", str(TTYD_PORT),
"tmux", "attach", "-t", session_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
TTYD_PID_PATH.write_text(str(proc.pid))
_active_process = proc
return proc
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_ttyd.py::test_spawn_ttyd_writes_pid_file -v
pytest coordinator/tests/test_ttyd.py::test_spawn_ttyd_uses_correct_command -v
pytest coordinator/tests/test_ttyd.py::test_spawn_ttyd_returns_process_object -v
Expected: all 3 tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/ttyd.py coordinator/tests/test_ttyd.py
git commit -m "feat: ttyd.py — spawn ttyd process and write PID file"
Task 10: ttyd.py — Kill and PID File Cleanup
Files:
- Modify:
coordinator/ttyd.py - Modify:
coordinator/tests/test_ttyd.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_ttyd.py:
# ---------------------------------------------------------------------------
# kill_ttyd tests
# ---------------------------------------------------------------------------
import signal
import asyncio
async def test_kill_ttyd_returns_false_when_no_pid_file(tmp_path):
import coordinator.ttyd as ttyd_mod
# No PID file exists — nothing to kill
result = await ttyd_mod.kill_ttyd()
assert result is False
async def test_kill_ttyd_reads_pid_file_and_sends_sigterm(tmp_path):
import coordinator.ttyd as ttyd_mod
pid_file = tmp_path / "ttyd.pid"
pid_file.write_text("99999") # fake PID
with patch("os.kill") as mock_kill, \
patch("asyncio.sleep", new_callable=AsyncMock):
# os.kill with signal 0 checks process existence — make it pass
# os.kill with SIGTERM kills it
def side_effect(pid: int, sig: int) -> None:
if sig == 0:
return # process exists
# SIGTERM send — do nothing (fake)
mock_kill.side_effect = side_effect
result = await ttyd_mod.kill_ttyd()
assert result is True
assert mock_kill.called
async def test_kill_ttyd_removes_pid_file(tmp_path):
import coordinator.ttyd as ttyd_mod
pid_file = tmp_path / "ttyd.pid"
pid_file.write_text("99999")
with patch("os.kill", side_effect=lambda pid, sig: None), \
patch("asyncio.sleep", new_callable=AsyncMock):
await ttyd_mod.kill_ttyd()
assert not pid_file.exists()
async def test_kill_ttyd_handles_process_already_dead(tmp_path):
"""If process is gone (ProcessLookupError), remove PID file and return True."""
import coordinator.ttyd as ttyd_mod
pid_file = tmp_path / "ttyd.pid"
pid_file.write_text("99999")
def fake_kill(pid: int, sig: int) -> None:
raise ProcessLookupError("no such process")
with patch("os.kill", side_effect=fake_kill):
result = await ttyd_mod.kill_ttyd()
assert result is True
assert not pid_file.exists()
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_ttyd.py::test_kill_ttyd_returns_false_when_no_pid_file -v
Expected: FAILED — ImportError: cannot import name... kill_ttyd
Step 3: Add kill_ttyd to coordinator/ttyd.py
Append to the bottom of /home/bkrabach/dev/web-tmux/coordinator/ttyd.py:
# ---------------------------------------------------------------------------
# Kill
# ---------------------------------------------------------------------------
async def kill_ttyd() -> bool:
"""
Kill the currently running ttyd process.
Reads PID from TTYD_PID_PATH, sends SIGTERM, waits up to 2 seconds for
the process to exit, then removes the PID file.
Returns True if a process was killed (or was already dead), False if
no PID file existed.
"""
global _active_process
if not TTYD_PID_PATH.exists():
return False
pid_text = TTYD_PID_PATH.read_text().strip()
try:
pid = int(pid_text)
except ValueError:
TTYD_PID_PATH.unlink(missing_ok=True)
_active_process = None
return False
try:
os.kill(pid, 0) # Check if process exists (raises if not)
except ProcessLookupError:
# Already dead — clean up the PID file
TTYD_PID_PATH.unlink(missing_ok=True)
_active_process = None
return True
try:
os.kill(pid, signal.SIGTERM)
# Give the process up to 2 seconds to exit
for _ in range(20):
await asyncio.sleep(0.1)
try:
os.kill(pid, 0)
except ProcessLookupError:
break # Process exited
except ProcessLookupError:
pass # Process already gone
TTYD_PID_PATH.unlink(missing_ok=True)
_active_process = None
return True
Step 4: Run all ttyd tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_ttyd.py -v
Expected: all 7 tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/ttyd.py coordinator/tests/test_ttyd.py
git commit -m "feat: ttyd.py — kill_ttyd with SIGTERM and PID file cleanup"
Task 11: ttyd.py — Orphan Detection on Startup
Files:
- Modify:
coordinator/ttyd.py - Modify:
coordinator/tests/test_ttyd.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_ttyd.py:
# ---------------------------------------------------------------------------
# kill_orphan_ttyd tests
# ---------------------------------------------------------------------------
async def test_kill_orphan_ttyd_returns_false_when_no_pid_file(tmp_path):
import coordinator.ttyd as ttyd_mod
result = await ttyd_mod.kill_orphan_ttyd()
assert result is False
async def test_kill_orphan_ttyd_kills_running_process(tmp_path):
import coordinator.ttyd as ttyd_mod
pid_file = tmp_path / "ttyd.pid"
pid_file.write_text("88888")
with patch("os.kill", side_effect=lambda pid, sig: None), \
patch("asyncio.sleep", new_callable=AsyncMock):
result = await ttyd_mod.kill_orphan_ttyd()
assert result is True
assert not pid_file.exists()
async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process(tmp_path):
"""PID file exists but process is already dead — still return True, remove file."""
import coordinator.ttyd as ttyd_mod
pid_file = tmp_path / "ttyd.pid"
pid_file.write_text("77777")
def fake_kill(pid: int, sig: int) -> None:
raise ProcessLookupError("no such process")
with patch("os.kill", side_effect=fake_kill):
result = await ttyd_mod.kill_orphan_ttyd()
assert result is True
assert not pid_file.exists()
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content(tmp_path):
"""PID file contains garbage — remove it, return False."""
import coordinator.ttyd as ttyd_mod
pid_file = tmp_path / "ttyd.pid"
pid_file.write_text("not-a-pid")
result = await ttyd_mod.kill_orphan_ttyd()
assert result is False
assert not pid_file.exists()
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_ttyd.py::test_kill_orphan_ttyd_returns_false_when_no_pid_file -v
Expected: FAILED — ImportError: cannot import name... kill_orphan_ttyd
Step 3: Add kill_orphan_ttyd to coordinator/ttyd.py
Append to the bottom of /home/bkrabach/dev/web-tmux/coordinator/ttyd.py:
# ---------------------------------------------------------------------------
# Orphan detection (run on startup)
# ---------------------------------------------------------------------------
async def kill_orphan_ttyd() -> bool:
"""
On coordinator startup: check for a stale PID file from a previous run.
If found, kill the process (if still running) and remove the PID file.
This prevents two ttyd instances running simultaneously after a coordinator
restart or crash.
Returns True if an orphan was found (dead or alive), False if no PID file.
"""
return await kill_ttyd()
Step 4: Run all ttyd tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_ttyd.py -v
Expected: all 11 tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/ttyd.py coordinator/tests/test_ttyd.py
git commit -m "feat: ttyd.py — orphan detection on coordinator startup"
Task 12: main.py — FastAPI Skeleton, Lifespan, Health Endpoint
Files:
- Create:
coordinator/main.py - Create:
coordinator/tests/test_api.py
Step 1: Write failing tests
Create /home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py:
"""Tests for coordinator/main.py — FastAPI endpoints."""
import json
import pathlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(autouse=True)
def patch_startup_and_state(tmp_path, monkeypatch):
"""
Prevent lifespan from touching tmux, ttyd, or production state files.
- Redirects state files to tmp_path
- Mocks kill_orphan_ttyd (no real ttyd)
- Replaces poll loop with a no-op (no real tmux)
"""
import coordinator.state as state_mod
import coordinator.ttyd as ttyd_mod
monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
monkeypatch.setattr(ttyd_mod, "TTYD_PID_DIR", tmp_path)
monkeypatch.setattr(ttyd_mod, "TTYD_PID_PATH", tmp_path / "ttyd.pid")
with patch("coordinator.ttyd.kill_orphan_ttyd", new_callable=AsyncMock), \
patch("coordinator.main._poll_loop", new_callable=AsyncMock):
yield
@pytest.fixture
def client():
"""Return a TestClient with lifespan running."""
from coordinator.main import app
with TestClient(app) as c:
yield c
def test_health_returns_200(client):
response = client.get("/health")
assert response.status_code == 200
def test_health_returns_ok_status(client):
response = client.get("/health")
data = response.json()
assert data["status"] == "ok"
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py::test_health_returns_200 -v
Expected: ERROR — ModuleNotFoundError: No module named 'coordinator.main'
Step 3: Create coordinator/main.py
Create /home/bkrabach/dev/web-tmux/coordinator/main.py:
"""
Web-Tmux Dashboard — Session Coordinator
FastAPI server on port 8080. Serves JSON API for the dashboard frontend.
Startup (lifespan):
1. Kill any orphaned ttyd process from previous run
2. Start background poll loop (runs every POLL_INTERVAL seconds)
Poll loop (every 2s by default, configurable via POLL_INTERVAL env var):
- Enumerate tmux sessions
- Update capture-pane snapshots (in-memory cache)
- Process bell flags (detect 0→1 transitions, update unseen_count)
- Apply bell clear rule (active-device gate)
- Prune stale devices (> 5 min without heartbeat)
- Atomically write updated state.json
Endpoints:
GET /health — liveness check
GET /api/state — full persistent state
PATCH /api/state — update session_order
GET /api/sessions — sessions + snapshots + bell flags
POST /api/sessions/{name}/connect — spawn ttyd for named session
DELETE /api/sessions/current — kill ttyd, clear active session
POST /api/heartbeat — device heartbeat + view state
# NOTE (Phase 2):
The backtick (`) key toggles fullscreen → grid. When frontend implements
this, it must send a heartbeat with view_mode="grid" immediately on toggle.
"""
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from coordinator import bells, sessions, state, ttyd
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
# ---------------------------------------------------------------------------
# Background poll loop
# ---------------------------------------------------------------------------
_poll_task: asyncio.Task | None = None
async def _run_poll_cycle() -> None:
"""
Single poll cycle: enumerate sessions, update snapshots, process bells,
apply clear rule, prune devices, write state atomically.
"""
session_names = await sessions.enumerate_sessions()
await sessions.update_session_cache(session_names)
existing = set(session_names)
async with state.state_lock:
current = state.load_state()
# Reconcile session_order: preserve user order, add new, remove deleted
known_order = current["session_order"]
new_order = [s for s in known_order if s in existing]
new_sessions_to_add = [s for s in session_names if s not in set(known_order)]
new_order.extend(new_sessions_to_add)
current["session_order"] = new_order
# Ensure every session has a bell entry
for name in session_names:
if name not in current["sessions"]:
current["sessions"][name] = {"bell": state.empty_bell()}
# Remove sessions that no longer exist in tmux
current["sessions"] = {
k: v for k, v in current["sessions"].items() if k in existing
}
# Clear active_session if that session is gone
if current["active_session"] not in existing:
current["active_session"] = None
# Process bell flags and apply clear rule
await bells.process_bell_flags(session_names, current)
bells.apply_bell_clear_rule(current)
# Prune stale devices
state.prune_devices(current)
# Write back
state.save_state(current)
async def _poll_loop() -> None:
"""Runs _run_poll_cycle() every POLL_INTERVAL seconds indefinitely."""
while True:
try:
await _run_poll_cycle()
except Exception:
pass # Log but don't crash the loop
await asyncio.sleep(POLL_INTERVAL)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global _poll_task
await ttyd.kill_orphan_ttyd()
_poll_task = asyncio.create_task(_poll_loop())
yield
if _poll_task:
_poll_task.cancel()
try:
await _poll_task
except asyncio.CancelledError:
pass
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(
title="tmux-web coordinator",
version="0.1.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.get("/health")
async def health() -> dict:
"""Liveness check."""
return {"status": "ok"}
Step 4: Run tests to verify they pass
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py::test_health_returns_200 coordinator/tests/test_api.py::test_health_returns_ok_status -v
Expected:
PASSED coordinator/tests/test_api.py::test_health_returns_200
PASSED coordinator/tests/test_api.py::test_health_returns_ok_status
2 passed in 0.XXs
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/main.py coordinator/tests/test_api.py
git commit -m "feat: main.py — FastAPI skeleton, lifespan, poll loop, /health"
Task 13: main.py — GET /api/state and PATCH /api/state
Files:
- Modify:
coordinator/main.py - Modify:
coordinator/tests/test_api.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py:
# ---------------------------------------------------------------------------
# GET /api/state and PATCH /api/state
# ---------------------------------------------------------------------------
def test_get_state_returns_full_state(client):
response = client.get("/api/state")
assert response.status_code == 200
data = response.json()
assert "active_session" in data
assert "session_order" in data
assert "sessions" in data
assert "devices" in data
def test_get_state_active_session_is_none_initially(client):
response = client.get("/api/state")
data = response.json()
assert data["active_session"] is None
def test_patch_state_updates_session_order(client, tmp_path):
# Write initial state with some sessions
import coordinator.state as state_mod
initial = state_mod.empty_state()
initial["session_order"] = ["main", "work", "logs"]
state_mod.save_state(initial)
response = client.patch(
"/api/state",
json={"session_order": ["logs", "work", "main"]},
)
assert response.status_code == 200
# Verify persisted
check = client.get("/api/state")
assert check.json()["session_order"] == ["logs", "work", "main"]
def test_patch_state_rejects_non_list_session_order(client):
response = client.patch(
"/api/state",
json={"session_order": "not-a-list"},
)
assert response.status_code == 422
def test_patch_state_ignores_unknown_fields(client):
"""PATCH only updates known mutable fields; unknown fields are silently ignored."""
response = client.patch(
"/api/state",
json={"session_order": [], "unknown_field": "value"},
)
assert response.status_code == 200
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py::test_get_state_returns_full_state -v
Expected: FAILED — 404 Not Found (endpoint doesn't exist yet)
Step 3: Add endpoints to coordinator/main.py
Add these imports and endpoint definitions after the /health endpoint in /home/bkrabach/dev/web-tmux/coordinator/main.py:
# Add to imports at the top of main.py:
from pydantic import BaseModel
# Add after the /health endpoint:
class StatePatch(BaseModel):
session_order: list[str]
@app.get("/api/state")
async def get_state() -> dict:
"""Return the full persistent state."""
return await state.read_state()
@app.patch("/api/state")
async def patch_state(patch: StatePatch) -> dict:
"""Update mutable state fields. Currently supports session_order only."""
async with state.state_lock:
current = state.load_state()
current["session_order"] = patch.session_order
state.save_state(current)
return current
Step 4: Run tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py -v -k "state"
Expected: all 5 state tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/main.py coordinator/tests/test_api.py
git commit -m "feat: main.py — GET /api/state and PATCH /api/state endpoints"
Task 14: main.py — GET /api/sessions
Files:
- Modify:
coordinator/main.py - Modify:
coordinator/tests/test_api.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py:
# ---------------------------------------------------------------------------
# GET /api/sessions
# ---------------------------------------------------------------------------
def test_get_sessions_returns_list(client):
with patch("coordinator.sessions.get_session_list", return_value=["main", "work"]), \
patch("coordinator.sessions.get_snapshots",
return_value={"main": "output\n", "work": "other\n"}):
response = client.get("/api/sessions")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_get_sessions_each_item_has_required_fields(client):
with patch("coordinator.sessions.get_session_list", return_value=["main"]), \
patch("coordinator.sessions.get_snapshots", return_value={"main": "output\n"}):
response = client.get("/api/sessions")
data = response.json()
assert len(data) == 1
session = data[0]
assert "name" in session
assert "snapshot" in session
assert "bell" in session
def test_get_sessions_includes_snapshot_text(client):
with patch("coordinator.sessions.get_session_list", return_value=["myapp"]), \
patch("coordinator.sessions.get_snapshots",
return_value={"myapp": "hello world\n"}):
response = client.get("/api/sessions")
data = response.json()
assert data[0]["snapshot"] == "hello world\n"
def test_get_sessions_includes_bell_state(client, tmp_path):
import coordinator.state as state_mod
s = state_mod.empty_state()
s["sessions"]["myapp"] = {"bell": state_mod.empty_bell()}
s["sessions"]["myapp"]["bell"]["unseen_count"] = 2
state_mod.save_state(s)
with patch("coordinator.sessions.get_session_list", return_value=["myapp"]), \
patch("coordinator.sessions.get_snapshots", return_value={"myapp": ""}):
response = client.get("/api/sessions")
data = response.json()
assert data[0]["bell"]["unseen_count"] == 2
def test_get_sessions_returns_empty_list_when_no_sessions(client):
with patch("coordinator.sessions.get_session_list", return_value=[]), \
patch("coordinator.sessions.get_snapshots", return_value={}):
response = client.get("/api/sessions")
assert response.json() == []
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py::test_get_sessions_returns_list -v
Expected: FAILED — 404 Not Found
Step 3: Add GET /api/sessions to coordinator/main.py
Append after the patch_state endpoint in /home/bkrabach/dev/web-tmux/coordinator/main.py:
@app.get("/api/sessions")
async def get_sessions() -> list[dict]:
"""
Return all known tmux sessions with their capture-pane snapshots and bell state.
Data comes from:
- sessions.get_session_list() — cached session names (updated by poll loop)
- sessions.get_snapshots() — cached capture-pane output (updated by poll loop)
- state.read_state() — persistent state (bell counts, etc.)
"""
session_names = sessions.get_session_list()
snapshots = sessions.get_snapshots()
current_state = await state.read_state()
result: list[dict] = []
for name in session_names:
bell_data = (
current_state.get("sessions", {})
.get(name, {})
.get("bell", state.empty_bell())
)
result.append({
"name": name,
"snapshot": snapshots.get(name, ""),
"bell": bell_data,
})
return result
Step 4: Run tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py -v -k "sessions"
Expected: all 5 sessions tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/main.py coordinator/tests/test_api.py
git commit -m "feat: main.py — GET /api/sessions endpoint"
Task 15: main.py — POST /api/sessions/{name}/connect and DELETE /api/sessions/current
Files:
- Modify:
coordinator/main.py - Modify:
coordinator/tests/test_api.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py:
# ---------------------------------------------------------------------------
# POST /api/sessions/{name}/connect and DELETE /api/sessions/current
# ---------------------------------------------------------------------------
def test_connect_session_returns_200(client, tmp_path):
import coordinator.state as state_mod
s = state_mod.empty_state()
s["sessions"]["work"] = {"bell": state_mod.empty_bell()}
s["session_order"] = ["work"]
state_mod.save_state(s)
mock_proc = MagicMock()
mock_proc.pid = 12345
with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock), \
patch("coordinator.ttyd.spawn_ttyd", new_callable=AsyncMock,
return_value=mock_proc):
response = client.post("/api/sessions/work/connect")
assert response.status_code == 200
def test_connect_session_sets_active_session(client, tmp_path):
import coordinator.state as state_mod
s = state_mod.empty_state()
s["sessions"]["work"] = {"bell": state_mod.empty_bell()}
s["session_order"] = ["work"]
state_mod.save_state(s)
mock_proc = MagicMock()
mock_proc.pid = 12345
with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock), \
patch("coordinator.ttyd.spawn_ttyd", new_callable=AsyncMock,
return_value=mock_proc):
client.post("/api/sessions/work/connect")
check = client.get("/api/state")
assert check.json()["active_session"] == "work"
def test_connect_session_kills_existing_ttyd(client, tmp_path):
import coordinator.state as state_mod
s = state_mod.empty_state()
s["sessions"]["work"] = {"bell": state_mod.empty_bell()}
s["session_order"] = ["work"]
state_mod.save_state(s)
mock_proc = MagicMock()
mock_proc.pid = 1
with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock) as mock_kill, \
patch("coordinator.ttyd.spawn_ttyd", new_callable=AsyncMock,
return_value=mock_proc):
client.post("/api/sessions/work/connect")
mock_kill.assert_called_once()
def test_connect_nonexistent_session_returns_404(client, tmp_path):
import coordinator.state as state_mod
s = state_mod.empty_state()
state_mod.save_state(s)
with patch("coordinator.sessions.get_session_list", return_value=["main"]):
response = client.post("/api/sessions/ghost/connect")
assert response.status_code == 404
def test_delete_current_kills_ttyd_and_clears_active(client, tmp_path):
import coordinator.state as state_mod
s = state_mod.empty_state()
s["active_session"] = "work"
state_mod.save_state(s)
with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock) as mock_kill:
response = client.delete("/api/sessions/current")
assert response.status_code == 200
mock_kill.assert_called_once()
check = client.get("/api/state")
assert check.json()["active_session"] is None
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py::test_connect_session_returns_200 -v
Expected: FAILED — 404 Not Found (endpoint missing)
Step 3: Add connect and delete endpoints to coordinator/main.py
Append after the get_sessions endpoint in /home/bkrabach/dev/web-tmux/coordinator/main.py:
@app.post("/api/sessions/{name}/connect")
async def connect_session(name: str) -> dict:
"""
Kill any existing ttyd, spawn a fresh one for the named session,
and update active_session in state.
Returns 404 if the session is not in the current session list.
"""
known = sessions.get_session_list()
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
await ttyd.kill_ttyd()
await ttyd.spawn_ttyd(name)
async with state.state_lock:
current = state.load_state()
current["active_session"] = name
state.save_state(current)
return {"active_session": name, "ttyd_port": ttyd.TTYD_PORT}
@app.delete("/api/sessions/current")
async def disconnect_session() -> dict:
"""
Kill the active ttyd process and clear active_session from state.
"""
await ttyd.kill_ttyd()
async with state.state_lock:
current = state.load_state()
current["active_session"] = None
state.save_state(current)
return {"active_session": None}
Step 4: Run tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py -v -k "connect or delete"
Expected: all 5 tests pass.
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/main.py coordinator/tests/test_api.py
git commit -m "feat: main.py — connect/disconnect session endpoints"
Task 16: main.py — POST /api/heartbeat
Files:
- Modify:
coordinator/main.py - Modify:
coordinator/tests/test_api.py
Step 1: Add failing tests
Append to /home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py:
# ---------------------------------------------------------------------------
# POST /api/heartbeat
# ---------------------------------------------------------------------------
import time
def test_heartbeat_returns_200(client):
response = client.post(
"/api/heartbeat",
json={
"device_id": "d-abc123",
"label": "Laptop Chrome",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": time.time(),
},
)
assert response.status_code == 200
def test_heartbeat_registers_new_device(client):
now = time.time()
client.post(
"/api/heartbeat",
json={
"device_id": "d-new",
"label": "Test Phone",
"viewing_session": "work",
"view_mode": "fullscreen",
"last_interaction_at": now,
},
)
check = client.get("/api/state")
devices = check.json()["devices"]
assert "d-new" in devices
assert devices["d-new"]["label"] == "Test Phone"
assert devices["d-new"]["viewing_session"] == "work"
def test_heartbeat_updates_existing_device(client):
now = time.time()
# First heartbeat
client.post(
"/api/heartbeat",
json={
"device_id": "d-abc",
"label": "Laptop",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": now - 10,
},
)
# Second heartbeat with updated view state
client.post(
"/api/heartbeat",
json={
"device_id": "d-abc",
"label": "Laptop",
"viewing_session": "dev-server",
"view_mode": "fullscreen",
"last_interaction_at": now,
},
)
check = client.get("/api/state")
device = check.json()["devices"]["d-abc"]
assert device["viewing_session"] == "dev-server"
assert device["view_mode"] == "fullscreen"
def test_heartbeat_missing_device_id_returns_422(client):
response = client.post(
"/api/heartbeat",
json={
"label": "Laptop",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": time.time(),
},
)
assert response.status_code == 422
def test_heartbeat_invalid_view_mode_returns_422(client):
response = client.post(
"/api/heartbeat",
json={
"device_id": "d-abc",
"label": "Laptop",
"viewing_session": None,
"view_mode": "invalid-mode", # must be "grid" or "fullscreen"
"last_interaction_at": time.time(),
},
)
assert response.status_code == 422
Step 2: Run to verify failures
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py::test_heartbeat_returns_200 -v
Expected: FAILED — 404 Not Found
Step 3: Add heartbeat endpoint to coordinator/main.py
Add this import and endpoint in /home/bkrabach/dev/web-tmux/coordinator/main.py.
First, add Literal to the imports at the top:
from typing import Literal
Then append after the disconnect_session endpoint:
class HeartbeatPayload(BaseModel):
device_id: str
label: str
viewing_session: str | None
view_mode: Literal["grid", "fullscreen"]
last_interaction_at: float
@app.post("/api/heartbeat")
async def heartbeat(payload: HeartbeatPayload) -> dict:
"""
Receive a device heartbeat.
Called by the dashboard every 5 seconds. Updates device registration,
view state, and interaction timestamp in state.json.
"""
async with state.state_lock:
current = state.load_state()
state.register_device(
current,
device_id=payload.device_id,
label=payload.label,
viewing_session=payload.viewing_session,
view_mode=payload.view_mode,
last_interaction_at=payload.last_interaction_at,
)
state.save_state(current)
return {"device_id": payload.device_id, "status": "ok"}
Step 4: Run all tests
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_api.py -v
Expected: all API tests pass (approximately 25 tests).
Step 5: Run full test suite
cd /home/bkrabach/dev/web-tmux
pytest -v
Expected: all tests pass across all test files.
Step 6: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/main.py coordinator/tests/test_api.py
git commit -m "feat: main.py — POST /api/heartbeat endpoint"
Task 17: Integration Test — Real tmux + Full Poll Cycle
Files:
- Create:
coordinator/tests/test_integration.py
⚠️ Requirements: tmux must be installed on the host. These tests are marked integration and are excluded from the default pytest run. Run them explicitly.
Step 1: Write the integration test
Create /home/bkrabach/dev/web-tmux/coordinator/tests/test_integration.py:
"""
Integration tests — require tmux installed on the host.
These tests spin up a real, isolated tmux server on a test socket and verify
the full coordinator poll cycle end-to-end. They do NOT require a browser.
Run with:
pytest coordinator/tests/test_integration.py -v -m integration
Skip with:
pytest -m "not integration" (default run excludes these)
"""
import asyncio
import subprocess
import time
import pytest
pytestmark = pytest.mark.integration
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def tmux_server():
"""
Start an isolated tmux server on socket 'test-server'.
Creates one session named 'test' (220x50).
Tears down after all tests in this module.
"""
socket = "test-server"
subprocess.run(
["tmux", "-L", socket, "new-session", "-d", "-s", "test", "-x", "220", "-y", "50"],
check=True,
capture_output=True,
)
yield socket
subprocess.run(
["tmux", "-L", socket, "kill-server"],
capture_output=True,
)
@pytest.fixture(autouse=True)
def use_tmp_state(tmp_path, monkeypatch):
"""Redirect all state files to tmp_path for each test."""
import coordinator.state as state_mod
import coordinator.ttyd as ttyd_mod
monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
monkeypatch.setattr(ttyd_mod, "TTYD_PID_DIR", tmp_path)
monkeypatch.setattr(ttyd_mod, "TTYD_PID_PATH", tmp_path / "ttyd.pid")
# ---------------------------------------------------------------------------
# Helper: run tmux command against the test socket
# ---------------------------------------------------------------------------
def tmux(socket: str, *args: str) -> str:
result = subprocess.run(
["tmux", "-L", socket, *args],
capture_output=True,
text=True,
)
return result.stdout.strip()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
async def test_enumerate_sessions_finds_test_session(tmux_server):
from coordinator.sessions import enumerate_sessions
# Temporarily make run_tmux use the test socket
import coordinator.sessions as sessions_mod
original = sessions_mod.run_tmux
async def patched_run_tmux(*args: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "-L", tmux_server, *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(stderr.decode())
return stdout.decode()
sessions_mod.run_tmux = patched_run_tmux
try:
result = await enumerate_sessions()
finally:
sessions_mod.run_tmux = original
assert "test" in result
async def test_capture_pane_returns_content(tmux_server):
"""Send text to tmux, capture it, verify it appears in snapshot."""
import coordinator.sessions as sessions_mod
socket = tmux_server
# Send some text to the test session
subprocess.run(
["tmux", "-L", socket, "send-keys", "-t", "test", "echo hello-world", "Enter"],
check=True,
)
time.sleep(0.3) # wait for output to appear
async def patched_run_tmux(*args: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "-L", socket, *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return stdout.decode()
original = sessions_mod.run_tmux
sessions_mod.run_tmux = patched_run_tmux
try:
from coordinator.sessions import capture_pane
output = await capture_pane("test")
finally:
sessions_mod.run_tmux = original
assert "hello-world" in output
async def test_bell_flag_detected_after_printf_bell(tmux_server):
"""
Send a bell to the test session via printf '\a'.
Verify that poll_bell_flag returns True.
"""
import coordinator.bells as bells_mod
import coordinator.sessions as sessions_mod
socket = tmux_server
async def patched_run_tmux(*args: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "-L", socket, *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return stdout.decode()
original = sessions_mod.run_tmux
sessions_mod.run_tmux = patched_run_tmux
bells_mod.run_tmux = patched_run_tmux
try:
# Trigger a bell
subprocess.run(
["tmux", "-L", socket, "send-keys", "-t", "test",
"printf '\\a'", "Enter"],
check=True,
)
time.sleep(0.3)
from coordinator.bells import poll_bell_flag
result = await poll_bell_flag("test")
finally:
sessions_mod.run_tmux = original
bells_mod.run_tmux = original
assert result is True, (
"Bell flag should be 1 after printf '\\a'. "
"If this fails, re-run the spike script to verify bell behavior."
)
async def test_full_poll_cycle_via_api(tmux_server, tmp_path):
"""
Run a full poll cycle through the FastAPI app (via TestClient).
Verifies: session appears in /api/sessions after poll cycle runs.
"""
import coordinator.sessions as sessions_mod
from unittest.mock import patch, AsyncMock
socket = tmux_server
async def patched_run_tmux(*args: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "-L", socket, *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return stdout.decode()
sessions_mod.run_tmux = patched_run_tmux
try:
from coordinator.main import _run_poll_cycle
await _run_poll_cycle()
finally:
# Restore: import original run_tmux
import importlib
importlib.reload(sessions_mod)
# Verify state was written with the session
from coordinator.state import load_state
s = load_state()
assert "test" in s["session_order"]
async def test_state_file_written_atomically_by_poll_cycle(tmux_server, tmp_path):
"""
After a poll cycle, state.json must exist and be valid JSON.
The .tmp file must NOT exist (os.replace was called).
"""
import coordinator.sessions as sessions_mod
import coordinator.state as state_mod
socket = tmux_server
async def patched_run_tmux(*args: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux", "-L", socket, *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
return stdout.decode()
sessions_mod.run_tmux = patched_run_tmux
try:
from coordinator.main import _run_poll_cycle
await _run_poll_cycle()
finally:
import importlib
importlib.reload(sessions_mod)
state_path = state_mod.STATE_PATH
tmp_path_file = state_path.with_suffix(".tmp")
assert state_path.exists(), "state.json must exist after poll cycle"
assert not tmp_path_file.exists(), ".tmp file must be cleaned up (os.replace)"
# Verify it's valid JSON
import json
content = json.loads(state_path.read_text())
assert "active_session" in content
assert "session_order" in content
Step 2: Add the integration mark to pyproject.toml
Append to the [tool.pytest.ini_options] section in /home/bkrabach/dev/web-tmux/pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["coordinator/tests"]
asyncio_mode = "auto"
addopts = "--import-mode=importlib -m 'not integration'"
markers = [
"integration: marks tests that require tmux installed on the host",
]
Note: The
addoptsline above adds-m 'not integration'so integration tests are excluded from the defaultpytestrun. Run integration tests explicitly withpytest -m integration.
Step 3: Verify unit tests still all pass
cd /home/bkrabach/dev/web-tmux
pytest -v
Expected: all unit tests pass, integration tests are skipped (not collected due to -m 'not integration').
Step 4: Run the integration tests (requires tmux)
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_integration.py -v -m integration
Expected:
PASSED ...test_enumerate_sessions_finds_test_session
PASSED ...test_capture_pane_returns_content
PASSED ...test_bell_flag_detected_after_printf_bell
PASSED ...test_full_poll_cycle_via_api
PASSED ...test_state_file_written_atomically_by_poll_cycle
5 passed in X.XXs
Step 5: Commit
cd /home/bkrabach/dev/web-tmux
git add coordinator/tests/test_integration.py pyproject.toml
git commit -m "test: integration tests — real tmux server, full poll cycle end-to-end"
Final Verification
Run all unit tests:
cd /home/bkrabach/dev/web-tmux
pytest -v
Expected: all tests pass with zero failures.
Run integration tests (if tmux is available):
cd /home/bkrabach/dev/web-tmux
pytest coordinator/tests/test_integration.py -v -m integration
Smoke test — start the server manually:
cd /home/bkrabach/dev/web-tmux
python3 -m uvicorn coordinator.main:app --host 0.0.0.0 --port 8080 --reload
Then in another terminal:
curl -s http://localhost:8080/health | python3 -m json.tool
# Expected: {"status": "ok"}
curl -s http://localhost:8080/api/state | python3 -m json.tool
# Expected: full state JSON with active_session: null
curl -s http://localhost:8080/api/sessions | python3 -m json.tool
# Expected: list of running tmux sessions with snapshots
Final commit (if any loose ends):
cd /home/bkrabach/dev/web-tmux
git status # should be clean
git log --oneline -20
Phase 1 Completion Checklist
- Task 0: Bell flag spike script created and run, findings documented in
bells.py - Task 1:
requirements.txt,pyproject.toml, directory skeleton committed - Task 2:
state.py— schema factories, all tests green - Task 3:
state.py— atomic read/write with lock, all tests green - Task 4:
state.py— device registration + pruning, all tests green - Task 5:
sessions.py— session enumeration +run_tmux, all tests green - Task 6:
sessions.py— capture-pane tests added, all tests green - Task 7:
bells.py— bell polling + unseen_count, all tests green - Task 8:
bells.py— active-device gate rule, all tests green - Task 9:
ttyd.py— spawn + PID file, all tests green - Task 10:
ttyd.py— kill + cleanup, all tests green - Task 11:
ttyd.py— orphan detection, all tests green - Task 12:
main.py— skeleton + lifespan +/health, all tests green - Task 13:
main.py—GET/PATCH /api/state, all tests green - Task 14:
main.py—GET /api/sessions, all tests green - Task 15:
main.py— connect/disconnect endpoints, all tests green - Task 16:
main.py—POST /api/heartbeat, all tests green - Task 17: Integration tests written and passing
Ready for Phase 2 (Frontend) when all items above are checked.
Scope Boundary Reminder
Phase 1 does not include:
- Any HTML, CSS, or JavaScript files
- xterm.js integration
- Caddy configuration
- systemd service file
- PWA manifest
- Mobile layout or responsive breakpoints
- Bell visual indicators (amber dot, pulsing animation)
- Browser Notifications API
- The backtick key handler (noted in code comments for Phase 2)