main
John Lauer v0.1.19: pre-empt script modals - cross-workspace residency check + in-flight watcher 72db7b8 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756
"""agent.py — the bridge side of the resident DelphiScript agent (the REPL lane).

Lifecycle
  install()  render AdomAgent.pas.tmpl with the workspace path baked in, drop the
             .pas + .PrjScr into  <cache>\\agent\\  (idempotent, content-hashed)
  start()    `X2.EXE -RScriptingSystem:RunScript(ProjectName="…AdomAgent.PrjScr"|ProcName="StartAgent")`
             — the helper X2 forwards to the running instance and exits; we then
             wait for hb.txt to appear.
  call()     write request_<id>.txt (+ .payload), poll for response_<id>.json,
             watch hb.txt to tell "slow handler" from "engine wedged".
  stop()     drop the `stop` file; the loop exits on its next tick.

Engine rules this module enforces (measured 2026-08-29, AD 26.9.1):
  * ONE script at a time.  While the agent is resident, NEVER launch another
    -R RunScript/RunScriptText: it raises the modal "Another script executing
    now" on the main instance and every later -R is refused as "busy" until a
    human/UIA dismisses it.  So `run_script_lane()` refuses when the agent is up.
  * A modal on the main instance => the instance is "busy" to -R and the agent
    loop is frozen (modal message loop).  dialogs.py is how you get out.
  * RunScriptText = statements only; CreateOleObject does not exist.
"""
from __future__ import annotations

import hashlib
import json
import os
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path

from handlers import altium_detect
import caller_identity

_inbox_lock = threading.Lock()
_no_recover = False

AGENT_TEMPLATE = Path(__file__).resolve().parent.parent / "agent" / "AdomAgent.pas.tmpl"
AGENT_PRJ = Path(__file__).resolve().parent.parent / "agent" / "AdomAgent.PrjScr"


def cache_dir() -> Path:
    """%LOCALAPPDATA%\\Adom Bridge\\bridges-cache\\altium — next to the other bridges.
    Tolerates ab's stripped env (LOCALAPPDATA may be unset)."""
    base = os.environ.get("LOCALAPPDATA")
    if not base:
        up = os.environ.get("USERPROFILE") or str(Path.home())
        base = str(Path(up) / "AppData" / "Local")
    d = Path(base) / "Adom Bridge" / "bridges-cache" / "altium"
    d.mkdir(parents=True, exist_ok=True)
    return d


# The instance this module is currently acting for. Set by server.py per verb via `use_instance`.
# Default None = the legacy single-instance workspace, so an upgrade changes nothing for the
# common case of one Altium.
_instance: dict | None = None


def use_instance(inst: dict | None) -> None:
    """Point every workspace-derived path at ONE Altium instance for the duration of a verb."""
    global _instance
    _instance = inst


def current_instance() -> dict | None:
    return _instance


def workspace_dir() -> Path:
    """The agent's file-drop directory — PER INSTANCE.

    Two Altiums sharing one workspace is the failure that does not announce itself: both resident
    loops poll the same `request.txt`, so whichever notices first eats the other's request and
    answers it from the wrong board (John, 2026-09-01, running Designer + Designer Develop for two
    AI threads). Keyed by install folder ("AD26", "ADDevelop") so a restart keeps the same folder.
    """
    d = cache_dir() / "agent"
    if _instance and _instance.get("key"):
        d = d / str(_instance["key"])
    d.mkdir(parents=True, exist_ok=True)
    return d


def _ws_str() -> str:
    s = str(workspace_dir())
    return s if s.endswith("\\") else s + "\\"


def rendered_agent_source() -> str:
    tmpl = AGENT_TEMPLATE.read_text(encoding="utf-8")
    # DelphiScript string literal: single quotes; the workspace has none, but be safe.
    return tmpl.replace("__WORKSPACE__", _ws_str().replace("'", "''")).replace("__GEN__", _agent_base())


