55 KiB
Views Feature — Phase 1: Stable Device Identity + Data Model
Execution: Use the subagent-driven-development workflow to implement this plan.
Goal: Establish stable device identity and data model changes so the backend and data layer are ready for the Views UI work in Phase 2.
Architecture: Each muxplex instance gets a persistent UUID stored in ~/.config/muxplex/identity.json (outside federation sync boundary). All session keys change from positional remoteId:name to device_id:name uniformly for both local and remote sessions. Settings gain a views array (synced) and state gains an active_view field (per-device, not synced). Federation proxy endpoints switch from integer index to device_id string lookup.
Tech Stack: Python 3.12+ / FastAPI / pytest + pytest-asyncio / vanilla JS
Design reference: docs/plans/2026-04-15-views-design.md
Task 1: Create muxplex/identity.py — Device Identity Module
Files:
- Create:
muxplex/identity.py - Create:
muxplex/tests/test_identity.py
Step 1: Write the failing tests
Create muxplex/tests/test_identity.py:
"""
Tests for muxplex/identity.py — device identity management.
"""
import json
import uuid
from pathlib import Path
import pytest
import muxplex.identity as identity_mod
from muxplex.identity import (
IDENTITY_PATH,
load_device_id,
reset_device_id,
)
# ---------------------------------------------------------------------------
# Autouse fixture: redirect IDENTITY_PATH to tmp_path
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def redirect_identity_path(tmp_path, monkeypatch):
"""Redirect IDENTITY_PATH to a temporary file for all tests."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
return fake_path
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_load_creates_file_when_absent(tmp_path, monkeypatch):
"""load_device_id() creates identity.json with a valid UUID when no file exists."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
device_id = load_device_id()
assert fake_path.exists()
# Must be a valid UUID
uuid.UUID(device_id)
def test_load_returns_same_id_on_repeated_calls(tmp_path, monkeypatch):
"""load_device_id() returns the same device_id on repeated calls."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
first = load_device_id()
second = load_device_id()
assert first == second
def test_load_reads_existing_file(tmp_path, monkeypatch):
"""load_device_id() reads an existing identity.json without overwriting."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
expected_id = str(uuid.uuid4())
fake_path.write_text(json.dumps({"device_id": expected_id}))
assert load_device_id() == expected_id
def test_load_creates_parent_dirs(tmp_path, monkeypatch):
"""load_device_id() creates parent directories if needed."""
nested_path = tmp_path / "a" / "b" / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", nested_path)
load_device_id()
assert nested_path.exists()
def test_load_regenerates_on_corrupt_json(tmp_path, monkeypatch):
"""load_device_id() generates a new id when identity.json is corrupt."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
fake_path.write_text("not valid json{{{")
device_id = load_device_id()
uuid.UUID(device_id) # must be valid
def test_load_regenerates_on_missing_key(tmp_path, monkeypatch):
"""load_device_id() generates a new id when device_id key is missing from JSON."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
fake_path.write_text(json.dumps({"other_key": "value"}))
device_id = load_device_id()
uuid.UUID(device_id) # must be valid
def test_reset_generates_new_id(tmp_path, monkeypatch):
"""reset_device_id() writes a new UUID different from the previous one."""
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
original = load_device_id()
new_id = reset_device_id()
assert new_id != original
uuid.UUID(new_id) # must be valid
# File on disk must match
data = json.loads(fake_path.read_text())
assert data["device_id"] == new_id
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_identity.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'muxplex.identity'
Step 3: Write the implementation
Create muxplex/identity.py:
"""
Device identity management for muxplex.
Each muxplex instance gets a persistent device_id (UUID v4) stored in
~/.config/muxplex/identity.json. This file is explicitly outside the
federation settings sync boundary — it is never synced, never overwritten
by settings propagation.
The device_id is generated once on first startup and never regenerated
automatically. The --reset-device-id CLI command can generate a new one
for the "I copied my dotfiles" scenario.
"""
import json
import uuid
from pathlib import Path
IDENTITY_PATH = Path.home() / ".config" / "muxplex" / "identity.json"
def load_device_id() -> str:
"""Load the device_id from identity.json, generating one if absent.
Creates the file and parent directories on first call.
Regenerates if the file is corrupt or missing the device_id key.
"""
try:
data = json.loads(IDENTITY_PATH.read_text())
device_id = data.get("device_id", "")
if device_id:
return device_id
except (FileNotFoundError, json.JSONDecodeError, OSError):
pass
# Generate and persist a new device_id
return reset_device_id()
def reset_device_id() -> str:
"""Generate a new device_id, write it to identity.json, and return it.
Overwrites any existing device_id. Creates parent directories if needed.
"""
device_id = str(uuid.uuid4())
IDENTITY_PATH.parent.mkdir(parents=True, exist_ok=True)
IDENTITY_PATH.write_text(json.dumps({"device_id": device_id}, indent=2) + "\n")
return device_id
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_identity.py -v
Expected: All 8 tests PASS
Step 5: Commit
cd muxplex && git add muxplex/identity.py muxplex/tests/test_identity.py && git commit -m "feat: add identity.py for stable device identity (UUID in identity.json)"
Task 2: Update state.py — New muxplex State Path + active_view Field
Files:
- Modify:
muxplex/state.py - Modify:
muxplex/tests/test_state.py
Step 1: Write the failing tests
Add to the end of muxplex/tests/test_state.py:
# ---------------------------------------------------------------------------
# active_view field in empty_state
# ---------------------------------------------------------------------------
def test_empty_state_has_active_view_key():
state = empty_state()
assert "active_view" in state
def test_empty_state_active_view_defaults_to_all():
state = empty_state()
assert state["active_view"] == "all"
# ---------------------------------------------------------------------------
# State path migration: tmux-web -> muxplex
# ---------------------------------------------------------------------------
def test_state_dir_uses_muxplex_name():
"""STATE_DIR default should use 'muxplex', not 'tmux-web'."""
import muxplex.state as state_mod
# The _default_state_dir (used when env var is not set) must contain 'muxplex'
assert "muxplex" in str(state_mod._default_state_dir)
assert "tmux-web" not in str(state_mod._default_state_dir)
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_state.py::test_empty_state_has_active_view_key muxplex/tests/test_state.py::test_empty_state_active_view_defaults_to_all muxplex/tests/test_state.py::test_state_dir_uses_muxplex_name -v
Expected: FAIL — active_view not in state, and tmux-web is still in _default_state_dir
Step 3: Apply the changes to muxplex/state.py
Change 1 — Update _default_state_dir (line 40):
# Before:
_default_state_dir = Path.home() / ".local" / "share" / "tmux-web"
# After:
_default_state_dir = Path.home() / ".local" / "share" / "muxplex"
Change 2 — Keep env var name for backward compatibility but update the variable name:
# Before:
STATE_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir))
# After:
STATE_DIR: Path = Path(os.environ.get("MUXPLEX_STATE_DIR", os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir)))
Change 3 — Add active_view to empty_state() (line 60-66):
def empty_state() -> dict:
"""Return a fresh, empty top-level state dict.
Every call returns a fully independent object — no shared mutables.
"""
return {
"active_session": None,
"active_remote_id": None,
"active_view": "all",
"session_order": [],
"sessions": {},
"devices": {},
}
Change 4 — Update the module docstring to include active_view in the schema (top of file, add after active_remote_id in the schema comment):
# "active_view": str, # "all" | "hidden" | view name
Step 4: Run all state tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_state.py -v
Expected: All tests PASS (including the 3 new ones and all existing ones)
Step 5: Commit
cd muxplex && git add muxplex/state.py muxplex/tests/test_state.py && git commit -m "feat: migrate state path to muxplex, add active_view field"
Task 3: Update settings.py — Add views to Settings + Remove filtered from gridViewMode
Files:
- Modify:
muxplex/settings.py - Modify:
muxplex/tests/test_settings.py
Step 1: Write the failing tests
Add to the end of muxplex/tests/test_settings.py:
# ---------------------------------------------------------------------------
# views in DEFAULT_SETTINGS and SYNCABLE_KEYS
# ---------------------------------------------------------------------------
def test_views_in_default_settings():
"""DEFAULT_SETTINGS must include 'views' as an empty list."""
assert "views" in DEFAULT_SETTINGS
assert DEFAULT_SETTINGS["views"] == []
def test_views_in_syncable_keys():
"""'views' must be in SYNCABLE_KEYS so it syncs across federation."""
assert "views" in SYNCABLE_KEYS
def test_views_roundtrip_through_save_and_load(tmp_path, monkeypatch):
"""Views data survives a save/load cycle."""
fake_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path)
views_data = [
{"name": "Work", "sessions": ["abc:dev-server", "def:monitoring"]},
{"name": "Hobby", "sessions": ["abc:3d-printer"]},
]
save_settings({"views": views_data})
result = load_settings()
assert result["views"] == views_data
def test_patch_settings_syncs_views(tmp_path, monkeypatch):
"""Patching 'views' bumps settings_updated_at (because views is in SYNCABLE_KEYS)."""
fake_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path)
result = patch_settings({"views": [{"name": "Test", "sessions": []}]})
assert result["views"] == [{"name": "Test", "sessions": []}]
assert result["settings_updated_at"] > 0
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_settings.py::test_views_in_default_settings muxplex/tests/test_settings.py::test_views_in_syncable_keys -v
Expected: FAIL — views not in DEFAULT_SETTINGS or SYNCABLE_KEYS
Step 3: Apply the changes to muxplex/settings.py
Change 1 — Add views to DEFAULT_SETTINGS (after "hidden_sessions": [] on line 24):
"hidden_sessions": [],
"views": [],
Change 2 — Add "views" to SYNCABLE_KEYS (in the Session behavior section, after "hidden_sessions"):
"hidden_sessions",
"views",
Step 4: Run all settings tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_settings.py -v
Expected: All tests PASS
Step 5: Commit
cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add views to DEFAULT_SETTINGS and SYNCABLE_KEYS"
Task 4: Create muxplex/views.py — Mutual Exclusion Invariant Functions
Files:
- Create:
muxplex/views.py - Create:
muxplex/tests/test_views.py
Step 1: Write the failing tests
Create muxplex/tests/test_views.py:
"""
Tests for muxplex/views.py — views invariant enforcement.
"""
import pytest
from muxplex.views import (
enforce_mutual_exclusion,
validate_view_name,
)
# ---------------------------------------------------------------------------
# enforce_mutual_exclusion
# ---------------------------------------------------------------------------
def test_enforce_removes_from_hidden_when_in_view():
"""If a session is in both hidden_sessions and a view, remove from hidden (favor visibility)."""
settings = {
"hidden_sessions": ["abc:dev", "def:build"],
"views": [
{"name": "Work", "sessions": ["abc:dev", "abc:web"]},
],
}
result = enforce_mutual_exclusion(settings)
assert "abc:dev" not in result["hidden_sessions"]
assert "def:build" in result["hidden_sessions"]
assert "abc:dev" in result["views"][0]["sessions"]
def test_enforce_no_change_when_no_overlap():
"""No changes when there is no overlap between hidden and views."""
settings = {
"hidden_sessions": ["abc:old"],
"views": [
{"name": "Work", "sessions": ["abc:dev"]},
],
}
result = enforce_mutual_exclusion(settings)
assert result["hidden_sessions"] == ["abc:old"]
assert result["views"][0]["sessions"] == ["abc:dev"]
def test_enforce_handles_empty_views():
"""Works when views is an empty list."""
settings = {
"hidden_sessions": ["abc:dev"],
"views": [],
}
result = enforce_mutual_exclusion(settings)
assert result["hidden_sessions"] == ["abc:dev"]
def test_enforce_handles_empty_hidden():
"""Works when hidden_sessions is empty."""
settings = {
"hidden_sessions": [],
"views": [{"name": "Work", "sessions": ["abc:dev"]}],
}
result = enforce_mutual_exclusion(settings)
assert result["hidden_sessions"] == []
def test_enforce_deduplicates_view_sessions():
"""Duplicate session keys within a view are deduplicated."""
settings = {
"hidden_sessions": [],
"views": [
{"name": "Work", "sessions": ["abc:dev", "abc:dev", "abc:web"]},
],
}
result = enforce_mutual_exclusion(settings)
assert result["views"][0]["sessions"] == ["abc:dev", "abc:web"]
def test_enforce_overlap_across_multiple_views():
"""A hidden session appearing in multiple views is removed from hidden."""
settings = {
"hidden_sessions": ["abc:dev"],
"views": [
{"name": "Work", "sessions": ["abc:dev"]},
{"name": "Hobby", "sessions": ["abc:dev", "abc:printer"]},
],
}
result = enforce_mutual_exclusion(settings)
assert "abc:dev" not in result["hidden_sessions"]
# ---------------------------------------------------------------------------
# validate_view_name
# ---------------------------------------------------------------------------
def test_validate_rejects_empty_name():
assert validate_view_name("", []) is not None
def test_validate_rejects_whitespace_only():
assert validate_view_name(" ", []) is not None
def test_validate_rejects_too_long():
assert validate_view_name("a" * 31, []) is not None
def test_validate_rejects_reserved_all():
assert validate_view_name("all", []) is not None
def test_validate_rejects_reserved_hidden():
assert validate_view_name("Hidden", []) is not None
def test_validate_rejects_duplicate():
existing = [{"name": "Work", "sessions": []}]
assert validate_view_name("Work", existing) is not None
def test_validate_accepts_valid_name():
assert validate_view_name("My Project", []) is None
def test_validate_trims_whitespace():
"""A name that is valid after trimming should pass."""
assert validate_view_name(" My Project ", []) is None
def test_validate_accepts_at_max_length():
assert validate_view_name("a" * 30, []) is None
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_views.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'muxplex.views'
Step 3: Write the implementation
Create muxplex/views.py:
"""
Views invariant enforcement and validation for muxplex.
Core invariants:
- hidden_sessions and any views[].sessions never share a session key.
- View names are non-empty, max 30 chars, trimmed, unique, not reserved.
- Duplicate session keys within a view are deduplicated.
"""
RESERVED_VIEW_NAMES = frozenset({"all", "hidden"})
MAX_VIEW_NAME_LENGTH = 30
def enforce_mutual_exclusion(settings: dict) -> dict:
"""Enforce that hidden_sessions and view sessions are disjoint.
If a session key appears in both hidden_sessions and any view,
it is removed from hidden_sessions (favor visibility over hiding).
Also deduplicates session keys within each view.
Mutates and returns the settings dict.
"""
views = settings.get("views", [])
hidden = settings.get("hidden_sessions", [])
# Collect all session keys across all views
all_view_sessions: set[str] = set()
for view in views:
all_view_sessions.update(view.get("sessions", []))
# Remove overlap from hidden (favor visibility)
if all_view_sessions and hidden:
settings["hidden_sessions"] = [
s for s in hidden if s not in all_view_sessions
]
# Deduplicate session keys within each view (preserve order)
for view in views:
sessions = view.get("sessions", [])
seen: set[str] = set()
deduped: list[str] = []
for s in sessions:
if s not in seen:
seen.add(s)
deduped.append(s)
view["sessions"] = deduped
return settings
def validate_view_name(name: str, existing_views: list[dict]) -> str | None:
"""Validate a view name. Returns an error message string, or None if valid.
Rules:
- Non-empty after trimming
- Max 30 characters after trimming
- Not a reserved name ("all", "hidden") case-insensitive
- Unique among existing views (case-sensitive match)
"""
trimmed = name.strip()
if not trimmed:
return "View name cannot be empty"
if len(trimmed) > MAX_VIEW_NAME_LENGTH:
return f"View name must be {MAX_VIEW_NAME_LENGTH} characters or fewer"
if trimmed.lower() in RESERVED_VIEW_NAMES:
return f"'{trimmed}' is a reserved name"
existing_names = {v.get("name", "") for v in existing_views}
if trimmed in existing_names:
return f"A view named '{trimmed}' already exists"
return None
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_views.py -v
Expected: All 15 tests PASS
Step 5: Commit
cd muxplex && git add muxplex/views.py muxplex/tests/test_views.py && git commit -m "feat: add views.py with mutual exclusion invariant and name validation"
Task 5: Extend /api/instance-info to Return device_id
Files:
- Modify:
muxplex/main.py(lines 866–880) - Modify:
muxplex/tests/test_api.py
Step 1: Write the failing test
Add to muxplex/tests/test_api.py, after the existing instance-info tests (after line ~1293):
def test_instance_info_includes_device_id(client, tmp_path, monkeypatch):
"""GET /api/instance-info returns a device_id field."""
import muxplex.identity as identity_mod
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", tmp_path / "identity.json")
response = client.get("/api/instance-info")
assert response.status_code == 200
data = response.json()
assert "device_id" in data, f"Response must include 'device_id', got: {data}"
# Must be a non-empty string
assert isinstance(data["device_id"], str) and len(data["device_id"]) > 0
Step 2: Run test to verify it fails
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_instance_info_includes_device_id -v
Expected: FAIL — device_id not in response
Step 3: Apply the change to muxplex/main.py
Change 1 — Add import at the top of main.py (in the imports section, after the settings imports around line 67–73):
from muxplex.identity import load_device_id
Change 2 — Modify the instance_info() function (line 866–880):
@app.get("/api/instance-info")
async def instance_info() -> dict:
"""Return this instance's display name, version, and device identity.
Public endpoint (no auth required) — used by remote instances to
discover peer names, verify reachability, and obtain device_id.
"""
settings = load_settings()
# Read fresh so the UI reflects key-file changes without requiring a restart.
fed_key = load_federation_key()
return {
"name": settings["device_name"],
"device_id": load_device_id(),
"version": app.version,
"federation_enabled": bool(fed_key),
}
Step 4: Run the test to verify it passes
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_instance_info_includes_device_id -v
Expected: PASS
Step 5: Run the full instance-info test suite to check for regressions
cd muxplex && python -m pytest muxplex/tests/test_api.py -k "instance_info" -v
Expected: All instance-info tests PASS
Step 6: Commit
cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: include device_id in /api/instance-info response"
Task 6: Add --reset-device-id CLI Command
Files:
- Modify:
muxplex/cli.py - Modify:
muxplex/tests/test_cli.py
Step 1: Write the failing tests
Add to muxplex/tests/test_cli.py:
# ---------------------------------------------------------------------------
# reset-device-id subcommand tests
# ---------------------------------------------------------------------------
def test_reset_device_id_writes_new_id(tmp_path, monkeypatch, capsys):
"""reset_device_id_command() generates a new device_id and prints confirmation."""
import json
import muxplex.identity as identity_mod
from muxplex.cli import reset_device_id_command
fake_path = tmp_path / "identity.json"
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path)
# Create an initial identity
original_id = identity_mod.load_device_id()
# Reset it
reset_device_id_command()
# File must have a different device_id
data = json.loads(fake_path.read_text())
assert data["device_id"] != original_id
# Output must mention warning about orphaned keys
captured = capsys.readouterr()
assert "device_id" in captured.out.lower() or "identity" in captured.out.lower()
assert "warning" in captured.out.lower() or "orphan" in captured.out.lower()
def test_main_dispatches_to_reset_device_id(monkeypatch):
"""main() with 'reset-device-id' subcommand must invoke reset_device_id_command()."""
import muxplex.cli as cli_mod
calls = []
monkeypatch.setattr(cli_mod, "reset_device_id_command", lambda: calls.append(True))
with patch("sys.argv", ["muxplex", "reset-device-id"]):
cli_mod.main()
assert calls, (
"reset_device_id_command() must be called for 'reset-device-id' subcommand"
)
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_reset_device_id_writes_new_id muxplex/tests/test_cli.py::test_main_dispatches_to_reset_device_id -v
Expected: FAIL — ImportError: cannot import name 'reset_device_id_command'
Step 3: Apply the changes to muxplex/cli.py
Change 1 — Add the function (after the reset_secret() function, around line 150):
def reset_device_id_command() -> None:
"""Regenerate the device identity UUID and warn about orphaned session keys."""
from muxplex.identity import IDENTITY_PATH, load_device_id, reset_device_id
old_id = None
try:
old_id = load_device_id()
except Exception:
pass
new_id = reset_device_id()
print(f"New device_id: {new_id}")
print(f"Identity file: {IDENTITY_PATH}")
if old_id:
print(f"Previous device_id: {old_id}")
print("Warning: session keys in views and hidden_sessions that referenced")
print("the old device_id are now orphaned and will not match this instance.")
Change 2 — Register the subparser (after the reset-secret parser registration, around line 954):
sub.add_parser(
"reset-device-id",
help="Regenerate device identity UUID (orphans existing session keys)",
)
Change 3 — Add dispatch (after the reset-secret dispatch, around line 1007):
elif args.command == "reset-device-id":
reset_device_id_command()
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_reset_device_id_writes_new_id muxplex/tests/test_cli.py::test_main_dispatches_to_reset_device_id -v
Expected: Both PASS
Step 5: Commit
cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: add --reset-device-id CLI command"
Task 7: Wire Post-Sync Invariant Repair into Settings Sync
Files:
- Modify:
muxplex/settings.py - Modify:
muxplex/tests/test_settings.py
Step 1: Write the failing test
Add to muxplex/tests/test_settings.py:
def test_apply_synced_settings_enforces_mutual_exclusion(tmp_path, monkeypatch):
"""apply_synced_settings() runs mutual exclusion repair after applying synced data."""
import json
fake_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path)
# Pre-populate settings with a hidden session
save_settings({"hidden_sessions": ["abc:dev"], "views": []})
# Incoming sync adds a view containing the hidden session
incoming = {
"views": [{"name": "Work", "sessions": ["abc:dev"]}],
"hidden_sessions": ["abc:dev"],
}
result = apply_synced_settings(incoming, 999.0)
# Mutual exclusion: abc:dev should be removed from hidden_sessions
assert "abc:dev" not in result["hidden_sessions"]
assert "abc:dev" in result["views"][0]["sessions"]
Step 2: Run test to verify it fails
cd muxplex && python -m pytest muxplex/tests/test_settings.py::test_apply_synced_settings_enforces_mutual_exclusion -v
Expected: FAIL — abc:dev still in hidden_sessions
Step 3: Apply the change to muxplex/settings.py
Change the apply_synced_settings function (line 162–174) to call enforce_mutual_exclusion after applying synced keys:
def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) -> dict:
"""Apply synced settings from a remote server.
Only applies keys that are in SYNCABLE_KEYS. Sets settings_updated_at
to the incoming timestamp (NOT time.time()) to prevent sync loops.
Runs mutual exclusion invariant repair after applying.
"""
from muxplex.views import enforce_mutual_exclusion
current = load_settings()
for key in SYNCABLE_KEYS:
if key in incoming_settings:
current[key] = incoming_settings[key]
current["settings_updated_at"] = incoming_timestamp
enforce_mutual_exclusion(current)
save_settings(current)
return current
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_settings.py -v
Expected: All tests PASS
Step 5: Commit
cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: run mutual exclusion invariant repair after settings sync"
Task 8: Federation Proxy — Helper Function to Look Up Remote by device_id
Files:
- Modify:
muxplex/main.py - Modify:
muxplex/tests/test_api.py
This task adds a shared helper function that looks up a remote instance by device_id instead of integer index. Tasks 9 and 10 will use this helper to rewrite the federation endpoints.
Step 1: Write the failing test
Add to muxplex/tests/test_api.py:
# ---------------------------------------------------------------------------
# _lookup_remote_by_device_id helper
# ---------------------------------------------------------------------------
def test_lookup_remote_by_device_id_found(tmp_path, monkeypatch):
"""_lookup_remote_by_device_id returns the remote dict when device_id matches."""
import json
import muxplex.main as main_mod
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(
json.dumps(
{
"remote_instances": [
{"url": "https://pi:8088", "name": "pi", "device_id": "aaa-111"},
{"url": "https://desktop:8088", "name": "desktop", "device_id": "bbb-222"},
]
}
)
)
remote = main_mod._lookup_remote_by_device_id("bbb-222")
assert remote is not None
assert remote["name"] == "desktop"
def test_lookup_remote_by_device_id_not_found(tmp_path, monkeypatch):
"""_lookup_remote_by_device_id returns None when no remote matches."""
import json
import muxplex.main as main_mod
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(
json.dumps(
{
"remote_instances": [
{"url": "https://pi:8088", "name": "pi", "device_id": "aaa-111"},
]
}
)
)
remote = main_mod._lookup_remote_by_device_id("zzz-999")
assert remote is None
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_lookup_remote_by_device_id_found muxplex/tests/test_api.py::test_lookup_remote_by_device_id_not_found -v
Expected: FAIL — AttributeError: module 'muxplex.main' has no attribute '_lookup_remote_by_device_id'
Step 3: Add the helper function to muxplex/main.py
Add this function before the federation proxy section (before the federation_terminal_ws_proxy function, around line 1008):
def _lookup_remote_by_device_id(device_id: str) -> dict | None:
"""Look up a remote instance by device_id.
Returns the remote dict from remote_instances if found, None otherwise.
Supports both the new device_id-based lookup and falls back to integer
index lookup during the transition period.
"""
settings = load_settings()
remotes = settings.get("remote_instances", [])
# Primary: match by device_id field
for remote in remotes:
if remote.get("device_id") == device_id:
return remote
# Fallback: if device_id looks like an integer, try index-based lookup
# (transition compatibility for old-format URLs)
try:
idx = int(device_id)
if 0 <= idx < len(remotes):
return remotes[idx]
except (ValueError, TypeError):
pass
return None
Step 4: Run tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_lookup_remote_by_device_id_found muxplex/tests/test_api.py::test_lookup_remote_by_device_id_not_found -v
Expected: Both PASS
Step 5: Commit
cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add _lookup_remote_by_device_id helper for federation proxy"
Task 9: Federation Proxy Endpoints — Switch to device_id Lookup
Files:
- Modify:
muxplex/main.py(federation_connect, federation_bell_clear, federation_create_session, federation_delete_session, federation_terminal_ws_proxy) - Modify:
muxplex/tests/test_api.py
This task changes all federation proxy URL patterns from /api/federation/{remote_id:int}/... to /api/federation/{device_id}/... and uses _lookup_remote_by_device_id() instead of array indexing. The integer fallback in the helper ensures backward compatibility during migration.
Step 1: Write the failing test
Add to muxplex/tests/test_api.py:
def test_federation_connect_by_device_id(client, tmp_path, monkeypatch):
"""POST /api/federation/{device_id}/connect/{session} accepts device_id strings."""
import json
import httpx
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(
json.dumps(
{
"remote_instances": [
{
"url": "https://pi:8088",
"name": "pi",
"key": "test-key",
"device_id": "aaa-111-bbb",
}
]
}
)
)
# Mock the federation client to return a success response
async def mock_post(url, **kwargs):
resp = httpx.Response(200, json={"status": "connected"})
return resp
monkeypatch.setattr(
client.app.state, "federation_client", type("MockClient", (), {"post": mock_post})()
)
response = client.post("/api/federation/aaa-111-bbb/connect/my-session")
assert response.status_code == 200
def test_federation_connect_device_id_not_found(client, tmp_path, monkeypatch):
"""POST /api/federation/{device_id}/connect/{session} returns 404 for unknown device_id."""
import json
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(json.dumps({"remote_instances": []}))
response = client.post("/api/federation/nonexistent-device/connect/my-session")
assert response.status_code == 404
Step 2: Run tests to verify they fail
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_connect_by_device_id muxplex/tests/test_api.py::test_federation_connect_device_id_not_found -v
Expected: FAIL — the endpoint expects int type for remote_id, not a UUID string. The request will fail with a 422 validation error.
Step 3: Apply the changes to muxplex/main.py
For each of the four federation HTTP proxy endpoints, change:
- The route path parameter from
{remote_id}to{device_id} - The function parameter from
remote_id: inttodevice_id: str - The lookup from
remotes[remote_id]to_lookup_remote_by_device_id(device_id)
federation_connect (line ~1337):
@app.post("/api/federation/{device_id}/connect/{session_name}")
async def federation_connect(
device_id: str, session_name: str, request: Request
) -> dict:
"""Proxy a connect POST to a remote instance to spawn its ttyd.
Looks up the remote by device_id in remote_instances settings,
sends POST {remote_url}/api/sessions/{session_name}/connect with a
Bearer auth header, and returns the remote's JSON response.
Raises HTTP 404 if device_id is not found in remote_instances.
"""
remote = _lookup_remote_by_device_id(device_id)
if remote is None:
raise HTTPException(
status_code=404,
detail=f"Remote instance '{device_id}' not found",
)
remote_url: str = remote.get("url", "").rstrip("/")
remote_key: str = remote.get("key", "")
url = f"{remote_url}/api/sessions/{session_name}/connect"
http_client: httpx.AsyncClient = request.app.state.federation_client
try:
resp = await http_client.post(
url,
headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {},
)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
raise HTTPException(
status_code=502,
detail=f"Remote returned {exc.response.status_code}",
)
except Exception as exc:
_log.warning("federation_connect: remote %s unreachable: %s", remote_url, exc)
raise HTTPException(
status_code=503,
detail=f"Remote unreachable: {remote_url} ({type(exc).__name__}: {exc})",
)
Apply the same pattern to:
federation_bell_clear (line ~1383): Change route to "/api/federation/{device_id}/sessions/{session_name}/bell/clear", parameter to device_id: str, lookup to _lookup_remote_by_device_id(device_id) with the same None → 404 pattern.
federation_create_session (line ~1431): Change route to "/api/federation/{device_id}/sessions", parameter to device_id: str, lookup to _lookup_remote_by_device_id(device_id) with the same None → 404 pattern.
federation_delete_session (line ~1479): Change route to "/api/federation/{device_id}/sessions/{session_name}", parameter to device_id: str, lookup to _lookup_remote_by_device_id(device_id) with the same None → 404 pattern.
federation_terminal_ws_proxy (line ~1011): Change route to "/federation/{device_id}/terminal/ws", parameter to device_id: str, and change the lookup logic from array indexing to:
# Look up remote instance by device_id
remote = _lookup_remote_by_device_id(device_id)
if remote is None:
await websocket.close(code=4004)
return
Remove the old settings = load_settings() / remote_instances / bounds-check block and replace with the above.
Step 4: Run the new tests to verify they pass
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_connect_by_device_id muxplex/tests/test_api.py::test_federation_connect_device_id_not_found -v
Expected: Both PASS
Step 5: Run the full API test suite to check for regressions
cd muxplex && python -m pytest muxplex/tests/test_api.py -v --timeout=60
Expected: All tests PASS. Existing federation tests that use integer indices will still work because _lookup_remote_by_device_id has an integer fallback.
Step 6: Commit
cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: switch federation proxy endpoints from integer index to device_id lookup"
Task 10: Update fetch_remote() to Tag Sessions with device_id
Files:
- Modify:
muxplex/main.py(thefetch_remoteinner function and_federation_cache, lines ~1185–1304) - Modify:
muxplex/tests/test_api.py
This task changes the federation_sessions endpoint to tag each remote session with device_id instead of integer remoteId, and to generate sessionKey as device_id:name instead of remoteId:name. Local sessions also get tagged with the local device_id.
Step 1: Write the failing test
Add to muxplex/tests/test_api.py:
def test_federation_sessions_tags_local_with_device_id(client, tmp_path, monkeypatch):
"""GET /api/federation/sessions includes deviceId for local sessions."""
import json
import muxplex.identity as identity_mod
import muxplex.main as main_mod
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json")
monkeypatch.setattr(identity_mod, "IDENTITY_PATH", tmp_path / "identity.json")
# Write identity file
(tmp_path / "identity.json").write_text(json.dumps({"device_id": "local-uuid"}))
# Mock get_session_list to return one session
monkeypatch.setattr(main_mod, "get_session_list", lambda: ["dev"])
monkeypatch.setattr(main_mod, "get_snapshots", lambda: {})
response = client.get("/api/federation/sessions")
assert response.status_code == 200
data = response.json()
local_sessions = [s for s in data if s.get("name") == "dev"]
assert len(local_sessions) == 1
assert local_sessions[0].get("deviceId") == "local-uuid"
assert local_sessions[0].get("sessionKey") == "local-uuid:dev"
Step 2: Run test to verify it fails
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_sessions_tags_local_with_device_id -v
Expected: FAIL — local sessions don't have deviceId or the new sessionKey format
Step 3: Apply the changes to muxplex/main.py
Change 1 — In the federation_sessions function (line ~1192), add local device_id import and tagging:
@app.get("/api/federation/sessions")
async def federation_sessions(request: Request) -> list[dict]:
"""Fetch sessions from all instances (local + remotes) and merge.
Local sessions are tagged with deviceName, deviceId, and sessionKey.
Remote sessions are fetched concurrently via asyncio.gather with Bearer auth
headers. Failed remotes produce a status entry with status='unreachable' or
status='auth_failed'.
"""
settings = load_settings()
local_device_name: str = settings.get("device_name", "")
local_device_id: str = load_device_id()
remote_instances: list[dict] = settings.get("remote_instances", [])
# Build local sessions with deviceName/deviceId/sessionKey tags
names = get_session_list()
snapshots = get_snapshots()
state = await read_state()
local_sessions: list[dict] = []
for name in names:
session_state = state.get("sessions", {}).get(name, {})
bell = session_state.get("bell", empty_bell())
local_sessions.append(
{
"name": name,
"snapshot": snapshots.get(name, ""),
"bell": bell,
"deviceName": local_device_name,
"deviceId": local_device_id,
"remoteId": None,
"sessionKey": f"{local_device_id}:{name}",
}
)
Change 2 — In the fetch_remote inner function, use device_id from the remote instance for tagging instead of the integer index. Change the remote_id: int = i line and all references:
async def fetch_remote(i: int, remote: dict) -> list[dict]:
url: str = remote.get("url", "")
key: str = remote.get("key", "")
remote_name: str = remote.get("name", url)
remote_device_id: str = remote.get("device_id", str(i))
# ... rest of function uses remote_device_id instead of remote_id
In the tagged session list comprehension:
tagged = [
{
**s,
"deviceName": remote_name,
"deviceId": remote_device_id,
"remoteId": remote_device_id,
"sessionKey": f"{remote_device_id}:{s.get('name', '')}",
}
for s in sessions
]
In all status entries (auth_failed, empty, unreachable), change "remoteId": remote_id to "remoteId": remote_device_id and add "deviceId": remote_device_id.
Change 3 — Update _federation_cache type hint from dict[int, dict] to dict[str, dict] (line ~1188):
_federation_cache: dict[str, dict] = {}
And change all cache key references from remote_id to remote_device_id.
Step 4: Run the new test to verify it passes
cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_sessions_tags_local_with_device_id -v
Expected: PASS
Step 5: Run the full API test suite
cd muxplex && python -m pytest muxplex/tests/test_api.py -v --timeout=60
Expected: All tests PASS. Existing tests that check remoteId will still pass because we kept the remoteId field (now containing device_id string instead of integer, but the existing tests that create mock remotes will work with the string fallback).
Note to implementer: Some existing federation tests may need minor adjustments if they assert
remoteIdis an integer. If a test fails, check if it's assertingremoteId == 0(integer) — change the assertion to match the newdevice_idstring. The_federation_cachekey type change may also require updating existing test expectations.
Step 6: Commit
cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: tag sessions with device_id-based sessionKey in federation_sessions"
Task 11: Frontend — Change Session Key Format to device_id:name
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/tests/test_frontend_js.py
The backend now sends deviceId and sessionKey in device_id:name format on every session object. The frontend needs to:
- Use
session.deviceIdinstead ofsession.remoteIdfor federation API calls - Use
session.sessionKey(already present from the backend) instead of constructing keys locally - Update
data-remote-idattributes to usedeviceId - Update
createNewSession()andkillSession()to usedeviceIdin API URLs - Update
openSession()to acceptdeviceIdinstead ofremoteId - Update state patches to use
deviceIdforactive_remote_id
Step 1: Write the failing test
Add to muxplex/tests/test_frontend_js.py (find the section for session key tests or add at the end):
def test_session_tile_uses_device_id_in_data_attribute(page):
"""Session tiles use deviceId in data-remote-id attribute."""
page.evaluate("""() => {
const app = window.MuxplexApp;
app._setServerSettings({
hidden_sessions: [],
sort_order: 'manual',
});
app._setCurrentSessions([
{ name: 'dev', deviceName: 'pi', deviceId: 'abc-123', remoteId: 'abc-123', sessionKey: 'abc-123:dev', bell: { unseen_count: 0 } },
]);
}""")
# Render the grid
page.evaluate("() => window.MuxplexApp.renderGrid()")
tile = page.query_selector('[data-session="dev"]')
assert tile is not None
remote_id_attr = tile.get_attribute("data-remote-id")
assert remote_id_attr == "abc-123"
Note to implementer: The frontend JS test file uses a specific test framework. Look at the top of
muxplex/tests/test_frontend_js.pyto understand the exact test setup pattern (it may usesubprocessto run Node tests, or pytest with a browser fixture). Match whatever pattern exists. If the test file uses Node.js + jsdom or a similar approach, adapt the test accordingly. The assertion content is what matters — verifydata-remote-iduses thedeviceIdstring.
Step 2: Run the test to verify it fails
Run the existing frontend JS test suite first to understand the baseline:
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "device_id" -v
Step 3: Apply the changes to muxplex/frontend/app.js
This is a large set of find-and-replace changes. The key transformations:
Change 1 — In createNewSession() (line ~2089–2092), replace remoteId with deviceId in the API URL construction:
// Before:
remoteId = remoteId || '';
try {
var endpoint = remoteId ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions' : '/api/sessions';
// After:
var deviceId = remoteId || ''; // Accept either name during transition
try {
var endpoint = deviceId ? '/api/federation/' + encodeURIComponent(deviceId) + '/sessions' : '/api/sessions';
Change 2 — In openSession() (line ~1228–1232), use deviceId for the federation connect URL:
// Before:
var _remoteId = opts.remoteId != null ? opts.remoteId : '';
try {
if (_remoteId !== '') {
// After:
var _deviceId = opts.remoteId != null ? opts.remoteId : '';
try {
if (_deviceId !== '') {
And update the /api/federation/ URL to use _deviceId.
Change 3 — In killSession() function, update the federation delete URL to use deviceId:
Find the federation delete endpoint URL construction and change remoteId to deviceId from the session data.
Change 4 — In getVisibleSessions() (line ~537–547), update hidden session matching to use sessionKey instead of name:
// Before:
if (hidden.length > 0 && hidden.includes(s.name)) {
// After:
if (hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name))) {
This provides backward compatibility — hidden_sessions may contain either old format (plain name) or new format (device_id:name).
Change 5 — In the _viewingRemoteId state variable and its usage, rename conceptually to represent device_id. Since this is used throughout the file, the simplest approach is to keep the variable name _viewingRemoteId but ensure it stores the deviceId value. The data-remote-id attributes already work because the backend now sends deviceId as the remoteId value.
Change 6 — In the PATCH /api/state call that sets active_remote_id, ensure it sends the device_id string:
// The value stored is already opts.remoteId which now contains deviceId
// No code change needed if the backend accepts strings for active_remote_id
Important: The backend's
StatePatchmodel hasactive_remote_id: str | None(line 428 of main.py), so it already accepts strings. No backend change needed here.
Step 4: Run the frontend tests
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -v --timeout=120
Expected: All tests PASS. The deviceId field is backward-compatible because the backend now sends it alongside remoteId.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: update frontend to use device_id-based session keys and API URLs"
Task 12: Remove filtered from gridViewMode Options
Files:
- Modify:
muxplex/frontend/app.js - Modify:
muxplex/tests/test_frontend_js.py
The design removes filtered as a gridViewMode value. Only flat and grouped remain.
Step 1: Write the failing test
Add to muxplex/tests/test_frontend_js.py:
def test_grid_view_mode_filtered_not_available(page):
"""gridViewMode 'filtered' should not be recognized — defaults to 'flat'."""
page.evaluate("""() => {
const app = window.MuxplexApp;
app._setGridViewMode('filtered');
}""")
mode = page.evaluate("() => window.MuxplexApp._getGridViewMode()")
# After removing filtered, setting it should fall back to 'flat'
assert mode == "flat"
Step 2: Run test to verify it fails
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "filtered_not_available" -v
Expected: FAIL — _setGridViewMode('filtered') currently accepts the value
Step 3: Apply the changes to muxplex/frontend/app.js
Change 1 — Remove all filtered-specific code paths in renderGrid() (lines ~761-763, ~780-784, ~822-823):
Remove:
// In filtered mode, apply device filter
if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') {
visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; });
}
Remove all if (_gridViewMode === 'filtered') blocks that render the filter bar.
Change 2 — In loadGridViewMode() (line ~1547), add a guard:
function loadGridViewMode() {
var ds = getDisplaySettings();
var mode = ds.gridViewMode || 'flat';
// 'filtered' was removed in the Views feature — fall back to 'flat'
if (mode === 'filtered') mode = 'flat';
return mode;
}
Change 3 — In _setGridViewMode() test helper, add the same guard:
function _setGridViewMode(mode) {
if (mode === 'filtered') mode = 'flat';
_gridViewMode = mode;
}
Change 4 — In DISPLAY_DEFAULTS (line ~149), the comment already says 'flat' | 'grouped'. No change needed there.
Change 5 — Remove the _activeFilterDevice state variable and renderFilterBar function, or leave them as dead code for now (they'll be removed when the filter bar HTML is removed in Phase 2). The safer approach is to leave them and let Phase 2 clean up the HTML. Just ensure _gridViewMode never gets set to 'filtered'.
Step 4: Run the frontend tests
cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -v --timeout=120
Expected: All tests PASS. Tests that explicitly test filtered mode should be updated to expect flat fallback behavior.
Note to implementer: If existing tests assert that setting gridViewMode to 'filtered' works, update those tests to expect 'flat' instead. Search for
'filtered'intest_frontend_js.py.
Step 5: Commit
cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: remove 'filtered' gridViewMode, keep only flat and grouped"
Task 13: Final Integration — Run Full Test Suite
Files: None (verification only)
Step 1: Run the complete test suite
cd muxplex && python -m pytest muxplex/tests/ -v --timeout=120
Expected: All tests PASS
Step 2: Run quality checks
cd muxplex && python -m ruff check muxplex/
cd muxplex && python -m ruff format --check muxplex/
Expected: No errors. Fix any formatting or lint issues.
Step 3: Verify the data flow end-to-end
Check that the full chain works by listing what Phase 1 established:
identity.pycreates/loads device_id from~/.config/muxplex/identity.json✓state.pyuses~/.local/share/muxplex/and includesactive_view✓settings.pyincludesviewsin DEFAULT_SETTINGS and SYNCABLE_KEYS ✓views.pyenforces mutual exclusion between hidden and view sessions ✓/api/instance-inforeturnsdevice_id✓--reset-device-idCLI command works ✓- Post-sync invariant repair runs after
apply_synced_settings()✓ - Federation proxy endpoints accept
device_idstrings ✓ federation_sessionstags all sessions withdevice_id:namekeys ✓- Frontend uses
deviceIdfor API calls and session keys ✓ filteredgridViewMode removed ✓
Step 4: Commit any remaining fixes
cd muxplex && git add -A && git status
If there are uncommitted changes, commit them:
cd muxplex && git commit -m "chore: phase 1 integration fixes"
Deferred to Phase 2
The following are explicitly NOT in Phase 1:
- Session key migration logic — rewriting old positional
remoteId:namekeys inhidden_sessionsandsession_ordertodevice_id:name. The integer fallback in_lookup_remote_by_device_id()provides backward compatibility, so migration can happen in Phase 2 when the remote'sdevice_idis first discovered via/api/instance-info. - Header dropdown UI for view switching
- Tile flyout menu (
⋮button) - Add Sessions panel
getVisibleSessions()rewrite to filter by active view- Settings dialog — Manage Views tab
- Mobile variants — bottom sheets
- Config path migration (moving files from
~/.local/share/tmux-web/to~/.local/share/muxplex/) — theMUXPLEX_STATE_DIRenv var and the fallback toTMUX_WEB_STATE_DIRprovide compatibility. Actual file migration (copying old state.json to new location) can be added as a startup step in Phase 2 once the identity system is stable.