#!/usr/bin/env python3 """ aircontext — wrapper around `claude` providing automatic context compaction loop. Usage: aircontext [args passed through to claude] Behaviour: 1. Verify ./AirContext/ exists and config.yaml is valid (create from templates if missing). 2. Spawn `claude` as a child process (stdio inherited so UX matches running claude directly). 3. A watcher thread polls AirContext/state.json; when compaction_ready=true, it: - terminates the running claude gracefully - re-spawns `claude --resume ` so the new isolated-summary chain takes effect 4. Loop exits when the user closes claude without a pending compaction. """ from __future__ import annotations import os import sys import signal import subprocess import threading import time from pathlib import Path # Ensure plugin's scripts/ is importable regardless of how the wrapper is invoked. _PLUGIN_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_PLUGIN_ROOT / "scripts")) from core.auto_init import ( # noqa: E402 auto_configure, propagate_settings_env, required_fields_missing, ) from core.config_loader import ensure_aircontext_dir, validate_config # noqa: E402 from core.state import StateFile # noqa: E402 WATCH_INTERVAL_SECONDS = 2.0 TERMINATE_TIMEOUT_SECONDS = 10 def terminate_gracefully(proc: subprocess.Popen) -> None: """Send a platform-appropriate stop signal, then SIGKILL after timeout.""" try: if os.name == "nt": proc.send_signal(signal.CTRL_BREAK_EVENT) else: proc.send_signal(signal.SIGTERM) except (ProcessLookupError, OSError): return try: proc.wait(timeout=TERMINATE_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: proc.kill() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: pass def spawn_claude(resume_id: str | None, passthrough_args: list[str]) -> subprocess.Popen: cmd = ["claude"] if resume_id: cmd += ["--resume", resume_id] cmd += passthrough_args env = {**os.environ, "AIRCONTEXT_ACTIVE": "1"} creationflags = 0 if os.name == "nt": creationflags = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] return subprocess.Popen(cmd, env=env, creationflags=creationflags) def main() -> int: project_root = Path.cwd() air = project_root / "AirContext" # Make ~/.claude/settings.json `env` block visible in os.environ so the # validate_config call below resolves ${env:VAR} placeholders the same way # claude/hooks/compactor will at runtime. propagate_settings_env() created = ensure_aircontext_dir(air, plugin_root=_PLUGIN_ROOT) config_path = air / "config.yaml" if created: cfg = auto_configure(config_path) window = cfg.get("trigger", {}).get("model_context_window", 200000) print( f"[aircontext] AirContext/ initialised at {air}", file=sys.stderr, ) print( f"[aircontext] Auto-configured: model_context_window={window}, " f"backend.type={cfg.get('backend', {}).get('type')}", file=sys.stderr, ) missing = required_fields_missing(config_path) if missing: print( "[aircontext] Please fill these REQUIRED fields in " f"{config_path}:", file=sys.stderr, ) for m in missing: print(f"[aircontext] - {m}", file=sys.stderr) print( "[aircontext] Then re-run `aircontext` and you'll go straight into claude.", file=sys.stderr, ) return 1 err = validate_config(config_path) if err: print(f"[aircontext] Invalid config: {err}", file=sys.stderr) return 1 state = StateFile(air / "state.json") state.reset_for_new_session() passthrough = sys.argv[1:] resume_id = state.last_session_id while True: proc = spawn_claude(resume_id, passthrough) pending_resume_id: list[str | None] = [None] stop_watcher = threading.Event() def watcher() -> None: while not stop_watcher.is_set(): snap = state.read() if snap.get("compaction_ready"): rid = snap.get("pending_resume_session_id") if rid: pending_resume_id[0] = rid terminate_gracefully(proc) return if stop_watcher.wait(WATCH_INTERVAL_SECONDS): return t = threading.Thread(target=watcher, daemon=True) t.start() try: proc.wait() except KeyboardInterrupt: terminate_gracefully(proc) finally: stop_watcher.set() t.join(timeout=3) if pending_resume_id[0]: resume_id = pending_resume_id[0] state.clear_ready() print( f"[aircontext] Compaction applied, resuming session {resume_id}", file=sys.stderr, ) continue # User exited without a pending compaction — finish. return proc.returncode or 0 if __name__ == "__main__": sys.exit(main())