Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
165 lines
5.2 KiB
Python
Executable File
165 lines
5.2 KiB
Python
Executable File
#!/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 <session_id>` 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())
|