def _agent_base() -> str:
    """AdomAgent_<digest> — a NEW filename whenever the source changes. Altium keeps
    the .PrjScr/.pas OPEN in its Script editor after a RunScript and re-runs the
    in-editor copy, so overwriting AdomAgent.pas in place ran the STALE unit
    (measured 2026-08-29: "Program cannot find script … StartAgent" + the Run
    Script picker, hours after the fix was on disk)."""
    raw = AGENT_TEMPLATE.read_text(encoding="utf-8")
    digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:10]
    return f"AdomAgent_{digest}"


def prjscr_path() -> Path:
    return workspace_dir() / (_agent_base() + ".PrjScr")


def install() -> dict:
    """Write the agent script project into the workspace. Idempotent per digest."""
    ws = workspace_dir()
    src = rendered_agent_source()
    base = _agent_base()
    pas = ws / (base + ".pas")
    prj = ws / (base + ".PrjScr")
    changed = not pas.exists()
    if changed:
        pas.write_text(src, encoding="ascii", errors="replace")
        prj.write_text(AGENT_PRJ.read_text(encoding="utf-8").replace("AdomAgent.pas", base + ".pas"), encoding="ascii")
        # prune older generations (keep the newest 3 so an open editor tab does not dangle)
        gens = sorted(ws.glob("AdomAgent_*.pas"), key=lambda q: q.stat().st_mtime)
        for old in gens[:-3]:
            for q in (old, old.with_suffix(".PrjScr")):
                try:
                    q.unlink()
                except Exception:
                    pass
    return {"installed": True, "changed": changed, "prjscr": str(prj), "pas": str(pas), "digest": base[-10:]}


# ---- liveness ----------------------------------------------------------------

def _hb() -> dict | None:
    p = workspace_dir() / "hb.txt"
    if not p.exists():
        return None
    try:
        lines = p.read_text(errors="replace").splitlines()
        tick = int(lines[0].strip())
        out = {"tick": tick, "ageSec": None, "mtime": p.stat().st_mtime}
        out["ageSec"] = round(time.time() - p.stat().st_mtime, 2)
        for l in lines[1:]:
            if "=" in l:
                k, v = l.split("=", 1)
                out[k.strip()] = v.strip()
        return out
    except Exception:
        return None


# Two different questions, two thresholds (measured 2026-08-29):
#   responsive  = heartbeat < 3 s  -> the loop is idle-polling right now
#   alive       = heartbeat < 60 s -> the loop exists; it may be INSIDE a long
#                 RunProcess (PCB:Zoom on a 3D board took 3.2 s, an OutJob takes
#                 minutes) and cannot heartbeat until that returns.
# Treating "busy" as "dead" and auto-starting a second loop raised Altium's
# "Another script executing now" modal, which then froze the real loop. Never
# start while ALIVE; only report "not responsive".
ALIVE_SEC = 60.0
RESPONSIVE_SEC = 3.0


def is_alive(max_age_sec: float = ALIVE_SEC) -> bool:
    hb = _hb()
    return bool(hb and hb.get("ageSec") is not None and hb["ageSec"] <= max_age_sec)


def is_responsive() -> bool:
    return is_alive(RESPONSIVE_SEC)


def status() -> dict:
    hb = _hb()
    alive = is_alive()
    st = {"alive": alive, "responsive": is_responsive(), "heartbeat": hb, "workspace": _ws_str(),
          "installed": prjscr_path().exists()}
    if alive and not st["responsive"]:
        st["_hint"] = "Loop is alive but busy inside a long Altium process (no heartbeat while a RunProcess runs). Wait; do not start a second agent."
    aj = workspace_dir() / "agent.json"
    if aj.exists():
        try:
            st["agent"] = json.loads(aj.read_text(errors="replace"))
        except Exception:
            pass
    if hb and not alive:
        st["_hint"] = ("Heartbeat is stale: the loop stopped (auto-stop after idle, Altium closed, or a script "
                       "error left the engine halted). Call altium_agent_start to bring it back; if a modal "
                       "is up, altium_check_dialogs first.")
    return st


# ---- start / stop -------------------------------------------------------------

def _x2_exe() -> str | None:
    """The exe to spawn a -R helper against.

    MUST be the selected instance's own image: Designer and Designer Develop live in different
    install folders, so a constant path would forward every request to the wrong Altium."""
    if _instance and _instance.get("exe"):
        return _instance["exe"]
    inst = altium_detect.detect_installs()
    return inst[0]["exe"] if inst else None


