app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
v0.1.12: 14x faster agent round trip - Break inside Try..Except never breaks in this engine
8 files changed
+226−20
CHANGELOG.md+7@@ -1,5 +1,12 @@ # Changelog +## 0.1.12 — 2026-08-31+- **14× faster agent round trip: 450–600 ms → 31–47 ms.** Root cause: `Break` inside a `Try..Except` in this script engine is implemented as an exception, so the `Except` arm swallowed it — every file write ran all 20 retry iterations (`Sleep(10)` each, ~220–310 ms) even though the FIRST write had already succeeded. Both write primitives now use a `Done` flag with a `While` guard instead. Verified across the whole template: no other `Break`-inside-`Try` site exists.+- `hb.txt` keeps tmp+rename (readers hold it open, and a direct rewrite raced them into `EFCreateError`); `response_<id>.json` writes direct — one file op instead of save+delete+rename.+- Responses carry `agentPhases` — `{readMs, dispatchMs, detectTick, sleepMs, hbSaveMs, hbSaveTries, hbSaveExcept, directMs, directTries, directExcept}`. `*Tries: 1, *Except: 0` is the healthy shape; anything higher means the box is contending for the file and the retry loop is earning its keep.+- New agent-side `io_bench` command (not a bridge verb): times SaveToFile new/overwrite/delete against the workspace, %TEMP% and Documents, plus rename/Sleep(1)/loop cost. This is what localized the stall — keep it for the next latency regression.+- Measured after the fix (LAN7800_test.PcbDoc, live): list_designators 44 ms, list_nets 57 ms, read_rules 68 ms (35 rules), layers 47 ms (28), copper_layers 67 ms (48), component_data 87 ms — agent round trip, dialogs 0.+ ## 0.1.11 — 2026-08-31 - Phase 1 of the parity plan: 11 read-only board/schematic introspection verbs — list_designators, component_data, component_pins, list_nets, net_connections, layers, copper_layers, schematic_data (pins + part IDs), read_rules, set_layer_visibility, footprint_primitives. All verified live on Bluetooth Sentinel (16–350 ms), zero dialogs. - Busy-notice reaper rewritten to go through ab's desktop verbs (find_window → find_control → ui_click) with a 4 s watch, after the X2-pid scan missed the notice twice on a live box.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.11",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.11/adom-bridge-altium-v0.1.11.zip",- "sha256": "3f5062567ccffa208025b7cc4bdb51b7a06bce047c5d0c8e1cf6f9019478ff8d",- "size": 102890,+ "version": "0.1.12",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.12/adom-bridge-altium-v0.1.12.zip",+ "sha256": "5f61c9096177d798b34f1bac8503577483be61c5ba216a8acdcaa1953dd29cc6",+ "size": 104395, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.12.zipadded⋯ 1 unchanged line ⋯
bridge.json+2−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.11",+ "version": "0.1.12", "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",@@ -163,4 +163,4 @@ ] } }-}+}
package.json+2−2@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.11",+ "version": "0.1.12", "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": [@@ -110,4 +110,4 @@ "prompt": "generate gerbers and a BOM from my altium project" } ]-}+}
skills/altium-bridge-dev/SKILL.md+74@@ -106,3 +106,77 @@ Frame-check EVERY generated look/variant (a 3-of-10 spot check shipped three def 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 dump/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.
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.11+0.1.12
src/agent/AdomAgent.pas.tmpl+136−11@@ -35,6 +35,11 @@ Var gRequests : Integer; gLastReqTick: Integer; gLastHbTick : Integer;+ gLastSleepMs : Integer;+ gTicks : Integer;+ gLastSaveMs, gLastRenameMs, gLastDeleteMs : Integer;+ gLastSaveTries, gLastSaveExcept : Integer;+ gDirectTries, gDirectExcept, gDirectMs : Integer; { ---------------------------------------------------------------- files ---- } @@ -71,28 +76,79 @@ End; Procedure WriteAtomic(Path : String; Content : String); Var- L : TStringList;- Tmp : String;- K : Integer;+ L : TStringList;+ Tmp : String;+ Done : Boolean;+ K, TA, TB, TC : Integer; Begin Tmp := Path + '.tmp'; L := TStringList.Create; Try L.Text := Content;- For K := 1 To 20 Do+ TA := GetTickCount;+ gLastSaveTries := 0;+ gLastSaveExcept := 0;+ Done := False;+ K := 0;+ { NOTE: never `Break` out of a Try..Except here - this engine implements Break as an+ exception, the Except swallows it, and the loop runs all 20 iterations (~220 ms). }+ While (Not Done) And (K < 20) Do Begin+ K := K + 1;+ gLastSaveTries := K; Try L.SaveToFile(Tmp);- Break;+ Done := True; Except+ gLastSaveExcept := gLastSaveExcept + 1; Sleep(10); End; End;+ TB := GetTickCount; Finally L.Free; End; If FileExists(Path) Then DeleteFile(Path);+ TC := GetTickCount; RenameFile(Tmp, Path);+ gLastSaveMs := TB - TA;+ gLastDeleteMs := TC - TB;+ gLastRenameMs := GetTickCount - TC;+End;++{ Direct write, no tmp+rename (A/B against WriteAtomic for the response path). Readers tolerate a+ partially written file by retrying the JSON parse. }+Procedure WriteDirect(Path : String; Content : String);+Var+ L : TStringList;+ Done : Boolean;+ K : Integer;+ T0 : Integer;+Begin+ L := TStringList.Create;+ Try+ L.Text := Content;+ T0 := GetTickCount;+ gDirectTries := 0;+ gDirectExcept := 0;+ Done := False;+ K := 0;+ While (Not Done) And (K < 20) Do+ Begin+ K := K + 1;+ gDirectTries := K;+ Try+ L.SaveToFile(Path);+ Done := True;+ Except+ gDirectExcept := gDirectExcept + 1;+ Sleep(10);+ End;+ End;+ gDirectMs := GetTickCount - T0;+ Finally+ L.Free;+ End; End; Function JsonEsc(S : String) : String;@@ -142,6 +198,9 @@ Begin End; End; +{ hb.txt MUST be written tmp+rename: readers (the bridge's is_alive poll) hold the final path+ open, so a direct SaveToFile onto it raises EFCreateError and halts the engine with a modal.+ Response files use WriteDirect - a fresh unique name nobody has open yet. } Procedure Heartbeat(Force : Boolean); Var T : Integer;@@ -150,7 +209,7 @@ Begin If Force Or ((T - gLastHbTick) >= HEARTBEAT_MS) Then Begin Try- WriteAtomic(WORKSPACE + 'hb.txt', IntToStr(T) + #13#10 + 'requests=' + IntToStr(gRequests) + #13#10 + 'version=' + AGENT_VERSION + #13#10 + 'gen=' + AGENT_GEN);+ WriteAtomic(WORKSPACE + 'hb.txt', IntToStr(T) + #13#10 + 'requests=' + IntToStr(gRequests) + #13#10 + 'ticks=' + IntToStr(gTicks) + #13#10 + 'version=' + AGENT_VERSION + #13#10 + 'gen=' + AGENT_GEN); Except End; gLastHbTick := T;@@ -3489,6 +3548,63 @@ Begin JRaw('count', IntToStr(K)) + ',' + JRaw('otherPrimitives', IntToStr(Other)) + ',' + JRaw('primitives', '[' + S + ']') + '}'; End; +{ I/O bench: where does the ~300 ms per file write go? Times TStringList.SaveToFile against+ several paths (workspace / %TEMP% / Documents), overwrite vs new, and rename/delete. Uses only+ routines the agent already relies on, so it cannot introduce a new undeclared identifier. }+Function BenchOne(Path : String; Content : String) : String;+Var+ L : TStringList;+ T0, TNew, TOver, TDel : Integer;+Begin+ L := TStringList.Create;+ Try+ L.Text := Content;+ If FileExists(Path) Then DeleteFile(Path);+ T0 := GetTickCount; L.SaveToFile(Path); TNew := GetTickCount - T0;+ T0 := GetTickCount; L.SaveToFile(Path); TOver := GetTickCount - T0;+ T0 := GetTickCount; DeleteFile(Path); TDel := GetTickCount - T0;+ Finally+ L.Free;+ End;+ Result := '{' + JStr('path', Path) + ',' + JRaw('newMs', IntToStr(TNew)) + ',' + JRaw('overwriteMs', IntToStr(TOver)) + ',' + JRaw('deleteMs', IntToStr(TDel)) + '}';+End;++Function CmdIoBench(Lines : TStringList) : String;+Var+ Big, Small, TmpDir, Docs : String;+ K, T0, TRen, TSleep, TLoop, X : Integer;+ L : TStringList;+Begin+ Small := 'payload 0123456789 abcdefghij';+ Big := '';+ For K := 1 To 200 Do Big := Big + 'line ' + IntToStr(K) + ' 0123456789 abcdefghij 0123456789 abcdefghij' + #13#10;+ { derive %TEMP% and Documents from WORKSPACE (…\AppData\Local\Adom Bridge\…) - no new identifiers }+ TmpDir := Copy(WORKSPACE, 1, Pos('\Adom Bridge\', WORKSPACE)) + 'Temp';+ Docs := Copy(WORKSPACE, 1, Pos('\AppData\', WORKSPACE)) + 'Documents';+ { rename cost on an existing small file }+ L := TStringList.Create;+ Try+ L.Text := Small;+ L.SaveToFile(WORKSPACE + 'bench_ren.tmp');+ Finally+ L.Free;+ End;+ T0 := GetTickCount; RenameFile(WORKSPACE + 'bench_ren.tmp', WORKSPACE + 'bench_ren.txt'); TRen := GetTickCount - T0;+ DeleteFile(WORKSPACE + 'bench_ren.txt');+ T0 := GetTickCount; Sleep(1); TSleep := GetTickCount - T0;+ T0 := GetTickCount; For K := 1 To 1000 Do X := K * 2; TLoop := GetTickCount - T0;+ Result := '{' + JRaw('ok', 'true')+ + ',' + JRaw('wsSmall', BenchOne(WORKSPACE + 'bench_a.txt', Small))+ + ',' + JRaw('wsSmallTmpExt', BenchOne(WORKSPACE + 'bench_b.tmp', Small))+ + ',' + JRaw('wsBig', BenchOne(WORKSPACE + 'bench_c.json', Big))+ + ',' + JRaw('tempSmall', BenchOne(TmpDir + '\adom_bench_d.txt', Small))+ + ',' + JRaw('docsSmall', BenchOne(Docs + '\adom_bench_e.txt', Small))+ + ',' + JRaw('renameMs', IntToStr(TRen))+ + ',' + JRaw('sleep1Ms', IntToStr(TSleep))+ + ',' + JRaw('loop1000Ms', IntToStr(TLoop))+ + '}';+End;+ Function Dispatch(Cmd : String; Lines : TStringList; Payload : String) : String; Begin If Cmd = 'ping' Then Result := CmdPing(Lines)@@ -3496,6 +3612,7 @@ Begin Else If Cmd = 'run_process' Then Result := CmdRunProcess(Lines) Else If Cmd = 'open' Then Result := CmdOpen(Lines) Else If Cmd = 'eval' Then Result := CmdEval(Lines, Payload)+ Else If Cmd = 'io_bench' Then Result := CmdIoBench(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)@@ -3546,7 +3663,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') + '}';+ 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') + '}'; End; { ------------------------------------------------------------------ loop --- }@@ -3559,7 +3676,7 @@ Function ServeOne : Boolean; Var Id, ReqPath, PayPath, Cmd, Body, Payload : String; Lines : TStringList;- T0 : Integer;+ T0, TDetect, TRead, TDone : Integer; Begin Result := False; ReqPath := WORKSPACE + 'request.txt';@@ -3567,8 +3684,10 @@ Begin PayPath := WORKSPACE + 'request.payload'; Lines := TStringList.Create; Try+ TDetect := GetTickCount; Lines.Text := ReadAll(ReqPath); DeleteFile(ReqPath);+ TRead := GetTickCount; Payload := ''; If FileExists(PayPath) Then Begin@@ -3585,9 +3704,12 @@ Begin Except Body := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'exception in agent while handling ' + Cmd) + '}'; End;- { wrap: id + timing + the handler body as "data" }- WriteAtomic(WORKSPACE + 'response_' + Id + '.json',- '{' + JStr('id', Id) + ',' + JStr('command', Cmd) + ',' + JRaw('durationMs', IntToStr(GetTickCount - T0)) + ',' + JRaw('data', Body) + '}');+ TDone := GetTickCount;+ { wrap: id + timing + the handler body as "data"; agentPhases = read/dispatch ms + the loop tick that saw the request }+ WriteDirect(WORKSPACE + 'response_' + Id + '.json',+ '{' + JStr('id', Id) + ',' + JStr('command', Cmd) + ',' + JRaw('durationMs', IntToStr(TDone - T0)) + ',' ++ JRaw('agentPhases', '{' + JRaw('readMs', IntToStr(TRead - TDetect)) + ',' + JRaw('dispatchMs', IntToStr(TDone - T0)) + ',' + JRaw('detectTick', IntToStr(TDetect)) + ',' + JRaw('sleepMs', IntToStr(gLastSleepMs)) + ',' + JRaw('hbSaveMs', IntToStr(gLastSaveMs)) + ',' + JRaw('hbSaveTries', IntToStr(gLastSaveTries)) + ',' + JRaw('hbSaveExcept', IntToStr(gLastSaveExcept)) + ',' + JRaw('hbDeleteMs', IntToStr(gLastDeleteMs)) + ',' + JRaw('hbRenameMs', IntToStr(gLastRenameMs)) + ',' + JRaw('directMs', IntToStr(gDirectMs)) + ',' + JRaw('directTries', IntToStr(gDirectTries)) + ',' + JRaw('directExcept', IntToStr(gDirectExcept)) + '}') + ',' ++ JRaw('data', Body) + '}'); Finally Lines.Free; End;@@ -3625,6 +3747,7 @@ Begin gLastReqTick := GetTickCount; gLastHbTick := 0; IdleTicks := 0;+ gTicks := 0; { a stale stop file must not kill a fresh loop } If FileExists(WORKSPACE + 'stop') Then DeleteFile(WORKSPACE + 'stop'); WriteAtomic(WORKSPACE + 'agent.json', '{' + JStr('version', AGENT_VERSION) + ',' + JRaw('startedTick', IntToStr(GetTickCount)) + ',' + JStr('workspace', WORKSPACE) + '}');@@ -3659,6 +3782,8 @@ Begin If (GetTickCount - gLastReqTick) > AUTO_STOP_MS Then gRunning := False; Heartbeat(False); Application.ProcessMessages;+ gLastSleepMs := SleepMs;+ gTicks := gTicks + 1; Sleep(SleepMs); End; Try