app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
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.")}