chore: push all design docs, V2 plan specs, and current working state
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>
This commit is contained in:
164
AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext
Executable file
164
AirPlan/docs/spec/AirPlan-ParaV2/AirContextServer/bin/aircontext
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user