def _spawn_helper(exe: str, arg: str) -> subprocess.Popen:
    r"""Launch `X2.EXE <arg>` with the -R argument passed VERBATIM.

    Measured 2026-08-29: Popen([exe, arg]) re-quotes the argument because the
    workspace path contains a space ("Adom Bridge") — the helper then received
    "-RScriptingSystem:RunScript(ProjectName=\"C:\...\")" with backslash-escaped
    quotes, which Altium cannot parse, so it neither forwarded nor exited. A
    single command-line STRING is handed to CreateProcess untouched, exactly as
    PowerShell's Start-Process -ArgumentList did in the experiments that worked.
    """
    cmdline = f'"{exe}" {arg}'
    return subprocess.Popen(cmdline, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))


def _kill_stray_helpers() -> list[int]:
    """Kill X2 processes that are LEFT-OVER -R HELPERS — nothing else.

    A helper carries its request in its own command line (`-R…ScriptingSystem`); a real Altium never
    does. The previous rule was "any X2 that is not the main window's process", which on 2026-09-01
    killed a genuine second instance (Altium Designer Develop, running deliberately for another AI
    thread). Never again: no -R in the command line means not a helper, full stop."""
    live = {i["pid"] for i in altium_detect.instances()}
    out = []
    try:
        r = caller_identity.ad_callback("process_list", {"nameFilter": "X2"}, on_behalf=False, timeout=10) or {}
        procs = r.get("processes") or []
    except Exception:
        return out
    for p in procs:
        pid, cl = p.get("pid"), (p.get("commandLine") or "")
        if pid is None or pid in live:
            continue  # owns an Altium main window: a real instance
        if "-R" not in cl or "ScriptingSystem" not in cl:
            continue  # not carrying a script request: not a helper
        try:
            subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=10,
                           creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
            out.append(pid)
        except Exception:
            pass
    return out


def _reap_busy_notice(wait_sec: float = 4.0) -> list:
    """A stray -R helper fired at a busy main instance makes Windows raise the single-instance
    notice ("There is another instance of running, but it is busy at the moment") as a modal on
    the USER's screen. Measured 2026-08-31: it can appear a beat AFTER the helper is reaped and
    our own X2-pid enumeration missed it twice. So: ask ab (the desktop authority) — find any
    "Information" window whose text carries the notice, press its OK, and keep looking for a
    short window. Never blind-dismiss anything else."""
    reaped = []
    deadline = time.time() + wait_sec
    seen = set()
    while time.time() < deadline:
        try:
            r = caller_identity.ad_callback("desktop_find_window", {"titleContains": "Information"}, on_behalf=False, timeout=8) or {}
            wins = r.get("matches") or r.get("windows") or ([r.get("best")] if r.get("best") else [])
        except Exception:
            wins = []
        hit = False
        for w in wins:
            hw = (w or {}).get("hwnd")
            if not hw or hw in seen:
                continue
            try:
                t = caller_identity.ad_callback("desktop_find_control", {"hwnd": hw, "contains": "another instance", "role": "text"}, on_behalf=False, timeout=8) or {}
                found = bool(t.get("control") or t.get("matches") or t.get("found"))
            except Exception:
                found = False
            if not found:
                continue
            seen.add(hw)
            try:
                caller_identity.ad_callback("desktop_ui_click", {"hwnd": hw, "name": "OK", "role": "button"}, on_behalf=False, timeout=8)
                reaped.append(hw)
                hit = True
            except Exception:
                pass
        # fallback: our own X2 scan (covers a notice parented to a live helper we still track)
        try:
            from handlers import dialogs
            for d in dialogs.scan(main_pid_only=False):
                blob = ((d.get("body") or "") + " " + (d.get("title") or "")).lower()
                if d["hwnd"] not in seen and ("another instance" in blob or "busy at the moment" in blob or "cannot proceed with the request" in blob):
                    dialogs.press(d["hwnd"], "OK"); seen.add(d["hwnd"]); reaped.append(d["hwnd"]); hit = True
        except Exception:
            pass
        if hit:
            time.sleep(0.5)
            continue
        if reaped:
            break
        time.sleep(0.5)
    return reaped



