← Commit history
CHANGELOG.md+7
@@ -1,5 +1,12 @@ # Changelog +## 0.1.17 — 2026-09-01+- **New verb `altium_layers_in_use`: how many layers the board ACTUALLY has.** Until now the only layer answer was "48", which is every copper layer Altium CAN address, not a property of the board. Altium's in-stack flag has no identifier proven on AD26 and a wrong guess halts the engine behind a modal, so the agent's new `layer_occupancy` counts primitives by layer in ONE pass (using only identifiers already shipped in this unit) and the bridge filters that tally to the copper layers. Verified live: LAN7800_test reports **1 copper layer in use (Top Layer)**, 7 layers carrying primitives, against 48 addressable.+- Read the result as a FLOOR: an inner plane carrying no primitives is invisible to a primitive count. The reply says so, and `copperLayersAddressable` is labelled as a capability rather than a board property.+- `read_board` gains the `occupancy` section (default on).+- The first cut of this view forgot to filter to copper and cheerfully reported a MECHANICAL layer (296 primitives) as the board's busiest layer. Now covered by a unit test against that exact shape.+- 96 verbs.+ ## Unreleased — bridge-rs only (no change to the shipped Python runtime) - **`check_dialogs` no longer sees other applications' windows.** The first cut matched window TITLES containing "Error"/"Warning" and picked up four *"RemoteApp security warning"* windows on John's desktop that had nothing to do with Altium — which `dismiss_dialogs` would then have clicked OK on. Pressing buttons in someone else's application is exactly what this bridge must never do. Now scoped by PID: `process_list` for Altium's pids, `desktop_enum_windows {pids}` for only those windows, then class + "not the main window". Also 300× faster (one enumeration instead of six title searches): **1 ms**. - Note for the Python bridge: it was never affected — `handlers/dialogs.py` has always attributed dialogs by pid.
adom-bridge-altium-manifest.json+4−4
@@ -1,9 +1,9 @@ {   "name": "altium",-  "version": "0.1.16",-  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.16/adom-bridge-altium-v0.1.16.zip",-  "sha256": "894e07d34e0182a0c730fd79f1d9b74b1836d1514f433a722d442e3fd3447deb",-  "size": 105131,+  "version": "0.1.17",+  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.17/adom-bridge-altium-v0.1.17.zip",+  "sha256": "b732f4be98cb86443f9665c0ad2329e59b8609296e35f79c68b705abedc5c758",+  "size": 106316,   "verbPrefixes": [     "altium_"   ],
adom-bridge-altium-v0.1.17.zipadded
⋯ 1 unchanged line ⋯
bridge-rs/src/board.rs+57
@@ -99,6 +99,35 @@ impl BoardData {         json!(kinds)     } +    /// Every layer carrying a pad/track/arc, densest first — copper AND silkscreen AND mechanical.+    /// The agent tallies primitives by layer in one pass; this is the raw occupancy.+    pub fn layers_with_primitives(&self) -> Vec<Value> {+        let mut v = self.array("occupancy", "occupancy");+        v.sort_by_key(|l| std::cmp::Reverse(l.get("primitives").and_then(|p| p.as_i64()).unwrap_or(0)));+        v+    }++    /// The layer ids Altium reports as COPPER (top + mid + internal planes + bottom). Taken from+    /// the dump's own `copper` section rather than assumed from id ranges — the ranges are an+    /// implementation detail of the enum and not something to hardcode.+    fn copper_layer_ids(&self) -> std::collections::BTreeSet<i64> {+        self.copper_layers().iter().filter_map(|l| l.get("id").and_then(|i| i.as_i64())).collect()+    }++    /// COPPER layers that actually carry copper, densest first — the honest answer to "how many+    /// layers is this board".+    ///+    /// Two caveats the caller must not lose: an inner plane with no primitives is invisible here+    /// (so this is a floor, not the stack), and the first cut of this view forgot to filter by+    /// copper at all and cheerfully reported silkscreen and mechanical layers as board layers.+    pub fn copper_layers_in_use(&self) -> Vec<Value> {+        let copper = self.copper_layer_ids();+        self.layers_with_primitives()+            .into_iter()+            .filter(|l| l.get("layerId").and_then(|i| i.as_i64()).map(|i| copper.contains(&i)).unwrap_or(false))+            .collect()+    }+     /// One-screen summary: what is on this board.     pub fn summary(&self) -> Value {         let comps = self.components();@@ -117,6 +146,12 @@ impl BoardData {             // 16 planes + bottom), not the stack actually in use — there is no "in stack" flag in             // the read yet, so this is a capability count, not a layer count. Do not report it to             // a user as "this board has N layers".+            // layers actually carrying copper (a floor — an empty inner plane is invisible here);+            // `copperLayersAddressable` is what Altium CAN address, which is not a board property+            "copperLayersInUse": self.copper_layers_in_use().len(),+            "copperLayersInUseNames": self.copper_layers_in_use().iter()+                .filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect::<Vec<_>>(),+            "layersWithPrimitives": self.layers_with_primitives().len(),             "copperLayersAddressable": self.copper_layers().len(),             "rules": rules.len(),             "rulesEnabled": rules.iter().filter(|r| r.get("enabled").and_then(|b| b.as_bool()) != Some(false)).count(),@@ -219,6 +254,28 @@ mod tests {         assert!(by.get("Clearance").is_some());     } +    #[test]+    fn copper_layers_in_use_excludes_silkscreen_and_mechanical() {+        // measured on a live board: the raw tally is dominated by a MECHANICAL layer+        // (Adom.SolderBalls, 296 prims) — reporting that as a board layer would be wrong+        let d = BoardData::new(json!({+            "copper": {"ok": true, "layers": [{"id": 1, "name": "Top Layer"}, {"id": 32, "name": "Bottom Layer"}]},+            "occupancy": {"ok": true, "occupancy": [+                {"layerId": 65, "name": "Adom.SolderBalls", "primitives": 296},+                {"layerId": 1,  "name": "Top Layer",        "primitives": 51},+                {"layerId": 33, "name": "Top Overlay",      "primitives": 9},+                {"layerId": 32, "name": "Bottom Layer",     "primitives": 4}+            ]}+        }));+        assert_eq!(d.layers_with_primitives().len(), 4, "raw tally keeps everything");+        let cu = d.copper_layers_in_use();+        assert_eq!(cu.len(), 2, "only copper survives");+        assert_eq!(cu[0].get("name").unwrap(), "Top Layer", "densest copper first");+        let s = d.summary();+        assert_eq!(s.get("copperLayersInUse").and_then(|v| v.as_u64()), Some(2));+        assert_eq!(s.get("layersWithPrimitives").and_then(|v| v.as_u64()), Some(4));+    }+     #[test]     fn an_unknown_ordinal_is_labelled_not_invented() {         assert_eq!(rule_kind_name(0), "Clearance");
bridge-rs/src/verbs.rs+8−3
@@ -10,7 +10,7 @@ const KNOWN: &[&str] = &[     "list_designators", "component_data", "component_pins", "list_nets", "net_connections",     "layers", "copper_layers", "read_rules", "set_layer_visibility", "footprint_primitives", "schematic_data",     "agent_start", "agent_stop", "agent_recover", "launch", "close", "check_dialogs", "dismiss_dialogs",-    "read_board", "board_summary", "rules_by_kind",+    "read_board", "board_summary", "rules_by_kind", "layers_in_use",     "describe", "uninstall", ]; @@ -216,9 +216,9 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->             dialogs::dismiss(&button, titles.as_deref())         }         // ---- board-read primitive: ONE agent round trip, views shaped here -----        "read_board" | "board_summary" | "rules_by_kind" => {+        "read_board" | "board_summary" | "rules_by_kind" | "layers_in_use" => {             if let Some(g) = need_licensed() { return g; }-            let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules".into());+            let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules,occupancy".into());             let r = agent::call("read_board", &[("sections", sections), ("limit", s(args, "limit").unwrap_or("2000".into()))],                                 None, Duration::from_secs(timeout.max(120)));             let data = r.get("data").cloned().unwrap_or(Value::Null);@@ -237,6 +237,11 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->                                           "_hint": "One agent round trip; every view below is derived in the bridge."}),                 "rules_by_kind" => json!({"success": true, "board": d.board_path(), "rulesByKind": d.rules_by_kind(),                                           "roundtripMs": r.get("roundtripMs")}),+                "layers_in_use" => json!({"success": true, "board": d.board_path(),+                                          "copperLayersInUse": d.copper_layers_in_use(),+                                          "layersWithPrimitives": d.layers_with_primitives(),+                                          "roundtripMs": r.get("roundtripMs"),+                                          "_hint": "copperLayersInUse = COPPER layers carrying copper, densest first — a floor, not the stack (an empty inner plane is invisible). layersWithPrimitives additionally counts silkscreen and mechanical."}),                 _ => json!({"success": true, "board": d.board_path(), "summary": d.summary(),                             "designators": d.designators(), "nets": d.nets(), "layers": d.layers(),                             "copperLayers": d.copper_layers(), "rules": d.rules(),
bridge.json+2−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.1.16",+  "version": "0.1.17",   "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",@@ -67,6 +67,7 @@     "altium_install_library_bundle",     "altium_launch",     "altium_layers",+    "altium_layers_in_use",     "altium_license",     "altium_list_commands",     "altium_list_designators",
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.1.16",+  "version": "0.1.17",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.16+0.1.17
src/agent/AdomAgent.pas.tmpl+54−2
@@ -3605,6 +3605,56 @@ Begin         + '}'; End; +{ Which copper layers actually CARRY copper. The layer stack's "is this layer in the stack" flag has+  no identifier we have proven on this Altium build, and guessing one halts the engine behind a modal+  on the user's screen — so instead of asking the stack, count primitives. ONE pass over pads, tracks+  and arcs across all layers, tallied by Prim.Layer; every identifier here is already used elsewhere+  in this unit. An empty inner plane reports zero, which is why the caller must read this as+  "layers with copper on them", never as "the board's layer count". }+Function CmdLayerOccupancy(Lines : TStringList) : String;+Var+    Board : IPCB_Board;+    BIt   : IPCB_BoardIterator;+    Prim  : IPCB_Primitive;+    SL    : TStringList;+    S, K  : String;+    I, C, N : Integer;+Begin+    Board := PCBServer.GetCurrentPCBBoard;+    If Board = Nil Then+    Begin+        Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}';+        Exit;+    End;+    SL := TStringList.Create;+    N := 0;+    BIt := Board.BoardIterator_Create;+    BIt.AddFilter_ObjectSet(MkSet(ePadObject, eTrackObject, eArcObject));+    BIt.AddFilter_LayerSet(AllLayers);+    BIt.AddFilter_Method(eProcessAll);+    Prim := BIt.FirstPCBObject;+    While Prim <> Nil Do+    Begin+        K := IntToStr(Prim.Layer);+        C := StrToIntDef(SL.Values[K], 0);+        SL.Values[K] := IntToStr(C + 1);+        N := N + 1;+        Prim := BIt.NextPCBObject;+    End;+    Board.BoardIterator_Destroy(BIt);+    S := '';+    For I := 0 To SL.Count - 1 Do+    Begin+        If S <> '' Then S := S + ',';+        K := SL.Names[I];+        S := S + '{' + JRaw('layerId', K) + ',' ++            JStr('name', Board.LayerStack.LayerObject[StrToIntDef(K, 1)].Name) + ',' ++            JRaw('primitives', SL.Values[K]) + '}';+    End;+    SL.Free;+    Result := '{' + JRaw('ok','true') + ',' + JRaw('primitives', IntToStr(N)) + ',' + JRaw('occupancy', '[' + S + ']') + '}';+End;+ { ONE round trip to READ the whole board. Composed from the EXISTING readers on purpose: no new Altium   API surface, so it cannot introduce an undeclared identifier (which halts the engine behind a   modal). The bridge shapes these into typed views; recorded dumps are the unit-test fixtures. }@@ -3620,13 +3670,14 @@ Begin         Exit;     End;     Sects := Param(Lines, 'sections');-    If Sects = '' Then Sects := 'components,nets,layers,copper,rules';+    If Sects = '' Then Sects := 'components,nets,layers,copper,rules,occupancy';     S := '';     If Pos('components', Sects) > 0 Then S := S + ',' + JRaw('components', CmdComponentData(Lines));     If Pos('nets', Sects) > 0 Then S := S + ',' + JRaw('nets', CmdListNets(Lines));     If Pos('layers', Sects) > 0 Then S := S + ',' + JRaw('layers', CmdLayers(Lines));     If Pos('copper', Sects) > 0 Then S := S + ',' + JRaw('copper', CmdCopperLayers(Lines));     If Pos('rules', Sects) > 0 Then S := S + ',' + JRaw('rules', CmdReadRules(Lines));+    If Pos('occupancy', Sects) > 0 Then S := S + ',' + JRaw('occupancy', CmdLayerOccupancy(Lines));     Result := '{' + JRaw('ok','true') + ',' + JStr('board', Board.FileName) + ',' + JStr('sections', Sects) + S + '}'; End; @@ -3639,6 +3690,7 @@ Begin     Else If Cmd = 'eval' Then Result := CmdEval(Lines, Payload)     Else If Cmd = 'io_bench' Then Result := CmdIoBench(Lines)     Else If Cmd = 'read_board' Then Result := CmdReadBoard(Lines)+    Else If Cmd = 'layer_occupancy' Then Result := CmdLayerOccupancy(Lines)     Else If Cmd = 'lib_list' Then Result := CmdLibList(Lines)     Else If Cmd = 'lib_select' Then Result := CmdLibSelect(Lines)     Else If Cmd = 'compile' Then Result := CmdCompile(Lines)@@ -3689,7 +3741,7 @@ Begin         gRunning := False;         Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'agent loop stopping') + '}';     End-    Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,read_board') + '}';+    Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,read_board,layer_occupancy') + '}'; End;  { ------------------------------------------------------------------ loop --- }
src/server.py+37−1
@@ -1077,6 +1077,41 @@ def h_activate_document(args):     return r  ++def h_layers_in_use(args):+    """How many layers does this board actually have? — answered from primitives, not the stack.++    Altium's "is this layer in the stack" flag has no identifier proven on AD26, and a wrong guess+    halts the script engine behind a modal on the user's screen. So the agent tallies primitives by+    layer in ONE pass and this filters that tally to the layers Altium reports as copper.++    Read it as a FLOOR: an inner plane carrying no primitives is invisible here. `copperLayersAddressable`+    (48) is what Altium can address — never report that as a board property.++    NOTE: the same shaping exists in bridge-rs (`board.rs`). That duplication is deliberate and+    temporary — it goes away when the Rust bridge reaches parity and this runtime retires.+    """+    g = _need_licensed()+    if g:+        return g+    r = agent.call("read_board", {"sections": "copper,occupancy"}, timeout=float(args.get("timeout", 90)))+    data = r.get("data") or {}+    if not data.get("ok"):+        return r+    copper_ids = {l.get("id") for l in ((data.get("copper") or {}).get("layers") or [])}+    occupancy = sorted(((data.get("occupancy") or {}).get("occupancy") or []),+                       key=lambda l: -(l.get("primitives") or 0))+    copper = [l for l in occupancy if l.get("layerId") in copper_ids]+    return {"success": True, "board": data.get("board"),+            "copperLayersInUse": copper,+            "copperLayerCount": len(copper),+            "layersWithPrimitives": occupancy,+            "roundtripMs": r.get("roundtripMs"),+            "_hint": "copperLayersInUse = COPPER layers carrying copper, densest first. A floor, not the "+                     "stack: an empty inner plane is invisible. layersWithPrimitives also counts "+                     "silkscreen and mechanical layers."}++ # ---- licence lifecycle: status, ensure-licensed, trial setup (John 2026-08-31) ------------  def h_license_status(args):@@ -1643,6 +1678,7 @@ _VERB_CATALOG = {     "launch": "Start X2.exe and wait for the main window (~60s cold).",     "close": "WM_CLOSE the main window; reports a save prompt instead of answering it; force:true taskkills.",     "activate_document": "Make an ALREADY-OPEN document current by invoking its document-bar tab through UIA — no cursor, no foreground.",+    "layers_in_use": "How many layers the board ACTUALLY has: copper layers carrying primitives, densest first (a floor, not the stack).",     "agent_start": "Install + bootstrap the resident DelphiScript agent via X2 -R RunScript; waits for heartbeat.",     "agent_stop": "Drop the stop file; loop exits.",     "agent_status": "Heartbeat age, request count, workspace.",@@ -1697,7 +1733,7 @@ COMMAND_HANDLERS = {     "readiness": h_readiness, "status": h_status, "diagnostics": h_diagnostics,     "window_info": h_window_info, "state": h_state, "check_dialogs": h_check_dialogs,     "dismiss_dialogs": h_dismiss_dialogs, "launch": h_launch, "close": h_close,-    "activate_document": h_activate_document,+    "activate_document": h_activate_document, "layers_in_use": h_layers_in_use,     "agent_start": h_agent_start, "agent_stop": h_agent_stop, "agent_status": h_agent_status,     "agent_recover": h_agent_recover,     "eval": h_eval, "run_command": h_run_command, "run_script": h_run_script,