diff --git a/config/vulture-whitelist.py b/config/vulture-whitelist.py index 820dd1eb..e49da481 100644 --- a/config/vulture-whitelist.py +++ b/config/vulture-whitelist.py @@ -42,6 +42,7 @@ spans_with_candidates spans_without_candidates with_vs_without_median_drain_ratio +installed # HTTP handlers are selected by BaseHTTPRequestHandler or route dispatch maps. _.do_POST @@ -75,6 +76,7 @@ _._handle_context _._handle_context_settings _._handle_open_investigator +_._handle_health _._handle_status _._handle_dedupe_diagnostics _._handle_calls diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 261105b4..a5b3916d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -81,6 +81,26 @@ codex-usage-tracker serve-dashboard --no-refresh --open codex-usage-tracker serve-dashboard --no-context-api --open ``` +Keep the live dashboard available across terminal and Codex task lifetimes on +macOS with the user LaunchAgent commands: + +```bash +codex-usage-tracker dashboard-service install +codex-usage-tracker dashboard-service status +codex-usage-tracker dashboard-service uninstall +``` + +The persistent service defaults to `http://127.0.0.1:47821`, starts at login, +restarts after failure, and never opens a browser automatically. Installation +refuses an occupied port rather than silently changing the URL; use +`dashboard-service install --port PORT` for an explicit alternative. These +management actions are macOS-only. The foreground `serve-dashboard` command is +cross-platform and keeps its existing default port `8765`. + +The persistent process starts with the cached aggregate index so the fixed URL +binds promptly. Its Refresh and Live controls remain the explicit way to rescan +Codex logs. + `serve-dashboard --context-api explicit` is the default and keeps context loading as an explicit per-row action. `serve-dashboard --no-context-api` or `--context-api disabled` starts with context loading off; a token-protected button in the local details panel can enable it without restarting the server. `open-dashboard` and `serve-dashboard` refresh active-session logs before opening by default. `serve-dashboard --open` opens the React dashboard; the legacy dashboard remains available at `/dashboard.html` on the same localhost server. Use `--no-refresh` only for an intentionally cached snapshot. The lower-level `dashboard` command writes from the current SQLite index and does not rescan logs. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 3418353b..2076d761 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -6,15 +6,37 @@ This guide uses synthetic aggregate data. The screenshots do not contain real pr ## Open The Dashboard -For the best experience, run the localhost dashboard server: +On macOS, install the persistent localhost dashboard once: ```bash codex-usage-tracker setup codex-usage-tracker update-pricing codex-usage-tracker update-rate-card +codex-usage-tracker dashboard-service install +codex-usage-tracker dashboard-service status +open http://127.0.0.1:47821 +``` + +The LaunchAgent starts at login, restarts after a failure, stays bound to +`127.0.0.1`, and does not open browser tabs automatically. If port `47821` is +already owned by another process, installation refuses the collision; use +`dashboard-service install --port PORT` to choose and persist a different +address. Remove the managed service with +`codex-usage-tracker dashboard-service uninstall`. + +Persistent startup serves the cached aggregate index immediately. Use Refresh +or Live in the dashboard to rescan Codex logs after the page is reachable. + +For Linux, Windows, or an on-demand macOS session, run the foreground server: + +```bash +codex-usage-tracker setup codex-usage-tracker serve-dashboard --open ``` +The foreground command retains its existing default port `8765` and stops when +its process exits. + For optional allowance context, initialize a local template and copy values from Codex Usage or `/status`: ```bash diff --git a/docs/install.md b/docs/install.md index 52ba0413..b74bbe41 100644 --- a/docs/install.md +++ b/docs/install.md @@ -11,9 +11,15 @@ python -m pip install --user pipx python -m pipx ensurepath pipx install codex-usage-tracking codex-usage-tracker setup -codex-usage-tracker serve-dashboard --open +codex-usage-tracker dashboard-service install # macOS ``` +On macOS, the persistent service starts at login, restarts after a failure, and +keeps the live dashboard available at `http://127.0.0.1:47821` without opening +browser tabs automatically. Check it with +`codex-usage-tracker dashboard-service status`. On Linux and Windows, or for a +one-time macOS session, use `codex-usage-tracker serve-dashboard --open`. + Use the Python launcher that is normal for your platform: - macOS/Linux: `python3` may be the right command instead of `python`. @@ -112,6 +118,25 @@ codex-usage-tracker install-plugin --python .venv/bin/python --force ## Local Dashboard +On macOS, install the localhost-only login service once: + +```bash +codex-usage-tracker dashboard-service install +codex-usage-tracker dashboard-service status +open http://127.0.0.1:47821 +``` + +The service uses fixed port `47821` by default and never exposes a non-loopback +host. If another local process owns that port, installation stops with a clear +error instead of changing the URL; choose an explicit alternative with +`codex-usage-tracker dashboard-service install --port PORT`. To remove only the +tracker-managed LaunchAgent, run +`codex-usage-tracker dashboard-service uninstall`. + +The login service binds promptly from the cached aggregate index. Use the +dashboard's Refresh or Live controls when you want to rescan Codex logs; the +initial background process does not hold the port closed during a full rescan. + Generate a static dashboard: ```bash @@ -126,6 +151,9 @@ codex-usage-tracker serve-dashboard --open codex-usage-tracker serve-dashboard --no-context-api --open ``` +Foreground `serve-dashboard` remains the cross-platform, on-demand option and +retains its existing default port `8765`. + The server binds to localhost, requires a per-server token for refresh/context endpoints, and rejects non-loopback `Host` or cross-origin `Origin` headers. `--no-context-api` starts context loading off; the details panel can enable it later without restarting the server. diff --git a/docs/superpowers/plans/2026-07-17-persistent-dashboard-service.md b/docs/superpowers/plans/2026-07-17-persistent-dashboard-service.md new file mode 100644 index 00000000..1ded0398 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-persistent-dashboard-service.md @@ -0,0 +1,620 @@ +# Persistent Dashboard Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a macOS LaunchAgent workflow that keeps the localhost dashboard available at `http://127.0.0.1:47821` across Codex tasks, crashes, and user logins. + +**Architecture:** A focused `dashboard_service` module owns deterministic plist generation, local port checks, atomic managed-file updates, launchctl lifecycle calls, and health status. A thin CLI adapter exposes `dashboard-service install|status|uninstall`; the existing dashboard HTTP server remains unchanged and continues to enforce localhost/privacy controls. + +**Tech Stack:** Python 3.10+, standard-library `argparse`, `plistlib`, `socket`, `subprocess`, `urllib.request`, macOS `launchctl`, pytest. + +## Global Constraints + +- Keep `serve-dashboard` backward-compatible, including its existing default port `8765`. +- The persistent service defaults to `127.0.0.1:47821` and offers no non-loopback host option. +- Use LaunchAgent label `com.codex-usage-tracker.dashboard` and plist path `~/Library/LaunchAgents/com.codex-usage-tracker.dashboard.plist`. +- Start with `--context-api explicit --no-refresh`, never `--open`, and never put credentials or raw usage content in the plist or service logs. +- Use the absolute install-time Python interpreter and separate `ProgramArguments`; never invoke launchctl through a shell string. +- Refuse unknown port owners and never silently select another port or kill an unknown process. +- Automated tests use temporary homes and fakes/disposable sockets; they never load the developer's real LaunchAgent. +- Keep existing untracked `.idea/` and `.playwright-cli/` paths untouched. + +--- + +## File Structure + +- Create `src/codex_usage_tracker/dashboard_service.py`: constants, status model, paths, plist construction, port/HTTP probes, and macOS lifecycle functions. +- Create `src/codex_usage_tracker/cli/dashboard_service.py`: argparse namespace adapter and concise human-readable output. +- Modify `src/codex_usage_tracker/cli/parser_data.py`: nested `dashboard-service` parser. +- Modify `src/codex_usage_tracker/cli/parser.py`: register the new parser builder. +- Modify `src/codex_usage_tracker/cli/main.py`: dispatch the new command. +- Modify `src/codex_usage_tracker/cli/help_i18n.py`: localize new help strings consistently with existing CLI behavior. +- Create `tests/cli/test_dashboard_service.py`: pure configuration, collision, lifecycle, parser, dispatch, and privacy tests. +- Modify `docs/install.md`, `docs/dashboard-guide.md`, and `docs/cli-reference.md`: document the stable service URL and lifecycle. +- Modify both `skills/codex-usage-tracker/SKILL.md` and `src/codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md`: prefer a healthy persistent service for dashboard-open requests while retaining the foreground fallback. + +--- + +### Task 1: Deterministic LaunchAgent Configuration and Local Probes + +**Files:** +- Create: `src/codex_usage_tracker/dashboard_service.py` +- Create: `tests/cli/test_dashboard_service.py` + +**Interfaces:** +- Produces: `DEFAULT_SERVICE_PORT: int`, `SERVICE_LABEL: str`, `DashboardServicePaths`, `DashboardServiceStatus`, `service_paths(home: Path)`, `validate_service_port(port: int)`, `build_launch_agent(python: Path, home: Path, port: int)`, `port_is_available(port: int)`, and `dashboard_is_reachable(port: int)`. +- Consumes: standard-library types only. + +- [ ] **Step 1: Write failing tests for paths, plist privacy, port validation, collision detection, and HTTP probing** + +```python +from __future__ import annotations + +import contextlib +import plistlib +import socket +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread + +import pytest + +from codex_usage_tracker.dashboard_service import ( + DEFAULT_SERVICE_PORT, + SERVICE_LABEL, + build_launch_agent, + dashboard_is_reachable, + port_is_available, + service_paths, + validate_service_port, +) + + +def test_service_paths_stay_in_user_owned_locations(tmp_path: Path) -> None: + paths = service_paths(tmp_path) + assert paths.plist == tmp_path / "Library/LaunchAgents/com.codex-usage-tracker.dashboard.plist" + assert paths.stdout_log == tmp_path / ".codex-usage-tracker/logs/dashboard-service.stdout.log" + assert paths.stderr_log == tmp_path / ".codex-usage-tracker/logs/dashboard-service.stderr.log" + + +def test_launch_agent_is_loopback_only_and_contains_no_content(tmp_path: Path) -> None: + payload = build_launch_agent( + python=Path("/opt/tracker/bin/python"), + home=tmp_path, + port=DEFAULT_SERVICE_PORT, + ) + encoded = plistlib.dumps(payload).decode("utf-8") + assert payload["Label"] == SERVICE_LABEL + assert payload["ProgramArguments"] == [ + "/opt/tracker/bin/python", "-m", "codex_usage_tracker", + "serve-dashboard", "--host", "127.0.0.1", "--port", "47821", + "--context-api", "explicit", "--no-refresh", + ] + assert payload["RunAtLoad"] is True + assert payload["KeepAlive"] is True + assert "--open" not in encoded + assert "prompt" not in encoded.lower() + assert "assistant" not in encoded.lower() + + +@pytest.mark.parametrize("port", [0, 1, 1023, 65536]) +def test_service_port_rejects_privileged_or_invalid_values(port: int) -> None: + with pytest.raises(ValueError, match="1024 through 65535"): + validate_service_port(port) + + +def test_port_check_detects_an_existing_listener() -> None: + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen() + port = listener.getsockname()[1] + try: + assert port_is_available(port) is False + finally: + listener.close() + assert port_is_available(port) is True + + +def test_dashboard_probe_distinguishes_reachable_http_server() -> None: + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(b"Codex Usage Tracker") + + def log_message(self, *_: object) -> None: + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + assert dashboard_is_reachable(server.server_port) is True + finally: + server.shutdown() + thread.join() + server.server_close() +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: `PATH=.venv/bin:$PATH python -m pytest tests/cli/test_dashboard_service.py -q` + +Expected: collection fails because `codex_usage_tracker.dashboard_service` does not exist. + +- [ ] **Step 3: Implement the minimal deterministic configuration and probe API** + +```python +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import socket +from typing import Any +from urllib.error import URLError +from urllib.request import urlopen + +DEFAULT_SERVICE_PORT = 47821 +SERVICE_HOST = "127.0.0.1" +SERVICE_LABEL = "com.codex-usage-tracker.dashboard" + + +@dataclass(frozen=True) +class DashboardServicePaths: + plist: Path + stdout_log: Path + stderr_log: Path + + +@dataclass(frozen=True) +class DashboardServiceStatus: + installed: bool + loaded: bool + reachable: bool + port: int + detail: str + + @property + def url(self) -> str: + return f"http://{SERVICE_HOST}:{self.port}" + + +def service_paths(home: Path) -> DashboardServicePaths: + logs = home / ".codex-usage-tracker" / "logs" + return DashboardServicePaths( + plist=home / "Library" / "LaunchAgents" / f"{SERVICE_LABEL}.plist", + stdout_log=logs / "dashboard-service.stdout.log", + stderr_log=logs / "dashboard-service.stderr.log", + ) + + +def validate_service_port(port: int) -> int: + if not 1024 <= port <= 65535: + raise ValueError("dashboard service port must be 1024 through 65535") + return port + + +def build_launch_agent(*, python: Path, home: Path, port: int) -> dict[str, Any]: + paths = service_paths(home) + return { + "Label": SERVICE_LABEL, + "ProgramArguments": [ + str(python), "-m", "codex_usage_tracker", "serve-dashboard", + "--host", SERVICE_HOST, "--port", str(validate_service_port(port)), + "--context-api", "explicit", "--no-refresh", + ], + "EnvironmentVariables": {"HOME": str(home)}, + "RunAtLoad": True, + "KeepAlive": True, + "ThrottleInterval": 10, + "StandardOutPath": str(paths.stdout_log), + "StandardErrorPath": str(paths.stderr_log), + } + + +def port_is_available(port: int) -> bool: + validate_service_port(port) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as candidate: + try: + candidate.bind((SERVICE_HOST, port)) + except OSError: + return False + return True + + +def dashboard_is_reachable(port: int, *, timeout: float = 1.0) -> bool: + try: + with urlopen(f"http://{SERVICE_HOST}:{port}/", timeout=timeout) as response: # noqa: S310 + return response.status == 200 + except (OSError, URLError): + return False +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `PATH=.venv/bin:$PATH python -m pytest tests/cli/test_dashboard_service.py -q` + +Expected: all Task 1 tests pass. + +- [ ] **Step 5: Commit Task 1** + +```bash +git add -- src/codex_usage_tracker/dashboard_service.py tests/cli/test_dashboard_service.py +git commit -m "feat: define persistent dashboard service" +``` + +--- + +### Task 2: Safe launchctl Lifecycle + +**Files:** +- Modify: `src/codex_usage_tracker/dashboard_service.py` +- Modify: `tests/cli/test_dashboard_service.py` + +**Interfaces:** +- Consumes: Task 1 constants, paths, plist builder, port check, and health probe. +- Produces: `install_dashboard_service`, `dashboard_service_status`, and `uninstall_dashboard_service`, each returning `DashboardServiceStatus` and using the exact keyword-only signatures in Step 3. + +- [ ] **Step 1: Add failing lifecycle tests using a fake launchctl runner and temporary home** + +Add tests that define a `FakeRunner` recording `list[str]` commands and returning `subprocess.CompletedProcess`. Assert these exact behaviors: + +```python +import subprocess + + +class FakeRunner: + def __init__(self, *, print_returncode: int = 1, bootstrap_returncode: int = 0) -> None: + self.print_returncode = print_returncode + self.bootstrap_returncode = bootstrap_returncode + self.commands: list[list[str]] = [] + + def __call__(self, command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + self.commands.append(command) + if command[1] == "print": + return subprocess.CompletedProcess(command, self.print_returncode, "", "not loaded") + if command[1] == "bootstrap": + return subprocess.CompletedProcess(command, self.bootstrap_returncode, "", "bootstrap failed") + return subprocess.CompletedProcess(command, 0, "", "") + + +def test_install_writes_valid_plist_and_bootstraps_user_domain(tmp_path: Path) -> None: + runner = FakeRunner() + python = tmp_path / "python" + python.touch() + status = install_dashboard_service( + home=tmp_path, + python=python, + port=47821, + platform="darwin", + uid=501, + runner=runner, + port_available=lambda _: True, + reachable=lambda _: True, + ) + payload = plistlib.loads(service_paths(tmp_path).plist.read_bytes()) + assert payload["Label"] == SERVICE_LABEL + assert runner.commands[-2:] == [ + ["launchctl", "bootstrap", "gui/501", str(service_paths(tmp_path).plist)], + ["launchctl", "kickstart", "-k", f"gui/501/{SERVICE_LABEL}"], + ] + assert status.installed and status.loaded and status.reachable + + +def test_install_refuses_unknown_port_owner_without_writing(tmp_path: Path) -> None: + python = tmp_path / "python" + python.touch() + with pytest.raises(RuntimeError, match="47821 is already in use"): + install_dashboard_service( + home=tmp_path, + python=python, + port=47821, + platform="darwin", + uid=501, + runner=FakeRunner(), + port_available=lambda _: False, + reachable=lambda _: False, + ) + assert not service_paths(tmp_path).plist.exists() + + +def test_status_is_read_only_and_reports_loaded_but_unreachable(tmp_path: Path) -> None: + paths = service_paths(tmp_path) + paths.plist.parent.mkdir(parents=True) + paths.plist.write_bytes(plistlib.dumps(build_launch_agent( + python=Path("/opt/tracker/bin/python"), home=tmp_path, port=47821, + ))) + runner = FakeRunner(print_returncode=0) + status = dashboard_service_status( + home=tmp_path, platform="darwin", uid=501, runner=runner, + reachable=lambda _: False, + ) + assert status == DashboardServiceStatus(True, True, False, 47821, "loaded but unreachable") + assert runner.commands == [["launchctl", "print", f"gui/501/{SERVICE_LABEL}"]] + + +def test_uninstall_is_idempotent_and_removes_only_managed_plist(tmp_path: Path) -> None: + paths = service_paths(tmp_path) + paths.plist.parent.mkdir(parents=True) + paths.plist.write_text("managed") + unrelated = paths.plist.parent / "other.plist" + unrelated.write_text("keep") + runner = FakeRunner() + status = uninstall_dashboard_service( + home=tmp_path, platform="darwin", uid=501, runner=runner, + ) + assert not paths.plist.exists() + assert unrelated.read_text() == "keep" + assert status.installed is False +``` + +Also cover non-darwin refusal, a missing interpreter, repeated install with an identical healthy plist returning without a restart, a changed managed plist being booted out before replacement, atomic restoration after bootstrap failure, and port extraction from an existing plist. + +- [ ] **Step 2: Run lifecycle tests and verify RED** + +Run: `PATH=.venv/bin:$PATH python -m pytest tests/cli/test_dashboard_service.py -q` + +Expected: failures report missing lifecycle functions. + +- [ ] **Step 3: Implement the lifecycle with explicit injectable boundaries** + +Use these exact keyword-only signatures and command shapes. The implementation body follows the helper algorithm immediately below rather than leaving stub bodies in source: + +```python +Runner = Callable[..., subprocess.CompletedProcess[str]] + +INSTALL_SIGNATURE = "install_dashboard_service(*, home: Path, python: Path, port: int = DEFAULT_SERVICE_PORT, platform: str = sys.platform, uid: int | None = None, runner: Runner = subprocess.run, port_available: Callable[[int], bool] = port_is_available, reachable: Callable[[int], bool] = dashboard_is_reachable) -> DashboardServiceStatus" +STATUS_SIGNATURE = "dashboard_service_status(*, home: Path, platform: str = sys.platform, uid: int | None = None, runner: Runner = subprocess.run, reachable: Callable[[int], bool] = dashboard_is_reachable) -> DashboardServiceStatus" +UNINSTALL_SIGNATURE = "uninstall_dashboard_service(*, home: Path, platform: str = sys.platform, uid: int | None = None, runner: Runner = subprocess.run) -> DashboardServiceStatus" +``` + +Implement small private helpers `_require_macos`, `_domain(uid)`, `_target(uid)`, `_run_launchctl`, `_read_installed_port`, and `_atomic_write_plist`. `runner` receives argument arrays, `check=False`, `capture_output=True`, and `text=True`. Treat `launchctl print gui//