# ---- pre-emption: never fire a script lane that can raise a modal (John, 2026-09-01) ---------

def _any_agent_alive(max_age_sec: float = ALIVE_SEC) -> dict | None:
    """Is ANY resident loop alive, in ANY workspace? Returns {workspace, ageSec, gen} or None.

    `is_alive()` only checks the CURRENT instance's workspace. That is exactly how the "Another
    script executing now" modal reached John's screen after per-instance workspaces landed: the
    pre-upgrade loop was still resident under the legacy path, this module was looking at
    `agent/AD26`, saw no heartbeat, and fired a -R bootstrap into an engine already running a
    script. A script lane must clear EVERY workspace, not just its own."""
    root = cache_dir() / "agent"
    candidates = [root / "hb.txt"]
    try:
        candidates += [d / "hb.txt" for d in root.iterdir() if d.is_dir()]
    except Exception:
        pass
    for hb in candidates:
        try:
            age = time.time() - hb.stat().st_mtime
        except Exception:
            continue
        if age <= max_age_sec:
            gen = None
            try:
                for line in hb.read_text(errors="replace").splitlines():
                    if line.startswith("gen="):
                        gen = line.split("=", 1)[1].strip()
            except Exception:
                pass
            return {"workspace": str(hb.parent), "ageSec": round(age, 2), "gen": gen}
    return None


# Modals this bridge CAUSES. Only these are auto-cleared while an operation is in flight; anything
# else (a save prompt, a licence question) belongs to the user and must never be pressed.
_OURS = ("another script executing", "another instance of", "is busy at the moment",
         "scripting system is not able")


def _watch_and_clear(stop_evt, found: list, poll_sec: float = 0.4) -> None:
    """Run WHILE a script lane is in flight: the instant one of OUR modals appears, press OK.

    Detection alone is not pre-emption. The engine is single-threaded, so the modal it raises is
    also what freezes it — watching during the operation means the dialog lives a few hundred
    milliseconds instead of sitting on the user's screen until someone goes looking."""
    from handlers import dialogs as _dl
    while not stop_evt.is_set():
        try:
            for d in _dl.scan():
                blob = f"{d.get('title', '')} {d.get('body', '')}".lower()
                if not any(k in blob for k in _OURS):
                    continue
                caller_identity.ad_callback("desktop_ui_click",
                                            {"hwnd": d.get("hwnd"), "name": "OK", "role": "button"},
                                            on_behalf=False, timeout=8)
                found.append({"title": d.get("title"), "body": (d.get("body") or "")[:120],
                              "clearedInFlight": True})
        except Exception:
            pass
        stop_evt.wait(poll_sec)


def _guarded(fn):
    """Run `fn()` with the modal watcher live. Returns (result, cleared[])."""
    import threading
    evt = threading.Event()
    cleared: list = []
    th = threading.Thread(target=_watch_and_clear, args=(evt, cleared), daemon=True)
    th.start()
    try:
        return fn(), cleared
    finally:
        evt.set()
        th.join(timeout=2.0)


