diff --git a/README.md b/README.md
index c2587db..5c499db 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ Linux may work if `bleak` can access the Bluetooth adapter. Windows is not a tar
| matching `PostToolUse` after approval | `working` | return to working |
| ordinary `PostToolUse` | `working` | quiet activity heartbeat |
| `Stop` | `tool_done` | green finish flash, then idle |
+| `SessionEnd` | `idle` | release session ownership |
Tool-use events are intentionally not used for separate tool-running transitions. Ordinary `PostToolUse` events still refresh `working` so a premature `Stop` event or long-running turn does not leave the lamp idle while Codex is active.
@@ -51,10 +52,26 @@ When multiple Codex chats are open, the hook uses an active-session lock:
- the latest prompt claims the lamp
- other sessions cannot force idle while that session is active
- permission state cannot be overwritten by another session
-- the active session releases the lock on `Stop`
+- a new prompt can claim the lamp immediately
+- `SessionEnd` or lock expiry releases abandoned ownership
Codex currently does not expose a distinct “user clicked approve” hook. This project treats the matching `PostToolUse` after a permission request as the approval-resolved signal.
+## Night Mode
+
+Night mode can turn the lamp off during a local quiet window while still allowing Codex activity to wake it back up. It only applies when the daemon believes the lamp is idle or ambient; `working`, `attention`, and other active states continue to show normally.
+
+```json
+{
+ "night_mode_enabled": true,
+ "night_mode_off_time": "23:00",
+ "night_mode_on_time": "07:00",
+ "night_mode_state": "off"
+}
+```
+
+When the clock is inside the configured window, idle or ambient states are replaced with `night_mode_state`. A new Codex prompt, permission request, or tool heartbeat still applies its normal state and turns the lamp back on. At the end of the quiet window, the daemon restores the idle or ambient state that night mode replaced.
+
## Install
```bash
@@ -222,20 +239,20 @@ If daemon code changes after installing the LaunchAgent, rerun:
scripts/install-macos-service
```
-## Optional macOS Menu Bar App
+## macOS Menu Bar App
This branch includes an experimental menu bar wrapper for macOS. It does not replace the Python daemon or talk to BLE directly. It reads the existing config, state, and log files, then calls the same local scripts and CLI commands you would run from Terminal.
-Build the local unsigned app:
+Build, install, launch, and start the local unsigned app at login:
```bash
-macos/MenuBarApp/Scripts/package-app
+scripts/install-macos-menu-app
```
-Launch it:
+Remove it with:
```bash
-open "macos/MenuBarApp/build/Codex Moonside.app"
+scripts/uninstall-macos-menu-app
```
The menu shows daemon status, current lamp state, configured lamp target, and quick actions for testing `attention`, testing `ambient`, turning the lamp off, scanning BLE devices, restarting the daemon, and opening logs/config.
@@ -244,7 +261,6 @@ Current limitations:
- macOS only
- local unsigned app
-- developer convenience wrapper, not a polished installer
- expects the project checkout and `.venv` to remain available
- generated `.app` bundles are not committed to Git
@@ -264,6 +280,8 @@ Important fields:
"permission_file": "/tmp/codex_moonside_permission_pending.json",
"session_lock_file": "/tmp/codex_moonside_active_session.json",
"session_lock_enabled": true,
+ "session_lock_max_age_seconds": 43200,
+ "permission_marker_max_age_seconds": 43200,
"lamp_name_contains": "Moonside",
"ble_address": null,
"poll_interval_seconds": 0.2,
@@ -275,11 +293,15 @@ Important fields:
"ambient_after_idle_seconds": 1800,
"ambient_state": "ambient",
"codex_process_tracking_enabled": false,
- "codex_process_names": ["Codex"],
- "codex_process_poll_interval_seconds": 5,
+ "codex_process_names": ["ChatGPT", "Codex"],
+ "codex_process_poll_interval_seconds": 15,
+ "codex_process_timeout_seconds": 5,
+ "codex_process_warning_interval_seconds": 300,
"codex_process_missing_seconds": 60,
"codex_process_missing_state": "ambient",
- "codex_process_return_state": "idle"
+ "codex_process_return_state": "idle",
+ "log_max_bytes": 2097152,
+ "log_backup_count": 3
}
```
@@ -294,7 +316,7 @@ Optional macOS process tracking lets the daemon switch the lamp to ambient, or t
}
```
-This watches process names with `ps`, so it does not require the menu bar app. It is best suited for the Codex desktop app; leave it disabled if you mainly use Codex CLI without the app.
+This watches the current `ChatGPT.app` name and the legacy `Codex.app` name with a lightweight exact `pgrep` check, so it does not require the menu bar app. Process checks run outside the BLE event loop and warnings are rate-limited. Leave tracking disabled if you mainly use Codex CLI without the desktop app.
Default attention effect:
diff --git a/codex_moonside/config.py b/codex_moonside/config.py
index e4bc380..87ee480 100644
--- a/codex_moonside/config.py
+++ b/codex_moonside/config.py
@@ -30,6 +30,8 @@
"permission_file": DEFAULT_PERMISSION_FILE,
"session_lock_file": DEFAULT_SESSION_LOCK_FILE,
"session_lock_enabled": True,
+ "session_lock_max_age_seconds": 43200,
+ "permission_marker_max_age_seconds": 43200,
"lamp_name_contains": "Moonside",
"ble_address": None,
"poll_interval_seconds": 0.2,
@@ -42,14 +44,22 @@
"minimum_attention_seconds": 1.0,
"ambient_after_idle_seconds": 1800,
"ambient_state": "ambient",
+ "night_mode_enabled": False,
+ "night_mode_off_time": "23:00",
+ "night_mode_on_time": "07:00",
+ "night_mode_state": "off",
"codex_process_tracking_enabled": False,
- "codex_process_names": ["Codex"],
- "codex_process_poll_interval_seconds": 5,
+ "codex_process_names": ["ChatGPT", "Codex"],
+ "codex_process_poll_interval_seconds": 15,
+ "codex_process_timeout_seconds": 5,
+ "codex_process_warning_interval_seconds": 300,
"codex_process_missing_seconds": 60,
"codex_process_missing_state": "ambient",
"codex_process_return_state": "idle",
"log_file": DEFAULT_DAEMON_LOG_FILE,
"hook_log_file": DEFAULT_HOOK_LOG_FILE,
+ "log_max_bytes": 2097152,
+ "log_backup_count": 3,
"states": {
"idle": {
"commands": ["LEDON", "BRIGH060", "COLOR255120040"],
diff --git a/codex_moonside/daemon.py b/codex_moonside/daemon.py
index 2361aa4..dd1254f 100644
--- a/codex_moonside/daemon.py
+++ b/codex_moonside/daemon.py
@@ -3,7 +3,9 @@
import argparse
import asyncio
import json
+from datetime import datetime
from pathlib import Path
+import re
import subprocess
import sys
import time
@@ -141,6 +143,63 @@ def should_apply_ambient_timeout(
return now_monotonic - last_activity_monotonic >= timeout_seconds
+def parse_hhmm(value: str) -> int | None:
+ try:
+ hour_text, minute_text = value.strip().split(":", 1)
+ hour = int(hour_text)
+ minute = int(minute_text)
+ except Exception:
+ return None
+ if hour < 0 or hour > 23 or minute < 0 or minute > 59:
+ return None
+ return hour * 60 + minute
+
+
+def is_minute_in_window(now_minute: int, start_minute: int, end_minute: int) -> bool:
+ if start_minute == end_minute:
+ return False
+ if start_minute < end_minute:
+ return start_minute <= now_minute < end_minute
+ return now_minute >= start_minute or now_minute < end_minute
+
+
+def is_night_mode_active(now: datetime, off_time: str, on_time: str) -> bool:
+ off_minute = parse_hhmm(off_time)
+ on_minute = parse_hhmm(on_time)
+ if off_minute is None or on_minute is None:
+ return False
+ return is_minute_in_window(now.hour * 60 + now.minute, off_minute, on_minute)
+
+
+def should_apply_night_mode_off(
+ *,
+ enabled: bool,
+ current_state: str | None,
+ night_off_applied: bool,
+ now: datetime,
+ off_time: str,
+ on_time: str,
+) -> bool:
+ if not enabled or night_off_applied:
+ return False
+ if current_state not in {"idle", "ambient"}:
+ return False
+ return is_night_mode_active(now, off_time, on_time)
+
+
+def should_restore_after_night_mode(
+ *,
+ enabled: bool,
+ night_off_applied: bool,
+ now: datetime,
+ off_time: str,
+ on_time: str,
+) -> bool:
+ return night_off_applied and (
+ not enabled or not is_night_mode_active(now, off_time, on_time)
+ )
+
+
def configured_process_names(config: dict[str, Any]) -> list[str]:
raw_names = config.get("codex_process_names", [])
if isinstance(raw_names, str):
@@ -174,19 +233,37 @@ def is_configured_process_running(process_names: list[str], process_lines: list[
)
-def read_process_commands() -> list[str]:
+def read_process_commands(timeout_seconds: float = 5) -> list[str]:
result = subprocess.run(
["/bin/ps", "-axo", "comm="],
check=False,
capture_output=True,
text=True,
- timeout=2,
+ timeout=max(0.1, timeout_seconds),
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "Could not list running processes")
return result.stdout.splitlines()
+def is_configured_app_running(process_names: list[str], timeout_seconds: float = 5) -> bool:
+ if not process_names:
+ return False
+ alternatives = "|".join(re.escape(name) for name in process_names)
+ result = subprocess.run(
+ ["/usr/bin/pgrep", "-f", rf"/({alternatives})\.app/"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=max(0.1, timeout_seconds),
+ )
+ if result.returncode == 0:
+ return True
+ if result.returncode == 1:
+ return False
+ raise RuntimeError(result.stderr.strip() or "Could not inspect configured app processes")
+
+
def should_apply_codex_process_missing_state(
*,
tracking_enabled: bool,
@@ -208,9 +285,15 @@ async def watch_state_file(controller: MoonsideBLE, config: dict[str, Any], logg
poll_interval = float(config.get("poll_interval_seconds", 0.2))
ambient_after_idle_seconds = float(config.get("ambient_after_idle_seconds", 0))
ambient_state = str(config.get("ambient_state", "ambient"))
+ night_mode_enabled = bool(config.get("night_mode_enabled", False))
+ night_mode_off_time = str(config.get("night_mode_off_time", "23:00"))
+ night_mode_on_time = str(config.get("night_mode_on_time", "07:00"))
+ night_mode_state = str(config.get("night_mode_state", "off"))
process_tracking_enabled = bool(config.get("codex_process_tracking_enabled", False))
process_names = configured_process_names(config)
- process_poll_interval = float(config.get("codex_process_poll_interval_seconds", 5))
+ process_poll_interval = float(config.get("codex_process_poll_interval_seconds", 15))
+ process_timeout = float(config.get("codex_process_timeout_seconds", 5))
+ process_warning_interval = float(config.get("codex_process_warning_interval_seconds", 300))
process_missing_seconds = float(config.get("codex_process_missing_seconds", 60))
process_missing_state = str(config.get("codex_process_missing_state", "off"))
process_return_state = str(config.get("codex_process_return_state", "idle"))
@@ -223,10 +306,22 @@ async def watch_state_file(controller: MoonsideBLE, config: dict[str, Any], logg
last_process_seen_monotonic = time.monotonic()
last_process_running = True
process_missing_state_applied = False
+ night_off_applied = False
+ night_return_state: str | None = None
+ last_process_warning_monotonic = float("-inf")
if process_tracking_enabled and not process_names:
logger.warning("Codex process tracking is enabled, but codex_process_names is empty; disabling tracking")
process_tracking_enabled = False
+ if parse_hhmm(night_mode_off_time) is None:
+ logger.warning("Invalid night_mode_off_time=%s; disabling night mode", night_mode_off_time)
+ night_mode_enabled = False
+ if parse_hhmm(night_mode_on_time) is None:
+ logger.warning("Invalid night_mode_on_time=%s; disabling night mode", night_mode_on_time)
+ night_mode_enabled = False
+ if night_mode_state not in VALID_STATES:
+ logger.warning("Unknown night_mode_state=%s; using off", night_mode_state)
+ night_mode_state = "off"
if process_missing_state not in VALID_STATES:
logger.warning("Unknown codex_process_missing_state=%s; using off", process_missing_state)
process_missing_state = "off"
@@ -256,10 +351,57 @@ async def apply_ambient_if_due() -> bool:
ambient_applied = True
return True
+ async def apply_night_off_if_due() -> bool:
+ nonlocal current_state, previous_state, ambient_applied, night_off_applied, night_return_state
+ if not should_apply_night_mode_off(
+ enabled=night_mode_enabled,
+ current_state=current_state,
+ night_off_applied=night_off_applied,
+ now=datetime.now().astimezone(),
+ off_time=night_mode_off_time,
+ on_time=night_mode_on_time,
+ ):
+ return False
+
+ logger.info(
+ "Night mode active from %s to %s; applying state=%s",
+ night_mode_off_time,
+ night_mode_on_time,
+ night_mode_state,
+ )
+ night_return_state = current_state if current_state in {"idle", "ambient"} else "idle"
+ await send_state(controller, config, night_mode_state, logger)
+ previous_state = current_state
+ current_state = night_mode_state
+ ambient_applied = False
+ night_off_applied = True
+ return True
+
+ async def restore_after_night_if_due() -> bool:
+ nonlocal current_state, previous_state, ambient_applied, night_off_applied, night_return_state
+ if not should_restore_after_night_mode(
+ enabled=night_mode_enabled,
+ night_off_applied=night_off_applied,
+ now=datetime.now().astimezone(),
+ off_time=night_mode_off_time,
+ on_time=night_mode_on_time,
+ ):
+ return False
+
+ target = night_return_state if night_return_state in {"idle", "ambient"} else "idle"
+ logger.info("Night mode ended; restoring state=%s", target)
+ await send_state(controller, config, target, logger)
+ previous_state = current_state
+ current_state = target
+ ambient_applied = target == "ambient"
+ night_off_applied = False
+ night_return_state = None
+ return True
+
async def codex_process_allows_state_updates() -> bool:
- nonlocal current_state, previous_state, last_activity_monotonic, ambient_applied
+ nonlocal current_state, previous_state, last_activity_monotonic, ambient_applied, night_off_applied, night_return_state
nonlocal last_process_check_monotonic, last_process_seen_monotonic, last_process_running
- nonlocal process_missing_state_applied
+ nonlocal process_missing_state_applied, last_process_warning_monotonic
if not process_tracking_enabled:
return True
@@ -268,9 +410,15 @@ async def codex_process_allows_state_updates() -> bool:
if now - last_process_check_monotonic >= process_poll_interval:
last_process_check_monotonic = now
try:
- last_process_running = is_configured_process_running(process_names, read_process_commands())
+ last_process_running = await asyncio.to_thread(
+ is_configured_app_running,
+ process_names,
+ process_timeout,
+ )
except Exception as exc:
- logger.warning("Could not inspect Codex process state: %s", exc)
+ if now - last_process_warning_monotonic >= process_warning_interval:
+ logger.warning("Could not inspect Codex process state: %s", exc)
+ last_process_warning_monotonic = now
return True
if last_process_running:
@@ -281,6 +429,8 @@ async def codex_process_allows_state_updates() -> bool:
current_state = process_return_state
last_activity_monotonic = now
ambient_applied = False
+ night_off_applied = False
+ night_return_state = None
process_missing_state_applied = False
await send_state(controller, config, process_return_state, logger)
return True
@@ -302,6 +452,8 @@ async def codex_process_allows_state_updates() -> bool:
previous_state = current_state
current_state = process_missing_state
ambient_applied = False
+ night_off_applied = False
+ night_return_state = None
process_missing_state_applied = True
return False
@@ -315,18 +467,24 @@ async def codex_process_allows_state_updates() -> bool:
payload = read_state_file(state_file)
if payload is None:
+ await restore_after_night_if_due()
+ await apply_night_off_if_due()
await apply_ambient_if_due()
await asyncio.sleep(poll_interval)
continue
fingerprint = state_file_fingerprint(payload)
if fingerprint == last_fingerprint:
+ await restore_after_night_if_due()
+ await apply_night_off_if_due()
await apply_ambient_if_due()
await asyncio.sleep(poll_interval)
continue
last_fingerprint = fingerprint
last_activity_monotonic = time.monotonic()
ambient_applied = False
+ night_off_applied = False
+ night_return_state = None
state = str(payload["state"])
state_config = config.get("states", {}).get(state, {})
@@ -377,7 +535,14 @@ async def run_daemon(config: dict[str, Any], logger: Any) -> int:
async def async_main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
config = load_config(args.config)
- logger = setup_file_logger("codex_moonside.daemon", config.get("log_file"), debug=args.debug, console=True)
+ logger = setup_file_logger(
+ "codex_moonside.daemon",
+ config.get("log_file"),
+ debug=args.debug,
+ console=True,
+ max_bytes=int(config.get("log_max_bytes", 2 * 1024 * 1024)),
+ backup_count=int(config.get("log_backup_count", 3)),
+ )
try:
if args.scan:
diff --git a/codex_moonside/doctor.py b/codex_moonside/doctor.py
index 00f7983..4b6a6a8 100644
--- a/codex_moonside/doctor.py
+++ b/codex_moonside/doctor.py
@@ -27,7 +27,10 @@
DEFAULT_HOOKS_PATH = CODEX_DIR / "hooks.json"
DEFAULT_CODEX_CONFIG_PATH = CODEX_DIR / "config.toml"
MACOS_LAUNCH_AGENT_LABEL = "local.codex-moonside-lamp"
-BUNDLED_CODEX_PATH = Path("/Applications/Codex.app/Contents/Resources/codex")
+BUNDLED_CODEX_PATHS = (
+ Path("/Applications/ChatGPT.app/Contents/Resources/codex"),
+ Path("/Applications/Codex.app/Contents/Resources/codex"),
+)
@dataclass
@@ -122,8 +125,9 @@ def toml_bool_in_section(config_text: str, section: str, key: str) -> bool | Non
def find_codex_binaries() -> list[str]:
binaries: list[str] = []
- if BUNDLED_CODEX_PATH.exists():
- binaries.append(str(BUNDLED_CODEX_PATH))
+ for bundled_path in BUNDLED_CODEX_PATHS:
+ if bundled_path.exists():
+ binaries.append(str(bundled_path))
resolved = shutil.which("codex")
if resolved and resolved not in binaries:
binaries.append(resolved)
@@ -131,9 +135,13 @@ def find_codex_binaries() -> list[str]:
def resolve_codex_binary() -> str | None:
- binaries = find_codex_binaries()
- if binaries:
- return binaries[0]
+ for binary in find_codex_binaries():
+ try:
+ completed = run_command([binary, "--version"], timeout=10)
+ except Exception:
+ continue
+ if completed.returncode == 0:
+ return binary
return None
@@ -152,27 +160,29 @@ def run_command(args: list[str], *, timeout: float = 10, cwd: str | None = None,
def check_codex_binary() -> CheckResult:
binaries = find_codex_binaries()
if not binaries:
- return result("Codex binary", "warn", "codex not found on PATH and bundled Codex.app binary not found")
+ return result("Codex binary", "warn", "codex not found on PATH or in ChatGPT.app/Codex.app")
details: list[str] = []
versions: set[str] = set()
- status = "ok"
+ working_binaries = 0
for codex in binaries:
try:
completed = run_command([codex, "--version"], timeout=10)
except Exception as exc:
- details.append(f"{codex} (version check failed: {exc})")
- status = "warn"
+ details.append(f"{codex} (unusable: {exc})")
continue
output = (completed.stdout or completed.stderr).strip()
version = output.splitlines()[-1] if output else "unknown version"
+ if completed.returncode != 0:
+ details.append(f"{codex} (unusable; exited {completed.returncode})")
+ continue
details.append(f"{codex} ({version})")
versions.add(version)
- if completed.returncode != 0:
- status = "warn"
+ working_binaries += 1
+ status = "ok" if working_binaries else "warn"
if len(versions) > 1:
status = "warn"
- details.append("multiple Codex versions found; live test uses the first binary listed")
+ details.append("multiple working Codex versions found; live test uses the first binary listed")
return result("Codex binary", status, "; ".join(details))
@@ -375,27 +385,60 @@ def check_recent_logs() -> list[CheckResult]:
return checks
-def run_live_codex_test(hooks_log_path: Path | None, cwd: Path) -> CheckResult:
+def snapshot_files(paths: list[Path]) -> dict[Path, bytes | None]:
+ return {path: path.read_bytes() if path.exists() else None for path in paths}
+
+
+def restore_file_snapshots(snapshots: dict[Path, bytes | None]) -> None:
+ for path, content in snapshots.items():
+ if content is None:
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ pass
+ continue
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
+ try:
+ with os.fdopen(fd, "wb") as temp_file:
+ temp_file.write(content)
+ os.replace(temp_name, path)
+ finally:
+ try:
+ os.unlink(temp_name)
+ except FileNotFoundError:
+ pass
+
+
+def run_live_codex_test(
+ hooks_log_path: Path | None,
+ cwd: Path,
+ preserve_paths: list[Path] | None = None,
+) -> CheckResult:
codex = resolve_codex_binary()
if not codex:
return result("Live Codex hook test", "fail", "codex binary not found")
before_mtime = hooks_log_path.stat().st_mtime if hooks_log_path and hooks_log_path.exists() else 0.0
+ snapshots = snapshot_files(preserve_paths or [])
try:
- completed = run_command(
- [codex, "exec", "--skip-git-repo-check", "--enable", "hooks", "-C", str(cwd), "Reply with just ok."],
- timeout=90,
- input_text="",
- )
- except Exception as exc:
- return result("Live Codex hook test", "fail", f"Codex CLI run failed: {exc}")
- if completed.returncode != 0:
- stderr = completed.stderr.strip().splitlines()[-1] if completed.stderr.strip() else "no stderr"
- return result("Live Codex hook test", "fail", f"Codex exited {completed.returncode}: {stderr}")
- if hooks_log_path and hooks_log_path.exists() and hooks_log_path.stat().st_mtime > before_mtime:
- return result("Live Codex hook test", "ok", "hook log changed during Codex CLI turn")
- if "hook: UserPromptSubmit" in completed.stdout or "hook: Stop" in completed.stdout:
- return result("Live Codex hook test", "ok", "Codex CLI emitted hook events")
- return result("Live Codex hook test", "warn", "Codex CLI completed, but no hook evidence was observed")
+ try:
+ completed = run_command(
+ [codex, "exec", "--skip-git-repo-check", "--enable", "hooks", "-C", str(cwd), "Reply with just ok."],
+ timeout=90,
+ input_text="",
+ )
+ except Exception as exc:
+ return result("Live Codex hook test", "fail", f"Codex CLI run failed: {exc}")
+ if completed.returncode != 0:
+ stderr = completed.stderr.strip().splitlines()[-1] if completed.stderr.strip() else "no stderr"
+ return result("Live Codex hook test", "fail", f"Codex exited {completed.returncode}: {stderr}")
+ if hooks_log_path and hooks_log_path.exists() and hooks_log_path.stat().st_mtime > before_mtime:
+ return result("Live Codex hook test", "ok", "hook log changed during Codex CLI turn")
+ if "hook: UserPromptSubmit" in completed.stdout or "hook: Stop" in completed.stdout:
+ return result("Live Codex hook test", "ok", "Codex CLI emitted hook events")
+ return result("Live Codex hook test", "warn", "Codex CLI completed, but no hook evidence was observed")
+ finally:
+ restore_file_snapshots(snapshots)
def status_rank(status: str) -> int:
@@ -434,11 +477,17 @@ def main(argv: list[str] | None = None) -> int:
try:
config = load_config(str(moonside_config_path))
hook_log_path = Path(str(config.get("hook_log_file"))).expanduser() if config.get("hook_log_file") else None
+ preserve_paths = [
+ Path(str(config[key])).expanduser()
+ for key in ("state_file", "session_lock_file", "permission_file")
+ if config.get(key)
+ ]
except Exception:
hook_log_path = None
+ preserve_paths = []
if args.live_codex_test:
- checks.append(run_live_codex_test(hook_log_path, Path.cwd()))
+ checks.append(run_live_codex_test(hook_log_path, Path.cwd(), preserve_paths))
if args.json:
print(checks_to_json(checks))
diff --git a/codex_moonside/hook.py b/codex_moonside/hook.py
index 812648f..e730da8 100644
--- a/codex_moonside/hook.py
+++ b/codex_moonside/hook.py
@@ -7,7 +7,7 @@
from .config import VALID_STATES, load_config
from .logging_utils import setup_file_logger
from .state import (
- atomic_write_state,
+ atomic_write_latest_state,
clear_permission_marker,
clear_session_lock,
extract_cwd,
@@ -15,9 +15,11 @@
parse_hook_event,
payload_from_hook_input,
permission_marker_matches,
+ payload_is_expired,
read_permission_marker,
read_session_lock,
session_lock_matches,
+ should_ignore_hook_event,
write_permission_marker,
write_session_lock,
)
@@ -43,15 +45,38 @@ def build_parser() -> argparse.ArgumentParser:
return parser
+def config_float(config: dict, key: str, default: float = 0.0) -> float:
+ try:
+ return float(config.get(key, default))
+ except (TypeError, ValueError):
+ return default
+
+
+def read_current_permission_marker(config: dict) -> dict | None:
+ marker = read_permission_marker(config.get("permission_file"))
+ if marker and payload_is_expired(marker, config_float(config, "permission_marker_max_age_seconds")):
+ clear_permission_marker(config.get("permission_file"))
+ return None
+ return marker
+
+
+def read_current_session_lock(config: dict) -> dict | None:
+ lock = read_session_lock(config.get("session_lock_file"))
+ if lock and payload_is_expired(lock, config_float(config, "session_lock_max_age_seconds")):
+ clear_session_lock(config.get("session_lock_file"))
+ return None
+ return lock
+
+
def session_allowed(config: dict, raw_event: dict, event_name: str | None) -> bool:
if not config.get("session_lock_enabled", True):
return True
if event_name in {"SessionStart"}:
return True
- if event_name == "UserPromptSubmit" and not read_permission_marker(config.get("permission_file")):
+ if event_name == "UserPromptSubmit" and not read_current_permission_marker(config):
return True
- lock = read_session_lock(config.get("session_lock_file"))
+ lock = read_current_session_lock(config)
if not lock:
return True
if event_name == "PermissionRequest":
@@ -82,7 +107,13 @@ def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
config = load_config(args.config)
- logger = setup_file_logger("codex_moonside.hook", config.get("hook_log_file"), debug=args.debug)
+ logger = setup_file_logger(
+ "codex_moonside.hook",
+ config.get("hook_log_file"),
+ debug=args.debug,
+ max_bytes=int(config.get("log_max_bytes", 2 * 1024 * 1024)),
+ backup_count=int(config.get("log_backup_count", 3)),
+ )
stdin_text = read_available_stdin()
try:
raw_event = parse_hook_event(stdin_text) if stdin_text.strip() else {}
@@ -94,12 +125,16 @@ def main(argv: list[str] | None = None) -> int:
else:
raise
+ if should_ignore_hook_event(raw_event):
+ logger.debug("Ignoring non-terminal hook event=%s", extract_event_name(raw_event))
+ return 0
+
if args.permission_resolved_state:
event_name = extract_event_name(raw_event)
if event_name != "PostToolUse":
logger.info("Ignoring permission resolver for event=%s", event_name)
return 0
- marker = read_permission_marker(config.get("permission_file"))
+ marker = read_current_permission_marker(config)
if marker and not permission_marker_matches(marker, raw_event):
logger.info("No matching permission marker for PostToolUse; ignoring")
return 0
@@ -112,12 +147,12 @@ def main(argv: list[str] | None = None) -> int:
waited = wait_for_minimum_attention(marker, float(config.get("minimum_attention_seconds", 0)))
if waited:
logger.info("Held attention state for %.3fs before clearing", waited)
- atomic_write_state(payload, args.state_file or config["state_file"])
+ wrote = atomic_write_latest_state(payload, args.state_file or config["state_file"])
if marker is not None:
clear_permission_marker(config.get("permission_file"))
logger.info("Permission resolved; wrote state=%s", payload.get("state"))
else:
- logger.info("PostToolUse heartbeat; wrote state=%s", payload.get("state"))
+ logger.debug("PostToolUse heartbeat; wrote=%s state=%s", wrote, payload.get("state"))
return 0
if extract_event_name(raw_event) == "PermissionRequest":
@@ -147,8 +182,10 @@ def main(argv: list[str] | None = None) -> int:
write_session_lock(raw_event, config.get("session_lock_file"))
state_file = args.state_file or config["state_file"]
- atomic_write_state(payload, state_file)
+ atomic_write_latest_state(payload, state_file)
if event_name == "Stop":
+ clear_permission_marker(config.get("permission_file"))
+ if event_name == "SessionEnd":
clear_session_lock(config.get("session_lock_file"))
clear_permission_marker(config.get("permission_file"))
logger.info(
@@ -161,7 +198,13 @@ def main(argv: list[str] | None = None) -> int:
except Exception as exc:
try:
config = load_config(args.config)
- logger = setup_file_logger("codex_moonside.hook", config.get("hook_log_file"), debug=args.debug)
+ logger = setup_file_logger(
+ "codex_moonside.hook",
+ config.get("hook_log_file"),
+ debug=args.debug,
+ max_bytes=int(config.get("log_max_bytes", 2 * 1024 * 1024)),
+ backup_count=int(config.get("log_backup_count", 3)),
+ )
logger.exception("Hook failed but will exit 0: %s", exc)
except Exception:
pass
diff --git a/codex_moonside/hooks_config.py b/codex_moonside/hooks_config.py
index f8a75f6..404cc44 100644
--- a/codex_moonside/hooks_config.py
+++ b/codex_moonside/hooks_config.py
@@ -15,6 +15,7 @@
"PermissionRequest": "permission_request",
"PostToolUse": "post_tool_use",
"Stop": "stop",
+ "SessionEnd": "session_end",
}
diff --git a/codex_moonside/logging_utils.py b/codex_moonside/logging_utils.py
index 45d2b3d..e243fdd 100644
--- a/codex_moonside/logging_utils.py
+++ b/codex_moonside/logging_utils.py
@@ -1,15 +1,22 @@
from __future__ import annotations
import logging
+from logging.handlers import RotatingFileHandler
from pathlib import Path
+DEFAULT_LOG_MAX_BYTES = 2 * 1024 * 1024
+DEFAULT_LOG_BACKUP_COUNT = 3
+
+
def setup_file_logger(
name: str,
path: str | None,
*,
debug: bool = False,
console: bool = False,
+ max_bytes: int = DEFAULT_LOG_MAX_BYTES,
+ backup_count: int = DEFAULT_LOG_BACKUP_COUNT,
) -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG if debug else logging.INFO)
@@ -24,7 +31,12 @@ def setup_file_logger(
try:
log_path = Path(path).expanduser()
log_path.parent.mkdir(parents=True, exist_ok=True)
- handler: logging.Handler = logging.FileHandler(log_path, encoding="utf-8")
+ handler: logging.Handler = RotatingFileHandler(
+ log_path,
+ maxBytes=max(0, int(max_bytes)),
+ backupCount=max(0, int(backup_count)),
+ encoding="utf-8",
+ )
except OSError:
handler = logging.NullHandler()
else:
diff --git a/codex_moonside/state.py b/codex_moonside/state.py
index 9ff5e42..027b32c 100644
--- a/codex_moonside/state.py
+++ b/codex_moonside/state.py
@@ -3,6 +3,7 @@
import json
import os
import hashlib
+import fcntl
import tempfile
import time
from datetime import datetime
@@ -19,6 +20,17 @@
"PermissionRequest": "attention",
"PostToolUse": "tool_done",
"Stop": "idle",
+ "SessionEnd": "idle",
+}
+
+EVENT_ORDER = {
+ "SessionStart": 0,
+ "UserPromptSubmit": 1,
+ "PreToolUse": 2,
+ "PermissionRequest": 3,
+ "PostToolUse": 4,
+ "Stop": 5,
+ "SessionEnd": 6,
}
@@ -28,6 +40,13 @@ def event_to_state(event_name: str | None) -> str | None:
return EVENT_STATE_MAP.get(event_name)
+def should_ignore_hook_event(raw_event: dict[str, Any]) -> bool:
+ event_name = extract_event_name(raw_event)
+ if event_name == "SessionStart" and raw_event.get("source") == "compact":
+ return True
+ return event_name == "Stop" and raw_event.get("stop_hook_active") is True
+
+
def extract_event_name(raw_event: dict[str, Any]) -> str | None:
for key in (
"event",
@@ -130,6 +149,7 @@ def build_state_payload(
raise ValueError(f"Unknown state: {state}")
raw_event = raw_event or {}
+ now = datetime.now().astimezone()
return {
"state": state,
"event": event_name,
@@ -139,7 +159,8 @@ def build_state_payload(
"turn_id": extract_turn_id(raw_event),
"tool_use_id": extract_tool_use_id(raw_event),
"cwd": extract_cwd(raw_event) or cwd or os.getcwd(),
- "timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
+ "timestamp": now.isoformat(timespec="seconds"),
+ "timestamp_epoch_ns": time.time_ns(),
}
@@ -181,7 +202,6 @@ def atomic_write_state(payload: dict[str, Any], path: str = DEFAULT_STATE_FILE)
json.dump(payload, f, ensure_ascii=False, sort_keys=True)
f.write("\n")
f.flush()
- os.fsync(f.fileno())
os.replace(temp_name, target)
finally:
if os.path.exists(temp_name):
@@ -226,6 +246,28 @@ def read_permission_marker(path: str = DEFAULT_PERMISSION_FILE) -> dict[str, Any
return payload
+def payload_age_seconds(payload: dict[str, Any], *, now: float | None = None) -> float | None:
+ now = time.time() if now is None else now
+ timestamp_epoch = payload.get("timestamp_epoch")
+ if isinstance(timestamp_epoch, (int, float)):
+ return max(0.0, now - float(timestamp_epoch))
+
+ timestamp = payload.get("timestamp")
+ if not isinstance(timestamp, str) or not timestamp:
+ return None
+ try:
+ return max(0.0, now - datetime.fromisoformat(timestamp).timestamp())
+ except ValueError:
+ return None
+
+
+def payload_is_expired(payload: dict[str, Any], max_age_seconds: float | int | None) -> bool:
+ if max_age_seconds is None or float(max_age_seconds) <= 0:
+ return False
+ age = payload_age_seconds(payload)
+ return age is not None and age > float(max_age_seconds)
+
+
def permission_marker_matches(marker: dict[str, Any], raw_event: dict[str, Any]) -> bool:
matched_identity = False
@@ -303,7 +345,13 @@ def session_lock_matches(lock: dict[str, Any], raw_event: dict[str, Any]) -> boo
lock_session_id = lock.get("session_id")
event_session_id = extract_session_id(raw_event)
if lock_session_id and event_session_id:
- return lock_session_id == event_session_id
+ if lock_session_id != event_session_id:
+ return False
+ lock_turn_id = lock.get("turn_id")
+ event_turn_id = extract_turn_id(raw_event)
+ if lock_turn_id and event_turn_id:
+ return lock_turn_id == event_turn_id
+ return True
lock_turn_id = lock.get("turn_id")
event_turn_id = extract_turn_id(raw_event)
@@ -332,3 +380,49 @@ def read_state_file(path: str = DEFAULT_STATE_FILE) -> dict[str, Any] | None:
if payload.get("state") not in VALID_STATES:
return None
return payload
+
+
+def _payload_timestamp_ns(payload: dict[str, Any]) -> int:
+ timestamp_ns = payload.get("timestamp_epoch_ns")
+ if isinstance(timestamp_ns, int):
+ return timestamp_ns
+ timestamp_epoch = payload.get("timestamp_epoch")
+ if isinstance(timestamp_epoch, (int, float)):
+ return int(float(timestamp_epoch) * 1_000_000_000)
+ return 0
+
+
+def payload_is_newer(candidate: dict[str, Any], current: dict[str, Any]) -> bool:
+ same_turn = bool(
+ candidate.get("session_id")
+ and candidate.get("session_id") == current.get("session_id")
+ and candidate.get("turn_id")
+ and candidate.get("turn_id") == current.get("turn_id")
+ )
+ if same_turn:
+ candidate_order = EVENT_ORDER.get(str(candidate.get("event")), -1)
+ current_order = EVENT_ORDER.get(str(current.get("event")), -1)
+ if candidate_order != current_order:
+ return candidate_order > current_order
+ return _payload_timestamp_ns(candidate) >= _payload_timestamp_ns(current)
+
+
+def atomic_write_latest_state(payload: dict[str, Any], path: str = DEFAULT_STATE_FILE) -> bool:
+ target = Path(path).expanduser()
+ target.parent.mkdir(parents=True, exist_ok=True)
+ lock_path = target.with_name(f".{target.name}.write.lock")
+ lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
+ try:
+ with os.fdopen(lock_fd, "r+") as lock_file:
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
+ current = read_state_file(str(target))
+ if current is not None and not payload_is_newer(payload, current):
+ return False
+ atomic_write_state(payload, str(target))
+ return True
+ finally:
+ # fdopen owns lock_fd after it succeeds.
+ try:
+ os.close(lock_fd)
+ except OSError:
+ pass
diff --git a/config.example.json b/config.example.json
index 68bb811..4c21966 100644
--- a/config.example.json
+++ b/config.example.json
@@ -3,6 +3,8 @@
"permission_file": "/tmp/codex_moonside_permission_pending.json",
"session_lock_file": "/tmp/codex_moonside_active_session.json",
"session_lock_enabled": true,
+ "session_lock_max_age_seconds": 43200,
+ "permission_marker_max_age_seconds": 43200,
"lamp_name_contains": "Moonside",
"ble_address": null,
"poll_interval_seconds": 0.2,
@@ -15,14 +17,22 @@
"minimum_attention_seconds": 1.0,
"ambient_after_idle_seconds": 1800,
"ambient_state": "ambient",
+ "night_mode_enabled": false,
+ "night_mode_off_time": "23:00",
+ "night_mode_on_time": "07:00",
+ "night_mode_state": "off",
"codex_process_tracking_enabled": false,
- "codex_process_names": ["Codex"],
- "codex_process_poll_interval_seconds": 5,
+ "codex_process_names": ["ChatGPT", "Codex"],
+ "codex_process_poll_interval_seconds": 15,
+ "codex_process_timeout_seconds": 5,
+ "codex_process_warning_interval_seconds": 300,
"codex_process_missing_seconds": 60,
"codex_process_missing_state": "ambient",
"codex_process_return_state": "idle",
"log_file": "~/.codex-moonside-lamp/daemon.log",
"hook_log_file": "~/.codex-moonside-lamp/hook.log",
+ "log_max_bytes": 2097152,
+ "log_backup_count": 3,
"states": {
"idle": {
"commands": ["LEDON", "BRIGH060", "COLOR255120040"]
diff --git a/examples/hooks.json b/examples/hooks.json
index 6fd9bea..30e00f3 100644
--- a/examples/hooks.json
+++ b/examples/hooks.json
@@ -2,6 +2,7 @@
"hooks": {
"SessionStart": [
{
+ "matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
@@ -41,6 +42,7 @@
{
"type": "command",
"command": "codex-moonside-hook --permission-resolved-state working",
+ "async": true,
"statusMessage": "Clearing permission lamp state"
}
]
@@ -56,6 +58,17 @@
}
]
}
+ ],
+ "SessionEnd": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "codex-moonside-hook --state idle",
+ "statusMessage": "Cleaning up lamp session"
+ }
+ ]
+ }
]
}
}
diff --git a/macos/MenuBarApp/README.md b/macos/MenuBarApp/README.md
index b54cf8c..0ea45cb 100644
--- a/macos/MenuBarApp/README.md
+++ b/macos/MenuBarApp/README.md
@@ -16,22 +16,22 @@ cd macos/MenuBarApp
Scripts/package-app
```
-## Build A Local `.app`
+## Install The Menu App
```bash
-cd macos/MenuBarApp
-Scripts/package-app
-open "build/Codex Moonside.app"
+scripts/install-macos-menu-app
```
-The packaged app stores the project root path in `Contents/Resources/ProjectRoot.txt`, so it can still call the local daemon scripts.
+This installs `~/Applications/Codex Moonside.app`, launches it, and registers a user LaunchAgent so it starts at login. The packaged app stores the project root path in `Contents/Resources/ProjectRoot.txt`, so it can still call the local daemon scripts.
## What It Does
- shows whether the LaunchAgent daemon appears to be running
- shows the current state from `/tmp/codex_moonside_state.json`
- shows the configured BLE address or lamp name filter
+- shows night mode status
- can test `attention`, `ambient`, and `off` states through `codex-moonside-hook`
+- can enable or disable night mode and restart the daemon
- can run BLE scan output through `codex-moonside-daemon --scan`
- can restart or stop the macOS LaunchAgent using the project scripts
- opens the local config and log files
@@ -41,5 +41,4 @@ The packaged app stores the project root path in `Contents/Resources/ProjectRoot
- macOS only
- unsigned local app
- requires the project checkout and `.venv` to remain in place
-- does not install itself as a login item yet
- does not manage BLE directly; the Python daemon remains the source of truth
diff --git a/macos/MenuBarApp/Scripts/package-app b/macos/MenuBarApp/Scripts/package-app
index b355344..c186d40 100755
--- a/macos/MenuBarApp/Scripts/package-app
+++ b/macos/MenuBarApp/Scripts/package-app
@@ -37,9 +37,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<'PLIST'
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.0
+ 0.2.0
CFBundleVersion
- 1
+ 2
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/macos/MenuBarApp/Sources/CodexMoonsideMenuBar/main.m b/macos/MenuBarApp/Sources/CodexMoonsideMenuBar/main.m
index 0041f95..676f047 100644
--- a/macos/MenuBarApp/Sources/CodexMoonsideMenuBar/main.m
+++ b/macos/MenuBarApp/Sources/CodexMoonsideMenuBar/main.m
@@ -48,6 +48,18 @@ @implementation CommandResult
return [value isKindOfClass:NSDictionary.class] ? value : nil;
}
+static BOOL WriteJSONFile(NSString *path, NSDictionary *payload, NSError **error) {
+ NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys error:error];
+ if (!data) {
+ return NO;
+ }
+ NSString *directory = [path stringByDeletingLastPathComponent];
+ if (![NSFileManager.defaultManager createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:error]) {
+ return NO;
+ }
+ return [data writeToFile:path options:NSDataWritingAtomic error:error];
+}
+
static NSString *FindProjectRoot(NSString *startPath) {
NSFileManager *fileManager = NSFileManager.defaultManager;
NSURL *current = [NSURL fileURLWithPath:startPath];
@@ -223,6 +235,17 @@ - (NSString *)formatAmbientSeconds:(id)value {
return [NSString stringWithFormat:@"%d sec", (int)seconds];
}
+- (BOOL)nightModeEnabled:(NSDictionary *)config {
+ id value = config[@"night_mode_enabled"];
+ return [value respondsToSelector:@selector(boolValue)] && [value boolValue];
+}
+
+- (NSString *)nightModeSummary:(NSDictionary *)config {
+ NSString *offTime = [config[@"night_mode_off_time"] isKindOfClass:NSString.class] ? config[@"night_mode_off_time"] : @"23:00";
+ NSString *onTime = [config[@"night_mode_on_time"] isKindOfClass:NSString.class] ? config[@"night_mode_on_time"] : @"07:00";
+ return [self nightModeEnabled:config] ? [NSString stringWithFormat:@"On %@-%@", offTime, onTime] : @"Off";
+}
+
- (void)rebuildMenu {
NSDictionary *config = self.config;
NSDictionary *state = self.state;
@@ -248,6 +271,7 @@ - (void)rebuildMenu {
if (ambient.length > 0) {
[statusMenu addItem:[self disabledItem:[NSString stringWithFormat:@"Ambient after: %@", ambient]]];
}
+ [statusMenu addItem:[self disabledItem:[NSString stringWithFormat:@"Night mode: %@", [self nightModeSummary:config]]]];
[menu addItem:[self submenuItem:[NSString stringWithFormat:@"Status: %@", stateName] submenu:statusMenu]];
[menu addItem:NSMenuItem.separatorItem];
@@ -262,6 +286,7 @@ - (void)rebuildMenu {
[controlsMenu addItem:[self stateActionItem:@"Off" state:@"off"]];
[controlsMenu addItem:NSMenuItem.separatorItem];
[controlsMenu addItem:[self actionItem:@"Scan Lamps..." selector:@selector(scanLamps)]];
+ [controlsMenu addItem:[self actionItem:[self nightModeEnabled:config] ? @"Disable Night Mode" : @"Enable Night Mode" selector:@selector(toggleNightMode)]];
[menu addItem:[self submenuItem:@"Controls" submenu:controlsMenu]];
[menu addItem:NSMenuItem.separatorItem];
@@ -286,6 +311,29 @@ - (void)setStateFromMenuItem:(NSMenuItem *)item {
}
}
+- (void)toggleNightMode {
+ NSDictionary *current = self.config ?: @{};
+ NSMutableDictionary *config = [current mutableCopy];
+ BOOL enabled = ![self nightModeEnabled:current];
+ config[@"night_mode_enabled"] = @(enabled);
+ if (![config[@"night_mode_off_time"] isKindOfClass:NSString.class]) {
+ config[@"night_mode_off_time"] = @"23:00";
+ }
+ if (![config[@"night_mode_on_time"] isKindOfClass:NSString.class]) {
+ config[@"night_mode_on_time"] = @"07:00";
+ }
+ if (![config[@"night_mode_state"] isKindOfClass:NSString.class]) {
+ config[@"night_mode_state"] = @"off";
+ }
+
+ NSError *error = nil;
+ if (!WriteJSONFile(self.paths.configPath, config, &error)) {
+ [self showAlert:@"Could not update night mode" message:error.localizedDescription];
+ return;
+ }
+ [self restartDaemon];
+}
+
- (void)writeState:(NSString *)state {
CommandResult *result = RunCommand(self.paths.hookPath, @[@"--state", state], self.paths.root);
if (result.status != 0) {
@@ -365,12 +413,13 @@ - (void)showAlert:(NSString *)title message:(NSString *)message {
@end
+static MenuBarController *MenuController = nil;
+
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSApplication *app = NSApplication.sharedApplication;
[app setActivationPolicy:NSApplicationActivationPolicyAccessory];
- MenuBarController *controller = [[MenuBarController alloc] init];
- (void)controller;
+ MenuController = [[MenuBarController alloc] init];
[app run];
}
return 0;
diff --git a/pyproject.toml b/pyproject.toml
index 77ca33a..7cef3b1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,3 +32,6 @@ codex-moonside-doctor = "codex_moonside.doctor:main"
[tool.setuptools.packages.find]
include = ["codex_moonside*"]
+
+[tool.pytest.ini_options]
+asyncio_default_fixture_loop_scope = "function"
diff --git a/scripts/install-macos-menu-app b/scripts/install-macos-menu-app
new file mode 100755
index 0000000..7fe7838
--- /dev/null
+++ b/scripts/install-macos-menu-app
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+LABEL="local.codex-moonside-menu-bar"
+SOURCE_APP="$PWD/macos/MenuBarApp/build/Codex Moonside.app"
+INSTALL_DIR="$HOME/Applications"
+INSTALLED_APP="$INSTALL_DIR/Codex Moonside.app"
+EXECUTABLE="$INSTALLED_APP/Contents/MacOS/CodexMoonsideMenuBar"
+PLIST_DIR="$HOME/Library/LaunchAgents"
+PLIST_PATH="$PLIST_DIR/$LABEL.plist"
+STDOUT_LOG="$HOME/.codex-moonside-lamp/menu-bar.stdout.log"
+STDERR_LOG="$HOME/.codex-moonside-lamp/menu-bar.stderr.log"
+
+macos/MenuBarApp/Scripts/package-app
+
+mkdir -p "$INSTALL_DIR" "$PLIST_DIR" "$HOME/.codex-moonside-lamp"
+launchctl bootout "gui/$(id -u)" "$PLIST_PATH" >/dev/null 2>&1 || true
+rm -rf "$INSTALLED_APP"
+cp -R "$SOURCE_APP" "$INSTALLED_APP"
+
+cat > "$PLIST_PATH" <
+
+
+
+ Label
+ $LABEL
+ ProgramArguments
+
+ $EXECUTABLE
+
+ RunAtLoad
+
+ StandardOutPath
+ $STDOUT_LOG
+ StandardErrorPath
+ $STDERR_LOG
+
+
+EOF
+
+launchctl bootstrap "gui/$(id -u)" "$PLIST_PATH"
+launchctl enable "gui/$(id -u)/$LABEL"
+launchctl kickstart -k "gui/$(id -u)/$LABEL"
+
+echo "Installed and started $INSTALLED_APP"
+echo "LaunchAgent: $PLIST_PATH"
diff --git a/scripts/uninstall-macos-menu-app b/scripts/uninstall-macos-menu-app
new file mode 100755
index 0000000..c74822f
--- /dev/null
+++ b/scripts/uninstall-macos-menu-app
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LABEL="local.codex-moonside-menu-bar"
+PLIST_PATH="$HOME/Library/LaunchAgents/$LABEL.plist"
+INSTALLED_APP="$HOME/Applications/Codex Moonside.app"
+
+launchctl bootout "gui/$(id -u)" "$PLIST_PATH" >/dev/null 2>&1 || true
+rm -f "$PLIST_PATH"
+rm -rf "$INSTALLED_APP"
+
+echo "Removed $LABEL and $INSTALLED_APP"
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
index dd0bb77..763a48f 100644
--- a/tests/test_daemon.py
+++ b/tests/test_daemon.py
@@ -2,15 +2,23 @@
import asyncio
import unittest
+from datetime import datetime
+from unittest.mock import patch
from codex_moonside.daemon import (
configured_process_names,
+ is_minute_in_window,
+ is_night_mode_active,
is_configured_process_running,
+ is_configured_app_running,
is_recoverable_ble_send_error,
+ parse_hhmm,
process_name_matches,
send_state,
should_apply_codex_process_missing_state,
should_apply_ambient_timeout,
+ should_apply_night_mode_off,
+ should_restore_after_night_mode,
)
@@ -97,6 +105,7 @@ def test_process_name_matching_avoids_codex_moonside_helper(self) -> None:
self.assertTrue(process_name_matches("/Applications/Codex.app/Contents/Frameworks/Codex Helper", "Codex"))
self.assertFalse(process_name_matches("/tmp/CodexMoonsideMenuBar", "Codex"))
self.assertFalse(process_name_matches("/usr/local/bin/codex-moonside-daemon", "Codex"))
+ self.assertTrue(process_name_matches("/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", "ChatGPT"))
def test_configured_process_running_checks_all_process_lines(self) -> None:
self.assertTrue(
@@ -107,6 +116,16 @@ def test_configured_process_running_checks_all_process_lines(self) -> None:
)
self.assertFalse(is_configured_process_running(["Codex"], ["/tmp/CodexMoonsideMenuBar"]))
+ @patch("codex_moonside.daemon.subprocess.run")
+ def test_configured_app_running_uses_app_bundle_pattern(self, run) -> None:
+ run.return_value = type("Result", (), {"returncode": 0, "stderr": ""})()
+
+ self.assertTrue(is_configured_app_running(["ChatGPT", "Codex"]))
+ self.assertEqual(
+ run.call_args.args[0],
+ ["/usr/bin/pgrep", "-f", "/(ChatGPT|Codex)\\.app/"],
+ )
+
def test_process_missing_state_waits_for_grace_period(self) -> None:
self.assertTrue(
should_apply_codex_process_missing_state(
@@ -138,6 +157,85 @@ def test_process_missing_state_waits_for_grace_period(self) -> None:
missing_seconds=60,
)
)
+
+ def test_night_mode_time_window_wraps_midnight(self) -> None:
+ self.assertEqual(parse_hhmm("23:00"), 23 * 60)
+ self.assertIsNone(parse_hhmm("25:00"))
+ self.assertTrue(is_minute_in_window(23 * 60 + 30, 23 * 60, 7 * 60))
+ self.assertTrue(is_minute_in_window(2 * 60, 23 * 60, 7 * 60))
+ self.assertFalse(is_minute_in_window(12 * 60, 23 * 60, 7 * 60))
+ self.assertTrue(is_night_mode_active(datetime(2026, 5, 8, 23, 30), "23:00", "07:00"))
+ self.assertFalse(is_night_mode_active(datetime(2026, 5, 8, 12, 0), "23:00", "07:00"))
+
+ def test_night_mode_off_only_applies_to_idle_or_ambient(self) -> None:
+ self.assertTrue(
+ should_apply_night_mode_off(
+ enabled=True,
+ current_state="idle",
+ night_off_applied=False,
+ now=datetime(2026, 5, 8, 23, 30),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
+ self.assertTrue(
+ should_apply_night_mode_off(
+ enabled=True,
+ current_state="ambient",
+ night_off_applied=False,
+ now=datetime(2026, 5, 8, 23, 30),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
+ self.assertFalse(
+ should_apply_night_mode_off(
+ enabled=True,
+ current_state="working",
+ night_off_applied=False,
+ now=datetime(2026, 5, 8, 23, 30),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
+ self.assertTrue(
+ should_restore_after_night_mode(
+ enabled=True,
+ night_off_applied=True,
+ now=datetime(2026, 5, 9, 7, 0),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
+ self.assertFalse(
+ should_restore_after_night_mode(
+ enabled=True,
+ night_off_applied=True,
+ now=datetime(2026, 5, 9, 6, 59),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
+ self.assertFalse(
+ should_apply_night_mode_off(
+ enabled=True,
+ current_state="idle",
+ night_off_applied=True,
+ now=datetime(2026, 5, 8, 23, 30),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
+ self.assertFalse(
+ should_apply_night_mode_off(
+ enabled=False,
+ current_state="idle",
+ night_off_applied=False,
+ now=datetime(2026, 5, 8, 23, 30),
+ off_time="23:00",
+ on_time="07:00",
+ )
+ )
self.assertFalse(
should_apply_ambient_timeout(
current_state="working",
diff --git a/tests/test_doctor.py b/tests/test_doctor.py
index 6ce8d14..4f3cc34 100644
--- a/tests/test_doctor.py
+++ b/tests/test_doctor.py
@@ -6,10 +6,14 @@
import tempfile
import unittest
from pathlib import Path
+from unittest.mock import patch
from codex_moonside.doctor import (
+ check_codex_binary,
check_hook_trust,
executable_for_command,
+ resolve_codex_binary,
+ run_live_codex_test,
run_direct_hook_smoke_test,
toml_bool_in_section,
)
@@ -61,6 +65,53 @@ def test_run_direct_hook_smoke_test_writes_temp_state(self) -> None:
check = run_direct_hook_smoke_test({"hooks": {}}, command)
self.assertEqual(check.status, "ok")
+ @patch("codex_moonside.doctor.run_command")
+ @patch("codex_moonside.doctor.find_codex_binaries")
+ def test_resolve_codex_binary_skips_broken_path_binary(self, find_binaries, run_command) -> None:
+ find_binaries.return_value = ["/broken/codex", "/Applications/ChatGPT.app/Contents/Resources/codex"]
+ run_command.side_effect = [
+ type("Result", (), {"returncode": 1})(),
+ type("Result", (), {"returncode": 0})(),
+ ]
+
+ self.assertEqual(resolve_codex_binary(), "/Applications/ChatGPT.app/Contents/Resources/codex")
+
+ @patch("codex_moonside.doctor.run_command")
+ @patch("codex_moonside.doctor.find_codex_binaries")
+ def test_binary_check_is_ok_when_bundled_cli_works_and_path_shim_is_broken(self, find_binaries, run_command) -> None:
+ find_binaries.return_value = ["/Applications/ChatGPT.app/Contents/Resources/codex", "/broken/codex"]
+ run_command.side_effect = [
+ type("Result", (), {"returncode": 0, "stdout": "codex-cli 0.147.0", "stderr": ""})(),
+ type("Result", (), {"returncode": 1, "stdout": "", "stderr": "missing vendor binary"})(),
+ ]
+
+ check = check_codex_binary()
+
+ self.assertEqual(check.status, "ok")
+ self.assertIn("unusable", check.detail)
+
+ @patch("codex_moonside.doctor.resolve_codex_binary", return_value="/codex")
+ @patch("codex_moonside.doctor.run_command")
+ def test_live_test_restores_lamp_runtime_files(self, run_command, _resolve) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ state_path = tmp / "state.json"
+ hook_log_path = tmp / "hook.log"
+ state_path.write_text("old state", encoding="utf-8")
+ hook_log_path.write_text("before", encoding="utf-8")
+
+ def run_live(*_args, **_kwargs):
+ state_path.write_text("nested turn", encoding="utf-8")
+ hook_log_path.write_text("after live hook", encoding="utf-8")
+ return type("Result", (), {"returncode": 0, "stdout": "", "stderr": ""})()
+
+ run_command.side_effect = run_live
+
+ check = run_live_codex_test(hook_log_path, tmp, [state_path])
+
+ self.assertEqual(check.status, "ok")
+ self.assertEqual(state_path.read_text(encoding="utf-8"), "old state")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_hook_session.py b/tests/test_hook_session.py
index fe71229..7b35f86 100644
--- a/tests/test_hook_session.py
+++ b/tests/test_hook_session.py
@@ -10,10 +10,25 @@
from codex_moonside.hook import main as hook_main
from codex_moonside.hook import session_allowed, wait_for_minimum_attention
-from codex_moonside.state import read_state_file, write_session_lock
+from codex_moonside.state import atomic_write_state, read_state_file, write_session_lock
class HookSessionTests(unittest.TestCase):
+ def write_config(self, tmp: Path) -> Path:
+ config_path = tmp / "config.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "state_file": str(tmp / "state.json"),
+ "permission_file": str(tmp / "permission.json"),
+ "session_lock_file": str(tmp / "lock.json"),
+ "hook_log_file": str(tmp / "hook.log"),
+ }
+ ),
+ encoding="utf-8",
+ )
+ return config_path
+
def test_permission_request_allowed_for_same_cwd_sibling_session(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
lock_path = str(Path(tmpdir) / "lock.json")
@@ -48,6 +63,68 @@ def test_stop_still_rejected_for_sibling_session(self) -> None:
)
)
+ def test_stale_lock_does_not_block_session_event(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ lock_path = tmp / "lock.json"
+ config = {
+ "session_lock_enabled": True,
+ "session_lock_file": str(lock_path),
+ "permission_file": str(tmp / "permission.json"),
+ "session_lock_max_age_seconds": 1,
+ }
+ atomic_write_state(
+ {
+ "session_id": "old-session",
+ "turn_id": "old-turn",
+ "cwd": "/tmp/old-project",
+ "timestamp": "2000-01-01T00:00:00+00:00",
+ },
+ str(lock_path),
+ )
+
+ self.assertTrue(
+ session_allowed(
+ config,
+ {"session_id": "new-session", "cwd": "/tmp/new-project"},
+ "Stop",
+ )
+ )
+ self.assertFalse(lock_path.exists())
+
+ def test_stale_permission_marker_does_not_block_new_prompt(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ lock_path = tmp / "lock.json"
+ permission_path = tmp / "permission.json"
+ config = {
+ "session_lock_enabled": True,
+ "session_lock_file": str(lock_path),
+ "permission_file": str(permission_path),
+ "session_lock_max_age_seconds": 1,
+ "permission_marker_max_age_seconds": 1,
+ }
+ write_session_lock({"session_id": "old-session", "cwd": "/tmp/old-project"}, str(lock_path))
+ atomic_write_state(
+ {
+ "event": "PermissionRequest",
+ "session_id": "old-session",
+ "turn_id": "old-turn",
+ "cwd": "/tmp/old-project",
+ "timestamp_epoch": 1,
+ },
+ str(permission_path),
+ )
+
+ self.assertTrue(
+ session_allowed(
+ config,
+ {"session_id": "new-session", "cwd": "/tmp/new-project"},
+ "UserPromptSubmit",
+ )
+ )
+ self.assertFalse(permission_path.exists())
+
def test_wait_for_minimum_attention_sleeps_remaining_time(self) -> None:
started = time.time()
waited = wait_for_minimum_attention({"timestamp_epoch": started}, 0.02)
@@ -102,6 +179,64 @@ def test_post_tool_use_without_permission_marker_keeps_lamp_working(self) -> Non
self.assertEqual(payload["state"], "working")
self.assertEqual(payload["event"], "PostToolUse")
+ def test_compact_session_start_does_not_overwrite_working_state(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ config_path = self.write_config(tmp)
+ atomic_write_state({"state": "working", "timestamp": "now"}, str(tmp / "state.json"))
+ event = {
+ "hook_event_name": "SessionStart",
+ "source": "compact",
+ "session_id": "s1",
+ "cwd": "/tmp/project",
+ }
+
+ with patch("sys.stdin", io.StringIO(json.dumps(event))):
+ status = hook_main(["--config", str(config_path), "--state", "idle"])
+
+ self.assertEqual(status, 0)
+ self.assertEqual(read_state_file(str(tmp / "state.json"))["state"], "working") # type: ignore[index]
+
+ def test_active_stop_does_not_finish_or_release_session(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ config_path = self.write_config(tmp)
+ write_session_lock({"session_id": "s1", "turn_id": "t1", "cwd": "/tmp/project"}, str(tmp / "lock.json"))
+ atomic_write_state({"state": "working", "timestamp": "now"}, str(tmp / "state.json"))
+ event = {
+ "hook_event_name": "Stop",
+ "stop_hook_active": True,
+ "session_id": "s1",
+ "turn_id": "t1",
+ "cwd": "/tmp/project",
+ }
+
+ with patch("sys.stdin", io.StringIO(json.dumps(event))):
+ status = hook_main(["--config", str(config_path), "--state", "tool_done"])
+
+ self.assertEqual(status, 0)
+ self.assertEqual(read_state_file(str(tmp / "state.json"))["state"], "working") # type: ignore[index]
+ self.assertTrue((tmp / "lock.json").exists())
+
+ def test_session_end_clears_matching_session_lock(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+ config_path = self.write_config(tmp)
+ write_session_lock({"session_id": "s1", "turn_id": "t1", "cwd": "/tmp/project"}, str(tmp / "lock.json"))
+ event = {
+ "hook_event_name": "SessionEnd",
+ "session_id": "s1",
+ "turn_id": "t1",
+ "cwd": "/tmp/project",
+ }
+
+ with patch("sys.stdin", io.StringIO(json.dumps(event))):
+ status = hook_main(["--config", str(config_path), "--state", "idle"])
+
+ self.assertEqual(status, 0)
+ self.assertFalse((tmp / "lock.json").exists())
+ self.assertEqual(read_state_file(str(tmp / "state.json"))["event"], "SessionEnd") # type: ignore[index]
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py
index e43ca6d..2debddb 100644
--- a/tests/test_install_hooks.py
+++ b/tests/test_install_hooks.py
@@ -18,9 +18,11 @@ def test_trusted_hook_state_entries_match_generated_hooks(self) -> None:
hooks = load_hooks_template("/tmp/codex-moonside-hook")
entries = trusted_hook_state_entries(hooks, Path("/tmp/hooks.json"))
- self.assertEqual(len(entries), 5)
+ self.assertEqual(len(entries), 6)
self.assertEqual(entries[0][0], "/tmp/hooks.json:session_start:0:0")
self.assertTrue(entries[0][1].startswith("sha256:"))
+ self.assertEqual(hooks["hooks"]["SessionStart"][0]["matcher"], "startup|resume|clear")
+ self.assertTrue(hooks["hooks"]["PostToolUse"][0]["hooks"][0]["async"])
def test_upsert_hook_trust_entries_adds_and_replaces_hashes(self) -> None:
entries = [("/tmp/hooks.json:user_prompt_submit:0:0", "sha256:new")]
@@ -42,8 +44,9 @@ def test_write_hook_trust_uses_installed_hooks_source_path(self) -> None:
count = write_hook_trust(config_path, hooks_path, hooks)
- self.assertEqual(count, 5)
+ self.assertEqual(count, 6)
self.assertIn(f'[hooks.state."{hooks_path}:stop:0:0"]', config_path.read_text(encoding="utf-8"))
+ self.assertIn(f'[hooks.state."{hooks_path}:session_end:0:0"]', config_path.read_text(encoding="utf-8"))
if __name__ == "__main__":
diff --git a/tests/test_logging_utils.py b/tests/test_logging_utils.py
new file mode 100644
index 0000000..c0a5cb6
--- /dev/null
+++ b/tests/test_logging_utils.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import tempfile
+import unittest
+from pathlib import Path
+
+from codex_moonside.logging_utils import setup_file_logger
+
+
+class LoggingUtilsTests(unittest.TestCase):
+ def test_file_logger_rotates_bounded_logs(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "bounded.log"
+ logger = setup_file_logger(
+ f"codex_moonside.test.{id(path)}",
+ str(path),
+ max_bytes=128,
+ backup_count=2,
+ )
+ for index in range(30):
+ logger.info("line %d %s", index, "x" * 32)
+
+ self.assertTrue(path.exists())
+ self.assertTrue(Path(f"{path}.1").exists())
+ self.assertFalse(Path(f"{path}.3").exists())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_state.py b/tests/test_state.py
index 32e9d7d..446d053 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -6,14 +6,18 @@
from pathlib import Path
from codex_moonside.state import (
+ atomic_write_latest_state,
atomic_write_state,
build_session_lock,
build_permission_marker,
event_to_state,
+ payload_is_newer,
+ payload_is_expired,
permission_marker_matches,
payload_from_hook_input,
read_state_file,
session_lock_matches,
+ should_ignore_hook_event,
)
@@ -25,8 +29,65 @@ def test_event_mapping(self) -> None:
self.assertEqual(event_to_state("PermissionRequest"), "attention")
self.assertEqual(event_to_state("PostToolUse"), "tool_done")
self.assertEqual(event_to_state("Stop"), "idle")
+ self.assertEqual(event_to_state("SessionEnd"), "idle")
self.assertIsNone(event_to_state("Unexpected"))
+ def test_non_terminal_lifecycle_events_are_ignored(self) -> None:
+ self.assertTrue(
+ should_ignore_hook_event(
+ {"hook_event_name": "SessionStart", "source": "compact"}
+ )
+ )
+ self.assertFalse(
+ should_ignore_hook_event(
+ {"hook_event_name": "SessionStart", "source": "resume"}
+ )
+ )
+ self.assertTrue(
+ should_ignore_hook_event(
+ {"hook_event_name": "Stop", "stop_hook_active": True}
+ )
+ )
+
+ def test_event_order_wins_over_async_completion_order_within_turn(self) -> None:
+ stop = {
+ "state": "idle",
+ "event": "Stop",
+ "session_id": "s1",
+ "turn_id": "t1",
+ "timestamp_epoch_ns": 100,
+ }
+ late_post_tool = {
+ "state": "working",
+ "event": "PostToolUse",
+ "session_id": "s1",
+ "turn_id": "t1",
+ "timestamp_epoch_ns": 200,
+ }
+ self.assertFalse(payload_is_newer(late_post_tool, stop))
+ self.assertTrue(payload_is_newer(stop, late_post_tool))
+
+ def test_atomic_latest_state_rejects_late_older_event(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "state.json"
+ stop = {
+ "state": "idle",
+ "event": "Stop",
+ "session_id": "s1",
+ "turn_id": "t1",
+ "timestamp_epoch_ns": 100,
+ }
+ late_post_tool = {
+ "state": "working",
+ "event": "PostToolUse",
+ "session_id": "s1",
+ "turn_id": "t1",
+ "timestamp_epoch_ns": 200,
+ }
+ self.assertTrue(atomic_write_latest_state(stop, str(path)))
+ self.assertFalse(atomic_write_latest_state(late_post_tool, str(path)))
+ self.assertEqual(read_state_file(str(path)), stop)
+
def test_payload_from_codex_style_input(self) -> None:
payload = payload_from_hook_input(
json.dumps(
@@ -130,7 +191,8 @@ def test_permission_marker_requires_some_identity_match(self) -> None:
def test_session_lock_matches_session_id_first(self) -> None:
lock = build_session_lock({"session_id": "s1", "turn_id": "t1", "cwd": "/tmp/a"})
- self.assertTrue(session_lock_matches(lock, {"session_id": "s1", "turn_id": "other", "cwd": "/tmp/b"}))
+ self.assertTrue(session_lock_matches(lock, {"session_id": "s1", "turn_id": "t1", "cwd": "/tmp/b"}))
+ self.assertFalse(session_lock_matches(lock, {"session_id": "s1", "turn_id": "other", "cwd": "/tmp/b"}))
self.assertFalse(session_lock_matches(lock, {"session_id": "s2", "turn_id": "t1", "cwd": "/tmp/a"}))
def test_session_lock_falls_back_to_turn_or_cwd(self) -> None:
@@ -139,6 +201,12 @@ def test_session_lock_falls_back_to_turn_or_cwd(self) -> None:
self.assertTrue(session_lock_matches({"cwd": "/tmp/a"}, {"cwd": "/tmp/a"}))
self.assertFalse(session_lock_matches({"cwd": "/tmp/a"}, {"cwd": "/tmp/b"}))
+ def test_payload_expiry_uses_epoch_or_iso_timestamp(self) -> None:
+ self.assertTrue(payload_is_expired({"timestamp_epoch": 1}, 1))
+ self.assertTrue(payload_is_expired({"timestamp": "2000-01-01T00:00:00+00:00"}, 1))
+ self.assertFalse(payload_is_expired({"timestamp": "2000-01-01T00:00:00+00:00"}, 0))
+ self.assertFalse(payload_is_expired({"timestamp": "not a timestamp"}, 1))
+
if __name__ == "__main__":
unittest.main()