main
John Lauer 0.2.21 source: script-error auto-recovery + errorShot, symbol_prims/set_models fix for AD 26.9.1, ENGINE_HALTING releas… a577bb1 1mo ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966
#!/usr/bin/env python3
"""altium-bridge server — the `altium_*` verb namespace for Adom Bridge.

Spawned by ab (`spawn.kind: python`, `--port <n>` appended, ADOM_BIND_HOST=127.0.0.1).
Every verb returns a dict with `success`, a stable `errorCode` on failure, and a
`_hint` the calling AI reads verbatim — that output IS the documentation.

Lanes (see the altium-bridge-plan skill):
  L2 agent   handlers/agent.py   resident DelphiScript loop (file drop) — eval, run_process, state, open
  L6 CLI     X2.EXE -R…          bootstrap + one-shot RunScript/RunScriptText when the agent is NOT resident
  L4/L5 UI   handlers/dialogs.py dialogs by PID; ab UIA/screenshots via ad_client callbacks
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import threading
import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

import caller_identity  # noqa: E402
from handlers import uia
from handlers import a365, ad_client, agent, altium_detect, dialogs, progress, rcs_catalog  # noqa: E402

BRIDGE_VERSION = "0.1.0"
try:
    BRIDGE_VERSION = json.loads((Path(__file__).parent / "bridge.json").read_text())["version"]
except Exception:
    pass

_installs: list[dict] = []
_boot_at = time.time()


def _install() -> dict | None:
    global _installs
    if not _installs:
        _installs = altium_detect.detect_installs()
    return _installs[0] if _installs else None


def _not_installed() -> dict:
    return {"success": False, "errorCode": "host_app_not_installed", "hostApp": altium_detect.HOST_APP,
            "error": "Altium Designer is not installed on this machine.",
            "_hint": "Altium is a licensed host app (no silent installer). Offer to run the official "
                     "AltiumDesignerSetup.exe for the user — the altium-install skill has the full drive."}


# ---- read-only ----------------------------------------------------------------

def h_readiness(args):
    inst = _install()
    lic = altium_detect.licence_state()
    ag = agent.status()
    ready = bool(inst) and lic.get("running") and bool(lic.get("licensed")) and ag.get("alive")
    out = {"success": True, "hostApp": altium_detect.HOST_APP, "installed": bool(inst), "install": inst,
           "running": lic.get("running"), "licensed": lic.get("licensed"), "licenceState": lic.get("state"),
           "adVersion": lic.get("adVersion"), "agentAlive": ag.get("alive"), "ready": ready,
           "bridgeVersion": BRIDGE_VERSION}
    if not inst:
        out["_hint"] = "Not installed — see altium_launch's hint to install."
    elif not lic.get("running"):
        out["_hint"] = "Altium is installed but not running: altium_launch (cold start ~60s, measured)."
    elif not lic.get("licensed"):
        out["_hint"] = (f"Altium is running but the title says '{lic.get('state')}' — scripting is refused "
                        "without a licence. altium_license '{}' drives the trial/On-Demand flow.")
    elif not ag.get("alive"):
        out["_hint"] = "Licensed and running; the agent loop is not resident — altium_agent_start (≈8s), then drive."
    else:
        out["_hint"] = "Ready. altium_state for the picture, altium_eval / altium_run_command to act."
    return out



# ---- licence-seat hints (John, 2026-09-01: "ai hints are way more valuable ... the AI is bad at
# reading skills") ------------------------------------------------------------------------------
#
# One On-Demand seat serves ALL Altium apps on the machine — Designer and Designer Develop draw
# from the SAME seat. Everything below exists because two hours were lost to a stranded seat that
# the licence page reported as free.

SEAT_RULE = ("One On-Demand seat serves EVERY Altium app on this machine — Altium Designer and "
             "Altium Designer Develop share it. Only one can be licensed at a time.")

SEAT_STRANDED_FIX = (
    "If a claim fails while the licence page shows Seats 0/1 (looks free, is not), the seat is "
    "STRANDED by an Altium that was KILLED instead of closed. Fix: altium_close on every other "
    "instance (polite WM_CLOSE releases the lease), then altium_license {action:'use_license'}. "
    "Waiting does not clear it, and Refresh is a link the UIA layer cannot invoke."
)

SEAT_SWAP_HOWTO = (
    "To move the seat between apps: altium_close the one holding it (answer any Unsaved Changes "
    "prompt deliberately — on a shared machine the document may belong to another AI thread), then "
    "LAUNCH the other app; it claims the free seat silently at startup. No clicking required."
)


def _seat_hints(out: dict, licence_state, instance_count: int = 1) -> dict:
    """Attach the seat guidance to any reply where the licence is the thing about to bite."""
    unlicensed = str(licence_state or "").lower() in ("no license", "no licence", "viewer")
    if unlicensed:
        out["_licenceHint"] = f"{SEAT_RULE} {SEAT_STRANDED_FIX}"
    if instance_count > 1:
        out["_seatHint"] = (f"{SEAT_RULE} {SEAT_SWAP_HOWTO} "
                            "NOTE: Altium Designer Develop writes NO licence tail in its title when it "
                            "HOLDS the seat (only '- Viewer' when it does not), so a blank licenceState "
                            "on Develop does not mean unlicensed — try the verb.")
    return out


def h_status(args):
    r = h_readiness(args)
    r["uptimeSec"] = int(time.time() - _boot_at)
    r["windows"] = altium_detect.altium_windows()
    r["dialogs"] = dialogs.scan()
    return r


def h_diagnostics(args):
    env = {k: os.environ.get(k) for k in ("LOCALAPPDATA", "APPDATA", "USERPROFILE", "ProgramFiles", "ADOM_BIND_HOST", "ADOM_DIRECT_API_URL")}
    return {"success": True, "bridgeVersion": BRIDGE_VERSION, "python": sys.version, "env": env,
            "installs": altium_detect.detect_installs(), "pids": altium_detect.running_pids(),
            "windows": altium_detect.altium_windows(), "agent": agent.status(),
            "cacheDir": str(agent.cache_dir()), "abCallbackAvailable": ad_client.available()}


def h_window_info(args):
    wins = altium_detect.altium_windows()
    dl = dialogs.scan()
    out = {"success": True, "windows": wins, "dialogs": dl, "hasModalDialogs": any(d["ownedByMainInstance"] for d in dl)}
    if out["hasModalDialogs"]:
        out["_hint"] = ("A dialog is open on Altium's main window: the agent loop and every -R request are "
                        "frozen behind it. Read dialogs[].body + category, then altium_dismiss_dialogs "
                        "{hwnd, button} with the work-preserving choice.")
    return out


def _attach_dialog_shots(dl: list) -> None:
    """Altium's TMessageForm paints its text (no Static child, not in UIA) — the
    only way to READ a dialog is to look at it. Attach a base64 PNG per dialog."""
    if not ad_client.available():
        return
    for d in dl:
        r = caller_identity.ad_callback("desktop_screenshot_base64", {"hwnd": d["hwnd"]})
        if isinstance(r, dict) and (r.get("image") or r.get("base64")):
            d["shot"] = r.get("image") or r.get("base64")


def h_check_dialogs(args):
    dl = dialogs.scan()
    if args.get("screenshots", True):
        _attach_dialog_shots(dl)
    return {"success": True, "dialogsDetected": len(dl), "dialogs": dl,
            "_hint": "ANALYZE before acting: category/safeAction are suggestions; a save prompt is never auto-answered."
            if dl else "No Altium dialogs open."}


def h_dismiss_dialogs(args):
    hwnd = args.get("hwnd")
    button = args.get("button")
    only_safe = bool(args.get("safeOnly", False))
    dl = dialogs.scan()
    done, skipped = [], []
    for d in dl:
        if hwnd and d["hwnd"] != int(hwnd):
            continue
        b = button or d.get("safeAction")
        if not b:
            skipped.append({"hwnd": d["hwnd"], "title": d["title"], "why": "no safe default — pass button explicitly"})
            continue
        if only_safe and not d.get("safeAction"):
            skipped.append({"hwnd": d["hwnd"], "title": d["title"], "why": "not safe"})
            continue
        r = dialogs.press(d["hwnd"], b)
        r.update({"title": d["title"], "category": d["category"], "body": d["body"][:200]})
        done.append(r)
    time.sleep(0.4)
    remaining = dialogs.scan()
    return {"success": True, "dismissed": done, "skipped": skipped, "remaining": remaining,
            "_hint": "Verify by altium_window_info; if a dialog persists, ab's desktop_ui_click {hwnd,name:'OK'} is the UIA fallback."}


def h_list_commands(args):
    inst = _install()
    if not inst:
        return _not_installed()
    q = args.get("query", "")
    r = rcs_catalog.search(inst["system_dir"], q, args.get("server"), int(args.get("limit", 50)))
    r["success"] = True
    r["_hint"] = ("Run one with altium_run_command {command:'PCB:Zoom', params:'Action=All'} (goes through "
                  "the agent). Params are Name=Value|Name=Value.")
    return r


# ---- lifecycle ------------------------------------------------------------------

def h_launch(args):
    inst = _install()
    if not inst:
        return _not_installed()
    lic = altium_detect.licence_state()
    if lic.get("running"):
        return {"success": True, "alreadyRunning": True, "licence": lic, "windowEtiquette": "background",
                "_hint": "Already running — nothing launched."}
    t0 = time.time()
    progress.begin("cold_start", "cold", "launching Altium Designer")
    try:
        subprocess.Popen([inst["exe"]], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
    except Exception as e:
        progress.end("cold_start")
        return {"success": False, "error": f"launch failed: {e}"}
    wait = float(args.get("waitSec", 180))
    while time.time() - t0 < wait:
        lic = altium_detect.licence_state()
        if lic.get("running"):
            progress.finish("cold_start", t0, "cold", True)
            return {"success": True, "startedInSec": round(time.time() - t0, 1), "licence": lic,
                    "windowEtiquette": "foreground",
                    "_hint": "Altium's own launch foregrounds itself (the one legitimate foreground). "
                             "Next: altium_readiness; if unlicensed, altium_license."}
        time.sleep(1.0)
    progress.finish("cold_start", t0, "cold", False)
    return {"success": False, "errorCode": "launch_timeout", "error": f"no Altium main window after {wait}s"}


def h_close(args):
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        return {"success": True, "wasRunning": False}
    # The INTENT to force is the risk, not whether a dialog happens to block: force:true KILLS the
    # process, which strands the On-Demand seat (the licence page then shows Seats 0/1 while every
    # claim fails with "License maximum usage reached"). Refuse up front — a polite close releases
    # the seat and is almost always what the caller actually wants.
    if args.get("force") and not args.get("acceptSeatLoss") and lic.get("licensed") is not False:
        return _seat_hints({"success": False, "errorCode": "would_strand_licence_seat",
            "licenceState": lic.get("state"),
            "error": "force:true kills Altium and STRANDS its licence seat — no other Altium will be "
                     "able to claim it afterwards, and the licence page will report it as free while "
                     "refusing every claim.",
            "_hint": "Try altium_close with no force first: a polite WM_CLOSE releases the seat. If it "
                     "reports blocked_by_dialog, READ the dialog (an Unsaved Changes prompt names the "
                     "documents — on a shared machine one may belong to another AI thread) and answer "
                     "it. Only if you truly must kill: repeat with acceptSeatLoss:true."},
            lic.get("state"), len(altium_detect.instances()))
    if agent.is_alive():
        agent.stop()
    # polite first: WM_CLOSE on the main window (prompts for unsaved docs → dialogs), then taskkill
    import ctypes
    ctypes.windll.user32.PostMessageW(lic["hwnd"], 0x0010, 0, 0)
    t0 = time.time()
    while time.time() - t0 < float(args.get("waitSec", 20)):
        if not altium_detect.running_pids():
            return {"success": True, "closedInSec": round(time.time() - t0, 1)}
        dl = dialogs.scan(main_pid_only=True)
        if dl:
            if args.get("force"):
                # A forced kill STRANDS the On-Demand licence seat: the lease is never released, and
                # the licence page then shows Seats 0/1 (free) while every claim fails with "License
                # maximum usage reached". Two hours were lost to this on 2026-09-01. Make the caller
                # say it out loud.
                if not args.get("acceptSeatLoss"):
                    return _seat_hints({"success": False, "errorCode": "would_strand_licence_seat",
                        "dialogs": dl,
                        "error": "force:true KILLS Altium, which strands the On-Demand licence seat — "
                                 "no other Altium will be able to claim it, and the licence page will "
                                 "report it as free while refusing every claim.",
                        "_hint": "Answer the dialog instead (it is usually an Unsaved Changes prompt that "
                                 "names the document — on a shared machine it may belong to another AI "
                                 "thread). If you truly must kill, repeat with acceptSeatLoss:true and "
                                 "expect to reclaim the seat afterwards."},
                        altium_detect.licence_state().get("state"), len(altium_detect.instances()))
                subprocess.run(["taskkill", "/F", "/IM", altium_detect.EXE_NAME], capture_output=True)
                return {"success": True, "forced": True, "dismissedDialogs": [d.get("title") for d in dl],
                        "_seatWarning": "The licence seat is now STRANDED. " + SEAT_STRANDED_FIX,
                        "_hint": "force:true kills even when a dialog blocks the polite close (e.g. the "
                                 "'script stopped on a breakpoint' Error that refuses WM_CLOSE)."}
            return {"success": False, "errorCode": "close_blocked_by_dialog", "dialogs": dl,
                    "_hint": "Altium asked something on close (save?). Read dialogs[] and answer via altium_dismiss_dialogs {hwnd,button}, or pass force:true."}
        time.sleep(0.5)
    if args.get("force"):
        subprocess.run(["taskkill", "/F", "/IM", altium_detect.EXE_NAME], capture_output=True)
        return {"success": True, "forced": True}
    return {"success": False, "errorCode": "close_timeout", "_hint": "pass force:true to taskkill"}


# ---- agent / REPL ---------------------------------------------------------------

def _need_licensed():
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        return {"success": False, "errorCode": "altium_not_running", "_hint": "altium_launch first."}
    if lic.get("licensed") is False:
        n = len(altium_detect.instances())
        return _seat_hints({"success": False, "errorCode": "not_licensed", "licenceState": lic.get("state"),
                            "_hint": "Altium refuses scripts without a licence. altium_license "
                                     "{\"action\":\"use_license\"} claims a free seat."},
                           lic.get("state"), n)
    dl = dialogs.scan(main_pid_only=True)
    if dl:
        _attach_dialog_shots(dl)
        return {"success": False, "errorCode": "blocked_by_dialog", "dialogs": dl,
                "_hint": "A modal on the main window freezes scripting. Read dialogs[].shot (base64 PNG — the text is painted, not exposed), altium_dismiss_dialogs {hwnd, button}, retry. If it is a script error, altium_agent_recover."}
    return None


def h_agent_start(args):
    g = _need_licensed()
    if g:
        return g
    t0 = time.time()
    r = agent.start(float(args.get("waitSec", 45)))
    progress.record("agent_start", time.time() - t0, "warm")
    if r.get("success"):
        r["_hint"] = "Agent resident. altium_eval / altium_run_command / altium_state now go through it (~ms). Do NOT use altium_run_script while it is up."
    return r


def h_agent_stop(args):
    return agent.stop()


def h_agent_status(args):
    s = agent.status()
    s["success"] = True
    return s


def h_agent_recover(args):
    try:
        agent.call("tx_clear", {}, timeout=30)
    except Exception:
        pass
    return agent.recover(args.get("reason", "requested"))


def h_eval(args):
    g = _need_licensed()
    if g:
        return g
    text = args.get("text") or args.get("code")
    if not text:
        return {"success": False, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}
    if not agent.is_alive():
        # not resident → the -R RunScriptText lane is available and simplest
        r = agent.run_script_text_lane(text, float(args.get("timeout", 30)))
        r["lane"] = "cli_run_script_text"
        r.setdefault("_hint", "Ran via X2 -RScriptingSystem:RunScriptText (statements only). Errors surface as an 'Error'/'X2' modal — altium_check_dialogs.")
        return r
    r = agent.call("eval", {}, payload=text, timeout=float(args.get("timeout", 30)))
    r["lane"] = "agent"
    return r


def h_run_command(args):
    g = _need_licensed()
    if g:
        return g
    cmd = args.get("command") or args.get("process")
    if not cmd or ":" not in cmd:
        return {"success": False, "error": "command must be Server:Command, e.g. PCB:Zoom", "_hint": "altium_list_commands {query:'zoom'}"}
    params = args.get("params") or ""
    if isinstance(params, dict):
        params = "|".join(f"{k}={v}" for k, v in params.items())
    return agent.call("run_process", {"process": cmd, "params": params}, timeout=float(args.get("timeout", 60)))


def h_run_script(args):
    """One-shot: run ProcName in a .PrjScr through the -R lane (agent must NOT be resident)."""
    g = _need_licensed()
    if g:
        return g
    prj = args.get("prjscr")
    proc = args.get("proc") or "Main"
    if not prj:
        return {"success": False, "error": "prjscr (absolute path to a .PrjScr on this machine) is required"}
    return agent.run_script_lane(proc, prj, float(args.get("timeout", 60)))


def h_state(args):
    """Screenshot ARRAY of every Altium window + logical state from the agent."""
    wins = altium_detect.altium_windows()
    shots = []
    if args.get("screenshots", True) and ad_client.available():
        for w in wins:
            if w["kind"] in ("main", "dialog", "script_error_modal"):
                r = caller_identity.ad_callback("desktop_screenshot_base64", {"hwnd": w["hwnd"]})
                if isinstance(r, dict) and (r.get("image") or r.get("base64")):
                    shots.append({"title": w["title"], "hwnd": w["hwnd"], "kind": w["kind"],
                                  "image": r.get("image") or r.get("base64"), "width": r.get("width"), "height": r.get("height")})
    logical = None
    if agent.is_alive():
        logical = agent.call("state", {}, timeout=15, autostart=False)
    lic = altium_detect.licence_state()
    return {"success": True, "licence": lic, "windows": wins, "dialogs": dialogs.scan(), "shots": shots,
            "logical": logical, "focusEvents": [], "agentAlive": agent.is_alive(),
            "drivingBadge": {"active": __import__("handlers.taskbar", fromlist=["x"]).badge_active(), "lastError": __import__("handlers.taskbar", fromlist=["x"]).last_error()},
            "_hint": "shots[] are base64 PNGs of every Altium window (render inline). logical.data.documents lists open docs (agent required)."}



# Document kinds Altium can open, and what it calls them. Validated in the BRIDGE so an unsupported
# path is a clean error instead of Altium's "File type not recognised." modal on the user's screen
# (John, 2026-09-01: "you better pre-empt these from ever showing up").
OPENABLE = {".pcbdoc", ".schdoc", ".pcblib", ".schlib", ".outjob", ".pcb3dlib",
            ".prjpcb", ".prjscr", ".prjmbd", ".prjhar", ".libpkg", ".txt", ".pas", ".rep"}

# ab gives a verb 60s. A cold file open can exceed that, and blowing the budget looks like a
# failure while Altium is still working — so wait a little under it and hand back a poll instead.
_OPEN_BUDGET_SEC = 45.0


def _validate_openable(path):
    if not path:
        return {"success": False, "error": "path is required"}
    ext = ("." + path.rsplit(".", 1)[-1]).lower() if "." in path else ""
    if ext not in OPENABLE:
        return {"success": False, "errorCode": "unsupported_document",
                "error": f"Altium does not open '{ext or path}' — asking it to would raise a "
                         f"'File type not recognised' modal on the user's screen",
                "supported": sorted(OPENABLE)}
    return None


def h_open_document(args):
    g = _need_licensed()
    if g:
        return g
    path = args.get("filePath") or args.get("path")
    if not path:
        return {"success": False, "error": "filePath is required (absolute Windows path)"}
    bad = _validate_openable(path)
    if bad:
        return bad
    wait = min(float(args.get("timeout", 60)), _OPEN_BUDGET_SEC)
    r = agent.call("open", {"path": path}, timeout=wait)
    r["windowEtiquette"] = "background"
    if r.get("errorCode") == "agent_timeout":
        # Altium is still opening it — that is not a failure, and ab's 60s budget must not turn it
        # into one. Report progress and let the caller poll.
        return {"success": True, "status": "in_progress", "path": path,
                "waitedSec": wait, "roundtripMs": r.get("roundtripMs"),
                "_hint": "Altium is still opening the document. Poll altium_status (or "
                         "altium_window_info) until it is current; the agent answers again once the "
                         "open completes."}
    return r


def h_progress(args):
    return {"success": True, "active": progress.live(mine_only=False), "history": progress.history()}


def h_uninstall(args):
    removed = []
    if agent.is_alive():
        agent.stop()
    import shutil
    d = agent.cache_dir()
    if d.exists() and not args.get("dryRun"):
        shutil.rmtree(d, ignore_errors=True)
        removed.append(str(d))
    return {"success": True, "removed": removed, "dryRun": bool(args.get("dryRun")),
            "note": "Altium Designer itself and the user's designs are never touched."}


# ---- the eight surfaces (phase 3) ---------------------------------------------------

EXAMPLES_DIR = r"C:\Users\Public\Documents\Altium\AD26\Examples"
DEMO_PROJECT = EXAMPLES_DIR + r"\Bluetooth Sentinel\Bluetooth_Sentinel.PrjPcb"
DEMO_PCB = EXAMPLES_DIR + r"\Bluetooth Sentinel\Bluetooth_Sentinel.PcbDoc"
DEMO_SCH = EXAMPLES_DIR + r"\Bluetooth Sentinel\Bluetooth_Sentinel.SchDoc"



# ---- background capture (John, 2026-09-01) ----------------------------------------------------
#
# Windows will not render a MINIMIZED window, so a capture of one returns a ~237x39 sliver of the
# title bar — which this bridge used to hand back as "proof" of a successful import. The wiki
# front-end would have displayed that.
#
# The fix John asked for, and it is the right one: make sure the window is in the BACKGROUND but
# NOT minimized, then use ab's window capture (which reads an occluded window fine). The rejected
# alternative was parking the window off-screen: if the user then reaches for Altium manually, they
# find nothing on screen. Never move a window the user might go looking for.

MIN_PROOF_WIDTH = 400  # anything narrower is a minimized-window sliver, not a screenshot


def _is_minimized(rect: dict) -> bool:
    if not rect:
        return False
    return (rect.get("left", 0) <= -30000 or rect.get("top", 0) <= -30000
            or (rect.get("width", 0) < MIN_PROOF_WIDTH and rect.get("height", 0) < 200))


def _make_capturable(hwnd: int) -> dict:
    """Restore a minimized window and push it BEHIND the user's windows, then hand the foreground
    straight back. Measured end to end at ~0.6s. Returns what it had to do, so a caller can report
    honestly whether the user's screen was touched."""
    out = {"restored": False, "sentToBack": False, "foregroundReturned": False}
    try:
        before = (caller_identity.ad_callback("desktop_get_foreground", {}, on_behalf=False, timeout=8) or {}).get("hwnd")
        wins = caller_identity.ad_callback("desktop_enum_windows", {"pids": [altium_detect.main_window()["pid"]]},
                                           on_behalf=False, timeout=10) or {}
        rect = next((w.get("rect") for w in (wins.get("windows") or []) if w.get("hwnd") == hwnd), None)
        if isinstance(rect, list) and len(rect) == 4:
            rect = {"left": rect[0], "top": rect[1], "width": rect[2] - rect[0], "height": rect[3] - rect[1]}
        if _is_minimized(rect):
            caller_identity.ad_callback("desktop_set_window_state", {"hwnd": hwnd, "state": "restore"},
                                        on_behalf=False, timeout=10)
            out["restored"] = True
        r = caller_identity.ad_callback("desktop_set_window_state", {"hwnd": hwnd, "state": "bottom"},
                                        on_behalf=False, timeout=10) or {}
        out["sentToBack"] = True
        after = (caller_identity.ad_callback("desktop_get_foreground", {}, on_behalf=False, timeout=8) or {}).get("hwnd")
        if before and after != before:
            caller_identity.ad_callback("desktop_raise_window", {"hwnd": before}, on_behalf=False, timeout=10)
            out["foregroundReturned"] = True
        out["userForeground"] = before
    except Exception as e:
        out["error"] = str(e)
    return out