def run_script_lane(proc_name: str, prjscr: str | None = None, timeout: float = 30.0) -> dict:
    """The -R lane: forward RunScript to the running instance. REFUSES while the
    agent is resident (one script at a time — see module docstring)."""
    other = _any_agent_alive()
    if other:
        return {"success": False, "errorCode": "agent_resident", "resident": other,
                "error": "a resident agent loop is already running in this Altium (workspace "
                         f"{other['workspace']}, heartbeat {other['ageSec']}s ago) - a second script "
                         "would raise Altium's 'Another script executing now' modal on the user's screen",
                "_hint": "altium_agent_stop, or altium_agent_recover if it is wedged, then retry. A workspace "
                         "path that is not this instance's means a loop from before an upgrade: stopping it is safe."}
    if is_alive():
        return {"success": False, "errorCode": "agent_resident",
                "error": "The Adom agent loop is resident; a second script would raise Altium's "
                         "'Another script executing now' modal.",
                "_hint": "Send the work through the agent (altium_eval / altium_run_command) or "
                         "altium_agent_stop first."}
    exe = _x2_exe()
    if not exe:
        return {"success": False, "errorCode": "host_app_not_installed", "hostApp": altium_detect.HOST_APP}
    prj = prjscr or str(prjscr_path())
    arg = f'-RScriptingSystem:RunScript(ProjectName="{prj}"|ProcName="{proc_name}")'
    try:
        p = _spawn_helper(exe, arg)
    except Exception as e:
        return {"success": False, "error": f"could not launch X2 helper: {e}"}
    # PRE-EMPT: watch for OUR OWN modals for as long as the helper is in flight and press OK the
    # instant one appears. The user should never have to find it. Anything not in _OURS (a save
    # prompt, a licence question) is left strictly alone.
    import threading as _th
    _evt = _th.Event()
    _cleared: list = []
    _w = _th.Thread(target=_watch_and_clear, args=(_evt, _cleared), daemon=True)
    _w.start()
    # The helper exits once the running instance accepted the request (or shows its
    # own "another instance is busy" dialog and hangs — we cap that).
    t0 = time.time()
    while time.time() - t0 < timeout:
        if p.poll() is not None:
            _evt.set(); _w.join(timeout=2.0)
            r = {"success": True, "helperExitCode": p.returncode, "helperSec": round(time.time() - t0, 2), "arg": arg}
            if _cleared:
                r["modalsPreempted"] = _cleared
            return r
        time.sleep(0.2)
    try:
        p.kill()
    except Exception:
        pass
    _evt.set(); _w.join(timeout=2.0)
    reaped = _reap_busy_notice()
    return {"success": False, "errorCode": "instance_busy", "modalsPreempted": _cleared,
            "error": "The X2 helper never exited: the running instance refused the request as busy "
                     "(a modal is open, or a script is already running).",
            "busyNoticeReaped": reaped,
            "_hint": "altium_check_dialogs, dismiss what is blocking, then retry."}


def run_script_text_lane(text: str, timeout: float = 30.0) -> dict:
    """-R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while the agent is resident."""
    other = _any_agent_alive()
    if other:
        return {"success": False, "errorCode": "agent_resident", "resident": other,
                "error": "a resident agent loop is already running in this Altium (workspace "
                         f"{other['workspace']}, heartbeat {other['ageSec']}s ago) - a second script "
                         "would raise Altium's 'Another script executing now' modal on the user's screen",
                "_hint": "altium_agent_stop, or altium_agent_recover if it is wedged, then retry. A workspace "
                         "path that is not this instance's means a loop from before an upgrade: stopping it is safe."}
    if is_alive():
        return {"success": False, "errorCode": "agent_resident",
                "_hint": "Use altium_eval (goes through the agent) while the agent is up."}
    exe = _x2_exe()
    if not exe:
        return {"success": False, "errorCode": "host_app_not_installed", "hostApp": altium_detect.HOST_APP}
    if '"' in text:
        return {"success": False, "error": "RunScriptText text cannot contain double quotes (the -R arg is double-quoted); use single quotes for strings."}
    arg = f'-RScriptingSystem:RunScriptText(Text="{text}")'
    try:
        p = _spawn_helper(exe, arg)
    except Exception as e:
        return {"success": False, "error": f"could not launch X2 helper: {e}"}
    t0 = time.time()
    while time.time() - t0 < timeout:
        if p.poll() is not None:
            return {"success": True, "helperSec": round(time.time() - t0, 2)}
        time.sleep(0.2)
    try:
        p.kill()
    except Exception:
        pass
    reaped = _reap_busy_notice()
    return {"success": False, "errorCode": "instance_busy", "busyNoticeReaped": reaped, "_hint": "altium_check_dialogs, then retry."}


