From 36c5edd57409f53d027ebab433f82e0142514a81 Mon Sep 17 00:00:00 2001 From: Monsky Date: Fri, 17 Jul 2026 12:30:36 -0400 Subject: [PATCH 01/12] docs: design persistent dashboard service --- ...-17-persistent-dashboard-service-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-persistent-dashboard-service-design.md diff --git a/docs/superpowers/specs/2026-07-17-persistent-dashboard-service-design.md b/docs/superpowers/specs/2026-07-17-persistent-dashboard-service-design.md new file mode 100644 index 00000000..42a95062 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-persistent-dashboard-service-design.md @@ -0,0 +1,184 @@ +# Persistent Dashboard Service Design + +## Goal + +Give the local dashboard one memorable address and keep it available across +Codex tasks, terminal exits, crashes, and Mac logins. The default address is +`http://127.0.0.1:47821`. + +The IP address is deliberately loopback-only. Port `47821` is preferred over +the existing interactive default, `8765`, because `8765` is registered by IANA +for Ultraseek HTTP and is commonly used by development servers. Port `47821` +is not assigned in the IANA service registry, was unused on the target Mac at +design time, and is below that Mac's `49152-65535` ephemeral range. + +## Approaches Considered + +### macOS LaunchAgent (selected) + +A user LaunchAgent starts the existing dashboard server at login and restarts +it after an unexpected exit. This is native to macOS, survives terminal and +Codex task lifetimes, requires no privileged system service, and can remain +strictly bound to localhost. + +### Detached terminal process + +This is easy to start but does not reliably survive logout or reboot, has no +standard status interface, and tends to leave users unsure which process owns +the port. + +### Codex-started background process + +Starting the server when Codex opens still couples dashboard availability to +Codex and makes duplicate-process handling harder. It does not solve the +request for an independently persistent local address. + +## User Interface + +Add a nested CLI command with three actions: + +```text +codex-usage-tracker dashboard-service install [--port 47821] +codex-usage-tracker dashboard-service status +codex-usage-tracker dashboard-service uninstall +``` + +`install` creates or updates the user LaunchAgent, loads it, and prints the +fixed dashboard URL. It does not open a browser tab. `status` reports whether +the agent is installed, loaded, running, and reachable, followed by the URL or +a concise recovery instruction. `uninstall` unloads the managed agent and +removes only the plist created by this feature. + +The first release is intentionally macOS-only. On another operating system, +all three actions return a clear unsupported-platform error without writing +files. Linux systemd support can be added separately if a concrete need +appears. + +## Components and Responsibilities + +### CLI parser and dispatcher + +The existing argparse CLI owns command discovery and dispatch. It validates +the action and port, then delegates to a dashboard-service module. Service +lifecycle logic does not belong in the dashboard HTTP server. + +### Dashboard-service module + +A focused module owns: + +- service paths and the stable LaunchAgent label; +- deterministic plist construction; +- target-port availability checks; +- `launchctl` invocation through argument arrays, never shell strings; +- atomic installation of the generated plist; +- service status and localhost HTTP reachability checks; and +- safe removal of the package-managed plist. + +The LaunchAgent label is `com.codex-usage-tracker.dashboard`. Its plist lives +at `~/Library/LaunchAgents/com.codex-usage-tracker.dashboard.plist`. Logs live +under `~/.codex-usage-tracker/logs/` and contain process diagnostics only; the +service must not log prompts, raw context, or usage records. + +### LaunchAgent process + +The generated plist records the absolute Python interpreter used to run the +install command and launches: + +```text +python -m codex_usage_tracker serve-dashboard + --host 127.0.0.1 + --port 47821 + --context-api explicit +``` + +The actual plist stores these as separate `ProgramArguments`. It uses +`RunAtLoad` and `KeepAlive`, sets a restart throttle, supplies an explicit +`HOME`, and does not inject secrets or broaden network access. The browser-open +flag is omitted. + +Using the install-time interpreter avoids relying on launchd's minimal `PATH`. +If that interpreter later disappears, `status` explains that the service must +be reinstalled from the current package environment. + +## Lifecycle and Data Flow + +1. `install` validates macOS, the interpreter, destination directories, and + the requested port. +2. If an unknown process owns the requested port, installation fails and + reports the collision. It never kills an unknown process and never silently + chooses a different port. +3. The command writes the deterministic plist atomically, then bootstraps the + LaunchAgent in the current user's `gui/` domain. +4. launchd starts the existing localhost dashboard server and restarts it when + needed. +5. The dashboard continues using its existing explicit refresh and lazy + context-loading behavior. Persistence does not introduce background log + polling beyond behavior already performed by `serve-dashboard`. +6. `status` combines launchd state with a bounded HTTP probe so a loaded but + unreachable process is distinguishable from a healthy service. + +Installation is idempotent. Re-running it updates the managed plist and +restarts only the managed LaunchAgent. A port is not literally reserved while +the service is stopped; while the agent is active, the server's loopback bind +holds it. The collision checks and fixed configuration make failures explicit +rather than changing the URL. + +## Error Handling + +- Invalid or privileged ports fail before filesystem or launchd mutation. +- A target-port collision names the port and asks the user to stop the owner or + reinstall with an explicit alternative. +- Missing `launchctl`, an invalid user domain, plist write failures, bootstrap + failures, and failed health probes produce distinct concise messages. +- A failed install preserves any previously valid managed plist whenever + possible; atomic replacement prevents partial configuration files. +- Uninstall is idempotent when the service or plist is already absent. +- Status is read-only and does not attempt automatic repair. + +## Privacy and Security + +- The server remains bound to `127.0.0.1`; the service interface does not offer + a non-loopback host option. +- Existing explicit raw-context controls remain unchanged. +- The plist contains only executable paths, fixed arguments, HOME, and log + paths. It contains no credentials, session content, database rows, or copied + allowance data. +- Generated logs and service state remain local and are excluded from package + and repository artifacts. + +## Verification + +Implementation follows test-driven development. Tests use temporary homes, +synthetic inputs, fake subprocess results, and local disposable sockets; they +must not load or modify the developer's real LaunchAgent during the automated +suite. + +Coverage includes: + +- parser and dispatch behavior for all three actions; +- deterministic, valid plist generation with localhost-only arguments; +- default and overridden port validation; +- collision refusal without killing or replacing an unknown listener; +- idempotent install and uninstall flows; +- launchctl error translation and missing-interpreter reporting; +- status distinctions for absent, loaded, running, and HTTP-reachable states; +- unsupported-platform behavior; and +- privacy assertions preventing secrets or raw content in plist/log settings. + +Focused service tests run first. Because this changes a CLI surface, packaged +behavior, dashboard startup, and user documentation, the repository's full +local CI and release-readiness gates run before the branch is considered +complete. + +## Documentation and Rollout + +Update the install guide, dashboard guide, CLI reference, and bundled tracker +skill with the fixed URL and lifecycle commands. Keep `serve-dashboard` and its +existing default port backward-compatible for interactive users; only the new +persistent service defaults to `47821`. + +After implementation and verification, install the service for the current +user, confirm `status` reports it reachable, and probe +`http://127.0.0.1:47821`. The currently running interactive server on `8765` +is not killed automatically; it may exit naturally without affecting the new +service. From d81e1eb54d6b45c9787916c18ad9a775b0262271 Mon Sep 17 00:00:00 2001 From: Monsky Date: Fri, 17 Jul 2026 12:36:35 -0400 Subject: [PATCH 02/12] docs: plan persistent dashboard service --- ...2026-07-17-persistent-dashboard-service.md | 620 ++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-persistent-dashboard-service.md 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..ea5dcd41 --- /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`, 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", + ] + 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", + ], + "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//