app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
skills/altium-bridge-dev/SKILL.mddeleted−214@@ -1,214 +0,0 @@-name: altium-bridge-dev-user-invocable: false-description: >-- DEVELOPER skill (never offered to everyday users) for working ON the altium-bridge: the- resident DelphiScript agent architecture (file-drop REPL, per-digest generations,- stale-agent auto-restart), the dialog pre-emption doctrine (the debugger breaks on ANY- exception, so a raise IS a modal), the transaction-hygiene rule (dangling PreProcess = "a- command is currently active"), the deploy loop (cross-compile bridge-rs + send_files the exe- into the bridge cache + bridge_kill; agent_stop before agent_start for template changes), and- the engine-halting identifier list. Read before editing agent/AdomAgent.pas.tmpl or- bridge-rs/src/. Trigger words — edit altium bridge, altium agent template, delphiscript- agent, deploy altium bridge, altium engine halt, undeclared identifier altium, bridge-rs.--# altium-bridge-dev--Everything you need to change the bridge without re-learning what this session paid for.--## Architecture--- **Resident DelphiScript agent** (`agent/AdomAgent.pas.tmpl`): bootstrapped once via- `X2.EXE -RScriptingSystem:RunScript(...StartAgent)`, then a polling loop over a file-drop- inbox (`request.txt` → `response_<id>.json` in the workspace). One script at a time; DO NOT- `run_script` while it is resident. `eval` runs STATEMENTS ONLY (no Var block).-- **Per-digest generations**: the tmpl is rendered to `AdomAgent_<digest>.pas`; the digest is- hashed from the RAW template (NOT the rendered source — that recursed and crashed the bridge- once). The heartbeat carries `gen`; `agent_start` auto-restarts a stale in-Altium loop.-- **The runtime is RUST (0.2.0+)**: `bridge-rs/` → `altium-bridge.exe`, `spawn.kind: "exe"`.- `src/server.py` and `src/handlers/` are the retired Python reference — behaviour questions can- still be answered there, but code changes go in `bridge-rs/src/` (verbs.rs KNOWN is the- dispatch of record; scripts/release.py lints bridge.json against it and refuses drift).-- **Deploy loop**: edit `bridge-rs/src/` → `cargo test && cargo build --release --target- x86_64-pc-windows-gnu` → `send_files` the exe into- `C:\Users\john\AppData\Local\Adom Bridge\bridges-cache\altium\` → `bridge_kill` (respawns the- exe). Template changes (`src/agent/AdomAgent.pas.tmpl`) additionally need- `altium_agent_stop` THEN `altium_agent_start` — bridge_kill alone leaves the in-Altium loop- running old code. TRAP from the 0.2.0 swap: after an in-place bridge_install, the OLD process- can keep squatting the runtime port while reporting the NEW version (it re-reads- BRIDGE_VERSION per request), so ab thinks the respawn happened — `process_list` for a- `server.py`/stale exe on the bridge port and kill it if the reply shape looks wrong.--## Deploying a big template (measured 2026-08-31)--`write_file` passes the content as a CLI argument; once `AdomAgent.pas.tmpl` passed ~130 KB it-failed with "Argument list too long" and NOTHING deployed (the reload then ran old code —-"Unknown command" for the new verbs, zero dialogs, easy to misread as a compile pass). Deploy-big files with `send_files {filePaths:[...]}` (streams; ignores destDir and lands in-`C:\Users\john\Downloads`) then `Move-Item` into `bridges-cache\altium\{agent\,}`.--## "Current document" semantics--`PCBServer.GetCurrentPCBBoard` / `SchServer.GetCurrentSchDocument` follow the ACTIVE document.-Opening a SchDoc makes board verbs answer "no current PCB board" until a PcbDoc is opened-again. Read verbs return that error with a `_hint`; order your calls, or open the right doc first.-Schematic iteration needs the sheet SHOWN (activated) — a background `open_document` can leave-`SchIterator` empty (measured: count 0 until `show_schematic`).--## Dialog pre-emption (John: a dismissed dialog is a defect)--The DelphiScript debugger breaks on ANY raised exception even inside Try/Except — a raise IS-an Error modal. So: `ReadAll` probes with `FileOpen` (returns -1, never raises) before-`LoadFromFile`. `TxClear` (an unbalanced `PostProcess`, harmless) runs at agent start, before-every save, and after every recover, because a command that halted between PreProcess and-PostProcess leaves "a command is currently active" and the next save draws the save-a-copy-Warning. When you add a mutating command, wrap file reads and never leave a transaction open.--## Engine-halting identifiers (all measured, all cost a recover)--`BottomXSize`/`MidXSize` (pad size props don't exist — Top covers a multi-layer pad),-`IPCB_BoardOutline.Clear`, `eProcessFreePrimitives`, `IPCB_Board.GroupIterator_Create` (use-`BoardIterator_Create`), `IPCB_Component.Name.Text :=` (use `NameOn`/`Inst.Name.MoveToXY`),-`GroupIterator` without `AddFilter_LayerSet(AllLayers)` (mech-layer children invisible),-`RunProcessWithParameters` (use ResetParameters+AddStringParameter+RunProcess), `IPCB_ObjectClass.SuperClass :=` (not declared in AD26 scripting — a new class from `PCBClassFactory` is already non-super; just omit it), a nested `{}`-inside a brace comment (breaks compilation). An unknown identifier is a RUNTIME error that-halts the debugger; `altium_agent_recover` clears it.--## The brace rule for JSON-building code--Result strings build JSON with `'{' + ... + '}'`; that's fine in a STRING LITERAL. It is only-BRACE COMMENTS that cannot contain a nested `{`. Lint new commands:-`re.sub(r"'[^']*'","",code)` then assert no `\{[^}]*\{`.--## Two sessions on one engine = a modal storm (measured 2026-08-31)--The resident loop is ONE script engine. When a second session (another Claude thread, a-delegated agent) also drives `altium_*` verbs, a halt raised by one session's command surfaces-as the OTHER session's error (it saw "Undeclared identifier: SuperClass/Name" on a read-only-verb that was innocent), both sessions then run `agent_recover`, and every recover's `-R`-helper puts the single-instance busy notice on the user's screen. Rule: exactly one driver-per Altium instance. Coordinate ownership (cross-session message) before any second session-touches the engine, and never delegate live-engine work without that handshake.--## Recover costs the user a modal — measured 2026-08-31 on John's screen--Each `agent_recover` fires `X2 -REditScript:Stop`; against a halted (busy) instance Windows raises the-single-instance notice ("There is another instance of running, but it is busy") on the USER's-screen, and 0.1.9's `_reap_busy_notice` did NOT catch it (twice). Until the reaper is proven:-never iterate a suspected-halting command live on the user's box — halt → recover → retry is a-modal per cycle. Eval-probe identifiers in a throwaway session, or ship the verb unwired.-`create_net_class` is parked unwired for exactly this reason (`SuperClass` halts; a second-identifier halts after it).--## When the bridge "vanishes" from ab--`bridge_list` without `altium` + `Unknown verb altium_*` = ab could not parse the cached `bridge.json`-(verbs[] must be strings — see the publish skill). ab's lifecycle log is-`%LOCALAPPDATA%\inc.adom.bridge\logs\Adom Bridge.log`; `bridges-cache\altium\server.out.log` only-covers a process that actually spawned.--## Verify like production fails--Frame-check EVERY generated look/variant (a 3-of-10 spot check shipped three defect classes).-Trust `altium_pcb_report` read-backs, not return codes. A timeout is usually success that-outran ab's 60s HTTP budget — poll/verify, don't blind-retry. Board frames are BLACK under a-locked Windows session (DirectX) — check `desktop_get_foreground` first.--## The Rust bridge (bridge-rs/) — status 2026-08-31--Decision (John): the bridge PROCESS moves to a Rust exe that (1) calls ab's verbs for everything-desktop-shaped (no Win32 in the bridge), (2) absorbs the DelphiScript agent's shaping logic so the-agent shrinks to generic read/apply primitives, (3) ships no MCP server (external agents use-`adom-bridge-cli`). Vertical slice VERIFIED live on AdomLapper: `/health` (Altium detected through-`desktop_find_window`, title parsed incl. the ADDevelop shape), `/verbs`, `/command` for status,-eval, run_command, open_document and the 11 Phase-1 introspection verbs — speaking the existing-agent's file-IPC protocol unchanged. Build: cross-compile from the container (`rustup` + target-`x86_64-pc-windows-gnu` + `mingw-w64` + host `gcc` for build scripts; linker pinned in-`.cargo/config.toml`); the box has cargo but no MSVC linker. Test manually on a spare port until-ab ships `spawn.kind: "exe"` (ab#987). Kill the running exe BEFORE `send_files` or the copy-silently fails (file lock).--**ab direct-API envelope (bit us):** every `/command` reply is `{error, output, status, success}`-with the verb's real result JSON-ENCODED AS A STRING in `output`. Unwrap it (`ab::unwrap_output`).-`desktop_find_window` returns `windows[]` + `best`, not `matches`.--**Latency:** each ab round trip is ~400-800 ms; never gate every verb on a live ab call — cache-licence/window state (5 s TTL) so the per-verb cost is just the agent round trip.--## Never let a script modal reach the user's screen (hard rule)--Altium's script engine keeps counting a **halted** script as *executing*. In that state:--- `altium_agent_status` → `alive: false` (the heartbeat file is gone), and-- `altium_check_dialogs` → `0`--…so both gates look clean while the engine is still occupied. Firing `altium_agent_start`-(or any `X2 -R` lane) then raises **"Another script executing now"** on the user's actual screen.-There is no read-only way to ask whether the engine is free.--**Rule:** an `X2 -R` lane is a one-shot, not a retry loop. Fire it only when the previous lane is-known to have exited, or after the user says Altium was restarted. If a start fails once: clear the-dialog, tell the user, and keep working **offline** (template edits, Rust bridge, docs) until the-engine is known-free. Two failed starts in a row means stop, not try harder.--## Two file-write laws inside the script engine (measured 2026-08-31)--1. **`Break` inside a `Try..Except` does not break.** This engine implements `Break` as an exception,- the `Except` swallows it, and the loop runs to completion. A `For K := 1 To 20 … Try … Break;- Except Sleep(10); End` retry loop therefore always runs 20 iterations and sleeps 200 ms —- this was the mysterious ~300 ms per file write, not disk latency. Use a flag:- `Done := False; While (Not Done) And (K < 20) Do … Done := True;`-2. **`hb.txt` must be written tmp+rename; responses may be written directly.** Readers hold the- heartbeat's final path open, so `SaveToFile` straight onto `hb.txt` raises `EFCreateError`- ("used by another process") and halts the engine with a modal. `response_<id>.json` is a fresh- unique name nobody has open → a direct write is safe and measures **0 ms**.--In-engine writes to the workspace, `%TEMP%` and `Documents` are all **0 ms** (new, overwrite and-delete), `RenameFile` 0 ms, `Sleep(1)` ≈ 15 ms, 1000 empty loop iterations 0 ms. Round-trip floor is-the agent's poll interval, not I/O.--**Confirmed end to end (0.1.12, live):** ping round trip **31–47 ms** (avg 42) where it was-450–600; through the Python bridge on a real board — list_designators 44 ms, list_nets 57 ms,-read_rules 68 ms, layers 47 ms, copper_layers 67 ms, component_data 87 ms. Read `agentPhases` in any-response to confirm: `hbSaveTries: 1, hbSaveExcept: 0, directTries: 1, directExcept: 0` is healthy;-tries climbing means real file contention, not the swallowed-Break bug returning.--Diagnostic: the agent command **`io_bench`** (agent-side only, no bridge verb) times SaveToFile-new/overwrite/delete against three directories plus rename/Sleep/loop cost. Drop a request file with-`command=io_bench` when a latency regression shows up — it is what localized this one.--## The Rust bridge is unblocked: `spawn.kind: "exe"` (ab, on #987)--ab already ships the compiled-binary runtime kind — spelled **`exe`**, not `binary`. ab spawns the-entrypoint directly with `--port <n>`, same env + supervision contract as node/python bridges;-`bridge_readiness` reports `runtime: "none"`. Integrity is the manifest's sha256 over the **zip**-(no spawn-time re-hash by design). Manifests are single-zip, so a compiled bridge is per-OS —-fine for Windows-only Altium. Set `killImageName` to the exe basename.--Fork target: `adom/hello-rust-bridge` v1.3.1 — the full contract in ~290 lines, including the-`describe` verb (ab's Verbs tab renders it verbatim) and the `uninstall` cascade verb.--## Board reads: the shape the Rust bridge is built on (2026-09-01)--`read_board` (agent) returns components + nets + layers + copper + rules in ONE round trip. It is-composed in DelphiScript from the EXISTING per-verb readers — deliberately: no new Altium API-surface means it cannot introduce an undeclared identifier, which is the one mistake that halts the-engine behind a modal on the user's screen.--The bridge then derives every view (`src/board.rs`). Two rules that came out of doing it:--0. **Name verbs for the caller's intent: `read_*` out, `write_*` / `apply_*` back.** The first draft- of this shipped as `dump_board`; John rejected it — "dump" names the implementation (a blob out of- memory), not the intent, and it has no counterpart, so it breaks the moment writes arrive.-1. **Record the RAW agent payload as a fixture, not the bridge's own response.** `altium_read_board {raw: true}` returns the unshaped payload; that is what `fixtures/read_board.json` holds, so the- tests exercise the shaping rather than re-reading its output.-2. **Never invent a label for an ordinal.** Altium emits `TRuleKind` as a number. The mapping in- `rule_kind_name()` was READ OFF a live default rule set (every default rule is named after its- own kind — 31 ordinals, with the five `Fanout_*` defaults sharing ordinal 49, which is why- grouping by ordinal beats grouping by name). Unknown ordinals render as `kind <n>`, and a test- fails loudly rather than silently mislabelling.--Known gap: the agent enumerates every copper layer Altium CAN address (48: top + 30 mid + 16 planes-+ bottom), not the stack in use — there is no "in stack" flag in the read yet, so the summary calls-it `copperLayersAddressable`. Do NOT report that number to a user as the board's layer count.