def start(wait_sec: float = 45.0) -> dict:
    """Install (idempotent) and bootstrap the loop; returns when hb.txt is fresh."""
    inst = install()
    if is_alive():
        hb = _hb() or {}
        resident = hb.get("gen")
        want = _agent_base()
        if resident and resident != want:
            # an OLDER generation is still polling inside Altium; it would answer with
            # stale code (measured 2026-08-30). Stop it, then bootstrap the new one.
            stop()
            time.sleep(1.0)
        else:
            return {"success": True, "alreadyRunning": True, "status": status()}
    # a stale heartbeat / inbox / responses from a previous life must not confuse this one
    ws = workspace_dir()
    for stale in ["hb.txt", "stop", "request.txt", "request.payload"] + [p.name for p in ws.glob("response_*.json")] + [p.name for p in ws.glob("request_*")]:
        try:
            (ws / stale).unlink()
        except FileNotFoundError:
            pass
        except Exception:
            pass
    t0 = time.time()
    user_fg = _fg()
    # A killed helper's -R request can still be DELIVERED later by Altium and
    # start a second loop -> "Another script executing now". Reap strays first.
    killed = _kill_stray_helpers()
    r = run_script_lane("StartAgent")
    if killed:
        r["strayHelpersKilled"] = killed
    if r.get("modalsPreempted"):
        # the bridge caused these and the bridge cleared them, in flight — say so rather than
        # letting them look like something the user has to deal with
        r.setdefault("_note", f"{len(r['modalsPreempted'])} modal(s) pre-empted during the bootstrap")
    if not r.get("success") and r.get("errorCode") == "instance_busy" and not _no_recover:
        rec = recover("helper reported busy on start")
        r = run_script_lane("StartAgent")
        r["recovery"] = rec
    # The -R bootstrap ACTIVATES Altium (it opens the .PrjScr in the editor). Hand the
    # user's window back right away and keep doing so while we wait — whether or not
    # the script then compiles. (John, 2026-08-29: "stop foregrounding on me".)
    if user_fg and not _is_altium_hwnd(user_fg):
        _restore_fg(user_fg, 2.0)
    if not r.get("success"):
        r["install"] = inst
        return r
    while time.time() - t0 < wait_sec:
        if user_fg and not _is_altium_hwnd(user_fg) and _fg() != user_fg:
            _restore_fg(user_fg, 0.5)
        if is_responsive():
            restored = 0
            if user_fg and not _is_altium_hwnd(user_fg):
                restored = _restore_fg(user_fg, 3.0)
            return {"success": True, "startedInSec": round(time.time() - t0, 2), "install": inst, "status": status(),
                    "windowEtiquette": "background" if restored or _fg() == user_fg else "foreground",
                    "focusRestores": restored}
        time.sleep(0.25)
    return {"success": False, "errorCode": "agent_no_heartbeat",
            "error": f"agent did not heartbeat within {wait_sec}s",
            "install": inst, "helper": r,
            "_hint": "A compile error in the agent raises an 'Error' modal on Altium's main window: run "
                     "altium_check_dialogs and read it. Otherwise Altium may still be starting."}


def stop(wait_sec: float = 10.0) -> dict:
    if not is_alive():
        return {"success": True, "wasRunning": False}
    (workspace_dir() / "stop").write_text("1")
    t0 = time.time()
    while time.time() - t0 < wait_sec:
        if not (workspace_dir() / "hb.txt").exists():
            return {"success": True, "wasRunning": True, "stoppedInSec": round(time.time() - t0, 2)}
        time.sleep(0.25)
    return {"success": False, "error": "agent did not stop", "_hint": "the loop may be frozen behind a modal — altium_check_dialogs"}


# ---- focus etiquette -------------------------------------------------------------
# Measured 2026-08-29: the -R RunScript bootstrap opens AdomAgent.PrjScr in the
# editor and ACTIVATES Altium's window — a foreground steal. Capture the user's
# foreground before and hand it back for a few seconds after (Altium re-raises
# once, late), the same shape kicad-bridge uses.

def _is_altium_hwnd(hwnd: int) -> bool:
    try:
        return any(w["hwnd"] == hwnd for w in altium_detect.altium_windows())
    except Exception:
        return False


def _fg() -> int:
    try:
        import ctypes
        return int(ctypes.windll.user32.GetForegroundWindow())
    except Exception:
        return 0


