← Commit history

0.2.33 source: 122/122 verbs documented + enforcing test; dismiss_dialogs honours hwnd; recover/sweeps never answer save prompts

John Lauer ·067d53ba38 ·1mo ago ·parent c6184fa
8 files changed +675−14
bridge-rs/Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "altium-bridge"-version = "0.2.32"+version = "0.2.33" dependencies = [  "getrandom",  "serde",
bridge-rs/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "altium-bridge"-version = "0.2.32"+version = "0.2.33" edition = "2021" description = "Adom altium-bridge — the bridge process, in Rust. Calls ab's verbs for the desktop; speaks file IPC to the resident DelphiScript agent inside Altium." license = "MIT"
bridge-rs/src/dialogs.rs+8
@@ -236,6 +236,14 @@ pub fn dismiss(button: &str, only_titles: Option<&[String]>) -> Value {     let found = scan();     let mut pressed: Vec<Value> = Vec::new();     for d in &found {+        // A SWEEP (no hwnd) never answers a question: save prompts and any dialog with more than one+        // button are skipped unless the caller named them by title. Pass {hwnd, button} to answer one.+        let cat = d.get("category").and_then(|c| c.as_str()).unwrap_or("unknown");+        let nbtn = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len()).unwrap_or(0);+        if only_titles.is_none() && (cat == "save_prompt" || nbtn > 1) {+            pressed.push(json!({"title": d.get("title"), "skipped": "asks a question: answer it explicitly with {hwnd, button}"}));+            continue;+        }         let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");         if let Some(filter) = only_titles {             if !filter.iter().any(|f| title.contains(f.as_str())) {
bridge-rs/src/lifecycle.rs+9
@@ -576,6 +576,15 @@ fn recover_impl(reason: &str, verify_by_restart: bool) -> Value {     for d in crate::dialogs::scan() {         if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {             let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("");+            // ONLY the script error box: a lone OK titled "Error" (or the X2 notice). This loop used+            // to press OK on EVERY dialog, and agent::call runs recover automatically: with the scan+            // now seeing save prompts that would have answered them (found 2026-09-21).+            let lone_ok = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.len() == 1 && a[0].as_str().map(|x| x.eq_ignore_ascii_case("ok")).unwrap_or(false)).unwrap_or(false);+            let ours = lone_ok && (title.trim().eq_ignore_ascii_case("error") || title.trim().eq_ignore_ascii_case("x2"));+            if !ours {+                steps.push(json!({"dialog": title, "left": "not a script error box: recover never answers other dialogs"}));+                continue;+            }             ab::call("desktop_ui_click", json!({"hwnd": hw, "name": "OK", "role": "button"}), &[], 15);             steps.push(json!({"dialog": title, "pressed": "OK"}));         }
bridge-rs/src/main.rs+1−1
@@ -26,7 +26,7 @@ mod win32; use std::time::Instant; use tiny_http::{Header, Method, Response, Server}; -pub const BRIDGE_VERSION: &str = "0.2.32";+pub const BRIDGE_VERSION: &str = "0.2.33";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/verbs.rs+653−9
@@ -70,12 +70,9 @@ fn verb_doc_long(name: &str, summary: &str, input: Value, output: Value, timeout fn describe() -> Value {     let board = json!({"success": "bool", "data": "object - the agent's payload", "roundtripMs": "int"});     let shot = json!({"success": "bool", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"});-    json!({"success": true, "bridge": "altium", "engine": "rust",-        "facts": ["one script at a time; a modal freezes everything",-                  "never foreground Altium; proofs are captured in the background",-                  "one On-Demand seat serves every Altium app on the machine; killing Altium strands it",-                  "instance:<pid|key|word> targets one of several running Altiums"],-        "verbs": [+    // A Vec, not a json! array literal: json! recurses once per element and 120+ entries would+    // sit on the macro recursion limit.+    let verbs: Vec<Value> = vec![         verb_doc("status", "Is Altium running, licensed, and is the resident agent alive?", json!({}),             json!({"running": "bool", "licensed": "bool", "licenceState": "string", "agentAlive": "bool"}), 15,             "Cheapest health call - answers from ab's window list plus the agent heartbeat; never touches the script engine.",@@ -168,7 +165,7 @@ fn describe() -> Value {             &["The caller owns the VOICE (adom-tts) - the bridge never synthesizes speech.",               "After done:true, restore the user's own document (altium_activate_document)."],             json!({"live": true, "stage": 0})),-        verb_doc("license", "Drive the License Management page (UIA first - no cursor, no foreground).", json!({"action": "show|refresh|use_license|start_trial|sign_out"}), shot, 60,+        verb_doc("license", "Drive the License Management page (UIA first - no cursor, no foreground).", json!({"action": "show|refresh|use_license|start_trial|sign_out"}), shot.clone(), 60,             "One On-Demand seat serves EVERY Altium app on this machine. use_license claims a free seat.",             &["ensure_licensed", "license_status", "close"], &["NEVER kill an Altium holding the seat: the lease strands, the page shows Seats 0/1 while every claim fails. altium_close releases it."], json!({"action": "use_license"})),         verb_doc("close", "Polite close (releases the licence seat); force needs acceptSeatLoss.", json!({"force": "bool", "acceptSeatLoss": "bool"}), json!({"closedInSec": "float"}), 30,@@ -184,8 +181,616 @@ fn describe() -> Value {         verb_doc("install_receipt", "The last bundle install's full receipt (proof frame included), for a client whose install call outlived ab's budget.", json!({}),             json!({"receipt": "object|null - the install_library_bundle reply"}), 10, "Read after altium_status shows lastInstall with your installId.", &["install_library_bundle", "status"], &[], json!({})),         verb_doc("describe", "This list - the bridge's verbs and schemas for ab's Verbs tab.", json!({}), json!({"verbs": "[{name, summary, input, output, ...}]"}), 5, "Static; safe to call any time.", &["status"], &[], json!({})),-    ],-    "allVerbs": names()})+        // ---- health / lifecycle ----+        verb_doc("readiness", "The one-call gate: is Altium installed, running, licensed, and is the resident agent alive? Answers with Altium closed and never touches the script engine.", json!({}),+            json!({"installed": "bool", "install": "{base_dir, exe, system_dir, major} | null", "running": "bool", "licensed": "bool | null", "licenceState": "string", "adVersion": "string | null", "agentAlive": "bool - the selected instance", "agentResidentAnywhere": "bool - any workspace", "ready": "bool - all four true", "agentLiveWorkspaces": "[{workspace, heartbeatAgeSec}] - only when a loop lives outside the selection"}), 15,+            "Follow _hint: not installed -> altium_install; not running -> altium_launch; licensed:false -> altium_ensure_licensed; agent down -> just call the verb you need (it bootstraps the agent) or altium_agent_start. ready:true -> altium_state.",+            &["status", "launch", "ensure_licensed", "agent_start"],+            &["agentAlive is the SELECTED instance's view. agentResidentAnywhere:true beside agentAlive:false means a loop lives in another workspace: close through altium_close, never the window X."], json!({})),+        verb_doc("diagnostics", "Bridge-side facts for a bug report: environment variables, every Altium install found, Altium's windows, the agent status block and the cache directory.", json!({}),+            json!({"env": "{LOCALAPPDATA, APPDATA, USERPROFILE, ProgramFiles, ADOM_BIND_HOST, ADOM_DIRECT_API_URL}", "installs": "[{base_dir, exe, system_dir, major}] newest major first", "windows": "[{hwnd, title, kind, className, pid, rect}]", "agent": "the altium_agent_status reply", "cacheDir": "path"}), 15,+            "installs:[] means Altium is not installed (altium_install). Otherwise altium_readiness for the verdict and altium_instances when several Altiums run.",+            &["readiness", "agent_status", "instances"], &["Instance-exempt: it runs with no instance selected, so agent.alive reads the legacy workspace; agent.liveWorkspaces is the union that counts."], json!({})),+        verb_doc("window_info", "Altium's top-level windows plus any open dialogs, without screenshots. The cheap way to ask 'is a modal blocking the engine?'.", json!({}),+            json!({"windows": "[{hwnd, title, kind, className, pid, rect}]", "dialogs": "[{hwnd, title, body, buttons, category, safeAction, ownedByMainInstance}]", "hasModalDialogs": "bool - a dialog owned by the main instance"}), 15,+            "hasModalDialogs:true means the agent loop and every -R request are frozen behind it: altium_check_dialogs for the pictures, then altium_dismiss_dialogs {button, titles}. Otherwise the main window's title names the current document.",+            &["check_dialogs", "dismiss_dialogs", "state"], &["Altium PAINTS its dialog text, so body is often empty here; altium_check_dialogs attaches a PNG per dialog."], json!({})),+        verb_doc("state", "The whole picture in one call: licence state, windows, dialogs, a screenshot of every main/dialog window, and the agent's logical state (focused document, focused project, open documents).",+            json!({"screenshots": "bool, default true"}),+            json!({"licence": "{running, licensed, state, adVersion, edition, document, hwnd, pid}", "windows": "[..]", "dialogs": "[..]", "shots": "[{title, hwnd, kind, image: base64 png, width, height}]", "logical": "agent reply; data = {focusedDocument, focusedProject, documents:[{path, kind, project, open}]} | null when no agent is alive", "agentAlive": "bool - union over every workspace", "agentWorkspace": "path | null - where logical came from", "liveWorkspaces": "[{workspace, heartbeatAgeSec}]", "drivingBadge": "{active}"}), 60,+            "Look before acting: logical.data.focusedDocument is what the board/schematic read verbs would read; altium_activate_document {path} changes it. Poll with screenshots:false.",+            &["status", "window_info", "activate_document", "agent_start"],+            &["logical is null when no agent is resident: state never bootstraps one. Call any agent-backed verb or altium_agent_start first.",+              "shots[] are full-size base64 PNGs of every main and dialog window; pass screenshots:false when you only need the data."], json!({"screenshots": false})),+        verb_doc("check_dialogs", "List every Altium dialog currently open, classified, each with a screenshot (the only way to READ one: Altium paints its dialog text).",+            json!({"screenshots": "bool, default true"}),+            json!({"dialogsDetected": "int", "dialogs": "[{hwnd, pid, title, className, body, buttons, category, safeAction, meaning, ownedByMainInstance, rect, shot: base64 png}]"}), 30,+            "READ dialogs[].shot, decide, then altium_dismiss_dialogs {button, titles:[..]}. A script 'Error' box: altium_agent_recover. A save prompt on close: see altium_close discardChanges.",+            &["dismiss_dialogs", "dialog_log", "agent_recover", "window_info"],+            &["category and safeAction are suggestions. A save_prompt is never auto-answered: it names documents that may belong to the user or another AI thread.",+              "Known nuisance boxes are pressed by the standing reaper before you ever see them; altium_dialog_log lists those."], json!({})),+        verb_doc("dismiss_dialogs", "Press one named button (default OK) on the detected Altium dialogs through UIA - no cursor, no focus steal. titles narrows it to dialogs whose title contains one of the strings.",+            json!({"button": "string, default 'OK' - the button's accessible name", "titles": "[string] - optional title substrings; only matching dialogs are pressed"}),+            json!({"detected": "int", "pressed": "[{title, hwnd, button, ok}]"}), 30,+            "ALWAYS pass titles. Then altium_check_dialogs to confirm nothing is left, and repeat the verb that was blocked.",+            &["check_dialogs", "close", "agent_recover"],+            &["Without titles the button is pressed on EVERY detected Altium dialog, save prompts included.",+              "hwnd is NOT read by this verb, although other verbs' hints say {hwnd, button}: target a dialog with titles.",+              "success is true even when nothing was pressed or the click failed: check pressed[].ok."],+            json!({"button": "OK", "titles": ["Error"]})),+        verb_doc("launch", "Start Altium (newest install) and wait for its main window. Answers alreadyRunning when one is up.",+            json!({"waitSec": "number, default 180"}),+            json!({"alreadyRunning": "bool", "startedInSec": "float", "licence": "{running, licensed, state, adVersion, edition, hwnd, pid}", "windowEtiquette": "foreground on a real launch, background when already running"}), 240,+            "Cold start is about 60 s. Next: altium_readiness; licensed:false -> altium_ensure_licensed. Most verbs need this first (errorCode altium_not_running).",+            &["readiness", "ensure_licensed", "close", "install"],+            &["A real launch RAISES Altium over the user's work (Altium foregrounds itself at startup). Call it once; never in a retry loop.",+              "errorCode host_app_not_installed -> altium_install. launch_timeout = no main window after waitSec.",+              "A 'Workspace Unavailable' box at launch (altium_dialog_log) means another running Altium holds the seat: this one is view-only and opens fail with view_only_no_author_seat."],+            json!({})),+        verb_doc("uninstall", "Remove the bridge's own cache directory (agent workspaces, rendered agent scripts, the Altium 365 token file). Altium Designer and the user's designs are never touched.",+            json!({"dryRun": "bool - report only, delete nothing"}),+            json!({"removed": "[path]", "dryRun": "bool", "note": "string"}), 30,+            "This is the manifest's uninstall hook; nothing to call afterwards. To revoke the Altium 365 grant remotely call altium_a365_logout BEFORE this.",+            &["a365_logout", "agent_stop"], &["A resident agent is stopped even with dryRun:true; only the delete is skipped."], json!({"dryRun": true})),+        verb_doc("dialog_log", "What the standing dialog reaper (and the bridge's own recoveries) pressed, newest last: nuisance boxes, script-error boxes, discarded save prompts, silent engine halts cleared.",+            json!({"limit": "int, default 25"}),+            json!({"reaped": "[{title, category, pressed, ok, note, at}]", "running": "bool"}), 10,+            "Read it after a surprise: enginePreviouslyParked on a reply, an agent_script_error, or an open that failed view-only ('Workspace Unavailable' shows here). A dialog that reached the user and is NOT listed is a catalog gap: report it.",+            &["check_dialogs", "status", "agent_recover"], &["An in-memory ring of the last 200 events: a bridge restart clears it.", "Save prompts and unknown dialogs never appear here as pressed: the reaper does not touch them."], json!({"limit": 10})),+        // ---- agent / REPL ----+        verb_doc("agent_start", "Bootstrap the resident DelphiScript agent inside Altium (renders the script, runs it through the -R lane, waits for the heartbeat). Optional: every agent-backed verb does this on demand.",+            json!({"waitSec": "int, default 45 - how long to wait for the first heartbeat"}),+            json!({"startedInSec": "float", "alreadyRunning": "bool", "generation": "string - AdomAgent_<digest>", "install": "object - the rendered script", "strayHelpersKilled": "[pid]"}), 120,+            "After success altium_eval / altium_run_command / altium_state go through the agent in milliseconds. Do NOT use altium_run_script while it is up.",+            &["agent_status", "agent_stop", "agent_recover", "eval"],+            &["The bootstrap ACTIVATES Altium for a moment (it opens the .PrjScr in the script editor); the bridge hands the user's foreground window back.",+              "no_heartbeat = the engine halted behind a script Error modal: altium_check_dialogs, then altium_agent_recover.",+              "The loop exits by itself after 5 minutes idle (residency is the freeze-on-close exposure window); the next verb restarts it."], json!({})),+        verb_doc("agent_stop", "Stop the selected instance's resident agent loop (drops a stop file and waits up to 12 s for the heartbeat to go stale).", json!({}),+            json!({"wasRunning": "bool", "stoppedInSec": "float"}), 20,+            "Needed before altium_run_script (the -R lanes refuse while a loop is resident). altium_close stops every workspace's loop itself. The next agent-backed verb restarts the agent.",+            &["agent_start", "agent_status", "run_script", "close"], &["'the loop did not exit' -> altium_agent_recover.", "With several Altiums running pass instance: to stop the right loop."], json!({})),+        verb_doc("agent_status", "Is the agent loop alive and responsive, in which workspace, and is any loop resident anywhere? Read-only; never starts the agent.", json!({}),+            json!({"alive": "bool - selected instance", "responsive": "bool", "residentAnywhere": "bool", "liveWorkspaces": "[{workspace, heartbeatAgeSec}]", "heartbeat": "{ageSec, tick, ...} | null", "workspace": "path"}), 10,+            "alive with responsive:false = the loop is busy inside a long Altium process: wait, do not start a second agent. Not alive: call the verb you need, it bootstraps.",+            &["agent_start", "agent_recover", "readiness"], &["residentAnywhere:true with alive:false is a freeze-on-close hazard: altium_close tears every loop down before the app exits."], json!({})),+        verb_doc("agent_recover", "Un-halt the script engine: clear the PCB transaction, press OK on open Altium dialogs, send EditScript:Stop, reap the busy notice and delete stale request files.",+            json!({"reason": "string - optional, recorded in steps"}),+            json!({"steps": "[{reason} | {dialog, pressed} | {editScriptStop, exited, sec} | busy-notice reap]"}), 60,+            "Use after agent_timeout / no_heartbeat / instance_busy, or when Altium sits on AdomAgent_*.pas in its debugger. Then call your verb again: it restarts the agent. Never ask the user to click.",+            &["check_dialogs", "agent_start", "dialog_log", "selftest_script_error"],+            &["It presses OK on EVERY Altium dialog it finds, not only the script Error box: altium_check_dialogs first if a save prompt may be up.",+              "An agent_script_error reply was ALREADY recovered by the bridge (autoRecovered): do not recover again, and do not resend the same command - it will halt again."],+            json!({"reason": "agent_timeout on read_board"})),+        verb_doc("run_script", "One shot: run a procedure of YOUR OWN .PrjScr through Altium's -R command line. Only for when the agent is NOT resident.",+            json!({"prjscr": "absolute path to a .PrjScr on this machine (required)", "proc": "string, default 'Main'", "timeout": "seconds, default 60"}),+            json!({"helperSec": "float", "arg": "string - the -R argument sent", "modalsPreempted": "[{title, body}] - bridge-caused boxes cleared in flight"}), 60,+            "While the agent is resident use altium_eval / altium_run_command instead (or altium_agent_stop first). Scripts return nothing: have yours write a file and read that.",+            &["eval", "run_command", "agent_stop", "agent_recover"],+            &["Refused with agent_resident while ANY agent loop is alive: a second script raises 'Another script executing now' on the user's screen.",+              "ONE SHOT: instance_busy means the running instance did not accept the request. Do not retry in a loop; each attempt costs the user a busy notice."],+            json!({"prjscr": "C:/scripts/MyTool.PrjScr", "proc": "Main"})),+        verb_doc("list_commands", "Search Altium's process-launcher catalog (about 1,100 Server:Command entries parsed from the install's System/*.rcs files) by text and/or server.",+            json!({"query": "string - substring over command, captions, descriptions and params", "server": "string - e.g. PCB, Sch, WorkspaceManager", "limit": "int, default 50"}),+            json!({"count": "int", "total": "int - distinct commands", "servers": "{server: count}", "hits": "[{command, server, captions, shortcuts, params, descriptions}]"}), 30,+            "Run a hit with altium_run_command {command, params}; hits[].params shows the Name=Value pairs Altium's own menus pass.",+            &["run_command", "eval"], &["Reads files, not Altium: works with Altium closed but answers host_app_not_installed without an install.", "Being listed does not make a process safe: some open a modal that blocks the agent (PCB:DesignRuleCheck - use altium_run_drc)."],+            json!({"query": "zoom", "server": "PCB"})),+        verb_doc("open_document", "Open a document or project by path through the agent and show it without focusing it. An already-open document is activated, not reloaded.",+            json!({"filePath": "absolute Windows path (alias: path)", "timeout": "seconds; the wait is capped at 45"}),+            json!({"data": "{path, objectKind, kind, shown: bool, lane}", "status": "'in_progress' when Altium is still opening it", "roundtripMs": "int", "windowEtiquette": "background"}), 60,+            "Then the verb for that document: altium_read_board / altium_read_schematic / altium_list_library. status:in_progress -> poll altium_status or altium_window_info until it is current.",+            &["activate_document", "close_document", "state", "show_2d_board"],+            &["Only .PcbDoc .SchDoc .PcbLib .SchLib .OutJob .Pcb3DLib .Prj* .LibPkg .txt .pas .rep are accepted; anything else is refused as unsupported_document (Altium would raise a 'File type not recognised' modal).",+              "success:true with data.shown:false on a non-project means the document did NOT open (this verb does not turn that into an error). In a view-only Altium that is every new document: see view_only_no_author_seat."],+            json!({"filePath": "C:/proj/board.PcbDoc"})),+        verb_doc("open_project", "Open a project (.PrjPcb / .PrjScr / .PrjMbd / .PrjHar / .LibPkg) by path. Same handler as altium_open_document.",+            json!({"filePath": "absolute Windows path (alias: path)", "timeout": "seconds; the wait is capped at 45"}),+            json!({"data": "{path, objectKind: 'Project', shown: false, note}", "roundtripMs": "int", "windowEtiquette": "background"}), 60,+            "Then altium_compile {projectPath} for ERC, or altium_open_document one of its sheets. altium_state lists the project's documents.",+            &["open_document", "compile", "show_project", "state"], &["data.shown:false is NORMAL for a project: projects have no document view."], json!({"filePath": "C:/proj/board.PrjPcb"})),+        verb_doc("close_document", "Close ONE open document by path through its document handle. Never saves.",+            json!({"path": "absolute path of the open document (required)"}),+            json!({"data": "{closed: bool, path} | {closed: false, note: 'not open: <path>'}", "roundtripMs": "int"}), 30,+            "altium_save {filePath} first if anything is worth keeping. After re-running altium_place_on_sch / altium_test_board to the same path, close the stale document before altium_show_*.",+            &["save", "open_document", "state"], &["Unsaved edits are DISCARDED without a prompt.", "success:true with closed:false means the document was not open."], json!({"path": "C:/proj/tof.SchDoc"})),+        verb_doc("activate_document", "Make a document current - the quiet lane (one in-process agent call, no cursor, no OS foreground). Board and schematic read verbs act on the CURRENT document.",+            json!({"path": "full path of the document (aliases: document, name)", "timeout": "seconds; the wait is capped at 45"}),+            json!({"data": "{path, objectKind, kind, shown}", "status": "'in_progress' when still opening", "lane": "agent", "windowEtiquette": "background"}), 60,+            "Activate the .PcbDoc, then altium_read_board; activate the .SchDoc, then altium_read_schematic. After a demo or a show_* call, activate the user's own document again.",+            &["open_document", "state", "read_board", "read_schematic"], &["Needs the FULL path with a supported extension; a bare document name is refused.", "It is the same agent 'open': a path that is not open yet gets opened."], json!({"path": "C:/proj/board.PcbDoc"})),+        verb_doc("progress", "The bridge's timing book: phases in flight right now (percent/ETA blocks ab paints on the taskbar), measured phase history and per-verb run statistics.", json!({}),+            json!({"active": "[progress blocks of phases in flight]", "history": "{phase: {warm|cold: {samples, p50, p90}}}", "verbTimes": "{verb: {count, p50, p90, max, failures}} - p90 descending, top 40"}), 10,+            "Use verbTimes.p90 to choose a timeout for a slow verb on THIS machine, and active[] to see what a long call is doing. Never touches Altium.",+            &["status", "describe"], &[], json!({})),+        // ---- board / schematic reads ----+        verb_doc("board_summary", "One-screen summary of the current board, shaped from one read_board round trip: component counts by side, nets, copper layers actually in use, rule counts.",+            json!({"sections": "string csv - optional", "limit": "int, default 2000", "raw": "bool - return the unshaped agent payload"}),+            json!({"summary": "{board, components, componentsTop, componentsBottom, nets, copperLayersInUse, copperLayersInUseNames, layersWithPrimitives, copperLayersAddressable, rules, rulesEnabled}", "roundtripMs": "int"}), 120,+            "Start here on an unknown board, then altium_review_board for findings or altium_component_data / altium_net_connections for detail.",+            &["read_board", "layers_in_use", "rules_by_kind", "review_board"],+            &["Needs a .PcbDoc current (altium_activate_document).", "copperLayersAddressable is what Altium CAN address (top + 30 mid + 16 planes + bottom), not this board's stack: report copperLayersInUse."], json!({})),+        verb_doc("rules_by_kind", "The current board's ENABLED design rules grouped by rule kind (Clearance, Width, RoutingVias, ...), each group sorted by priority. Shaped from one read_board round trip.",+            json!({"sections": "string csv - optional", "limit": "int, default 2000"}),+            json!({"board": "path", "rulesByKind": "{KindName: [{name, kind, enabled, priority, scope1, scope2, comment}]}", "roundtripMs": "int"}), 120,+            "Use it to answer 'what clearance/width does this board enforce'. altium_read_rules gives the flat list including disabled rules; altium_run_drc checks the board against them.",+            &["read_rules", "run_drc", "board_summary"], &["Needs a .PcbDoc current.", "Disabled rules are dropped; a rule kind the bridge has no name for appears as 'kind <n>'."], json!({})),+        verb_doc("layers_in_use", "Which layers of the current board actually carry primitives: copper layers with copper (densest first) and every layer with anything on it.",+            json!({"sections": "string csv - optional", "limit": "int, default 2000"}),+            json!({"board": "path", "copperLayersInUse": "[{layerId, name, primitives}]", "layersWithPrimitives": "[{layerId, name, primitives}]", "roundtripMs": "int"}), 120,+            "The honest answer to 'how many layers is this board'. altium_set_layer_visibility {layerId} to isolate one, then altium_show_2d_board for a frame.",+            &["board_summary", "layers", "copper_layers", "set_layer_visibility"], &["Needs a .PcbDoc current.", "A floor, not the stack: an empty inner plane carries no primitives and is invisible here."], json!({})),+        verb_doc("read_schematic", "Components on the CURRENT schematic sheet (designator, library reference, comment, position, parameters) in one agent round trip.",+            json!({"sections": "string csv, default 'components'", "params": "bool, default true - include component parameters", "limit": "int, default 500"}),+            json!({"document": "string", "count": "int", "designators": "[string]", "components": "[{designator, libReference, comment, ...}]", "roundtripMs": "int"}), 90,+            "Then altium_cross_check {board, sheet} to compare against the layout, or altium_schematic_data {designator} for one part's pins.",+            &["schematic_data", "cross_check", "show_schematic", "activate_document"],+            &["The CURRENT sheet only: a hierarchical parent holds sheet symbols, not parts. Read each leaf sheet.", "Needs a .SchDoc current; 'no current schematic' -> altium_open_document / altium_show_schematic."], json!({})),+        verb_doc("cross_check", "Compare one schematic sheet's designators with a board's. Altium holds ONE current document, so the verb activates the board, reads it, then activates the sheet and reads that.",+            json!({"board": "path to the .PcbDoc - optional, else the current board", "sheet": "path to the .SchDoc - optional, else the current sheet", "timeout": "seconds per step, default 45"}),+            json!({"sheet": "string", "board": "path", "sameProject": "bool | null - same folder", "matched": "int", "onSheetNotOnBoard": "[designator]", "onBoardNotOnSheet": "[designator]", "schematicComponents": "int", "boardComponents": "int"}), 120,+            "Pass BOTH paths: with neither, one of the two reads has no current document and the call fails at stage board or sheet. Follow differences with altium_component_data / altium_schematic_data.",+            &["read_board", "read_schematic", "review_board"],+            &["The schematic side is ONE sheet: on a hierarchical design run it per leaf sheet before calling anything missing.", "_warning means sheet and board sit in different folders, probably different projects: every difference is then meaningless.", "With paths given it leaves the SHEET current; altium_activate_document the board again before board verbs."],+            json!({"board": "C:/proj/board.PcbDoc", "sheet": "C:/proj/main.SchDoc"})),+        verb_doc("list_designators", "Every component designator on the current board.", json!({}),+            json!({"data": "{count, designators: [string]}", "roundtripMs": "int"}), 90,+            "Then altium_component_data {designator} or altium_component_pins {designator}.",+            &["component_data", "component_pins", "read_board"], &["Needs a .PcbDoc current: 'no current PCB board' carries the _hint to open one."], json!({})),+        verb_doc("component_data", "Placement data for one board component, or for all of them: footprint pattern, comment, layer/side, position, rotation, size, pad count and the nets on its pads.",+            json!({"designator": "string - optional; omit for every component", "limit": "int, default 500"}),+            json!({"data": "{component: {designator, pattern, comment, layerId, bottom, xMil, yMil, rotation, wMil, hMil, pads, nets}} | {count, components: [..]}", "roundtripMs": "int"}), 90,+            "Then altium_component_pins {designator} for pad-level nets, or altium_footprint_primitives {designator} for the geometry.",+            &["list_designators", "component_pins", "footprint_primitives"], &["Needs a .PcbDoc current.", "Units are mils; the designator match ignores case. limit only applies to the all-components form."], json!({"designator": "U1"})),+        verb_doc("component_pins", "Every pad of one board component with its net, position, layer, size and hole.",+            json!({"designator": "string (required), e.g. U1"}),+            json!({"data": "{designator, count, pins: [{name, net, xMil, yMil, layerId, wMil, hMil, holeMil}]}", "roundtripMs": "int"}), 90,+            "Use pins[].net with altium_net_connections {net} to follow a signal across the board.",+            &["component_data", "net_connections", "footprint_primitives"], &["Needs a .PcbDoc current.", "holeMil 0 = an SMD pad."], json!({"designator": "U1"})),+        verb_doc("list_nets", "Every net on the current board with its pad count.", json!({}),+            json!({"data": "{count, nets: [{name, pads}]}", "roundtripMs": "int"}), 90,+            "Then altium_net_connections {net} for the pads of one net.",+            &["net_connections", "read_board"], &["Needs a .PcbDoc current.", "Pad counts are tallied over pads: a net with no pads on it does not appear."], json!({})),+        verb_doc("net_connections", "Every pad on the board with its net and owner (Designator.Pad), plus a bounding span per net. net filters to one net.",+            json!({"net": "string - optional net name", "limit": "int, default 4000 - caps pads"}),+            json!({"data": "{count, pads: [{net, pad: 'U1.3', xMil, yMil}], nets: [{net, minXminYmaxXmaxYpads}]}", "roundtripMs": "int"}), 120,+            "Pass net on anything but a small board. The span string is 'minX,minY,maxX,maxY,pads' in mils: a quick measure of how far a net travels.",+            &["list_nets", "component_pins"], &["Needs a .PcbDoc current.", "limit truncates silently: count is what was returned, not what exists."], json!({"net": "GND"})),+        verb_doc("layers", "The current board's layer set with ids, names, kind and whether each is displayed.", json!({}),+            json!({"data": "{layers: [{name, id, kind, displayed}]}", "roundtripMs": "int"}), 90,+            "ids feed altium_set_layer_visibility {layerId}. For what is actually used, altium_layers_in_use.",+            &["copper_layers", "layers_in_use", "set_layer_visibility"], &["Needs a .PcbDoc current.", "The fixed layer set, not the stackup: thickness and dielectric detail are not read."], json!({})),+        verb_doc("copper_layers", "Every copper layer Altium can address on the current board (top, mid 1-30, planes 1-16, bottom) with names and visibility.", json!({}),+            json!({"data": "{layers: [{name, id, kind, displayed}]}", "roundtripMs": "int"}), 90,+            "Do not count these as the board's layers: altium_layers_in_use says which carry copper.",+            &["layers", "layers_in_use"], &["Needs a .PcbDoc current.", "A capability list (the same on any board), not the stack in use."], json!({})),+        verb_doc("read_rules", "Every design rule on the current board, flat, disabled ones included: name, kind ordinal, enabled, priority, both scope expressions, comment.", json!({}),+            json!({"data": "{count, rules: [{name, kind: int, enabled, priority, scope1, scope2, comment}]}", "roundtripMs": "int"}), 90,+            "altium_rules_by_kind groups the enabled ones under readable kind names; altium_run_drc checks the board against them.",+            &["rules_by_kind", "run_drc"], &["Needs a .PcbDoc current.", "kind is Altium's TRuleKind ORDINAL; rule values (the actual clearance/width numbers) are not read."], json!({})),+        verb_doc("set_layer_visibility", "Show or hide one layer of the current board (by layer id) and refresh the view and the layer tabs.",+            json!({"layerId": "int (required) - an id from altium_layers", "displayed": "bool, default true"}),+            json!({"data": "{layerId, name, displayed}", "roundtripMs": "int"}), 60,+            "Then altium_show_2d_board {filePath} for a frame of the result. Restore what you hid: visibility is view state the user keeps seeing.",+            &["layers", "layers_in_use", "show_2d_board"], &["Needs a .PcbDoc current.", "Only pass ids that altium_layers returned: the id is not range-checked before the layer object is read."], json!({"layerId": 1, "displayed": false})),+        verb_doc("footprint_primitives", "Geometry dump of one PLACED component: its tracks, arcs, pads and texts in mils with layer ids; other primitive kinds are counted.",+            json!({"designator": "string (required) - a component on the current board, e.g. U1"}),+            json!({"data": "{designator, pattern, count, otherPrimitives, primitives: [{kind: track|arc|pad|text, layerId, ...}]}", "roundtripMs": "int"}), 120,+            "Use it to verify an imported footprint on a altium_test_board: pad layerId 74 = multi-layer (plated through), which is what altium_plate_vias produces.",+            &["component_pins", "plate_vias", "test_board", "pcb_report"], &["Needs a .PcbDoc current: it reads a placed component, not a PcbLib member (altium_pcb_report reads libraries).", "3D bodies, fills and regions only show up in otherPrimitives."], json!({"designator": "U1"})),+        verb_doc("schematic_data", "Components on the CURRENT schematic sheet with their pins (number, name, electrical type), position and orientation. designator filters to one part.",+            json!({"designator": "string - optional filter", "limit": "int, default 500", "pins": "bool, default true"}),+            json!({"data": "{document, count, components: [{designator, partId, libReference, comment, xMil, yMil, orientation, pins: [{number, name, electrical}], params: []}]}", "roundtripMs": "int"}), 120,+            "Pin-level view of a sheet. For parameters use altium_read_schematic (params come back empty here).",+            &["read_schematic", "cross_check", "show_schematic"], &["Needs a .SchDoc current; one sheet only.", "electrical is Altium's pin-type ordinal, not a name."], json!({"designator": "U1"})),+        // ---- surfaces ----+        verb_doc("show_project", "Open a project (.PrjPcb) through the agent and return a proof frame of Altium's main window. With no filePath it opens Altium's bundled example project (Bluetooth Sentinel).",+            json!({"filePath": "path to a .PrjPcb - default: the bundled Bluetooth Sentinel example", "timeout": "seconds, default 60"}),+            json!({"surface": "project", "path": "string", "steps": "[{open, result, ms}]", "title": "string", "document": "string", "dialogsDetected": "int", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "elapsedSec": "float", "windowEtiquette": "background"}), 120,+            "A project has no document view of its own: follow with altium_show_schematic / altium_show_2d_board {filePath} for something to look at, or altium_compile {projectPath}.",+            &["show_schematic", "show_2d_board", "compile", "open_project"],+            &["ALWAYS pass filePath for the user's work: the default opens Altium's example project in their session.",+              "Measured live 2026-09-20: opening documents RAISES Altium over the user's work even though the reply says background. One call per proof; never loop show_* verbs.",+              "success:false with dialogs[] means a box came up during the open: read it before anything else."],+            json!({"filePath": "C:/proj/board.PrjPcb"})),+        verb_doc("show_schematic", "Open a .SchDoc, zoom to fit, and return a proof frame. With no filePath it opens the bundled Bluetooth Sentinel top sheet.",+            json!({"filePath": "path to a .SchDoc - default: the bundled example sheet", "timeout": "seconds, default 60"}),+            json!({"surface": "schematic", "path": "string", "steps": "[{open | process, ...}]", "title": "string", "document": "string", "dialogsDetected": "int", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "elapsedSec": "float", "windowEtiquette": "background"}), 120,+            "LOOK at shot.image before claiming anything. Then altium_read_schematic for the data, or altium_animate_view {lane:'sch'} for zoom moves.",+            &["read_schematic", "place_on_sch", "animate_view", "show_2d_board"],+            &["ALWAYS pass filePath: the default opens Altium's example design in the user's session.",+              "Measured live 2026-09-20: it RAISES Altium over the user's work. One call per proof; never loop it.",+              "After re-generating a sheet at the same path, altium_close_document the stale one first or the frame shows the OLD document."],+            json!({"filePath": "C:/proj/tof.SchDoc"})),+        verb_doc("show_2d_board", "Open a .PcbDoc, switch to 2D, zoom to fit, and return a proof frame. With no filePath it opens the bundled Bluetooth Sentinel board.",+            json!({"filePath": "path to a .PcbDoc - default: the bundled example board", "timeout": "seconds, default 120"}),+            json!({"surface": "2d_board", "path": "string", "steps": "[{open | process, ...}]", "title": "string", "document": "string", "dialogsDetected": "int", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "elapsedSec": "float", "windowEtiquette": "background"}), 120,+            "It also makes the board the CURRENT document, so altium_read_board / altium_board_summary work right after. altium_show_3d_board for the 3D frame.",+            &["show_3d_board", "read_board", "set_layer_visibility", "animate_view"],+            &["ALWAYS pass filePath: the default opens Altium's example board in the user's session.",+              "Measured live 2026-09-20: it RAISES Altium over the user's work. One call per proof; never loop it."],+            json!({"filePath": "C:/proj/tof.PcbDoc"})),+        verb_doc("show_3d_board", "Open a .PcbDoc, switch to 3D, zoom to fit, and return a proof frame. With no filePath it opens the bundled Bluetooth Sentinel board.",+            json!({"filePath": "path to a .PcbDoc - default: the bundled example board", "timeout": "seconds, default 120"}),+            json!({"surface": "3d_board", "path": "string", "steps": "[{open | process, ...}]", "title": "string", "document": "string", "dialogsDetected": "int", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "elapsedSec": "float", "windowEtiquette": "background"}), 120,+            "The proof for altium_test_board {pcbPath}: check every part shows a body. Missing body = the footprint has none (altium_add_body). altium_animate_view {preset:'showcase3d'} for motion.",+            &["show_2d_board", "test_board", "add_body", "animate_view"],+            &["ALWAYS pass filePath: the default opens Altium's example board in the user's session.",+              "Measured live 2026-09-20: it RAISES Altium over the user's work. One call per proof; never loop it.",+              "It leaves the board in 3D mode for the user; altium_show_2d_board switches back."],+            json!({"filePath": "C:/proj/tof.PcbDoc"})),+        verb_doc("show_library", "Open a .SchLib or .PcbLib (kind taken from the extension), select its first component, return a proof frame and the component names.",+            json!({"libraryPath": "path to a .SchLib/.PcbLib (alias: filePath) - default: the bundled SpiritLevel SchLib", "timeout": "seconds, default 60"}),+            json!({"surface": "library", "path": "string", "selected": "string", "components": "[string] up to 200", "componentCount": "int", "steps": "[..]", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 120,+            "Then altium_show_symbol / altium_show_footprint / altium_show_3d_chip {libraryPath, name} for one member. altium_list_library gives the names without a frame.",+            &["list_library", "show_symbol", "show_footprint", "show_3d_chip"],+            &["ALWAYS pass libraryPath: the default opens Altium's example library in the user's session.",+              "Measured live 2026-09-20: it RAISES Altium over the user's work. One call per proof; never loop it.",+              "errorCode proof_unavailable = opened and selected correctly, only the capture failed (window minimized)."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib"})),+        verb_doc("show_footprint", "Open a PcbLib member in the footprint editor in 2D, zoom to fit, and capture a proof frame.",+            json!({"libraryPath": "path to a .PcbLib (alias: filePath) - default: the bundled SpiritLevel PcbLib", "footprintName": "string (alias: name) - default: the first footprint of YOUR library, SO-G3 in the demo library", "timeout": "seconds, default 60"}),+            json!({"surface": "footprint", "path": "string", "selected": "string", "steps": "[..]", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "available": "[string] - when the name was not found", "windowEtiquette": "background"}), 120,+            "Check pads, silkscreen and courtyard in shot.image; altium_pcb_report {path, name} gives the numbers. altium_show_3d_chip for the body.",+            &["show_3d_chip", "pcb_report", "build_footprint", "list_library"],+            &["Measured live 2026-09-20: it RAISES Altium over the user's work. One call per proof; never loop it.",+              "A wrong footprintName fails with available[] (up to 50 names): pick from it.",+              "An empty-looking canvas usually means a 3D body sits 50000 mil from the pads: altium_normalize_bodies."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX"})),+        verb_doc("show_3d_chip", "Open a PcbLib member in the footprint editor in 3D, zoom to fit, and capture a proof frame of the part's 3D body.",+            json!({"libraryPath": "path to a .PcbLib (alias: filePath) - default: the bundled SpiritLevel PcbLib", "footprintName": "string (alias: name) - default: the first footprint of YOUR library, SO-G3 in the demo library", "timeout": "seconds, default 60"}),+            json!({"surface": "3d_chip", "path": "string", "selected": "string", "steps": "[..]", "dialogs": "[..]", "shot": "{image: base64 png, width, height}", "available": "[string] - when the name was not found", "windowEtiquette": "background"}), 120,+            "The proof after altium_add_body / altium_import_part {step}. Pads with no body in the frame: altium_pcb_report to see whether a body exists, altium_normalize_bodies if it sits off the pads.",+            &["show_footprint", "add_body", "normalize_bodies", "pcb_report"],+            &["Measured live 2026-09-20: it RAISES Altium over the user's work. One call per proof; never loop it.",+              "It leaves the library editor in 3D mode; altium_show_footprint switches back to 2D."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX"})),+        verb_doc("list_library", "Names of every component in a .SchLib or every footprint in a .PcbLib (kind taken from the extension). Opens the library; no screenshot.",+            json!({"libraryPath": "path to a .SchLib/.PcbLib (alias: filePath) - default: the bundled SpiritLevel SchLib", "limit": "int, default 500"}),+            json!({"data": "{kind: sch|pcb, count, names: [string]}", "path": "string", "kind": "sch | pcb", "roundtripMs": "int"}), 60,+            "Use the exact names with altium_show_symbol / altium_read_symbol / altium_show_footprint / altium_pcb_report.",+            &["show_library", "read_symbol", "pcb_report"], &["It makes the library the current document.", "count is the true total; names[] stops at limit."], json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib"})),+        verb_doc("save", "Save the focused document, or open filePath first and save that (WorkspaceManager:SaveObject on the focused document).",+            json!({"filePath": "path - optional; opened and made current before the save"}),+            json!({"data": "{process, params}", "roundtripMs": "int"}), 60,+            "Pass filePath every time: every library authoring verb leaves the library UNSAVED. Then altium_check_dialogs - a 'Save As' box means the document never had a path.",+            &["close_document", "check_dialogs", "close"], &["Without filePath it saves whatever has focus, which may be the user's document rather than yours.", "success only means the save process was dispatched; a Save As dialog blocks the engine until answered."],+            json!({"filePath": "C:/lib/Adom_ToF.SchLib"})),+        verb_doc("board_shape", "Redefine the CURRENT board's outline as a wMm x hMm rectangle at (x0Mm, y0Mm) by rewriting the board outline's segments.",+            json!({"pcbPath": "path to a .PcbDoc - optional; opened first", "x0Mm": "number, default 0", "y0Mm": "number, default 0", "wMm": "number, default 15", "hMm": "number, default 15", "timeout": "seconds, default 120"}),+            json!({"data": "{outlineMil: [left, bottom, right, top]}", "roundtripMs": "int"}), 120,+            "Then altium_save {filePath: pcbPath} and altium_show_2d_board {filePath} to see it. altium_pcb_report {want:'board'} reads outlineMil back.",+            &["test_board", "pcb_report", "show_2d_board", "save"],+            &["errorCode no_board_outline: the current document is a PcbLib (its internal board has no outline) - pass pcbPath.", "bad_size = wMm/hMm not greater than 0; bad_number = a value was not numeric.", "It REPLACES the outline; the previous shape is not kept."],+            json!({"pcbPath": "C:/proj/tof.PcbDoc", "wMm": 39, "hMm": 14})),+        verb_doc("pcb_report", "Numbers instead of pixels: for a PcbLib, every footprint's pads, pad bounding box, primitive census and 3D bodies; for a board, outline, components with nets and bodies, census and tracks.",+            json!({"path": "path to a .PcbLib or .PcbDoc - optional; opened first", "name": "string - footprint name filter (library form)", "want": "'board' - report the focused BOARD even though a PcbLib is also answering", "timeout": "seconds, default 60"}),+            json!({"data": "{kind: library, components: [{name, pads, padsBboxMil, census, bodies}], bodyCols} | {kind: board, outlineMil, components: [{.., pads, padsBboxMil, nets, bodies}], census, tracks, bodyCols}", "roundtripMs": "int"}), 60,+            "bodies[] rows follow bodyCols (layerId,wMil,hMil,leftMil,bottomMil,standoffMil,overallMil,hasModel). A body whose leftMil is ~50000 away from padsBboxMil needs altium_normalize_bodies.",+            &["footprint_primitives", "normalize_bodies", "add_body", "show_3d_chip"],+            &["Altium's GetCurrentPCBLibrary answers even when a BOARD is focused: pass want:'board' for a .PcbDoc or you get the library report.", "A footprint with bodies:[] places with no 3D model."],+            json!({"path": "C:/lib/Adom_ToF.PcbLib", "name": "VL53L5CX"})),+        // ---- outputs / project ----+        verb_doc("compile", "Compile a project and return Altium's Messages panel (ERC + connectivity) with counts by class. With no path it compiles Altium's bundled example project.",+            json!({"projectPath": "path to a .PrjPcb (alias: filePath) - default: the bundled Bluetooth Sentinel example", "objectKind": "string, default 'Project'", "limit": "int, default 200 messages", "timeout": "seconds"}),+            json!({"project": "path", "messageCount": "int", "byClass": "{class: n}", "messages": "[{class, source, document, text}]", "elapsedSec": "float", "windowEtiquette": "background"}), 180,+            "Zero messages = clean. Fix what messages[] names, recompile, then altium_run_drc for the board side.",+            &["run_erc", "run_drc", "open_project", "cross_check"],+            &["ALWAYS pass projectPath: the default compiles Altium's example project in the user's session.", "messages[] is the WHOLE Messages panel after the compile, capped at limit; messageCount is the true total."],+            json!({"projectPath": "C:/proj/board.PrjPcb"})),+        verb_doc("run_erc", "Electrical rules check. Same handler as altium_compile: in Altium, compiling the project IS the ERC.",+            json!({"projectPath": "path to a .PrjPcb (alias: filePath) - default: the bundled Bluetooth Sentinel example", "objectKind": "string, default 'Project'", "limit": "int, default 200 messages", "timeout": "seconds"}),+            json!({"project": "path", "messageCount": "int", "byClass": "{class: n}", "messages": "[{class, source, document, text}]", "elapsedSec": "float", "windowEtiquette": "background"}), 180,+            "byClass gives the error/warning split; read messages[].text and .document for each. Then altium_run_drc for the layout.",+            &["compile", "run_drc", "review_board"], &["ALWAYS pass projectPath: the default compiles Altium's example project.", "Needs the PROJECT path, not a sheet: a free .SchDoc has no project to compile."],+            json!({"projectPath": "C:/proj/board.PrjPcb"})),+        verb_doc("run_drc", "Run the design rule check on a board and read the violation markers back. PCB:DesignRuleCheck opens a MODAL; the bridge clicks 'Run Design Rule Check' through UIA in the background, waits for the agent, re-activates the board and reads the violations.",+            json!({"filePath": "path to a .PcbDoc - default: the bundled Bluetooth Sentinel board", "limit": "int, default 200 violations", "timeout": "seconds to wait for the DRC, default 180"}),+            json!({"board": "path", "violationCount": "int", "violations": "[{rule, description}]", "steps": "[..]", "dialogs": "[..]", "elapsedSec": "float", "windowEtiquette": "background"}), 300,+            "violationCount is the true total; violations[] stops at limit. Altium also opens its HTML DRC report as a new document: altium_state shows it, altium_close_document {path} removes it.",+            &["read_rules", "rules_by_kind", "review_board", "compile"],+            &["ALWAYS pass filePath: the default runs DRC on Altium's example board.", "drc_dialog_not_found = the DRC dialog never opened; check that a PCB is focused (altium_window_info).", "Never run PCB:DesignRuleCheck through altium_run_command: the modal freezes the agent with nobody to click it."],+            json!({"filePath": "C:/proj/board.PcbDoc"})),+        verb_doc("run_outjob", "Run one output medium of an .OutJob (gerbers, drill, BOM, PDFs...) and list what it wrote under 'Project Outputs for <project>'.",+            json!({"outjobPath": "path to an .OutJob (alias: filePath) - default: the bundled example Fabrication.OutJob", "medium": "string, default 'Folder Structure' - the OutputMedium name inside the OutJob", "projectPath": "path to the .PrjPcb the OutJob belongs to; opened first", "objectKind": "string, default 'OutputBatch'", "timeout": "seconds, default 300"}),+            json!({"outjob": "path", "medium": "string", "agent": "{outjob, medium, objectKind}", "outputDir": "path | null", "files": "[relative paths] up to 60", "dialogs": "[..]", "elapsedSec": "float", "windowEtiquette": "background"}), 600,+            "files[] is the proof. Pull what you need with ab's pull_file. OutJobs OPEN what they generate (CAMtastic, PDFs): altium_state afterwards and altium_close_document the viewers.",+            &["export_gerber", "export_bom", "export_step", "check_dialogs"],+            &["Pass projectPath with your own OutJob: outputDir/files are looked up beside the PROJECT, and without it beside the OutJob.", "medium must be the name of an output medium inside the OutJob. The agent cannot tell whether it ran: files[] is the only proof.", "A dialog in dialogs[] means a medium needed a decision (printer, PDF path): read dialogs[].shot."],+            json!({"outjobPath": "C:/proj/Fabrication.OutJob", "projectPath": "C:/proj/board.PrjPcb", "medium": "Folder Structure"})),+        verb_doc("export_gerber", "Gerber/drill export: altium_run_outjob with the fabrication OutJob and the 'Folder Structure' medium as defaults.",+            json!({"outjobPath": "path to an .OutJob - default: the bundled example Fabrication.OutJob", "medium": "string, default 'Folder Structure'", "projectPath": "path to the .PrjPcb", "timeout": "seconds, default 300"}),+            json!({"surface": "gerber", "outjob": "path", "outputDir": "path | null", "files": "[relative paths]", "dialogs": "[..]", "elapsedSec": "float"}), 300,+            "Check files[] for the gerber and NC drill files, then pull them with ab's pull_file. Close the CAMtastic documents the OutJob opened (altium_state, altium_close_document).",+            &["run_outjob", "export_bom", "export_step", "run_drc"],+            &["There is NO board-path argument: without outjobPath it exports Altium's EXAMPLE design. The user's project needs its own OutJob.", "OutJobs open what they generate: expect CAM documents left open in the user's session."],+            json!({"outjobPath": "C:/proj/Fabrication.OutJob", "projectPath": "C:/proj/board.PrjPcb"})),+        verb_doc("export_bom", "Bill of materials export: altium_run_outjob with the assembly OutJob and the 'Folder Structure' medium as defaults.",+            json!({"outjobPath": "path to an .OutJob - default: the bundled example Assembly.OutJob", "medium": "string, default 'Folder Structure'", "projectPath": "path to the .PrjPcb", "timeout": "seconds, default 300"}),+            json!({"surface": "bom", "outjob": "path", "outputDir": "path | null", "files": "[relative paths]", "dialogs": "[..]", "elapsedSec": "float"}), 300,+            "files[] names the BOM the OutJob wrote; pull it with ab's pull_file. For part data without an OutJob, altium_read_schematic returns parameters per component.",+            &["run_outjob", "export_gerber", "read_schematic"],+            &["Without outjobPath it exports the BOM of Altium's EXAMPLE design.", "The default OutJob is the example's Assembly.OutJob, which runs every output in that medium, not only a BOM."],+            json!({"outjobPath": "C:/proj/Assembly.OutJob", "projectPath": "C:/proj/board.PrjPcb"})),+        verb_doc("export_step", "Headless STEP export of a board through the AdomBridge .NET extension (X2 -RAdomBridge:ExportStep with a request file). With no filePath it targets the bundled example board.",+            json!({"filePath": "path to a .PcbDoc - default: the bundled example board", "output": "path - default: <board>.step", "holes": "string, default 'All'", "padHoles": "bool, default true", "copper": "string, default 'None'", "models": "string, default 'Step'", "folded": "bool, default false", "timeout": "seconds, default 300"}),+            json!({"result": "string - the extension's result file", "output": "path", "outputExists": "bool", "outputBytes": "int", "elapsedSec": "float"}), 360,+            "altium_extension_ping first. outputExists is the only proof; then pull the file with ab's pull_file.",+            &["extension_ping", "install_extension", "run_outjob"],+            &["Needs the extension: extension_not_installed -> altium_install_extension with Altium closed.", "Measured on AD 26.9.1: the extension never loaded into the RUNNING instance, so expect extension_timeout until it is rebuilt for AD 26. The verb fails loudly rather than faking a file.", "copper:'All' bakes traces as solids (about 30x the file size)."],+            json!({"filePath": "C:/proj/board.PcbDoc", "output": "C:/proj/board.step"})),+        verb_doc("import_kicad", "Import a KiCad file with Altium's own KiCad importer, driven through the AdomBridge .NET extension.",+            json!({"filePath": "path to a .kicad_sym / .kicad_sch / .kicad_pcb (required)", "mode": "string, default 'Import'", "timeout": "seconds, default 300"}),+            json!({"result": "string - the extension's result file", "elapsedSec": "float"}), 300,+            "Output lands in 'Imported <stem>' beside the source. Then link footprints with altium_set_params {footprint}. For Adom parts prefer altium_import_part, which needs no extension.",+            &["extension_ping", "install_extension", "import_part", "set_params"],+            &["Footprints are NOT imported by Altium's KiCad importer.", "Same extension caveat as altium_export_step: on AD 26.9.1 it answered extension_timeout."],+            json!({"filePath": "C:/lib/VL53L5CX.kicad_sym"})),+        verb_doc("install_extension", "Install, update or remove the AdomBridge .NET extension (the PowerShell installer shipped beside the bridge). Altium must be CLOSED.",+            json!({"remove": "bool, default false - uninstall instead"}),+            json!({"removed": "bool", "output": "string - installer stdout tail", "stderr": "string"}), 200,+            "Sequence: altium_close, altium_install_extension, altium_launch, altium_extension_ping. Only altium_export_step and altium_import_kicad need it.",+            &["extension_ping", "export_step", "import_kicad", "close"],+            &["Refused with altium_running while any Altium is up (it holds AdomBridge.dll open); remove:true is allowed while running.", "A failed extension load can stop Altium starting: put extension/REMOVE-AdomBridge.cmd on the Desktop first.", "It was unregistered on AD 26.9.1 after coinciding with a 'Could not start Vault Explorer' warning: install only when you need those two verbs."],+            json!({})),+        verb_doc("extension_ping", "Does the AdomBridge .NET extension answer? Sends AdomBridge:Ping through the -R lane and waits up to 30 s for its result file.", json!({}),+            json!({"result": "string", "installedOnDisk": "bool", "elapsedSec": "float"}), 60,+            "success -> altium_export_step / altium_import_kicad will work. installedOnDisk:true with extension_timeout = installed but not loading on this Altium build.",+            &["install_extension", "export_step", "import_kicad"], &["It spawns an X2 -R helper against the running instance: do not loop it."], json!({})),+        // ---- library authoring ----+        verb_doc("create_footprint", "Create one footprint from a pad list in a PcbLib (the library is created and saved if the path does not exist) and return a proof frame.",+            json!({"name": "string (required)", "libraryPath": "path to a .PcbLib (required)", "description": "string", "pads": "'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...' - hole 0 = SMD", "timeout": "seconds, default 90"}),+            json!({"data": "{footprint, library, libraryState: opened|created, pads: int}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 90,+            "The footprint is registered but the library is NOT saved: altium_save {filePath: libraryPath}. Then altium_add_body for 3D and altium_show_footprint to check. For a real part use altium_build_footprint (spec with silkscreen and courtyard).",+            &["build_footprint", "add_body", "save", "show_footprint"],+            &["errorCode bad_number = a pad field that must be a number was not; nothing is created.", "Units are MILS here; altium_build_footprint specs are millimetres.", "A fresh library carries the stray PCBComponent_1: altium_cleanup_libs."],+            json!({"name": "R0603", "libraryPath": "C:/lib/Test.PcbLib", "pads": "1,-30,0,35,37,rect,0;2,30,0,35,37,rect,0"})),+        verb_doc("create_symbol", "Create one rectangular symbol from a pin list in a SchLib (the library is created and saved if the path does not exist) and return a proof frame.",+            json!({"name": "string (required)", "libraryPath": "path to a .SchLib (required)", "description": "string", "designator": "string, default 'U'", "bodyWidthMil": "int, default 800", "bodyHeightMil": "int, default 600", "pins": "'des,name,left|right|top|bottom,offset_mil;...'", "timeout": "seconds, default 90"}),+            json!({"data": "{symbol, library, libraryState: opened|created, pins: int}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 90,+            "Library is UNSAVED: altium_set_params {footprint, params}, then altium_save {filePath: libraryPath}. For multi-mode symbols use altium_build_symbol_modes.",+            &["build_symbol_modes", "set_params", "read_symbol", "save"],+            &["A fresh library carries the stray Component_1: altium_cleanup_libs.", "Pin offsets are mils along the chosen side; check shot.image for overlapping pins."],+            json!({"name": "CH340G", "libraryPath": "C:/lib/Test.SchLib", "pins": "1,GND,left,100;2,TXD,left,200;16,VCC,right,100"})),+        verb_doc("build_footprint", "Build a complete footprint (pads, silkscreen, courtyard, arcs) in a PcbLib from a kicad2altium spec file on this machine, and return a proof frame.",+            json!({"libraryPath": "path to a .PcbLib (required; created if missing)", "spec": "Windows path to the spec (tools/kicad2altium.py output; mm, Y up) (required)", "timeout": "seconds, default 240"}),+            json!({"data": "{footprint, description, library, libraryState, pads, throughHolePads, drawingPrimitives}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 240,+            "Stage the spec with ab's send_files first. Then altium_add_body {step}, altium_plate_vias for thermal vias, altium_save. altium_import_part does all of it in one call.",+            &["import_part", "add_body", "plate_vias", "show_footprint"],+            &["The footprint name comes from the spec's META line, not from an argument.", "Pads with a repeated designator are legal and all map to that pin (the heatsink-via convention).", "Library is left UNSAVED."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "spec": "C:/lib/specs/VL53L5CX.fp.txt"})),+        verb_doc("build_symbol_modes", "Build ONE SchLib component with N display modes (Normal + Alternate 1..N-1) from a symbol_modes spec file on this machine, and return a proof frame.",+            json!({"libraryPath": "path to a .SchLib (required; created if missing)", "spec": "Windows path to the spec (tools/symbol_modes.py output; mils, Y up) (required)", "timeout": "seconds, default 240"}),+            json!({"data": "{libref, library, libraryState, modes, pins, lines, displayModeCount}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 240,+            "Then altium_add_symbol_art for the chip outline, altium_add_models / altium_set_models for the footprint, altium_save. altium_symbol_mode {mode} frames each alternate.",+            &["import_part", "add_symbol_art", "symbol_mode", "add_models"],+            &["libref comes from the spec's META line.", "Compare data.displayModeCount with data.modes before trusting the alternates.", "Library is left UNSAVED."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "spec": "C:/lib/specs/VL53L5CX.sym.txt"})),+        verb_doc("add_body", "Add a 3D body to a footprint of a PcbLib: a STEP model, or an extruded box when no step is given. The bridge then re-aligns bodies onto the pads (normalize_bodies) and returns a frame.",+            json!({"libraryPath": "path to a .PcbLib (required)", "footprintName": "string - omit for the FIRST footprint", "step": "Windows path to a .step - optional", "bwMil": "int, default 100 - box width", "bhMil": "int, default 100 - box depth", "hMil": "int, default 40 - box height", "soMil": "int, default 0 - standoff", "timeout": "seconds, default 90"}),+            json!({"data": "{footprint, body, bodiesNow, bodiesOnCanvas}", "normalized": "string - the shifts applied", "shot": "{image: base64 png, width, height}"}), 90,+            "altium_save {filePath: libraryPath}, then altium_show_3d_chip to look at it. Never engrave the MPN into the STEP.",+            &["normalize_bodies", "show_3d_chip", "pcb_report", "import_part"],+            &["It ADDS a body every call: check data.bodiesNow, a second call leaves two.", "Without footprintName it acts on the first footprint in the library.", "'could not load model' = the STEP path is not on THIS machine: stage it with send_files."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX", "step": "C:/lib/VL53L5CX.step"})),+        verb_doc("add_models", "Attach one or more PCBLIB footprint models, parameters, comment and description to a SchLib component (Altium's several-footprints-per-symbol mechanism).",+            json!({"libraryPath": "path to a .SchLib (required)", "symbolName": "string (required)", "models": "[{name, pcblib, description}]", "params": "{key: value}", "comment": "string", "description": "string", "timeout": "seconds, default 120"}),+            json!({"data": "{libref, models: int, params: int}", "shot": "{image: base64 png, width, height}"}), 120,+            "Check data.models is what you sent, then altium_save. To REPLACE the footprint use altium_set_models.",+            &["set_models", "set_params", "symbol_prims", "remove_symbol"],+            &["NOT idempotent: every call APPENDS another implementation. Repair = altium_set_models, or altium_remove_symbol + re-import.", "';' and '|' inside a description are rewritten (they are the spec separators).", "altium_read_symbol does not report models; use altium_symbol_prims {models:true}."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX", "models": [{"name": "VL53L5CX", "pcblib": "C:/lib/Adom_ToF.PcbLib", "description": "LGA-16 6.4x3.0"}], "params": {"Manufacturer Part Number": "VL53L5CXV0GC/1"}})),+        verb_doc("normalize_bodies", "Move every 3D body of a PcbLib's footprints (or one footprint) onto its pad cluster. A body added through the canvas lands 50000 mil from the pads in the stored frame, which makes Zoom All frame 50 inches of nothing.",+            json!({"libraryPath": "path to a .PcbLib - optional; opened first", "footprintName": "string - optional filter", "timeout": "seconds, default 120"}),+            json!({"data": "{moved: int, shifts: string}", "roundtripMs": "int"}), 120,+            "altium_add_body already runs it. Use it on libraries built elsewhere when altium_show_footprint shows an empty canvas; then altium_save.",+            &["add_body", "pcb_report", "show_3d_chip", "save"], &["Without libraryPath it acts on whatever PcbLib is current ('no PCB library is current' otherwise).", "It centres bodies on the pad-cluster centre: a deliberately offset body is moved too."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib"})),+        verb_doc("set_params", "Set parameters on a SchLib component, optionally its comment, and optionally link a footprint by name.",+            json!({"libraryPath": "path to a .SchLib (required)", "symbolName": "string - omit for the FIRST component", "params": "{key: value} or 'Key=Value;Key=Value'", "footprint": "string - PcbLib footprint name to link", "comment": "string", "timeout": "seconds, default 90"}),+            json!({"data": "{component, paramsSet: int, footprint}", "roundtripMs": "int"}), 90,+            "Unsaved: altium_save {filePath: libraryPath}. 'LCSC Part #' and 'Manufacturer Part Number' are the JLC/BOM keys. altium_read_symbol shows the result.",+            &["read_symbol", "set_visibility", "set_models", "save"],+            &["footprint APPENDS an implementation on every call; use altium_set_models to end up with exactly one.", "Parameters are created HIDDEN (visible ones pile up over the symbol): altium_set_visibility to show one.", "Without symbolName it edits the first component in the library."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX", "params": {"LCSC Part #": "C2888092", "Manufacturer": "ST"}, "comment": "VL53L5CX"})),+        verb_doc("set_visibility", "Show or hide one named parameter on a SchLib component.",+            json!({"libraryPath": "path to a .SchLib - optional; opened first", "symbolName": "string", "param": "string - the parameter name", "visible": "bool, default true", "timeout": "seconds, default 90"}),+            json!({"data": "{param, visible: '1'|'0'}", "roundtripMs": "int"}), 90,+            "altium_save afterwards; altium_show_symbol to see where the text landed.",+            &["set_params", "read_symbol", "show_symbol"], &["'parameter not found' = the name must match a parameter altium_read_symbol lists.", "Visible parameters stack over the symbol body: show few."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX", "param": "Manufacturer Part Number", "visible": true})),+        verb_doc("read_symbol", "Read a SchLib component back: description, display-mode count, the pins of mode 0 (designator, name) and every parameter with its hidden flag.",+            json!({"libraryPath": "path to a .SchLib - optional; opened first", "symbolName": "string", "timeout": "seconds, default 90"}),+            json!({"data": "{libref, description, displayModeCount, pinsMode0: int, pins: [{des, name}], params: [{name, value, hidden}]}", "roundtripMs": "int"}), 90,+            "The verification step after altium_import_part / altium_set_params. For the drawing and the footprint models use altium_symbol_prims.",+            &["symbol_prims", "set_params", "list_library", "show_symbol"], &["Footprint models are NOT reported here (not a missing link): altium_symbol_prims {models:true}.", "Only mode 0 pins are listed."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX"})),+        verb_doc("clone_symbol", "Copy a symbol inside the current SchLib under a new name, all display modes included.",+            json!({"libraryPath": "path to a .SchLib - optional; opened first", "from": "string - existing LibReference", "to": "string - new LibReference", "timeout": "seconds, default 90"}),+            json!({"data": "{from, to, primitives: int, displayModeCount}", "roundtripMs": "int"}), 90,+            "Then altium_set_params on the clone (its own MPN and comment) and altium_save.",+            &["rename_symbol", "remove_symbol", "set_params", "save"], &["'target already exists' - pick another name or altium_remove_symbol first.", "Same library only; there is no cross-library symbol copy."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "from": "VL53L5CX", "to": "VL53L7CX"})),+        verb_doc("rename_symbol", "Rename a component (LibReference) in the current SchLib.",+            json!({"libraryPath": "path to a .SchLib - optional; opened first", "from": "string", "to": "string", "timeout": "seconds, default 90"}),+            json!({"data": "{renamed: string}", "roundtripMs": "int"}), 90,+            "altium_save, then altium_list_library to confirm.",+            &["clone_symbol", "rename_footprint", "list_library", "save"], &["'to' is NOT checked: an empty or already-used name is applied as given. altium_list_library first."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "from": "Component_1", "to": "VL53L5CX"})),+        verb_doc("rename_footprint", "Rename a footprint in the current PcbLib.",+            json!({"libraryPath": "path to a .PcbLib - optional; opened first", "from": "string", "to": "string", "timeout": "seconds, default 90"}),+            json!({"data": "{renamed: string}", "roundtripMs": "int"}), 90,+            "altium_save, then re-link symbols that referenced the old name: altium_set_models {footprint: <new name>}.",+            &["copy_footprint", "rename_symbol", "set_models", "save"], &["'to' is NOT checked: an empty or already-used name is applied as given.", "Symbols link footprints BY NAME: a rename silently breaks their models."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "from": "PCBComponent_1", "to": "VL53L5CX"})),+        verb_doc("remove_symbol", "Delete one component from the current SchLib.",+            json!({"libraryPath": "path to a .SchLib - optional; opened first", "symbolName": "string", "timeout": "seconds, default 60"}),+            json!({"data": "{removed: string}", "roundtripMs": "int"}), 60,+            "The repair step for a symbol with duplicate models: remove, altium_import_part again, altium_save.",+            &["import_part", "cleanup_libs", "list_library", "save"], &["No undo through the bridge; the library is unsaved until altium_save, so altium_close_document without saving is the way back."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "symbolName": "VL53L5CX"})),+        verb_doc("remove_footprint", "Delete one footprint from the current PcbLib.",+            json!({"libraryPath": "path to a .PcbLib - optional; opened first", "footprintName": "string", "timeout": "seconds, default 90"}),+            json!({"data": "{removed: string}", "roundtripMs": "int"}), 90,+            "altium_save afterwards. Symbols that linked it keep a dangling model: altium_set_models.",+            &["remove_symbol", "cleanup_libs", "list_library", "save"], &["No undo through the bridge; closing the library without saving is the way back."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX"})),+        verb_doc("copy_footprint", "Duplicate a footprint inside the current PcbLib under a new name (every primitive replicated).",+            json!({"libraryPath": "path to a .PcbLib - optional; opened first", "from": "string", "to": "string", "timeout": "seconds, default 90"}),+            json!({"data": "{from, to, primitives: int}", "roundtripMs": "int"}), 90,+            "For a footprint from ANOTHER library use altium_xcopy_footprint. Then altium_save.",+            &["xcopy_footprint", "rename_footprint", "pcb_report"], &["'target already exists' - choose another name or altium_remove_footprint first."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "from": "VL53L5CX", "to": "VL53L5CX_HANDSOLDER"})),+        verb_doc("xcopy_footprint", "Copy a footprint ACROSS libraries: from fromLib into toLib (opened and made current), optionally under a new name.",+            json!({"fromLib": "path to the source .PcbLib", "from": "string - footprint name in the source", "toLib": "path to the destination .PcbLib - optional; else the current PcbLib", "to": "string - default: same as from", "timeout": "seconds, default 120"}),+            json!({"data": "{to, primitives: int}", "roundtripMs": "int"}), 120,+            "altium_pcb_report {path: toLib, name} to confirm pads and bodies arrived, then altium_save {filePath: toLib}.",+            &["copy_footprint", "pcb_report", "save"], &["'open the destination PcbLib first' = pass toLib.", "'target already exists' in the destination: pass a different to."],+            json!({"fromLib": "C:/lib/Vendor.PcbLib", "from": "LGA-16", "toLib": "C:/lib/Adom_ToF.PcbLib", "to": "VL53L5CX"})),+        verb_doc("swap_footprint", "Replace the FIRST component on the current board with a footprint from a PcbLib at the same position, re-assigning nets by pad designator. Tracks are untouched, so the wiring survives.",+            json!({"pcbPath": "path to the .PcbDoc - optional; opened first", "libraryPath": "path to the .PcbLib holding the new footprint", "footprintName": "string", "assigns": "'padDes=NET;...'", "timeout": "seconds, default 240"}),+            json!({"data": "{placed, padsOnComp, bodiesOnComp}", "windowEtiquette": "background"}), 240,+            "Built for single-part test boards (altium_test_board): iterate on a footprint and re-prove with altium_show_3d_board. altium_save the board afterwards.",+            &["test_board", "show_3d_board", "footprint_primitives"], &["There is NO designator argument: on a multi-part board it replaces whichever component the iterator returns first.", "Nets come ONLY from assigns (padDes=NET, all pads sharing a designator get the net): pads you do not list end up with no net."],+            json!({"pcbPath": "C:/proj/tof.PcbDoc", "libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX", "assigns": "1=GND;2=VDD"})),+        verb_doc("symbol_mode", "Show one display mode (0 = Normal, 1.. = alternates) of the CURRENT SchLib component in the editor and capture a frame. Drives the editor's own mode toggle, because setting DisplayMode alone does not repaint.",+            json!({"libraryPath": "path to a .SchLib - optional; opened first", "mode": "int, default 0", "timeout": "seconds, default 60"}),+            json!({"data": "{mode, displayModeCount, libref}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 60,+            "Select the component first with altium_show_symbol {libraryPath, symbolName}, then one call per mode up to displayModeCount-1.",+            &["show_symbol", "build_symbol_modes", "read_symbol"], &["There is no symbolName argument: it acts on the component current in the editor.", "Leave it on mode 0 when done: the editor keeps showing the last mode to the user."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib", "mode": 1})),+        verb_doc("cleanup_libs", "Remove the factory-default stray every fresh library carries: Component_1 from the current SchLib and an empty (no pads) PCBComponent_1 from the current PcbLib.",+            json!({"libraryPath": "path to a .SchLib/.PcbLib - optional; opened first", "timeout": "seconds, default 90"}),+            json!({"data": "{removed: int}", "roundtripMs": "int"}), 90,+            "Run once per new library after the first import, then altium_save and altium_list_library.",+            &["list_library", "remove_symbol", "remove_footprint", "save"], &["The SchLib side removes ANY component named Component_1 without checking that it is empty.", "removed:0 is normal on a library that was already clean."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib"})),+        verb_doc("install_library", "Register a library file in Altium's Installed Libraries (IntegratedLibraryManager.InstallLibrary) so its parts appear in the Components panel.",+            json!({"libraryPath": "path to a .IntLib/.SchLib/.PcbLib/.DbLib (required)", "timeout": "seconds, default 90"}),+            json!({"data": "{installed: path}", "roundtripMs": "int"}), 150,+            "Takes effect live, no restart. For a wiki bundle zip use altium_install_library_bundle (unpack + register + proof).",+            &["install_library_bundle", "list_library", "show_library"], &["'library not found' = the path must exist on THIS machine.", "Registers the file where it is: moving or deleting it later leaves a dead entry."],+            json!({"libraryPath": "C:/lib/Adom_ToF.SchLib"})),+        verb_doc("compile_intlib", "Compile a .LibPkg into an integrated library (.IntLib).",+            json!({"libpkgPath": "path to a .LibPkg (required)", "output": "destination directory", "timeout": "seconds, default 300"}),+            json!({"data": "{libpkg, outputDir}", "roundtripMs": "int"}), 300,+            "Then altium_install_library {libraryPath: <the .IntLib>}. Check the file exists: the reply does not verify it was written.",+            &["extract_intlib", "install_library"], &["The LibPkg is opened as a project in the user's workspace and stays open.", "success only means the compile was dispatched; verify the .IntLib on disk."],+            json!({"libpkgPath": "C:/lib/Adom_ToF.LibPkg", "output": "C:/lib/out"})),+        verb_doc("extract_intlib", "Extract the editable .SchLib/.PcbLib sources from a compiled .IntLib (Altium writes them next to the IntLib and opens them).",+            json!({"intlibPath": "path to an .IntLib (required)", "timeout": "seconds, default 300"}),+            json!({"data": "{intlib, sources: [path]}", "roundtripMs": "int"}), 300,+            "Then altium_list_library / altium_read_symbol on the extracted sources.",+            &["compile_intlib", "list_library", "install_library"], &["sources[] lists EVERY .SchLib/.PcbLib in any open project, not only the ones this call extracted."],+            json!({"intlibPath": "C:/lib/Vendor.IntLib"})),+        verb_doc("place_on_pcb", "Place ONE footprint from a PcbLib on a new board and return a proof frame. The single-part predecessor of altium_test_board.",+            json!({"libraryPath": "path to a .PcbLib (required)", "footprintName": "string", "pcbPath": "path to save the new .PcbDoc - optional", "timeout": "seconds, default 90"}),+            json!({"data": "{pcb, placed, libFile, libBodies, primitives, padsOnComp, bodiesOnComp}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 90,+            "Prefer altium_test_board (outline, several parts, nets). bodiesOnComp:0 with libBodies>0 means the body did not copy: report it.",+            &["test_board", "place_on_sch", "show_3d_board"], &["Every call creates a NEW PcbDoc; without pcbPath it exists only unsaved.", "'footprint not found in PcbLib' - names from altium_list_library."],+            json!({"libraryPath": "C:/lib/Adom_ToF.PcbLib", "footprintName": "VL53L5CX", "pcbPath": "C:/proj/one.PcbDoc"})),+        // ---- licence / account ----+        verb_doc("license_status", "Plain-language answer to 'what Altium licence am I on?': the state from the window title plus a capture of the License Management page.", json!({}),+            json!({"running": "bool", "licensed": "bool | null", "licenceState": "string", "seatStuck": "'possible' | null", "summary": "string", "shot": "{image: base64 png, ...}"}), 60,+            "Read shot.image for the product row (Trial/Full, days left, seat n/n): that lives ONLY on the page. Not licensed -> altium_ensure_licensed.",+            &["ensure_licensed", "license", "instances"],+            &["It brings up the License Management page inside Altium (when the agent is resident), replacing the view the user had.", "One On-Demand seat serves every Altium on the account: 'no seats available' usually means ANOTHER running Altium holds it - altium_close that one."], json!({})),+        verb_doc("ensure_licensed", "One call to get a usable Altium: launch if needed, then claim a seat (Use License via UIA). Optional escalations: sign out to force a seat release, or start the free-trial flow.",+            json!({"launchWaitSec": "int, default 240", "allowSignInReset": "bool - if no seat is free, Sign Out (the human must sign back in)", "allowTrial": "bool - if no seat is free, open the trial flow in the browser"}),+            json!({"alreadyLicensed": "bool", "licenceState": "string", "steps": "[..]", "summary": "string", "needsHuman": "string - only for browser sign-in / trial activation", "seatStuck": "bool", "shot": "{..}"}), 300,+            "success -> drive. errorCode no_seat_available: find the Altium holding the seat (altium_instances here, or another machine) and altium_close it, then call again.",+            &["license", "license_status", "instances", "close"],+            &["One On-Demand seat serves EVERY Altium on the account. A KILLED Altium strands the seat (page shows Seats 0/1 while every claim fails): altium_close releases it, waiting does not.",+              "allowSignInReset:true signs the user OUT and ends with needsHuman (browser sign-in). Use it only when nothing else holds the seat.",+              "It may launch Altium, which raises Altium over the user's work once."],+            json!({})),+        verb_doc("setup_trial", "Onboarding state machine: from nothing to a working trial. Each call reports the stage it found (install | launch | signin | done) and the ONE next action; human steps are named, not faked.", json!({}),+            json!({"stage": "install | launch | signin | done", "summary": "string", "nextAction": "string", "needsHuman": "string - when a person must act", "licenceState": "string"}), 300,+            "Follow nextAction and call again until stage is done. Not installed -> altium_install (the staged install verb; its licence stage does the clicks itself).",+            &["install", "ensure_licensed", "signin_status", "launch"],+            &["Signed in but unlicensed, it starts the free-trial flow in the BROWSER and answers trial_needs_browser: a person names the workspace, accepts terms and clicks Activate.", "stage launch answers success:true without launching anything: call altium_launch yourself."], json!({})),+        verb_doc("signin", "Report-only alias of altium_signin_status: it does NOT sign anyone in. Signing in to Altium is a browser flow only the human can complete.", json!({}),+            json!({"running": "bool", "licensed": "bool | null", "licenceState": "string", "workspaceConnected": "bool", "workspaceMenu": "string", "currentUserProcess": "object - only when the agent is resident"}), 300,+            "To see WHO is signed in: altium_license {action:'show'} and read the page ('Sign Out' visible = already signed in). Not connected to a workspace -> altium_connect_workspace.",+            &["signin_status", "license", "connect_workspace", "install"], &["Despite the name there are no credential arguments and nothing is typed or clicked.", "Never ask the user for their Altium password: the browser flow is theirs."], json!({})),+        verb_doc("signin_status", "Account and Altium 365 workspace connection as Altium's own UI reports it: licence state from the title, and whether the workspace menu reads 'Not Connected'.", json!({}),+            json!({"running": "bool", "licensed": "bool | null", "licenceState": "string", "workspaceConnected": "bool", "workspaceMenu": "string", "workspace": "null - the name is not read", "currentUserProcess": "object - only when the agent is resident"}), 60,+            "workspaceConnected:false -> altium_connect_workspace opens the picker. The signed-in account name is on the License Management page: altium_license {action:'show'}.",+            &["connect_workspace", "license", "license_status"], &["workspaceConnected is inferred from the ABSENCE of a 'Not Connected' menu item: a UIA miss reads as connected."], json!({})),+        verb_doc("connect_workspace", "Open Altium's workspace picker (the 'Not Connected' menu) and return a screenshot with a coordMap. The list is a custom control with no UIA children, so the caller clicks the row.", json!({}),+            json!({"picker": "{hwnd, title, className: TWorkspacesForm, rect}", "shot": "ab desktop_screenshot_window reply: image + coordMap/shotId", "windowEtiquette": "background"}), 60,+            "Click the wanted row with ab's desktop_click {space:'image', shotId, x, y} straight away, then altium_signin_status to confirm.",+            &["signin_status", "a365_status", "license"], &["picker_not_open usually means Altium is ALREADY connected (the menu shows the workspace name).", "The picker is a popup: it closes on focus loss, so click before doing anything else.", "A workspace with no free Author seat connects view-only: new documents then fail with view_only_no_author_seat."], json!({})),+        // ---- Altium 365 (cloud lane: works with Altium closed) ----+        verb_doc("a365_configure", "Store the Altium 365 workspace domain and, optionally, a Bearer token created by hand in the workspace web UI (Admin - Developer). Local-only; tokens never appear in replies.",+            json!({"workspace": "string - e.g. acme.365.altium.com (required unless already stored)", "token": "string - optional developer token"}),+            json!({"workspace": "string", "hasToken": "bool"}), 15,+            "Then altium_a365_status to prove the token is live. Preferred over a manual token: altium_a365_oauth_begin {clientId, workspace}.",+            &["a365_status", "a365_oauth_begin", "a365_query"], &["The token is written to a365.json in the bridge cache: altium_uninstall deletes it, altium_a365_logout removes it.", "Never echo a token back to the user or into a log."],+            json!({"workspace": "acme.365.altium.com"})),+        verb_doc("a365_oauth_begin", "Start the Altium 365 OAuth (PKCE + ActionWait) sign-in: the bridge starts listening FIRST, then returns the authorize URL for the user's browser. Works with Altium closed.",+            json!({"clientId": "string - an Altium-issued public OAuth client id (or the stored one)", "workspace": "string - e.g. acme.365.altium.com (or the stored one)", "selectWorkspace": "strict (default) | optional | none", "timeout": "int seconds, default 300 - how long to wait for the consent"}),+            json!({"status": "waiting", "workspace": "string", "authorizeUrl": "string"}), 30,+            "Open authorizeUrl in the user's EXISTING browser only after this returns, then poll altium_a365_oauth_status until status is authorized.",+            &["a365_oauth_status", "a365_oauth_cancel", "a365_status"],+            &["a365_client_id_required: OAuth clients come from Altium's Developer Center enrollment. Never reuse a first-party client id.", "a365_oauth_in_progress: one flow at a time - altium_a365_oauth_cancel the old one first.", "The consent page is the human's step; tokens never appear in any reply."],+            json!({"clientId": "<your Altium-issued client id>", "workspace": "acme.365.altium.com"})),+        verb_doc("a365_oauth_status", "State of the OAuth flow started by altium_a365_oauth_begin: idle | waiting | authorized | cancelled | error.", json!({}),+            json!({"status": "idle | waiting | authorized | cancelled | error", "workspace": "string", "claims": "object - unverified token claim summary (authorized)", "errorCode": "string (error)", "error": "string (error)"}), 10,+            "Poll every few seconds while waiting. authorized -> altium_a365_library_overview proves the workspace-scoped token. error -> read errorCode, then altium_a365_oauth_begin again.",+            &["a365_oauth_begin", "a365_oauth_cancel", "a365_library_overview"], &["success is false only for status error; waiting and idle are success:true.", "State lives in bridge memory: a bridge restart mid-flow reads idle."], json!({})),+        verb_doc("a365_oauth_cancel", "Cancel a waiting OAuth flow.", json!({}),+            json!({"status": "cancelling | <current status>", "alreadyStopped": "bool"}), 10,+            "Poll altium_a365_oauth_status until it reads cancelled, then altium_a365_oauth_begin again if wanted.",+            &["a365_oauth_begin", "a365_oauth_status"], &["The worker notices the cancel between long-polls (up to about 75 s)."], json!({})),+        verb_doc("a365_refresh", "Exchange the stored refresh token for a new access token (and store a rotated refresh token if one comes back).", json!({}),+            json!({"workspace": "string", "claims": "object - unverified token claim summary"}), 60,+            "Call when altium_a365_query answers a365_graphql_http 401 or altium_a365_status shows tokenLive:false, then retry.",+            &["a365_status", "a365_query", "a365_oauth_begin"], &["a365_refresh_not_configured = no OAuth grant stored (a manual developer token cannot be refreshed).", "a365_refresh_failed usually means the grant was revoked: altium_a365_oauth_begin again."], json!({})),+        verb_doc("a365_logout", "Revoke the refresh grant at Altium and remove every cached OAuth token. Workspace and client id stay configured.", json!({}),+            json!({"revoked": "bool - a remote revocation happened", "workspace": "string"}), 60,+            "altium_a365_status afterwards should read hasToken:false. Sign in again with altium_a365_oauth_begin.",+            &["a365_status", "a365_oauth_begin", "uninstall"], &["If the remote revocation fails (a365_revoke_failed / a365_network_error) the LOCAL tokens are kept on purpose: retry.", "This is the Altium 365 API grant only; it does not sign Altium Designer out or release a licence seat."], json!({})),+        verb_doc("a365_status", "Is the Altium 365 lane configured, is there a token, and is it LIVE (probed with a one-row GraphQL query)?", json!({}),+            json!({"configured": "bool", "workspace": "string | null", "hasToken": "bool", "hasRefreshToken": "bool", "clientConfigured": "bool", "endpoint": "string - the GraphQL URL", "tokenLive": "bool", "probe": "object - probe data or error"}), 60,+            "hasToken:false -> altium_a365_oauth_begin (or altium_a365_configure {token}). tokenLive:false with hasRefreshToken -> altium_a365_refresh. Live -> altium_a365_library_overview / altium_a365_query.",+            &["a365_oauth_begin", "a365_refresh", "a365_query", "a365_configure"], &["Independent of Altium Designer: this lane works while X2.exe is closed and says nothing about the desktop licence seat."], json!({})),+        verb_doc("a365_query", "Run one GraphQL query or mutation against the workspace's GraphQL gateway (https://<workspace>/svc/napi/gateway/graphql/) with the stored token.",+            json!({"query": "string - GraphQL (required)", "variables": "object", "timeout": "int seconds, default 60"}),+            json!({"data": "object | null", "errors": "[..] | null"}), 60,+            "Discover fields first with altium_a365_library_schema (or https://<workspace>/api/voyager). a365_graphql_http / a365_network_error -> altium_a365_status, then altium_a365_refresh.",+            &["a365_library_schema", "a365_library_overview", "a365_refresh", "a365_files_upload"],+            &["success is false whenever the reply carries errors[], even with partial data.", "Mutations change the user's shared workspace for everyone: know the rollback/delete mutation before running one.", "a365_not_configured = no workspace or token stored."],+            json!({"query": "{ desProjects(first: 10) { nodes { name } } }"})),+        verb_doc("a365_files_upload", "Upload one local file to the workspace's Files Service and get a temporary opaque file id to pass to a library mutation.",+            json!({"filePath": "path on THIS machine (required)", "maxBytes": "int, default 104857600 (100 MB)", "timeout": "int seconds, default 120"}),+            json!({"fileId": "string - opaque", "expiresAfterHoursInactive": "24"}), 120,+            "Pass fileId straight into the matching mutation via altium_a365_query {query, variables}. Do not parse or persist it.",+            &["a365_query", "a365_library_schema"], &["a365_file_not_found: the path must be on the bridge's machine - stage it with send_files.", "The id expires after 24 h of inactivity."],+            json!({"filePath": "C:/lib/Adom_ToF.SchLib"})),+        verb_doc("a365_library_overview", "One fixed read-only query: the workspace library's name, description, update time, component ids/names and footprint/symbol totals.", json!({}),+            json!({"data": "{desLibrary: {name, description, updatedAt, components: {nodes: [{id, name}]}, footprints: {totalCount}, symbols: {totalCount}}}", "errors": "[..] | null"}), 60,+            "The proof that a fresh OAuth token is workspace-scoped. Then altium_a365_library_schema before attempting any mutation.",+            &["a365_status", "a365_library_schema", "a365_query"], &["Same error codes as altium_a365_query (a365_not_configured, a365_graphql_http, a365_network_error)."], json!({})),+        verb_doc("a365_library_schema", "Read-only schema map: the GraphQL queries and mutations whose names mention lib, component, symbol, footprint, part or datasheet, with mutation argument types.",+            json!({"timeout": "int seconds, default 60"}),+            json!({"queries": "[{name, description}]", "mutations": "[{name, description, args: [{name, type}]}]"}), 60,+            "Pick the mutation, understand its input types AND its rollback/delete counterpart, upload inputs with altium_a365_files_upload, then altium_a365_query.",+            &["a365_query", "a365_files_upload", "a365_library_overview"], &["A name filter over introspection: a relevant field named differently is not listed - the full schema is at https://<workspace>/api/voyager."], json!({})),+    ];+    json!({"success": true, "bridge": "altium", "engine": "rust",+        "facts": ["one script at a time; a modal freezes everything",+                  "never foreground Altium; proofs are captured in the background",+                  "one On-Demand seat serves every Altium app on the machine; killing Altium strands it",+                  "instance:<pid|key|word> targets one of several running Altiums",+                  "agent-backed verbs bootstrap the resident agent on demand (~8 s) and answer {success, data:{ok,...}, roundtripMs}; the loop exits after 5 min idle",+                  "errorCode agent_busy names the command the agent is still running (busyWith, busyForSec): wait and retry",+                  "errorCode agent_script_error = a DelphiScript runtime error the bridge ALREADY cleared (read errorShot); do not resend the same command",+                  "errorCode bad_number = a caller value that must be numeric was not",+                  "errorCode view_only_no_author_seat = another running Altium holds the account's single seat; close it cleanly, then close + launch this one",+                  "errorCode blocked_by_dialog = a modal on the main window froze scripting: read dialogs[].shot, altium_dismiss_dialogs, retry"],+        "verbs": verbs,+        "allVerbs": names()}) }  fn need_licensed() -> Option<Value> {@@ -296,6 +901,15 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         }         "dismiss_dialogs" => {             let button = s(args, "button").unwrap_or_else(|| "OK".into());+            // {hwnd, button} is what every hint in this bridge tells callers to pass; it was IGNORED+            // and the button was pressed on EVERY dialog, save prompts included (found 2026-09-21).+            if let Some(hw) = args.get("hwnd").and_then(|h| h.as_i64()) {+                let r = dialogs::press(hw, &button);+                let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false);+                return json!({"success": ok, "pressed": [{"hwnd": hw, "button": button, "ok": ok}],+                    "errorCode": if ok { Value::Null } else { json!("button_not_pressed") },+                    "_hint": if ok { "Pressed on that one dialog only. altium_check_dialogs to confirm it is gone." } else { "UIA could not press that button on that window: read dialogs[].buttons from altium_check_dialogs for the exact label." }});+            }             let titles: Option<Vec<String>> = args.get("titles").and_then(|a| a.as_array())                 .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());             dialogs::dismiss(&button, titles.as_deref())@@ -596,3 +1210,33 @@ fn cross_check(args: &Value) -> Value {     out["success"] = json!(true);     out }++#[cfg(test)]+mod describe_tests {+    use super::*;++    /// The SDK rule: EVERY verb declares a hint, related verbs, and an example. A verb added to+    /// KNOWN without a verb_doc entry, or a `related` name that is not a real verb, fails here.+    #[test]+    fn every_known_verb_is_documented_once_with_real_related_verbs() {+        let d = describe();+        let docs = d["verbs"].as_array().expect("verbs[]");+        let all = names();+        let mut seen = std::collections::BTreeSet::new();+        for v in docs {+            let name = v["name"].as_str().unwrap_or("").to_string();+            assert!(all.contains(&name), "{name} is documented but not in KNOWN");+            assert!(seen.insert(name.clone()), "{name} is documented twice");+            assert!(!v["summary"].as_str().unwrap_or("").is_empty(), "{name}: empty summary");+            assert!(!v["hint"].as_str().unwrap_or("").is_empty(), "{name}: empty hint");+            assert!(v["timeoutSeconds"].as_u64().unwrap_or(0) > 0, "{name}: no timeout");+            for r in v["related"].as_array().expect("related[]") {+                let r = r.as_str().unwrap_or("").to_string();+                assert!(all.contains(&r), "{name}: related {r} is not a verb");+                assert!(r != name, "{name}: related to itself");+            }+        }+        let missing: Vec<&String> = all.iter().filter(|n| !seen.contains(*n)).collect();+        assert!(missing.is_empty(), "verbs with no verb_doc entry: {missing:?}");+    }+}
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.32",+  "version": "0.2.33",   "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",
page.json+1−1
@@ -140,4 +140,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}