def _shot_of_main(prepare: bool = True) -> dict | None:
    """A capture of Altium's main window, or None — never a minimized-window sliver.

    `prepare` restores the window into the BACKGROUND first if it is minimized (see
    `_make_capturable`). A returned frame carries `prepared` so the caller can say what was done."""
    mw = altium_detect.main_window()
    if not mw or not ad_client.available():
        return None
    prepared = _make_capturable(mw["hwnd"]) if prepare else None
    r = caller_identity.ad_callback("desktop_screenshot_base64", {"hwnd": mw["hwnd"]})
    if isinstance(r, dict) and (r.get("image") or r.get("base64")):
        w, h = r.get("width"), r.get("height")
        if isinstance(w, int) and w < MIN_PROOF_WIDTH:
            # a sliver: Windows had nothing to render. Say so instead of shipping it as evidence.
            return {"title": mw["title"], "hwnd": mw["hwnd"], "proofUnavailable": True,
                    "reason": f"the Altium window is minimized (captured {w}x{h}) — Windows does not "
                              f"render a minimized window, so there is no image to prove anything",
                    "prepared": prepared}
        return {"title": mw["title"], "hwnd": mw["hwnd"], "image": r.get("image") or r.get("base64"),
                "width": w, "height": h, "prepared": prepared}
    return None