def _user_input_recent(ms: int = 1500) -> bool:
    """True when the human touched keyboard/mouse within the last `ms`.
    THE etiquette rule (John, 2026-08-30, "why can't i foreground altium"): the
    restore loop must never fight a human. Repeated agent restarts each ran this
    loop, and his taskbar clicks were reverted within 0.3 s - from his chair
    Altium simply refused to come forward."""
    try:
        import ctypes

        class LASTINPUTINFO(ctypes.Structure):
            _fields_ = [("cbSize", ctypes.c_uint), ("dwTime", ctypes.c_uint)]

        li = LASTINPUTINFO()
        li.cbSize = ctypes.sizeof(li)
        if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(li)):
            return False
        idle_ms = ctypes.windll.kernel32.GetTickCount() - li.dwTime
        return idle_ms < ms
    except Exception:
        return False


def _restore_fg(hwnd: int, seconds: float = 6.0) -> int:
    if not hwnd or sys.platform != "win32":
        return 0
    import ctypes
    u = ctypes.windll.user32
    k = ctypes.windll.kernel32
    restores = 0
    t0 = time.time()
    while time.time() - t0 < seconds:
        if _user_input_recent():
            break                      # the human is driving; never fight their click
        cur = _fg()
        if cur != hwnd and u.IsWindow(hwnd):
            try:
                cur_tid = u.GetWindowThreadProcessId(cur, None)
                my_tid = k.GetCurrentThreadId()
                u.AttachThreadInput(my_tid, cur_tid, True)
                u.SetForegroundWindow(hwnd)
                u.AttachThreadInput(my_tid, cur_tid, False)
                restores += 1
            except Exception:
                pass
        time.sleep(0.3)
    return restores


# ---- recovery -------------------------------------------------------------------

def recover(reason: str = "") -> dict:
    """Get the script engine back from a HALTED state.

    Measured 2026-08-29: an unknown identifier is a RUNTIME error in DelphiScript
    (late-bound), it raises an 'Error' modal on the main window AND leaves the
    debugger paused on that line in the Script editor. The paused script counts as
    'executing', so every later -R request is refused ('Another script executing
    now') and the loop's heartbeat stops. The fix that works: press OK on the modal
    (background BM_CLICK), then `X2.EXE -REditScript:Stop`, then re-probe.
    """
    from handlers import dialogs
    steps = []
    for d in dialogs.scan(main_pid_only=True):
        if d.get("category") in ("script_compile_error", "script_runtime_error", "script_busy") or d["title"] in ("Error", "X2"):
            b = "Cancel" if d.get("category") == "script_runtime_error" else "OK"
            steps.append({"dialog": d["title"], "body": d["body"][:120], "pressed": b, "result": dialogs.press(d["hwnd"], b)})
    time.sleep(0.8)
    exe = _x2_exe()
    user_fg = _fg()
    if exe:
        p = _spawn_helper(exe, "-REditScript:Stop")
        t0 = time.time()
        while time.time() - t0 < 15 and p.poll() is None:
            time.sleep(0.2)
        if p.poll() is None:
            p.kill()
            steps.append({"editScriptStop": "helper hung (instance still busy)"})
        else:
            steps.append({"editScriptStop": "sent", "sec": round(time.time() - t0, 2)})
        reaped = _reap_busy_notice()
        if reaped:
            steps.append({"busyNoticeReaped": reaped})
    ws = workspace_dir()
    for stale in ["hb.txt", "stop", "request.txt", "request.payload"] + [q.name for q in ws.glob("response_*.json")]:
        try:
            (ws / stale).unlink()
        except Exception:
            pass
    # Colby (issue #3): a halted bootstrap leaves its scratch AdomAgent_<digest>
    # project in the user's Projects panel. The engine is free again and the loop
    # is dead (hb.txt just cleared, so the lane accepts), so closing it is safe —
    # never do this after a SUCCESSFUL start, the resident loop runs from it.
    prj = prjscr_path()
    if exe and prj.exists():
        closed = run_script_text_lane(
            "Begin ResetParameters; AddStringParameter('ObjectKind','Project'); "
            "AddStringParameter('FileName','" + str(prj) + "'); "
            "RunProcess('WorkspaceManager:CloseObject'); End;", timeout=15.0)
        steps.append({"scratchProjectClosed": bool(closed.get("success")), "path": prj.name})
    if user_fg and not _is_altium_hwnd(user_fg):
        _restore_fg(user_fg, 2.0)
    remaining = dialogs.scan(main_pid_only=True)
    return {"success": not remaining, "reason": reason, "steps": steps, "remainingDialogs": remaining,
            "_hint": "Engine should be free now: altium_agent_start to bring the loop back." if not remaining
                     else "A dialog is still up — read remainingDialogs[] and altium_dismiss_dialogs it."}


