← Commit history
CHANGELOG.md+6
@@ -1,5 +1,11 @@ # Changelog +## 0.1.9 — 2026-08-31+- #2/PR #64 (Colby): detect the ADDevelop build — _TITLE_RE now accepts an optional edition word (Develop/Viewer/…) and optional version parens, with a guard so a bare mid-string "Altium Designer" in a dialog cannot pose as the main window. Proven against six title shapes.+- #3 (Colby, partial): the -R helpers now reap Altium's single-instance "another instance is busy" notice in the background (both RunScript/RunScriptText lanes and recover), so a stray helper never leaves that modal on the user's screen.+- Repo now carries the FULL source tree (handlers/, agent/, extension/, tools/) — previously only src/server.py was pushed, forcing PRs against the release zip.++ ## 0.1.8 — 2026-08-31 - FIX: bridge.json verbs[] must be plain strings — 0.1.5–0.1.7 shipped four entries as objects, which ab's bridge_registry could not parse (the bridge vanished from the registry after its next reap while bridge_install still reported ok). Release assets are now uploaded explicitly (release create --binary-path does not attach). 
PLAN.mdadded+327
@@ -0,0 +1,327 @@+---+name: altium-bridge-plan+user-invocable: false+description: >-+  THE plan of record for the Altium Designer bridge (keyword `altium`, verbs `altium_*`) —+  every automation surface Altium exposes, measured on a real AD 26.9.1 install, ranked into+  lanes: the ScriptingSystem:RunScriptText eval, the resident DelphiScript agent (the REPL lane),+  the 1,109-command process-launcher catalog, X2.EXE -R command-line bootstrap, UIA over the+  VCL/DevExpress shell (and CDP for its WebView2 pages), the Altium 365 GraphQL API + embeddable+  viewer, file-format parsers, the native SDK extension, and the sign-in/license gates. Includes+  the verb catalog, the dialog doctrine, the phase plan with acceptance criteria, and the Phase 0+  experiments still owed. Refer back here before any altium-bridge work. Trigger words — altium+  bridge, altium_ verbs, drive altium, control altium designer, altium automation, altium+  delphiscript, RunScriptText, altium process launcher, altium 365 api, altium sign in license,+  altium add-in, altium plan.+---++Parent skill: **adom-bridge-sdk-new-app** (the method) and **adom-bridge-sdk** (the contract).+Written 2026-08-29 from live probes of Altium Designer 26.9.1 on John's box (`galliaApril`+container → `AdomLapper` desktop) plus the vendor docs and the community MCP bridges.++# altium-bridge — plan of record++## 0. What we measured on the box (evidence, not guesses)++| Fact | Evidence |+|---|---|+| Install | `C:\Program Files\Altium\AD26\X2.EXE`, 26.9.1 (Build 10). `System\` holds 634 DLLs, 47 `.rcs` process tables, 71 `.ins` server descriptors. No `Examples\Scripts` shipped. |+| Process | `X2.exe` pid 54932, main window class **`TDocumentForm`**, title `License Management - Altium Designer (26.9.1) - No License`. |+| Script engine | `ScriptingSystem.dll` + `ScriptingSystem.ins` declaring commands **`RunScript`, `RunScriptFile`, `RunScriptText`**; `EditScript.dll/.rcs` (script editor); `Altium.EditScript.Interfaces.dll`; `OUT_Script.dll`. Prefs page *Scripting System → Global Projects / Form Designer*. |+| Command catalog | **3,205 process launchers → 1,109 distinct `Server:Command` strings** in the `.rcs` files: CAMTASTIC 230, PCB 211, Sch 190, TextEdit 100, WorkspaceManager 87, Client 50, SimView 44, IntegratedLibrary 28, UnifiedComponent 26, VersionControl 23, PCB3D 13, … Format: `PL <id> Command='PCB:Zoom' Params='Action=...\|...' Shortcut1='...' End`. |+| Listening ports | **none** while idle. No application COM automation server registered — only document ProgIDs (`AltiumLibrary`, `AltiumDraftsmanDocument`, …). |+| UIA | The VCL/DevExpress shell **IS exposed**: 45 nodes on the licence screen — `MainMenu` MenuItems (File/View/Project/Window/Help/Preferences…), `TGUIDocBarButton`s, `TdxTabSheet` panes, all with `automationId`s and `invokable:true`. The **Home / License Management page content is a WebView2** (`ProgramData\...\WebView2Cache` exists) — its *Start Free Trial / Get License / Sign Out* buttons are **not** in the UIA tree. |+| State dirs | `%APPDATA%\Altium\Altium Designer {ED62C102-…}\` (prefs), `%APPDATA%\Altium\AD26\`, `C:\ProgramData\Altium\Altium Designer {ED62C102-…}\Extensions\` (Altimade, Draftsman, IPC Footprint Generator, EXPORT3D, IPC2581, SchLibGenerationTool, …), `HKCU\Software\Altium\Altium Designer {ED62C102-…}`. |+| Auth state | Signed in as *John John* (Altium account), **no license**; a free *Altium Designer Viewer* seat is offered and a **15-day trial** button exists. Vendor docs: **Viewer mode prohibits scripting** → licence is a hard prerequisite for every in-app lane. |++## 1. The surfaces, ranked into lanes++### L2 — the REPL lane: `ScriptingSystem:RunScriptText` + a resident DelphiScript agent ⭐++Altium has a literal eval: process **`ScriptingSystem:RunScriptText`** with parameter+**`Text=Begin … End;`** (vendor docs, and the string is in `ScriptingSystem.dll`). Plus+`RunScript(ProjectName=…|ProcName=…)` and `RunScriptFile`. DelphiScript reaches the full DXP API+(`PCBServer`, `SchServer`, `GetWorkspace`, `Client`, `RunProcess`), so this is Fusion's+`run_modeling_script` equivalent.++Design (the same shape the two best community bridges converged on — `salitronic/eda-agent`+~400 tools, `coffeenmusic/altium-mcp`):++1. Ship `Altium_Agent.PrjScr` (+ `json_utils.pas`, `dispatcher.pas`) in the Release zip; install+   it as a **Global Project** (Scripting System prefs) so it survives restarts.+2. Bootstrap from the bridge with the CLI lane: `X2.EXE -RScriptingSystem:RunScript(ProjectName="…\Altium_Agent.PrjScr"^|ProcName="StartAgent")`.+3. `StartAgent` runs a **polling loop on the UI thread** with `Application.ProcessMessages`+   yields (eda-agent measured ~10 ms/call active, 100 ms idle polling; auto-stop after 10 min idle+   unless the bridge keep-alives every 30 s). Transport: **file drop** `request_<id>.json` →+   `response_<id>.json` under the bridge cache (works in any DelphiScript); **Phase 0 experiment**:+   `CreateOleObject('MSXML2.ServerXMLHTTP')` for a direct HTTP long-poll to the bridge.+4. `altium_eval {text}` = the agent runs `RunProcess('ScriptingSystem:RunScriptText', 'Text=…')`+   (or writes a temp `.pas` and `RunScriptFile`) and returns stdout captured to a file.+   Everything else — `get_state`, `place_component`, `run_drc`, `create_footprint` — is a canned+   eval with rich hints.++Known engine traps (all from the community bridges, all to design around, not discover again):+- **DelphiScript is untyped**: no records/classes/pointers; single-threaded; a runtime error+  ("Undeclared identifier", "Could not convert variant of type (Dispatch)") **pauses the debugger+  and every later run silently no-ops** until it is stopped. The bridge must detect this+  (heartbeat file stops updating) and recover it (UIA-click *Stop* in the Script IDE, or+  `Client:CloseDocument` the script; then re-bootstrap).+- Characters above **U+00FF become `?`** on the way in — ASCII-escape JSON.+- While the loop is resident, Altium's **own** script-driven buttons are unresponsive; expose+  `altium_agent_detach`.+- No scripted ECO executor: `proj_sync_pcb` runs the GUI ECO and blocks on its dialog → L4.++### L3 — the process-launcher lane (1,109 commands, no UI, no focus)++`RunProcess('PCB:DesignRuleCheck')`, `RunProcess('WorkspaceManager:Compile')`,+`Client:OpenDocument`, `Client:CloseDocument`, `PCB:SwitchTo3D`, `PCB:Zoom Action=All`,+`Sch:AddComponentPart`, `WorkspaceManager:GenerateReport ObjectKind=OutputBatch|Action=Run`+(the OutJob runner from Altium's own CI article). `altium_run_command {command, params}` exposes+the whole table; `altium_describe` ships the catalog parsed from the `.rcs` files on the box+(`coffeenmusic/altium-mcp` keeps one as `dev/altium_process_catalog.txt`). Deterministic and+background — prefer it over any menu click.++### L6 — the CLI lane (bootstrap + batch)++`X2.EXE "<doc>"` opens documents; `-R<Server>:<Command>(Param="v"^|Param2="v")` remote-executes+a process. Altium's CI recipe: run a script that opens `Build.OutJob`, runs the OutputBatch, then+`TerminateWithExitCode(0)`. **Phase 0 experiment:** confirm `-R…` targets the *running* instance+(single-instance forward) vs spawning a second `X2.exe`, and whether `-RScriptingSystem:RunScriptText(Text="…")`+works straight from the shell (that would make bootstrap one line).++### L4 — UIA over the VCL shell; CDP for the WebView2 pages++`desktop_ui_tree` sees menus, doc-bar buttons, panels, status bar → `desktop_ui_click` /+`desktop_ui_set` for Preferences (incl. *Scripting System → Global Projects* install), dialogs,+the Projects/Components panels. The Home/License pages are **WebView2**: launch X2 with+`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<n>` (Phase 0 experiment) and drive+them with CDP (`nbrowser`/pup-style), else image-space clicks with `coordMap.shotId`.+Dialog handling per `fusion-driving`: scan every window owned by the X2 PID, classify by title,+screenshot each, pre-empt what can be pre-empted (save first; pass every parameter; `Client:CloseDocument`+with explicit params), and treat any sweep as a logged defect.++### L5 — Win32 lane++`PostMessage(WM_COMMAND)` after a `GetMenu` walk for cold-start actions before the agent binds;+`PostMessage` keys to exact hwnds; **never** `SendInput`/`SetCursorPos` on a default path. Altium+does not run elevated, so ab's synthetic input reaches it — but it is still the last rung.++### L1 — Altium 365 cloud lane (works with the app closed)++GraphQL at `https://{workspace}.altium.com/api/graphql/` (Voyager at `/api/voyager`, Nitro IDE),+`Authorization: Bearer <token>`; tokens from **Admin → Developer** (1-year access tokens, or+refresh + client credentials). Bounded contexts: **Design** (projects, variants, releases,+manufacturing packages), **Library** (components, symbols, footprints, part requests),+**Procurement** (BOMs), **Platform**, **Collaboration** (comments, tasks), **Customization**+(**workflows, scripts, script executions** — cloud-run scripts). Reads *and writes*. Plus the+**embeddable viewer** (`viewer.altium.com/client/static/js/embed.js`, `data-project-src=<zip url>`+or `data-project-token`) renders schematic/PCB/3D/BOM from a design zip — a render lane that+needs no desktop. Verbs: `altium_a365_search/browse/project/bom/component/release/run_script`,+`altium_web_preview`. Unknowns to verify: plan gating for tokens on a trial workspace; rate limits.++### L0 — file lane (no app, no licence)++`.PcbDoc/.SchDoc/.PcbLib/.SchLib` are OLE-CFB containers; ASCII PcbDoc exists. Parsers:+KiCad's Altium importer (best maintained; `dev-docs.kicad.org/en/import-formats/altium/`),+`vadmium/python-altium` (schematic), `thesourcerer8/altium2kicad`, `AtoK`, `PyAltium` (alpha),+`embedded-society/altium-designer-mcp` (writes PcbLib/SchLib primitives). Use for: `altium_state`+evidence when the app is closed, lint, library generation (IPC-7351 from the Adom molecule+pipeline), and KiCad-side previews.++### L7 — the native SDK extension (Phase 5, optional)++Altium Developer extension → SDK at `C:\ProgramData\Altium\Altium Designer {…}\Extensions\Altium Developer\SDK`;+DLL + `.ins` + `.rcs`; Delphi XE2+/VS2010+ C++/**C#**. A C# server DLL hosting an in-process HTTP+listener would be the Fusion-grade add-in (no file polling, real threads marshalled to the UI).+Costs a licensed AD + a build toolchain; do it only after L2 proves insufficient.++## 2. Authentication & licensing — the gates, in order++1. **Altium account sign-in** — system browser OAuth (email / Google / SSO). Drive with+   `nbrowser`/pup from the bridge; the user's Google session on this box is warm. Human step: password/2FA only.+2. **Licence** — On-Demand / Standalone / **Trial (15 days)** / **Viewer (no scripting)**. The+   trial button lives in the WebView2 licence page → CDP or image click, one toast to the user+   because it is a contractual click. `altium_readiness` must report `licensed:false` with the+   exact next verb, and refuse in-app lanes while unlicensed (they fail silently otherwise).+3. **Altium 365 API token** — Admin → Developer in the workspace web UI; drive it, store the token+   under `%LOCALAPPDATA%\Adom Bridge\bridges-cache\altium\a365.json`, never in the repo.+4. **Scripting enablement** — verify whether AD 26 gates DelphiScript behind a preference+   (`Legacy.Scripts.SupportOldLanguages` exists for VBScript/JScript; check for a DelphiScript+   gate) and whether a Global Project can be registered by writing the prefs file directly.++## 3. Verb catalog (v1 target)++| Group | Verbs |+|---|---|+| Contract | `altium_readiness` `altium_status` `altium_describe` `altium_state` `altium_progress` `altium_uninstall` `altium_diagnostics` |+| Lifecycle | `altium_launch` `altium_close` `altium_signin` `altium_license {trial\|viewer\|ondemand}` `altium_agent_start/stop/detach` |+| REPL | `altium_eval {text}` `altium_run_command {command, params}` `altium_run_script {prjscr, proc}` |+| Documents | `altium_open_project` `altium_open_document` `altium_close_document` `altium_save` `altium_compile` |+| Eight surfaces | `altium_show_symbol` `_footprint` `_3d_chip` `_library` `_schematic` `_2d_board` `_3d_board` `_project` |+| Evidence | `altium_screenshot_all` `altium_window_info` `altium_check_dialogs` `altium_dismiss_dialogs` |+| Outputs | `altium_run_outjob` `altium_export_gerber/odb/ipc2581/step/pdf/bom` `altium_run_drc` `altium_run_erc` |+| Libraries | `altium_create_symbol` `altium_create_footprint` `altium_import_library` `altium_list_library` `altium_install_library_bundle` |+| Cloud | `altium_a365_*` `altium_web_preview` |+| Demo | `altium_demo` (symbol → footprint → 3D part → schematic → 2D → 3D board, one beat per call, same shape as `kicad_demo`/`fusion_demo`) |++Every verb: `_hint`/`_next`, stable `errorCode` (`host_app_not_installed`, `not_licensed`,+`agent_not_running`, `agent_wedged`, `foreground_reason_required`, …), `windowEtiquette`,+`dialogsDetected`/`dialogs[]`, measured `progress` on anything > 3 s, caller identity forwarded on+every ab callback with `X-Adom-Caller-Delegate: altium`.++## 4. bridge.json essentials++```jsonc+{ "name":"altium", "displayName":"Altium Designer", "spawn":{"kind":"python","entrypoint":"server.py","port":0,"persistent":true},+  "verbPrefixes":["altium_"], "statusVerb":"altium_status", "uninstall":"altium_uninstall",+  "detect":{"hostApp":"Altium Designer","appPathsExe":"X2.EXE","paths":{"windows":["%ProgramFiles%\\Altium\\AD*\\X2.EXE"]}},+  "timeouts":{"default":60,"verbs":{"launch":240,"run_outjob":600,"export_step":360,"demo":300}},+  "docs":"https://wiki.adom.inc/adom/altium-bridge" }+```+Python binds `ADOM_BIND_HOST`; scratch under `%LOCALAPPDATA%\Adom Bridge\bridges-cache\altium\` with+the kicad retention rule (48 h / 300 files / oldest-first / 10-min rate limit).++## 5. Dialog doctrine for Altium (pre-empt, never sweep)++Commands that prompt → the pre-emption: closing a dirty doc → `WorkspaceManager:SaveObject` first or+`Client:CloseDocument` with `SaveMode`; ECO → run compile first and drive the ECO dialog via UIA+with a classified catalog; OutJob overwrite → pre-delete outputs; "Confirm" style boxes from+DelphiScript (`ShowMessage`) → never call them in the agent; Script IDE debugger halt → detect by+heartbeat and recover. Keep `handlers/dialog_classify.py` with title → category → resolution.++## 6. Phases + acceptance++0. **Phase 0 experiments (owed, on a LICENSED AD; ~1 day):** (a) trial licence via CDP/image click+   with toast; (b) `-R` forwards to running instance?; (c) `RunScriptText` from CLI?; (d) DelphiScript+   `CreateOleObject('MSXML2.ServerXMLHTTP')`?; (e) ProcessMessages loop keeps UI responsive?; (f)+   WebView2 `--remote-debugging-port` honoured?; (g) Global Project registration by prefs-file+   write?; (h) runtime-error recovery path; (i) ASCII PcbDoc export via `PCB:Export`; (j) A365 token+   on a trial workspace.+1. **Skeleton** (1–2 days): bridge.json, server, readiness/status/describe/state/uninstall/diagnostics,+   screenshot array by PID, rcs catalog parser. *Green chip on a fresh VM; `state` returns real shots.*+2. **Bootstrap + REPL** (3–5 days): agent PrjScr, Global Project install, CLI bootstrap, file-drop+   transport, `eval`, `run_command`, heartbeat + wedge recovery. *100 sequential evals, p50 recorded.*+3. **Eight surfaces + demo** (3–5 days): each `show_*` background + frame; `altium_demo`. *Web-control+   page renders all eight.*+4. **Outputs + libraries** (5 days): OutJob runner, exports, DRC/ERC, symbol/footprint creation,+   library bundle install from the wiki, dialog catalog. *Adom molecule round-trip: wiki part →+   Altium library → board → Gerber.*+5. **Cloud + auth** (3 days): sign-in + licence driven with one toast, A365 verbs, web preview.+6. **Publish**: Release zip + manifest, skills pkg (`altium-bridge` core + `altium-driving`,+   `altium-libraries`, `altium-a365`, `altium-signin` sub-skills), 30–40 discovery triggers, hero,+   `make release`, audit checklist. Then (optional) Phase 5: C# SDK extension.++## 6b. Phase 0 RESULTS (measured 2026-08-29, AD 26.9.1 Trial, AdomLapper) — the facts the code is built on++| Experiment | Result |+|---|---|+| Trial licence | `Start Free Trial` (WebView2, image click) → Edge opens `altium.com/365-intro/activate-add-eval` → workspace form (name, region, terms, **Activate**) → workspace `Adom` provisioned at `john-john-3.365.altium.com` in ~60 s → back in AD: **Refresh**, then **Use License** on "Altium Designer Develop · On-Demand · Trial · 30 days". Title flips to `… - Trial`. |+| `-R` forwarding | A second `X2.EXE -R<Server>:<Cmd>(…)` forwards to the running instance and exits in ~2–8 s. **If the instance is busy (any modal, or a script running) the helper hangs and shows its own "There is another instance … busy" Information dialog** — kill it. |+| `RunScriptText` | Works from the shell: `-RScriptingSystem:RunScriptText(Text="Begin RunApplication('…'); End;")`. **Statements only** — a `Var` block is "Unknown identifier: Var". |+| `RunScript` | `-RScriptingSystem:RunScript(ProjectName="…\X.PrjScr"\|ProcName="Main")` works with a plain `\|` when the arg is passed VERBATIM (PowerShell `Start-Process`, or Python `Popen("<one string>")`). `Popen([exe, arg])` re-quotes (the path has a space) and the helper never forwards. `^\|` is only for cmd.exe. |+| Concurrency | **One script at a time.** A -R script while the agent loop is resident raises `Error: Another script executing now` on the MAIN window; every later -R is refused until it is dismissed. |+| Late binding | An unknown identifier (`CreateOleObject`, `RunProcessWithParameters`) is a **runtime** error: `Error: Undeclared identifier …` modal + the Script editor opens with the **debugger halted** on that line. The halted script counts as executing. |+| Recovery | Press OK on the Error (background `BM_CLICK`), then `X2.EXE -REditScript:Stop` (helper exits ≈1 s), then scripts run again. Implemented as `altium_agent_recover`. |+| `CreateOleObject` | **Not available** → no HTTP from inside. Transport is file drop. |+| `FindFirst('request_*.txt')` | Never matched from the engine. Use ONE fixed inbox `request.txt` (atomic tmp+rename, serialized by the bridge). |+| `RunProcess` | `ResetParameters; AddStringParameter(k,v)…; RunProcess('Client:ArrangeAllWindows')` works from the loop: 828 ms in-agent. |+| Agent loop | `Application.ProcessMessages` + `Sleep(10)` (100 ms idle) keeps AD responsive; heartbeat `hb.txt` every 500 ms; **start → heartbeat 1.5 s**; `eval` round trip 0.7 s; `run_command` 1.4 s. RunScript opens the `.PrjScr` as a document in the Projects panel (harmless). |+| ab spawn | Port 47311 is in a Hyper-V reserved range (`WinError 10013`) — always `--port 0`/ab-assigned. `shell_execute` does not return while a process it started (the server) is alive — start long-lived processes via ab's spawn/`launch_process`, never a foreground shell. |++The bridge source lives in `~/project/altium-bridge/src` (server.py, handlers/agent.py, handlers/dialogs.py, handlers/rcs_catalog.py, handlers/altium_detect.py, agent/AdomAgent.pas.tmpl).++## 6c. Phase 1–3 RESULTS (2026-08-29 evening) — what is DONE++- ab adopted the bridge from `bridges-cache\altium` after `refresh_bridges {name:"altium"}`; it spawns on stable port 64070; `adom-bridge altium_*` works from the container. `bridge_kill {name,confirm:true,reason}` reloads code.+- **All eight surfaces return real frames** (base64 PNG of the main window, background, foreground restored): show_project 6.7 s, show_schematic 6.8 s, show_2d_board 8.7 s, show_3d_board 14.5 s, show_library 21.6 s (39 components listed), show_symbol 18.4 s, show_footprint 5.1 s warm (a cold PcbLib open blew ab's 60 s default — declare `timeouts` and expect it), show_3d_chip 16.4 s. `altium_demo {stage}` chains them.+- Agent lane: eval 0.7–1.1 s, run_command ~1.4 s, `lib_list` / `lib_select` via `SchServer.GetCurrentSchDocument` + `SchLibIterator_Create` / `PCBServer.GetCurrentPCBLibrary.GetComponent(i)` + `CurrentComponent`.+- Opening docs: `Client.OpenDocument('', path)` returns Nil on AD 26 — use `ResetParameters; AddStringParameter('ObjectKind','Document'|'Project'); AddStringParameter('FileName',path); RunProcess('WorkspaceManager:OpenObject')`, then `Client.GetDocumentByPath` + `Client.ShowDocument`.+- Three real-world races found and fixed: (1) a busy loop (long RunProcess) has no heartbeat → never auto-start a second loop (`alive` = hb < 60 s, `responsive` = < 3 s); (2) a killed -R helper's request is still DELIVERED later by Altium → reap stray X2 helpers before every start; (3) `tasklist` took 7 s under load → Toolhelp32 snapshot in-process.+- Dialog text is PAINTED (no Static child, not in UIA) — `altium_check_dialogs` attaches a base64 shot per dialog; OK buttons are `TXPBitBtn` (BM_CLICK) with ab `desktop_ui_click` as fallback.++## 6d. Phase 4 RESULTS (2026-08-29 night) — outputs lane++- `altium_compile` / `altium_run_erc`: `WorkspaceManager:Compile Action=Compile|ObjectKind=Project`, then read the Messages panel via `GetWorkspace.DM_MessagesManager` → **`MessagesCount`, `Messages(i).MsgClass/Source/Document/Text`** (`DM_MessageCount` does NOT exist). 1.7 s. Declare the manager as `IDMMessagesManager` (that type name compiles); `IMessagesManager`/`IMessageItem` as Var types give "Error in declaration block".+- `altium_run_outjob {outjobPath, medium}` → `WorkspaceManager:OpenObject` the .OutJob, then `WorkspaceManager:GenerateReport Action=Run|ObjectKind=OutputBatch|OutputMedium=<name>|DisableDialog=True`. Fabrication.OutJob → Gerber + NC Drill + ODB++ + DRC report in 23.6 s; Assembly.OutJob → BOM .xlsx + pick-and-place in 16.6 s. Output lands in `Project Outputs for <project>` next to the project. `altium_export_gerber` / `altium_export_bom` are those two mediums.+- `altium_run_drc`: `PCB:DesignRuleCheck` opens a MODAL "Design Rule Checker [mm]" (automationId `Run_Button` = "Run Design Rule Check...") — the agent blocks inside RunProcess, the bridge clicks the button through ab UIA in the background, then reads violations off the board (`BoardIterator` filtered `eViolationObject`, `V.Rule.Name`, `V.Description`).+- **Script-engine gotchas that cost the most time:** (1) never nest `{ }` inside a brace comment — the inner `}` ends the comment and the rest becomes code ("Error in declaration block"); (2) Altium keeps the .PrjScr/.pas OPEN in its Script editor after RunScript and re-runs the STALE in-editor copy after you overwrite the file — the bridge now renders `AdomAgent_<digest>.pas/.PrjScr` per source change; (3) a failed RunScript target opens the "Select Item To Run" picker (`TForm_RunScriptParameters`) and disables the main window — detect modals by "main window disabled + another enabled top-level window of the same PID", not by class.+- Foreground: the -R bootstrap activates Altium; the bridge captures the user's foreground before and hands it back immediately and during the wait (John: "stop foregrounding on me"). Restart the agent as rarely as possible — every restart is one activation.++## 6e. Phase 4 COMPLETE (2026-08-29) — library authoring++- `altium_create_footprint {name, libraryPath, pads:"des,x,y,w,h,rect|round,hole;…"}` — opens or CREATES the .PcbLib (`CreateNewDocumentFromDocumentKind('PCBLIB')` + `Doc.DoSafeChangeFileNameAndSave(path,'PCBLIB')`), `PCBServer.CreatePCBLibComp`, pads via `PCBObjectFactory(ePadObject…)` + `Comp.AddPCBObject`, `PLib.RegisterComponent`, `CurrentComponent`, `Board.ViewManager_FullUpdate`. 3.1 s, returns a frame.+- `altium_create_symbol {name, libraryPath, designator, bodyWidthMil, bodyHeightMil, pins:"des,name,left|right|top|bottom,offset;…"}` — `SchObjectFactory(eSchComponent)`, `eRectangle` body, `ePin`s with `Orientation`/`PinLength`, `SLib.AddSchComponent` + `RobotManager.SendMessage(... SCHM_PrimitiveRegistration ...)`. 1.1 s.+- `altium_save` — `WorkspaceManager:SaveObject ObjectKind=FocusedDocument`. Both libraries verified on disk.+- Release kit in the repo: `SKILL.md` (user skill), `README.md`, `package.json` (39 discovery triggers), `install.sh`/`uninstall.sh`, `CHANGELOG.md`, `scripts/release.py` (runtime zip 52 KB + manifest). NOT yet published — the wiki already has Barrett's `barrett-land/adom-desktop-altium-bridge` using name `altium`/prefix `altium_`; resolve rename-vs-supersede first.++## 6f. Barrett merge + taskbar (2026-08-29/30)++Ported from `barrett-land/adom-desktop-altium-bridge` (v0.0.55) into the resident agent — all measured working: `altium_set_params` (parameters incl. `LCSC Part #`, comment, footprint link via `ISch_Implementation.AddDataFileLink`), `altium_add_body` (extruded contour or `ModelFactory_FromFilename` STEP), `altium_install_library` (`IntegratedLibraryManager.InstallLibrary`), `altium_compile_intlib` / `altium_extract_intlib` (`CreateIntegratedLibrary` / `ExtractSources`). His **AdomBridge .NET extension** (prebuilt DLL + .ins/.rcs + Install-AdomBridge.ps1 + REMOVE cmd) is vendored under `src/extension/` and exposed as `altium_install_extension` (Altium CLOSED), `altium_extension_ping`, `altium_export_step` (headless STEP with `Holes=All|Copper|Models`), `altium_import_kicad` (no-wizard import). Dispatch is `X2.EXE -RAdomBridge:<Process>(Req=<space-free request file>)` — a server process, not a script, so it coexists with the resident agent. Facts from his page worth keeping: `-R` splits on whitespace (parameters go in a request file), OutJobs only generate when they are project members, STEP via OutJob no-ops headlessly, `Client.ShowDocumentDontFocus` exists (the agent's `open` now prefers it).++Taskbar: `handlers/taskbar.py` — overlay badge via ab `desktop_taskbar {overlay:{icon}}` on the first driving verb (cleared on idle), measured-estimate progress bar for verbs ≥3 s, no AUMID stamping. `altium_state.drivingBadge` reports `{active, lastError}`.++## 6g. Phase 5 RESULTS + the extension verdict (2026-08-30)++- **AdomBridge .NET extension (Barrett's) does NOT load into the running AD 26.9.1 instance.** Registered via his Install-AdomBridge.ps1 (registry node added cleanly, 20 items), Altium restarted → a `Could not start Vault Explorer` warning at startup; `X2 -RAdomBridge:Ping` helpers waited ~65–90 s on the *startup mutex* and then hosted the server in themselves (their own DXP_Startup.log says "Starting server AdomBridge"), the main instance never did; `RunProcess('AdomBridge:Ping')` from the resident agent returned in 16 ms and wrote nothing (unknown process → silently ignored). Unregistered again (`altium_install_extension {remove:true}`). Verbs kept, fail with `extension_timeout` + this note. Next step if wanted: rebuild against AD 26's in-box .NET assemblies (Barrett's `adom-altium-extension` project) and verify with a clean-profile Altium.+- **Altium 365 API on the Develop trial:** Admin shows Settings / Part Providers / Explorer / Usage and Billing / Extensions — **no Developer page**, every guessed token URL 404s. `altium_a365_*` verbs are wired for a token from a Pro/Enterprise workspace.+- Account/licence: `altium_signin_status`, `altium_license {action}` (image-space clicks on the WebView2 page with measured coords: refresh (1288,178), use_license (1252,388), start_trial (788,607) on a 1400×840 capture — verify against the returned shot), `altium_connect_workspace` (opens the `Not Connected` picker, `TWorkspacesForm`, custom list without UIA children → returns a shot to click).+- Saving example projects on close: Altium asks "Confirm Save Locally for (N) Modified Documents" (WPF grid: Save All / Save None / OK / Cancel, all UIA-invokable) — `altium_close` reports it; answer Save None + OK for scratch work.++## 6h. First end-to-end demo — Ray's CH340G (2026-08-30) + two bugs found++Imported `ray/ch340g` (WCH USB-serial, SOP-16) from the wiki into a NATIVE Altium library entirely through the bridge, then walked the surfaces. WORKED, all of that chip: `altium_create_footprint` (16 SOIC pads parsed from the page's pads.json, mm→mil), `altium_add_body` (the page's real 217 KB KiCad STEP), `altium_create_symbol` (16 named pins GND/TXD/RXD/UD+/VCC/…), `altium_set_params` (MPN/mfr/pkg + footprint link), `altium_show_symbol` / `_footprint` / `_library`, `altium_place_on_pcb` (new `place_on_pcb` verb: `IPCB_LibComponent.Replicate` → `Board.AddPCBObject` → the footprint lands on a real PcbDoc, 16 pads), `altium_show_2d_board`.++TWO REAL BUGS surfaced — fix before publishing:+1. **`place_on_sch` loses the symbol graphics.** `ISch_Component.Replicate` onto a fresh sheet placed the component (U1 designator + CH340G comment appear) but NOT its rectangle/pins — only the two text strings render. Setting `CurrentPartID:=1`/`DisplayMode:=0` on the copy did not fix it. The primitives inside the component group aren't being carried/registered onto the target sheet. Likely need to iterate the lib component's children and `AddSchObject` each onto the instance, or place via `IntegratedLibraryManager` rather than `Replicate`.+2. **STEP 3D body renders OFF-ORIGIN.** `add_body` with `ModelFactory_FromFilename` attaches (returns ok), but in both the library 3D view and on the board the body sits at the model's native origin (a small sliver at the board corner), not over the pads — so the 3D board shows bare gold pads. Reposition the body to the footprint centroid after `SetState_FromModel` (set `Body.ModelPosition`/move), or bake the offset.++Also: **`PCB:SetCamera(To=RotateFit_*)` is a no-op via RunProcess on AD 26.9.1** even with the window raised — the 3D view stays dead top-down. A real isometric needs either a focused numpad-key rotation (SendInput) or a different camera API; do not rely on SetCamera. Board substrate also renders white (no green plane) in the library/loose-PcbDoc 3D — a board shape + layer stack is needed for a proper 3D board.++Net for a demo today: symbol, footprint, library manager, schematic-with-part (text only), 2D board with the real footprint — all solid; 3D-with-body and schematic-graphics need the two fixes above.++## 6i. Demo fixes (2026-08-30) — schematic FIXED, one important bridge bug FIXED, 3D body still open++**FIXED — `place_on_sch` now renders the full symbol.** `ISch_Component.Replicate` does NOT re-parent a library part's pins/rectangle onto a normal sheet (only the designator/comment text placed). The working path: build a fresh `SchServer.SchObjectFactory(eSchComponent)`, iterate the lib component's children (`ePin/eRectangle/eLine/…`), `child.Replicate` each, set `OwnerPartId:=1`/`OwnerPartDisplayMode:=0`, `Inst.AddSchObject(child)`, then `RegisterSchObjectInContainer` + SendMessage. Proven on Ray's CH340G — 17 primitives (rect + 16 pins) render with all pin names on `CH340G_Final.SchDoc`. (Setting `OwnerPartDisplayMode` alone on the ORIGINAL symbol did NOT fix Replicate — the copy-primitives approach is required.)++**FIXED — `agent_start` silently ran STALE code.** The DelphiScript loop lives inside Altium; `bridge_kill` restarts the ab PROCESS but not the loop, and `start()` early-returned `alreadyRunning` when it saw the old loop's heartbeat — so redeployed agent code never loaded (every "fix" this session needed a manual `agent_stop` first). Fix: the agent stamps its generation (`AGENT_GEN` = template digest) into `hb.txt`; `start()` compares the resident `gen` to the current template digest and auto-stops+restarts a stale loop. ⚠ TWO traps hit while adding it: (1) `_agent_base()` must hash the RAW template, NOT `rendered_agent_source()` — the latter recursed and crashed the whole bridge; (2) editing `agent.py`/`server.py` needs a `bridge_kill` to reload the PROCESS, separate from restarting the in-Altium loop.++**STILL OPEN — 3D component body does not render over the pads in the PCB Library 3D view.** `add_body` attaches (STEP via `ModelFactory_FromFilename`, or an extruded contour) and returns ok, but neither shows as a solid over the pads in the footprint 3D editor — both appear as a small sliver in a corner. A post-add `MoveByXY(BoundingRectangle…)` centering attempt was WRONG (moved it further off) and has been reverted. Likely causes to chase next: STEP unit/scale on import; the PCB Library editor's "Display 3D Bodies" option; and bodies render reliably only on a real board (place the footprint on a PcbDoc WITH a layer stack + board shape, then view 3D) — the loose-PcbDoc/library 3D view is the weak path. Also `PCB:SetCamera(To=RotateFit_*)` is a no-op via RunProcess, so the view stays top-down regardless — a real isometric needs SendInput numpad keys to the focused 3D window. The Altium main window also self-MINIMIZED during the 3D switches; `desktop_set_window_state {state:restore}` fixes the sliver captures.++## 6j. 3D BODY FIXED end-to-end (2026-08-30) — five root causes, all measured++The chip now renders in 3D on a real board (black body + 16 gold pads on the green PCB, `CH340G_Board14.PcbDoc`). It took FIVE stacked fixes, each verified by data (`altium_pcb_report` — a new diagnostic verb that dumps per-component pads/bodies with layer + bbox in mils, plus a board object census by ObjectId):++1. **A primitive with no Layer is silently dropped.** `add_body` never set `Body.Layer`; pads always set theirs. Fix: `Body.Layer := eMechanical1` (+ enable `LayerStack.LayerObject[eMechanical1].MechanicalLayerEnabled` and `LayerIsDisplayed`) — and `Body.BodyOpacity3D := 1.0`.+2. **The stored lib component is NOT the editing surface.** `Fp.AddPCBObject(Body)` counted 1 body immediately but the body was GONE after save+reopen — the editor canvas copy overwrites the stored component. Fix: `PLib.CurrentComponent := Fp` FIRST, then add to the CANVAS (`PLib.Board.AddPCBObject(Body)`); the editor persists the canvas into the component on save. (`bodiesNow=1` AND survives reopen: `libBodies=1`.)+3. **Group `Replicate` of an `IPCB_LibComponent` DISSOLVES on a board** — census showed 16 free pads, no `eComponentObject`, no body. Fix (same cure as the schematic): fresh `PCBObjectFactory(eComponentObject)`, copy each child with primitive-level `Replicate` + `Inst.AddPCBObject`, and — critical — **fill the group BEFORE `Board.AddPCBObject(Inst)`**: children added to an already-registered component exist in data but never draw, whatever address they're robot-registered to.+4. **The lib stores pads around the canvas origin (~50000,50000 mil) but an authored body contour around 0,0** — one group move can't land both (body hit the target, pads landed 50000 mil away). Fix: normalize per cluster — compute the copied PAD bbox center and `MoveByXY` pads (and non-text children) by `target−center`; move each BODY to the target by its own bbox center.+5. **Assigning `Inst.x/y` AFTER moving children re-translates the whole group** (everything landed at 8000,6000). Anchor `Inst.x/y := target` BEFORE the normalization. Also hide `NameOn/CommentOn` (the factory "Designator1" text is huge and hijacks Zoom All; hidden texts still bloat the bbox — frame with `Board.GraphicalView_ZoomOnRect(...)` via eval instead of trusting Zoom All).++More traps measured on the way: `GetCurrentPCBBoard` answers with the LIBRARY's internal board when a PcbLib is focused (a board-reuse path placed into the lib canvas and renamed the lib doc — always create the PcbDoc and only then read GetCurrentPCBBoard); `GetCurrentPCBLibrary` answers even when a board is focused (pcb_report needs `want:"board"`); a nil `BoardOutline.BoundingRectangle`, `SetFileName` on an unassigned doc, and `GroupIterator_Create` on IPCB_Board all HALT the engine as "Undeclared identifier"; `IPCB_Component.Name.Text :=` halts too (use `NameOn`/`MoveToXY` on `Inst.Name`). GroupIterators need `AddFilter_LayerSet(AllLayers)` or mech-layer children are invisible to them. `WriteAtomic` stage breadcrumbs into the workspace (`agent/stage.txt`) pinpoint hangs.++Still open (minor): the PCB **Library** editor 3D view does not render bodies (the board 3D does — use `show_3d_chip` for selection, the placed-board flow for true 3D); `PCB:SetCamera` remains a no-op so views are framed with `GraphicalView_ZoomOnRect`; the schematic `place_on_sch` fix (§6i) still stands. Cleanup owed: the fix session littered `CH340G_v2..v8` libs and `CH340G_Board..Board15` docs in Downloads and many open tabs.++### Progress surface ruling (John, 2026-08-30)+Long-job progress goes on the Windows taskbar icon ONLY (`desktop_taskbar` progress on Altium's button — handlers/taskbar.py already paces it per verb). NOT ab progress toasts: "when i say progress bar i just mean in the windows taskbar icons." Toasts remain the nuclear only-a-human-can-act channel.++### Ralph rule for generated CAD (John, 2026-08-30)+Frame-check EVERY generated look/variant, not a sample — the shipped symbol had colliding 4-side names, visible param texts (SetSchParam defaulted IsHidden False; now True), and std==2col, all invisible in a 3-of-10 spot check. symbol_modes.py now sizes bodies from measured name lengths (55 mil/char); ralph the full matrix before reporting.++## 6k. LAN7800 creative import (2026-08-30) — variants, display modes, footprint models, board proof++New verbs (all agent-lane, all measured): `altium_build_footprint {libraryPath, spec}` (spec from `tools/kicad2altium.py`: pads incl. duplicate-designator through-hole vias, roundrect %, tracks/arcs/filled discs on named mech layers), `altium_build_symbol_modes {libraryPath, spec}` (ONE component, N display modes from `tools/symbol_modes.py`), `altium_symbol_mode {mode}` (editor-driven: `Sch:ToggleComponentDisplayMode Action=Mode0/Next` — `Comp.DisplayMode` changes data only), `altium_add_models {models[], params, comment, description}` (N PCBLIB implementations = Altium footprint variants; the user picks the current footprint on the placed part), `altium_normalize_bodies`, `altium_test_board {footprintName, wMm, hMm, assigns, stubs}`, `altium_board_shape {x0Mm,y0Mm,wMm,hMm}` (process `PCB:PlaceBoardOutline Mode=BOARDOUTLINE_FROM_SEL_PRIMS` on 4 temp selected tracks — `BoardOutline.PointCount/Segments` is a silent no-op, `.Clear` halts), `altium_swap_footprint {footprintName, libraryPath, assigns}` (remove + per-primitive re-place at the same x/y, nets re-assigned by designator — tracks untouched, so wiring survives), `altium_pcb_report` gained `padsBboxMil`, `nets`, `tracks`.++**Dialog pre-emption (John: a dismissed dialog is a defect).** The DelphiScript debugger breaks on ANY raised exception even inside Try/Except — a raise IS an Error modal. `ReadAll` now probes with `FileOpen` (returns -1, never raises) before `LoadFromFile`. A halted command leaves `PCBServer.PreProcess` open = "a command is currently active" → save draws the save-a-copy Warning: `TxClear` (unbalanced `PostProcess`, harmless) runs at agent start, before every save, and after every recover. Undeclared identifiers hit this session: `BottomXSize`/`Mid*` pad sizes, `IPCB_BoardOutline.Clear`, `eProcessFreePrimitives`, `IPCB_Board.GroupIterator_Create`.++**Library canvas offset.** Bodies added through the PcbLib canvas land 50000 mil from the pads in the stored frame; the editor then paints an EMPTY canvas (Zoom All frames 50 inches; the CH340G "orange sliver" was this). `normalize_bodies` centres each body on its pad cluster; `add_body` now realigns automatically. GroupIterators need `AddFilter_LayerSet(AllLayers)` or mech-layer children are invisible.++**Frames under a locked session.** GDI editors (schematic, PcbLib 2D) still capture; the DirectX PCB canvas paints BLACK while the Windows session is locked (`desktop_get_foreground` = 0, `desktop_list_windows` = 0, full-screen capture white). Check the session before trusting a board frame.++## 7. Sources++Altium docs: scripting overview/setup, running scripts, DelphiScript, Altium SDK overview, Altium+365 API quick start + API page, embeddable viewer, licensing, sign-in. Vendor CI article (X2 `-R`+recipe). Community: `salitronic/eda-agent`, `coffeenmusic/altium-mcp`, `ee-in-a-box/altium-copilot`,+`embedded-society/altium-designer-mcp`, `10xgenomicsEng/altium-mcp-unified`,+`oharown/mcp-codex-altium-designer`, `Altium-Designer-addons/scripts-libraries`, KiCad Altium+importer docs, `vadmium/python-altium`, `thesourcerer8/altium2kicad`.
adom-bridge-altium-manifest.json+4−4
@@ -1,9 +1,9 @@ {   "name": "altium",-  "version": "0.1.8",-  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.8/adom-bridge-altium-v0.1.8.zip",-  "sha256": "e0abbcb7b4e894ab0071e3d124b0314abf6a366da79da655307505c63f5c602d",-  "size": 94072,+  "version": "0.1.9",+  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.9/adom-bridge-altium-v0.1.9.zip",+  "sha256": "3a4d9dc1e3dca536acb925370fe1a96babbf0b79cd7ae83ae79f39b8254965a2",+  "size": 97080,   "verbPrefixes": [     "altium_"   ],
dashboard/ui/assets/icons/settings-cog.svgadded+1
@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg>\ No newline at end of file
dashboard/ui/assets/shots/taskbar-both.pngadded
⋯ 1 unchanged line ⋯
dashboard/ui/assets/shots/taskbar-flash.pngadded
⋯ 1 unchanged line ⋯
dashboard/ui/assets/shots/taskbar-none.pngadded
⋯ 1 unchanged line ⋯
dashboard/ui/assets/shots/taskbar-overlay.pngadded
⋯ 1 unchanged line ⋯
dashboard/ui/assets/shots/taskbar-progress.pngadded
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.1.8",+  "version": "0.1.9",   "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.pyadded+64
@@ -0,0 +1,64 @@+#!/usr/bin/env python3+"""Build the RUNTIME release zip + manifest for altium-bridge (SDK payload rules:+runtime-only, bridge.json at the zip ROOT, no media/docs).++  python3 scripts/release.py            # -> dist/adom-bridge-altium-v<ver>.zip + dist/adom-bridge-altium-manifest.json+  python3 scripts/release.py --publish  # + adom-wiki release create/upload + POST manifest to the page++Versions live in ONE place: src/bridge.json (package.json for the skills pkg is bumped in lockstep).+"""+import hashlib, json, os, subprocess, sys, zipfile+from pathlib import Path++ROOT = Path(__file__).resolve().parent.parent+SRC = ROOT / "src"+DIST = ROOT / "dist"+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/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",+           "extension/AdomBridge/AdomBridge.dll", "extension/AdomBridge/AdomBridge.ins", "extension/AdomBridge/AdomBridge.rcs",+           "extension/AdomBridge/Help/ServerDlls.txt"]+++def main():+    ver = json.loads((SRC / "bridge.json").read_text())["version"]+    DIST.mkdir(exist_ok=True)+    zname = f"adom-bridge-altium-v{ver}.zip"+    zpath = DIST / zname+    with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:+        for rel in INCLUDE:+            p = SRC / rel+            if not p.exists():+                sys.exit(f"missing {rel}")+            z.write(p, rel)+    sha = hashlib.sha256(zpath.read_bytes()).hexdigest()+    manifest = {+        "name": "altium", "version": ver,+        "url": f"https://wiki.adom.inc/download/{PAGE}/{ver}/{zname}",+        "sha256": sha, "size": zpath.stat().st_size,+        "verbPrefixes": ["altium_"], "healthEndpoint": "/health",+        "updateManifestUrl": f"https://wiki.adom.inc/api/v1/pages/{PAGE_SLUG}/files/adom-bridge-altium-manifest.json",+    }+    mpath = DIST / "adom-bridge-altium-manifest.json"+    mpath.write_text(json.dumps(manifest, indent=2))+    (ROOT / "adom-bridge-altium-manifest.json").write_text(json.dumps(manifest, indent=2))+    print(f"built {zpath} ({zpath.stat().st_size} bytes) sha256={sha[:12]}")+    with zipfile.ZipFile(zpath) as z:+        big = sorted(z.infolist(), key=lambda i: -i.file_size)[:5]+        for i in big:+            print(f"  {i.file_size:>8}  {i.filename}")+    if "--publish" in sys.argv:+        run = lambda *a: subprocess.run(a, check=True)+        run("adom-wiki", "release", "create", PAGE, ver, "--title", f"altium-bridge {ver}", "--changelog", "see CHANGELOG.md")+        run("adom-wiki", "release", "upload", PAGE, ver, str(zpath), "--platform", "any")+        run("adom-wiki", "repo", "push", PAGE)   # manifest + page files+        print("published")+++if __name__ == "__main__":+    main()
src/BRIDGE_VERSIONadded+1
@@ -0,0 +1 @@+0.1.9
src/agent/AdomAgent.PrjScradded+4
@@ -0,0 +1,4 @@+[Design]+Version=1.0+[Document1]+DocumentPath=AdomAgent.pas
src/agent/AdomAgent.pas.tmpladded+3066
@@ -0,0 +1,3066 @@+{ AdomAgent.pas — the resident Adom agent that runs INSIDE Altium Designer's+  DelphiScript engine.  Generated from AdomAgent.pas.tmpl by the bridge: every+  __WORKSPACE__ token is replaced with the absolute agent workspace directory+  (DelphiScript has no reliable way to read env vars, so the path is baked in).++  Transport: file drop.  The bridge writes  request_<id>.txt  (one "key=value"+  per line; a multi-line payload rides in request_<id>.payload) and the agent+  answers with  response_<id>.json  (written to .tmp then renamed, so a reader+  never sees a half file).  hb.txt carries a heartbeat tick every ~500 ms so+  the bridge can tell "still working" from "engine wedged".++  Engine facts (measured on AD 26.9.1, 2026-08-29):+    - only ONE script may run at a time; a -R RunScript/RunScriptText from the+      shell while this loop is resident raises the modal "Another script+      executing now" on the MAIN instance and makes it refuse every later -R+      until dismissed.  So while resident, EVERYTHING goes through this loop.+    - RunScriptText accepts STATEMENTS only (Begin ... End;), no Var block.+    - CreateOleObject is NOT available ("Undeclared identifier"), so no HTTP+      from inside; file drop is the transport.+    - DelphiScript is untyped: no records/classes; Try/Except works.+}++Const+    AGENT_VERSION = '0.1.0';+    WORKSPACE     = '__WORKSPACE__';+    AGENT_GEN     = '__GEN__';+    POLL_ACTIVE_MS = 10;+    POLL_IDLE_MS   = 100;+    IDLE_AFTER_TICKS = 300;           { ~3 s of no requests -> idle cadence }+    HEARTBEAT_MS   = 500;+    AUTO_STOP_MS   = 1800000;         { 30 min without a request -> exit loop }++Var+    gRunning    : Boolean;+    gRequests   : Integer;+    gLastReqTick: Integer;+    gLastHbTick : Integer;++{ ---------------------------------------------------------------- files ---- }++Function ReadAll(Path : String) : String;+Var+    L : TStringList;+    K, H : Integer;+Begin+    Result := '';+    { NEVER let LoadFromFile raise: the script debugger breaks on ANY exception+      even inside Try/Except and draws an Error modal (EFOpenError "being used+      by another process" when the bridge is mid-write, measured 2026-08-30).+      Probe with FileOpen, which returns -1 instead of raising, until the file+      is readable, then load. }+    For K := 1 To 100 Do+    Begin+        H := FileOpen(Path, fmOpenRead Or fmShareDenyNone);+        If H >= 0 Then+        Begin+            FileClose(H);+            Break;+        End;+        Sleep(20);+    End;+    If H < 0 Then Exit;+    L := TStringList.Create;+    Try+        L.LoadFromFile(Path);+        Result := L.Text;+    Finally+        L.Free;+    End;+End;++Procedure WriteAtomic(Path : String; Content : String);+Var+    L   : TStringList;+    Tmp : String;+    K   : Integer;+Begin+    Tmp := Path + '.tmp';+    L := TStringList.Create;+    Try+        L.Text := Content;+        For K := 1 To 20 Do+        Begin+            Try+                L.SaveToFile(Tmp);+                Break;+            Except+                Sleep(10);+            End;+        End;+    Finally+        L.Free;+    End;+    If FileExists(Path) Then DeleteFile(Path);+    RenameFile(Tmp, Path);+End;++Function JsonEsc(S : String) : String;+Var+    I : Integer;+    C : Char;+Begin+    Result := '';+    For I := 1 To Length(S) Do+    Begin+        C := S[I];+        If C = '"' Then Result := Result + '\"'+        Else If C = '\' Then Result := Result + '\\'+        Else If C = #13 Then Result := Result + '\r'+        Else If C = #10 Then Result := Result + '\n'+        Else If C = #9 Then Result := Result + '\t'+        Else If Ord(C) < 32 Then Result := Result + ' '+        Else Result := Result + C;+    End;+End;++Function JStr(Key : String; Val : String) : String;+Begin+    Result := '"' + Key + '":"' + JsonEsc(Val) + '"';+End;++Function JRaw(Key : String; Val : String) : String;+Begin+    Result := '"' + Key + '":' + Val;+End;++Function JBool(B : Boolean) : String;+Begin+    If B Then Result := 'true' Else Result := 'false';+End;+++{ Close a dangling PCBServer transaction. A command that halted between+  PreProcess and PostProcess leaves Altium in "a command is currently active",+  and the next save draws the save-a-copy Warning. Measured 2026-08-30: an+  unbalanced PostProcess is harmless and clears it. }+Procedure TxClear;+Begin+    Try+        PCBServer.PostProcess;+    Except+    End;+End;++Procedure Heartbeat(Force : Boolean);+Var+    T : Integer;+Begin+    T := GetTickCount;+    If Force Or ((T - gLastHbTick) >= HEARTBEAT_MS) Then+    Begin+        Try+            WriteAtomic(WORKSPACE + 'hb.txt', IntToStr(T) + #13#10 + 'requests=' + IntToStr(gRequests) + #13#10 + 'version=' + AGENT_VERSION + #13#10 + 'gen=' + AGENT_GEN);+        Except+        End;+        gLastHbTick := T;+    End;+End;++{ --------------------------------------------------------- request parse --- }++{ Requests are "key=value" lines.  Param(Lines, 'command') etc. }+Function Param(Lines : TStringList; Key : String) : String;+Var+    I : Integer;+    L : String;+Begin+    Result := '';+    For I := 0 To Lines.Count - 1 Do+    Begin+        L := Lines[I];+        If Pos(Key + '=', L) = 1 Then+        Begin+            Result := Copy(L, Length(Key) + 2, Length(L));+            Break;+        End;+    End;+End;++{ ------------------------------------------------------------- handlers --- }++Function CmdPing(Lines : TStringList) : String;+Begin+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('agent', AGENT_VERSION) + ',' + JRaw('requests', IntToStr(gRequests)) + ',' + JRaw('tick', IntToStr(GetTickCount)) + '}';+End;++{ Every open document in the workspace + which one has focus. }+Function CmdState(Lines : TStringList) : String;+Var+    WS   : IWorkspace;+    Prj  : IProject;+    Doc  : IDocument;+    I, J : Integer;+    Docs : String;+    Focused : String;+    PrjName : String;+Begin+    Docs := '';+    Focused := '';+    PrjName := '';+    WS := GetWorkspace;+    If WS <> Nil Then+    Begin+        Try+            Doc := WS.DM_FocusedDocument;+            If Doc <> Nil Then Focused := Doc.DM_FullPath;+        Except+        End;+        Try+            Prj := WS.DM_FocusedProject;+            If Prj <> Nil Then PrjName := Prj.DM_ProjectFullPath;+        Except+        End;+        For I := 0 To WS.DM_ProjectCount - 1 Do+        Begin+            Prj := WS.DM_Projects(I);+            For J := 0 To Prj.DM_LogicalDocumentCount - 1 Do+            Begin+                Doc := Prj.DM_LogicalDocuments(J);+                If Docs <> '' Then Docs := Docs + ',';+                Docs := Docs + '{' + JStr('path', Doc.DM_FullPath) + ',' + JStr('kind', Doc.DM_DocumentKind) + ',' + JStr('project', Prj.DM_ProjectFileName) + ',' + JRaw('open', JBool(Doc.DM_DocumentIsLoaded)) + '}';+            End;+        End;+    End;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('focusedDocument', Focused) + ',' + JStr('focusedProject', PrjName) + ',' + JRaw('documents', '[' + Docs + ']') + '}';+End;++{ Run any server process from the 1,109-entry catalog: command=PCB:Zoom  params=Action=All|... }+Function CmdRunProcess(Lines : TStringList) : String;+Var+    Cmd, P, Rest, Pair : String;+    K : Integer;+Begin+    Cmd := Param(Lines, 'process');+    P := Param(Lines, 'params');+    If Cmd = '' Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'process is required, e.g. PCB:Zoom') + '}';+        Exit;+    End;+    { RunProcessWithParameters halted the debugger at this line on AD 26.9.1+      (2026-08-29) — the supported form is ResetParameters + one+      AddStringParameter per Name=Value pair, then RunProcess(Command). }+    ResetParameters;+    Rest := P;+    While Rest <> '' Do+    Begin+        K := Pos('|', Rest);+        If K > 0 Then+        Begin+            Pair := Copy(Rest, 1, K - 1);+            Rest := Copy(Rest, K + 1, Length(Rest));+        End+        Else+        Begin+            Pair := Rest;+            Rest := '';+        End;+        K := Pos('=', Pair);+        If K > 0 Then AddStringParameter(Copy(Pair, 1, K - 1), Copy(Pair, K + 1, Length(Pair)));+    End;+    RunProcess(Cmd);+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('process', Cmd) + ',' + JStr('params', P) + '}';+End;++{ Open a document or project by path. Client.OpenDocument('', path) returns Nil on+  AD 26.9.1 (measured), so go through the WorkspaceManager process launcher, then+  look the document up and show it. .PrjPcb/.PrjScr/.PrjMbd open as a Project. }+Function CmdOpen(Lines : TStringList) : String;+Var+    Path, Ext, Kind : String;+    Doc  : IServerDocument;+    K    : Integer;+Begin+    Path := Param(Lines, 'path');+    If Not FileExists(Path) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'file not found: ' + Path) + '}';+        Exit;+    End;+    Ext := '';+    K := Length(Path);+    While (K > 0) And (Path[K] <> '.') Do K := K - 1;+    If K > 0 Then Ext := UpperCase(Copy(Path, K + 1, Length(Path)));+    If (Ext = 'PRJPCB') Or (Ext = 'PRJSCR') Or (Ext = 'PRJMBD') Or (Ext = 'PRJHAR') Or (Ext = 'LIBPKG') Then+        Kind := 'Project'+    Else+        Kind := 'Document';+    ResetParameters;+    AddStringParameter('ObjectKind', Kind);+    AddStringParameter('FileName', Path);+    RunProcess('WorkspaceManager:OpenObject');+    Doc := Nil;+    Try+        Doc := Client.GetDocumentByPath(Path);+    Except+    End;+    If Doc <> Nil Then+    Begin+        Try+            Client.ShowDocumentDontFocus(Doc);+        Except+            Client.ShowDocument(Doc);+        End;+        Result := '{' + JRaw('ok', 'true') + ',' + JStr('path', Path) + ',' + JStr('objectKind', Kind) + ',' + JStr('kind', Doc.Kind) + ',' + JRaw('shown', 'true') + '}';+    End+    Else+        Result := '{' + JRaw('ok', 'true') + ',' + JStr('path', Path) + ',' + JStr('objectKind', Kind) + ',' + JRaw('shown', 'false') + ',' + JStr('note', 'OpenObject ran; document handle not resolved (projects have no document view)') + '}';+End;++{ Inline statements: RunScriptText from INSIDE the agent (measured separately;+  if the engine refuses nested scripts this returns the error in-band). }+Function CmdEval(Lines : TStringList; Payload : String) : String;+Var+    Text : String;+Begin+    Text := Payload;+    If Text = '' Then Text := Param(Lines, 'text');+    If Text = '' Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'text (statements only, Begin ... End;) is required') + '}';+        Exit;+    End;+    ResetParameters;+    AddStringParameter('Text', Text);+    RunProcess('ScriptingSystem:RunScriptText');+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'RunScriptText dispatched; statements only, no Var block; capture output by writing a file') + '}';+End;+++{ ------------------------------------------------------------ libraries --- }++{ List components of the CURRENT library document (open it first). kind=sch|pcb }+Function CmdLibList(Lines : TStringList) : String;+Var+    Kind  : String;+    SLib  : ISch_Lib;+    Iter  : ISch_Iterator;+    Comp  : ISch_Component;+    PLib  : IPCB_Library;+    I, N  : Integer;+    Names : String;+    Limit : Integer;+Begin+    Kind := Param(Lines, 'kind');+    Limit := StrToIntDef(Param(Lines, 'limit'), 200);+    Names := '';+    N := 0;+    If Kind = 'sch' Then+    Begin+        SLib := SchServer.GetCurrentSchDocument;+        If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+        Begin+            Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'current document is not a schematic library') + '}';+            Exit;+        End;+        Iter := SLib.SchLibIterator_Create;+        Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+        Comp := Iter.FirstSchObject;+        While Comp <> Nil Do+        Begin+            If N < Limit Then+            Begin+                If Names <> '' Then Names := Names + ',';+                Names := Names + '"' + JsonEsc(Comp.LibReference) + '"';+            End;+            N := N + 1;+            Comp := Iter.NextSchObject;+        End;+        SLib.SchIterator_Destroy(Iter);+    End+    Else+    Begin+        PLib := PCBServer.GetCurrentPCBLibrary;+        If PLib = Nil Then+        Begin+            Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'current document is not a PCB library') + '}';+            Exit;+        End;+        For I := 0 To PLib.ComponentCount - 1 Do+        Begin+            If N < Limit Then+            Begin+                If Names <> '' Then Names := Names + ',';+                Names := Names + '"' + JsonEsc(PLib.GetComponent(I).Name) + '"';+            End;+            N := N + 1;+        End;+    End;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('kind', Kind) + ',' + JRaw('count', IntToStr(N)) + ',' + JRaw('names', '[' + Names + ']') + '}';+End;++{ Make one component current in the open library editor. kind=sch|pcb name=<LibReference|footprint>+  (name '' = the first one), then zoom all. }+Function CmdLibSelect(Lines : TStringList) : String;+Var+    Kind, Name, Found : String;+    SLib  : ISch_Lib;+    Iter  : ISch_Iterator;+    Comp  : ISch_Component;+    PLib  : IPCB_Library;+    PComp : IPCB_LibComponent;+    I     : Integer;+Begin+    Kind := Param(Lines, 'kind');+    Name := Param(Lines, 'name');+    Found := '';+    If Kind = 'sch' Then+    Begin+        SLib := SchServer.GetCurrentSchDocument;+        If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+        Begin+            Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'current document is not a schematic library') + '}';+            Exit;+        End;+        Iter := SLib.SchLibIterator_Create;+        Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+        Comp := Iter.FirstSchObject;+        While Comp <> Nil Do+        Begin+            If (Name = '') Or (UpperCase(Comp.LibReference) = UpperCase(Name)) Then+            Begin+                SLib.CurrentSchComponent := Comp;+                Found := Comp.LibReference;+                Break;+            End;+            Comp := Iter.NextSchObject;+        End;+        SLib.SchIterator_Destroy(Iter);+        If Found <> '' Then+        Begin+            SLib.GraphicallyInvalidate;+            ResetParameters;+            AddStringParameter('Action', 'All');+            RunProcess('Sch:Zoom');+        End;+    End+    Else+    Begin+        PLib := PCBServer.GetCurrentPCBLibrary;+        If PLib = Nil Then+        Begin+            Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'current document is not a PCB library') + '}';+            Exit;+        End;+        For I := 0 To PLib.ComponentCount - 1 Do+        Begin+            PComp := PLib.GetComponent(I);+            If (Name = '') Or (UpperCase(PComp.Name) = UpperCase(Name)) Then+            Begin+                PLib.CurrentComponent := PComp;+                Found := PComp.Name;+                Break;+            End;+        End;+        If Found <> '' Then+        Begin+            PLib.RefreshView;+            ResetParameters;+            AddStringParameter('Action', 'All');+            RunProcess('PCB:Zoom');+        End;+    End;+    If Found = '' Then+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'component not found: ' + Name) + ',' + JStr('hint', 'lib_list to see names') + '}'+    Else+        Result := '{' + JRaw('ok', 'true') + ',' + JStr('kind', Kind) + ',' + JStr('selected', Found) + '}';+End;++{ ------------------------------------------------------------- outputs --- }++{ Compile the focused project (= ERC for schematics) and return the Messages panel.+  IMessagesManager (Workspace Manager API): MessagesCount, Messages(i) -> IMessageItem+  with MsgClass, Text, Source, Document. DM_MessageCount does not exist (measured).+  NEVER nest braces inside a brace comment - the inner one ends the comment early+  and the rest becomes code ("Error in declaration block"). }+Function CmdCompile(Lines : TStringList) : String;+Var+    WS   : IWorkspace;+    MM   : IDMMessagesManager;+    I, N : Integer;+    Msgs : String;+    Kind : String;+    Limit: Integer;+Begin+    Kind := Param(Lines, 'objectKind');+    If Kind = '' Then Kind := 'Project';+    Limit := StrToIntDef(Param(Lines, 'limit'), 200);+    ResetParameters;+    AddStringParameter('Action', 'Compile');+    AddStringParameter('ObjectKind', Kind);+    RunProcess('WorkspaceManager:Compile');+    Msgs := '';+    N := 0;+    WS := GetWorkspace;+    If WS <> Nil Then+    Begin+        MM := WS.DM_MessagesManager;+        If MM <> Nil Then+        Begin+            N := MM.MessagesCount;+            For I := 0 To N - 1 Do+            Begin+                If I >= Limit Then Break;+                If Msgs <> '' Then Msgs := Msgs + ',';+                Msgs := Msgs + '{' + JStr('class', MM.Messages(I).MsgClass) + ',' + JStr('source', MM.Messages(I).Source) + ',' + JStr('document', MM.Messages(I).Document) + ',' + JStr('text', MM.Messages(I).Text) + '}';+            End;+        End;+    End;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('objectKind', Kind) + ',' + JRaw('messageCount', IntToStr(N)) + ',' + JRaw('messages', '[' + Msgs + ']') + '}';+End;++{ DRC violations currently on the focused board (run PCB:DesignRuleCheck first). }+Function CmdViolations(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    Iter  : IPCB_BoardIterator;+    V     : IPCB_Violation;+    N     : Integer;+    Items : String;+    Limit : Integer;+Begin+    Limit := StrToIntDef(Param(Lines, 'limit'), 200);+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'no PCB document is current') + '}';+        Exit;+    End;+    Items := '';+    N := 0;+    Iter := Board.BoardIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eViolationObject));+    Iter.AddFilter_LayerSet(AllLayers);+    Iter.AddFilter_Method(eProcessAll);+    V := Iter.FirstPCBObject;+    While V <> Nil Do+    Begin+        If N < Limit Then+        Begin+            If Items <> '' Then Items := Items + ',';+            Items := Items + '{' + JStr('rule', V.Rule.Name) + ',' + JStr('description', V.Description) + '}';+        End;+        N := N + 1;+        V := Iter.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(Iter);+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('board', Board.FileName) + ',' + JRaw('violationCount', IntToStr(N)) + ',' + JRaw('violations', '[' + Items + ']') + '}';+End;++{ Run an OutJob medium. path=<.OutJob>  medium=<OutputMedium name, e.g. Folder Structure> }+Function CmdOutJob(Lines : TStringList) : String;+Var+    Path, Medium, Kind : String;+    Doc : IServerDocument;+Begin+    Path := Param(Lines, 'path');+    Medium := Param(Lines, 'medium');+    Kind := Param(Lines, 'objectKind');+    If Kind = '' Then Kind := 'OutputBatch';+    If Not FileExists(Path) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'OutJob not found: ' + Path) + '}';+        Exit;+    End;+    ResetParameters;+    AddStringParameter('ObjectKind', 'Document');+    AddStringParameter('FileName', Path);+    RunProcess('WorkspaceManager:OpenObject');+    Doc := Client.GetDocumentByPath(Path);+    If Doc <> Nil Then Client.ShowDocument(Doc);+    ResetParameters;+    AddStringParameter('Action', 'Run');+    AddStringParameter('ObjectKind', Kind);+    If Medium <> '' Then AddStringParameter('OutputMedium', Medium);+    AddStringParameter('DisableDialog', 'True');+    RunProcess('WorkspaceManager:GenerateReport');+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('outjob', Path) + ',' + JStr('medium', Medium) + ',' + JStr('objectKind', Kind) + '}';+End;++{ ------------------------------------------------------- library authoring --- }++{ Split "a;b;c" into a TStringList (one item per ';'). }+Function SplitSemi(S : String) : TStringList;+Var+    K : Integer;+    Part : String;+Begin+    Result := TStringList.Create;+    While S <> '' Do+    Begin+        K := Pos(';', S);+        If K > 0 Then+        Begin+            Part := Copy(S, 1, K - 1);+            S := Copy(S, K + 1, Length(S));+        End+        Else+        Begin+            Part := S;+            S := '';+        End;+        If Trim(Part) <> '' Then Result.Add(Trim(Part));+    End;+End;++{ Field N (1-based) of a comma list. }+Function Field(S : String; N : Integer) : String;+Var+    I, K : Integer;+Begin+    Result := '';+    For I := 1 To N Do+    Begin+        K := Pos(',', S);+        If K > 0 Then+        Begin+            Result := Copy(S, 1, K - 1);+            S := Copy(S, K + 1, Length(S));+        End+        Else+        Begin+            Result := S;+            S := '';+        End;+    End;+    Result := Trim(Result);+End;++{ Open an existing library or create + save a new one. kind=PCBLIB|SCHLIB  path=<file> }+Function EnsureLibrary(Kind : String; Path : String) : String;+Var+    Doc : IServerDocument;+Begin+    If FileExists(Path) Then+    Begin+        ResetParameters;+        AddStringParameter('ObjectKind', 'Document');+        AddStringParameter('FileName', Path);+        RunProcess('WorkspaceManager:OpenObject');+        Doc := Client.GetDocumentByPath(Path);+        If Doc <> Nil Then Client.ShowDocument(Doc);+        Result := 'opened';+    End+    Else+    Begin+        Doc := CreateNewDocumentFromDocumentKind(Kind);+        If Doc = Nil Then+        Begin+            Result := 'error: could not create ' + Kind;+            Exit;+        End;+        Client.ShowDocument(Doc);+        Doc.DoSafeChangeFileNameAndSave(Path, Kind);+        Result := 'created';+    End;+End;++{ create_footprint: name, libraryPath, description,+  pads = des,x_mil,y_mil,w_mil,h_mil,shape(rect|round),hole_mil ; ...   (hole 0 = SMD) }+Function CmdCreateFootprint(Lines : TStringList) : String;+Var+    PLib  : IPCB_Library;+    Comp  : IPCB_LibComponent;+    Pad   : IPCB_Pad;+    Pads  : TStringList;+    I     : Integer;+    Name, LibPath, Desc, Spec, Shape, LibState : String;+    Hole  : Integer;+Begin+    Name := Param(Lines, 'name');+    LibPath := Param(Lines, 'libraryPath');+    Desc := Param(Lines, 'description');+    If (Name = '') Or (LibPath = '') Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'name and libraryPath are required') + '}';+        Exit;+    End;+    LibState := EnsureLibrary('PCBLIB', LibPath);+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'no PCB library is current (' + LibState + ')') + '}';+        Exit;+    End;+    PCBServer.PreProcess;+    Comp := PCBServer.CreatePCBLibComp;+    Comp.Name := Name;+    Comp.Description := Desc;+    Pads := SplitSemi(Param(Lines, 'pads'));+    For I := 0 To Pads.Count - 1 Do+    Begin+        Spec := Pads[I];+        Pad := PCBServer.PCBObjectFactory(ePadObject, eNoDimension, eCreate_Default);+        Pad.Name := Field(Spec, 1);+        Pad.X := MilsToCoord(StrToFloat(Field(Spec, 2)));+        Pad.Y := MilsToCoord(StrToFloat(Field(Spec, 3)));+        Pad.TopXSize := MilsToCoord(StrToFloat(Field(Spec, 4)));+        Pad.TopYSize := MilsToCoord(StrToFloat(Field(Spec, 5)));+        Shape := LowerCase(Field(Spec, 6));+        If Shape = 'round' Then Pad.TopShape := eRounded Else Pad.TopShape := eRectangular;+        Hole := 0;+        If Field(Spec, 7) <> '' Then Hole := StrToInt(Field(Spec, 7));+        If Hole > 0 Then+        Begin+            Pad.HoleSize := MilsToCoord(Hole);+            Pad.Layer := eMultiLayer;+        End+        Else+        Begin+            Pad.HoleSize := 0;+            Pad.Layer := eTopLayer;+        End;+        Comp.AddPCBObject(Pad);+    End;+    Pads.Free;+    PLib.RegisterComponent(Comp);+    PLib.CurrentComponent := Comp;+    PCBServer.PostProcess;+    PLib.Board.ViewManager_FullUpdate;+    PLib.RefreshView;+    ResetParameters;+    AddStringParameter('Action', 'All');+    RunProcess('PCB:Zoom');+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('footprint', Name) + ',' + JStr('library', LibPath) + ',' + JStr('libraryState', LibState) + ',' + JRaw('pads', IntToStr(Comp.GetPrimitiveCount(MkSet(ePadObject)))) + '}';+End;++{ create_symbol: name, libraryPath, designator(U), description, body_w_mil, body_h_mil,+  pins = des,name,side(left|right|top|bottom),offset_mil ; ...  }+Function CmdCreateSymbol(Lines : TStringList) : String;+Var+    SLib  : ISch_Lib;+    Comp  : ISch_Component;+    Rect  : ISch_Rectangle;+    Pin   : ISch_Pin;+    Pins  : TStringList;+    I, W, H, Off : Integer;+    Name, LibPath, Desc, Des, Spec, Side, LibState : String;+Begin+    Name := Param(Lines, 'name');+    LibPath := Param(Lines, 'libraryPath');+    Desc := Param(Lines, 'description');+    Des := Param(Lines, 'designator');+    If Des = '' Then Des := 'U';+    W := StrToIntDef(Param(Lines, 'body_w_mil'), 800);+    H := StrToIntDef(Param(Lines, 'body_h_mil'), 600);+    If (Name = '') Or (LibPath = '') Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'name and libraryPath are required') + '}';+        Exit;+    End;+    LibState := EnsureLibrary('SCHLIB', LibPath);+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'no schematic library is current (' + LibState + ')') + '}';+        Exit;+    End;+    Comp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+    Comp.CurrentPartID := 1;+    Comp.DisplayMode := 0;+    Comp.LibReference := Name;+    Comp.ComponentDescription := Desc;+    Comp.Designator.Text := Des;+    Rect := SchServer.SchObjectFactory(eRectangle, eCreate_Default);+    Rect.OwnerPartId := 1;+    Rect.OwnerPartDisplayMode := 0;+    Rect.Location := Point(MilsToCoord(0), MilsToCoord(0));+    Rect.Corner := Point(MilsToCoord(W), MilsToCoord(H));+    Rect.IsSolid := True;+    Comp.AddSchObject(Rect);+    Pins := SplitSemi(Param(Lines, 'pins'));+    For I := 0 To Pins.Count - 1 Do+    Begin+        Spec := Pins[I];+        Pin := SchServer.SchObjectFactory(ePin, eCreate_Default);+        Pin.OwnerPartId := 1;+        Pin.OwnerPartDisplayMode := 0;+        Pin.Designator := Field(Spec, 1);+        Pin.Name := Field(Spec, 2);+        Side := LowerCase(Field(Spec, 3));+        Off := StrToIntDef(Field(Spec, 4), 100);+        Pin.PinLength := MilsToCoord(300);+        If Side = 'right' Then+        Begin+            Pin.Orientation := eRotate0;+            Pin.Location := Point(MilsToCoord(W), MilsToCoord(H - Off));+        End+        Else If Side = 'top' Then+        Begin+            Pin.Orientation := eRotate90;+            Pin.Location := Point(MilsToCoord(Off), MilsToCoord(H));+        End+        Else If Side = 'bottom' Then+        Begin+            Pin.Orientation := eRotate270;+            Pin.Location := Point(MilsToCoord(Off), MilsToCoord(0));+        End+        Else+        Begin+            Pin.Orientation := eRotate180;+            Pin.Location := Point(MilsToCoord(0), MilsToCoord(H - Off));+        End;+        Comp.AddSchObject(Pin);+    End;+    I := Pins.Count;+    Pins.Free;+    SLib.AddSchComponent(Comp);+    SchServer.RobotManager.SendMessage(SLib.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Comp.I_ObjectAddress);+    SLib.CurrentSchComponent := Comp;+    SLib.GraphicallyInvalidate;+    ResetParameters;+    AddStringParameter('Action', 'All');+    RunProcess('Sch:Zoom');+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('symbol', Name) + ',' + JStr('library', LibPath) + ',' + JStr('libraryState', LibState) + ',' + JRaw('pins', IntToStr(I)) + '}';+End;++{ --------------------------------------------- library management (Barrett port) --- }++{ Register a library in Altium's installed-libraries list (IntLib, SchLib, PcbLib, DbLib). }+Function CmdInstallLibrary(Lines : TStringList) : String;+Var+    Path : String;+Begin+    Path := Param(Lines, 'path');+    If Not FileExists(Path) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'library not found: ' + Path) + '}';+        Exit;+    End;+    IntegratedLibraryManager.InstallLibrary(Path);+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('installed', Path) + '}';+End;++{ Compile a .LibPkg into a .IntLib: path=<LibPkg> output=<dir> }+Function CmdCompileIntLib(Lines : TStringList) : String;+Var+    Path, OutDir : String;+    Proj : IProject;+Begin+    Path := Param(Lines, 'path');+    OutDir := Param(Lines, 'output');+    If Not FileExists(Path) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'LibPkg not found: ' + Path) + '}';+        Exit;+    End;+    Proj := GetWorkspace.DM_OpenProject(Path, True);+    If Proj = Nil Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'could not open LibPkg') + '}';+        Exit;+    End;+    Proj.DM_Compile;+    IntegratedLibraryManager.CreateIntegratedLibrary(Proj, OutDir, True);+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('libpkg', Path) + ',' + JStr('outputDir', OutDir) + '}';+End;++{ Extract editable SchLib/PcbLib sources from a compiled .IntLib: path=<IntLib> }+Function CmdExtractIntLib(Lines : TStringList) : String;+Var+    Path, P, Acc : String;+    WS : IWorkspace;+    Prj : IProject;+    Doc : IDocument;+    I, J : Integer;+Begin+    Path := Param(Lines, 'path');+    If Not FileExists(Path) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'IntLib not found: ' + Path) + '}';+        Exit;+    End;+    IntegratedLibraryManager.ExtractSources(Path);+    Acc := '';+    WS := GetWorkspace;+    If WS <> Nil Then+        For I := 0 To WS.DM_ProjectCount - 1 Do+        Begin+            Prj := WS.DM_Projects(I);+            For J := 0 To Prj.DM_LogicalDocumentCount - 1 Do+            Begin+                Doc := Prj.DM_LogicalDocuments(J);+                P := Doc.DM_FullPath;+                If (Pos('.SchLib', P) > 0) Or (Pos('.PcbLib', P) > 0) Or (Pos('.SCHLIB', P) > 0) Or (Pos('.PCBLIB', P) > 0) Then+                Begin+                    If Acc <> '' Then Acc := Acc + ',';+                    Acc := Acc + '"' + JsonEsc(P) + '"';+                End;+            End;+        End;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('intlib', Path) + ',' + JRaw('sources', '[' + Acc + ']') + '}';+End;++{ Set/replace parameters on a component of the CURRENT SchLib and optionally link a footprint.+  name=<LibReference>  params=Key=Value;Key=Value  footprint=<PcbLib footprint name>  comment=<text> }+Procedure SetSchParam(Cmp : ISch_Component; PName : String; PVal : String);+Var+    It : ISch_Iterator;+    Pm, Existing : ISch_Parameter;+Begin+    Existing := Nil;+    It := Cmp.SchIterator_Create;+    It.AddFilter_ObjectSet(MkSet(eParameter));+    Pm := It.FirstSchObject;+    While Pm <> Nil Do+    Begin+        If Pm.Name = PName Then+        Begin+            Existing := Pm;+            Break;+        End;+        Pm := It.NextSchObject;+    End;+    Cmp.SchIterator_Destroy(It);+    If Existing <> Nil Then+        Existing.Text := PVal+    Else+    Begin+        Pm := SchServer.SchObjectFactory(eParameter, eCreate_GlobalCopy);+        Pm.Name := PName;+        Pm.Text := PVal;+        { library-part parameters are metadata; visible ones pile up over the symbol (measured 2026-08-30) }+        Pm.IsHidden := True;+        Cmp.AddSchObject(Pm);+    End;+End;++Function CmdSetParams(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    Iter : ISch_Iterator;+    Comp : ISch_Component;+    Impl : ISch_Implementation;+    Name, Fp, Cmt, Spec, K, V : String;+    Items : TStringList;+    I, P, N : Integer;+Begin+    Name := Param(Lines, 'name');+    Fp := Param(Lines, 'footprint');+    Cmt := Param(Lines, 'comment');+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'current document is not a schematic library') + '}';+        Exit;+    End;+    Comp := Nil;+    Iter := SLib.SchLibIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+    Comp := Iter.FirstSchObject;+    While Comp <> Nil Do+    Begin+        If (Name = '') Or (UpperCase(Comp.LibReference) = UpperCase(Name)) Then Break;+        Comp := Iter.NextSchObject;+    End;+    SLib.SchIterator_Destroy(Iter);+    If Comp = Nil Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'component not found: ' + Name) + '}';+        Exit;+    End;+    N := 0;+    Items := SplitSemi(Param(Lines, 'params'));+    For I := 0 To Items.Count - 1 Do+    Begin+        Spec := Items[I];+        P := Pos('=', Spec);+        If P > 0 Then+        Begin+            K := Trim(Copy(Spec, 1, P - 1));+            V := Trim(Copy(Spec, P + 1, Length(Spec)));+            SetSchParam(Comp, K, V);+            N := N + 1;+        End;+    End;+    Items.Free;+    If Cmt <> '' Then Comp.Comment.Text := Cmt;+    If Fp <> '' Then+    Begin+        Impl := Comp.AddSchImplementation;+        Impl.ClearAllDatafileLinks;+        Impl.ModelName := Fp;+        Impl.ModelType := 'PCBLIB';+        Impl.AddDataFileLink(Fp, '', 'PCBLIB');+        Impl.IsCurrent := True;+    End;+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('component', Comp.LibReference) + ',' + JRaw('paramsSet', IntToStr(N)) + ',' + JStr('footprint', Fp) + '}';+End;++{ Add a 3D body to a footprint of the CURRENT PcbLib: name=<footprint>+  step=<path.step>  OR  extruded box: bw_mil,bh_mil,h_mil,so_mil }+{ ------------------------------------------------- diagnostics: pcb_report --- }++Function BodyJson(Prim : IPCB_Primitive) : String;+Var+    R : TCoordRect;+    HasModel : String;+Begin+    R := Prim.BoundingRectangle;+    HasModel := 'false';+    Try+        If Prim.Model <> Nil Then HasModel := 'true';+    Except+    End;+    Result := '[' + IntToStr(Prim.Layer) + ',' ++        IntToStr(Round(CoordToMils(R.Right - R.Left))) + ',' ++        IntToStr(Round(CoordToMils(R.Top - R.Bottom))) + ',' ++        IntToStr(Round(CoordToMils(R.Left))) + ',' ++        IntToStr(Round(CoordToMils(R.Bottom))) + ',' ++        IntToStr(Round(CoordToMils(Prim.StandoffHeight))) + ',' ++        IntToStr(Round(CoordToMils(Prim.OverallHeight))) + ',' ++        HasModel + ']';+End;++Function GroupBodies(Grp : IPCB_Group) : String;+Var+    GIt  : IPCB_GroupIterator;+    Prim : IPCB_Primitive;+    S    : String;+Begin+    S := '';+    GIt := Grp.GroupIterator_Create;+    GIt.AddFilter_ObjectSet(MkSet(eComponentBodyObject));+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        If S <> '' Then S := S + ',';+        S := S + BodyJson(Prim);+        Prim := GIt.NextPCBObject;+    End;+    Grp.GroupIterator_Destroy(GIt);+    Result := '[' + S + ']';+End;++Function GroupCensus(Grp : IPCB_Group) : String;+Var+    GIt  : IPCB_GroupIterator;+    Prim : IPCB_Primitive;+    N    : Integer;+    S    : String;+Begin+    N := 0;+    S := '';+    GIt := Grp.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        N := N + 1;+        If S <> '' Then S := S + ';';+        S := S + IntToStr(Prim.ObjectId);+        Prim := GIt.NextPCBObject;+    End;+    Grp.GroupIterator_Destroy(GIt);+    Result := IntToStr(N) + ':' + S;+End;++Function GroupCount(Grp : IPCB_Group; Kind : Integer) : Integer;+Var+    GIt  : IPCB_GroupIterator;+    Prim : IPCB_Primitive;+    N    : Integer;+Begin+    N := 0;+    GIt := Grp.GroupIterator_Create;+    GIt.AddFilter_ObjectSet(MkSet(Kind));+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        N := N + 1;+        Prim := GIt.NextPCBObject;+    End;+    Grp.GroupIterator_Destroy(GIt);+    Result := N;+End;+++Function BoardBodyCount(Board : IPCB_Board) : Integer;+Var+    BIt  : IPCB_BoardIterator;+    Prim : IPCB_Primitive;+    N    : Integer;+Begin+    N := 0;+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eComponentBodyObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Prim := BIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        N := N + 1;+        Prim := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    Result := N;+End;++Function CmdAddBody(Lines : TStringList) : String;+Var+    PLib : IPCB_Library;+    Fp   : IPCB_LibComponent;+    Body : IPCB_ComponentBody;+    Mdl  : IPCB_Model;+    Cont : IPCB_Contour;+    Name, StepPath, Note : String;+    I, BW, BH, HG, SO : Integer;+    Found : Boolean;+Begin+    Name := Param(Lines, 'name');+    StepPath := Param(Lines, 'step');+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'current document is not a PCB library') + '}';+        Exit;+    End;+    Found := False;+    For I := 0 To PLib.ComponentCount - 1 Do+    Begin+        Fp := PLib.GetComponent(I);+        If (Name = '') Or (UpperCase(Fp.Name) = UpperCase(Name)) Then+        Begin+            Found := True;+            Break;+        End;+    End;+    If Not Found Then+    Begin+        Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'footprint not found: ' + Name) + '}';+        Exit;+    End;+    PCBServer.PreProcess;+    Body := PCBServer.PCBObjectFactory(eComponentBodyObject, eNoDimension, eCreate_Default);+    { A primitive without a layer is silently dropped on AddPCBObject (measured+      2026-08-30: add returned ok, census showed 16 pads and 0 bodies). Component+      bodies live on a mechanical layer. }+    Body.Layer := eMechanical1;+    { An invisible body is indistinguishable from a missing one: opacity defaults+      to 0 in this engine. Make sure the mech layer is enabled and the body opaque. }+    PLib.Board.LayerStack.LayerObject[eMechanical1].MechanicalLayerEnabled := True;+    PLib.Board.LayerIsDisplayed[eMechanical1] := True;+    Body.BodyOpacity3D := 1.0;+    If StepPath <> '' Then+    Begin+        Mdl := Body.ModelFactory_FromFilename(StepPath, False);+        If Mdl = Nil Then+        Begin+            PCBServer.PostProcess;+            Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'could not load model: ' + StepPath) + '}';+            Exit;+        End;+        Body.SetState_FromModel;+        Body.Model := Mdl;+        Note := 'step';+    End+    Else+    Begin+        BW := StrToIntDef(Param(Lines, 'bw_mil'), 100);+        BH := StrToIntDef(Param(Lines, 'bh_mil'), 100);+        HG := StrToIntDef(Param(Lines, 'h_mil'), 40);+        SO := StrToIntDef(Param(Lines, 'so_mil'), 0);+        Body.BodyProjection := eBoardSide_Top;+        Body.StandoffHeight := MilsToCoord(SO);+        Body.OverallHeight := MilsToCoord(SO + HG);+        Body.BodyColor3D := $00202020;+        Cont := PCBServer.PCBContourFactory;+        Cont.AddPoint(MilsToCoord(-(BW Div 2)), MilsToCoord(-(BH Div 2)));+        Cont.AddPoint(MilsToCoord((BW Div 2)), MilsToCoord(-(BH Div 2)));+        Cont.AddPoint(MilsToCoord((BW Div 2)), MilsToCoord((BH Div 2)));+        Cont.AddPoint(MilsToCoord(-(BW Div 2)), MilsToCoord((BH Div 2)));+        Body.SetOutlineContour(Cont);+        Note := 'extruded';+    End;+    { The stored component is NOT the editing surface: a direct Fp.AddPCBObject+      counted 1 body immediately but the editor's canvas copy overwrote it on+      save/reopen - libBodies read back 0 (measured 2026-08-30). Make Fp current+      FIRST, then add to the CANVAS (PLib.Board); the editor owns the canvas and+      syncs it into the stored component on save. }+    PLib.CurrentComponent := Fp;+    PLib.Board.AddPCBObject(Body);+    PCBServer.SendMessageToRobots(PLib.Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Body.I_ObjectAddress);+    Note := Note + ':canvas';+    PCBServer.PostProcess;+    PLib.Board.ViewManager_FullUpdate;+    PLib.RefreshView;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('footprint', Fp.Name) + ',' + JStr('body', Note) + ',' ++        JRaw('bodiesNow', IntToStr(GroupCount(Fp, eComponentBodyObject))) + ',' ++        JRaw('bodiesOnCanvas', IntToStr(BoardBodyCount(PLib.Board))) + '}';+End;++{ --------------------------------------------------- demo: place on sch + pcb --- }++{ Create a new schematic sheet and place the CH340G symbol (Replicated from the+  currently-open SchLib) at the centre. sch=<save path> libref=<component> }+Function CmdPlaceOnSch(Lines : TStringList) : String;+Var+    SLib   : ISch_Lib;+    Iter   : ISch_Iterator;+    PIt    : ISch_Iterator;+    LibCmp : ISch_Component;+    Inst   : ISch_Component;+    Prim   : ISch_GraphicalObject;+    Cpy    : ISch_GraphicalObject;+    SD     : IServerDocument;+    Sheet  : ISch_Document;+    SchPath, LibRef : String;+    NCopied : Integer;+Begin+    SchPath := Param(Lines, 'sch');+    LibRef := Param(Lines, 'libref');+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first (it must be the current document)') + '}';+        Exit;+    End;+    LibCmp := Nil;+    Iter := SLib.SchLibIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+    LibCmp := Iter.FirstSchObject;+    While LibCmp <> Nil Do+    Begin+        If UpperCase(LibCmp.LibReference) = UpperCase(LibRef) Then Break;+        LibCmp := Iter.NextSchObject;+    End;+    SLib.SchIterator_Destroy(Iter);+    If LibCmp = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found in SchLib: ' + LibRef) + '}';+        Exit;+    End;+    SD := CreateNewDocumentFromDocumentKind('SCH');+    If SD = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a schematic sheet') + '}';+        Exit;+    End;+    Client.ShowDocument(SD);+    Sheet := SchServer.GetCurrentSchDocument;+    { Build a fresh component instance and copy the library part's primitives into it.+      ISch_Component.Replicate does NOT re-parent the pins/rectangle onto a normal+      sheet (measured 2026-08-30: only the designator/comment text placed), so copy+      each child primitive with primitive-level Replicate + AddSchObject. }+    Inst := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+    Inst.CurrentPartID := 1;+    Inst.DisplayMode := 0;+    Inst.LibReference := LibCmp.LibReference;+    Inst.ComponentDescription := LibCmp.ComponentDescription;+    Inst.Location := Point(MilsToCoord(5000), MilsToCoord(4000));+    NCopied := 0;+    PIt := LibCmp.SchIterator_Create;+    Prim := PIt.FirstSchObject;+    While Prim <> Nil Do+    Begin+        If (Prim.ObjectID = ePin) Or (Prim.ObjectID = eRectangle) Or+           (Prim.ObjectID = eLine) Or (Prim.ObjectID = eArc) Or+           (Prim.ObjectID = eEllipse) Or (Prim.ObjectID = ePolygon) Or+           (Prim.ObjectID = ePolyline) Or (Prim.ObjectID = eLabel) Then+        Begin+            Cpy := Prim.Replicate;+            Cpy.OwnerPartId := 1;+            Cpy.OwnerPartDisplayMode := 0;+            Inst.AddSchObject(Cpy);+            NCopied := NCopied + 1;+        End;+        Prim := PIt.NextSchObject;+    End;+    LibCmp.SchIterator_Destroy(PIt);+    Inst.Designator.Text := 'U1';+    Inst.Designator.IsHidden := False;+    Sheet.RegisterSchObjectInContainer(Inst);+    SchServer.RobotManager.SendMessage(Sheet.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Inst.I_ObjectAddress);+    Sheet.GraphicallyInvalidate;+    ResetParameters; AddStringParameter('Action','All'); RunProcess('Sch:Zoom');+    If SchPath <> '' Then+    Begin+        SD.SetFileName(SchPath);+        SD.DoFileSave('');+    End;+    Result := '{' + JRaw('ok','true') + ',' + JStr('sch', SchPath) + ',' + JStr('placed', LibRef) + ',' + JStr('designator','U1') + ',' + JRaw('primitives', IntToStr(NCopied)) + '}';+End;++Function CmdPlaceOnPcb(Lines : TStringList) : String;+Var+    PLib   : IPCB_Library;+    LibFp  : IPCB_LibComponent;+    Inst   : IPCB_Component;+    Board  : IPCB_Board;+    SD     : IServerDocument;+    GIt    : IPCB_GroupIterator;+    Prim   : IPCB_Primitive;+    Cpy    : IPCB_Primitive;+    PcbPath, FpName : String;+    I, NCopied : Integer;+    Found  : Boolean;+    R      : TCoordRect;+    DX, DY, CL, CB, CR, CT : Integer;+    AnyP   : Boolean;+Begin+    PcbPath := Param(Lines, 'pcb');+    FpName := Param(Lines, 'footprint');+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the PcbLib first (it must be the current document)') + '}';+        Exit;+    End;+    Found := False;+    For I := 0 To PLib.ComponentCount - 1 Do+    Begin+        LibFp := PLib.GetComponent(I);+        If UpperCase(LibFp.Name) = UpperCase(FpName) Then Begin Found := True; Break; End;+    End;+    If Not Found Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found in PcbLib: ' + FpName) + '}';+        Exit;+    End;+    { ALWAYS create a fresh PcbDoc: with a PcbLib focused, GetCurrentPCBBoard+      returns the LIBRARY internal board - a reuse path put the part into the lib+      canvas and renamed the lib doc, measured 2026-08-30. }+    SD := CreateNewDocumentFromDocumentKind('PCB');+    If SD = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a PCB document') + '}';+        Exit;+    End;+    Client.ShowDocument(SD);+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board') + '}';+        Exit;+    End;+    Board.LayerStack.LayerObject[eMechanical1].MechanicalLayerEnabled := True;+    Board.LayerIsDisplayed[eMechanical1] := True;+    WriteAtomic(WORKSPACE + '\stage.txt', 'board_ready');+    { Group Replicate of an IPCB_LibComponent DISSOLVES on a board: census showed+      16 free pads, no eComponentObject, no body - measured 2026-08-30. Build a+      fresh board component and copy each child primitive, the same cure the+      schematic placement needed. }+    PCBServer.PreProcess;+    Inst := PCBServer.PCBObjectFactory(eComponentObject, eNoDimension, eCreate_Default);+    Inst.Layer := eTopLayer;+    { The factory component's auto Designator1/Comment text is enormous and+      hijacks Zoom All - hide both. }+    Inst.NameOn := False;+    Inst.CommentOn := False;+    { ORDER MATTERS: fill the group BEFORE the board add. Children added to an+      already-registered component exist in data but never draw, whatever address+      they are robot-registered to (measured 2026-08-30). This mirrors+      create_footprint, where pads go in before RegisterComponent. }+    NCopied := 0;+    GIt := LibFp.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        Cpy := Prim.Replicate;+        Inst.AddPCBObject(Cpy);+        NCopied := NCopied + 1;+        Prim := GIt.NextPCBObject;+    End;+    LibFp.GroupIterator_Destroy(GIt);+    Board.AddPCBObject(Inst);+    PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Inst.I_ObjectAddress);+    WriteAtomic(WORKSPACE + '\stage.txt', 'component_registered');+    WriteAtomic(WORKSPACE + '\stage.txt', 'children_copied');+    { Copied children keep their LIBRARY coordinates (lib origin ~50000,50000 mil);+      anchor the fresh component onto them BEFORE the move so MoveToXY translates+      the whole group to the target (bbox was 50146 mil wide without this). }+    { Anchor the component BEFORE normalizing children: assigning Inst.x/y+      afterwards re-translates the whole group by the same delta (measured+      2026-08-30: everything landed at 8000,6000 instead of 4000,3000). }+    Inst.x := MilsToCoord(4000);+    Inst.y := MilsToCoord(3000);+    { Normalize positions: the lib stores pads around the canvas origin+      (~50000,50000 mil) but an authored body contour around 0,0 - one group move+      cannot land both (measured 2026-08-30: body at 4000, pads at 54000). Move+      the pad cluster and each body to the target independently. }+    AnyP := False;+    CL := 0; CB := 0; CR := 0; CT := 0;+    GIt := Inst.GroupIterator_Create;+    GIt.AddFilter_ObjectSet(MkSet(ePadObject));+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        R := Prim.BoundingRectangle;+        If Not AnyP Then+        Begin+            CL := R.Left; CB := R.Bottom; CR := R.Right; CT := R.Top; AnyP := True;+        End+        Else+        Begin+            If R.Left < CL Then CL := R.Left;+            If R.Bottom < CB Then CB := R.Bottom;+            If R.Right > CR Then CR := R.Right;+            If R.Top > CT Then CT := R.Top;+        End;+        Prim := GIt.NextPCBObject;+    End;+    Inst.GroupIterator_Destroy(GIt);+    DX := 0; DY := 0;+    If AnyP Then+    Begin+        DX := MilsToCoord(4000) - ((CL + CR) Div 2);+        DY := MilsToCoord(3000) - ((CB + CT) Div 2);+    End;+    GIt := Inst.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        If Prim.ObjectId = eComponentBodyObject Then+        Begin+            R := Prim.BoundingRectangle;+            Prim.MoveByXY(MilsToCoord(4000) - ((R.Left + R.Right) Div 2),+                          MilsToCoord(3000) - ((R.Bottom + R.Top) Div 2));+        End+        Else If Prim.ObjectId <> eTextObject Then+            Prim.MoveByXY(DX, DY);+        Prim := GIt.NextPCBObject;+    End;+    Inst.GroupIterator_Destroy(GIt);+    { Hidden auto Name/Comment texts otherwise bloat the bbox by ~50000 mil. }+    Inst.Name.MoveToXY(MilsToCoord(4000), MilsToCoord(3250));+    Inst.Comment.MoveToXY(MilsToCoord(4000), MilsToCoord(2750));+    PCBServer.PostProcess;+    WriteAtomic(WORKSPACE + '\stage.txt', 'moved');+    Board.ViewManager_FullUpdate;+    ResetParameters; AddStringParameter('Action','All'); RunProcess('PCB:Zoom');+    If PcbPath <> '' Then+    Begin+        SD.SetFileName(PcbPath);+        SD.DoFileSave('');+    End;+    WriteAtomic(WORKSPACE + '\stage.txt', 'saved');+    Result := '{' + JRaw('ok','true') + ',' + JStr('pcb', PcbPath) + ',' + JStr('placed', FpName) + ',' ++        JStr('libFile', PLib.Board.FileName) + ',' ++        JRaw('libBodies', IntToStr(GroupCount(LibFp, eComponentBodyObject))) + ',' ++        JRaw('primitives', IntToStr(NCopied)) + ',' ++        JRaw('padsOnComp', IntToStr(GroupCount(Inst, ePadObject))) + ',' ++        JRaw('bodiesOnComp', IntToStr(GroupCount(Inst, eComponentBodyObject))) + '}';+End;++Function BoardCensus(Board : IPCB_Board) : String;+Var+    BIt  : IPCB_BoardIterator;+    Prim : IPCB_Primitive;+    N    : Integer;+    S    : String;+Begin+    N := 0;+    S := '';+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(AllObjects);+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Prim := BIt.FirstPCBObject;+    While (Prim <> Nil) And (N < 200) Do+    Begin+        N := N + 1;+        If S <> '' Then S := S + ';';+        S := S + IntToStr(Prim.ObjectId);+        Prim := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    Result := IntToStr(N) + ':' + S;+End;+++Function GroupPadBbox(Grp : IPCB_Group) : String;+Var+    GIt  : IPCB_GroupIterator;+    Prim : IPCB_Primitive;+    R    : TCoordRect;+    L, B, Rt, T : Integer;+    Any  : Boolean;+Begin+    Any := False;+    L := 0; B := 0; Rt := 0; T := 0;+    GIt := Grp.GroupIterator_Create;+    GIt.AddFilter_ObjectSet(MkSet(ePadObject));+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        R := Prim.BoundingRectangle;+        If Not Any Then+        Begin+            L := R.Left; B := R.Bottom; Rt := R.Right; T := R.Top; Any := True;+        End+        Else+        Begin+            If R.Left < L Then L := R.Left;+            If R.Bottom < B Then B := R.Bottom;+            If R.Right > Rt Then Rt := R.Right;+            If R.Top > T Then T := R.Top;+        End;+        Prim := GIt.NextPCBObject;+    End;+    Grp.GroupIterator_Destroy(GIt);+    If Not Any Then+    Begin+        Result := '[]';+        Exit;+    End;+    Result := '[' + IntToStr(Round(CoordToMils(L))) + ',' + IntToStr(Round(CoordToMils(B))) + ',' ++        IntToStr(Round(CoordToMils(Rt))) + ',' + IntToStr(Round(CoordToMils(T))) + ']';+End;+++{ Net names on the pads that matter for the swap proof: "des=net;..." for pads+  1 and 49 (every pad named 49 listed), plus the track count and their nets. }+Function CompNets(Comp : IPCB_Component) : String;+Var+    GIt : IPCB_GroupIterator;+    Pad : IPCB_Pad;+    S, N : String;+Begin+    S := '';+    GIt := Comp.GroupIterator_Create;+    GIt.AddFilter_ObjectSet(MkSet(ePadObject));+    GIt.AddFilter_LayerSet(AllLayers);+    Pad := GIt.FirstPCBObject;+    While Pad <> Nil Do+    Begin+        If (Pad.Name = '1') Or (Pad.Name = '49') Then+        Begin+            N := '';+            If Pad.Net <> Nil Then N := Pad.Net.Name;+            If S <> '' Then S := S + ';';+            S := S + Pad.Name + '=' + N;+        End;+        Pad := GIt.NextPCBObject;+    End;+    Comp.GroupIterator_Destroy(GIt);+    Result := S;+End;++Function BoardTracks(Board : IPCB_Board) : String;+Var+    BIt : IPCB_BoardIterator;+    T   : IPCB_Track;+    S, N : String;+Begin+    S := '';+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eTrackObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    T := BIt.FirstPCBObject;+    While T <> Nil Do+    Begin+        N := '';+        If T.Net <> Nil Then N := T.Net.Name;+        If S <> '' Then S := S + ';';+        S := S + N + '@' + IntToStr(Round(CoordToMils(T.X1))) + ',' + IntToStr(Round(CoordToMils(T.Y1)));+        T := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    Result := S;+End;++Function CmdPcbReport(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    PLib  : IPCB_Library;+    Fp    : IPCB_LibComponent;+    Comp  : IPCB_Component;+    BIt   : IPCB_BoardIterator;+    R     : TCoordRect;+    Name, S : String;+    I     : Integer;+Begin+    PLib := PCBServer.GetCurrentPCBLibrary;+    Name := Param(Lines, 'name');+    { GetCurrentPCBLibrary answers even when a BOARD is focused; want=board skips+      the library branch so the report describes the focused board. }+    If Param(Lines, 'want') = 'board' Then PLib := Nil;+    If PLib <> Nil Then+    Begin+        S := '';+        For I := 0 To PLib.ComponentCount - 1 Do+        Begin+            Fp := PLib.GetComponent(I);+            If (Name = '') Or (UpperCase(Fp.Name) = UpperCase(Name)) Then+            Begin+                If S <> '' Then S := S + ',';+                S := S + '{' + JStr('name', Fp.Name) + ',' ++                    JRaw('pads', IntToStr(GroupCount(Fp, ePadObject))) + ',' ++                    JRaw('padsBboxMil', GroupPadBbox(Fp)) + ',' ++                    JStr('census', GroupCensus(Fp)) + ',' ++                    JRaw('bodies', GroupBodies(Fp)) + '}';+            End;+        End;+        Result := '{' + JRaw('ok','true') + ',' + JStr('kind','library') + ',' ++            JRaw('components', '[' + S + ']') + ',' ++            JStr('bodyCols','layerId,wMil,hMil,leftMil,bottomMil,standoffMil,overallMil,hasModel') + '}';+        Exit;+    End;+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','current document is neither a PCB library nor a board') + '}';+        Exit;+    End;+    S := '';+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Comp := BIt.FirstPCBObject;+    While Comp <> Nil Do+    Begin+        If S <> '' Then S := S + ',';+        R := Comp.BoundingRectangle;+        S := S + '{' + JStr('pattern', Comp.Pattern) + ',' ++            JRaw('xMil', IntToStr(Round(CoordToMils(Comp.x)))) + ',' ++            JRaw('yMil', IntToStr(Round(CoordToMils(Comp.y)))) + ',' ++            JRaw('wMil', IntToStr(Round(CoordToMils(R.Right - R.Left)))) + ',' ++            JRaw('pads', IntToStr(GroupCount(Comp, ePadObject))) + ',' ++            JRaw('padsBboxMil', GroupPadBbox(Comp)) + ',' ++            JStr('nets', CompNets(Comp)) + ',' ++            JRaw('bodies', GroupBodies(Comp)) + '}';+        Comp := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    { A library's internal board has no outline; touching BoundingRectangle on the+      nil BoardOutline halts the engine (measured 2026-08-30). }+    If Board.BoardOutline <> Nil Then+        R := Board.BoardOutline.BoundingRectangle+    Else+        R := Board.BoundingRectangle;+    Result := '{' + JRaw('ok','true') + ',' + JStr('kind','board') + ',' ++        JRaw('outlineMil', '[' + IntToStr(Round(CoordToMils(R.Left))) + ',' + IntToStr(Round(CoordToMils(R.Bottom))) + ',' ++             IntToStr(Round(CoordToMils(R.Right))) + ',' + IntToStr(Round(CoordToMils(R.Top))) + ']') + ',' ++        JRaw('components', '[' + S + ']') + ',' ++        JStr('census', BoardCensus(Board)) + ',' ++        JStr('tracks', BoardTracks(Board)) + ',' ++        JStr('bodyCols','layerId,wMil,hMil,leftMil,bottomMil,standoffMil,overallMil,hasModel') + '}';+End;++{ ------------------------------------------------ build_footprint (spec file) --- }++{ Pipe-separated field N (1-based) of S. }+Function FieldP(S : String; N : Integer) : String;+Var+    I, Cnt, Start : Integer;+Begin+    Cnt := 1; Start := 1; Result := '';+    For I := 1 To Length(S) Do+    Begin+        If S[I] = '|' Then+        Begin+            If Cnt = N Then Begin Result := Copy(S, Start, I - Start); Exit; End;+            Cnt := Cnt + 1; Start := I + 1;+        End;+    End;+    If Cnt = N Then Result := Copy(S, Start, Length(S) - Start + 1);+End;++Function MmC(S : String) : Integer;+Begin+    Result := MMsToCoord(StrToFloat(S));+End;++Function SpecLayer(Tag : String) : Integer;+Begin+    If Tag = 'overlay' Then Result := eTopOverlay+    Else If Tag = 'fab' Then Result := eMechanical13+    Else If Tag = 'courtyard' Then Result := eMechanical15+    Else If Tag = 'balls' Then Result := eMechanical9+    Else If Tag = 'comment' Then Result := eMechanical1+    Else If Tag = 'multi' Then Result := eMultiLayer+    Else Result := eTopLayer;+End;++{ Build a footprint from a spec file (tools/kicad2altium.py output):+  META|name|desc  PAD|des|x|y|w|h|shape|hole|layer|crpct  LINE|layer|x1|y1|x2|y2|w+  ARC|layer|cx|cy|r|w|a0|a1  DISC|layer|cx|cy|r.  Units mm, Y up. Args:+  libraryPath, spec=<windows path>. Pads with a repeated designator are legal in+  Altium and all map to that pin (the heatsink-via convention). }+Function CmdBuildFootprint(Lines : TStringList) : String;+Var+    PLib  : IPCB_Library;+    Comp  : IPCB_LibComponent;+    Pad   : IPCB_Pad;+    Trk   : IPCB_Track;+    Arc   : IPCB_Arc;+    Spec  : TStringList;+    I, NPads, NTh, NPrims : Integer;+    L, Kind, Name, Desc, LibState, LibPath, SpecPath, Shape : String;+    R : Integer;+Begin+    LibPath := Param(Lines, 'libraryPath');+    SpecPath := Param(Lines, 'spec');+    If (LibPath = '') Or (SpecPath = '') Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','libraryPath and spec are required') + '}';+        Exit;+    End;+    LibState := EnsureLibrary('PCBLIB', LibPath);+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no PCB library is current (' + LibState + ')') + '}';+        Exit;+    End;+    Spec := TStringList.Create;+    Spec.LoadFromFile(SpecPath);+    Name := ''; Desc := '';+    NPads := 0; NTh := 0; NPrims := 0;+    { mech layers this spec may use: enable + name them once per library }+    PLib.Board.LayerStack.LayerObject[eMechanical1].MechanicalLayerEnabled := True;+    PLib.Board.LayerStack.LayerObject[eMechanical9].MechanicalLayerEnabled := True;+    PLib.Board.LayerStack.LayerObject[eMechanical9].Name := 'Adom.SolderBalls';+    PLib.Board.LayerStack.LayerObject[eMechanical13].MechanicalLayerEnabled := True;+    PLib.Board.LayerStack.LayerObject[eMechanical13].Name := 'Fab';+    PLib.Board.LayerStack.LayerObject[eMechanical15].MechanicalLayerEnabled := True;+    PLib.Board.LayerStack.LayerObject[eMechanical15].Name := 'Courtyard';+    PLib.Board.LayerIsDisplayed[eMechanical9] := False;+    PLib.Board.LayerIsDisplayed[eMechanical13] := True;+    PLib.Board.LayerIsDisplayed[eMechanical15] := True;+    PCBServer.PreProcess;+    Comp := PCBServer.CreatePCBLibComp;+    For I := 0 To Spec.Count - 1 Do+    Begin+        L := Spec[I];+        Kind := FieldP(L, 1);+        If Kind = 'META' Then+        Begin+            Name := FieldP(L, 2); Desc := FieldP(L, 3);+            Comp.Name := Name; Comp.Description := Desc;+        End+        Else If Kind = 'PAD' Then+        Begin+            Pad := PCBServer.PCBObjectFactory(ePadObject, eNoDimension, eCreate_Default);+            Pad.Name := FieldP(L, 2);+            Pad.X := MmC(FieldP(L, 3));+            Pad.Y := MmC(FieldP(L, 4));+            Pad.TopXSize := MmC(FieldP(L, 5));+            Pad.TopYSize := MmC(FieldP(L, 6));+            Shape := FieldP(L, 7);+            If Shape = 'rect' Then Pad.TopShape := eRectangular+            Else If Shape = 'roundrect' Then Pad.TopShape := eRoundedRectangular+            Else Pad.TopShape := eRounded;+            Pad.HoleSize := MmC(FieldP(L, 8));+            If FieldP(L, 9) = 'multi' Then+            Begin+                Pad.Layer := eMultiLayer;+                NTh := NTh + 1;+            End+            Else Pad.Layer := eTopLayer;+            R := StrToIntDef(FieldP(L, 10), 0);+            If (Shape = 'roundrect') And (R > 0) Then+                Pad.SetState_StackCRPctOnLayer(eTopLayer, R);+            Comp.AddPCBObject(Pad);+            NPads := NPads + 1;+        End+        Else If Kind = 'LINE' Then+        Begin+            Trk := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default);+            Trk.Layer := SpecLayer(FieldP(L, 2));+            Trk.X1 := MmC(FieldP(L, 3)); Trk.Y1 := MmC(FieldP(L, 4));+            Trk.X2 := MmC(FieldP(L, 5)); Trk.Y2 := MmC(FieldP(L, 6));+            Trk.Width := MmC(FieldP(L, 7));+            Comp.AddPCBObject(Trk);+            NPrims := NPrims + 1;+        End+        Else If Kind = 'ARC' Then+        Begin+            Arc := PCBServer.PCBObjectFactory(eArcObject, eNoDimension, eCreate_Default);+            Arc.Layer := SpecLayer(FieldP(L, 2));+            Arc.XCenter := MmC(FieldP(L, 3)); Arc.YCenter := MmC(FieldP(L, 4));+            Arc.Radius := MmC(FieldP(L, 5));+            Arc.LineWidth := MmC(FieldP(L, 6));+            Arc.StartAngle := StrToFloat(FieldP(L, 7)); Arc.EndAngle := StrToFloat(FieldP(L, 8));+            Comp.AddPCBObject(Arc);+            NPrims := NPrims + 1;+        End+        Else If Kind = 'DISC' Then+        Begin+            { a filled disc of radius r = a full arc at r/2 with stroke r }+            Arc := PCBServer.PCBObjectFactory(eArcObject, eNoDimension, eCreate_Default);+            Arc.Layer := SpecLayer(FieldP(L, 2));+            Arc.XCenter := MmC(FieldP(L, 3)); Arc.YCenter := MmC(FieldP(L, 4));+            Arc.Radius := MmC(FieldP(L, 5)) Div 2;+            Arc.LineWidth := MmC(FieldP(L, 5));+            Arc.StartAngle := 0; Arc.EndAngle := 360;+            Comp.AddPCBObject(Arc);+            NPrims := NPrims + 1;+        End;+    End;+    Spec.Free;+    PLib.RegisterComponent(Comp);+    PLib.CurrentComponent := Comp;+    PCBServer.PostProcess;+    PLib.Board.ViewManager_FullUpdate;+    PLib.RefreshView;+    { no PCB:Zoom here: an interactive process left 'a command active' and the+      next save drew Altium's save-a-copy Warning (measured 2026-08-30). }+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('footprint', Name) + ',' + JStr('description', Desc) + ',' ++        JStr('library', LibPath) + ',' + JStr('libraryState', LibState) + ',' ++        JRaw('pads', IntToStr(NPads)) + ',' + JRaw('throughHolePads', IntToStr(NTh)) + ',' ++        JRaw('drawingPrimitives', IntToStr(NPrims)) + '}';+End;+++{ ------------------------------------------- build_symbol_modes (spec file) --- }++{ ONE component with N display modes (the Altium alternate-symbol mechanism:+  Normal + Alternate 1..N-1). Spec (tools/symbol_modes.py), mils, Y up:+  META|libref|desc|designator  MODE|i|name  RECT|mode|x1|y1|x2|y2+  PIN|mode|number|name|x|y|orient|len  LINE|mode|x1|y1|x2|y2+  Args: libraryPath, spec=<windows path>. Every primitive carries+  OwnerPartId 1 and OwnerPartDisplayMode = its mode. }+Function CmdBuildSymbolModes(Lines : TStringList) : String;+Var+    SLib  : ISch_Lib;+    Comp  : ISch_Component;+    Rect  : ISch_Rectangle;+    Pin   : ISch_Pin;+    Ln    : ISch_Line;+    Spec  : TStringList;+    I, NModes, NPins, NLines, Md : Integer;+    L, Kind, LibRef, Desc, Des, LibPath, SpecPath, LibState, Orient : String;+Begin+    LibPath := Param(Lines, 'libraryPath');+    SpecPath := Param(Lines, 'spec');+    If (LibPath = '') Or (SpecPath = '') Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','libraryPath and spec are required') + '}';+        Exit;+    End;+    LibState := EnsureLibrary('SCHLIB', LibPath);+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no schematic library is current (' + LibState + ')') + '}';+        Exit;+    End;+    Spec := TStringList.Create;+    Spec.LoadFromFile(SpecPath);+    NModes := 0; NPins := 0; NLines := 0;+    LibRef := ''; Desc := ''; Des := 'U';+    Comp := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+    Comp.CurrentPartID := 1;+    Comp.DisplayMode := 0;+    For I := 0 To Spec.Count - 1 Do+    Begin+        L := Spec[I];+        Kind := FieldP(L, 1);+        If Kind = 'META' Then+        Begin+            LibRef := FieldP(L, 2); Desc := FieldP(L, 3); Des := FieldP(L, 4);+            Comp.LibReference := LibRef;+            Comp.ComponentDescription := Desc;+            Comp.Designator.Text := Des + '?';+            Comp.Comment.Text := LibRef;+        End+        Else If Kind = 'MODE' Then+        Begin+            Md := StrToInt(FieldP(L, 2));+            If Md + 1 > NModes Then NModes := Md + 1;+        End+        Else If Kind = 'RECT' Then+        Begin+            Md := StrToInt(FieldP(L, 2));+            Rect := SchServer.SchObjectFactory(eRectangle, eCreate_Default);+            Rect.OwnerPartId := 1;+            Rect.OwnerPartDisplayMode := Md;+            Rect.Location := Point(MilsToCoord(StrToInt(FieldP(L, 3))), MilsToCoord(StrToInt(FieldP(L, 4))));+            Rect.Corner := Point(MilsToCoord(StrToInt(FieldP(L, 5))), MilsToCoord(StrToInt(FieldP(L, 6))));+            Rect.IsSolid := True;+            Comp.AddSchObject(Rect);+        End+        Else If Kind = 'PIN' Then+        Begin+            Md := StrToInt(FieldP(L, 2));+            Pin := SchServer.SchObjectFactory(ePin, eCreate_Default);+            Pin.OwnerPartId := 1;+            Pin.OwnerPartDisplayMode := Md;+            Pin.Designator := FieldP(L, 3);+            Pin.Name := FieldP(L, 4);+            Pin.PinLength := MilsToCoord(StrToInt(FieldP(L, 8)));+            Orient := FieldP(L, 7);+            { orientation = the direction the pin points AWAY from the body }+            If Orient = 'left' Then Pin.Orientation := eRotate180+            Else If Orient = 'right' Then Pin.Orientation := eRotate0+            Else If Orient = 'up' Then Pin.Orientation := eRotate90+            Else Pin.Orientation := eRotate270;+            Pin.Location := Point(MilsToCoord(StrToInt(FieldP(L, 5))), MilsToCoord(StrToInt(FieldP(L, 6))));+            Pin.Electrical := eElectricPassive;+            Comp.AddSchObject(Pin);+            NPins := NPins + 1;+        End+        Else If Kind = 'LINE' Then+        Begin+            Md := StrToInt(FieldP(L, 2));+            Ln := SchServer.SchObjectFactory(eLine, eCreate_Default);+            Ln.OwnerPartId := 1;+            Ln.OwnerPartDisplayMode := Md;+            Ln.Location := Point(MilsToCoord(StrToInt(FieldP(L, 3))), MilsToCoord(StrToInt(FieldP(L, 4))));+            Ln.Corner := Point(MilsToCoord(StrToInt(FieldP(L, 5))), MilsToCoord(StrToInt(FieldP(L, 6))));+            Comp.AddSchObject(Ln);+            NLines := NLines + 1;+        End;+    End;+    Spec.Free;+    Comp.DisplayModeCount := NModes;+    SLib.AddSchComponent(Comp);+    SchServer.RobotManager.SendMessage(SLib.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Comp.I_ObjectAddress);+    SLib.CurrentSchComponent := Comp;+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok', 'true') + ',' + JStr('libref', LibRef) + ',' + JStr('library', LibPath) + ',' ++        JStr('libraryState', LibState) + ',' + JRaw('modes', IntToStr(NModes)) + ',' ++        JRaw('pins', IntToStr(NPins)) + ',' + JRaw('lines', IntToStr(NLines)) + ',' ++        JRaw('displayModeCount', IntToStr(Comp.DisplayModeCount)) + '}';+End;++{ Switch the display mode of the current library component (for frames). mode=<int> }+Function CmdSymbolMode(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    Comp : ISch_Component;+    Md   : Integer;+Begin+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no schematic library is current') + '}';+        Exit;+    End;+    Comp := SLib.CurrentSchComponent;+    If Comp = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current component') + '}';+        Exit;+    End;+    Md := StrToIntDef(Param(Lines, 'mode'), 0);+    Comp.DisplayMode := Md;+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok','true') + ',' + JRaw('mode', IntToStr(Comp.DisplayMode)) + ',' ++        JRaw('displayModeCount', IntToStr(Comp.DisplayModeCount)) + ',' + JStr('libref', Comp.LibReference) + '}';+End;+++{ ------------------------------------------------------------- add_models --- }++{ Attach PCB footprint models (name|pcblib|description;...) and parameters+  (k=v;...) to a SchLib component; set comment + description. Altium keeps+  several footprints on one component and the user picks the current one on+  the placed part (Properties > Footprint) - the CHANGE PACKAGE equivalent. }+Function CmdAddModels(Lines : TStringList) : String;+Var+    SLib   : ISch_Lib;+    Iter   : ISch_Iterator;+    Cmp    : ISch_Component;+    Impl   : ISch_Implementation;+    Models, Params, Name, Item, K, V, Cmt, Desc : String;+    ML, PL : TStringList;+    I, NModels, NParams : Integer;+Begin+    Name := Param(Lines, 'name');+    Models := Param(Lines, 'models');+    Params := Param(Lines, 'params');+    Cmt := Param(Lines, 'comment');+    Desc := Param(Lines, 'description');+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first') + '}';+        Exit;+    End;+    Cmp := Nil;+    Iter := SLib.SchLibIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+    Cmp := Iter.FirstSchObject;+    While Cmp <> Nil Do+    Begin+        If UpperCase(Cmp.LibReference) = UpperCase(Name) Then Break;+        Cmp := Iter.NextSchObject;+    End;+    SLib.SchIterator_Destroy(Iter);+    If Cmp = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Name) + '}';+        Exit;+    End;+    SchServer.ProcessControl.PreProcess(SLib, '');+    If Cmt <> '' Then Cmp.Comment.Text := Cmt;+    If Desc <> '' Then Cmp.ComponentDescription := Desc;+    NModels := 0;+    ML := SplitSemi(Models);+    For I := 0 To ML.Count - 1 Do+    Begin+        Item := ML[I];+        If FieldP(Item, 1) = '' Then Continue;+        Impl := Cmp.AddSchImplementation;+        Impl.ModelName := FieldP(Item, 1);+        Impl.ModelType := 'PCBLIB';+        Impl.Description := FieldP(Item, 3);+        Impl.IsCurrent := (NModels = 0);+        If FieldP(Item, 2) <> '' Then Impl.AddDataFileLink(FieldP(Item, 1), FieldP(Item, 2), 'PCBLIB');+        NModels := NModels + 1;+    End;+    ML.Free;+    NParams := 0;+    PL := SplitSemi(Params);+    For I := 0 To PL.Count - 1 Do+    Begin+        Item := PL[I];+        If Pos('=', Item) = 0 Then Continue;+        K := Copy(Item, 1, Pos('=', Item) - 1);+        V := Copy(Item, Pos('=', Item) + 1, Length(Item));+        SetSchParam(Cmp, K, V);+        NParams := NParams + 1;+    End;+    PL.Free;+    SchServer.ProcessControl.PostProcess(SLib, '');+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('libref', Cmp.LibReference) + ',' ++        JRaw('models', IntToStr(NModels)) + ',' + JRaw('params', IntToStr(NParams)) + '}';+End;+++{ ------------------------------------------------------ normalize_bodies --- }++{ The PcbLib canvas origin sits 50000 mil from the stored component origin, so a+  body added through the canvas lands 50000 mil away from the pads in the stored+  frame (measured: body left -50138 vs pads +/-157) - Zoom All then frames 50+  inches of nothing and the editor paints an empty canvas. Align every body+  centre onto the pad-cluster centre of its footprint. name=<optional filter> }+Function CmdNormalizeBodies(Lines : TStringList) : String;+Var+    PLib : IPCB_Library;+    Fp   : IPCB_LibComponent;+    GIt  : IPCB_GroupIterator;+    Prim : IPCB_Primitive;+    R    : TCoordRect;+    I, NMoved : Integer;+    PL, PB, PR, PT, DX, DY : Integer;+    AnyP : Boolean;+    Name, S : String;+Begin+    PLib := PCBServer.GetCurrentPCBLibrary;+    Name := Param(Lines, 'name');+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no PCB library is current') + '}';+        Exit;+    End;+    NMoved := 0; S := '';+    PCBServer.PreProcess;+    For I := 0 To PLib.ComponentCount - 1 Do+    Begin+        Fp := PLib.GetComponent(I);+        If (Name <> '') And (UpperCase(Fp.Name) <> UpperCase(Name)) Then Continue;+        AnyP := False; PL := 0; PB := 0; PR := 0; PT := 0;+        GIt := Fp.GroupIterator_Create;+        GIt.AddFilter_ObjectSet(MkSet(ePadObject));+        GIt.AddFilter_LayerSet(AllLayers);+        Prim := GIt.FirstPCBObject;+        While Prim <> Nil Do+        Begin+            R := Prim.BoundingRectangle;+            If Not AnyP Then Begin PL := R.Left; PB := R.Bottom; PR := R.Right; PT := R.Top; AnyP := True; End+            Else+            Begin+                If R.Left < PL Then PL := R.Left;+                If R.Bottom < PB Then PB := R.Bottom;+                If R.Right > PR Then PR := R.Right;+                If R.Top > PT Then PT := R.Top;+            End;+            Prim := GIt.NextPCBObject;+        End;+        Fp.GroupIterator_Destroy(GIt);+        If Not AnyP Then Continue;+        GIt := Fp.GroupIterator_Create;+        GIt.AddFilter_ObjectSet(MkSet(eComponentBodyObject));+        GIt.AddFilter_LayerSet(AllLayers);+        Prim := GIt.FirstPCBObject;+        While Prim <> Nil Do+        Begin+            R := Prim.BoundingRectangle;+            DX := ((PL + PR) Div 2) - ((R.Left + R.Right) Div 2);+            DY := ((PB + PT) Div 2) - ((R.Bottom + R.Top) Div 2);+            If (Abs(DX) > MilsToCoord(5)) Or (Abs(DY) > MilsToCoord(5)) Then+            Begin+                Prim.MoveByXY(DX, DY);+                NMoved := NMoved + 1;+                If S <> '' Then S := S + ';';+                S := S + Fp.Name + ':' + IntToStr(Round(CoordToMils(DX))) + ',' + IntToStr(Round(CoordToMils(DY)));+            End;+            Prim := GIt.NextPCBObject;+        End;+        Fp.GroupIterator_Destroy(GIt);+    End;+    PCBServer.PostProcess;+    PLib.Board.ViewManager_FullUpdate;+    PLib.RefreshView;+    Result := '{' + JRaw('ok','true') + ',' + JRaw('moved', IntToStr(NMoved)) + ',' + JStr('shifts', S) + '}';+End;+++{ ------------------------------------------ test_board / swap_footprint --- }++{ Copy a library footprint onto Board as a fresh component centred at (CX,CY)+  coords, per-primitive (group Replicate dissolves), children BEFORE the board+  add, pad cluster and bodies normalised to the target independently. }+Function PlaceCopy(Board : IPCB_Board; LibFp : IPCB_LibComponent; CX, CY : Integer) : IPCB_Component;+Var+    Inst : IPCB_Component;+    GIt  : IPCB_GroupIterator;+    Prim, Cpy : IPCB_Primitive;+    R    : TCoordRect;+    DX, DY, CL, CB, CR, CT : Integer;+    AnyP : Boolean;+Begin+    Inst := PCBServer.PCBObjectFactory(eComponentObject, eNoDimension, eCreate_Default);+    Inst.Layer := eTopLayer;+    Inst.NameOn := False;+    Inst.CommentOn := False;+    GIt := LibFp.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        Cpy := Prim.Replicate;+        Inst.AddPCBObject(Cpy);+        Prim := GIt.NextPCBObject;+    End;+    LibFp.GroupIterator_Destroy(GIt);+    Board.AddPCBObject(Inst);+    PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Inst.I_ObjectAddress);+    Inst.x := CX; Inst.y := CY;+    AnyP := False; CL := 0; CB := 0; CR := 0; CT := 0;+    GIt := Inst.GroupIterator_Create;+    GIt.AddFilter_ObjectSet(MkSet(ePadObject));+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        R := Prim.BoundingRectangle;+        If Not AnyP Then Begin CL := R.Left; CB := R.Bottom; CR := R.Right; CT := R.Top; AnyP := True; End+        Else+        Begin+            If R.Left < CL Then CL := R.Left;+            If R.Bottom < CB Then CB := R.Bottom;+            If R.Right > CR Then CR := R.Right;+            If R.Top > CT Then CT := R.Top;+        End;+        Prim := GIt.NextPCBObject;+    End;+    Inst.GroupIterator_Destroy(GIt);+    DX := 0; DY := 0;+    If AnyP Then Begin DX := CX - ((CL + CR) Div 2); DY := CY - ((CB + CT) Div 2); End;+    GIt := Inst.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        If Prim.ObjectId = eComponentBodyObject Then+        Begin+            R := Prim.BoundingRectangle;+            Prim.MoveByXY(CX - ((R.Left + R.Right) Div 2), CY - ((R.Bottom + R.Top) Div 2));+        End+        Else If Prim.ObjectId <> eTextObject Then+            Prim.MoveByXY(DX, DY);+        Prim := GIt.NextPCBObject;+    End;+    Inst.GroupIterator_Destroy(GIt);+    Inst.Name.MoveToXY(CX, CY + MilsToCoord(250));+    Inst.Comment.MoveToXY(CX, CY - MilsToCoord(250));+    Result := Inst;+End;++Function FindNet(Board : IPCB_Board; NetName : String) : IPCB_Net;+Var+    BIt : IPCB_BoardIterator;+    N   : IPCB_Net;+Begin+    Result := Nil;+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eNetObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    N := BIt.FirstPCBObject;+    While N <> Nil Do+    Begin+        If N.Name = NetName Then Begin Result := N; Break; End;+        N := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+End;++{ Assign nets to pads by designator: assigns=<des>=<net>;... (all pads sharing a+  designator get the net - the heatsink-via convention). }+Procedure AssignPadNets(Board : IPCB_Board; Inst : IPCB_Component; Assigns : String);+Var+    GIt  : IPCB_GroupIterator;+    Pad  : IPCB_Pad;+    AL   : TStringList;+    I    : Integer;+    Des, NetName : String;+    Net  : IPCB_Net;+Begin+    AL := SplitSemi(Assigns);+    For I := 0 To AL.Count - 1 Do+    Begin+        If Pos('=', AL[I]) = 0 Then Continue;+        Des := Copy(AL[I], 1, Pos('=', AL[I]) - 1);+        NetName := Copy(AL[I], Pos('=', AL[I]) + 1, Length(AL[I]));+        Net := FindNet(Board, NetName);+        If Net = Nil Then+        Begin+            Net := PCBServer.PCBObjectFactory(eNetObject, eNoDimension, eCreate_Default);+            Net.Name := NetName;+            Board.AddPCBObject(Net);+        End;+        GIt := Inst.GroupIterator_Create;+        GIt.AddFilter_ObjectSet(MkSet(ePadObject));+        GIt.AddFilter_LayerSet(AllLayers);+        Pad := GIt.FirstPCBObject;+        While Pad <> Nil Do+        Begin+            If Pad.Name = Des Then Pad.Net := Net;+            Pad := GIt.NextPCBObject;+        End;+        Inst.GroupIterator_Destroy(GIt);+    End;+    AL.Free;+End;++{ New PcbDoc: FR4 rectangle wMm x hMm (origin at 0,0), the footprint placed at+  the centre, pad 1 and pad 49 given nets with a stub track each.+  Args: footprint, wMm, hMm, pcb=<save path>, assigns=<des=net;...>, stubs=<des=x,y;...> (mm from centre) }+Function CmdTestBoard(Lines : TStringList) : String;+Var+    PLib  : IPCB_Library;+    LibFp : IPCB_LibComponent;+    Board : IPCB_Board;+    SD    : IServerDocument;+    Inst  : IPCB_Component;+    BO    : IPCB_BoardOutline;+    Seg   : TPolySegment;+    Trk   : IPCB_Track;+    Net   : IPCB_Net;+    GIt   : IPCB_GroupIterator;+    Pad   : IPCB_Pad;+    SL    : TStringList;+    I, WC, HC, CX, CY, PX, PY, Found : Integer;+    FpName, PcbPath, Des, NetName, Item : String;+    Ok    : Boolean;+Begin+    FpName := Param(Lines, 'footprint');+    PcbPath := Param(Lines, 'pcb');+    WC := MMsToCoord(StrToFloat(Param(Lines, 'wMm')));+    HC := MMsToCoord(StrToFloat(Param(Lines, 'hMm')));+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the PcbLib first') + '}';+        Exit;+    End;+    Ok := False;+    For I := 0 To PLib.ComponentCount - 1 Do+    Begin+        LibFp := PLib.GetComponent(I);+        If UpperCase(LibFp.Name) = UpperCase(FpName) Then Begin Ok := True; Break; End;+    End;+    If Not Ok Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found: ' + FpName) + '}';+        Exit;+    End;+    SD := CreateNewDocumentFromDocumentKind('PCB');+    If SD = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','could not create a PCB document') + '}';+        Exit;+    End;+    Client.ShowDocument(SD);+    Board := PCBServer.GetCurrentPCBBoard;+    Board.LayerStack.LayerObject[eMechanical9].MechanicalLayerEnabled := True;+    Board.LayerStack.LayerObject[eMechanical9].Name := 'Adom.SolderBalls';+    Board.LayerIsDisplayed[eMechanical9] := False;+    Board.LayerStack.LayerObject[eMechanical13].MechanicalLayerEnabled := True;+    Board.LayerStack.LayerObject[eMechanical13].Name := 'Fab';+    Board.LayerStack.LayerObject[eMechanical15].MechanicalLayerEnabled := True;+    Board.LayerStack.LayerObject[eMechanical15].Name := 'Courtyard';+    PCBServer.PreProcess;+    { board shape }+    BO := Board.BoardOutline;+    BO.Invalidate;+    { no Clear: IPCB_BoardOutline has no Clear in this engine (halt, measured);+      PointCount + Segments[] + Rebuild is the working pattern }+    BO.PointCount := 4;+    Seg := BO.Segments[0]; Seg.vx := 0; Seg.vy := 0; Seg.Kind := ePolySegmentLine; BO.Segments[0] := Seg;+    Seg := BO.Segments[1]; Seg.vx := WC; Seg.vy := 0; Seg.Kind := ePolySegmentLine; BO.Segments[1] := Seg;+    Seg := BO.Segments[2]; Seg.vx := WC; Seg.vy := HC; Seg.Kind := ePolySegmentLine; BO.Segments[2] := Seg;+    Seg := BO.Segments[3]; Seg.vx := 0; Seg.vy := HC; Seg.Kind := ePolySegmentLine; BO.Segments[3] := Seg;+    BO.Rebuild;+    BO.Validate;+    CX := WC Div 2; CY := HC Div 2;+    Inst := PlaceCopy(Board, LibFp, CX, CY);+    AssignPadNets(Board, Inst, Param(Lines, 'assigns'));+    { stub tracks: des=x,y (mm from centre) -> a track from that pad to the point, on its net }+    SL := SplitSemi(Param(Lines, 'stubs'));+    For I := 0 To SL.Count - 1 Do+    Begin+        Item := SL[I];+        If Pos('=', Item) = 0 Then Continue;+        Des := Copy(Item, 1, Pos('=', Item) - 1);+        PX := CX + MMsToCoord(StrToFloat(Field(Copy(Item, Pos('=', Item) + 1, Length(Item)), 1)));+        PY := CY + MMsToCoord(StrToFloat(Field(Copy(Item, Pos('=', Item) + 1, Length(Item)), 2)));+        GIt := Inst.GroupIterator_Create;+        GIt.AddFilter_ObjectSet(MkSet(ePadObject));+        GIt.AddFilter_LayerSet(AllLayers);+        Pad := GIt.FirstPCBObject;+        Found := 0;+        While (Pad <> Nil) And (Found = 0) Do+        Begin+            If (Pad.Name = Des) And (Pad.Layer <> eMultiLayer) Then Found := 1+            Else If (Pad.Name = Des) And (Found = 0) And (Pad.Layer = eMultiLayer) Then Found := 0;+            If Found = 1 Then Break;+            Pad := GIt.NextPCBObject;+        End;+        If Pad <> Nil Then+        Begin+            Trk := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default);+            Trk.Layer := eTopLayer;+            Trk.X1 := Pad.x; Trk.Y1 := Pad.y; Trk.X2 := PX; Trk.Y2 := PY;+            Trk.Width := MMsToCoord(0.2);+            Trk.Net := Pad.Net;+            Board.AddPCBObject(Trk);+            PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Trk.I_ObjectAddress);+        End;+        Inst.GroupIterator_Destroy(GIt);+    End;+    SL.Free;+    PCBServer.PostProcess;+    Board.ViewManager_FullUpdate;+    If PcbPath <> '' Then+    Begin+        SD.SetFileName(PcbPath);+        SD.DoFileSave('');+    End;+    Result := '{' + JRaw('ok','true') + ',' + JStr('pcb', PcbPath) + ',' + JStr('placed', FpName) + ',' ++        JRaw('padsOnComp', IntToStr(GroupCount(Inst, ePadObject))) + ',' ++        JRaw('bodiesOnComp', IntToStr(GroupCount(Inst, eComponentBodyObject))) + '}';+End;++{ Replace the first component on the current board with another library+  footprint at the same position, keeping the nets by pad designator. Tracks are+  untouched, so the wiring survives the swap. Args: footprint, assigns }+Function CmdSwapFootprint(Lines : TStringList) : String;+Var+    PLib  : IPCB_Library;+    LibFp : IPCB_LibComponent;+    Board : IPCB_Board;+    BIt   : IPCB_BoardIterator;+    Old, Inst : IPCB_Component;+    I, CX, CY : Integer;+    FpName, LibPath : String;+    Ok : Boolean;+Begin+    FpName := Param(Lines, 'footprint');+    LibPath := Param(Lines, 'libraryPath');+    Board := PCBServer.GetCurrentPCBBoard;+    PLib := Nil;+    If LibPath <> '' Then PLib := PCBServer.LoadPCBLibraryByPath(LibPath);+    If PLib = Nil Then PLib := PCBServer.GetCurrentPCBLibrary;+    If (Board = Nil) Or (PLib = Nil) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','need the board focused and the PcbLib path') + '}';+        Exit;+    End;+    Ok := False;+    For I := 0 To PLib.ComponentCount - 1 Do+    Begin+        LibFp := PLib.GetComponent(I);+        If UpperCase(LibFp.Name) = UpperCase(FpName) Then Begin Ok := True; Break; End;+    End;+    If Not Ok Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found: ' + FpName) + '}';+        Exit;+    End;+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Old := BIt.FirstPCBObject;+    Board.BoardIterator_Destroy(BIt);+    If Old = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no component on the board') + '}';+        Exit;+    End;+    CX := Old.x; CY := Old.y;+    PCBServer.PreProcess;+    Board.RemovePCBObject(Old);+    Inst := PlaceCopy(Board, LibFp, CX, CY);+    AssignPadNets(Board, Inst, Param(Lines, 'assigns'));+    PCBServer.PostProcess;+    Board.ViewManager_FullUpdate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('placed', FpName) + ',' ++        JRaw('padsOnComp', IntToStr(GroupCount(Inst, ePadObject))) + ',' ++        JRaw('bodiesOnComp', IntToStr(GroupCount(Inst, eComponentBodyObject))) + '}';+End;+++{ ------------------------------------------------------------ board_shape --- }++{ Redefine the CURRENT board shape as a wMm x hMm rectangle at (x0Mm,y0Mm) using+  the editor process PCB:PlaceBoardOutline Mode=BOARDOUTLINE_FROM_SEL_PRIMS on+  four temporary selected tracks (BoardOutline.PointCount/Segments is a silent+  no-op in this engine, measured 2026-08-30). }+Function CmdBoardShape(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    T     : IPCB_Track;+    Trks  : Array[0..3] Of IPCB_Track;+    X0, Y0, X1, Y1, I : Integer;+    R : TCoordRect;+Begin+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no board focused') + '}';+        Exit;+    End;+    X0 := MMsToCoord(StrToFloat(Param(Lines, 'x0Mm')));+    Y0 := MMsToCoord(StrToFloat(Param(Lines, 'y0Mm')));+    X1 := X0 + MMsToCoord(StrToFloat(Param(Lines, 'wMm')));+    Y1 := Y0 + MMsToCoord(StrToFloat(Param(Lines, 'hMm')));+    PCBServer.PreProcess;+    For I := 0 To 3 Do+    Begin+        T := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default);+        T.Layer := eMechanical1;+        T.Width := MMsToCoord(0.1);+        Case I Of+            0: Begin T.X1 := X0; T.Y1 := Y0; T.X2 := X1; T.Y2 := Y0; End;+            1: Begin T.X1 := X1; T.Y1 := Y0; T.X2 := X1; T.Y2 := Y1; End;+            2: Begin T.X1 := X1; T.Y1 := Y1; T.X2 := X0; T.Y2 := Y1; End;+            3: Begin T.X1 := X0; T.Y1 := Y1; T.X2 := X0; T.Y2 := Y0; End;+        End;+        Board.AddPCBObject(T);+        PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, T.I_ObjectAddress);+        Trks[I] := T;+    End;+    PCBServer.PostProcess;+    Board.SelectedObjects_Clear;+    For I := 0 To 3 Do Trks[I].Selected := True;+    ResetParameters;+    AddStringParameter('Mode', 'BOARDOUTLINE_FROM_SEL_PRIMS');+    RunProcess('PCB:PlaceBoardOutline');+    PCBServer.PreProcess;+    For I := 0 To 3 Do Board.RemovePCBObject(Trks[I]);+    PCBServer.PostProcess;+    Board.ViewManager_FullUpdate;+    R := Board.BoardOutline.BoundingRectangle;+    Result := '{' + JRaw('ok','true') + ',' ++        JRaw('outlineMil', '[' + IntToStr(Round(CoordToMils(R.Left))) + ',' + IntToStr(Round(CoordToMils(R.Bottom))) + ',' ++             IntToStr(Round(CoordToMils(R.Right))) + ',' + IntToStr(Round(CoordToMils(R.Top))) + ']') + '}';+End;+++{ ---------------------------------------------------------- remove_symbol --- }++Function CmdRemoveSymbol(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    Iter : ISch_Iterator;+    Comp : ISch_Component;+    Name : String;+    Found : Boolean;+Begin+    Name := Param(Lines, 'name');+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first') + '}';+        Exit;+    End;+    Found := False;+    Iter := SLib.SchLibIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+    Comp := Iter.FirstSchObject;+    While Comp <> Nil Do+    Begin+        If UpperCase(Comp.LibReference) = UpperCase(Name) Then Begin Found := True; Break; End;+        Comp := Iter.NextSchObject;+    End;+    SLib.SchIterator_Destroy(Iter);+    If Not Found Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Name) + '}';+        Exit;+    End;+    SLib.RemoveSchComponent(Comp);+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('removed', Name) + '}';+End;+++{ -------------------------------------- Barrett-parity: library manipulation --- }++Function FindSchComp(SLib : ISch_Lib; Name : String) : ISch_Component;+Var+    Iter : ISch_Iterator;+    C    : ISch_Component;+Begin+    Result := Nil;+    Iter := SLib.SchLibIterator_Create;+    Iter.AddFilter_ObjectSet(MkSet(eSchComponent));+    C := Iter.FirstSchObject;+    While C <> Nil Do+    Begin+        If UpperCase(C.LibReference) = UpperCase(Name) Then Begin Result := C; Break; End;+        C := Iter.NextSchObject;+    End;+    SLib.SchIterator_Destroy(Iter);+End;++Function FindFp(PLib : IPCB_Library; Name : String) : IPCB_LibComponent;+Var+    I : Integer;+    F : IPCB_LibComponent;+Begin+    Result := Nil;+    For I := 0 To PLib.ComponentCount - 1 Do+    Begin+        F := PLib.GetComponent(I);+        If UpperCase(F.Name) = UpperCase(Name) Then Begin Result := F; Break; End;+    End;+End;++{ Clone a symbol inside the current SchLib under a new LibReference, all display+  modes included (per-primitive copy - group Replicate drops children). }+Function CmdCloneSymbol(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    Src, Dst : ISch_Component;+    It  : ISch_Iterator;+    Prim, Cpy : ISch_GraphicalObject;+    NCopied : Integer;+Begin+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first') + '}';+        Exit;+    End;+    Src := FindSchComp(SLib, Param(Lines, 'from'));+    If Src = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Param(Lines, 'from')) + '}';+        Exit;+    End;+    If FindSchComp(SLib, Param(Lines, 'to')) <> Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','target already exists: ' + Param(Lines, 'to')) + '}';+        Exit;+    End;+    Dst := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+    Dst.LibReference := Param(Lines, 'to');+    Dst.ComponentDescription := Src.ComponentDescription;+    Dst.CurrentPartID := 1;+    Dst.DisplayMode := 0;+    Dst.Designator.Text := Src.Designator.Text;+    Dst.Comment.Text := Src.Comment.Text;+    NCopied := 0;+    It := Src.SchIterator_Create;+    Prim := It.FirstSchObject;+    While Prim <> Nil Do+    Begin+        If Prim.ObjectID <> eParameter Then+        Begin+            Cpy := Prim.Replicate;+            Dst.AddSchObject(Cpy);+            NCopied := NCopied + 1;+        End;+        Prim := It.NextSchObject;+    End;+    Src.SchIterator_Destroy(It);+    Dst.DisplayModeCount := Src.DisplayModeCount;+    SLib.AddSchComponent(Dst);+    SchServer.RobotManager.SendMessage(SLib.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Dst.I_ObjectAddress);+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('from', Src.LibReference) + ',' + JStr('to', Dst.LibReference) + ',' ++        JRaw('primitives', IntToStr(NCopied)) + ',' + JRaw('displayModeCount', IntToStr(Dst.DisplayModeCount)) + '}';+End;++Function CmdRenameSymbol(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    C    : ISch_Component;+Begin+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first') + '}';+        Exit;+    End;+    C := FindSchComp(SLib, Param(Lines, 'from'));+    If C = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Param(Lines, 'from')) + '}';+        Exit;+    End;+    C.LibReference := Param(Lines, 'to');+    SLib.GraphicallyInvalidate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('renamed', Param(Lines, 'to')) + '}';+End;++{ Read a symbol back: pins (designator/name/mode), description, modes, params. }+Function CmdReadSymbol(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    C    : ISch_Component;+    It   : ISch_Iterator;+    Prim : ISch_GraphicalObject;+    Pin  : ISch_Pin;+    Pm   : ISch_Parameter;+    Pins, Params : String;+    NPins : Integer;+Begin+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first') + '}';+        Exit;+    End;+    C := FindSchComp(SLib, Param(Lines, 'name'));+    If C = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Param(Lines, 'name')) + '}';+        Exit;+    End;+    Pins := ''; Params := ''; NPins := 0;+    It := C.SchIterator_Create;+    Prim := It.FirstSchObject;+    While Prim <> Nil Do+    Begin+        If Prim.ObjectID = ePin Then+        Begin+            Pin := Prim;+            If Pin.OwnerPartDisplayMode = 0 Then+            Begin+                If Pins <> '' Then Pins := Pins + ',';+                Pins := Pins + '{' + JStr('des', Pin.Designator) + ',' + JStr('name', Pin.Name) + '}';+                NPins := NPins + 1;+            End;+        End+        Else If Prim.ObjectID = eParameter Then+        Begin+            Pm := Prim;+            If Params <> '' Then Params := Params + ',';+            Params := Params + '{' + JStr('name', Pm.Name) + ',' + JStr('value', Pm.Text) + ',' ++                JRaw('hidden', JBool(Pm.IsHidden)) + '}';+        End;+        Prim := It.NextSchObject;+    End;+    C.SchIterator_Destroy(It);+    Result := '{' + JRaw('ok','true') + ',' + JStr('libref', C.LibReference) + ',' ++        JStr('description', C.ComponentDescription) + ',' ++        JRaw('displayModeCount', IntToStr(C.DisplayModeCount)) + ',' ++        JRaw('pinsMode0', IntToStr(NPins)) + ',' ++        JRaw('pins', '[' + Pins + ']') + ',' + JRaw('params', '[' + Params + ']') + '}';+End;++{ Copy a footprint inside the current PcbLib under a new name (per-primitive). }+Function CmdCopyFootprint(Lines : TStringList) : String;+Var+    PLib : IPCB_Library;+    Src, Dst : IPCB_LibComponent;+    GIt  : IPCB_GroupIterator;+    Prim, Cpy : IPCB_Primitive;+    NCopied : Integer;+Begin+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the PcbLib first') + '}';+        Exit;+    End;+    Src := FindFp(PLib, Param(Lines, 'from'));+    If Src = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found: ' + Param(Lines, 'from')) + '}';+        Exit;+    End;+    If FindFp(PLib, Param(Lines, 'to')) <> Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','target already exists: ' + Param(Lines, 'to')) + '}';+        Exit;+    End;+    PCBServer.PreProcess;+    Dst := PCBServer.CreatePCBLibComp;+    Dst.Name := Param(Lines, 'to');+    Dst.Description := Src.Description;+    NCopied := 0;+    GIt := Src.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        Cpy := Prim.Replicate;+        Dst.AddPCBObject(Cpy);+        NCopied := NCopied + 1;+        Prim := GIt.NextPCBObject;+    End;+    Src.GroupIterator_Destroy(GIt);+    PLib.RegisterComponent(Dst);+    PCBServer.PostProcess;+    PLib.Board.ViewManager_FullUpdate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('from', Src.Name) + ',' + JStr('to', Dst.Name) + ',' ++        JRaw('primitives', IntToStr(NCopied)) + '}';+End;++Function CmdRenameFootprint(Lines : TStringList) : String;+Var+    PLib : IPCB_Library;+    F    : IPCB_LibComponent;+Begin+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the PcbLib first') + '}';+        Exit;+    End;+    F := FindFp(PLib, Param(Lines, 'from'));+    If F = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found: ' + Param(Lines, 'from')) + '}';+        Exit;+    End;+    F.Name := Param(Lines, 'to');+    PLib.Board.ViewManager_FullUpdate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('renamed', Param(Lines, 'to')) + '}';+End;++Function CmdRemoveFootprint(Lines : TStringList) : String;+Var+    PLib : IPCB_Library;+    F    : IPCB_LibComponent;+Begin+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the PcbLib first') + '}';+        Exit;+    End;+    F := FindFp(PLib, Param(Lines, 'name'));+    If F = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found: ' + Param(Lines, 'name')) + '}';+        Exit;+    End;+    PLib.RemoveComponent(F);+    PLib.Board.ViewManager_FullUpdate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('removed', Param(Lines, 'name')) + '}';+End;+++{ ---------------------------------------- cleanup_libs / set_visibility / xcopy --- }++{ Remove factory-default empty components (Component_1 / PCBComponent_1) from the+  current SchLib or PcbLib - the strays every fresh library carries. }+Function CmdCleanupLibs(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    PLib : IPCB_Library;+    C    : ISch_Component;+    F    : IPCB_LibComponent;+    I, NRemoved : Integer;+Begin+    NRemoved := 0;+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib <> Nil) And (SLib.ObjectID = eSchLib) Then+    Begin+        C := FindSchComp(SLib, 'Component_1');+        If C <> Nil Then+        Begin+            SLib.RemoveSchComponent(C);+            NRemoved := NRemoved + 1;+        End;+        SLib.GraphicallyInvalidate;+    End;+    PLib := PCBServer.GetCurrentPCBLibrary;+    If PLib <> Nil Then+    Begin+        F := FindFp(PLib, 'PCBComponent_1');+        If (F <> Nil) And (GroupCount(F, ePadObject) = 0) Then+        Begin+            PLib.RemoveComponent(F);+            NRemoved := NRemoved + 1;+        End;+        PLib.Board.ViewManager_FullUpdate;+    End;+    Result := '{' + JRaw('ok','true') + ',' + JRaw('removed', IntToStr(NRemoved)) + '}';+End;++{ Show or hide a named parameter on a SchLib component. name, param, visible=0|1 }+Function CmdSetVisibility(Lines : TStringList) : String;+Var+    SLib : ISch_Lib;+    C    : ISch_Component;+    It   : ISch_Iterator;+    Pm   : ISch_Parameter;+    Target : String;+    Found : Boolean;+Begin+    SLib := SchServer.GetCurrentSchDocument;+    If (SLib = Nil) Or (SLib.ObjectID <> eSchLib) Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the SchLib first') + '}';+        Exit;+    End;+    C := FindSchComp(SLib, Param(Lines, 'name'));+    If C = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','component not found: ' + Param(Lines, 'name')) + '}';+        Exit;+    End;+    Target := Param(Lines, 'param');+    Found := False;+    It := C.SchIterator_Create;+    It.AddFilter_ObjectSet(MkSet(eParameter));+    Pm := It.FirstSchObject;+    While Pm <> Nil Do+    Begin+        If UpperCase(Pm.Name) = UpperCase(Target) Then+        Begin+            Pm.IsHidden := Param(Lines, 'visible') <> '1';+            Found := True;+        End;+        Pm := It.NextSchObject;+    End;+    C.SchIterator_Destroy(It);+    SLib.GraphicallyInvalidate;+    If Not Found Then+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','parameter not found: ' + Target) + '}'+    Else+        Result := '{' + JRaw('ok','true') + ',' + JStr('param', Target) + ',' + JStr('visible', Param(Lines, 'visible')) + '}';+End;++{ Copy a footprint ACROSS libraries: fromLib(path)+from -> the CURRENT PcbLib as to. }+Function CmdXCopyFootprint(Lines : TStringList) : String;+Var+    SrcLib, DstLib : IPCB_Library;+    Src, Dst : IPCB_LibComponent;+    GIt  : IPCB_GroupIterator;+    Prim, Cpy : IPCB_Primitive;+    NCopied : Integer;+Begin+    DstLib := PCBServer.GetCurrentPCBLibrary;+    If DstLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','open the destination PcbLib first') + '}';+        Exit;+    End;+    SrcLib := PCBServer.LoadPCBLibraryByPath(Param(Lines, 'fromLib'));+    If SrcLib = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','source library not loadable: ' + Param(Lines, 'fromLib')) + '}';+        Exit;+    End;+    Src := FindFp(SrcLib, Param(Lines, 'from'));+    If Src = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','footprint not found in source: ' + Param(Lines, 'from')) + '}';+        Exit;+    End;+    If FindFp(DstLib, Param(Lines, 'to')) <> Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','target already exists: ' + Param(Lines, 'to')) + '}';+        Exit;+    End;+    PCBServer.PreProcess;+    Dst := PCBServer.CreatePCBLibComp;+    Dst.Name := Param(Lines, 'to');+    Dst.Description := Src.Description;+    NCopied := 0;+    GIt := Src.GroupIterator_Create;+    GIt.AddFilter_LayerSet(AllLayers);+    Prim := GIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        Cpy := Prim.Replicate;+        Dst.AddPCBObject(Cpy);+        NCopied := NCopied + 1;+        Prim := GIt.NextPCBObject;+    End;+    Src.GroupIterator_Destroy(GIt);+    DstLib.RegisterComponent(Dst);+    PCBServer.PostProcess;+    DstLib.Board.ViewManager_FullUpdate;+    Result := '{' + JRaw('ok','true') + ',' + JStr('to', Dst.Name) + ',' + JRaw('primitives', IntToStr(NCopied)) + '}';+End;+++Function Dispatch(Cmd : String; Lines : TStringList; Payload : String) : String;+Begin+    If Cmd = 'ping' Then Result := CmdPing(Lines)+    Else If Cmd = 'state' Then Result := CmdState(Lines)+    Else If Cmd = 'run_process' Then Result := CmdRunProcess(Lines)+    Else If Cmd = 'open' Then Result := CmdOpen(Lines)+    Else If Cmd = 'eval' Then Result := CmdEval(Lines, Payload)+    Else If Cmd = 'lib_list' Then Result := CmdLibList(Lines)+    Else If Cmd = 'lib_select' Then Result := CmdLibSelect(Lines)+    Else If Cmd = 'compile' Then Result := CmdCompile(Lines)+    Else If Cmd = 'violations' Then Result := CmdViolations(Lines)+    Else If Cmd = 'outjob' Then Result := CmdOutJob(Lines)+    Else If Cmd = 'create_footprint' Then Result := CmdCreateFootprint(Lines)+    Else If Cmd = 'create_symbol' Then Result := CmdCreateSymbol(Lines)+    Else If Cmd = 'install_library' Then Result := CmdInstallLibrary(Lines)+    Else If Cmd = 'compile_intlib' Then Result := CmdCompileIntLib(Lines)+    Else If Cmd = 'extract_intlib' Then Result := CmdExtractIntLib(Lines)+    Else If Cmd = 'set_params' Then Result := CmdSetParams(Lines)+    Else If Cmd = 'add_body' Then Result := CmdAddBody(Lines)+    Else If Cmd = 'place_on_sch' Then Result := CmdPlaceOnSch(Lines)+    Else If Cmd = 'place_on_pcb' Then Result := CmdPlaceOnPcb(Lines)+    Else If Cmd = 'pcb_report' Then Result := CmdPcbReport(Lines)+    Else If Cmd = 'build_footprint' Then Result := CmdBuildFootprint(Lines)+    Else If Cmd = 'build_symbol_modes' Then Result := CmdBuildSymbolModes(Lines)+    Else If Cmd = 'symbol_mode' Then Result := CmdSymbolMode(Lines)+    Else If Cmd = 'add_models' Then Result := CmdAddModels(Lines)+    Else If Cmd = 'remove_symbol' Then Result := CmdRemoveSymbol(Lines)+    Else If Cmd = 'clone_symbol' Then Result := CmdCloneSymbol(Lines)+    Else If Cmd = 'rename_symbol' Then Result := CmdRenameSymbol(Lines)+    Else If Cmd = 'read_symbol' Then Result := CmdReadSymbol(Lines)+    Else If Cmd = 'copy_footprint' Then Result := CmdCopyFootprint(Lines)+    Else If Cmd = 'rename_footprint' Then Result := CmdRenameFootprint(Lines)+    Else If Cmd = 'remove_footprint' Then Result := CmdRemoveFootprint(Lines)+    Else If Cmd = 'cleanup_libs' Then Result := CmdCleanupLibs(Lines)+    Else If Cmd = 'set_visibility' Then Result := CmdSetVisibility(Lines)+    Else If Cmd = 'xcopy_footprint' Then Result := CmdXCopyFootprint(Lines)+    Else If Cmd = 'normalize_bodies' Then Result := CmdNormalizeBodies(Lines)+    Else If Cmd = 'test_board' Then Result := CmdTestBoard(Lines)+    Else If Cmd = 'board_shape' Then Result := CmdBoardShape(Lines)+    Else If Cmd = 'swap_footprint' Then Result := CmdSwapFootprint(Lines)+    Else If Cmd = 'tx_clear' Then Begin TxClear; Result := '{' + JRaw('ok','true') + ',' + JStr('note','PCBServer transaction cleared') + '}'; End+    Else If Cmd = 'stop' Then+    Begin+        gRunning := False;+        Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'agent loop stopping') + '}';+    End+    Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop') + '}';+End;++{ ------------------------------------------------------------------ loop --- }++{ ONE inbox file, request.txt, written atomically by the bridge (tmp + rename) and+  serialized on the bridge side. A wildcard FindFirst('request_*.txt') never matched+  in this engine (measured 2026-08-29: heartbeat fine, requests stuck on disk), so+  the inbox is a fixed name and the id rides inside. }+Function ServeOne : Boolean;+Var+    Id, ReqPath, PayPath, Cmd, Body, Payload : String;+    Lines   : TStringList;+    T0      : Integer;+Begin+    Result := False;+    ReqPath := WORKSPACE + 'request.txt';+    If Not FileExists(ReqPath) Then Exit;+    PayPath := WORKSPACE + 'request.payload';+    Lines := TStringList.Create;+    Try+        Lines.Text := ReadAll(ReqPath);+        DeleteFile(ReqPath);+        Payload := '';+        If FileExists(PayPath) Then+        Begin+            Payload := ReadAll(PayPath);+            DeleteFile(PayPath);+        End;+        Id := Param(Lines, 'id');+        Cmd := Param(Lines, 'command');+        gRequests := gRequests + 1;+        gLastReqTick := GetTickCount;+        T0 := GetTickCount;+        Try+            Body := Dispatch(Cmd, Lines, Payload);+        Except+            Body := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'exception in agent while handling ' + Cmd) + '}';+        End;+        { wrap: id + timing + the handler body as "data" }+        WriteAtomic(WORKSPACE + 'response_' + Id + '.json',+            '{' + JStr('id', Id) + ',' + JStr('command', Cmd) + ',' + JRaw('durationMs', IntToStr(GetTickCount - T0)) + ',' + JRaw('data', Body) + '}');+    Finally+        Lines.Free;+    End;+    Result := True;+End;++Procedure StartAgent;+Var+    IdleTicks : Integer;+    Had       : Boolean;+    SleepMs   : Integer;+Begin+    TxClear;+    gRunning := True;+    gRequests := 0;+    gLastReqTick := GetTickCount;+    gLastHbTick := 0;+    IdleTicks := 0;+    { a stale stop file must not kill a fresh loop }+    If FileExists(WORKSPACE + 'stop') Then DeleteFile(WORKSPACE + 'stop');+    WriteAtomic(WORKSPACE + 'agent.json', '{' + JStr('version', AGENT_VERSION) + ',' + JRaw('startedTick', IntToStr(GetTickCount)) + ',' + JStr('workspace', WORKSPACE) + '}');+    Heartbeat(True);+    While gRunning Do+    Begin+        Try+            If Client.IsQuitting Then gRunning := False;+        Except+        End;+        If FileExists(WORKSPACE + 'stop') Then+        Begin+            DeleteFile(WORKSPACE + 'stop');+            gRunning := False;+        End;+        If Not gRunning Then Break;+        Had := False;+        Try+            Had := ServeOne;+        Except+        End;+        If Had Then+        Begin+            IdleTicks := 0;+            SleepMs := POLL_ACTIVE_MS;+        End+        Else+        Begin+            IdleTicks := IdleTicks + 1;+            If IdleTicks > IDLE_AFTER_TICKS Then SleepMs := POLL_IDLE_MS Else SleepMs := POLL_ACTIVE_MS;+        End;+        If (GetTickCount - gLastReqTick) > AUTO_STOP_MS Then gRunning := False;+        Heartbeat(False);+        Application.ProcessMessages;+        Sleep(SleepMs);+    End;+    Try+        WriteAtomic(WORKSPACE + 'agent.json', '{' + JStr('version', AGENT_VERSION) + ',' + JRaw('stoppedTick', IntToStr(GetTickCount)) + ',' + JRaw('requests', IntToStr(gRequests)) + '}');+        If FileExists(WORKSPACE + 'hb.txt') Then DeleteFile(WORKSPACE + 'hb.txt');+    Except+    End;+End;++{ Convenience entry points for File > Run Script users. }+Procedure StopAgent;+Begin+    WriteAtomic(WORKSPACE + 'stop', '1');+End;
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.1.8",+  "version": "0.1.9",   "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",
src/caller_identity.pyadded+132
@@ -0,0 +1,132 @@+"""Caller provenance — WHO asked (Bridge 1.9.180 + 1.9.183).++A user runs ~20 AI threads against one desktop. Bridge now stamps three headers on+every relayed request so a bridge can attribute work to the thread that caused+it. This module keeps that identity for the life of a request and re-forwards it+on any Bridge callback the bridge makes while carrying out that request, so the+Activity Log shows `chip-fetcher tab 3 (via altium)` instead of a nameless bridge.++Trust rule (Bridge's, and it is not optional): these values are SELF-ASSERTED, like+`reason`. Use them for logging, UX, window labels and arbitration hints — NEVER+for authorization. `if thread == "admin-tab": allow()` is a hole, not a check.+"""++import threading++# The identity of the request THIS thread is currently serving. A threading+# HTTPServer handles each request on its own thread, so thread-local is the+# right scope — no request can see another's caller.+_current = threading.local()++_HEADERS = ("X-Adom-Caller-Thread", "X-Adom-Caller-Container", "X-Adom-Caller-Reason")++# What we name ourselves when we forward. Kept generic ("kicad") to match the+# verb prefix Bridge already knows us by.+DELEGATE = "altium"+++def set_from_request(headers, args) -> dict:+    """Capture the caller from an incoming relay request.++    Bridge delivers caller identity TWO ways and we must read BOTH:+      * INBOUND relay (Bridge -> bridge): the CLI's --ai-thread flag and any explicit+        `caller` in args arrive as `args["caller"] = {"aiThread", "containerName",+        "source"}`. This is what actually shows up in practice (verified on+        AdomLapper 1.9.183, 2026-07-25) — NOT the X-Adom-Caller-* headers.+      * Header form (X-Adom-Caller-*): the shape Bridge uses on its OWN direct-API+        callbacks and what the SDK examples forward. Read it as a fallback so we+        also work if a future Bridge stamps the relay path with headers.++    args wins when both are present (it carries the explicit/flag identity).+    """+    ident = {h: (headers.get(h, "") or "") for h in _HEADERS} if headers else {h: "" for h in _HEADERS}+    caller = (args or {}).get("caller") or {}+    if isinstance(caller, dict):+        if caller.get("aiThread"):+            ident["X-Adom-Caller-Thread"] = caller["aiThread"]+        if caller.get("containerName"):+            ident["X-Adom-Caller-Container"] = caller["containerName"]+    if (args or {}).get("reason") and not ident["X-Adom-Caller-Reason"]:+        ident["X-Adom-Caller-Reason"] = args["reason"]+    _current.ident = ident+    return ident+++def set_from_headers(headers) -> dict:+    """Capture the caller headers off an incoming request. `headers` is anything+    with .get() (http.server's message object). Returns the captured dict."""+    ident = {h: (headers.get(h, "") or "") for h in _HEADERS}+    _current.ident = ident+    return ident+++def clear() -> None:+    _current.ident = None+++def thread_name() -> str:+    """The caller's thread name, or '' if none was supplied (loopback callers —+    Hydrogen, another bridge, the local CLI — are exempt and send nothing)."""+    ident = getattr(_current, "ident", None) or {}+    return ident.get("X-Adom-Caller-Thread", "")+++def log_suffix() -> str:+    """A `caller="..."` fragment for per-verb log lines, or '' when anonymous."""+    t = thread_name()+    return f' caller="{t}"' if t else ""+++def forward_headers() -> dict:+    """Headers to put on a Bridge callback made WHILE serving an AI's verb. Echoes+    the three we were handed and ADDS X-Adom-Caller-Delegate naming us, so Bridge can+    show both facts: who asked AND who ran it. Empty when there's no caller to+    forward (e.g. a self-initiated poll) — see self_caller() for that case."""+    ident = getattr(_current, "ident", None) or {}+    if not ident.get("X-Adom-Caller-Thread"):+        return {}+    fwd = {h: ident.get(h, "") for h in _HEADERS}+    fwd["X-Adom-Caller-Delegate"] = DELEGATE+    return fwd+++def self_caller() -> dict:+    """The `caller` args block to send on a callback the bridge makes on its OWN+    behalf (health poll, timer, crash cleanup). An explicit caller in args always+    wins over the forwarded headers, so this never leaks a stale thread name."""+    return {"aiThread": "altium bridge (self)", "containerName": "local"}+++def ad_callback(command: str, args: dict = None, on_behalf: bool = True, timeout: int = 30):+    """Call a Bridge verb back over the loopback direct API, forwarding the caller.++    `on_behalf=True` (default): this call is part of serving an AI's verb, so it+    forwards the X-Adom-Caller-* headers + adds our Delegate — Bridge shows+    "chip-fetcher tab 3 (via altium)". `on_behalf=False`: a self-initiated call+    (poll/timer/cleanup) — sends our own `caller` in args so no stale thread leaks.++    Reads ADOM_DIRECT_API_URL + ADOM_BRIDGE_TOKEN from the spawn env (Bridge injects+    both). Returns the parsed JSON, or {"success": False, ...} if Bridge is+    unreachable or the env isn't populated. NEVER raises — a labeling/notify+    callback failing must not fail the Altium verb the user actually asked for.+    """+    import os, json as _json, urllib.request+    base = os.environ.get("ADOM_DIRECT_API_URL", "").rstrip("/")+    if not base:+        return {"success": False, "error": "ADOM_DIRECT_API_URL not in env (inbound-only spawn?)"}+    body = {"command": command, "args": dict(args or {})}+    headers = {"Content-Type": "application/json"}+    tok = os.environ.get("ADOM_BRIDGE_TOKEN")+    if tok:+        headers["X-Adom-Bridge-Token"] = tok   # attribution badge, not an auth gate+    if on_behalf:+        headers.update(forward_headers())+    else:+        body["args"].setdefault("caller", self_caller())+    try:+        req = urllib.request.Request(f"{base}/command", data=_json.dumps(body).encode(),+                                     headers=headers, method="POST")+        with urllib.request.urlopen(req, timeout=timeout) as r:+            return _json.loads(r.read().decode() or "{}")+    except Exception as e:+        return {"success": False, "error": f"Bridge callback failed: {e}"}
src/extension/AdomBridge/AdomBridge.dlladded
⋯ 1 unchanged line ⋯
src/extension/AdomBridge/AdomBridge.insadded+26
@@ -0,0 +1,26 @@+ClientInsFile 1.0++Server+    EditorName        = 'AdomBridge'+    EditorExePath     = 'AdomBridge.DLL'+    EditorDescription = 'Adom Bridge - parameterized processes for container automation'+    Version           = 'Version 0.2.0'+    Date              = '05-Aug-2026'+    HelpAboutInfo     = 'Adom Inc. in-process automation bridge.'+    Copyright         = 'Copyright Adom Inc. 2026'+End++Command+    Name = 'Ping'+    LongSummary = 'AdomBridge connectivity test - writes adombridge_ping.txt'+End++Command+    Name = 'ExportStep'+    LongSummary = 'Export a PcbDoc 3D model to STEP. PcbDoc=<path>|Out=<path>'+End++Command+    Name = 'ImportKiCad'+    LongSummary = 'Import a KiCad file with no wizard. Req=<request file>'+End
src/extension/AdomBridge/AdomBridge.rcsadded+23
@@ -0,0 +1,23 @@+PL PLAdomBridge_Ping+        Command='AdomBridge:Ping'+        Caption='Adom Ping'+        Image=''+        Params=''+        Description='AdomBridge connectivity test'+End++PL PLAdomBridge_ExportStep+        Command='AdomBridge:ExportStep'+        Caption='Adom Export STEP'+        Image=''+        Params=''+        Description='Export a PcbDoc 3D model to STEP'+End++PL PLAdomBridge_ImportKiCad+        Command='AdomBridge:ImportKiCad'+        Caption='Adom Import KiCad'+        Image=''+        Params=''+        Description='Import a KiCad file with no wizard'+End
src/extension/AdomBridge/Help/ServerDlls.txtadded+1
@@ -0,0 +1 @@+AdomBridge=AdomBridge\ No newline at end of file
src/extension/Install-AdomBridge.ps1added+100
@@ -0,0 +1,100 @@+# Install-AdomBridge.ps1 — hand-install the AdomBridge extension.+#+# Altium ships no silent extension-install CLI (AltiumInstaller.exe has no /silent-style switch,+# and no .rcs registers an install process), but the Extensions folder and ExtensionsRegistry.xml+# both carry an inherited "Everyone: Modify" ACE, so writing the files directly IS the supported+# shape. This is what a working third-party extension's Deploy.ps1 does too.+#+# Does NOT restart Altium — killing a live Altium risks unsaved schematics. Restart is the+# caller's decision, made explicitly.+#+#   powershell -ExecutionPolicy Bypass -File Install-AdomBridge.ps1 -Source <dir> [-Remove]++[CmdletBinding()]+param(+    [string] $Source = "$PSScriptRoot\AdomBridge",+    [switch] $Remove+)+$ErrorActionPreference = 'Stop'++$adRoot = Get-ChildItem 'C:\ProgramData\Altium' -Directory |+          Where-Object { $_.Name -like 'Altium Designer*' } | Select-Object -First 1+if (-not $adRoot) { throw 'No "Altium Designer {GUID}" folder under C:\ProgramData\Altium' }++$extRoot = Join-Path $adRoot.FullName 'Extensions'+$target  = Join-Path $extRoot 'AdomBridge'+$reg     = Join-Path $extRoot 'ExtensionsRegistry.xml'+$guid    = 'CDFE1901-218C-5A6A-BEF9-E5BE7083B9FC'++Write-Output "extensions root : $extRoot"+Write-Output "registry        : $reg"++# --- always back the registry up before touching it -------------------------------------+$backup = "$reg.bak-" + (Get-Date -Format 'yyyyMMdd-HHmmss')+Copy-Item -LiteralPath $reg -Destination $backup -Force+Write-Output "registry backup : $backup"++$xml = [xml](Get-Content -LiteralPath $reg -Raw)+$existing = $xml.SelectNodes('//Item') | Where-Object { $_.GetAttribute('HRID') -eq 'AdomBridge' }+foreach ($n in $existing) { [void]$n.ParentNode.RemoveChild($n) }   # idempotent re-install++if ($Remove) {+    $xml.Save($reg)+    if (Test-Path $target) { Remove-Item -LiteralPath $target -Recurse -Force }+    Write-Output 'REMOVED (restart Altium to unload)'+    exit 0+}++# --- files ------------------------------------------------------------------------------+if (Test-Path $target) { Remove-Item -LiteralPath $target -Recurse -Force }+Copy-Item -LiteralPath $Source -Destination $target -Recurse -Force+Write-Output "installed files : $target"+Get-ChildItem $target -Recurse -File | ForEach-Object { Write-Output ('   ' + $_.FullName.Substring($target.Length + 1)) }++# --- registry node ----------------------------------------------------------------------+# Field set mirrors the shipped entries; anything Altium fills in itself is left empty.+$item = $xml.CreateElement('Item')+$item.SetAttribute('HRID', 'AdomBridge')+$item.SetAttribute('Guid', $guid)+function Add-Child([string]$name, [string]$text) {+    $e = $xml.CreateElement($name); $e.InnerText = $text; [void]$item.AppendChild($e)+}+Add-Child 'Path'             $target+Add-Child 'Status'           '0'+Add-Child 'VaultGuid'        ''+Add-Child 'CreatedBy'        'Adom Inc.'+Add-Child 'CategoryGuid'     ''+Add-Child 'CategoryName'     ''+Add-Child 'ReadMe'           ''+Add-Child 'Help'             ''+Add-Child 'Requirements'     ''+Add-Child 'Title'            'Adom Bridge'+Add-Child 'ShortDescription' 'Parameterized in-process automation for container-driven Altium.'+Add-Child 'LongDescription'  'Registers processes that accept arguments, so a container can drive Altium without the wizard-and-click surface Altium exposes externally.'+Add-Child 'SmallImage'       ''+Add-Child 'LargeImage'       ''+Add-Child 'Version'          '0.1.0.0'+Add-Child 'VersionGuid'      ([guid]::NewGuid().ToString().ToUpper())+$oleNow = [string]([datetime]::Now.ToOADate())+Add-Child 'ReleasedDate'     $oleNow+Add-Child 'ReleaseNotes'     ''+Add-Child 'DateInstalled'    $oleNow++# Shipped entries all carry this block; absent it, the client may treat the entry as malformed.+$pv = $xml.CreateElement('PlatformVersions')+foreach ($pair in @(@('DXP','1.0.16.43'), @('EDP','10.0.16.43'), @('MaxDXP','0.0.0.0'), @('MaxEDP','0.0.0.0'))) {+    $e = $xml.CreateElement($pair[0]); $e.SetAttribute('BuildNumber', $pair[1]); [void]$pv.AppendChild($e)+}+[void]$item.AppendChild($pv)+[void]$xml.DocumentElement.AppendChild($item)++# AltiumInstaller carries a "…it is opened by some process" string, so tolerate a transient lock.+for ($i = 0; $i -lt 5; $i++) {+    try { $xml.Save($reg); break }+    catch { if ($i -eq 4) { throw }; Start-Sleep -Seconds 2 }+}++$check = [xml](Get-Content -LiteralPath $reg -Raw)+$n = ($check.SelectNodes('//Item') | Where-Object { $_.GetAttribute('HRID') -eq 'AdomBridge' }).Count+Write-Output "registry items  : $(($check.SelectNodes('//Item')).Count) total, AdomBridge nodes = $n"+Write-Output 'INSTALLED — Altium must be restarted before the server is loaded.'
src/extension/REMOVE-AdomBridge.cmdadded+38
@@ -0,0 +1,38 @@+@echo off+REM ============================================================================+REM  EMERGENCY ROLLBACK - double-click this if Altium will not start.+REM+REM  Deletes the AdomBridge extension folder and its ExtensionsRegistry.xml entry,+REM  returning Altium to exactly how it was before the extension was installed.+REM  Needs no admin rights and no network. Safe to run twice.+REM+REM  After it finishes, start Altium normally.+REM ============================================================================+setlocal+echo.+echo   Removing the AdomBridge Altium extension...+echo.++powershell -NoProfile -ExecutionPolicy Bypass -Command ^+  "$ErrorActionPreference='Stop';" ^+  "$root = Get-ChildItem 'C:\ProgramData\Altium' -Directory | Where-Object { $_.Name -like 'Altium Designer*' } | Select-Object -First 1;" ^+  "if (-not $root) { Write-Host '  No Altium Designer folder found - nothing to do.'; exit 0 };" ^+  "$ext = Join-Path $root.FullName 'Extensions';" ^+  "$dir = Join-Path $ext 'AdomBridge';" ^+  "$reg = Join-Path $ext 'ExtensionsRegistry.xml';" ^+  "if (Test-Path $dir) { Remove-Item -LiteralPath $dir -Recurse -Force; Write-Host '  removed folder :' $dir } else { Write-Host '  folder already gone' };" ^+  "if (Test-Path $reg) {" ^+  "  Copy-Item -LiteralPath $reg -Destination ($reg + '.bak-remove-' + (Get-Date -Format 'yyyyMMdd-HHmmss')) -Force;" ^+  "  $x = [xml](Get-Content -LiteralPath $reg -Raw);" ^+  "  $n = @($x.SelectNodes('//Item') | Where-Object { $_.GetAttribute('HRID') -eq 'AdomBridge' });" ^+  "  foreach ($i in $n) { [void]$i.ParentNode.RemoveChild($i) };" ^+  "  for ($k=0; $k -lt 5; $k++) { try { $x.Save($reg); break } catch { Start-Sleep -Seconds 2 } };" ^+  "  Write-Host '  removed registry entries :' $n.Count;" ^+  "  $c = [xml](Get-Content -LiteralPath $reg -Raw);" ^+  "  Write-Host '  registry now has' @($c.SelectNodes('//Item')).Count 'items'" ^+  "} else { Write-Host '  no ExtensionsRegistry.xml found' }"++echo.+echo   Done. Start Altium normally.+echo.+pause
src/handlers/__init__.pyadded

  
src/handlers/ad_client.pyadded+282
@@ -0,0 +1,282 @@+"""Outbound calls from this bridge BACK to Adom Desktop (AD) core.++(Ported verbatim from adom/fusion-bridge ad_client.py, 2026-08-17 - keep in sync.)++Why this exists: as of **AD 1.9.84** a bridge's loopback callback+(`POST $ADOM_DIRECT_API_URL/command`) reaches the FULL AD verb set - not just+`desktop_*`. That means this bridge can, on its own:++  * `notify_user` the user directly (it used to be "Unknown desktop command", so+    the old code returned a `notifyUser` PAYLOAD and asked the driving AI to relay+    it - a workaround, now dropped);+  * enumerate peer ADs on the relay (`targets`) and route ANY verb to one via a+    cross-AD `target` - so a bridge running on a VM can reach the user on their+    LAPTOP ("notify them where they actually are").++Everything here is BEST-EFFORT and never raises: an older AD (pre-1.9.84), a+missing env var, or a closed port just yields None/False and the caller falls+back to its prior behavior. We CAPABILITY-PROBE (`GET /commands`) rather than+hardcode, per the SDK, so this keeps working as the verb set evolves.++Auth model (from disc #78): `X-Adom-Bridge-Token` = ATTRIBUTION only (AD binds+127.0.0.1 and trusts-by-install), so calls run ungated; a stale token 403s, so we+re-read it from the env on every call.+"""++import json+import os+import urllib.request+import urllib.error++_TIMEOUT = 8+# Cache the capability set for the process lifetime (the AD verb surface doesn't+# change under a running AD). None = not-yet-probed / unreachable.+_caps_cache = None+_self_name_cache = None+++_discovered_base = None  # None = not tried, "" = tried+failed, str = discovered base URL+++def _raw_get(url, timeout=4):+    """Low-level GET that does NOT go through _base_url (used by discovery so it can't recurse).+    Returns parsed JSON, or None."""+    try:+        req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET")+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            return json.loads(resp.read().decode("utf-8", "replace"))+    except Exception:+        return None+++def _looks_like_ad_api(data) -> bool:+    """Does a /commands response look like AD's verb catalog (so we don't bind a random service)?"""+    if isinstance(data, dict):+        if isinstance(data.get("commands"), list):+            return True+        # AD's /commands also carries a _hint mentioning POST /command+        if "command" in (data.get("_hint") or "").lower():+            return True+    return isinstance(data, list)+++def _discover_direct_api():+    """Find AD's loopback direct-API base by port-probing, for the case where AD did NOT inject+    ADOM_DIRECT_API_URL into our spawn env (seen live on an AD 1.9.103 Hyper-V VM: the direct API+    WAS up on 127.0.0.1 but the env var was absent, so available() short-circuited to False and the+    bridge fell back to slow CLI relays - John caught it 2026-07-14). We enumerate the loopback TCP+    ports adom-desktop.exe is LISTENING on and probe each `/commands`; the one that returns AD's verb+    catalog is the direct API. Cached for the process lifetime. Windows-only + best-effort; never raises."""+    global _discovered_base+    if _discovered_base:                # cache SUCCESS only; a transient miss must not stick forever+        return _discovered_base+    try:+        import subprocess as _sp, re as _re+        cnw = getattr(_sp, "CREATE_NO_WINDOW", 0)+        tl = _sp.run(["tasklist", "/FI", "IMAGENAME eq adom-desktop.exe", "/FO", "CSV", "/NH"],+                     capture_output=True, text=True, timeout=8, creationflags=cnw).stdout+        pids = set(_re.findall(r'"adom-desktop\.exe","(\d+)"', tl))+        if not pids:+            return None+        ns = _sp.run(["netstat", "-ano", "-p", "TCP"], capture_output=True, text=True,+                     timeout=8, creationflags=cnw).stdout+        seen = []+        for line in ns.splitlines():+            m = _re.search(r'\s(?:127\.0\.0\.1|\[::1\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)', line)+            if m and m.group(2) in pids:+                p = int(m.group(1))+                if p not in seen:+                    seen.append(p)+        for p in seen:+            base = f"http://127.0.0.1:{p}"+            if _looks_like_ad_api(_raw_get(base + "/commands")):+                _discovered_base = base+                return base+    except Exception:+        pass+    return None+++def _base_url():+    """The loopback direct-API base: the URL AD injects into our spawn env, else one we DISCOVER by+    probing adom-desktop's loopback ports (AD sometimes fails to inject the env var even though the+    API is up - see _discover_direct_api). None only if neither yields a reachable API."""+    env = (os.environ.get("ADOM_DIRECT_API_URL") or "").rstrip("/")+    if env:+        return env+    return _discover_direct_api()+++def _token():+    # Re-read every call: after an AD restart the old token 403s.+    return os.environ.get("ADOM_BRIDGE_TOKEN") or ""+++def _http(method, path, body=None, timeout=_TIMEOUT):+    """Best-effort JSON request to the direct API. Returns parsed dict/list or None."""+    base = _base_url()+    if not base:+        return None+    url = base + path+    data = None+    headers = {"Accept": "application/json"}+    tok = _token()+    if tok:+        headers["X-Adom-Bridge-Token"] = tok+    if body is not None:+        data = json.dumps(body).encode("utf-8")+        headers["Content-Type"] = "application/json"+    req = urllib.request.Request(url, data=data, headers=headers, method=method)+    try:+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            raw = resp.read().decode("utf-8", "replace")+    except Exception:+        return None+    try:+        return json.loads(raw)+    except Exception:+        return None+++def available() -> bool:+    """True if the direct API is reachable at all (env var present + port open)."""+    if not _base_url():+        return False+    return capabilities() is not None+++def _names_from_list(items):+    """Collect verb names from a list of strings and/or {name|command|verb} dicts."""+    out = set()+    for it in items or []:+        if isinstance(it, str):+            out.add(it)+        elif isinstance(it, dict):+            n = it.get("name") or it.get("command") or it.get("verb")+            if n:+                out.add(n)+    return out+++def _parse_command_names(data):+    """Extract the full verb-name set from a /commands response, tolerant of BOTH the legacy+    `{"commands":[...]}` shape AND the current AD (1.9.x) shape, where verbs are split across:+      topLevel: ["notify_user", "targets", ...]            (flat name list)+      cliRequired: ["pull_file", "shell_execute", ...]     (flat name list)+      bridges: [{name, verbs:[...]}, ...]                  (per-bridge verb lists)+      desktop: {commands:["screenshot_window", ...]}       (SHORT names; real verb = "desktop_"+name)+    Without this, capabilities() returned None on every current AD, so available() was always False+    and the bridge silently fell back to slow CLI relays (root-caused live, John 2026-07-14)."""+    names = set()+    if isinstance(data, list):+        return _names_from_list(data)+    if not isinstance(data, dict):+        return names+    if isinstance(data.get("commands"), list):          # legacy shape+        names |= _names_from_list(data["commands"])+    for k in ("topLevel", "cliRequired"):               # current: flat name lists+        if isinstance(data.get(k), list):+            names |= _names_from_list(data[k])+    for b in (data.get("bridges") or []):               # current: per-bridge verb lists+        if isinstance(b, dict):+            names |= _names_from_list(b.get("verbs"))+    dk = data.get("desktop")                            # current: desktop short names -> desktop_*+    if isinstance(dk, dict):+        for c in (dk.get("commands") or []):+            if isinstance(c, str):+                names.add(c)+                names.add("desktop_" + c)+    return names+++def capabilities():+    """Set of AD command names from `GET /commands` (cached). None if unreachable/unparseable."""+    global _caps_cache+    if _caps_cache is not None:+        return _caps_cache+    data = _http("GET", "/commands")+    if data is None:+        return None+    names = _parse_command_names(data)+    if not names:+        return None+    _caps_cache = names+    return names+++def has(command: str):+    """True/False if `command` is in the probed capability set; None if unknown+    (probe failed). Callers should try when the answer isn't an explicit False."""+    caps = capabilities()+    if caps is None:+        return None+    return command in caps+++def call(command: str, args=None, target=None, timeout=_TIMEOUT):+    """Dispatch ANY AD verb back through the direct API. Returns the parsed JSON+    response dict, or None on any failure (never raises).++    target: a peer AD clientName (from `peers()`), or "all" to fan out. Omit to+    run on THIS AD (the one hosting the bridge)."""+    payload = {"command": command}+    if args:+        payload["args"] = args+    if target:+        payload["target"] = target+    return _http("POST", "/command", body=payload, timeout=timeout)+++def self_name():+    """This AD's own clientName (to exclude it from peer routing). Best-effort."""+    global _self_name_cache+    if _self_name_cache is not None:+        return _self_name_cache or None+    st = _http("GET", "/status") or {}+    name = st.get("clientName") or st.get("name") or st.get("hostname")+    _self_name_cache = name or ""+    return name or None+++def peers():+    """clientNames of the OTHER ADs on the relay (excludes self). [] if none/unknown."""+    res = call("targets") or {}+    # tolerate shape drift: the peer list is under `clients` on current AD, `targets` on older.+    tlist = None+    if isinstance(res, dict):+        tlist = res.get("targets") or res.get("clients")+    if not isinstance(tlist, list):+        return []+    me = self_name()+    out = []+    for t in tlist:+        n = t if isinstance(t, str) else ((t or {}).get("name") or (t or {}).get("hostname") or (t or {}).get("clientName"))+        if n and n != me:+            out.append(n)+    return out+++def notify(title, body, level="info", buttons=None, target=None, reach_user=False):+    """Fire an AD `notify_user` toast. Returns the response dict, or None if the+    direct API is unreachable or AD is too old to have notify_user (caller falls+    back to returning a notifyUser payload for the driving AI to relay).++    reach_user: when this bridge may be running UNATTENDED (e.g. on a VM) while the+    user is on another machine, set True to also reach peer ADs. Since AD can't yet+    resolve which peer is "attended", we fan out to "all" when peers exist so the+    toast lands wherever the user actually is. `target` (explicit) overrides this.+    """+    if has("notify_user") is False:+        return None+    args = {"title": title, "body": body, "level": level}+    if buttons:+        args["buttons"] = buttons+    tgt = target+    if tgt is None and reach_user:+        # Fan out to EVERY connected desktop so the toast lands wherever the user actually is. Do NOT+        # gate on peers() (John 2026-07-14): the `targets` response shape drifted to {clients:[...]}+        # so peers() wrongly returns [] and the toast would hit only THIS (unattended VM). The relay+        # honors target="all" directly - verified reaching the user's laptop.+        tgt = "all"+    res = call("notify_user", args, target=tgt)+    return res
src/handlers/agent.pyadded+556
@@ -0,0 +1,556 @@+"""agent.py — the bridge side of the resident DelphiScript agent (the REPL lane).++Lifecycle+  install()  render AdomAgent.pas.tmpl with the workspace path baked in, drop the+             .pas + .PrjScr into  <cache>\\agent\\  (idempotent, content-hashed)+  start()    `X2.EXE -RScriptingSystem:RunScript(ProjectName="…AdomAgent.PrjScr"|ProcName="StartAgent")`+             — the helper X2 forwards to the running instance and exits; we then+             wait for hb.txt to appear.+  call()     write request_<id>.txt (+ .payload), poll for response_<id>.json,+             watch hb.txt to tell "slow handler" from "engine wedged".+  stop()     drop the `stop` file; the loop exits on its next tick.++Engine rules this module enforces (measured 2026-08-29, AD 26.9.1):+  * ONE script at a time.  While the agent is resident, NEVER launch another+    -R RunScript/RunScriptText: it raises the modal "Another script executing+    now" on the main instance and every later -R is refused as "busy" until a+    human/UIA dismisses it.  So `run_script_lane()` refuses when the agent is up.+  * A modal on the main instance => the instance is "busy" to -R and the agent+    loop is frozen (modal message loop).  dialogs.py is how you get out.+  * RunScriptText = statements only; CreateOleObject does not exist.+"""+from __future__ import annotations++import hashlib+import json+import os+import subprocess+import sys+import threading+import time+import uuid+from pathlib import Path++from handlers import altium_detect++_inbox_lock = threading.Lock()+_no_recover = False++AGENT_TEMPLATE = Path(__file__).resolve().parent.parent / "agent" / "AdomAgent.pas.tmpl"+AGENT_PRJ = Path(__file__).resolve().parent.parent / "agent" / "AdomAgent.PrjScr"+++def cache_dir() -> Path:+    """%LOCALAPPDATA%\\Adom Bridge\\bridges-cache\\altium — next to the other bridges.+    Tolerates ab's stripped env (LOCALAPPDATA may be unset)."""+    base = os.environ.get("LOCALAPPDATA")+    if not base:+        up = os.environ.get("USERPROFILE") or str(Path.home())+        base = str(Path(up) / "AppData" / "Local")+    d = Path(base) / "Adom Bridge" / "bridges-cache" / "altium"+    d.mkdir(parents=True, exist_ok=True)+    return d+++def workspace_dir() -> Path:+    d = cache_dir() / "agent"+    d.mkdir(parents=True, exist_ok=True)+    return d+++def _ws_str() -> str:+    s = str(workspace_dir())+    return s if s.endswith("\\") else s + "\\"+++def rendered_agent_source() -> str:+    tmpl = AGENT_TEMPLATE.read_text(encoding="utf-8")+    # DelphiScript string literal: single quotes; the workspace has none, but be safe.+    return tmpl.replace("__WORKSPACE__", _ws_str().replace("'", "''")).replace("__GEN__", _agent_base())+++def _agent_base() -> str:+    """AdomAgent_<digest> — a NEW filename whenever the source changes. Altium keeps+    the .PrjScr/.pas OPEN in its Script editor after a RunScript and re-runs the+    in-editor copy, so overwriting AdomAgent.pas in place ran the STALE unit+    (measured 2026-08-29: "Program cannot find script … StartAgent" + the Run+    Script picker, hours after the fix was on disk)."""+    raw = AGENT_TEMPLATE.read_text(encoding="utf-8")+    digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:10]+    return f"AdomAgent_{digest}"+++def prjscr_path() -> Path:+    return workspace_dir() / (_agent_base() + ".PrjScr")+++def install() -> dict:+    """Write the agent script project into the workspace. Idempotent per digest."""+    ws = workspace_dir()+    src = rendered_agent_source()+    base = _agent_base()+    pas = ws / (base + ".pas")+    prj = ws / (base + ".PrjScr")+    changed = not pas.exists()+    if changed:+        pas.write_text(src, encoding="ascii", errors="replace")+        prj.write_text(AGENT_PRJ.read_text(encoding="utf-8").replace("AdomAgent.pas", base + ".pas"), encoding="ascii")+        # prune older generations (keep the newest 3 so an open editor tab does not dangle)+        gens = sorted(ws.glob("AdomAgent_*.pas"), key=lambda q: q.stat().st_mtime)+        for old in gens[:-3]:+            for q in (old, old.with_suffix(".PrjScr")):+                try:+                    q.unlink()+                except Exception:+                    pass+    return {"installed": True, "changed": changed, "prjscr": str(prj), "pas": str(pas), "digest": base[-10:]}+++# ---- liveness ----------------------------------------------------------------++def _hb() -> dict | None:+    p = workspace_dir() / "hb.txt"+    if not p.exists():+        return None+    try:+        lines = p.read_text(errors="replace").splitlines()+        tick = int(lines[0].strip())+        out = {"tick": tick, "ageSec": None, "mtime": p.stat().st_mtime}+        out["ageSec"] = round(time.time() - p.stat().st_mtime, 2)+        for l in lines[1:]:+            if "=" in l:+                k, v = l.split("=", 1)+                out[k.strip()] = v.strip()+        return out+    except Exception:+        return None+++# Two different questions, two thresholds (measured 2026-08-29):+#   responsive  = heartbeat < 3 s  -> the loop is idle-polling right now+#   alive       = heartbeat < 60 s -> the loop exists; it may be INSIDE a long+#                 RunProcess (PCB:Zoom on a 3D board took 3.2 s, an OutJob takes+#                 minutes) and cannot heartbeat until that returns.+# Treating "busy" as "dead" and auto-starting a second loop raised Altium's+# "Another script executing now" modal, which then froze the real loop. Never+# start while ALIVE; only report "not responsive".+ALIVE_SEC = 60.0+RESPONSIVE_SEC = 3.0+++def is_alive(max_age_sec: float = ALIVE_SEC) -> bool:+    hb = _hb()+    return bool(hb and hb.get("ageSec") is not None and hb["ageSec"] <= max_age_sec)+++def is_responsive() -> bool:+    return is_alive(RESPONSIVE_SEC)+++def status() -> dict:+    hb = _hb()+    alive = is_alive()+    st = {"alive": alive, "responsive": is_responsive(), "heartbeat": hb, "workspace": _ws_str(),+          "installed": prjscr_path().exists()}+    if alive and not st["responsive"]:+        st["_hint"] = "Loop is alive but busy inside a long Altium process (no heartbeat while a RunProcess runs). Wait; do not start a second agent."+    aj = workspace_dir() / "agent.json"+    if aj.exists():+        try:+            st["agent"] = json.loads(aj.read_text(errors="replace"))+        except Exception:+            pass+    if hb and not alive:+        st["_hint"] = ("Heartbeat is stale: the loop stopped (auto-stop after idle, Altium closed, or a script "+                       "error left the engine halted). Call altium_agent_start to bring it back; if a modal "+                       "is up, altium_check_dialogs first.")+    return st+++# ---- start / stop -------------------------------------------------------------++def _x2_exe() -> str | None:+    inst = altium_detect.detect_installs()+    return inst[0]["exe"] if inst else None+++def _spawn_helper(exe: str, arg: str) -> subprocess.Popen:+    r"""Launch `X2.EXE <arg>` with the -R argument passed VERBATIM.++    Measured 2026-08-29: Popen([exe, arg]) re-quotes the argument because the+    workspace path contains a space ("Adom Bridge") — the helper then received+    "-RScriptingSystem:RunScript(ProjectName=\"C:\...\")" with backslash-escaped+    quotes, which Altium cannot parse, so it neither forwarded nor exited. A+    single command-line STRING is handed to CreateProcess untouched, exactly as+    PowerShell's Start-Process -ArgumentList did in the experiments that worked.+    """+    cmdline = f'"{exe}" {arg}'+    return subprocess.Popen(cmdline, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))+++def _kill_stray_helpers() -> list[int]:+    """Kill every X2.exe that is NOT the one owning the main window (those are+    -R helpers left waiting on a busy instance)."""+    mw = altium_detect.main_window()+    main_pid = mw["pid"] if mw else None+    out = []+    for pid in altium_detect.running_pids():+        if main_pid is not None and pid != main_pid:+            try:+                subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=10,+                               creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))+                out.append(pid)+            except Exception:+                pass+    return out+++def _reap_busy_notice() -> list:+    """A stray -R helper fired at a busy main instance makes Windows raise the single-instance+    notice ("There is another instance ... but it is busy at the moment") as a modal on the+    USER's screen — owned by the helper X2, not the main one (issue #3 follow-up, Colby). Scan+    every X2 dialog and dismiss that specific notice in the background so it never lingers."""+    from handlers import dialogs+    reaped = []+    try:+        for d in dialogs.scan(main_pid_only=False):+            blob = ((d.get("body") or "") + " " + (d.get("title") or "")).lower()+            if "another instance" in blob or "busy at the moment" in blob or "cannot proceed with the request" in blob:+                dialogs.press(d["hwnd"], "OK")+                reaped.append(d.get("title") or "notice")+    except Exception:+        pass+    return reaped+++def run_script_lane(proc_name: str, prjscr: str | None = None, timeout: float = 30.0) -> dict:+    """The -R lane: forward RunScript to the running instance. REFUSES while the+    agent is resident (one script at a time — see module docstring)."""+    if is_alive():+        return {"success": False, "errorCode": "agent_resident",+                "error": "The Adom agent loop is resident; a second script would raise Altium's "+                         "'Another script executing now' modal.",+                "_hint": "Send the work through the agent (altium_eval / altium_run_command) or "+                         "altium_agent_stop first."}+    exe = _x2_exe()+    if not exe:+        return {"success": False, "errorCode": "host_app_not_installed", "hostApp": altium_detect.HOST_APP}+    prj = prjscr or str(prjscr_path())+    arg = f'-RScriptingSystem:RunScript(ProjectName="{prj}"|ProcName="{proc_name}")'+    try:+        p = _spawn_helper(exe, arg)+    except Exception as e:+        return {"success": False, "error": f"could not launch X2 helper: {e}"}+    # The helper exits once the running instance accepted the request (or shows its+    # own "another instance is busy" dialog and hangs — we cap that).+    t0 = time.time()+    while time.time() - t0 < timeout:+        if p.poll() is not None:+            return {"success": True, "helperExitCode": p.returncode, "helperSec": round(time.time() - t0, 2), "arg": arg}+        time.sleep(0.2)+    try:+        p.kill()+    except Exception:+        pass+    reaped = _reap_busy_notice()+    return {"success": False, "errorCode": "instance_busy",+            "error": "The X2 helper never exited: the running instance refused the request as busy "+                     "(a modal is open, or a script is already running).",+            "busyNoticeReaped": reaped,+            "_hint": "altium_check_dialogs, dismiss what is blocking, then retry."}+++def run_script_text_lane(text: str, timeout: float = 30.0) -> dict:+    """-R RunScriptText: STATEMENTS ONLY (Begin ... End;). Refused while the agent is resident."""+    if is_alive():+        return {"success": False, "errorCode": "agent_resident",+                "_hint": "Use altium_eval (goes through the agent) while the agent is up."}+    exe = _x2_exe()+    if not exe:+        return {"success": False, "errorCode": "host_app_not_installed", "hostApp": altium_detect.HOST_APP}+    if '"' in text:+        return {"success": False, "error": "RunScriptText text cannot contain double quotes (the -R arg is double-quoted); use single quotes for strings."}+    arg = f'-RScriptingSystem:RunScriptText(Text="{text}")'+    try:+        p = _spawn_helper(exe, arg)+    except Exception as e:+        return {"success": False, "error": f"could not launch X2 helper: {e}"}+    t0 = time.time()+    while time.time() - t0 < timeout:+        if p.poll() is not None:+            return {"success": True, "helperSec": round(time.time() - t0, 2)}+        time.sleep(0.2)+    try:+        p.kill()+    except Exception:+        pass+    reaped = _reap_busy_notice()+    return {"success": False, "errorCode": "instance_busy", "busyNoticeReaped": reaped, "_hint": "altium_check_dialogs, then retry."}+++def start(wait_sec: float = 45.0) -> dict:+    """Install (idempotent) and bootstrap the loop; returns when hb.txt is fresh."""+    inst = install()+    if is_alive():+        hb = _hb() or {}+        resident = hb.get("gen")+        want = _agent_base()+        if resident and resident != want:+            # an OLDER generation is still polling inside Altium; it would answer with+            # stale code (measured 2026-08-30). Stop it, then bootstrap the new one.+            stop()+            time.sleep(1.0)+        else:+            return {"success": True, "alreadyRunning": True, "status": status()}+    # a stale heartbeat / inbox / responses from a previous life must not confuse this one+    ws = workspace_dir()+    for stale in ["hb.txt", "stop", "request.txt", "request.payload"] + [p.name for p in ws.glob("response_*.json")] + [p.name for p in ws.glob("request_*")]:+        try:+            (ws / stale).unlink()+        except FileNotFoundError:+            pass+        except Exception:+            pass+    t0 = time.time()+    user_fg = _fg()+    # A killed helper's -R request can still be DELIVERED later by Altium and+    # start a second loop -> "Another script executing now". Reap strays first.+    killed = _kill_stray_helpers()+    r = run_script_lane("StartAgent")+    if killed:+        r["strayHelpersKilled"] = killed+    if not r.get("success") and r.get("errorCode") == "instance_busy" and not _no_recover:+        rec = recover("helper reported busy on start")+        r = run_script_lane("StartAgent")+        r["recovery"] = rec+    # The -R bootstrap ACTIVATES Altium (it opens the .PrjScr in the editor). Hand the+    # user's window back right away and keep doing so while we wait — whether or not+    # the script then compiles. (John, 2026-08-29: "stop foregrounding on me".)+    if user_fg and not _is_altium_hwnd(user_fg):+        _restore_fg(user_fg, 2.0)+    if not r.get("success"):+        r["install"] = inst+        return r+    while time.time() - t0 < wait_sec:+        if user_fg and not _is_altium_hwnd(user_fg) and _fg() != user_fg:+            _restore_fg(user_fg, 0.5)+        if is_responsive():+            restored = 0+            if user_fg and not _is_altium_hwnd(user_fg):+                restored = _restore_fg(user_fg, 3.0)+            return {"success": True, "startedInSec": round(time.time() - t0, 2), "install": inst, "status": status(),+                    "windowEtiquette": "background" if restored or _fg() == user_fg else "foreground",+                    "focusRestores": restored}+        time.sleep(0.25)+    return {"success": False, "errorCode": "agent_no_heartbeat",+            "error": f"agent did not heartbeat within {wait_sec}s",+            "install": inst, "helper": r,+            "_hint": "A compile error in the agent raises an 'Error' modal on Altium's main window: run "+                     "altium_check_dialogs and read it. Otherwise Altium may still be starting."}+++def stop(wait_sec: float = 10.0) -> dict:+    if not is_alive():+        return {"success": True, "wasRunning": False}+    (workspace_dir() / "stop").write_text("1")+    t0 = time.time()+    while time.time() - t0 < wait_sec:+        if not (workspace_dir() / "hb.txt").exists():+            return {"success": True, "wasRunning": True, "stoppedInSec": round(time.time() - t0, 2)}+        time.sleep(0.25)+    return {"success": False, "error": "agent did not stop", "_hint": "the loop may be frozen behind a modal — altium_check_dialogs"}+++# ---- focus etiquette -------------------------------------------------------------+# Measured 2026-08-29: the -R RunScript bootstrap opens AdomAgent.PrjScr in the+# editor and ACTIVATES Altium's window — a foreground steal. Capture the user's+# foreground before and hand it back for a few seconds after (Altium re-raises+# once, late), the same shape kicad-bridge uses.++def _is_altium_hwnd(hwnd: int) -> bool:+    try:+        return any(w["hwnd"] == hwnd for w in altium_detect.altium_windows())+    except Exception:+        return False+++def _fg() -> int:+    try:+        import ctypes+        return int(ctypes.windll.user32.GetForegroundWindow())+    except Exception:+        return 0+++def _user_input_recent(ms: int = 1500) -> bool:+    """True when the human touched keyboard/mouse within the last `ms`.+    THE etiquette rule (John, 2026-08-30, "why can't i foreground altium"): the+    restore loop must never fight a human. Repeated agent restarts each ran this+    loop, and his taskbar clicks were reverted within 0.3 s - from his chair+    Altium simply refused to come forward."""+    try:+        import ctypes++        class LASTINPUTINFO(ctypes.Structure):+            _fields_ = [("cbSize", ctypes.c_uint), ("dwTime", ctypes.c_uint)]++        li = LASTINPUTINFO()+        li.cbSize = ctypes.sizeof(li)+        if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(li)):+            return False+        idle_ms = ctypes.windll.kernel32.GetTickCount() - li.dwTime+        return idle_ms < ms+    except Exception:+        return False+++def _restore_fg(hwnd: int, seconds: float = 6.0) -> int:+    if not hwnd or sys.platform != "win32":+        return 0+    import ctypes+    u = ctypes.windll.user32+    k = ctypes.windll.kernel32+    restores = 0+    t0 = time.time()+    while time.time() - t0 < seconds:+        if _user_input_recent():+            break                      # the human is driving; never fight their click+        cur = _fg()+        if cur != hwnd and u.IsWindow(hwnd):+            try:+                cur_tid = u.GetWindowThreadProcessId(cur, None)+                my_tid = k.GetCurrentThreadId()+                u.AttachThreadInput(my_tid, cur_tid, True)+                u.SetForegroundWindow(hwnd)+                u.AttachThreadInput(my_tid, cur_tid, False)+                restores += 1+            except Exception:+                pass+        time.sleep(0.3)+    return restores+++# ---- recovery -------------------------------------------------------------------++def recover(reason: str = "") -> dict:+    """Get the script engine back from a HALTED state.++    Measured 2026-08-29: an unknown identifier is a RUNTIME error in DelphiScript+    (late-bound), it raises an 'Error' modal on the main window AND leaves the+    debugger paused on that line in the Script editor. The paused script counts as+    'executing', so every later -R request is refused ('Another script executing+    now') and the loop's heartbeat stops. The fix that works: press OK on the modal+    (background BM_CLICK), then `X2.EXE -REditScript:Stop`, then re-probe.+    """+    from handlers import dialogs+    steps = []+    for d in dialogs.scan(main_pid_only=True):+        if d.get("category") in ("script_compile_error", "script_runtime_error", "script_busy") or d["title"] in ("Error", "X2"):+            b = "Cancel" if d.get("category") == "script_runtime_error" else "OK"+            steps.append({"dialog": d["title"], "body": d["body"][:120], "pressed": b, "result": dialogs.press(d["hwnd"], b)})+    time.sleep(0.8)+    exe = _x2_exe()+    user_fg = _fg()+    if exe:+        p = _spawn_helper(exe, "-REditScript:Stop")+        t0 = time.time()+        while time.time() - t0 < 15 and p.poll() is None:+            time.sleep(0.2)+        if p.poll() is None:+            p.kill()+            steps.append({"editScriptStop": "helper hung (instance still busy)"})+        else:+            steps.append({"editScriptStop": "sent", "sec": round(time.time() - t0, 2)})+        reaped = _reap_busy_notice()+        if reaped:+            steps.append({"busyNoticeReaped": reaped})+    ws = workspace_dir()+    for stale in ["hb.txt", "stop", "request.txt", "request.payload"] + [q.name for q in ws.glob("response_*.json")]:+        try:+            (ws / stale).unlink()+        except Exception:+            pass+    if user_fg and not _is_altium_hwnd(user_fg):+        _restore_fg(user_fg, 2.0)+    remaining = dialogs.scan(main_pid_only=True)+    return {"success": not remaining, "reason": reason, "steps": steps, "remainingDialogs": remaining,+            "_hint": "Engine should be free now: altium_agent_start to bring the loop back." if not remaining+                     else "A dialog is still up — read remainingDialogs[] and altium_dismiss_dialogs it."}+++# ---- calls ---------------------------------------------------------------------++def call(command: str, params: dict | None = None, payload: str | None = None,+         timeout: float = 30.0, autostart: bool = True) -> dict:+    """One request/response round trip through the file drop."""+    if not is_alive():+        if not autostart:+            return {"success": False, "errorCode": "agent_not_running", "_hint": "altium_agent_start"}+        st = start()+        if not st.get("success"):+            return st+    ws = workspace_dir()+    rid = uuid.uuid4().hex[:10]+    lines = [f"id={rid}", f"command={command}"]+    for k, v in (params or {}).items():+        if v is None:+            continue+        s = str(v)+        if "\n" in s or "\r" in s:+            return {"success": False, "error": f"param {k} must be single-line; put multi-line text in payload"}+        lines.append(f"{k}={s}")+    inbox = ws / "request.txt"+    t0 = time.time()+    # ONE inbox (the engine's FindFirst wildcard never matched): serialize callers+    # in-process, and wait for a previous caller's inbox to be consumed.+    with _inbox_lock:+        while inbox.exists() and time.time() - t0 < timeout:+            time.sleep(0.02)+        if inbox.exists():+            return {"success": False, "errorCode": "agent_busy", "error": "previous request still in the inbox",+                    "_hint": "the agent is not consuming requests — altium_agent_status; if the heartbeat is stale, altium_agent_start"}+        if payload:+            (ws / "request.payload.tmp").write_text(payload, encoding="ascii", errors="replace")+            os.replace(ws / "request.payload.tmp", ws / "request.payload")+        tmp = ws / "request.txt.tmp"+        tmp.write_text("\r\n".join(lines) + "\r\n", encoding="ascii", errors="replace")+        os.replace(tmp, inbox)+    resp = ws / f"response_{rid}.json"+    last_hb = None+    while time.time() - t0 < timeout:+        if resp.exists():+            try:+                raw = resp.read_text(errors="replace")+                data = json.loads(raw)+            except Exception as e:+                time.sleep(0.05)+                if time.time() - t0 < timeout:+                    continue+                return {"success": False, "error": f"unparseable response: {e}", "raw": raw[:500]}+            try:+                resp.unlink()+            except Exception:+                pass+            data["roundtripMs"] = int((time.time() - t0) * 1000)+            body = data.get("data") or {}+            data["success"] = bool(body.get("ok", True)) if isinstance(body, dict) else True+            return data+        hb = _hb()+        if hb:+            last_hb = hb+        time.sleep(0.02)+    # timed out: is the loop even alive?+    alive = is_alive()+    from handlers import dialogs as _dl+    blocking = _dl.scan(main_pid_only=True)+    if blocking:+        return {"success": False, "errorCode": "blocked_by_dialog", "dialogs": blocking,+                "error": f"no response to {command} within {timeout}s — a dialog is up on Altium's main window",+                "_hint": "The loop is frozen behind that modal. Read dialogs[] (altium_check_dialogs returns a shot per dialog), altium_dismiss_dialogs, then retry."}+    return {"success": False, "errorCode": "agent_timeout" if alive else "agent_wedged",+            "error": f"no response to {command} within {timeout}s",+            "agentAlive": alive, "lastHeartbeat": last_hb,+            "_hint": ("The loop is still heartbeating, so the handler is slow — raise timeout or check "+                      "for a dialog it opened." if alive else+                      "Heartbeat stopped: a modal or a script error froze the engine. altium_check_dialogs, "+                      "dismiss, then altium_agent_start.")}
src/handlers/altium_detect.pyadded+251
@@ -0,0 +1,251 @@+"""altium_detect.py — where is Altium Designer, is it running, which windows are its own.++Detection facts (measured on AD 26.9.1, 2026-08-29):+  * install root  C:\\Program Files\\Altium\\AD<nn>\\X2.EXE   (one dir per major version)+  * process name  X2.exe; the main window class is TDocumentForm and its title ends+    with "- Altium Designer (26.9.1) - <licence state>" ("No License" / "Trial" / …)+  * a second X2.EXE launched with a -R<Server>:<Command>(…) argument FORWARDS the+    command to the running instance and exits (single-instance); it only spawns a+    new instance when none is running.+  * modal script errors are owned dialogs titled exactly "X2" (VCL TMessageForm) —+    see dialogs.py.++Never read a user-profile path from ONE env var (ab strips the bridge's env; see+kicad-bridge's LOCALAPPDATA lesson) — derive from several and take the first hit.+"""+from __future__ import annotations++import glob+import os+import re+import subprocess+import sys+from pathlib import Path++IS_WINDOWS = sys.platform == "win32"+EXE_NAME = "X2.EXE"+PROC_NAME = "X2"+HOST_APP = "Altium Designer"++_TITLE_RE = re.compile(+    r"Altium Designer"+    r"(?: (?P<edition>Develop|Viewer|Standard|Professional|Pro|Enterprise))?"+    r"(?: \((?P<ver>[\d.]+)\))?"+    r"(?: - (?P<lic>.+))?$"+)  # issue #2/PR #64: ADDevelop titles as "…- Altium Designer Develop" (no version parens)+++def _program_files_roots() -> list[Path]:+    roots = []+    for var in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"):+        v = os.environ.get(var)+        if v:+            roots.append(Path(v))+    for fixed in (r"C:\Program Files", r"C:\Program Files (x86)"):+        roots.append(Path(fixed))+    seen, out = set(), []+    for r in roots:+        k = str(r).lower()+        if k not in seen:+            seen.add(k)+            out.append(r)+    return out+++def _user_profile_candidates() -> list[Path]:+    c = []+    for var in ("USERPROFILE",):+        v = os.environ.get(var)+        if v:+            c.append(Path(v))+    try:+        c.append(Path.home())+    except Exception:+        pass+    return c+++def appdata_dir() -> Path | None:+    """%APPDATA%\\Altium — where prefs/logs live. Tolerates a stripped env."""+    for var in ("APPDATA",):+        v = os.environ.get(var)+        if v and (Path(v) / "Altium").exists():+            return Path(v) / "Altium"+    for up in _user_profile_candidates():+        p = up / "AppData" / "Roaming" / "Altium"+        if p.exists():+            return p+    return None+++def _version_from_dir(d: Path) -> str:+    m = re.search(r"AD(\d+)$", d.name)+    return m.group(1) if m else d.name+++def _exe_file_version(exe: Path) -> str | None:+    if not IS_WINDOWS:+        return None+    try:+        import ctypes+        from ctypes import wintypes+        ver = ctypes.windll.version+        size = ver.GetFileVersionInfoSizeW(str(exe), None)+        if not size:+            return None+        buf = ctypes.create_string_buffer(size)+        ver.GetFileVersionInfoW(str(exe), 0, size, buf)+        lp = ctypes.c_void_p()+        ln = wintypes.UINT()+        ver.VerQueryValueW(buf, "\\", ctypes.byref(lp), ctypes.byref(ln))+        class VS_FIXEDFILEINFO(ctypes.Structure):+            _fields_ = [("dwSignature", wintypes.DWORD), ("dwStrucVersion", wintypes.DWORD),+                        ("dwFileVersionMS", wintypes.DWORD), ("dwFileVersionLS", wintypes.DWORD),+                        ("dwProductVersionMS", wintypes.DWORD), ("dwProductVersionLS", wintypes.DWORD)]+        ffi = ctypes.cast(lp, ctypes.POINTER(VS_FIXEDFILEINFO)).contents+        ms, ls = ffi.dwFileVersionMS, ffi.dwFileVersionLS+        return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}"+    except Exception:+        return None+++def detect_installs() -> list[dict]:+    """Every Altium install on the box, newest major first."""+    found = {}+    for root in _program_files_roots():+        for exe in glob.glob(str(root / "Altium" / "AD*" / EXE_NAME)):+            p = Path(exe)+            d = p.parent+            found[str(d).lower()] = {+                "installed": True,+                "base_dir": str(d),+                "exe": str(p),+                "system_dir": str(d / "System"),+                "major": _version_from_dir(d),+                "fileVersion": _exe_file_version(p),+            }+    installs = list(found.values())+    installs.sort(key=lambda i: int(i["major"]) if str(i["major"]).isdigit() else 0, reverse=True)+    return installs+++def running_pids() -> list[int]:+    """PIDs of every X2.exe — via a Toolhelp32 snapshot (ctypes, no subprocess).+    The earlier `tasklist` probe took 7+ s under load and timed out, which made+    every verb report 'Altium not running' while its window was plainly there."""+    if not IS_WINDOWS:+        return []+    import ctypes+    from ctypes import wintypes+    TH32CS_SNAPPROCESS = 0x00000002++    class PROCESSENTRY32W(ctypes.Structure):+        _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD),+                    ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)),+                    ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD),+                    ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", ctypes.c_long),+                    ("dwFlags", wintypes.DWORD), ("szExeFile", ctypes.c_wchar * 260)]++    k32 = ctypes.windll.kernel32+    snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)+    if snap == wintypes.HANDLE(-1).value or snap == -1:+        return []+    pids = []+    try:+        pe = PROCESSENTRY32W()+        pe.dwSize = ctypes.sizeof(PROCESSENTRY32W)+        ok = k32.Process32FirstW(snap, ctypes.byref(pe))+        while ok:+            if pe.szExeFile.lower() == EXE_NAME.lower():+                pids.append(int(pe.th32ProcessID))+            ok = k32.Process32NextW(snap, ctypes.byref(pe))+    finally:+        k32.CloseHandle(snap)+    return pids+++# ---- windows owned by Altium ------------------------------------------------++def _enum_windows_for_pids(pids: set[int]) -> list[dict]:+    """Top-level windows whose owning process is one of `pids` (ctypes, no deps)."""+    if not IS_WINDOWS or not pids:+        return []+    import ctypes+    from ctypes import wintypes+    user32 = ctypes.windll.user32+    res = []++    @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)+    def cb(hwnd, _):+        pid = wintypes.DWORD()+        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+        if pid.value in pids:+            length = user32.GetWindowTextLengthW(hwnd)+            buf = ctypes.create_unicode_buffer(length + 1)+            user32.GetWindowTextW(hwnd, buf, length + 1)+            cls = ctypes.create_unicode_buffer(256)+            user32.GetClassNameW(hwnd, cls, 256)+            rect = wintypes.RECT()+            user32.GetWindowRect(hwnd, ctypes.byref(rect))+            res.append({+                "hwnd": int(hwnd), "pid": int(pid.value),+                "title": buf.value, "className": cls.value,+                "visible": bool(user32.IsWindowVisible(hwnd)),+                "enabled": bool(user32.IsWindowEnabled(hwnd)),+                "rect": {"left": rect.left, "top": rect.top, "right": rect.right, "bottom": rect.bottom,+                         "width": rect.right - rect.left, "height": rect.bottom - rect.top},+            })+        return True++    user32.EnumWindows(cb, 0)+    return res+++def altium_windows() -> list[dict]:+    """Every visible top-level window owned by an X2.exe, classified."""+    wins = _enum_windows_for_pids(set(running_pids()))+    out = []+    for w in wins:+        if not w["visible"] or (w["rect"]["width"] < 20 and w["rect"]["height"] < 20):+            continue+        t = w["title"] or ""+        m = _TITLE_RE.search(t)+        kind = "other"+        # A match must carry a version, an edition word, or a licence tail — or BE the whole+        # title — so a bare mid-string "Altium Designer" inside a dialog cannot pose as main.+        if m and (m.group("ver") or m.group("edition") or m.group("lic") or m.group(0) == t):+            kind = "main"+            w["adVersion"] = m.group("ver")+            w["edition"] = m.group("edition")+            w["licenceState"] = (m.group("lic") or "").strip() or None+            w["document"] = t.split(" - Altium Designer")[0].strip() if " - Altium Designer" in t else None+        elif t == "X2":+            kind = "script_error_modal"+        elif w["className"] in ("TMessageForm", "#32770"):+            kind = "dialog"+        w["kind"] = kind+        out.append(w)+    # main window first, then dialogs, then the rest+    order = {"main": 0, "script_error_modal": 1, "dialog": 2, "other": 3}+    out.sort(key=lambda w: order.get(w["kind"], 9))+    return out+++def main_window() -> dict | None:+    for w in altium_windows():+        if w["kind"] == "main":+            return w+    return None+++def licence_state() -> dict:+    """Parsed from the main window title (the cheapest truthful source)."""+    mw = main_window()+    if not mw:+        return {"running": False, "licensed": None, "state": None}+    st = mw.get("licenceState")+    licensed = None+    if st:+        licensed = st.lower() not in ("no license", "no licence", "viewer")+    return {"running": True, "licensed": licensed, "state": st, "adVersion": mw.get("adVersion"),+            "hwnd": mw["hwnd"], "pid": mw["pid"]}
src/handlers/dialogs.pyadded+167
@@ -0,0 +1,167 @@+"""dialogs.py — find, classify, read and dismiss Altium's dialogs BY PID, in the background.++Why by PID: a title match is a prefilter at best (a browser tab titled "Altium …"+would match). Every window here is confirmed to belong to an X2.exe process.++Classes seen on AD 26.9.1 (2026-08-29):+  TMessageForm  "X2"            script runtime error: "Unknown identifier … Continue execution?"  [OK|Cancel]+  TMessageForm  "Error"         script compile error / "Another script executing now"            [OK]+  TMessageForm  "Information"   "There is another instance of running, but it is busy"           [OK]  (owned by a HELPER X2, not the main one)+  TMessageForm  "Confirm"       save / overwrite prompts                                          [Yes|No|Cancel]+  #32770        Win32 common dialogs (file open/save)++Dismissal is a posted button click (BM_CLICK to the button hwnd found by text) —+no focus steal, no cursor. UIA via ab is the fallback (server.py has the callback).+"""+from __future__ import annotations++import ctypes+import sys+import time+from ctypes import wintypes++from handlers import altium_detect++IS_WINDOWS = sys.platform == "win32"+DIALOG_CLASSES = ("TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters")++# title/body substring -> (category, safe default action, meaning)+CATALOG = [+    ("another script executing", "script_busy", "OK",+     "A -R script request arrived while the agent loop (or another script) was resident. Dismiss; route work through the agent."),+    ("continue execution?", "script_runtime_error", "Cancel",+     "A script hit a runtime error. Cancel stops it cleanly; OK continues past the error."),+    ("undeclared identifier", "script_compile_error", "OK",+     "The script failed to compile; the engine is not halted after OK."),+    ("unknown identifier", "script_compile_error", "OK", "Compile error in RunScriptText text (statements only, no Var)."),+    ("select item to run", "run_script_picker", "Cancel",+     "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),+    ("another instance of", "second_instance_busy", "OK",+     "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),+    ("do you want to save", "save_prompt", None, "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),+    ("save changes", "save_prompt", None, "Unsaved changes. Decide explicitly."),+    ("license", "licence", None, "Licence prompt — read it; usually informational."),+    ("trial", "licence", None, "Trial notice."),+]+++def _text_of(hwnd: int) -> str:+    """All Static/label text under a dialog (WM_GETTEXT on children), joined."""+    if not IS_WINDOWS:+        return ""+    user32 = ctypes.windll.user32+    parts = []++    @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)+    def cb(h, _):+        n = user32.GetWindowTextLengthW(h)+        if n:+            buf = ctypes.create_unicode_buffer(n + 1)+            user32.GetWindowTextW(h, buf, n + 1)+            cls = ctypes.create_unicode_buffer(64)+            user32.GetClassNameW(h, cls, 64)+            parts.append((cls.value, buf.value))+        return True++    user32.EnumChildWindows(hwnd, cb, 0)+    texts = [t for c, t in parts if c.lower() not in ("button", "tbutton", "txpbutton")]+    return " ".join(t.strip() for t in texts if t.strip())+++def _buttons_of(hwnd: int) -> list[dict]:+    if not IS_WINDOWS:+        return []+    user32 = ctypes.windll.user32+    out = []++    @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)+    def cb(h, _):+        cls = ctypes.create_unicode_buffer(64)+        user32.GetClassNameW(h, cls, 64)+        # Altium's OK is a WinXPButtons.TXPBitBtn (measured) — match btn|button+        if "button" in cls.value.lower() or "btn" in cls.value.lower():+            n = user32.GetWindowTextLengthW(h)+            buf = ctypes.create_unicode_buffer(n + 1)+            user32.GetWindowTextW(h, buf, n + 1)+            out.append({"hwnd": int(h), "text": buf.value.replace("&", ""), "className": cls.value,+                        "enabled": bool(user32.IsWindowEnabled(h))})+        return True++    user32.EnumChildWindows(hwnd, cb, 0)+    return out+++def classify(title: str, body: str) -> dict:+    hay = f"{title} {body}".lower()+    for needle, cat, action, meaning in CATALOG:+        if needle in hay:+            return {"category": cat, "safeAction": action, "meaning": meaning}+    return {"category": "unknown", "safeAction": None,+            "meaning": "Not in the catalog — read the text and decide; never blind-dismiss."}+++def scan(main_pid_only: bool = False) -> list[dict]:+    """Every dialog window owned by any X2.exe (helpers included, flagged)."""+    wins = altium_detect.altium_windows()+    main = next((w for w in wins if w["kind"] == "main"), None)+    main_pid = main["pid"] if main else None+    out = []+    main_disabled = bool(main) and not main.get("enabled", True)+    for w in wins:+        if w["kind"] == "main":+            continue+        # Any enabled top-level window while the MAIN window is disabled IS the modal,+        # whatever its class (measured: TForm_RunScriptParameters "Select Item To Run").+        modal_by_state = main_disabled and w.get("enabled") and w["pid"] == main_pid and w["className"] not in ("TClientBarControl", "TThemedNotificationPopup", "TAnimatedNotificationControl")+        if w["className"] not in DIALOG_CLASSES and w["kind"] not in ("dialog", "script_error_modal") and not modal_by_state:+            continue+        body = _text_of(w["hwnd"])+        d = {+            "hwnd": w["hwnd"], "pid": w["pid"], "title": w["title"], "className": w["className"],+            "body": body, "buttons": _buttons_of(w["hwnd"]),+            "ownedByMainInstance": (main_pid is not None and w["pid"] == main_pid),+            "rect": w["rect"],+        }+        d.update(classify(w["title"], body))+        if main_pid_only and not d["ownedByMainInstance"]:+            continue+        out.append(d)+    return out+++def press(hwnd: int, button_text: str) -> dict:+    """Click a dialog button by its text — background, two ways:+    1. BM_CLICK posted to the button hwnd (TXPBitBtn / Button classes);+    2. ab's UIA InvokePattern (desktop_ui_click) — Altium's TMessageForm exposes+       'OK'/'Cancel' as invokable Buttons even when Win32 posting is ignored."""+    if not IS_WINDOWS:+        return {"success": False, "error": "windows only"}+    user32 = ctypes.windll.user32+    for b in _buttons_of(hwnd):+        if b["text"].strip().lower() == button_text.strip().lower():+            BM_CLICK = 0x00F5+            user32.PostMessageW(b["hwnd"], BM_CLICK, 0, 0)+            time.sleep(0.4)+            if not user32.IsWindow(hwnd):+                return {"success": True, "clicked": b["text"], "buttonHwnd": b["hwnd"], "via": "BM_CLICK"}+            break+    try:+        import caller_identity+        r = caller_identity.ad_callback("desktop_ui_click", {"hwnd": hwnd, "name": button_text, "role": "button"})+        time.sleep(0.4)+        if not user32.IsWindow(hwnd):+            return {"success": True, "clicked": button_text, "via": "uia", "ab": r}+        return {"success": False, "error": f"dialog {hwnd} still open after UIA click", "ab": r,+                "buttons": [b["text"] for b in _buttons_of(hwnd)]}+    except Exception as e:+        return {"success": False, "error": f"no button '{button_text}' on hwnd {hwnd} ({e})",+                "buttons": [b["text"] for b in _buttons_of(hwnd)]}+++def close(hwnd: int) -> dict:+    """WM_CLOSE = the dialog's Cancel/X path. Background."""+    if not IS_WINDOWS:+        return {"success": False, "error": "windows only"}+    WM_CLOSE = 0x0010+    ctypes.windll.user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)+    return {"success": True, "posted": "WM_CLOSE", "hwnd": hwnd}
src/handlers/png_stdlib.pyadded+61
@@ -0,0 +1,61 @@+"""png_stdlib.py — write a PNG from raw GDI BGRA pixels with ZERO dependencies.++Why: ab's managed Python runtime (adom-runtimes/python-3.12.13, ab 2.0.44+) is+stdlib-only — no PIL. On a fresh user box every screenshot fell back to .bmp,+which kicad_state refuses ("capture failed (non-png fallback?)"), so a brand-new+install had NO working captures (found on ConfRoomROG, 2026-08-15). The bridge's+own dependency policy is stdlib-only; the screenshot path now honors it.++Input is what GetDIBits hands us: 32bpp BGRA, bottom-up. Output is an 8-bit RGB+PNG, optionally nearest-neighbor downscaled to max_dim. Channel swizzling uses+sliced bytearray assignment (C speed); only downscale column-sampling loops in+Python, over at most max_dim ints per row.+"""+import struct+import zlib+from array import array+++def _chunk(tag: bytes, data: bytes) -> bytes:+    return (struct.pack(">I", len(data)) + tag + data+            + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))+++def write_png_from_bgra(path: str, width: int, height: int, bgra_bottomup: bytes,+                        max_dim: int = 1568) -> tuple:+    """Write an RGB PNG; returns (out_width, out_height)."""+    if max(width, height) > max_dim:+        ratio = max_dim / max(width, height)+        nw = max(1, int(width * ratio))+        nh = max(1, int(height * ratio))+        xs = [int(x * width / nw) for x in range(nw)]+        ys = [int(y * height / nh) for y in range(nh)]+    else:+        nw, nh = width, height+        xs = None+        ys = list(range(height))++    stride = width * 4+    mv = memoryview(bgra_bottomup)+    out = bytearray()+    rgb_row = bytearray(nw * 3)+    for y in ys:+        src_y = height - 1 - y  # GDI rows are bottom-up+        row = mv[src_y * stride: src_y * stride + stride]+        if xs is not None:+            ints = row.cast("I")+            row = array("I", [ints[x] for x in xs]).tobytes()+        rgb_row[0::3] = row[2::4]  # R+        rgb_row[1::3] = row[1::4]  # G+        rgb_row[2::3] = row[0::4]  # B+        out.append(0)  # filter: none+        out += rgb_row++    ihdr = struct.pack(">IIBBBBB", nw, nh, 8, 2, 0, 0, 0)+    png = (b"\x89PNG\r\n\x1a\n"+           + _chunk(b"IHDR", ihdr)+           + _chunk(b"IDAT", zlib.compress(bytes(out), 6))+           + _chunk(b"IEND", b""))+    with open(path, "wb") as f:+        f.write(png)+    return nw, nh
src/handlers/progress.pyadded+416
@@ -0,0 +1,416 @@+"""progress.py — the MEASURED-progress contract (wiki issue #32).++John's rule, verbatim from the issue: every slow verb reports measured progress,+never "can take a couple of minutes". A shrug is not an estimate: it cannot be+drawn as a bar, it never narrows, and it hides the difference between slow and+wedged.++So this module owns three things:++1. A rolling, PER-MACHINE history of how long each phase actually took, split by+   cold vs warm start, persisted next to the bridge's other state+   (%USERPROFILE%/.adom/altium-bridge-timings.json). Estimates come from this+   machine's own p50 - never from a hardcoded literal, because the literal is+   what made the old message useless.++2. Honest CONFIDENCE. A fresh box has no samples, and labelling a shipped+   default as "measured" would be a lie:+       0 samples  -> confidence "unknown"  (seeded from the shipped default)+       1-2        -> confidence "typical"+       3 or more  -> confidence "measured" (p50 of this machine's own history)+   This rule is the caveat I raised on #32 and it lives here, in one place.++3. The block itself, in the shape ab consumes verbatim - plus the two amendments+   I proposed on the issue: phase ids are NAMESPACED ("kicad.cold_start") so a UI+   can group histories across bridges without collisions, and `blockedBy` names a+   human-controlled wait (a modal, an unsaved-changes prompt, a UAC dialog) so a+   UI can say "waiting for you" instead of animating a bar through something that+   cannot progress.+"""+import json+import os+import threading+import time+from pathlib import Path++PREFIX = "altium"+_KEEP = 10         # rolling window per key (fusion parity, John 2026-08-21)+_lock = threading.Lock()++# Shipped defaults, used ONLY to seed a machine with no history of its own.+# Every one of these is a measured figure from this fleet, not a guess.+_DEFAULT_SECONDS = {+    "cold_start": 60.0,       # X2.exe from nothing (AD26, measured once on AdomLapper — refine)+    "warm_start": 1.0,        # Altium already running+    "agent_start": 8.0,       # bootstrapping the resident DelphiScript agent+    "show_symbol": 12.0,+    "show_footprint": 20.0,   # the Footprint Editor loads its libraries as it opens+    "show_3d_chip": 25.0,+    "show_library": 12.0,+    "show_schematic": 15.0,+    "show_2d_board": 15.0,+    "show_3d_board": 30.0,+    "show_project": 10.0,+    "install_plugin": 6.0,+    "run_outjob": 60.0,+    "export": 8.0,+    "demo": 210.0,+}+++def _path() -> Path:+    base = os.environ.get("USERPROFILE") or str(Path.home())+    d = Path(base) / ".adom"+    try:+        d.mkdir(parents=True, exist_ok=True)+    except Exception:+        pass+    return d / "altium-bridge-timings.json"+++def _load() -> dict:+    try:+        return json.loads(_path().read_text(encoding="utf-8"))+    except Exception:+        return {}+++def _save(data: dict) -> None:+    try:+        _path().write_text(json.dumps(data, indent=2), encoding="utf-8")+    except Exception:+        pass+++def _key(phase: str, start_kind: str) -> str:+    return f"{phase}:{start_kind or 'warm'}"+++def _pct(samples: list, q: float) -> float:+    if not samples:+        return 0.0+    s = sorted(samples)+    i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))+    return round(s[i], 2)+++def record(phase: str, seconds: float, start_kind: str = "warm") -> None:+    """Add one real measurement. This is what makes confidence 'measured'."""+    if seconds is None or seconds < 0:+        return+    with _lock:+        data = _load()+        k = _key(phase, start_kind)+        samples = list(data.get(k) or [])+        samples.append(round(float(seconds), 2))+        data[k] = samples[-_KEEP:]+        _save(data)+++def estimate(phase: str, start_kind: str = "warm") -> tuple:+    """(estimatedSec, confidence) for this phase ON THIS MACHINE."""+    data = _load()+    samples = data.get(_key(phase, start_kind)) or []+    if len(samples) >= 3:+        return _pct(samples, 0.5), "measured"+    default = _DEFAULT_SECONDS.get(phase)+    if samples:+        # blend the shipped default with what little we have measured+        base = default if default is not None else samples[-1]+        return round((sum(samples) / len(samples) + base) / 2, 2), "typical"+    if default is not None:+        # A phase-SPECIFIC seeded literal is "typical" per the ladder (John's+        # directive: measured > typical [1-2 samples, or a seeded literal] >+        # unknown). It was mislabelled "unknown" here.+        return float(default), "typical"+    # No samples AND no seed: genuinely unknown. Return 0 so block() renders an+    # indeterminate marquee. The old fallback borrowed the generic warm default+    # (1.0s) for phases it had never heard of, which made a 5s run look deeply+    # overdue at 92% - a guessed number wearing a measurement's clothes, the+    # exact failure mode this system exists to kill.+    return 0.0, "unknown"+++def history() -> dict:+    """Everything we know, for describe/status: p50 + p90 + sample counts."""+    out = {}+    for k, samples in (_load() or {}).items():+        if not samples:+            continue+        phase, _, kind = k.partition(":")+        out.setdefault(phase, {})[kind] = {+            "samples": len(samples), "p50": _pct(samples, 0.5), "p90": _pct(samples, 0.9),+        }+    return out+++def start_kind_for_altium() -> str:+    """cold = X2.exe not running yet; warm = Altium Designer is already up."""+    try:+        from handlers.altium_detect import running_pids+        return "warm" if running_pids() else "cold"+    except Exception:+        return "cold"++def block(phase: str, step_label: str, started_at: float, percent=None,+          start_kind: str = "warm", blocked_by: str = None, done: bool = False) -> dict:+    """Build the progress block. `percent` may be None while genuinely unknown -+    the contract allows it, as long as stepLabel keeps moving."""+    elapsed = max(0.0, time.time() - started_at)+    est, confidence = estimate(phase, start_kind)+    # Adopted verbatim-in-spirit from fusion's timings.py (John's directive,+    # 2026-08-21: copy the method, do not redesign). Their comments record two+    # live failures these formulas exist to prevent, and both apply here:+    #   * est = elapsed * 1.25 alone pins percent at exactly 80 forever - a+    #     spinner wearing a progress bar's clothes;+    #   * pre-overdue and overdue formulas that do not MEET at elapsed == est0+    #     walk percent BACKWARDS at the crossover (91 then 84, live).+    est0 = est+    overdue = bool(est0 and elapsed > est0)+    if overdue:+        est = round(elapsed * 1.25, 1)     # revised, so eta never lies at 0+        if confidence == "measured":+            confidence = "typical"          # past what this box ever measured+        if elapsed > 2 * est0:+            confidence = "unknown"          # stop claiming to know at all+    if done:+        percent = 100+        est = round(elapsed, 2)+    elif percent is None:+        if not est0:+            percent = None                  # unknown -> indeterminate, never 0%+        elif not overdue:+            percent = int(80 * elapsed / est0)          # 0 -> 80 across the estimate+        else:+            over = elapsed - est0+            percent = int(80 + 15 * (over / (over + est0)))   # 80 -> 95, asymptotic+    eta = None if percent is None else max(0.0, round(est - elapsed, 1))+    out = {+        "phase": f"{PREFIX}.{phase}",+        "stepLabel": step_label,+        "percent": percent,+        "elapsedSec": round(elapsed, 1),+        "estimatedSec": round(est, 1),+        "etaSec": eta,+        "confidence": confidence,+        "startKind": start_kind,+    }+    if not done and est0:+        out["overdue"] = overdue+        if overdue and confidence == "unknown":+            out["stepLabel"] = f"{step_label} (taking far longer than this machine ever has)"+    if blocked_by:+        out["blockedBy"] = blocked_by+        out["stepLabel"] = f"waiting for you: {blocked_by}"+    if done:+        out["measured"] = {"phase": f"{PREFIX}.{phase}", "seconds": round(elapsed, 2),+                           "startKind": start_kind}+    return out+++def finish(phase: str, started_at: float, start_kind: str = "warm", ok: bool = True,+           step_label: str = "done") -> dict:+    """Terminal frame: carries the ACTUALS and feeds the history a new sample."""+    elapsed = max(0.0, time.time() - started_at)+    if ok:+        record(phase, elapsed, start_kind)+    b = block(phase, step_label, started_at, percent=100, start_kind=start_kind, done=True)+    if not ok:+        b["percent"] = None+        b["stepLabel"] = step_label+        b.pop("measured", None)+    return b+++# ── LIVE, IN-FLIGHT progress (0.9.195) ──────────────────────────────────────+# John's contract to the team (2026-08-19): "ongoing streaming feedback so+# progress bars could be shown in the front-end" and "show live streaming of the+# progress in your text status line". Until now `block()` was only ever built+# ONCE, on the terminal frame - which is a receipt, not a progress bar. A caller+# watching a 24s cold footprint open saw nothing at all until it finished.+#+# So a running verb publishes its current frame here, and anything that can+# poll a verb (ab's `watch` wrapper, the dashboard's SSE hub, a status line)+# can read it while the work is still happening. In-memory on purpose: this is+# liveness, and a stale frame surviving a bridge restart would be a lie.+_live: dict = {}+_live_seq = 0+++def _caller() -> str:+    """Who is asking. Used to keep concurrent callers out of each other's steps."""+    try:+        import caller_identity+        return str(caller_identity.thread_name() or "unknown")+    except Exception:+        return "unknown"+++def begin(phase: str, start_kind: str = "warm", step_label: str = "starting",+          caller: str = None) -> None:+    """Mark a phase as in flight, SCOPED TO THE CALLER.++    Wiki issue #46 (John): a second caller's steps appeared inside a first+    caller's response - "locating 'OPA2134'" in a response about nRF54L15,+    because someone used a wiki Quick Action on one KiCad while a matrix run+    drove the same box. Two independent web callers, one global step stream.++    Two distinct bugs, both from keying this registry on the phase alone:+      * a reader saw steps that were never theirs, which reads as a wrong-part+        failure to anyone who does not go and check the pixels;+      * worse and quieter, the second caller OVERWROTE the first's entry, so a+        caller could lose its own progress to someone else's call.++    Keying on (caller, phase) fixes both. It degrades honestly rather than+    perfectly: two browser tabs from one origin still share an identity until ab+    ships a per-page discriminator, but every other pair of callers separates+    correctly today, and every frame now says who produced it."""+    global _live_seq+    who = caller or _caller()+    with _lock:+        _live_seq += 1+        _live[(who, phase)] = {"startedAt": time.time(), "startKind": start_kind,+                               "stepLabel": step_label, "percent": None,+                               "blockedBy": None, "seq": _live_seq, "caller": who}+++def step(phase: str, step_label: str, percent=None, blocked_by: str = None) -> None:+    """Advance the visible sub-step of an in-flight phase.++    This is what turns one opaque 24-second bar into "launching KiCad" ->+    "indexing symbol libraries" -> "navigating to IN-S42ATR", which is the+    sub-phase breakdown Colby asked for on wiki issue #35."""+    who = _caller()+    with _lock:+        cur = _live.get((who, phase))+        if cur is None:+            # A handler thread may not carry the caller context; fall back to the+            # only in-flight entry for this phase, but never guess when several+            # callers are running the same phase - a wrong attribution is the+            # bug this issue is about.+            matches = [k for k in _live if k[1] == phase]+            if len(matches) != 1:+                return+            cur = _live[matches[0]]+        cur["stepLabel"] = step_label+        cur["blockedBy"] = blocked_by+        if percent is not None:+            # never let a bar go backwards: a bar that retreats reads as broken+            cur["percent"] = max(percent, cur.get("percent") or 0)+++def end(phase: str, caller: str = None) -> None:+    who = caller or _caller()+    with _lock:+        if _live.pop((who, phase), None) is None:+            for k in [k for k in _live if k[1] == phase and k[0] == who]:+                _live.pop(k, None)+++def live(mine_only: bool = True, asker: str = None) -> list:+    """Every phase currently in flight, as full progress blocks.++    By default a caller sees ONLY ITS OWN phases (#46). Pass mine_only=False for+    an operator view of everything in flight; each frame carries `caller`.++    Percent/ETA are recomputed at READ time from this machine's own history, so+    a poller gets a bar that keeps moving even if the verb has not reported a+    new sub-step since its last one."""+    asker = asker or _caller()+    out = []+    with _lock:+        snapshot = list(_live.items())+    for (who, phase), cur in snapshot:+        if mine_only and who != asker:+            continue+        try:+            # A step's explicit percent is a FLOOR, not a freeze. The first live+            # test pinned the bar at 10% for 47s while the ETA counted to zero,+            # because an explicit percent suppressed the elapsed-derived one -+            # a stuck bar next to a moving ETA reads as a hung job. Take+            # whichever is further along, so the bar always advances and never+            # goes backwards.+            # ONE derivation, not two. This used to compute its own percent+            # (min(95, elapsed/est*95)) alongside block()'s - exactly fusion's+            # crossover trap, where two formulas for one bar disagree and+            # percent walks backwards. Now block() owns the math and the+            # explicit step percent is applied only as a monotonic FLOOR.+            b = block(phase, cur["stepLabel"], cur["startedAt"],+                      percent=None, start_kind=cur.get("startKind", "warm"),+                      blocked_by=cur.get("blockedBy"))+            _floor = cur.get("percent")+            if _floor is not None and b.get("percent") is not None:+                b["percent"] = max(int(_floor), b["percent"])+            elif _floor is not None:+                b["percent"] = int(_floor)+            b["seq"] = cur.get("seq")+            b["caller"] = who+            out.append(b)+        except Exception:+            pass+    return out+++# ── Per-VERB run times (fusion parity, John's directive 2026-08-21) ─────────+# Lifted from fusion's timings.py. Two stores because they answer different+# questions: the rolling window above drives the LIVE estimate (recent, tracks+# a machine that got faster or slower); the append-only log below is the+# HISTORICAL record, every run WITH its outcome - a verb that fails fast is a+# different bug from a slow one, and durations alone cannot tell them apart.+# A rolling window can only answer what it still remembers; a log can be+# re-analysed for questions nobody thought to ask up front.++_RUNLOG_MAX_BYTES = 4 * 1024 * 1024+++def _runlog_path() -> Path:+    return _path().parent / "altium-verb-times.jsonl"+++def log_run(verb: str, seconds: float, ok: bool = True, note: str = "") -> None:+    """Append one verb run to the historical log AND the rolling window.+    Called at the dispatch chokepoint for EVERY verb - one wrapper, not+    per-verb calls someone forgets. Never raises."""+    try:+        seconds = float(seconds)+        if seconds < 0 or seconds > 7200:+            return                      # a clock jump is not a sample+        record(f"verb:{verb}", seconds)+        p = _runlog_path()+        try:+            if p.exists() and p.stat().st_size > _RUNLOG_MAX_BYTES:+                p.replace(str(p) + ".1")    # one generation back, never unbounded+        except Exception:+            pass+        with open(p, "a", encoding="utf-8") as f:+            f.write(json.dumps({"at": int(time.time()), "verb": verb,+                                "seconds": round(seconds, 2),+                                "ok": bool(ok), "note": (note or "")[:120]}) + "\n")+    except Exception:+        pass+++def verb_report(limit: int = 40) -> dict:+    """Aggregate the historical log: per verb count / p50 / p90 / max / failures,+    sorted by p90 descending - p90, not p50, is the column for hunting slow+    paths (p50 15s beside p90 400s is two code paths wearing one name)."""+    runs = {}+    try:+        with open(_runlog_path(), "r", encoding="utf-8") as f:+            for line in f:+                try:+                    r = json.loads(line)+                except Exception:+                    continue+                d = runs.setdefault(r.get("verb", "?"), {"secs": [], "fail": 0})+                d["secs"].append(float(r.get("seconds") or 0))+                if not r.get("ok"):+                    d["fail"] += 1+    except Exception:+        return {}+    out = {}+    for verb, d in runs.items():+        s = d["secs"]+        out[verb] = {"count": len(s), "p50": _pct(s, 0.5), "p90": _pct(s, 0.9),+                     "max": max(s) if s else None, "failures": d["fail"]}+    return dict(sorted(out.items(), key=lambda kv: -(kv[1]["p90"] or 0))[:limit])
src/handlers/rcs_catalog.pyadded+88
@@ -0,0 +1,88 @@+"""rcs_catalog.py — parse Altium's *.rcs process-launcher tables into a command catalog.++Every menu/toolbar action in Altium is a "process launcher" line like++  PL PLPCB:Undo   Command='PCB:Undo'  Caption='&Undo'  Image='UNDO.PNG'  Shortcut1='Ctrl+Z'+     Shortcut2='Alt+Backspace'  Params=''  Description='Undo'  End++The Command is what RunProcess / RunProcessWithParameters takes; Params is the+Name=Value|Name=Value string. AD 26.9.1 ships 47 .rcs files -> 3,205 launchers ->+1,109 distinct commands (PCB 211, Sch 190, WorkspaceManager 87, Client 50 …).+"""+from __future__ import annotations++import json+import re+import time+from pathlib import Path++_LINE = re.compile(r"^\s*PL\w*\s+(?P<id>\S+)\s+(?P<rest>.*)End\s*$")+_ATTR = re.compile(r"(\w+)='((?:[^']|'')*)'")++_cache: dict = {}+++def parse_dir(system_dir: str) -> dict:+    key = system_dir.lower()+    c = _cache.get(key)+    if c and time.time() - c["at"] < 3600:+        return c["data"]+    commands: dict[str, dict] = {}+    files = 0+    launchers = 0+    for rcs in sorted(Path(system_dir).glob("*.rcs")) + sorted(Path(system_dir).glob("*.RCS")):+        files += 1+        try:+            text = rcs.read_text(encoding="latin-1", errors="replace")+        except Exception:+            continue+        for line in text.splitlines():+            if line.lstrip().startswith("//"):+                continue+            m = _LINE.match(line)+            if not m:+                continue+            launchers += 1+            attrs = {k: v.replace("''", "'") for k, v in _ATTR.findall(m.group("rest"))}+            cmd = attrs.get("Command")+            if not cmd:+                continue+            e = commands.setdefault(cmd, {"command": cmd, "server": cmd.split(":", 1)[0],+                                          "captions": [], "shortcuts": [], "params": [], "descriptions": [],+                                          "sources": []})+            for k, lst in (("Caption", "captions"), ("Description", "descriptions"), ("Params", "params")):+                v = attrs.get(k, "")+                v = v.replace("&", "") if k == "Caption" else v+                if v and v not in e[lst]:+                    e[lst].append(v)+            for k in ("Shortcut1", "Shortcut2"):+                v = attrs.get(k, "")+                if v and v not in e["shortcuts"]:+                    e["shortcuts"].append(v)+            if rcs.name not in e["sources"]:+                e["sources"].append(rcs.name)+    servers: dict[str, int] = {}+    for c in commands.values():+        servers[c["server"]] = servers.get(c["server"], 0) + 1+    data = {"systemDir": system_dir, "files": files, "launchers": launchers,+            "distinctCommands": len(commands), "servers": dict(sorted(servers.items(), key=lambda kv: -kv[1])),+            "commands": commands}+    _cache[key] = {"at": time.time(), "data": data}+    return data+++def search(system_dir: str, query: str = "", server: str | None = None, limit: int = 50) -> dict:+    data = parse_dir(system_dir)+    q = (query or "").lower()+    hits = []+    for cmd, e in data["commands"].items():+        if server and e["server"].lower() != server.lower():+            continue+        hay = " ".join([cmd] + e["captions"] + e["descriptions"] + e["params"]).lower()+        if q and q not in hay:+            continue+        hits.append({k: e[k] for k in ("command", "server", "captions", "shortcuts", "params", "descriptions")})+        if len(hits) >= limit:+            break+    return {"query": query, "server": server, "count": len(hits), "total": data["distinctCommands"],+            "servers": data["servers"], "hits": hits}
src/handlers/taskbar.pyadded+165
@@ -0,0 +1,165 @@+"""taskbar.py — "Adom is driving Altium" on the Windows taskbar, the same way the+KiCad and Fusion bridges do it.++Two signals, both painted by ab-core's generic `desktop_taskbar` verb (never by an+in-process ITaskbarList3 call — that returns S_OK cross-process and the shell+silently ignores it; pup, fusion and kicad each paid for that lesson):++  * OVERLAY BADGE — the Adom mark (resources/adom-overlay-32.png, anchored top-right+    of a transparent 32 px canvas) on Altium's taskbar button while the bridge is+    driving it; cleared when the bridge goes idle.+        set:   desktop_taskbar {hwnd, overlay:{icon:<b64 png>, tooltip}}+        clear: desktop_taskbar {hwnd, overlay:{badge:"none"}}+  * PROGRESS BAR — for slow verbs, paced by this machine's MEASURED estimate+    (handlers/progress.py); marquee when there is no history yet; red flash on+    failure; cleared when the verb returns. Also cancels Windows' own orange+    attention flash on the button (John: "can u not orange flash the taskbar").++NO AUMID / window-identity stamping. KiCad learned the hard way that a per-window+AppUserModelID poisons the shell's icon cache; Altium's own taskbar identity is+left exactly as Altium set it. Badge + progress ride ITaskbarList3 by hwnd only.+"""+from __future__ import annotations++import base64+import os+import threading+import time++_LOCK = threading.Lock()+_B64: str | None = None+_BADGED: set[int] = set()+_LAST_ERROR = [None]+_PROGRESS_STOP: dict[str, threading.Event] = {}+++def last_error():+    return _LAST_ERROR[0]+++def _badge_b64() -> str:+    global _B64+    if _B64 is None:+        p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "resources", "adom-overlay-32.png")+        try:+            _B64 = base64.b64encode(open(p, "rb").read()).decode()+        except Exception:+            _B64 = ""+    return _B64+++def _taskbar(args: dict) -> bool:+    try:+        import caller_identity+        from handlers import ad_client+        if not ad_client.available():+            _LAST_ERROR[0] = "ab direct API unavailable"+            return False+        r = caller_identity.ad_callback("desktop_taskbar", args, timeout=15)+        raw = str(r)+        if r and ("Taskbar updated" in raw or (isinstance(r, dict) and r.get("success"))):+            _LAST_ERROR[0] = None+            return True+        _LAST_ERROR[0] = f"desktop_taskbar reply: {raw[:160]}"+        return False+    except Exception as e:+        _LAST_ERROR[0] = f"exception: {str(e)[:160]}"+        return False+++def _main_hwnd() -> int | None:+    try:+        from handlers import altium_detect+        mw = altium_detect.main_window()+        return mw["hwnd"] if mw else None+    except Exception:+        return None+++# ---- badge -----------------------------------------------------------------------++def set_driving_badge(hwnd: int | None = None) -> bool:+    hwnd = hwnd or _main_hwnd()+    if not hwnd:+        return False+    if hwnd in _BADGED:+        return True+    icon = _badge_b64()+    if not icon:+        _LAST_ERROR[0] = "badge asset missing (resources/adom-overlay-32.png)"+        return False+    with _LOCK:+        ok = _taskbar({"hwnd": int(hwnd), "overlay": {"icon": icon, "tooltip": "Adom is driving Altium Designer"}})+        if ok:+            _BADGED.add(int(hwnd))+        return ok+++def clear_driving_badge(hwnd: int | None = None) -> bool:+    targets = [int(hwnd)] if hwnd else list(_BADGED)+    ok = True+    with _LOCK:+        for h in targets:+            if _taskbar({"hwnd": h, "overlay": {"badge": "none"}}):+                _BADGED.discard(h)+            else:+                ok = False+    return ok+++def badge_active() -> bool:+    return bool(_BADGED)+++# ---- progress ---------------------------------------------------------------------++def _taskbar_progress(state: str, value=None) -> bool:+    hwnd = _main_hwnd()+    if not hwnd:+        return False+    prog = {"state": state}+    if value is not None:+        prog["value"] = max(0, min(100, int(value)))+    return _taskbar({"hwnd": int(hwnd), "progress": prog})+++def start_activity_progress(verb: str, estimate_s: float | None, confidence: str = "typical"):+    """Begin a progress bar for `verb`, paced by its measured estimate. Returns the stopper."""+    stop = threading.Event()+    _PROGRESS_STOP[verb] = stop++    def _tick():+        started = time.time()+        if not estimate_s or confidence == "unknown":+            _taskbar_progress("indeterminate")+            while not stop.wait(1.0):+                pass+            return+        fails = 0+        while not stop.wait(0.7):+            pct = min(95, int(95 * (time.time() - started) / estimate_s)) if estimate_s > 0 else 0+            if _taskbar_progress("normal", pct):+                fails = 0+            else:+                fails += 1          # window may not exist yet during a cold launch — keep trying+                if fails > 120:+                    return++    threading.Thread(target=_tick, daemon=True).start()+    return stop+++def end_activity_progress(verb: str, failed: bool = False) -> None:+    stop = _PROGRESS_STOP.pop(verb, None)+    if stop:+        stop.set()+    try:+        if failed:+            _taskbar_progress("error", 100)+            time.sleep(1.2)+        _taskbar_progress("none")+        hwnd = _main_hwnd()+        if hwnd:+            _taskbar({"hwnd": int(hwnd), "flash": "stop"})+    except Exception:+        pass
src/handlers/uia.pyadded+144
@@ -0,0 +1,144 @@+"""uia.py — BACKGROUND UI Automation for Altium Designer, via .NET UIAutomation (PowerShell).++Why: opening the Footprint Editor / 3D viewer used to fall back to clicking a+toolbar icon (moves the user's MOUSE) or an Alt+3 keystroke (needs the window+FOREGROUNDED). Both hijack the desktop. UIA's Invoke pattern triggers a control+PROGRAMMATICALLY — no foreground, no focus steal, no cursor movement. Altium (VCL/DevExpress) is+wxWidgets but exposes an invokable UIA tree, so its toolbar buttons / menu items+are reachable by accessible name.++Self-contained: shells out to `powershell` (present on every Windows box) which+loads System.Windows.Automation. NOT Bridge's gated `run_script` — this is the bridge's+OWN subprocess (same as how it runs kicad-cli), so no shell-approval is involved.+UIA is a mature, stable API (unlike the thin/undocumented Win32 menu-ID path).+"""++import json+import subprocess+import sys++IS_WINDOWS = sys.platform == "win32"++# PowerShell that: attaches to a window by hwnd, finds a control by exact name or+# name-substring (optionally filtered by control type), and Invokes it (or, for+# SetValue, sets its text). Emits a one-line JSON result. All background.+_PS_TEMPLATE = r'''+$ErrorActionPreference = 'Stop'+try {+  Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes+  $AE  = [System.Windows.Automation.AutomationElement]+  $TS  = [System.Windows.Automation.TreeScope]+  $root = $AE::FromHandle([IntPtr]__HWND__)+  if ($root -eq $null) { '{"ok":false,"reason":"window_not_found"}'; exit }++  $name     = __NAME__+  $contains = __CONTAINS__+  $action   = '__ACTION__'+  $text     = __TEXT__++  $target = $null+  if ($name) {+    $cond = New-Object System.Windows.Automation.PropertyCondition($AE::NameProperty, $name)+    $target = $root.FindFirst($TS::Descendants, $cond)+  }+  if ($target -eq $null -and $contains) {+    # bounded FindAll, filter by name substring. For an invoke we must pick a control+    # that ACTUALLY supports InvokePattern — KiCad exposes the label "Footprint Editor"+    # AND the toolbar button with the same name; grabbing the label yields+    # not_invokable. So when action=invoke, prefer an invokable match (fall back to+    # first substring match only if none is invokable).+    $all = $root.FindAll($TS::Descendants, [System.Windows.Automation.Condition]::TrueCondition)+    $firstMatch = $null+    foreach ($el in $all) {+      $n = $el.Current.Name+      if ($n -and $n.ToLower().Contains($contains.ToLower())) {+        if ($firstMatch -eq $null) { $firstMatch = $el }+        if ($action -eq 'invoke') {+          $tmp = $null+          if ($el.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$tmp)) { $target = $el; break }+        } elseif ($action -eq 'setvalue') {+          $tmp = $null+          if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$tmp)) { $target = $el; break }+        } else { $target = $el; break }+      }+    }+    if ($target -eq $null) { $target = $firstMatch }+  }+  if ($target -eq $null) { '{"ok":false,"reason":"control_not_found"}'; exit }++  $foundName = $target.Current.Name+  if ($action -eq 'invoke') {+    $p = $null+    if ($target.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$p)) {+      $p.Invoke()+      '{"ok":true,"action":"invoke","name":' + ($foundName | ConvertTo-Json) + '}'+    } else {+      # some KiCad menu items expose ExpandCollapse or are only Selectable+      $sp = $null+      if ($target.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref]$sp)) {+        $sp.Select(); '{"ok":true,"action":"select","name":' + ($foundName | ConvertTo-Json) + '}'+      } else { '{"ok":false,"reason":"not_invokable","name":' + ($foundName | ConvertTo-Json) + '}' }+    }+  } elseif ($action -eq 'setvalue') {+    $vp = $null+    if ($target.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {+      $vp.SetValue($text)+      '{"ok":true,"action":"setvalue","name":' + ($foundName | ConvertTo-Json) + '}'+    } else { '{"ok":false,"reason":"not_settable","name":' + ($foundName | ConvertTo-Json) + '}' }+  } else {+    '{"ok":true,"action":"find","name":' + ($foundName | ConvertTo-Json) + ',"found":true}'+  }+} catch {+  '{"ok":false,"reason":"exception","error":' + ($_.Exception.Message | ConvertTo-Json) + '}'+}+'''+++def _ps_json(value) -> str:+    """Render a Python value as a PowerShell literal (string or $null)."""+    if value is None:+        return "$null"+    return json.dumps(str(value))  # JSON string == a valid PS single/double-quoted string+++def _run(hwnd: int, action: str, name=None, contains=None, text=None, timeout: float = 8.0) -> dict:+    if not IS_WINDOWS:+        return {"ok": False, "reason": "not_windows"}+    script = (_PS_TEMPLATE+              .replace("__HWND__", str(int(hwnd)))+              .replace("__NAME__", _ps_json(name))+              .replace("__CONTAINS__", _ps_json(contains))+              .replace("__ACTION__", action)+              .replace("__TEXT__", _ps_json(text)))+    try:+        proc = subprocess.run(+            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],+            capture_output=True, text=True, timeout=timeout,+        )+        out = (proc.stdout or "").strip().splitlines()+        for line in reversed(out):  # last non-empty line is our JSON+            line = line.strip()+            if line.startswith("{"):+                return json.loads(line)+        return {"ok": False, "reason": "no_output", "stderr": (proc.stderr or "")[:200]}+    except subprocess.TimeoutExpired:+        return {"ok": False, "reason": "timeout"}+    except Exception as e:  # pylint: disable=broad-except+        return {"ok": False, "reason": "run_failed", "error": str(e)[:200]}+++def uia_invoke(hwnd: int, name: str = None, contains: str = None, timeout: float = 8.0) -> dict:+    """Background-Invoke a control (toolbar button / menu item) in the given window,+    matched by exact accessible name OR name substring. No foreground, no cursor.+    Returns {ok, action, name} / {ok:False, reason}."""+    return _run(hwnd, "invoke", name=name, contains=contains, timeout=timeout)+++def uia_set_value(hwnd: int, text: str, name: str = None, contains: str = None, timeout: float = 8.0) -> dict:+    """Background-SetValue on a text control (e.g. a search box) — no focus/typing."""+    return _run(hwnd, "setvalue", name=name, contains=contains, text=text, timeout=timeout)+++def uia_find(hwnd: int, name: str = None, contains: str = None, timeout: float = 6.0) -> dict:+    """Read-only: is the control present in the UIA tree? {ok, found, name}."""+    return _run(hwnd, "find", name=name, contains=contains, timeout=timeout)
src/resources/adom-overlay-32.pngadded
⋯ 1 unchanged line ⋯
src/server.py+118−1
@@ -1038,6 +1038,119 @@ def h_license(args):     return out  ++# ---- 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:@@ -1409,6 +1522,9 @@ _VERB_CATALOG = {     "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'}.",@@ -1469,7 +1585,8 @@ COMMAND_HANDLERS = {     "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, "compile_intlib": h_compile_intlib, "extract_intlib": h_extract_intlib,+    "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, "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,