app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
v0.1.20: schematic read + cross_check in python, manifest drift fixed, release-time verb lint
8 files changed
+333−34
CHANGELOG.md+5@@ -1,5 +1,10 @@ # Changelog +## 0.1.20 — 2026-09-01+- **`altium_read_schematic` and `altium_cross_check` in the shipped runtime** (they existed only in the Rust bridge). `cross_check` compares the current sheet against the current board — what is drawn but not placed, and placed but not drawn — which neither read answers alone. Two caveats travel WITH the answer because both make it wrong to act on: the schematic side is ONE sheet (a hierarchical parent holds sheet symbols, not parts), and Altium's SchServer and PCBServer each hold their own "current document", so the two can belong to different projects — detected by comparing folders and reported as `sameProject: false` with a loud warning.+- **Manifest drift fixed, in both directions.** `bridge.json` declared three verbs with no handler (`close_document`, `export_pdf`, `screenshot_all`) — ab routed them and the bridge answered "Unknown command" — and omitted three that work (`ensure_licensed`, `license_status`, `setup_trial`), leaving them unreachable through ab. The phantoms are gone (they never worked; implementing them is separate work) and the three real ones are now routable.+- **The release refuses to build on drift.** `scripts/release.py` lints `bridge.json` against what `server.py` can actually dispatch and fails with both lists rather than shipping a manifest that lies. 106 verbs declared and dispatchable.+ ## 0.1.19 — 2026-09-01 - **Pre-emption, properly.** John: *"you better pre-empt these from ever showing up"*. Two changes so a script modal cannot reach the user: 1. **A script lane now refuses when ANY resident loop is alive, in ANY workspace** — not just the current instance's. That gap is exactly how "Another script executing now" got on screen after per-instance workspaces landed: the pre-upgrade loop was still resident under the legacy path, `is_alive()` looked only at `agent/<key>`, saw nothing, and fired a bootstrap into an engine that was already running a script. The refusal names the workspace and heartbeat age so the fix is obvious.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.19",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.19/adom-bridge-altium-v0.1.19.zip",- "sha256": "21da53c635db6983af65ef53b0102e76edbd3c9090af348d070262b676ee8b9e",- "size": 111520,+ "version": "0.1.20",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.20/adom-bridge-altium-v0.1.20.zip",+ "sha256": "594b1cd5a867d3f4c853ce341a12854b4e193c560c52d5683ecb3d9679941fd4",+ "size": 118220, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.20.zipadded⋯ 1 unchanged line ⋯
bridge.json+13−4@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.19",+ "version": "0.1.20", "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.", "homepage": "https://wiki.adom.inc/adom/altium-bridge", "docs": "https://wiki.adom.inc/adom/altium-bridge",@@ -22,7 +22,14 @@ ], "verbs": [ "altium_a365_configure",+ "altium_a365_library_overview",+ "altium_a365_library_schema",+ "altium_a365_logout",+ "altium_a365_oauth_begin",+ "altium_a365_oauth_cancel",+ "altium_a365_oauth_status", "altium_a365_query",+ "altium_a365_refresh", "altium_a365_status", "altium_activate_document", "altium_add_body",@@ -38,7 +45,6 @@ "altium_cleanup_libs", "altium_clone_symbol", "altium_close",- "altium_close_document", "altium_compile", "altium_compile_intlib", "altium_component_data",@@ -48,14 +54,15 @@ "altium_copy_footprint", "altium_create_footprint", "altium_create_symbol",+ "altium_cross_check", "altium_demo", "altium_describe", "altium_diagnostics", "altium_dismiss_dialogs",+ "altium_ensure_licensed", "altium_eval", "altium_export_bom", "altium_export_gerber",- "altium_export_pdf", "altium_export_step", "altium_extension_ping", "altium_extract_intlib",@@ -70,6 +77,7 @@ "altium_layers", "altium_layers_in_use", "altium_license",+ "altium_license_status", "altium_list_commands", "altium_list_designators", "altium_list_library",@@ -83,6 +91,7 @@ "altium_place_on_sch", "altium_progress", "altium_read_rules",+ "altium_read_schematic", "altium_read_symbol", "altium_readiness", "altium_remove_footprint",@@ -96,10 +105,10 @@ "altium_run_script", "altium_save", "altium_schematic_data",- "altium_screenshot_all", "altium_set_layer_visibility", "altium_set_params", "altium_set_visibility",+ "altium_setup_trial", "altium_show_2d_board", "altium_show_3d_board", "altium_show_3d_chip",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.19",+ "version": "0.1.20", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
scripts/release.py+24−1@@ -17,7 +17,7 @@ PAGE = "adom/altium-bridge" PAGE_SLUG = "altium-bridge" INCLUDE = ["server.py", "bridge.json", "BRIDGE_VERSION", "caller_identity.py",- "handlers/__init__.py", "handlers/ad_client.py", "handlers/progress.py", "handlers/uia.py",+ "handlers/__init__.py", "handlers/a365.py", "handlers/ad_client.py", "handlers/progress.py", "handlers/uia.py", "handlers/png_stdlib.py", "handlers/altium_detect.py", "handlers/agent.py", "handlers/dialogs.py", "handlers/rcs_catalog.py", "handlers/taskbar.py", "agent/AdomAgent.pas.tmpl", "agent/AdomAgent.PrjScr", "resources/adom-overlay-32.png", "extension/Install-AdomBridge.ps1", "extension/REMOVE-AdomBridge.cmd",@@ -25,7 +25,30 @@ INCLUDE = ["server.py", "bridge.json", "BRIDGE_VERSION", "caller_identity.py", "extension/AdomBridge/Help/ServerDlls.txt"] ++def lint_verbs(src_dir):+ """bridge.json's verbs[] is a PROMISE ab routes on. Drift breaks it in both directions:+ a declared verb with no handler answers "Unknown command" after ab has already routed it, and a+ working handler that is not declared is unreachable. Both were live on 2026-09-01 (3 phantom+ verbs, 3 unreachable ones), so the release now refuses rather than shipping it again."""+ import json as _json, re as _re+ src = (src_dir / "server.py").read_text(encoding="utf-8", errors="replace")+ keys = set(_re.findall(r'COMMAND_HANDLERS\["([a-z0-9_]+)"\]', src))+ m = _re.search(r'COMMAND_HANDLERS\s*=\s*\{(.*?)\n\}', src, _re.S)+ keys |= set(_re.findall(r'"([a-z0-9_]+)"\s*:\s*h_', m.group(1) if m else ""))+ declared = {v[len("altium_"):] for v in _json.loads((src_dir / "bridge.json").read_text())["verbs"]}+ phantom, unreachable = sorted(declared - keys), sorted(keys - declared)+ if phantom or unreachable:+ raise SystemExit(+ "bridge.json does not match server.py\n"+ f" declared but NOT dispatchable (ab routes them, the bridge 404s): {phantom}\n"+ f" dispatchable but NOT declared (unreachable through ab): {unreachable}")+ return len(declared)++ def main():+ n = lint_verbs(SRC)+ print(f"verb lint ok: {n} verbs declared and dispatchable") ver = json.loads((SRC / "bridge.json").read_text())["version"] DIST.mkdir(exist_ok=True) zname = f"adom-bridge-altium-v{ver}.zip"
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.19+0.1.20
src/server.py+285−23@@ -16,6 +16,7 @@ import json import os import subprocess import sys+import threading import time import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer@@ -25,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import caller_identity # noqa: E402 from handlers import uia-from handlers import ad_client, agent, altium_detect, dialogs, progress, rcs_catalog # noqa: E402+from handlers import a365, ad_client, agent, altium_detect, dialogs, progress, rcs_catalog # noqa: E402 BRIDGE_VERSION = "0.1.0" try:@@ -887,6 +888,8 @@ def h_import_kicad(args): # ---- 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():@@ -900,6 +903,23 @@ def _a365_load() -> dict: 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)."""@@ -909,7 +929,7 @@ def h_a365_configure(args): cfg[k] = args[k] if not cfg.get("workspace"): return {"success": False, "error": "workspace is required, e.g. 'john-john-3.365.altium.com'"}- _a365_cfg_path().write_text(json.dumps(cfg))+ _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."} @@ -917,12 +937,13 @@ def h_a365_configure(args): def h_a365_status(args): cfg = _a365_load() out = {"success": True, "configured": bool(cfg.get("workspace")), "workspace": cfg.get("workspace"),- "hasToken": bool(cfg.get("token")), "endpoint": f"https://{cfg.get('workspace')}/api/graphql/" if cfg.get("workspace") else None}- if not cfg.get("token"):- out["_hint"] = ("No token. Tokens are minted in the workspace web UI at Admin → Developer (Pro/Enterprise "- "workspaces). The Altium Develop TRIAL workspace shows no Developer page (measured 2026-08-29: "- "Admin has only Settings / Part Providers / Explorer / Usage and Billing / Extensions), so the "- "GraphQL API is not reachable on it. altium_a365_configure {workspace, token} once you have one.")+ "hasToken": bool(_a365_access_token(cfg)), "hasRefreshToken": bool(cfg.get("refreshToken")),+ "clientConfigured": bool(cfg.get("clientId")),+ "endpoint": f"https://{cfg.get('workspace')}/api/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"))@@ -931,26 +952,174 @@ def h_a365_status(args): def h_a365_query(args):- import urllib.request cfg = _a365_load()- if not cfg.get("workspace") or not cfg.get("token"):- return {"success": False, "errorCode": "a365_not_configured", "_hint": "altium_a365_configure {workspace, token}"}+ 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 } } }"}- body = json.dumps({"query": q, "variables": args.get("variables") or {}}).encode()- req = urllib.request.Request(f"https://{cfg['workspace']}/api/graphql/", data=body,- headers={"Content-Type": "application/json", "Authorization": f"Bearer {cfg['token']}"}) try:- with urllib.request.urlopen(req, timeout=float(args.get("timeout", 60))) as resp:- data = json.loads(resp.read().decode())- except Exception as e:- return {"success": False, "errorCode": "a365_http_error", "error": str(e)[:300],- "_hint": "401 = token expired/invalid (mint a new one); 404 = wrong workspace domain."}+ 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. john-john-3.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_library_overview(args):+ return h_a365_query({"query": "query GetLibraryOverview { desLibrary { name description updatedAt components { totalCount } 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()@@ -1137,6 +1306,86 @@ def h_instances(args): if len(found) > 1 else "One Altium running — `instance` is optional."} ++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",+ "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 the current schematic sheet against the current board.++ Neither read answers this alone, which is the point: what is drawn but not placed, and what is+ placed but not drawn. Two caveats travel WITH the answer because both make it wrong to act on:+ the schematic side is one sheet, and Altium's SchServer and PCBServer each hold their own+ "current document" — so nothing stops the two belonging to different projects."""+ g = _need_licensed()+ if g:+ return g+ sr = agent.call("read_schematic", {"sections": "components"}, timeout=float(args.get("timeout", 90)))+ sd = sr.get("data") or {}+ if not sd.get("ok"):+ return {"success": False, "stage": "schematic", "error": sd.get("error"),+ "_hint": "Activate a leaf .SchDoc (altium_activate_document), then retry."}+ br = agent.call("read_board", {"sections": "components"}, timeout=float(args.get("timeout", 90)))+ bd = br.get("data") or {}+ if not bd.get("ok"):+ return {"success": False, "stage": "board", "error": bd.get("error"),+ "_hint": "A .PcbDoc must be current — altium_activate_document."}+ sch = sorted({c.get("designator") for c in ((sd.get("components") or {}).get("components") or [])+ if c.get("designator")}, key=_natural_key)+ pcb = sorted({c.get("designator") for c in ((bd.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),+ "roundtripMs": (sr.get("roundtripMs") or 0) + (br.get("roundtripMs") or 0),+ "_scope": "The schematic side is the CURRENT SHEET only. On a hierarchical design, read every "+ "leaf sheet before calling anything 'missing from the schematic'."}+ if same is False:+ out["_warning"] = ("The current sheet and the current board are in DIFFERENT folders, so they are "+ "probably not the same project — which makes every difference above meaningless. "+ "Activate the right documents and re-run.")+ return out++ # ---- licence lifecycle: status, ensure-licensed, trial setup (John 2026-08-31) ------------ def h_license_status(args):@@ -1704,6 +1953,8 @@ _VERB_CATALOG = { "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).",+ "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.",@@ -1747,9 +1998,16 @@ _VERB_CATALOG = { "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 + Bearer token for the Altium 365 GraphQL API {workspace, token}.",- "a365_status": "Is A365 configured, is the token live (probes desProjects).",+ "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_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).",@@ -1760,7 +2018,7 @@ COMMAND_HANDLERS = { "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,+ "instances": h_instances, "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,@@ -1779,7 +2037,11 @@ COMMAND_HANDLERS = { "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_status": h_a365_status, "a365_query": h_a365_query,+ "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_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, }