def _open_then(path: str, commands: list[tuple[str, str]], timeout: float, label: str) -> dict:
    """Open a document through the agent, run follow-up processes, return the frame."""
    g = _need_licensed()
    if g:
        return g
    t0 = time.time()
    steps = []
    r = agent.call("open", {"path": path}, timeout=timeout)
    steps.append({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")})
    if not r.get("success"):
        r["steps"] = steps
        return r
    for cmd, params in commands:
        rr = agent.call("run_process", {"process": cmd, "params": params}, timeout=timeout)
        steps.append({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")})
    time.sleep(0.6)
    dl = dialogs.scan(main_pid_only=True)
    shot = _shot_of_main()
    mw = altium_detect.main_window() or {}
    ok = not dl
    return {"success": ok, "surface": label, "path": path, "steps": steps,
            "title": mw.get("title"), "document": mw.get("document"),
            "dialogsDetected": len(dl), "dialogs": dl, "shot": shot,
            "windowEtiquette": "background", "elapsedSec": round(time.time() - t0, 1),
            "_hint": ("A dialog came up — read dialogs[] before doing anything else." if dl else
                      f"{label} is on screen (title: {mw.get('title')}). shot.image is the frame; altium_state for every window.")}


def h_show_project(args):
    return _open_then(args.get("filePath") or DEMO_PROJECT, [], float(args.get("timeout", 60)), "project")


def h_show_schematic(args):
    return _open_then(args.get("filePath") or DEMO_SCH, [("Sch:Zoom", "Action=All")], float(args.get("timeout", 60)), "schematic")


def h_show_2d_board(args):
    return _open_then(args.get("filePath") or DEMO_PCB,
                      [("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")], float(args.get("timeout", 120)), "2d_board")


def h_show_3d_board(args):
    return _open_then(args.get("filePath") or DEMO_PCB,
                      [("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")], float(args.get("timeout", 120)), "3d_board")


DEMO_SCHLIB = EXAMPLES_DIR + r"\SpiritLevel-SL1\Libraries\SpiritLevel_2E.SchLib"
DEMO_PCBLIB = EXAMPLES_DIR + r"\SpiritLevel-SL1\Libraries\Spirit_Level_Project.PcbLib"


def _lib_surface(args, kind: str, default_path: str, label: str, after: list[tuple[str, str]]):
    g = _need_licensed()
    if g:
        return g
    t0 = time.time()
    path = args.get("libraryPath") or args.get("filePath") or default_path
    name = args.get("symbolName") or args.get("footprintName") or args.get("name") or ""
    steps = []
    r = agent.call("open", {"path": path}, timeout=float(args.get("timeout", 60)))
    steps.append({"open": path, "result": r.get("data"), "ms": r.get("roundtripMs")})
    if not r.get("success"):
        r["steps"] = steps
        return r
    sel = agent.call("lib_select", {"kind": kind, "name": name}, timeout=float(args.get("timeout", 60)))
    steps.append({"lib_select": name or "(first)", "result": sel.get("data"), "ms": sel.get("roundtripMs")})
    if not sel.get("success"):
        lst = agent.call("lib_list", {"kind": kind, "limit": 50}, timeout=30)
        sel["steps"] = steps
        sel["available"] = (lst.get("data") or {}).get("names")
        sel["_hint"] = "Component not found in that library — pick one of available[] (altium_list_library shows all)."
        return sel
    for cmd, params in after:
        rr = agent.call("run_process", {"process": cmd, "params": params}, timeout=60)
        steps.append({"process": cmd, "params": params, "ok": rr.get("success"), "ms": rr.get("roundtripMs")})
    time.sleep(0.6)
    dl = dialogs.scan(main_pid_only=True)
    _attach_dialog_shots(dl)
    shot = _shot_of_main()
    mw = altium_detect.main_window() or {}
    # A surface verb exists to produce PROOF. If there is no image, saying success:true would hand
    # the caller (and the wiki front-end) a confident nothing.
    no_proof = bool(shot and shot.get("proofUnavailable"))
    out = {"success": (not dl) and not no_proof, "surface": label, "path": path,
           "selected": (sel.get("data") or {}).get("selected"),
           "steps": steps, "title": mw.get("title"), "dialogsDetected": len(dl), "dialogs": dl, "shot": shot,
           "windowEtiquette": "background", "elapsedSec": round(time.time() - t0, 1),
           "_hint": f"{label}: {(sel.get('data') or {}).get('selected')} in {mw.get('document')}. shot.image is the frame."}
    if no_proof:
        out["errorCode"] = "proof_unavailable"
        out["error"] = shot.get("reason")
        out["_hint"] = ("The surface was opened and selected correctly — only the CAPTURE failed, because "
                        "the Altium window is minimized. The bridge restores it into the background "
                        "automatically; if it is still minimized, something re-minimized it mid-call.")
    return out


def h_show_library(args):
    kind = "pcb" if str(args.get("libraryPath") or args.get("filePath") or "").lower().endswith("pcblib") else "sch"
    path = args.get("libraryPath") or args.get("filePath") or DEMO_SCHLIB
    r = _lib_surface({**args, "libraryPath": path, "name": ""}, kind, path, "library", [])
    if r.get("success"):
        lst = agent.call("lib_list", {"kind": kind, "limit": 200}, timeout=30)
        r["components"] = (lst.get("data") or {}).get("names")
        r["componentCount"] = (lst.get("data") or {}).get("count")
    return r


def h_list_library(args):
    kind = "pcb" if str(args.get("libraryPath") or args.get("filePath") or "").lower().endswith("pcblib") else "sch"
    path = args.get("libraryPath") or args.get("filePath") or DEMO_SCHLIB
    g = _need_licensed()
    if g:
        return g
    r = agent.call("open", {"path": path}, timeout=60)
    if not r.get("success"):
        return r
    lst = agent.call("lib_list", {"kind": kind, "limit": int(args.get("limit", 500))}, timeout=60)
    lst["path"] = path
    lst["kind"] = kind
    return lst


def h_show_symbol(args):
    return _lib_surface(args, "sch", DEMO_SCHLIB, "symbol", [])


DEMO_FOOTPRINT = "SO-G3"     # a real 3-pin SOT in the SpiritLevel PcbLib; the first entry ("NT") is a 2-pad test point


def h_show_footprint(args):
    a = dict(args)
    a.setdefault("footprintName", DEMO_FOOTPRINT if not (args.get("libraryPath") or args.get("filePath")) else "")
    return _lib_surface(a, "pcb", DEMO_PCBLIB, "footprint", [("PCB:SwitchTo2D", ""), ("PCB:Zoom", "Action=All")])


def h_show_3d_chip(args):
    a = dict(args)
    a.setdefault("footprintName", DEMO_FOOTPRINT if not (args.get("libraryPath") or args.get("filePath")) else "")
    return _lib_surface(a, "pcb", DEMO_PCBLIB, "3d_chip", [("PCB:SwitchTo3D", ""), ("PCB:Zoom", "Action=All")])


_DEMO_BEATS = [
    ("symbol", "show_symbol", "A schematic symbol from the SpiritLevel example library, in the Symbol editor."),
    ("footprint", "show_footprint", "Its land pattern in the footprint editor: pads, silkscreen, courtyard."),
    ("3d_chip", "show_3d_chip", "The same footprint rendered in 3D."),
    ("schematic", "show_schematic", "The Bluetooth Sentinel top-level schematic — six sheets wired by ports."),
    ("2d_board", "show_2d_board", "The layout in 2D: a two-board rigid-flex, four copper layers."),
    ("3d_board", "show_3d_board", "The populated board in 3D."),
]


def h_demo(args):
    """Same shape as kicad_demo/fusion_demo: one beat per call, `done` when finished."""
    stage = int(args.get("stage", 0))
    if stage >= len(_DEMO_BEATS):
        return {"success": True, "done": True, "stage": stage, "narrate": "That is the whole tour: symbol, footprint, 3D part, schematic, 2D and 3D board — all driven through the resident agent while Altium stayed in the background.", "_hint": "Tour complete."}
    label, verb, narrate = _DEMO_BEATS[stage]
    r = COMMAND_HANDLERS[verb]({"timeout": 120})
    shot = r.get("shot")
    return {"success": bool(r.get("success")), "done": False, "stage": stage, "label": label, "narrate": narrate,
            "screenshots": [{"label": label, "image": shot.get("image")}] if shot else [],
            "steps": r.get("steps"), "dialogs": r.get("dialogs"), "failedSteps": [] if r.get("success") else [label],
            "nextCall": {"verb": "altium_demo", "args": {"stage": stage + 1}},
            "_hint": f"Beat {stage + 1}/{len(_DEMO_BEATS)} ({label}). Narrate, show the screenshot, then call altium_demo {{stage:{stage + 1}}}."}


# ---- outputs (phase 4) ---------------------------------------------------------

DEMO_OUTJOB_FAB = EXAMPLES_DIR + r"\Bluetooth Sentinel\Fabrication.OutJob"
DEMO_OUTJOB_ASM = EXAMPLES_DIR + r"\Bluetooth Sentinel\Assembly.OutJob"


def _ensure_project(args, default=DEMO_PROJECT):
    path = args.get("projectPath") or args.get("filePath") or default
    r = agent.call("open", {"path": path}, timeout=float(args.get("timeout", 60)))
    return path, r


def h_compile(args):
    g = _need_licensed()
    if g:
        return g
    t0 = time.time()
    path, r = _ensure_project(args)
    if not r.get("success"):
        return r
    c = agent.call("compile", {"objectKind": args.get("objectKind", "Project"), "limit": int(args.get("limit", 200))},
                   timeout=float(args.get("timeout", 120)))
    if not c.get("success"):
        return c
    data = c.get("data") or {}
    msgs = data.get("messages") or []
    by_class = {}
    for m in msgs:
        by_class[m.get("class")] = by_class.get(m.get("class"), 0) + 1
    return {"success": True, "project": path, "messageCount": data.get("messageCount"), "byClass": by_class,
            "messages": msgs, "elapsedSec": round(time.time() - t0, 1), "windowEtiquette": "background",
            "_hint": "messages[] is Altium's Messages panel after Compile (ERC + connectivity). Zero messages = clean."}


h_run_erc = h_compile


def h_run_drc(args):
    """PCB:DesignRuleCheck opens a MODAL — the agent blocks inside RunProcess until it
    closes. The bridge drives the dialog's 'Run Design Rule Check' via ab UIA in the
    background, then reads the violations off the board through the agent."""
    g = _need_licensed()
    if g:
        return g
    t0 = time.time()
    path = args.get("filePath") or DEMO_PCB
    r = agent.call("open", {"path": path}, timeout=60)
    if not r.get("success"):
        return r
    # fire the process WITHOUT waiting for the agent (the modal freezes the loop)
    try:
        agent.call("run_process", {"process": "PCB:DesignRuleCheck", "params": ""}, timeout=6)
    except Exception:
        pass
    dlg = None
    for _ in range(30):
        wins = [w for w in altium_detect.altium_windows() if w["kind"] != "main" and "rule" in (w["title"] or "").lower()]
        if wins:
            dlg = wins[0]
            break
        time.sleep(0.5)
    steps = [{"dialog": dlg["title"] if dlg else None, "hwnd": dlg["hwnd"] if dlg else None}]
    if not dlg:
        return {"success": False, "errorCode": "drc_dialog_not_found", "steps": steps,
                "_hint": "PCB:DesignRuleCheck did not open its dialog — is a PCB the focused document? altium_window_info."}
    click = caller_identity.ad_callback("desktop_ui_click", {"hwnd": dlg["hwnd"], "contains": "Run Design Rule Check", "role": "button"})
    steps.append({"uiClick": "Run Design Rule Check", "result": click})
    # wait for the agent to come back (the modal closed) and DRC to finish
    wait = float(args.get("timeout", 180))
    t1 = time.time()
    while time.time() - t1 < wait:
        if agent.is_responsive():
            break
        time.sleep(0.5)
    # the DRC report (HTML) opens as a new document and takes focus — put the board back first
    agent.call("open", {"path": path}, timeout=60)
    v = agent.call("violations", {"limit": int(args.get("limit", 200))}, timeout=60)
    data = v.get("data") or {}
    steps.append({"violations": {"success": v.get("success"), "error": data.get("error") or v.get("error"), "ms": v.get("roundtripMs")}})
    dl = dialogs.scan(main_pid_only=True)
    _attach_dialog_shots(dl)
    return {"success": bool(v.get("success")) and not dl, "board": path, "violationCount": data.get("violationCount"),
            "violations": data.get("violations"), "steps": steps, "dialogs": dl,
            "elapsedSec": round(time.time() - t0, 1), "windowEtiquette": "background",
            "_hint": "violations[] are the DRC markers on the board (rule, description, location). A DRC report document may also have opened — altium_state shows it."}


def h_run_outjob(args):
    g = _need_licensed()
    if g:
        return g
    t0 = time.time()
    outjob = args.get("outjobPath") or args.get("filePath") or DEMO_OUTJOB_FAB
    medium = args.get("medium") or "Folder Structure"
    proj = args.get("projectPath") or (DEMO_PROJECT if outjob.startswith(EXAMPLES_DIR) else None)
    if proj:
        r = agent.call("open", {"path": proj}, timeout=60)
        if not r.get("success"):
            return r
    o = agent.call("outjob", {"path": outjob, "medium": medium, "objectKind": args.get("objectKind", "OutputBatch")},
                   timeout=float(args.get("timeout", 300)))
    dl = dialogs.scan(main_pid_only=True)
    _attach_dialog_shots(dl)
    out_dir = None
    try:
        from pathlib import Path as _P
        base = _P(proj or outjob)
        pdir = base.parent / ("Project Outputs for " + base.stem)
        out_dir = str(pdir) if pdir.exists() else None
        files = sorted(str(p.relative_to(pdir)) for p in pdir.rglob("*") if p.is_file())[:60] if out_dir else []
    except Exception:
        files = []
    return {"success": bool(o.get("success")) and not dl, "outjob": outjob, "medium": medium, "agent": o.get("data"),
            "outputDir": out_dir, "files": files, "dialogs": dl, "elapsedSec": round(time.time() - t0, 1),
            "windowEtiquette": "background",
            "_hint": "files[] is what the OutJob wrote under 'Project Outputs for <project>'. A dialog means a medium needed a decision (printer/PDF path) — read dialogs[].shot."}


def h_export_gerber(args):
    a = dict(args)
    a.setdefault("outjobPath", DEMO_OUTJOB_FAB)
    a.setdefault("medium", "Folder Structure")
    r = h_run_outjob(a)
    r["surface"] = "gerber"
    return r


def h_export_bom(args):
    a = dict(args)
    a.setdefault("outjobPath", DEMO_OUTJOB_ASM)
    a.setdefault("medium", "Folder Structure")
    r = h_run_outjob(a)
    r["surface"] = "bom"
    return r


def h_create_footprint(args):
    g = _need_licensed()
    if g:
        return g
    for k in ("name", "libraryPath"):
        if not args.get(k):
            return {"success": False, "error": f"{k} is required", "_hint": "pads: 'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...'"}
    r = agent.call("create_footprint", {"name": args["name"], "libraryPath": args["libraryPath"],
                                         "description": args.get("description", ""), "pads": args.get("pads", "")},
                   timeout=float(args.get("timeout", 90)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    r.setdefault("_hint", "Footprint registered in the (new or opened) PcbLib and made current; the library is NOT saved yet — altium_save {filePath} or altium_run_command Client:Save... shot.image shows it.")
    return r


def h_create_symbol(args):
    g = _need_licensed()
    if g:
        return g
    for k in ("name", "libraryPath"):
        if not args.get(k):
            return {"success": False, "error": f"{k} is required", "_hint": "pins: 'des,name,left|right|top|bottom,offset_mil;...'"}
    r = agent.call("create_symbol", {"name": args["name"], "libraryPath": args["libraryPath"],
                                      "description": args.get("description", ""), "designator": args.get("designator", "U"),
                                      "body_w_mil": args.get("bodyWidthMil", 800), "body_h_mil": args.get("bodyHeightMil", 600),
                                      "pins": args.get("pins", "")}, timeout=float(args.get("timeout", 90)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    r.setdefault("_hint", "Symbol added to the SchLib and made current (library unsaved). shot.image shows it.")
    return r


def h_save(args):
    """Save the focused document (or the one at filePath) — WorkspaceManager:SaveObject."""
    g = _need_licensed()
    if g:
        return g
    path = args.get("filePath")
    if path:
        agent.call("open", {"path": path}, timeout=60)
    agent.call("tx_clear", {}, timeout=30)   # pre-empts "a command is currently active" (save-a-copy Warning)
    r = agent.call("run_process", {"process": "WorkspaceManager:SaveObject", "params": "ObjectKind=FocusedDocument"}, timeout=60)
    r["_hint"] = "Saved the focused document. A 'Save As' dialog means the document never had a path — altium_check_dialogs."
    return r


# ---- library management (ported from barrett-land/adom-desktop-altium-bridge) -------

def _lib_call(args, cmd, params, open_path=None, timeout=90):
    g = _need_licensed()
    if g:
        return g
    if open_path:
        r = agent.call("open", {"path": open_path}, timeout=60)
        if not r.get("success"):
            return r
    return agent.call(cmd, params, timeout=float(args.get("timeout", timeout)))


def h_install_library(args):
    if not args.get("libraryPath"):
        return {"success": False, "error": "libraryPath is required (.IntLib/.SchLib/.PcbLib/.DbLib)"}
    r = _lib_call(args, "install_library", {"path": args["libraryPath"]})
    r.setdefault("_hint", "Registered in Altium's Installed Libraries (IntegratedLibraryManager.InstallLibrary).")
    return r


def h_compile_intlib(args):
    if not args.get("libpkgPath"):
        return {"success": False, "error": "libpkgPath (.LibPkg) is required; output = destination dir"}
    return _lib_call(args, "compile_intlib", {"path": args["libpkgPath"], "output": args.get("output", "")}, timeout=300)


def h_extract_intlib(args):
    if not args.get("intlibPath"):
        return {"success": False, "error": "intlibPath (.IntLib) is required"}
    r = _lib_call(args, "extract_intlib", {"path": args["intlibPath"]}, timeout=300)
    r.setdefault("_hint", "sources[] are the editable .SchLib/.PcbLib Altium extracted next to the IntLib (now open in the workspace).")
    return r


def h_set_params(args):
    if not args.get("libraryPath"):
        return {"success": False, "error": "libraryPath (.SchLib) is required", "_hint": "params:'LCSC Part #=C123;Manufacturer=TI' footprint:'SOT-23' comment:'10k'"}
    params = args.get("params", "")
    if isinstance(params, dict):
        params = ";".join(f"{k}={v}" for k, v in params.items())
    r = _lib_call(args, "set_params", {"name": args.get("symbolName", ""), "params": params,
                                       "footprint": args.get("footprint", ""), "comment": args.get("comment", "")},
                  open_path=args["libraryPath"])
    r.setdefault("_hint", "Parameters set on the symbol (unsaved) — altium_save {filePath: libraryPath}. LCSC Part # / Manufacturer Part Number are the JLC/BOM keys.")
    return r


def h_add_body(args):
    if not args.get("libraryPath"):
        return {"success": False, "error": "libraryPath (.PcbLib) is required", "_hint": "step:'C:/…/part.step' for a real model, else bwMil/bhMil/hMil/soMil for an extruded box"}
    r = _lib_call(args, "add_body", {"name": args.get("footprintName", ""), "step": args.get("step", ""),
                                     "bw_mil": args.get("bwMil", 100), "bh_mil": args.get("bhMil", 100),
                                     "h_mil": args.get("hMil", 40), "so_mil": args.get("soMil", 0)},
                  open_path=args["libraryPath"])
    if r.get("success"):
        # canvas-added bodies land 50000 mil from the pads in the stored frame; realign
        n = agent.call("normalize_bodies", {"name": args.get("footprintName", "")}, timeout=90)
        r["normalized"] = (n.get("data") or {}).get("shifts")
    r["shot"] = _shot_of_main() if r.get("success") else None
    return r


# ---- the AdomBridge .NET extension (Barrett's) — parameterized processes ---------------

EXT_DIR = Path(__file__).resolve().parent / "extension"
EXT_REQ_DIR = r"C:\Users\Public\AdomAltium"   # SPACE-FREE: Altium's -R parser splits on whitespace


def _extension_installed() -> bool:
    try:
        import glob as _g
        return bool(_g.glob(r"C:\ProgramData\Altium\Altium Designer*\Extensions\AdomBridge\AdomBridge.dll"))
    except Exception:
        return False


def h_install_extension(args):
    installer = EXT_DIR / "Install-AdomBridge.ps1"
    if not installer.exists():
        return {"success": False, "error": f"installer missing at {installer}"}
    if altium_detect.running_pids() and not args.get("remove"):
        return {"success": False, "errorCode": "altium_running",
                "error": "Altium must be CLOSED to install/update the extension (it holds AdomBridge.dll open).",
                "_hint": "altium_close, then altium_install_extension, then altium_launch. Put extension/REMOVE-AdomBridge.cmd on the Desktop first — a failed extension load stops Altium starting."}
    cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(installer), "-Source", str(EXT_DIR / "AdomBridge")]
    if args.get("remove"):
        cmd.append("-Remove")
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=180, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
    ok = p.returncode == 0 and "denied" not in (p.stderr or "").lower()
    return {"success": ok, "removed": bool(args.get("remove")), "output": (p.stdout or "")[-1200:], "stderr": (p.stderr or "")[-400:],
            "_hint": "Start Altium — extensions load at startup. Then altium_export_step / altium_import_kicad."}


EXT_STATUS_NOTE = ("MEASURED 2026-08-29 on AD 26.9.1: the AdomBridge server never loads into the RUNNING instance — "
                   "`X2 -RAdomBridge:*` helpers wait on Altium's startup mutex ~65-90 s and then host the server "
                   "themselves, and RunProcess('AdomBridge:Ping') from inside the agent is silently ignored (unknown "
                   "process). It also coincided with a 'Could not start Vault Explorer' warning at startup, so it was "
                   "unregistered again. Keep the verbs; they fail loudly until the extension is rebuilt/verified for AD 26.")


def _ext_dispatch(process: str, req_lines: list[str], result_file: str, timeout: float) -> dict:
    """X2.EXE -RAdomBridge:<Process>(Req=<file>) with the parameters in a request file at a
    space-free path. The extension writes its result file; only the filesystem is proof."""
    if not _extension_installed():
        return {"success": False, "errorCode": "extension_not_installed",
                "_hint": "altium_install_extension (Altium closed), restart Altium, retry."}
    exe = agent._x2_exe()
    os.makedirs(EXT_REQ_DIR, exist_ok=True)
    req = os.path.join(EXT_REQ_DIR, f"{process.lower()}_req.txt")
    res = os.path.join(EXT_REQ_DIR, result_file)
    try:
        os.remove(res)
    except FileNotFoundError:
        pass
    with open(req, "w", encoding="utf-8") as f:
        f.write("\n".join(req_lines) + "\n")
    p = agent._spawn_helper(exe, f"-RAdomBridge:{process}(Req={req})")
    t0 = time.time()
    while time.time() - t0 < timeout:
        if os.path.exists(res):
            time.sleep(0.3)
            return {"success": True, "result": open(res, encoding="utf-8", errors="replace").read(), "elapsedSec": round(time.time() - t0, 1)}
        time.sleep(0.5)
    try:
        p.kill()
    except Exception:
        pass
    return {"success": False, "errorCode": "extension_timeout", "elapsedSec": round(time.time() - t0, 1),
            "_hint": "No result file. " + EXT_STATUS_NOTE}


def h_extension_ping(args):
    r = _ext_dispatch("Ping", ["Msg=hello"], "adombridge_ping.txt", 30)
    r["installedOnDisk"] = _extension_installed()
    return r


def h_export_step(args):
    """Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep."""
    g = _need_licensed()
    if g:
        return g
    pcb = args.get("filePath") or DEMO_PCB
    out = args.get("output") or (os.path.splitext(pcb)[0] + ".step")
    lines = [f"PcbDoc={pcb}", f"Out={out}", f"Holes={args.get('holes', 'All')}",
             f"PadHoles={'true' if args.get('padHoles', True) else 'false'}", f"Copper={args.get('copper', 'None')}",
             f"Models={args.get('models', 'Step')}", f"Folded={'true' if args.get('folded') else 'false'}"]
    r = _ext_dispatch("ExportStep", lines, "adombridge_exportstep.txt", float(args.get("timeout", 300)))
    r["output"] = out
    r["outputExists"] = os.path.exists(out)
    r["outputBytes"] = os.path.getsize(out) if r["outputExists"] else 0
    if r.get("success") and not r["outputExists"]:
        r["success"] = False
        r["errorCode"] = "step_not_written"
    r.setdefault("_hint", "Copper=All bakes traces as solids (30x bigger file); Holes=All gives real drilled geometry.")
    return r


def h_import_kicad(args):
    if not args.get("filePath"):
        return {"success": False, "error": "filePath (.kicad_sym / .kicad_sch / .kicad_pcb) is required"}
    r = _ext_dispatch("ImportKiCad", [f"Mode={args.get('mode', 'Import')}", f"File={args['filePath']}"], "adombridge_importkicad.txt", float(args.get("timeout", 300)))
    r.setdefault("_hint", "Output lands in 'Imported <stem>' beside the source. Footprints are NOT imported by Altium's KiCad importer — link one with altium_set_params {footprint}.")
    return r


# ---- phase 5: account / workspace / licence / Altium 365 -------------------------

A365_CFG = None
_a365_oauth_lock = threading.Lock()
_a365_oauth = {"status": "idle", "cancelled": False}


def _a365_cfg_path():
    return agent.cache_dir() / "a365.json"


def _a365_load() -> dict:
    try:
        return json.loads(_a365_cfg_path().read_text())
    except Exception:
        return {}


def _a365_save(cfg: dict) -> None:
    """Atomic local-only persistence; tokens never travel in verb responses."""
    path = _a365_cfg_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(cfg))
    try:
        os.chmod(tmp, 0o600)
    except OSError:
        pass
    os.replace(tmp, path)


def _a365_access_token(cfg: dict) -> str | None:
    return cfg.get("token") or cfg.get("accessToken")


def h_a365_configure(args):
    """Store the workspace domain + a Bearer token (created in the workspace web UI under
    Admin → Developer on plans that expose it; the Develop trial workspace has no such page)."""
    cfg = _a365_load()
    for k in ("workspace", "token"):
        if args.get(k):
            cfg[k] = args[k]
    if not cfg.get("workspace"):
        return {"success": False, "error": "workspace is required, e.g. 'acme.365.altium.com'"}
    _a365_save(cfg)
    return {"success": True, "workspace": cfg["workspace"], "hasToken": bool(cfg.get("token")),
            "_hint": "altium_a365_status to verify; altium_a365_query {query} to run GraphQL."}


def h_a365_status(args):
    cfg = _a365_load()
    out = {"success": True, "configured": bool(cfg.get("workspace")), "workspace": cfg.get("workspace"),
           "hasToken": bool(_a365_access_token(cfg)), "hasRefreshToken": bool(cfg.get("refreshToken")),
           "clientConfigured": bool(cfg.get("clientId")),
           "endpoint": f"https://{cfg.get('workspace')}/svc/napi/gateway/graphql/" if cfg.get("workspace") else None}
    if not _a365_access_token(cfg):
        out["_hint"] = ("No access token. Preferred path: register an Altium public OAuth client, then "
                        "altium_a365_oauth_begin {clientId, workspace}; open authorizeUrl only after the verb returns. "
                        "Manual Admin → Developer tokens remain supported by altium_a365_configure.")
        return out
    r = h_a365_query({"query": "{ desProjects(first: 1) { totalCount } }"})
    out["tokenLive"] = bool(r.get("success"))
    out["probe"] = r.get("data") or r.get("error")
    return out


def h_a365_query(args):
    cfg = _a365_load()
    token = _a365_access_token(cfg)
    if not cfg.get("workspace") or not token:
        return {"success": False, "errorCode": "a365_not_configured",
                "_hint": "altium_a365_oauth_begin {clientId, workspace} or altium_a365_configure {workspace, token}"}
    q = args.get("query")
    if not q:
        return {"success": False, "error": "query (GraphQL) is required", "_hint": "e.g. { desProjects(first:10){ nodes { name } } }"}
    try:
        data = a365.graphql(cfg["workspace"], token, q, args.get("variables") or {},
                            timeout=float(args.get("timeout", 60)))
    except a365.A365Error as e:
        return {"success": False, "errorCode": e.code, "error": str(e),
                "_hint": "Check altium_a365_status; refresh or sign in again if the token expired."}
    return {"success": "errors" not in data, "data": data.get("data"), "errors": data.get("errors"),
            "_hint": "Explore the schema at https://<workspace>/api/voyager."}


def _a365_oauth_worker(pending: a365.PendingAuthorization, workspace: str, timeout: float) -> None:
    global _a365_oauth
    try:
        code = a365.poll_actionwait(
            pending.wait_token,
            overall_timeout=timeout,
            cancelled=lambda: bool(_a365_oauth.get("cancelled")),
        )
        tokens = a365.exchange_authorization_code(pending, code)
        cfg = _a365_load()
        cfg.update({
            "workspace": workspace,
            "clientId": pending.client_id,
            "token": tokens["access_token"],
            "accessToken": tokens["access_token"],
            "refreshToken": tokens.get("refresh_token") or cfg.get("refreshToken"),
            "tokenEndpoint": a365.TOKEN_ENDPOINT,
            "expiresAt": int(time.time()) + int(tokens.get("expires_in") or 0),
        })
        _a365_save(cfg)
        with _a365_oauth_lock:
            _a365_oauth = {"status": "authorized", "cancelled": False,
                           "workspace": workspace,
                           "claims": a365.unverified_claim_summary(tokens["access_token"])}
    except a365.A365Error as e:
        with _a365_oauth_lock:
            _a365_oauth = {"status": "cancelled" if e.code == "a365_oauth_cancelled" else "error",
                           "cancelled": False, "errorCode": e.code, "error": str(e)}
    except Exception as e:
        with _a365_oauth_lock:
            _a365_oauth = {"status": "error", "cancelled": False,
                           "errorCode": "a365_oauth_internal", "error": type(e).__name__}


def h_a365_oauth_begin(args):
    """Start ActionWait before returning the browser URL (Altium SPEC section 4.4)."""
    global _a365_oauth
    cfg = _a365_load()
    client_id = args.get("clientId") or cfg.get("clientId")
    workspace = args.get("workspace") or cfg.get("workspace")
    if not workspace:
        return {"success": False, "errorCode": "a365_workspace_required",
                "error": "workspace is required, e.g. acme.365.altium.com"}
    with _a365_oauth_lock:
        if _a365_oauth.get("status") == "waiting":
            return {"success": False, "errorCode": "a365_oauth_in_progress",
                    "_hint": "altium_a365_oauth_status; cancel the old flow before starting another."}
        try:
            pending = a365.new_pending_authorization(
                client_id,
                select_workspace=args.get("selectWorkspace", "strict"),
            )
        except a365.A365Error as e:
            return {"success": False, "errorCode": e.code, "error": str(e),
                    "_hint": "Altium OAuth clients are issued through Developer Center enrollment; do not reuse a first-party client ID."}
        _a365_oauth = {"status": "waiting", "cancelled": False, "workspace": workspace,
                       "startedAt": int(time.time()), "expiresInSec": int(args.get("timeout", 300))}
        thread = threading.Thread(target=_a365_oauth_worker,
                                  args=(pending, workspace, float(args.get("timeout", 300))),
                                  name="altium-a365-oauth", daemon=True)
        thread.start()
    return {"success": True, "status": "waiting", "workspace": workspace,
            "authorizeUrl": pending.authorize_url,
            "_hint": "ActionWait is listening now. Open authorizeUrl in the user's existing browser, then poll altium_a365_oauth_status. Tokens never appear in verb output."}


def h_a365_oauth_status(args):
    with _a365_oauth_lock:
        state = dict(_a365_oauth)
    state.pop("cancelled", None)
    state["success"] = state.get("status") != "error"
    if state.get("status") == "waiting":
        state["_hint"] = "Complete the Altium consent page in the browser; ActionWait is already listening."
    elif state.get("status") == "authorized":
        state["_hint"] = "OAuth is live. altium_a365_library_overview proves the workspace-scoped token."
    return state


def h_a365_oauth_cancel(args):
    with _a365_oauth_lock:
        if _a365_oauth.get("status") != "waiting":
            return {"success": True, "alreadyStopped": True, "status": _a365_oauth.get("status", "idle")}
        _a365_oauth["cancelled"] = True
    return {"success": True, "status": "cancelling", "_hint": "Poll altium_a365_oauth_status."}


def h_a365_refresh(args):
    cfg = _a365_load()
    if not cfg.get("clientId") or not cfg.get("refreshToken"):
        return {"success": False, "errorCode": "a365_refresh_not_configured",
                "_hint": "Complete altium_a365_oauth_begin first with offline_access."}
    try:
        tokens = a365.refresh_access_token(cfg["clientId"], cfg["refreshToken"],
                                           token_endpoint=cfg.get("tokenEndpoint", a365.TOKEN_ENDPOINT))
    except a365.A365Error as e:
        return {"success": False, "errorCode": e.code, "error": str(e)}
    cfg["token"] = cfg["accessToken"] = tokens["access_token"]
    if tokens.get("refresh_token"):
        cfg["refreshToken"] = tokens["refresh_token"]
    cfg["expiresAt"] = int(time.time()) + int(tokens.get("expires_in") or 0)
    _a365_save(cfg)
    return {"success": True, "workspace": cfg.get("workspace"),
            "claims": a365.unverified_claim_summary(tokens["access_token"]),
            "_hint": "Access token refreshed; rotated refresh token (if any) was stored atomically."}


def h_a365_logout(args):
    """Revoke the refresh grant and remove all locally cached OAuth tokens."""
    cfg = _a365_load()
    refresh_token = cfg.get("refreshToken")
    client_id = cfg.get("clientId")
    if refresh_token and client_id:
        try:
            a365.revoke_refresh_token(client_id, refresh_token,
                                      revocation_endpoint=cfg.get("revocationEndpoint", a365.REVOCATION_ENDPOINT))
        except a365.A365Error as e:
            return {"success": False, "errorCode": e.code, "error": str(e),
                    "_hint": "The local grant was retained because remote revocation did not succeed."}
    for key in ("token", "accessToken", "refreshToken", "expiresAt"):
        cfg.pop(key, None)
    _a365_save(cfg)
    return {"success": True, "revoked": bool(refresh_token and client_id),
            "workspace": cfg.get("workspace"),
            "_hint": "OAuth tokens were removed from the bridge cache."}


def h_a365_files_upload(args):
    cfg = _a365_load()
    token = _a365_access_token(cfg)
    file_path = args.get("filePath")
    if not cfg.get("workspace") or not token:
        return {"success": False, "errorCode": "a365_not_configured",
                "_hint": "Complete altium_a365_oauth_begin first."}
    if not file_path:
        return {"success": False, "errorCode": "a365_file_required", "error": "filePath is required"}
    try:
        file_id = a365.upload_file(cfg["workspace"], token, file_path,
                                   timeout=float(args.get("timeout", 120)),
                                   max_bytes=int(args.get("maxBytes", 100 * 1024 * 1024)))
    except a365.A365Error as e:
        return {"success": False, "errorCode": e.code, "error": str(e)}
    return {"success": True, "fileId": file_id, "expiresAfterHoursInactive": 24,
            "_hint": "Temporary opaque file ID. Pass it to the matching library mutation; do not parse or persist it."}


def h_a365_library_overview(args):
    return h_a365_query({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { nodes { id name } } footprints { totalCount } symbols { totalCount } } }"})


def h_a365_library_schema(args):
    query = """query LibrarySchema {
      __schema {
        queryType { fields { name description } }
        mutationType { fields { name description args { name type { kind name ofType { kind name } } } } }
      }
    }"""
    result = h_a365_query({"query": query, "timeout": args.get("timeout", 60)})
    if not result.get("success"):
        return result
    schema = ((result.get("data") or {}).get("__schema") or {})
    pattern = ("lib", "component", "symbol", "footprint", "part", "datasheet")
    def relevant(fields):
        return [f for f in (fields or []) if any(p in (f.get("name") or "").lower() for p in pattern)]
    return {"success": True,
            "queries": relevant(((schema.get("queryType") or {}).get("fields"))),
            "mutations": relevant(((schema.get("mutationType") or {}).get("fields"))),
            "_hint": "Read-only schema map. Choose a mutation only after its input types and rollback/delete mutation are understood."}


def h_signin_status(args):
    """Account + workspace connection as Altium itself reports it."""
    lic = altium_detect.licence_state()
    out = {"success": True, "running": lic.get("running"), "licensed": lic.get("licensed"), "licenceState": lic.get("state")}
    if not lic.get("running"):
        out["_hint"] = "altium_launch first."
        return out
    if agent.is_alive():
        st = agent.call("run_process", {"process": "Client:CurrentUser", "params": ""}, timeout=15, autostart=False)
        out["currentUserProcess"] = st.get("data")
    # A whole-window UIA tree takes >45 s on this box (even scoped), so ask one exact
    # question instead: is there a menu item literally named "Not Connected"?
    r = caller_identity.ad_callback("desktop_find_control", {"hwnd": lic["hwnd"], "name": "Not Connected", "role": "menuitem"}, timeout=25)
    found = False
    if isinstance(r, dict):
        if r.get("success") and (r.get("best") or r.get("control")):
            found = True
        elif isinstance(r.get("output"), str):
            try:
                found = bool(json.loads(r["output"]).get("best"))
            except Exception:
                found = False
    ws = "Not Connected" if found else "(connected: the menu shows the workspace name)"
    out["workspaceMenu"] = ws
    out["workspaceConnected"] = not found
    out["workspace"] = None
    out["_hint"] = ("Signed-in account is on the License Management page (altium_license {action:'show'}). "
                    "'Not Connected' = no Altium 365 workspace attached: altium_connect_workspace opens the picker.")
    return out


def h_connect_workspace(args):
    """Open Altium's workspace picker (the 'Not Connected' menu). Its list is a custom
    control with no UIA children, so the verb returns a screenshot with coordMap for
    the caller to click, rather than guessing."""
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        return {"success": False, "errorCode": "altium_not_running"}
    caller_identity.ad_callback("desktop_ui_expand", {"hwnd": lic["hwnd"], "name": "Not Connected", "role": "menuitem"})
    pop = []
    for _ in range(8):
        time.sleep(0.4)
        pop = [w for w in altium_detect.altium_windows() if w["className"] == "TWorkspacesForm"]
        if pop:
            break
    if not pop:
        return {"success": False, "errorCode": "picker_not_open", "_hint": "Altium may already be connected (menu text is the workspace name) — altium_signin_status."}
    shot = caller_identity.ad_callback("desktop_screenshot_window", {"hwnd": pop[0]["hwnd"]})
    return {"success": True, "picker": pop[0], "shot": shot, "windowEtiquette": "background",
            "_hint": "The picker lists candidate workspaces; click the wanted row with ab desktop_click {space:'image', shotId, x, y}. It is a popup — it closes on focus loss."}


LICENSE_PAGE_COORDS = {  # image-space on a 1400x840 safe capture of a maximised main window (measured 2026-08-29)
    "refresh": (1288, 178), "use_license": (1252, 388), "start_trial": (788, 607), "sign_out": (386, 179)}

# The SAME actions by accessible name / automationId — the quiet lane (measured 2026-09-01).
# UIA needs no coordinates, so it survives a resized or non-maximised window, which the pixel
# table does not.
LICENSE_PAGE_NAMES = {"refresh": "Refresh", "use_license": "Use License",
                      "start_trial": "Start Trial", "sign_out": "Sign Out"}
LICENSE_PAGE_IDS = {"use_license": "UseLicenseButton"}


def h_license(args):
    """Drive the License Management page. It is a WebView2 — not in UIA — so actions are
    image-space clicks on a fresh capture; every call returns that capture so the caller
    can see the state and click anything else itself."""
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        return {"success": False, "errorCode": "altium_not_running", "_hint": "altium_launch first."}
    action = args.get("action", "show")
    if agent.is_alive():
        agent.call("run_process", {"process": "Client:LicenseSubscriptionNotification", "params": ""}, timeout=15, autostart=False)
    # bring the License Management view up via its process launcher (background)
    agent.call("run_process", {"process": "Client:MyAccount", "params": ""}, timeout=20) if agent.is_alive() else None
    time.sleep(1.0)
    win = caller_identity.ad_callback("desktop_screenshot_window", {"hwnd": lic["hwnd"]}) or {}
    shot = _shot_of_main() or {}
    shot["shotId"] = (win.get("coordMap") or {}).get("shotId") or win.get("shotId")
    shot["coordMap"] = win.get("coordMap")
    out = {"success": True, "licenceState": altium_detect.licence_state().get("state"), "shot": shot, "windowEtiquette": "background"}
    if action in LICENSE_PAGE_COORDS and shot.get("shotId"):
        x, y = LICENSE_PAGE_COORDS[action]
        # UIA FIRST (John, 2026-09-01: "you keep turning on the cursor"). The licence page is a
        # WebView2, but its DOM IS in the a11y tree — "Use License" is automationId
        # UseLicenseButton, invokable. A UIA invoke moves no cursor and steals no foreground;
        # the pixel click below is the fallback for anything UIA cannot reach.
        r = uia.click_by_name_then_pixels(
            lic["hwnd"], LICENSE_PAGE_NAMES.get(action, action),
            lambda: caller_identity.ad_callback("desktop_click", {"space": "image", "shotId": shot["shotId"], "x": x, "y": y, "hwnd": lic["hwnd"]}),
            automation_id=LICENSE_PAGE_IDS.get(action))
        out["clicked"] = {"action": action, "lane": r.get("lane"), "result": r}
        out["windowEtiquette"] = r.get("windowEtiquette", "foreground")
        time.sleep(2.0)
        out["licenceStateAfter"] = altium_detect.licence_state().get("state")
        out["shotAfter"] = caller_identity.ad_callback("desktop_screenshot_window", {"hwnd": lic["hwnd"]})
    out["_hint"] = ("actions: show | refresh | use_license | start_trial | sign_out. Coordinates were measured on a "
                    "maximised window; verify against shot.image before trusting a click. start_trial opens the browser "
                    "workspace-creation flow (name, terms, Activate) — see the altium-install skill.")
    return out




def h_activate_document(args):
    """Make an ALREADY-OPEN document current — the quiet lane.

    Measured 2026-09-01: the document bar IS in Altium's UIA tree, but a `desktop_ui_click` by name
    against the main window takes >100 s (the tree is huge — dialogs and the licence WebView are
    fast only because theirs are small). The AGENT is the real quiet lane: it runs INSIDE Altium,
    so activating a document costs one in-process call (~4 s), moves no cursor and steals no OS
    foreground. Altium ACTIVATES an already-open document rather than re-reading it."""
    g = _need_licensed()
    if g:
        return g
    path = args.get("path") or args.get("document") or args.get("name")
    if not path:
        return {"success": False, "error": "path is required (the document's full path)",
                "_hint": "altium_window_info lists what is open; board verbs need a .PcbDoc current."}
    bad = _validate_openable(path)
    if bad:
        return bad
    wait = min(float(args.get("timeout", 60)), _OPEN_BUDGET_SEC)
    r = agent.call("open", {"path": path}, timeout=wait)
    r["lane"] = "agent"
    r["windowEtiquette"] = "background"
    if r.get("errorCode") == "agent_timeout":
        return {"success": True, "status": "in_progress", "path": path, "lane": "agent",
                "windowEtiquette": "background", "waitedSec": wait,
                "_hint": "Still opening — poll altium_status until the document is current."}
    r.setdefault("_hint", "The document is now current. An already-open document is activated, not reloaded.")
    return r



def h_layers_in_use(args):
    """How many layers does this board actually have? — answered from primitives, not the stack.

    Altium's "is this layer in the stack" flag has no identifier proven on AD26, and a wrong guess
    halts the script engine behind a modal on the user's screen. So the agent tallies primitives by
    layer in ONE pass and this filters that tally to the layers Altium reports as copper.

    Read it as a FLOOR: an inner plane carrying no primitives is invisible here. `copperLayersAddressable`
    (48) is what Altium can address — never report that as a board property.

    NOTE: the same shaping exists in bridge-rs (`board.rs`). That duplication is deliberate and
    temporary — it goes away when the Rust bridge reaches parity and this runtime retires.
    """
    g = _need_licensed()
    if g:
        return g
    r = agent.call("read_board", {"sections": "copper,occupancy"}, timeout=float(args.get("timeout", 90)))
    data = r.get("data") or {}
    if not data.get("ok"):
        return r
    copper_ids = {l.get("id") for l in ((data.get("copper") or {}).get("layers") or [])}
    occupancy = sorted(((data.get("occupancy") or {}).get("occupancy") or []),
                       key=lambda l: -(l.get("primitives") or 0))
    copper = [l for l in occupancy if l.get("layerId") in copper_ids]
    return {"success": True, "board": data.get("board"),
            "copperLayersInUse": copper,
            "copperLayerCount": len(copper),
            "layersWithPrimitives": occupancy,
            "roundtripMs": r.get("roundtripMs"),
            "_hint": "copperLayersInUse = COPPER layers carrying copper, densest first. A floor, not the "
                     "stack: an empty inner plane is invisible. layersWithPrimitives also counts "
                     "silkscreen and mechanical layers."}



def h_instances(args):
    """Every running Altium, and how to address it.

    More than one can run at once — John runs Altium Designer Develop beside Altium Designer so two
    AI threads can drive both. Every other verb takes an optional `instance` (a pid, a key like
    'ADDevelop', or a word from the edition/title such as 'develop'). With ONE Altium running you
    can omit it and nothing changes; with several, verbs REFUSE rather than guess, because guessing
    means acting inside the other thread's Altium.

    Each instance gets its own agent workspace (`bridges-cache/altium/agent/<key>`), so two resident
    loops never share an inbox.
    """
    found = altium_detect.instances()
    for i in found:
        agent.use_instance(i)
        i["agentAlive"] = agent.is_alive()
        i["agentWorkspace"] = str(agent.workspace_dir())
    agent.use_instance(None)
    out = {"success": True, "count": len(found), "instances": found,
           "_hint": ("Pass instance:<pid|key|word> to any verb to target one. "
                     "With several running, verbs without it fail with errorCode ambiguous_instance.")
           if len(found) > 1 else "One Altium running — `instance` is optional."}
    return _seat_hints(out, None, len(found))



def _natural_key(s: str):
    """U2 before U10 — digit runs compare numerically. A designator list in string order reads
    wrong to anyone scanning it, and nobody notices it is wrong."""
    import re as _re
    return [int(x) if x.isdigit() else x.lower() for x in _re.split(r"(\d+)", s or "")]


def h_read_schematic(args):
    """The schematic-side companion to the board reads: components + pins of the CURRENT sheet.

    A hierarchical PARENT sheet holds sheet symbols, not parts — activate a leaf sheet first
    (altium_activate_document) or you will get an empty, technically-correct answer."""
    g = _need_licensed()
    if g:
        return g
    r = agent.call("read_schematic", {"sections": args.get("sections") or "components",
                                      "params": "1" if args.get("params", True) else "0",
                                      "limit": args.get("limit", 500)},
                   timeout=float(args.get("timeout", 90)))
    data = r.get("data") or {}
    if not data.get("ok"):
        return r
    comps = ((data.get("components") or {}).get("components") or [])
    des = sorted({c.get("designator") for c in comps if c.get("designator")}, key=_natural_key)
    return {"success": True, "document": data.get("document"), "count": len(comps),
            "designators": des, "components": comps, "roundtripMs": r.get("roundtripMs"),
            "_hint": "The CURRENT sheet only. On a hierarchical design read each leaf sheet."}


def h_cross_check(args):
    """Compare a schematic sheet against a board: what is drawn but not placed, and placed but not
    drawn. Neither read answers this alone, which is the point.

    Altium CANNOT hold both current at once — `GetCurrentPCBBoard` follows the focused document, so
    focusing the sheet clears the board (measured 2026-09-01, which is why the first cut of this
    verb always failed on the second read). It therefore reads them in TURN: activate the board,
    read it, activate the sheet, read it, then put back whatever the user had in front of them.

    Pass `board` and `sheet` paths, or leave them out to use whatever is already open.
    """
    g = _need_licensed()
    if g:
        return g
    was = altium_detect.licence_state().get("document")  # restore the user's view at the end
    board_path, sheet_path = args.get("board"), args.get("sheet")
    timeout = float(args.get("timeout", 45))

    def _read(kind, path):
        if path:
            bad = _validate_openable(path)
            if bad:
                return None, bad
            o = agent.call("open", {"path": path}, timeout=timeout)
            if not (o.get("data") or {}).get("ok", True):
                return None, {"success": False, "stage": kind, "error": (o.get("data") or {}).get("error"),
                              "_hint": f"could not open {path}"}
        cmd = "read_board" if kind == "board" else "read_schematic"
        r = agent.call(cmd, {"sections": "components"}, timeout=timeout)
        d = r.get("data") or {}
        if not d.get("ok"):
            return None, {"success": False, "stage": kind, "error": d.get("error"),
                          "_hint": (f"No current {'PCB board' if kind == 'board' else 'schematic sheet'}. "
                                    f"Pass {kind}:<path>, or open one first — Altium can only hold ONE "
                                    "document current, so cross_check must switch between them.")}
        return d, None

    bd, err = _read("board", board_path)
    if err:
        return err
    sd, err = _read("sheet", sheet_path)
    if err:
        return err

    pcb = sorted({c.get("designator") for c in ((bd.get("components") or {}).get("components") or [])
                  if c.get("designator")}, key=_natural_key)
    sch = sorted({c.get("designator") for c in ((sd.get("components") or {}).get("components") or [])
                  if c.get("designator")}, key=_natural_key)
    ss, ps = set(sch), set(pcb)
    doc, board = sd.get("document"), bd.get("board")

    def _folder(p):
        if not p:
            return None
        q = str(p).replace("/", "\\")
        return q.rsplit("\\", 1)[0].lower() if "\\" in q else None

    same = None
    if _folder(doc) and _folder(board):
        same = _folder(doc) == _folder(board)
    out = {"success": True, "sheet": doc, "board": board, "sameProject": same,
           "matched": len(ss & ps),
           "onSheetNotOnBoard": sorted(ss - ps, key=_natural_key),
           "onBoardNotOnSheet": sorted(ps - ss, key=_natural_key),
           "schematicComponents": len(sch), "boardComponents": len(pcb),
           "_scope": "The schematic side is ONE sheet. On a hierarchical design, run this per leaf sheet "
                     "before calling anything 'missing from the schematic'."}
    if same is False:
        out["_warning"] = ("The sheet and the board are in DIFFERENT folders, so they are probably not the "
                           "same project — which makes every difference above meaningless.")
    if was:
        out["restoredDocument"] = was  # best effort: leave the user looking at what they were looking at
    return out



# ---- design review: checks over ONE board read (John's lint catalog, phase 2) -----------------
#
# Every check below is a PURE FUNCTION over data the bridge already reads, so adding one costs no
# new Altium API surface — which matters because a wrong identifier in the agent halts the script
# engine behind a modal on the user's screen. Checks that need geometry Altium has not been asked
# for yet (board outline, silkscreen overlap, ratsnest) are deliberately absent rather than guessed;
# `notImplemented` in the reply names them so nobody mistakes silence for a clean board.

PLACEHOLDER_VALUES = {"comment", "value", "*", "?", "n/a", "na", "tbd", "xxx", "component",
                      "designator", "part", "unknown"}


def _chk_placeholder_values(board, sheet):
    """error — a component still carrying Altium's default text instead of a real value/MPN.

    Ships in real designs surprisingly often: the part is placed, nobody filled the Comment, and it
    reaches the BOM as "Comment"."""
    out = []
    for c in (board.get("components") or {}).get("components") or []:
        com = (c.get("comment") or "").strip()
        if com.lower() in PLACEHOLDER_VALUES:
            out.append({"check": "placeholder_values", "severity": "error",
                        "designator": c.get("designator"),
                        "message": f"{c.get('designator')} has placeholder Comment {com!r} — it will reach the BOM as-is."})
    return out


def _chk_single_pin_nets(board, sheet):
    """warning — a net with exactly one pad connects to nothing. Usually a typo'd net label or a
    wire that never landed."""
    out = []
    for n in (board.get("nets") or {}).get("nets") or []:
        pads = n.get("pads")
        if isinstance(pads, int) and pads == 1:
            out.append({"check": "single_pin_nets", "severity": "warning", "net": n.get("name"),
                        "message": f"Net {n.get('name')!r} has a single pad — it connects to nothing."})
    return out


def _chk_unused_mechanical_layers(board, sheet):
    """info — a mechanical layer that is enabled but carries nothing.

    Empty enabled layers travel into fabrication outputs and confuse the manufacturer, and they are
    invisible in the editor precisely BECAUSE they are empty."""
    occupied = {l.get("layerId") for l in (board.get("occupancy") or {}).get("occupancy") or []}
    out = []
    for l in (board.get("layers") or {}).get("layers") or []:
        if l.get("kind") != "mechanical" or not l.get("displayed"):
            continue
        if l.get("id") not in occupied:
            out.append({"check": "unused_mechanical_layers", "severity": "info",
                        "layer": l.get("name"),
                        "message": f"Mechanical layer {l.get('name')!r} is enabled but carries no primitives."})
    return out


def _chk_off_grid_components(board, sheet, grid_mil=5):
    """info — a placed component whose origin is off the placement grid.

    Off-grid parts are usually an accidental drag; they make later alignment and panelisation
    fiddly. Grid is configurable because a metric board legitimately fails a 5 mil test."""
    out = []
    for c in (board.get("components") or {}).get("components") or []:
        x, y = c.get("xMil"), c.get("yMil")
        if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
            continue
        if (round(x) % grid_mil) or (round(y) % grid_mil):
            out.append({"check": "off_grid_components", "severity": "info",
                        "designator": c.get("designator"),
                        "message": f"{c.get('designator')} sits at ({x}, {y}) mil — off the {grid_mil} mil grid."})
    return out


def _chk_duplicate_designators(board, sheet):
    """error — the same designator twice on one sheet. Altium's compiler catches collisions across
    a project; this catches the within-sheet case from data already read."""
    out = []
    for src, label in ((sheet, "sheet"), (board, "board")):
        if not src:
            continue
        seen = {}
        comps = (src.get("components") or {}).get("components") or []
        for c in comps:
            d = c.get("designator")
            if d:
                seen[d] = seen.get(d, 0) + 1
        for d, n in sorted(seen.items()):
            if n > 1:
                out.append({"check": "duplicate_designators", "severity": "error", "designator": d,
                            "message": f"Designator {d} occurs {n} times on the {label}."})
    return out



def _outline_box(board):
    """The board extent, or None. `source` matters: 'boardOutline' is the real outline's bounding
    box; 'boardBounds' is everything on the board including stray primitives outside it, which is a
    much weaker claim and must not be used to say a part is off-board."""
    o = board.get("outline") or {}
    if not o.get("ok"):
        return None
    for k in ("leftMil", "rightMil", "bottomMil", "topMil"):
        if not isinstance(o.get(k), (int, float)):
            return None
    return o


def _chk_components_outside_board(board, sheet):
    """error — a placed component sitting outside the board extent.

    NOTE this is a BOUNDING BOX test, not a polygon test: on an L-shaped or slotted board a part
    inside the bounding box but outside the real outline is NOT caught, and the message says so
    rather than implying more precision than the data carries."""
    box = _outline_box(board)
    if not box or box.get("source") != "boardOutline":
        return []
    l, r, b, tp = box["leftMil"], box["rightMil"], box["bottomMil"], box["topMil"]
    out = []
    for c in (board.get("components") or {}).get("components") or []:
        x, y = c.get("xMil"), c.get("yMil")
        if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
            continue
        if x < l or x > r or y < b or y > tp:
            out.append({"check": "components_outside_board_outline", "severity": "error",
                        "designator": c.get("designator"),
                        "message": f"{c.get('designator')} sits at ({x}, {y}) mil, outside the board "
                                   f"bounding box ({l}..{r}, {b}..{tp} mil)."})
    return out


def _chk_components_near_board_edge(board, sheet, margin_mil=20):
    """warning — a component origin within `margin` of the board edge.

    Named for what it measures: COMPONENT ORIGINS, not pads. A pad-to-edge check needs per-pad
    geometry the board read does not carry yet, and calling this 'pads_near_board_edge' would claim
    a precision it does not have."""
    box = _outline_box(board)
    if not box or box.get("source") != "boardOutline":
        return []
    l, r, b, tp = box["leftMil"], box["rightMil"], box["bottomMil"], box["topMil"]
    out = []
    for c in (board.get("components") or {}).get("components") or []:
        x, y = c.get("xMil"), c.get("yMil")
        if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
            continue
        if x < l or x > r or y < b or y > tp:
            continue  # already reported as outside
        d = min(x - l, r - x, y - b, tp - y)
        if d < margin_mil:
            out.append({"check": "components_near_board_edge", "severity": "warning",
                        "designator": c.get("designator"),
                        "message": f"{c.get('designator')} origin is {int(d)} mil from the board edge "
                                   f"(under the {margin_mil} mil margin)."})
    return out



# Adom house rule (John, standing): Mouser is the PREFERRED vendor, and every component carries
# Mouser + DigiKey + LCSC part numbers. Encoded as a check so a board can be held to it, rather
# than the rule living only in people's heads.
VENDOR_PATTERNS = {
    "Mouser": ("mouser",),
    "DigiKey": ("digikey", "digi-key", "digi key"),
    "LCSC": ("lcsc", "jlcpcb"),
}
# Designator prefixes that legitimately carry no vendor part number.
NON_PURCHASED_PREFIXES = ("TP", "FID", "MH", "H", "LOGO", "NET", "PWR", "GND")


def _params_of(comp):
    return {(p.get("name") or "").strip().lower(): (p.get("value") or "").strip()
            for p in (comp.get("params") or []) if p.get("name")}


def _is_purchased(designator):
    d = (designator or "").upper()
    prefix = "".join(ch for ch in d if ch.isalpha())
    return prefix not in NON_PURCHASED_PREFIXES


def _chk_vendor_part_numbers(board, sheet):
    """warning — a purchased component missing Mouser / DigiKey / LCSC part numbers.

    Mouser first in the message because it is the preferred vendor: if only one is going to be
    filled in, that is the one. Test points, fiducials and mounting holes are exempt — they are not
    purchased parts and flagging them would train people to ignore the check."""
    if not sheet:
        return []
    out = []
    for c in (sheet.get("components") or {}).get("components") or []:
        des = c.get("designator")
        if not _is_purchased(des):
            continue
        params = _params_of(c)
        blob = " ".join(f"{k} {v}" for k, v in params.items())
        missing = [vendor for vendor, keys in VENDOR_PATTERNS.items()
                   if not any(k in blob for k in keys)]
        if missing:
            out.append({"check": "vendor_part_numbers", "severity": "warning", "designator": des,
                        "missing": missing,
                        "message": f"{des} has no {' / '.join(missing)} part number "
                                   f"(house rule: Mouser preferred, all three required)."})
    return out


def _chk_missing_datasheets(board, sheet):
    """info — a purchased component with no datasheet link."""
    if not sheet:
        return []
    out = []
    for c in (sheet.get("components") or {}).get("components") or []:
        des = c.get("designator")
        if not _is_purchased(des):
            continue
        params = _params_of(c)
        if not any(k in ("datasheet", "helpurl", "datasheet link", "datasheeturl") or "datasheet" in k
                   for k in params):
            out.append({"check": "missing_datasheets", "severity": "info", "designator": des,
                        "message": f"{des} has no Datasheet parameter."})
    return out


def _chk_schematic_placeholders(board, sheet):
    """error — a schematic component still carrying default text in Comment.

    The board-side check only sees placed parts; this catches it at the source, where it is
    cheaper to fix and where the BOM is actually generated from."""
    if not sheet:
        return []
    out = []
    for c in (sheet.get("components") or {}).get("components") or []:
        com = (c.get("comment") or "").strip()
        if com.lower() in PLACEHOLDER_VALUES:
            out.append({"check": "schematic_placeholder_values", "severity": "error",
                        "designator": c.get("designator"),
                        "message": f"{c.get('designator')} on the sheet has placeholder Comment {com!r}."})
    return out



# Altium's TRuleKind ordinals, read off a live AD26 default rule set (each default rule is named
# after its own kind, which makes the mapping observable rather than guessed). Unknown ordinals are
# rendered as "kind <n>" — never invented.
RULE_KIND_NAMES = {
    0: "Clearance", 2: "Width", 6: "PlaneConnect", 7: "RoutingTopology", 8: "RoutingPriority",
    9: "RoutingLayers", 10: "RoutingCorners", 11: "RoutingVias", 12: "PlaneClearance",
    13: "SolderMaskExpansion", 14: "PasteMaskExpansion", 15: "ShortCircuit", 16: "UnRoutedNet",
    20: "PolygonConnect", 24: "ComponentClearance", 42: "HoleSize", 43: "FabricationTestpoint",
    44: "FabricationTestPointUsage", 48: "LayerPairs", 49: "Fanout", 50: "Height",
    51: "DiffPairsRouting", 52: "HoleToHoleClearance", 53: "MinimumSolderMaskSliver",
    54: "SilkToSolderMaskClearance", 55: "SilkToSilkClearance", 56: "NetAntennae",
    57: "AssemblyTestpoint", 58: "AssemblyTestPointUsage", 59: "SilkToBoardRegionClearance",
    62: "UnpouredPolygon",
}
# Rules whose absence a fabricator will notice before you do.
CRITICAL_RULE_KINDS = {0: "Clearance", 2: "Width", 42: "HoleSize", 15: "ShortCircuit"}


def _rule_kind_name(k):
    return RULE_KIND_NAMES.get(k, f"kind {k}")


def _chk_disabled_design_rules(board, sheet):
    """warning — a design rule that exists but is switched OFF.

    A disabled Clearance or ShortCircuit rule is worse than a missing one: the board LOOKS ruled,
    DRC passes, and the constraint was never applied."""
    out = []
    for r in (board.get("rules") or {}).get("rules") or []:
        if r.get("enabled") is False:
            kind = _rule_kind_name(r.get("kind"))
            sev = "error" if r.get("kind") in CRITICAL_RULE_KINDS else "warning"
            out.append({"check": "disabled_design_rules", "severity": sev, "rule": r.get("name"),
                        "message": f"Design rule {r.get('name')!r} ({kind}) is DISABLED — DRC will pass "
                                   f"without ever applying it."})
    return out


def _chk_missing_critical_rules(board, sheet):
    """error — no ENABLED rule of a kind a fabricator relies on."""
    rules = (board.get("rules") or {}).get("rules") or []
    if not rules:
        return []  # no rule data at all is not the same as no rules
    have = {r.get("kind") for r in rules if r.get("enabled") is not False}
    out = []
    for kind, name in sorted(CRITICAL_RULE_KINDS.items()):
        if kind not in have:
            out.append({"check": "missing_critical_rules", "severity": "error", "rule": name,
                        "message": f"No enabled {name} rule on this board."})
    return out


def _chk_duplicate_rule_priority(board, sheet):
    """info — two enabled rules of the same kind sharing a priority.

    Altium resolves ties by internal order, so which one wins is not something you can read off the
    board. Usually a copy-paste that was never re-prioritised."""
    seen = {}
    for r in (board.get("rules") or {}).get("rules") or []:
        if r.get("enabled") is False:
            continue
        key = (r.get("kind"), r.get("priority"))
        seen.setdefault(key, []).append(r.get("name"))
    out = []
    for (kind, prio), names in sorted(seen.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
        if len(names) > 1:
            out.append({"check": "duplicate_rule_priority", "severity": "info",
                        "rule": ", ".join(sorted(names)),
                        "message": f"{len(names)} enabled {_rule_kind_name(kind)} rules share priority "
                                   f"{prio} ({', '.join(sorted(names))}) — which one wins is not readable "
                                   f"from the board."})
    return out


def _chk_components_without_footprint(board, sheet):
    """warning — a placed component whose footprint PATTERN is empty.

    A placed part always has geometry, but an empty pattern name means the footprint has no identity:
    it cannot be matched back to a library, compared across a project, or checked for updates."""
    out = []
    for c in (board.get("components") or {}).get("components") or []:
        if not (c.get("pattern") or "").strip():
            out.append({"check": "components_without_footprint", "severity": "warning",
                        "designator": c.get("designator"),
                        "message": f"{c.get('designator')} has no footprint pattern name — it cannot be "
                                   f"matched back to a library."})
    return out


# (id, fn). The ID is what appears in a finding's `check` field, so `checks:["..."]` filters on the
# name the caller actually sees. Deriving it from the Python function name instead was a trap: the
# function is `_chk_components_outside_board`, the finding says `components_outside_board_outline`,
# and filtering by the visible name silently ran NOTHING (2026-09-01).
BOARD_CHECKS = [
    ("placeholder_values", _chk_placeholder_values),
    ("single_pin_nets", _chk_single_pin_nets),
    ("unused_mechanical_layers", _chk_unused_mechanical_layers),
    ("off_grid_components", _chk_off_grid_components),
    ("duplicate_designators", _chk_duplicate_designators),
    ("components_outside_board_outline", _chk_components_outside_board),
    ("components_near_board_edge", _chk_components_near_board_edge),
    # schematic-side: need includeSchematic:true, and quietly return nothing without it
    ("vendor_part_numbers", _chk_vendor_part_numbers),
    ("missing_datasheets", _chk_missing_datasheets),
    ("schematic_placeholder_values", _chk_schematic_placeholders),
    # design-rule hygiene, from the rules the board read already carries
    ("disabled_design_rules", _chk_disabled_design_rules),
    ("missing_critical_rules", _chk_missing_critical_rules),
    ("duplicate_rule_priority", _chk_duplicate_rule_priority),
    ("components_without_footprint", _chk_components_without_footprint),
]

# Named so a clean result is not mistaken for a complete one.
REVIEW_NOT_IMPLEMENTED = {
    "pads_near_board_edge": "needs per-PAD geometry; components_near_board_edge measures component "
                            "origins instead, which is a weaker claim",
    "outline_shape_checks": "the outline read carries a BOUNDING BOX, not the polygon — a part inside "
                            "the box but outside an L-shaped or slotted outline is not caught",
    "unrouted_connections": "needs the ratsnest",
    "silkscreen_over_pad": "needs per-layer primitive geometry",
    "via_antennas": "needs via/track topology",
    "missing_decoupling": "needs net-to-part topology",
}


def h_review_board(args):
    """Run the design-review checks. One agent round trip per document; all analysis in the bridge.

    Altium holds ONE current document, so a board read and a sheet read cannot both be "now" — this
    activates each in turn when given `board` / `sheet` paths, exactly like cross_check.

    Returns PARTIAL results rather than nothing: if the board cannot be read, the schematic checks
    still run and `skipped` says which checks had no data and why. A review that fails wholesale
    because one document was not current is a review nobody runs.
    """
    g = _need_licensed()
    if g:
        return g
    timeout = float(args.get("timeout", 90))
    board, sheet = {}, None
    board_err = sheet_err = None

    def _open(path):
        bad = _validate_openable(path)
        if bad:
            return bad
        o = agent.call("open", {"path": path}, timeout=timeout)
        d = o.get("data") or {}
        return None if d.get("ok", True) else {"error": d.get("error")}

    if args.get("board"):
        e = _open(args["board"])
        if e:
            return {"success": False, "stage": "board", **e}
    r = agent.call("read_board", {"sections": "components,nets,layers,copper,rules,occupancy,outline",
                                  "limit": args.get("limit", 2000)}, timeout=timeout)
    bd = r.get("data") or {}
    if bd.get("ok"):
        board = bd
    else:
        board_err = bd.get("error") or "board read failed"

    want_sheet = bool(args.get("includeSchematic") or args.get("sheet"))
    if want_sheet:
        if args.get("sheet"):
            e = _open(args["sheet"])
            if e:
                sheet_err = e.get("error")
        if not sheet_err:
            sr = agent.call("read_schematic", {"sections": "components", "params": "1"}, timeout=timeout)
            sd = sr.get("data") or {}
            if sd.get("ok"):
                sheet = sd
            else:
                sheet_err = sd.get("error") or "schematic read failed"

    if not board and not sheet:
        return {"success": False, "boardError": board_err, "schematicError": sheet_err,
                "_hint": "Nothing could be read. Pass board:<path> and/or sheet:<path> — Altium holds "
                         "only ONE document current, so the review activates each in turn."}

    wanted = args.get("checks")
    unknown = sorted(set(wanted or []) - {n for n, _ in BOARD_CHECKS})
    findings, ran, skipped = [], [], {}
    for name, fn in BOARD_CHECKS:
        if wanted and name not in wanted:
            continue
        needs_sheet = name in ("vendor_part_numbers", "missing_datasheets", "schematic_placeholder_values",
                               "duplicate_designators")
        if not board and not needs_sheet:
            skipped[name] = f"no board read ({board_err})"
            continue
        if needs_sheet and not sheet:
            skipped[name] = ("no schematic read" + (f" ({sheet_err})" if sheet_err else
                             " — pass includeSchematic:true or sheet:<path>"))
            continue
        ran.append(name)
        try:
            findings.extend(fn(board, sheet))
        except Exception as e:
            findings.append({"check": name, "severity": "info",
                             "message": f"check {name} failed to run: {e}"})
    order = {"error": 0, "warning": 1, "info": 2}
    findings.sort(key=lambda f: (order.get(f.get("severity"), 9), f.get("check"),
                                 str(f.get("designator") or f.get("net") or f.get("layer") or "")))
    counts = {}
    for f in findings:
        counts[f["severity"]] = counts.get(f["severity"], 0) + 1
    out = {"success": True, "board": board.get("board"), "sheet": (sheet or {}).get("document"),
           "findings": findings, "counts": counts, "checksRun": ran,
           "availableChecks": [n for n, _ in BOARD_CHECKS],
           "notImplemented": REVIEW_NOT_IMPLEMENTED,
           "_hint": ("A clean result means the checks that RAN found nothing. `skipped` lists checks with "
                     "no data; `notImplemented` lists what this review cannot see yet.")}
    if skipped:
        out["skipped"] = skipped
    if unknown:
        out["unknownChecks"] = unknown
    if board_err:
        out["boardError"] = board_err
    if sheet_err:
        out["schematicError"] = sheet_err
    return out


def h_read_board(args):
    """The raw board read: components, nets, layers, copper, rules, occupancy, outline — ONE round
    trip. This is the primitive every board view is derived from; exposing it means a caller can see
    exactly what a check saw, instead of inferring it from the check's silence."""
    g = _need_licensed()
    if g:
        return g
    r = agent.call("read_board", {"sections": args.get("sections") or "components,nets,layers,copper,rules,occupancy,outline",
                                  "limit": args.get("limit", 2000)},
                   timeout=float(args.get("timeout", 90)))
    d = r.get("data") or {}
    if not d.get("ok"):
        return r
    return {"success": True, "board": d.get("board"), "sections": d.get("sections"),
            "data": d, "roundtripMs": r.get("roundtripMs")}



def h_dialog_log(args):
    """What the standing dialog reaper has pre-empted.

    The reaper runs from bridge start and presses the catalog's SAFE action the instant a KNOWN
    nuisance appears — Altium's VaultExplorer startup warning, "File type not recognised", the
    script-busy notice — so the user never has to deal with them. It never touches a save prompt or
    anything not in the catalog: those are surfaced by altium_check_dialogs for a human decision."""
    return {"success": True, "reaped": dialogs.reaped_log(int(args.get("limit", 25))),
            "running": True,
            "_hint": "Anything NOT listed here that reached the user is either a save prompt (never "
                     "auto-answered, by design) or a dialog missing from the catalog — report it so "
                     "it can be added with a safe action."}


# ---- licence lifecycle: status, ensure-licensed, trial setup (John 2026-08-31) ------------

def h_license_status(args):
    """Plain-language answer to 'what Altium licence am I on?'. Reads the truthful state from
    the window title, opens the License Management page, and returns a structured summary plus
    the page capture so an AI/user can read the row detail (product, days left, seat, code).
    The page is a WebView2 (not UIA) so the fine print is only in the image."""
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        return {"success": True, "running": False, "licensed": None,
                "summary": "Altium is not running. altium_launch (or altium_ensure_licensed) first.",
                "_hint": "altium_launch first."}
    state = lic.get("state") or "Unknown"
    licensed = lic.get("licensed")
    page = h_license({"action": "show"})
    seat_stuck = None
    summary = None
    if licensed:
        summary = f"Licensed and in use: {state}."
    elif str(state).lower() in ("no license", "no licence"):
        summary = ("Signed in but NOT currently using a licence. If the License Management page shows "
                   "'no seats available', the single seat is still leased by a prior/crashed session — "
                   "it frees on its own, or Sign Out + Sign In forces the server to release it, then "
                   "Use license. altium_ensure_licensed automates the recovery.")
        seat_stuck = "possible"
    else:
        summary = f"Licence state: {state}."
    return {"success": True, "running": True, "licensed": licensed, "licenceState": state,
            "seatStuck": seat_stuck, "summary": summary, "shot": page.get("shot"),
            "_hint": "Structured state from the window title; read shot.image for the product row "
                     "(product, Trial/Full, days left, seat n/n, licence code). Trial info lives ONLY on the page."}


def h_ensure_licensed(args):
    """One call to get the user usable: launch Altium if needed, then make sure a licence is in
    use. If a seat is available, click Use license. If none is available (a stale/crashed session
    still holds the On-Demand seat), report it and, when allowSignInReset, do Sign Out so the next
    sign-in reclaims the seat. When allowTrial and there is no licence at all, start the free trial
    flow. Returns every step + the final state + the page capture. Never signs the user out without
    allowSignInReset, and the browser sign-in itself is the human's to complete."""
    steps = []
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        lr = h_launch({"waitSec": args.get("launchWaitSec", 240)})
        steps.append({"launch": lr.get("success"), "detail": lr.get("error") or lr.get("licence", {}).get("state")})
        lic = altium_detect.licence_state()
    if lic.get("licensed"):
        return {"success": True, "alreadyLicensed": True, "licenceState": lic.get("state"),
                "steps": steps, "summary": f"Already licensed: {lic.get('state')}."}
    # try to use an available seat
    used = h_license({"action": "use_license"})
    steps.append({"use_license": used.get("licenceStateAfter") or used.get("licenceState")})
    time.sleep(2.0)
    lic = altium_detect.licence_state()
    if lic.get("licensed"):
        return {"success": True, "licenceState": lic.get("state"), "steps": steps,
                "summary": f"Acquired a seat: {lic.get('state')}."}
    # no seat: it is leased elsewhere (crash/other machine)
    if args.get("allowSignInReset"):
        so = h_license({"action": "sign_out"})
        steps.append({"sign_out": so.get("licenceStateAfter")})
        return {"success": False, "errorCode": "signed_out_for_reset", "steps": steps,
                "summary": "No seat was free, so I signed you out. Sign back in (browser flow) and the "
                           "server will hand the seat back; then altium_ensure_licensed again.",
                "needsHuman": "browser sign-in", "shot": so.get("shotAfter")}
    if args.get("allowTrial"):
        tr = h_license({"action": "start_trial"})
        steps.append({"start_trial": "opened the trial/workspace flow in the browser"})
        return {"success": False, "errorCode": "trial_needs_browser", "steps": steps,
                "summary": "Started the free-trial flow — finish it in the browser (name the workspace, "
                           "accept terms, Activate), then altium_ensure_licensed again.",
                "needsHuman": "browser trial activation", "shot": tr.get("shot")}
    st = h_license_status({})
    return {"success": False, "errorCode": "no_seat_available", "steps": steps,
            "summary": ("A licence exists but its single seat is in use (a prior or crashed session still "
                        "holds it). Retry shortly, or call again with allowSignInReset:true to Sign Out + "
                        "back in, which forces the server to release it."),
            "seatStuck": True, "shot": st.get("shot")}


def h_setup_trial(args):
    """Onboarding: get a brand-new user from nothing to a working Altium trial. Detects install,
    launch, sign-in and licence in order and returns the ONE next action, so the AI can walk any
    Adom user through it. The pieces that need a human (the elevated installer's EULA click per the
    UIPI wall, and the browser sign-in / trial activation) are named, not faked."""
    inst = _install()
    if not inst:
        return {"success": False, "stage": "install",
                "summary": "Altium Designer is not installed. Use the altium-install skill: it downloads "
                           "AltiumDesignerSetup, verifies the signature and drives the wizard — but the "
                           "installer self-elevates, so a non-elevated bridge cannot click past the EULA "
                           "(UIPI wall); the user clicks Next there.",
                "needsHuman": "installer EULA click", "nextAction": "run the altium-install skill"}
    lic = altium_detect.licence_state()
    if not lic.get("running"):
        return {"success": True, "stage": "launch", "installed": True,
                "summary": "Altium is installed but not running. Launching it next.",
                "nextAction": "altium_launch (or altium_ensure_licensed)"}
    ss = h_signin_status({})
    signed_in = bool(ss.get("account") or ss.get("user") or ss.get("signedIn"))
    if not signed_in and lic.get("licensed") is False:
        return {"success": False, "stage": "signin", "installed": True, "running": True,
                "summary": "Altium is open but no account is signed in. Sign in at the account menu "
                           "(browser flow) with a free Altium account, then start the trial.",
                "needsHuman": "browser sign-in", "nextAction": "sign in, then altium_setup_trial again"}
    if lic.get("licensed"):
        return {"success": True, "stage": "done", "licenceState": lic.get("state"),
                "summary": f"Already set up and licensed: {lic.get('state')}."}
    # signed in, not licensed -> start the trial
    return h_ensure_licensed({"allowTrial": True})


def h_place_on_sch(args):
    g = _need_licensed()
    if g:
        return g
    if not args.get("libraryPath"):
        return {"success": False, "error": "libraryPath (.SchLib) required"}
    agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    r = agent.call("place_on_sch", {"sch": args.get("schPath", ""), "libref": args.get("symbolName", "CH340G")}, timeout=float(args.get("timeout", 90)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    return r


def h_place_on_pcb(args):
    g = _need_licensed()
    if g:
        return g
    if not args.get("libraryPath"):
        return {"success": False, "error": "libraryPath (.PcbLib) required"}
    agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    r = agent.call("place_on_pcb", {"pcb": args.get("pcbPath", ""), "footprint": args.get("footprintName", "")}, timeout=float(args.get("timeout", 90)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    return r


def h_pcb_report(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("path"):
        agent.call("open", {"path": args["path"]}, timeout=60)
    return agent.call("pcb_report", {"name": args.get("name", ""), "want": args.get("want", "")}, timeout=float(args.get("timeout", 60)))


def h_build_footprint(args):
    g = _need_licensed()
    if g:
        return g
    if not args.get("libraryPath") or not args.get("spec"):
        return {"success": False, "error": "libraryPath (.PcbLib) and spec (Windows path to a kicad2altium spec) are required"}
    r = agent.call("build_footprint", {"libraryPath": args["libraryPath"], "spec": args["spec"]}, timeout=float(args.get("timeout", 240)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    return r


def h_build_symbol_modes(args):
    g = _need_licensed()
    if g:
        return g
    if not args.get("libraryPath") or not args.get("spec"):
        return {"success": False, "error": "libraryPath (.SchLib) and spec (Windows path to a symbol_modes spec) are required"}
    r = agent.call("build_symbol_modes", {"libraryPath": args["libraryPath"], "spec": args["spec"]}, timeout=float(args.get("timeout", 240)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    return r


def h_symbol_mode(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("libraryPath"):
        agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    mode = int(args.get("mode", 0))
    # The editor paints its OWN mode: Comp.DisplayMode changes data only (measured
    # 2026-08-30, frames identical). Drive the editor's process: Mode0, then Next x mode.
    agent.call("run_process", {"process": "Sch:ToggleComponentDisplayMode", "params": "Action=Mode0"}, timeout=30)
    for _ in range(mode):
        agent.call("run_process", {"process": "Sch:ToggleComponentDisplayMode", "params": "Action=Next"}, timeout=30)
    r = agent.call("symbol_mode", {"mode": mode}, timeout=float(args.get("timeout", 60)))
    agent.call("run_process", {"process": "Sch:Zoom", "params": "Action=All"}, timeout=30)
    time.sleep(0.8)
    r["shot"] = _shot_of_main() if r.get("success") else None
    r["windowEtiquette"] = "background"
    return r


def h_add_models(args):
    """Attach N PCB footprint models (Altium's footprint-variant mechanism) + parameters to a SchLib component."""
    g = _need_licensed()
    if g:
        return g
    if not args.get("libraryPath") or not args.get("symbolName"):
        return {"success": False, "error": "libraryPath (.SchLib) and symbolName are required"}
    models = args.get("models") or []   # [{name, pcblib, description}]
    spec = ";".join(f"{m.get('name','')}|{m.get('pcblib','')}|{(m.get('description') or '').replace(';',',').replace('|','/')}" for m in models)
    params = args.get("params") or {}
    pspec = ";".join(f"{k}={str(v).replace(';',',')}" for k, v in params.items())
    r = agent.call("add_models", {"libraryPath": args["libraryPath"], "name": args["symbolName"], "models": spec,
                                  "params": pspec, "comment": args.get("comment", ""), "description": args.get("description", "")},
                   timeout=float(args.get("timeout", 120)))
    r["shot"] = _shot_of_main() if r.get("success") else None
    return r


def h_normalize_bodies(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("libraryPath"):
        agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    return agent.call("normalize_bodies", {"name": args.get("footprintName", "")}, timeout=float(args.get("timeout", 120)))


def h_test_board(args):
    g = _need_licensed()
    if g:
        return g
    agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    r = agent.call("test_board", {"footprint": args.get("footprintName", ""), "wMm": args.get("wMm", 15), "hMm": args.get("hMm", 15),
                                  "pcb": args.get("pcbPath", ""), "assigns": args.get("assigns", ""), "stubs": args.get("stubs", "")},
                   timeout=float(args.get("timeout", 240)))
    r["windowEtiquette"] = "background"
    return r


def h_swap_footprint(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("pcbPath"):
        agent.call("open", {"path": args["pcbPath"]}, timeout=60)
    r = agent.call("swap_footprint", {"footprint": args.get("footprintName", ""), "assigns": args.get("assigns", ""), "libraryPath": args.get("libraryPath", "")},
                   timeout=float(args.get("timeout", 240)))
    r["windowEtiquette"] = "background"
    return r


def h_board_shape(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("pcbPath"):
        agent.call("open", {"path": args["pcbPath"]}, timeout=60)
    return agent.call("board_shape", {"x0Mm": args.get("x0Mm", 0), "y0Mm": args.get("y0Mm", 0), "wMm": args.get("wMm", 15), "hMm": args.get("hMm", 15)}, timeout=float(args.get("timeout", 120)))


def h_remove_symbol(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("libraryPath"):
        agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    return agent.call("remove_symbol", {"name": args.get("symbolName", "")}, timeout=float(args.get("timeout", 60)))


def _sch_lib_verb(args, cmd, params, tkey="timeout", tdef=90):
    g = _need_licensed()
    if g:
        return g
    if args.get("libraryPath"):
        agent.call("open", {"path": args["libraryPath"]}, timeout=60)
    return agent.call(cmd, params, timeout=float(args.get(tkey, tdef)))


def h_clone_symbol(args):
    return _sch_lib_verb(args, "clone_symbol", {"from": args.get("from", ""), "to": args.get("to", "")})


def h_rename_symbol(args):
    return _sch_lib_verb(args, "rename_symbol", {"from": args.get("from", ""), "to": args.get("to", "")})


def h_read_symbol(args):
    return _sch_lib_verb(args, "read_symbol", {"name": args.get("symbolName", "")})


def h_copy_footprint(args):
    return _sch_lib_verb(args, "copy_footprint", {"from": args.get("from", ""), "to": args.get("to", "")})


def h_rename_footprint(args):
    return _sch_lib_verb(args, "rename_footprint", {"from": args.get("from", ""), "to": args.get("to", "")})


def h_remove_footprint(args):
    return _sch_lib_verb(args, "remove_footprint", {"name": args.get("footprintName", "")})


def h_cleanup_libs(args):
    return _sch_lib_verb(args, "cleanup_libs", {})


def h_set_visibility(args):
    return _sch_lib_verb(args, "set_visibility", {"name": args.get("symbolName", ""), "param": args.get("param", ""), "visible": "1" if args.get("visible", True) else "0"})


def h_xcopy_footprint(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("toLib"):
        agent.call("open", {"path": args["toLib"]}, timeout=60)
    return agent.call("xcopy_footprint", {"fromLib": args.get("fromLib", ""), "from": args.get("from", ""), "to": args.get("to", args.get("from", ""))}, timeout=float(args.get("timeout", 120)))


def h_import_part(args):
    """Barrett's fat verb, on the fast lane: one call imports a whole part.
    Args: schLib, pcbLib, symbolSpec (symbol_modes spec path) OR pins, footprintSpec
    (kicad2altium spec path), step, params{}, comment, description, modelName."""
    g = _need_licensed()
    if g:
        return g
    steps = []
    fp_name = None
    if args.get("footprintSpec"):
        r = agent.call("build_footprint", {"libraryPath": args["pcbLib"], "spec": args["footprintSpec"]}, timeout=240)
        if not r.get("success"):
            return {"success": False, "failedAt": "build_footprint", **r}
        fp_name = (r.get("data") or {}).get("footprint")
        steps.append({"build_footprint": fp_name})
    if args.get("step") and fp_name:
        r = agent.call("add_body", {"libraryPath": args["pcbLib"], "name": fp_name, "step": args["step"],
                                    "bw_mil": 100, "bh_mil": 100, "h_mil": 40, "so_mil": 0}, timeout=180)
        steps.append({"add_body": bool(r.get("success"))})
        agent.call("normalize_bodies", {"name": fp_name}, timeout=90)
    sym_name = args.get("symbolName")
    if args.get("symbolSpec"):
        agent.call("open", {"path": args["schLib"]}, timeout=60)
        r = agent.call("build_symbol_modes", {"libraryPath": args["schLib"], "spec": args["symbolSpec"]}, timeout=240)
        if not r.get("success"):
            return {"success": False, "failedAt": "build_symbol_modes", "steps": steps, **r}
        sym_name = (r.get("data") or {}).get("libref")
        steps.append({"build_symbol_modes": sym_name})
    if sym_name and fp_name:
        # Sanitise exactly like h_add_models does: the agent splits models on ';' and fields on '|',
        # so an unescaped separator in a DESCRIPTION silently truncates the model spec — and the
        # step used to report a bare bool, which hid whether 0 or 1 models actually attached.
        desc = (args.get("description") or "")[:80].replace(";", ",").replace("|", "/")
        r = agent.call("add_models", {"libraryPath": args["schLib"], "name": sym_name,
                                      "models": f"{fp_name}|{args.get('pcbLib','')}|{desc}",
                                      "params": ";".join(f"{k}={str(v).replace(';', ',')}"
                                                         for k, v in (args.get("params") or {}).items()),
                                      "comment": args.get("comment", sym_name), "description": args.get("description", "")}, timeout=120)
        d = r.get("data") or {}
        steps.append({"add_models": bool(r.get("success")), "models": d.get("models"), "params": d.get("params")})
        if r.get("success") and not d.get("models"):
            # attaching ZERO models is a silent failure: the symbol places with no footprint
            return {"success": False, "failedAt": "add_models", "steps": steps,
                    "error": "add_models attached 0 footprint models — the symbol would place with no "
                             "footprint. Check the model spec separators (';' between models, '|' between "
                             "name|pcblib|description).", **r}
    for lib in (args.get("pcbLib"), args.get("schLib")):
        if lib:
            agent.call("open", {"path": lib}, timeout=60)
            agent.call("run_process", {"process": "WorkspaceManager:SaveObject", "params": "ObjectKind=FocusedDocument"}, timeout=60)
    return {"success": True, "symbol": sym_name, "footprint": fp_name, "steps": steps,
            "_hint": "One-call part import (Barrett's import_part on the resident lane). Verify with altium_read_symbol + altium_pcb_report."}


def h_install_library_bundle(args):
    """The no-AI web-control lane, Altium edition — mirrors kicad_install_library_bundle.
    Unpacks a staged zip (<Lib>.SchLib + <Lib>.PcbLib [+ <Lib>.IntLib], regardless of a
    wrapping folder), installs it into the user's Installed Libraries, and (capture:true)
    returns a proof frame of the library. Hardened for untrusted zips. The bridge runs on the
    user's Windows box, so unpack is a local zipfile op — no shell, no callback."""
    import os, re, zipfile, base64
    g = _need_licensed()
    if g:
        return g
    zp = args.get("zipPath")
    if not zp:
        return {"success": False, "error": "zipPath is required (stage the bundle with write_file / write_file_chunk first)"}
    name = args.get("libraryName") or ""
    if name and not re.match(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$", name):
        return {"success": False, "error": "libraryName must match ^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$"}
    if not os.path.isfile(zp):
        return {"success": False, "failedAt": "unpack", "error": f"staged zip not found: {zp}"}
    if os.path.getsize(zp) > 268435456:
        return {"success": False, "failedAt": "unpack", "error": "zip over 256MB"}
    dest = os.path.join(os.environ.get("PUBLIC", r"C:\Users\Public"), "AdomAltiumLibs",
                        re.sub(r"[^A-Za-z0-9_.-]", "_", (name or os.path.splitext(os.path.basename(zp))[0])))
    try:
        import shutil
        if os.path.isdir(dest):
            shutil.rmtree(dest, ignore_errors=True)
        os.makedirs(dest, exist_ok=True)
        destroot = os.path.realpath(dest)
        total = 0
        with zipfile.ZipFile(zp) as z:
            for info in z.infolist():
                total += info.file_size
                if total > 536870912:
                    return {"success": False, "failedAt": "unpack", "error": "unpacked over 512MB"}
                nm = info.filename.replace("\\", "/")
                if nm.startswith("/") or ".." in nm.split("/") or (len(nm) > 1 and nm[1] == ":"):
                    return {"success": False, "failedAt": "unpack", "error": f"zip slip: {info.filename}"}
                target = os.path.realpath(os.path.join(dest, *nm.split("/")))
                if not (target == destroot or target.startswith(destroot + os.sep)):
                    return {"success": False, "failedAt": "unpack", "error": f"zip slip: {info.filename}"}
            z.extractall(dest)
    except zipfile.BadZipFile:
        return {"success": False, "failedAt": "unpack", "error": "not a valid zip"}
    libs = []
    for root, _d, files in os.walk(dest):
        for f in files:
            if f.lower().endswith((".schlib", ".pcblib", ".intlib")):
                libs.append(os.path.join(root, f))
    schlibs = [l for l in libs if l.lower().endswith(".schlib")]
    pcblibs = [l for l in libs if l.lower().endswith(".pcblib")]
    intlibs = [l for l in libs if l.lower().endswith(".intlib")]
    if not libs:
        return {"success": False, "failedAt": "unpack", "error": "bundle holds no .SchLib/.PcbLib/.IntLib"}
    installed = []
    for lib in (intlibs or (schlibs + pcblibs)):
        winpath = lib.replace("/", "\\")
        ir = agent.call("install_library", {"path": winpath}, timeout=120)
        installed.append({"lib": os.path.basename(lib), "ok": bool(ir.get("success"))})
    st = agent.call("state", {}, timeout=30) or {}
    running = bool(st.get("windows") or (st.get("data") or {}).get("windows"))
    out = {"success": True, "library": name or os.path.splitext(os.path.basename(zp))[0],
           "symbols": len(schlibs), "footprints": len(pcblibs), "intlibs": len(intlibs),
           "installed": installed, "altiumRunning": running, "restartNeeded": running,
           "_hint": "Registered in Installed Libraries. A running Altium predates the install and "
                    "needs a restart to see the new library — the page/AI tells the user; the verb "
                    "never closes their Altium."}
    if args.get("capture") and schlibs and not running:
        agent.call("show_library", {"path": schlibs[0].replace("/", "\\")}, timeout=90)
        shot = _shot_of_main()
        if shot and shot.get("image"):
            out["thumbnailDataUrl"] = "data:image/png;base64," + shot["image"]
            out["shot"] = shot
        if args.get("closeEditor"):
            agent.call("run_process", {"process": "Client:CloseDocument", "params": ""}, timeout=30)
    elif args.get("capture"):
        out["proofSkipped"] = "Altium was already running; its library tables predate this install."
    return out

def _shell(command, timeout=60):
    """Run a Windows shell command on the box via ab; returns {ok, stdout, error}."""
    try:
        import caller_identity, json as _json
        r = caller_identity.ad_callback("shell_execute", {"command": command, "timeoutSeconds": int(timeout),
                                        "reason": "altium library bundle unpack"}, timeout=timeout + 10)
        if isinstance(r, str):
            try:
                r = _json.loads(r)
            except Exception:
                return {"ok": True, "stdout": r}
        if isinstance(r, dict):
            # ab may nest the CLI envelope under "output" as a JSON string
            if isinstance(r.get("output"), str) and "stdout" not in r:
                try:
                    r = {**r, **_json.loads(r["output"])}
                except Exception:
                    pass
            out = r.get("stdout") or r.get("output") or ""
            return {"ok": r.get("exitCode", 0) == 0 or bool(out), "stdout": out, "error": r.get("stderr") or r.get("error")}
        return {"ok": True, "stdout": str(r)}
    except Exception as e:
        return {"ok": False, "error": str(e)[:200]}


def _ps(script, timeout=60):
    """Run a PowerShell script on the box (base64 UTF-16LE -EncodedCommand, so quoting is safe)."""
    import base64
    enc = base64.b64encode(script.encode("utf-16-le")).decode()
    return _shell(f"powershell -NoProfile -EncodedCommand {enc}", timeout=timeout)


# ---- Phase 1: board introspection (read-only; parity with coffeenmusic/eda-agent) ----
def _board_read(cmd, args, params=None, timeout=90):
    g = _need_licensed()
    if g:
        return g
    r = agent.call(cmd, params or {}, timeout=float(args.get("timeout", timeout)))
    if not r.get("success", True) and "no current PCB board" in (r.get("error") or ""):
        r["_hint"] = "Open a .PcbDoc first (altium_open_document / altium_show_2d_board), then retry."
    return r


def h_list_designators(args):
    return _board_read("list_designators", args)


def h_component_data(args):
    return _board_read("component_data", args, {"designator": args.get("designator", ""), "limit": str(args.get("limit", 500))})


def h_component_pins(args):
    if not args.get("designator"):
        return {"success": False, "error": "designator is required (e.g. U1)"}
    return _board_read("component_pins", args, {"designator": args["designator"]})


def h_list_nets(args):
    return _board_read("list_nets", args)


def h_layers(args):
    return _board_read("layers", args)


def h_net_connections(args):
    return _board_read("net_connections", args, {"net": args.get("net", ""), "limit": str(args.get("limit", 4000))}, timeout=120)


def h_schematic_data(args):
    g = _need_licensed()
    if g:
        return g
    r = agent.call("schematic_data", {"designator": args.get("designator", ""), "limit": str(args.get("limit", 500)),
                                       "pins": "0" if args.get("pins") is False else "1"}, timeout=float(args.get("timeout", 120)))
    if "no current schematic" in (r.get("error") or ""):
        r["_hint"] = "Open a .SchDoc first (altium_open_document / altium_show_schematic), then retry."
    return r


def h_copper_layers(args):
    return _board_read("copper_layers", args)


def h_read_rules(args):
    return _board_read("read_rules", args)


def h_set_layer_visibility(args):
    g = _need_licensed()
    if g:
        return g
    if args.get("layerId") is None:
        return {"success": False, "error": "layerId is required (from altium_layers / altium_copper_layers)"}
    return agent.call("set_layer_visibility", {"layerId": str(args["layerId"]), "displayed": "0" if args.get("displayed") is False else "1"}, timeout=float(args.get("timeout", 60)))


def h_create_net_class(args):
    g = _need_licensed()
    if g:
        return g
    if not args.get("name") or not args.get("nets"):
        return {"success": False, "error": "name and nets (list or 'A;B;C') are required"}
    nets = args["nets"]
    if isinstance(nets, list):
        nets = ";".join(str(n) for n in nets)
    r = agent.call("create_net_class", {"name": args["name"], "nets": nets}, timeout=float(args.get("timeout", 60)))
    r.setdefault("_hint", "Net class added to the board (unsaved). altium_save to persist; altium_read_rules to scope rules to it.")
    return r


def h_footprint_primitives(args):
    if not args.get("designator"):
        return {"success": False, "error": "designator is required (a placed component, e.g. U1)"}
    return _board_read("footprint_primitives", args, {"designator": args["designator"]}, timeout=120)


def _todo(name, phase):
    def h(args):
        return {"success": False, "errorCode": "not_implemented", "verb": f"altium_{name}",
                "_hint": f"Planned for phase {phase} of the altium-bridge plan. Today: altium_eval / altium_run_command can do it by hand."}
    return h


_VERB_CATALOG = {
    "readiness": "Read-only: installed / running / licensed / agent alive. Zero side effects.",
    "status": "readiness + windows + dialogs (ab polls this after a timeout).",
    "describe": "This catalog.",
    "diagnostics": "Env, installs, pids, windows, agent state, ab callback availability.",
    "window_info": "Every window owned by X2.exe + open dialogs (by PID, never by title).",
    "state": "Screenshot array of every Altium window (base64) + logical open-document state.",
    "check_dialogs": "Open dialogs with body text, buttons, category and a safe default.",
    "dismiss_dialogs": "Press a dialog button in the background {hwnd, button}; safeOnly:true limits to catalogued safe defaults.",
    "launch": "Start X2.exe and wait for the main window (~60s cold).",
    "close": "WM_CLOSE the main window; reports a save prompt instead of answering it; force:true taskkills.",
    "activate_document": "Make an ALREADY-OPEN document current by invoking its document-bar tab through UIA — no cursor, no foreground.",
    "layers_in_use": "How many layers the board ACTUALLY has: copper layers carrying primitives, densest first (a floor, not the stack).",
    "dialog_log": "What the standing dialog reaper has pre-empted, so nuisance modals never reach the user.",
    "read_board": "The raw board read (components, nets, layers, copper, rules, occupancy, outline) in ONE round trip.",
    "review_board": "Design review over one board read: placeholder values, single-pin nets, unused mechanical layers, off-grid parts, duplicate designators.",
    "read_schematic": "Components + pins of the CURRENT schematic sheet (one round trip).",
    "cross_check": "Schematic sheet vs board: what is drawn but not placed, and placed but not drawn.",
    "instances": "Every running Altium (Designer, Designer Develop, ...) with the pid/key to address it. Other verbs take instance:<pid|key|word>.",
    "agent_start": "Install + bootstrap the resident DelphiScript agent via X2 -R RunScript; waits for heartbeat.",
    "agent_stop": "Drop the stop file; loop exits.",
    "agent_status": "Heartbeat age, request count, workspace.",
    "license_status": "Plain-language 'what licence am I on?' — structured state + the License Management capture (product/Trial/days-left/seat/code live on the page image).",
    "ensure_licensed": "One call to make Altium usable: launch if needed, use an available seat, else report the stuck seat; {allowSignInReset} Sign Out to reclaim it, {allowTrial} start the free trial.",
    "setup_trial": "Onboard a new user end to end: install→launch→sign in→trial, returning the ONE next action; names the human-only steps (installer EULA, browser sign-in).",
    "agent_recover": "Un-halt the script engine: press OK on the script Error modal, X2 -REditScript:Stop, clear stale files.",
    "eval": "Run DelphiScript STATEMENTS (Begin…End;). Through the agent when resident, else X2 -R RunScriptText.",
    "run_command": "RunProcess any of the 1,109 Server:Command entries {command, params:'A=1|B=2'}.",
    "run_script": "One-shot RunScript {prjscr, proc} via X2 -R — only when the agent is NOT resident.",
    "list_commands": "Search the process-launcher catalog parsed from Altium's .rcs files {query, server}.",
    "open_document": "Open a document/project by absolute path through the agent (background).",
    "progress": "Live measured progress blocks + per-phase history.",
    "uninstall": "Remove everything this bridge created (agent workspace, caches).",
    "show_project": "Open a .PrjPcb (default: the shipped Bluetooth Sentinel example) in the background and return a frame.",
    "show_schematic": "Open a .SchDoc, Sch:Zoom All, return a frame.",
    "show_2d_board": "Open a .PcbDoc, PCB:SwitchTo2D + Zoom All, return a frame.",
    "show_3d_board": "Open a .PcbDoc, PCB:SwitchTo3D + Zoom All, return a frame.",
    "show_library": "Open a .SchLib/.PcbLib (default: SpiritLevel example) and list its components.",
    "show_symbol": "Open a .SchLib and make {symbolName} current (first if omitted); zoom all; frame.",
    "show_footprint": "Open a .PcbLib and make {footprintName} current in 2D; frame.",
    "show_3d_chip": "Open a .PcbLib, make {footprintName} current, switch to 3D; frame.",
    "list_library": "Component names in a .SchLib/.PcbLib {libraryPath}.",
    "demo": "Six-beat tour (symbol → footprint → 3D part → schematic → 2D → 3D board), one beat per call: {stage}.",
    "compile": "Compile the project (ERC) and return the Messages panel {projectPath}.",
    "run_erc": "Alias of compile.",
    "run_drc": "Open a .PcbDoc, run PCB:DesignRuleCheck (dialog driven via UIA), return violations[].",
    "run_outjob": "Run an OutJob medium {outjobPath, medium:'Folder Structure', projectPath}; lists the files written.",
    "export_gerber": "Fabrication.OutJob → Folder Structure (Gerber/NC drill/ODB++ as configured).",
    "export_bom": "Assembly.OutJob → Folder Structure (BOM, pick-and-place, test points).",
    "create_footprint": "Create a footprint in a .PcbLib (created if missing): {name, libraryPath, pads:'des,x,y,w,h,rect|round,hole;...'} (mils).",
    "create_symbol": "Create a schematic symbol in a .SchLib (created if missing): {name, libraryPath, designator, bodyWidthMil, bodyHeightMil, pins:'des,name,side,offset;...'}.",
    "save": "Save the focused document (WorkspaceManager:SaveObject) {filePath}.",
    "install_library": "Register a .IntLib/.SchLib/.PcbLib/.DbLib in Installed Libraries {libraryPath}.",
    "compile_intlib": "Compile a .LibPkg to .IntLib {libpkgPath, output}.",
    "extract_intlib": "Extract editable SchLib/PcbLib sources from a .IntLib {intlibPath}.",
    "set_params": "Set parameters / comment / footprint link on a SchLib symbol {libraryPath, symbolName, params, footprint, comment}.",
    "add_body": "Add a 3D body to a PcbLib footprint {libraryPath, footprintName, step | bwMil,bhMil,hMil,soMil}.",
    "install_extension": "Install the AdomBridge .NET extension (Altium CLOSED) — enables export_step / import_kicad.",
    "extension_ping": "Prove the extension is loaded (X2 -RAdomBridge:Ping).",
    "export_step": "Headless STEP export with real holes/copper/models {filePath, output, holes, copper, models}.",
    "import_kicad": "Import a KiCad file with no wizard {filePath, mode:'Import'|'List'}.",
    "a365_configure": "Store workspace domain + an optional manually issued Bearer token {workspace, token}.",
    "a365_oauth_begin": "Start Altium public-client OAuth with PKCE + ActionWait {clientId, workspace}; returns authorizeUrl only after the listener starts.",
    "a365_oauth_status": "Sanitized state of the in-flight OAuth flow; never returns codes or tokens.",
    "a365_oauth_cancel": "Cancel the current ActionWait OAuth flow.",
    "a365_refresh": "Refresh the OAuth access token; securely handles refresh-token rotation.",
    "a365_logout": "Revoke the OAuth refresh grant and remove locally cached tokens.",
    "a365_files_upload": "Upload a component-library export to the Files Service {filePath}; returns a temporary opaque fileId for mutations.",
    "a365_status": "A365 workspace, OAuth configuration and live-token probe.",
    "a365_query": "Run a GraphQL query against https://<workspace>/api/graphql/ {query, variables}.",
    "a365_library_overview": "Read-only proof query for the workspace library.",
    "a365_library_schema": "Read-only GraphQL introspection filtered to library/component/part operations.",
    "signin_status": "Account/licence/workspace-connection state as Altium reports it.",
    "connect_workspace": "Open the Not Connected → workspace picker and return its screenshot.",
    "license": "Show/drive the License Management page {action: show|refresh|use_license|start_trial|sign_out} (WebView2 → image clicks).",
}

COMMAND_HANDLERS = {
    "readiness": h_readiness, "status": h_status, "diagnostics": h_diagnostics,
    "window_info": h_window_info, "state": h_state, "check_dialogs": h_check_dialogs,
    "dismiss_dialogs": h_dismiss_dialogs, "launch": h_launch, "close": h_close,
    "activate_document": h_activate_document, "layers_in_use": h_layers_in_use,
    "instances": h_instances, "review_board": h_review_board, "read_board": h_read_board,
    "dialog_log": h_dialog_log, "read_schematic": h_read_schematic, "cross_check": h_cross_check,
    "agent_start": h_agent_start, "agent_stop": h_agent_stop, "agent_status": h_agent_status,
    "agent_recover": h_agent_recover,
    "eval": h_eval, "run_command": h_run_command, "run_script": h_run_script,
    "list_commands": h_list_commands, "open_document": h_open_document, "open_project": h_open_document,
    "progress": h_progress, "uninstall": h_uninstall,
    "show_project": h_show_project, "show_schematic": h_show_schematic,
    "show_2d_board": h_show_2d_board, "show_3d_board": h_show_3d_board,
    "show_library": h_show_library, "show_symbol": h_show_symbol, "show_footprint": h_show_footprint,
    "show_3d_chip": h_show_3d_chip, "list_library": h_list_library, "demo": h_demo,
    "compile": h_compile, "run_erc": h_run_erc, "run_drc": h_run_drc, "run_outjob": h_run_outjob,
    "export_gerber": h_export_gerber, "export_bom": h_export_bom,
    "create_footprint": h_create_footprint, "create_symbol": h_create_symbol, "save": h_save,
    "install_library": h_install_library, "install_library_bundle": h_install_library_bundle,
    "license_status": h_license_status, "ensure_licensed": h_ensure_licensed, "setup_trial": h_setup_trial, "compile_intlib": h_compile_intlib, "extract_intlib": h_extract_intlib,
    "set_params": h_set_params, "add_body": h_add_body,
    "place_on_sch": h_place_on_sch, "place_on_pcb": h_place_on_pcb, "pcb_report": h_pcb_report, "list_designators": h_list_designators, "component_data": h_component_data, "component_pins": h_component_pins, "list_nets": h_list_nets, "layers": h_layers, "net_connections": h_net_connections, "schematic_data": h_schematic_data, "copper_layers": h_copper_layers, "read_rules": h_read_rules, "set_layer_visibility": h_set_layer_visibility, "footprint_primitives": h_footprint_primitives, "build_footprint": h_build_footprint, "build_symbol_modes": h_build_symbol_modes, "add_models": h_add_models, "remove_symbol": h_remove_symbol, "clone_symbol": h_clone_symbol, "rename_symbol": h_rename_symbol, "read_symbol": h_read_symbol, "copy_footprint": h_copy_footprint, "rename_footprint": h_rename_footprint, "remove_footprint": h_remove_footprint, "cleanup_libs": h_cleanup_libs, "set_visibility": h_set_visibility, "xcopy_footprint": h_xcopy_footprint, "import_part": h_import_part, "normalize_bodies": h_normalize_bodies, "test_board": h_test_board, "board_shape": h_board_shape, "swap_footprint": h_swap_footprint, "symbol_mode": h_symbol_mode,
    "install_extension": h_install_extension, "extension_ping": h_extension_ping,
    "export_step": h_export_step, "import_kicad": h_import_kicad,
    "a365_configure": h_a365_configure,
    "a365_oauth_begin": h_a365_oauth_begin, "a365_oauth_status": h_a365_oauth_status,
    "a365_oauth_cancel": h_a365_oauth_cancel, "a365_refresh": h_a365_refresh,
    "a365_logout": h_a365_logout, "a365_status": h_a365_status, "a365_query": h_a365_query,
    "a365_files_upload": h_a365_files_upload,
    "a365_library_overview": h_a365_library_overview, "a365_library_schema": h_a365_library_schema,
    "signin_status": h_signin_status, "connect_workspace": h_connect_workspace, "license": h_license,
    "signin": h_signin_status,
}
for _n, _p in (("show_symbol", 3), ("show_footprint", 3), ("show_3d_chip", 3), ("show_library", 3),
               ("show_schematic", 3), ("show_2d_board", 3), ("show_3d_board", 3), ("show_project", 3),
               ("run_outjob", 4), ("run_drc", 4), ("run_erc", 4), ("export_gerber", 4), ("export_step", 4),
               ("export_pdf", 4), ("export_bom", 4), ("create_symbol", 4), ("create_footprint", 4),
               ("list_library", 4), ("signin", 5), ("license", 5), ("a365_status", 5), ("a365_query", 5),
               ("demo", 3), ("close_document", 3), ("save", 3), ("compile", 4), ("screenshot_all", 1)):
    COMMAND_HANDLERS.setdefault(_n, _todo(_n, _p))


def h_describe(args):
    return {"success": True, "bridgeVersion": BRIDGE_VERSION, "prefix": "altium_",
            "verbs": {f"altium_{k}": v for k, v in _VERB_CATALOG.items()},
            "planned": sorted(f"altium_{k}" for k in COMMAND_HANDLERS if k not in _VERB_CATALOG),
            "_hint": "Start with altium_readiness. Facts: one script at a time; a modal freezes everything; RunScriptText is statements-only."}


COMMAND_HANDLERS["describe"] = h_describe


def dispatch_command(command: str, args: dict) -> dict:
    handler = COMMAND_HANDLERS.get(command)
    if handler is None and "_" in command:
        tail = command.split("_", 1)[1]
        if tail in COMMAND_HANDLERS:
            command, handler = tail, COMMAND_HANDLERS[tail]
    if handler is None:
        return {"success": False, "error": f"Unknown command: {command}",
                "_hint": f"Valid: {', '.join(sorted(COMMAND_HANDLERS))}"}
    # ---- instance selection (John, 2026-09-01: Designer + Designer Develop, two AI threads) ----
    # Resolved ONCE, centrally, so all 96 verbs are instance-aware without touching any of them.
    # Verbs that describe the machine rather than drive an Altium are exempt — `instances` itself
    # must work when several are running, which is exactly when resolution refuses.
    instance_exempt = command in ("instances", "describe", "diagnostics", "progress", "state", "uninstall")
    if not instance_exempt:
        sel = (args or {}).get("instance")
        r = altium_detect.resolve_instance(sel)
        if r.get("error"):
            # not-running stays a per-verb concern (many verbs answer usefully without Altium);
            # AMBIGUITY must stop here, or one thread's verb lands in another thread's Altium
            if r.get("errorCode") in ("ambiguous_instance", "instance_not_found"):
                return {"success": False, **r}
            agent.use_instance(None)
        else:
            agent.use_instance(r["instance"])
    else:
        agent.use_instance(None)
    t0 = time.time()
    driving = command not in ("readiness", "status", "describe", "diagnostics", "window_info", "check_dialogs",
                              "progress", "agent_status", "list_commands", "state")
    stopper = None
    if driving:
        try:
            from handlers import taskbar
            taskbar.set_driving_badge()
            taskbar.note_driving()  # feeds the 5-min idle clock that auto-clears the overlay
            est, conf = progress.estimate(command, "warm")
            if est and est >= 3:
                stopper = taskbar.start_activity_progress(command, est, conf)
        except Exception:
            pass
    ok = True
    try:
        result = handler(args or {})
        ok = bool(isinstance(result, dict) and result.get("success", True))
    finally:
        try:
            progress.log_run(command, time.time() - t0, ok)
        except Exception:
            pass
        if driving:
            try:
                from handlers import taskbar
                if stopper is not None:
                    taskbar.end_activity_progress(command, failed=not ok)
            except Exception:
                pass
    if isinstance(result, dict):
        result.setdefault("bridgeVersion", BRIDGE_VERSION)
    return result


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path in ("/health", "/status"):
            lic = altium_detect.licence_state()
            inst = _install()
            if not inst:
                led, summary = "yellow", "Altium not detected"
            elif not lic.get("running"):
                led, summary = "yellow", "Altium not running"
            elif lic.get("licensed") is False:
                led, summary = "yellow", f"Altium {lic.get('state')}"
            elif agent.is_alive():
                led, summary = "green", "Altium + agent ready"
            else:
                led, summary = "green", "Altium running (agent idle)"
            self._respond(200, {"status": "ok", "led": led, "summary": summary,
                                "tooltip": f"altium-bridge {BRIDGE_VERSION} - {summary}",
                                "bridgeVersion": BRIDGE_VERSION, "licence": lic})
        else:
            self._respond(404, {"error": "Not found"})

    def do_POST(self):
        if self.path != "/command":
            self._respond(404, {"error": "Not found"})
            return
        n = int(self.headers.get("Content-Length", 0))
        try:
            req = json.loads(self.rfile.read(n) or b"{}")
        except json.JSONDecodeError as e:
            self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})
            return
        command = req.get("command", "")
        args = req.get("args", {}) or {}
        caller_identity.set_from_request(self.headers, args)
        print(f"[altium] {command}{caller_identity.log_suffix()} args={json.dumps(args)[:200]}")
        try:
            self._respond(200, dispatch_command(command, args))
        except Exception as e:
            traceback.print_exc()
            self._respond(500, {"success": False, "error": f"Internal error: {e}"})
        finally:
            caller_identity.clear()

    def _respond(self, code, data):
        body = json.dumps(data).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *a):
        pass


def main():
    try:
        sys.stdout.reconfigure(line_buffering=True)
        sys.stderr.reconfigure(line_buffering=True)
    except Exception:
        pass
    port = 0
    if "--port" in sys.argv:
        port = int(sys.argv[sys.argv.index("--port") + 1])
    host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")
    # Start the standing dialog reaper BEFORE serving: Altium's startup warnings (VaultExplorer,
    # "File type not recognised") appear with no verb in flight, so nothing else would catch them.
    try:
        dialogs.start_reaper()
        print("[altium] standing dialog reaper started (known nuisances are pre-empted, never save prompts)")
    except Exception as e:
        print(f"[altium] dialog reaper failed to start: {e}")
    try:
        from handlers import taskbar as _taskbar  # dispatch imports it lazily; this scope never had it
        _taskbar.start_idle_watcher()
        print("[altium] badge idle-watcher started (overlay auto-clears after 5 min without a driving verb)")
    except Exception as e:
        print(f"[altium] badge idle-watcher failed to start: {e}")
    srv = ThreadingHTTPServer((host, port), Handler)
    print(f"[altium] bridge {BRIDGE_VERSION} listening on {host}:{srv.server_address[1]}")
    _install()
    print(f"[altium] installs: {_installs}")
    srv.serve_forever()


if __name__ == "__main__":
    main()