# ---- calls ---------------------------------------------------------------------

def call(command: str, params: dict | None = None, payload: str | None = None,
         timeout: float = 30.0, autostart: bool = True) -> dict:
    """One request/response round trip through the file drop."""
    if not is_alive():
        if not autostart:
            return {"success": False, "errorCode": "agent_not_running", "_hint": "altium_agent_start"}
        st = start()
        if not st.get("success"):
            return st
    ws = workspace_dir()
    rid = uuid.uuid4().hex[:10]
    lines = [f"id={rid}", f"command={command}"]
    for k, v in (params or {}).items():
        if v is None:
            continue
        s = str(v)
        if "\n" in s or "\r" in s:
            return {"success": False, "error": f"param {k} must be single-line; put multi-line text in payload"}
        lines.append(f"{k}={s}")
    inbox = ws / "request.txt"
    t0 = time.time()
    # ONE inbox (the engine's FindFirst wildcard never matched): serialize callers
    # in-process, and wait for a previous caller's inbox to be consumed.
    with _inbox_lock:
        while inbox.exists() and time.time() - t0 < timeout:
            time.sleep(0.02)
        if inbox.exists():
            return {"success": False, "errorCode": "agent_busy", "error": "previous request still in the inbox",
                    "_hint": "the agent is not consuming requests — altium_agent_status; if the heartbeat is stale, altium_agent_start"}
        if payload:
            (ws / "request.payload.tmp").write_text(payload, encoding="ascii", errors="replace")
            os.replace(ws / "request.payload.tmp", ws / "request.payload")
        tmp = ws / "request.txt.tmp"
        tmp.write_text("\r\n".join(lines) + "\r\n", encoding="ascii", errors="replace")
        os.replace(tmp, inbox)
    resp = ws / f"response_{rid}.json"
    last_hb = None
    while time.time() - t0 < timeout:
        if resp.exists():
            try:
                raw = resp.read_text(errors="replace")
                data = json.loads(raw)
            except Exception as e:
                time.sleep(0.05)
                if time.time() - t0 < timeout:
                    continue
                return {"success": False, "error": f"unparseable response: {e}", "raw": raw[:500]}
            try:
                resp.unlink()
            except Exception:
                pass
            data["roundtripMs"] = int((time.time() - t0) * 1000)
            body = data.get("data") or {}
            data["success"] = bool(body.get("ok", True)) if isinstance(body, dict) else True
            return data
        hb = _hb()
        if hb:
            last_hb = hb
        time.sleep(0.02)
    # timed out: is the loop even alive?
    alive = is_alive()
    from handlers import dialogs as _dl
    blocking = _dl.scan(main_pid_only=True)
    if blocking:
        return {"success": False, "errorCode": "blocked_by_dialog", "dialogs": blocking,
                "error": f"no response to {command} within {timeout}s — a dialog is up on Altium's main window",
                "_hint": "The loop is frozen behind that modal. Read dialogs[] (altium_check_dialogs returns a shot per dialog), altium_dismiss_dialogs, then retry."}
    return {"success": False, "errorCode": "agent_timeout" if alive else "agent_wedged",
            "error": f"no response to {command} within {timeout}s",
            "agentAlive": alive, "lastHeartbeat": last_hb,
            "_hint": ("The loop is still heartbeating, so the handler is slow — raise timeout or check "
                      "for a dialog it opened." if alive else
                      "Heartbeat stopped: a modal or a script error froze the engine. altium_check_dialogs, "
                      "dismiss, then altium_agent_start.")}