← Commit history
CHANGELOG.md+10
@@ -1,5 +1,15 @@ # Changelog +## 0.1.33 — 2026-09-01+- **Three new skills, because AI reads verb hints and skills, not tribal memory** (John: *"ai hints are way more valuable to help the ai know how to navigate the stuff you didn't know how to"*):+  - **altium-licensing** — one On-Demand seat serves EVERY Altium app (Designer and Designer Develop share it); killing an instance strands the seat and the licence page then reports it FREE while refusing every claim; the only recovery is a polite `altium_close` then `use_license`. Also: Develop writes NO licence tail in its title when it HOLDS the seat.+  - **altium-instances** — addressing one Altium of several, why `ambiguous_instance` is a feature, the per-instance agent workspace, and the three places single-instance assumptions hid.+  - **altium-wiki-import** — wiki part page or Cloud Library → Altium, the Cloud Library's Altium gap (phase 3) and the pipeline that closes it, plus the background proof captures.+- **AI hints where the knowledge bites**, not only in the skills: `altium_instances` and every `not_licensed` refusal now carry `_licenceHint` / `_seatHint` explaining the one-seat rule, the stranded-seat signature (Seats 0/1 while claims fail) and the exact fix.+- **`altium_close {force:true}` refuses** unless `acceptSeatLoss: true` — a forced kill strands the seat, and this used to be one keystroke away. The blocked-by-dialog reply now also warns that an Unsaved Changes prompt NAMES documents that may belong to another AI thread.+- **Dialog catalog** gained `License maximum usage reached`, `License Renewal Failed` (re-fires on a timer; clearing it is not the fix), `Cannot focus a disabled or invisible window` (Altium's crash reporter — its only button SENDS data) and a sharper `Unsaved Changes` entry.+- **Title parser: an absent licence tail on Altium Designer Develop now means LICENSED**, not unknown. `altium_instances` reported ADD unlicensed for an entire test while its agent imported five parts.+ ## 0.1.32 — 2026-09-01 - **Instance-awareness now reaches the licence gate.** `main_window()` returned "the first Altium main window" regardless of the selected instance, and `licence_state()` → `_need_licensed()` are built on it — so with two Altiums running, every verb targeting one instance was judged against the OTHER instance's licence. Measured live: `altium_license_status {instance:"develop"}` correctly reported `Trial`, while `altium_agent_start {instance:"develop"}` was refused `not_licensed` because Designer held no seat. - Verified with a real second instance (Altium Designer Develop): the layer distinguishes `AD26/Designer` from `ADDevelop/Develop`, gives each its own agent workspace, and REFUSES a verb with `ambiguous_instance` when no selector is given — rather than acting inside the other AI thread's Altium.
adom-bridge-altium-manifest.json+4−4
@@ -1,9 +1,9 @@ {   "name": "altium",-  "version": "0.1.32",-  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.32/adom-bridge-altium-v0.1.32.zip",-  "sha256": "d382722088301629b1c1d11f3bd6f574d3370861fb25d891a6cf7ecf32d883fc",-  "size": 130236,+  "version": "0.1.33",+  "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.33/adom-bridge-altium-v0.1.33.zip",+  "sha256": "7c53c2a5f79036f65f431145af27a412965e4a4c2384ca8e0a14d217ab22db00",+  "size": 132257,   "verbPrefixes": [     "altium_"   ],
adom-bridge-altium-v0.1.33.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.1.32",+  "version": "0.1.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",
package.json+4−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.1.32",+  "version": "0.1.33",   "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": [@@ -36,11 +36,14 @@     "skills/altium-bridge-plan/SKILL.md",     "skills/altium-bridge-publish/SKILL.md",     "skills/altium-install/SKILL.md",+    "skills/altium-instances/SKILL.md",     "skills/altium-kicad-import/SKILL.md",     "skills/altium-libraries/SKILL.md",+    "skills/altium-licensing/SKILL.md",     "skills/altium-outputs/SKILL.md",     "skills/altium-part-import/SKILL.md",     "skills/altium-uia/SKILL.md",+    "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ],   "discovery_triggers": [
skills/altium-instances/SKILL.mdadded+73
@@ -0,0 +1,73 @@+---+name: altium-instances+description: >-+  More than one Altium can run at once (Altium Designer beside Altium Designer Develop), often+  driven by two different AI threads on one machine. How to address a specific one with+  instance:<pid|key|word>, why a verb REFUSES with ambiguous_instance instead of guessing, the+  per-instance agent workspace that stops two resident loops eating each other's requests, and the+  three places single-instance assumptions used to hide. Read before driving Altium on a machine+  where another AI thread may also be working, or when a verb returns ambiguous_instance. Trigger+  words — two altiums, altium designer develop, ADD vs AD, ambiguous_instance, instance selector,+  which altium, second instance, parallel ai threads, per-instance workspace.+---++# altium-instances++Parent skill: [altium-bridge](../altium-bridge/SKILL.md).++## Addressing one Altium++```bash+altium_instances                      # every running Altium: pid, key, edition, version, licence, agent+altium_status  '{"instance":"AD26"}'      # by install-folder key+altium_status  '{"instance":"develop"}'   # by a word from the edition/title+altium_status  '{"instance":30108}'       # by pid+```++* **One** Altium running → omit `instance`; nothing changes.+* **Several** running → a verb WITHOUT a selector fails with `errorCode: "ambiguous_instance"` and+  lists the candidates. That refusal is the feature: guessing means acting inside another AI+  thread's Altium.++The `key` is the install folder (`AD26`, `ADDevelop`) because it survives a restart, which a pid does+not. Two instances of the SAME install fall back to `pid<n>`.++## The failure you cannot see: one shared agent workspace++The resident agent talks over files (`request.txt`, `hb.txt`, `response_<id>.json`). Before+2026-09-01 every instance used ONE directory, so two resident loops polled the same inbox — whichever+noticed first ate the other's request and answered it **from the wrong board**. Not a crash; wrong+answers.++Each instance now gets `bridges-cache/altium/agent/<key>/`. Verified live: Designer ran from+`agent/AD26` while Develop ran from `agent/ADDevelop`, importing five parts each, neither aware of+the other.++If you upgrade a bridge while a loop is resident, the OLD loop keeps heartbeating under the LEGACY+path. A script lane refuses when ANY workspace has a live heartbeat — that is deliberate; stop the+old loop (its workspace path is named in the refusal) rather than forcing past it.++## Where single-instance assumptions hid++All three were live bugs, all found within an hour of running two Altiums:++1. **The launcher hardcoded `C:\Program Files\Altium\AD26\X2.EXE`** — so a `-R` lane aimed at+   Develop forwarded to Designer. It now spawns the SELECTED instance's own image.+2. **Dialog scanning swept every X2 pid** — one thread could see, and `dismiss_dialogs` could CLEAR,+   the other thread's modal. Now scoped to the selected instance.+3. **`main_window()` returned "the first Altium"**, and `licence_state()` → `_need_licensed()` are+   built on it — so a Develop-targeted verb was judged against Designer's licence and refused+   `not_licensed` while Develop held the seat. Instance-awareness has to reach all the way down.++## The stray-helper reaper must never kill a real Altium++A `-R` helper carries its request in its own command line (`-R…ScriptingSystem`); a real Altium never+does, and ab reports `commandLine` in `process_list`. The old rule — "any X2 that is not the main+window's process is a stray" — killed John's Develop instance on 2026-09-01 while it hosted another+thread's agent. Kill only processes whose command line carries a script request.++## Licensing caps what two instances can DO++Two Altiums can run; only one can be licensed — see [altium-licensing](../altium-licensing/SKILL.md).+So a genuinely parallel two-thread test is limited to one licensed instance at a time, and swapping+the seat means closing one app politely. Never kill it: that strands the seat.
skills/altium-licensing/SKILL.mdadded+94
@@ -0,0 +1,94 @@+---+name: altium-licensing+description: >-+  Altium's On-Demand licence seat, and the ONE mistake that costs an hour: killing Altium strands+  the seat, after which the licence page reports it FREE (Seats 0/1) while refusing every claim+  with "License maximum usage reached". Covers the one-seat-per-machine rule (Altium Designer and+  Altium Designer Develop share it), how to swap the seat between apps, how to recover a stranded+  one, and why Develop's window title does not say it is licensed. Read when a verb returns+  not_licensed, when the title says "No License" or "Viewer", when a claim fails, or BEFORE closing+  or killing any Altium. Trigger words — altium licence, altium license, no license, viewer mode,+  license maximum usage reached, license renewal failed, seat, on-demand, use license, release+  license, stranded seat, altium_close force, two altiums one seat.+---++# altium-licensing++Parent skill: [altium-bridge](../altium-bridge/SKILL.md).++## The rule that explains almost everything++**One On-Demand seat serves EVERY Altium app on the machine.** Altium Designer and Altium Designer+Develop draw from the *same* seat — on John's box the trial licence is literally named+`Altium Designer Develop` (On-Demand, Single Site, 1 seat) and both apps use it. So:++* Two Altiums can RUN at once. Only ONE can be LICENSED at once.+* The unlicensed one falls back to **Viewer**, where Altium refuses scripting — so the bridge's+  agent cannot start there, and every verb gated by `_need_licensed` returns `not_licensed`.+* You do NOT need a second trial to use Develop. You need the seat.++## NEVER kill an Altium that holds the seat++`taskkill` / `process_kill` / a crash all end the process WITHOUT releasing the lease. The result is+the worst kind of failure — one that lies to you:++```+licence page:  Seats 0/1        <- looks free+every claim:   "License maximum usage reached. Please contact your Administrator+                to add or release seats for this license."+```++Waiting does not clear it. `Refresh` on that page is a hyperlink the UIA layer cannot invoke.++**Recovery, measured 2026-09-01:** `altium_close` (polite WM_CLOSE) on the instance that held it+releases the lease immediately; then `altium_license {action:"use_license"}` claims it. That is the+whole fix, and nothing else worked.++`altium_close {force:true}` now refuses unless you also pass `acceptSeatLoss:true`, precisely so this+cannot be reached for casually.++## Swapping the seat between Designer and Develop++```bash+altium_agent_stop                                  # nothing mid-flight+altium_close                                       # polite: releases the seat+#   an Unsaved Changes prompt NAMES the documents — READ IT. On a shared machine one may belong to+#   ANOTHER AI thread. Save preserves their work; Don't Save destroys it. Never default this.+#   launch the other app; it claims the free seat SILENTLY at startup, no clicking:+shell_execute 'Start-Process "C:\Program Files\Altium\ADDevelop\X2.EXE"'+altium_agent_start '{"instance":"develop"}'+```++## Develop does not admit it is licensed++Altium Designer writes the licence into its title (`… - Trial`, `… - No License`).+**Altium Designer Develop writes NOTHING when it HOLDS a seat** — only `- Viewer` when it does not.++So a blank `licenceState` on Develop does **not** mean unlicensed. On 2026-09-01 `altium_instances`+reported ADD unlicensed for an entire test while its agent bootstrapped and imported five parts+without complaint. The bridge now treats an absent tail on a Develop edition as *licensed, kind+unknown* — but if you are reading a raw title, remember it.++## Reclaiming without an agent++The licence page is a WebView2 whose DOM IS in the UIA tree, so the quiet lane works even with no+agent running (no cursor, no foreground):++```bash+desktop_ui_click '{"hwnd":<main>,"automationId":"UseLicenseButton"}'   # claim+desktop_ui_click '{"hwnd":<main>,"automationId":"ReleaseButton"}'      # release+```++Both were verified live. `Refresh` and `Sign Out` are links, not buttons — UIA cannot invoke them.++In **Develop** the account menu is NOT UIA-addressable at all (no `Manage Seats`, no account button,+`desktop_ui_from_point` returns nothing), so there is no in-app quiet lane to its licence page. Do+not burn time hunting for one: free the seat elsewhere and let Develop take it at startup.++## Dialogs this produces++| dialog | meaning | do |+|---|---|---|+| `License maximum usage reached` | seat stranded by a killed instance | close other Altiums politely, then claim |+| `License Renewal Failed` | same cause; **re-fires on a timer** | clearing it is not the fix |+| `You are not currently using an Altium Designer license` | seat free, nobody claimed it | `Choose a license` → `Use License` |
skills/altium-wiki-import/SKILL.mdadded+99
@@ -0,0 +1,99 @@+---+name: altium-wiki-import+description: >-+  Import a component from the Adom wiki into Altium — one part page, or a whole Cloud Library+  (Colby's component cart) — with symbol, footprint, 3D body and Mouser/DigiKey/LCSC identity, in+  seconds, without foregrounding Altium. Covers the Cloud Library's Altium gap (export --format+  altium is phase 3; KiCad + Fusion ship today) and the working pipeline that closes it, plus the+  background proof captures the wiki front-end needs. Read when asked to "add this wiki part to+  Altium", "install my cloud library into Altium", or to prove an import worked. Trigger words —+  wiki part to altium, cloud library, adom-wiki library, import component altium, kicad_sym to+  altium, kicad_mod to altium, import_part, library export, prove the import.+---++# altium-wiki-import++Parent skill: [altium-bridge](../altium-bridge/SKILL.md).++## What ships today++`adom-wiki library` (Cloud Libraries — component carts) exports **KiCad and Fusion**. Altium is+**phase 3**:++```+$ adom-wiki library availability <ref>      -> {"kicad":5,"fusion":5,"altium":0}+                                               per item: "no Altium assets declared"+$ adom-wiki library export <ref> --format altium+  LIB_FORMAT_UNAVAILABLE: format "altium" is not exportable yet+  "KiCad and Fusion ship today; the Altium lane (verified vendor assets, clone-based merge) is phase 3."+```++Wiki component pages carry `.kicad_sym`, `.kicad_mod`, `.step`, `.glb`, `.lbr` — no Altium-native+assets, which is exactly what the exporter is telling you. **The bridge closes that gap today.**++## The pipeline (verified end to end, 2026-09-01)++Ray's `stm32f303cbt6` and a 5-part Cloud Library, into both Altium Designer and Altium Designer+Develop: **5/5 parts, 2.4–7.3 s each, zero dialogs, foreground never disturbed.**++```bash+# 1. get the parts.  ONE page:            /api/v1/pages/<slug>/files/<slug>.kicad_sym|.kicad_mod|.step+#    a whole cart:+adom-wiki library export <ref> --format kicad -o lib.zip     # merged .kicad_sym + .pretty/ + 3dmodels/++# 2. a Cloud Library ships ONE merged .kicad_sym — split it per part before converting, or+#    symbol_modes.py will fuse every part's pins into a single symbol.+#    Each part's (property "Footprint" "Lib:FP") names its .pretty/<FP>.kicad_mod.++# 3. convert (container side; both tools ship in the bridge repo's tools/)+python3 tools/kicad2altium.py <part>.kicad_mod fp.spec "<FootprintName>" "<desc>"+python3 tools/symbol_modes.py <part>.kicad_sym sym.spec "<SymbolName>"    "<desc>"++# 4. vendor identity — ONE parts-search call, never a loop (shared daily quota)+adom-parts-search search "<MPN>"        # -> Mouser / DigiKey / LCSC part numbers++# 5. stage on the box and import (agent lane: in-process, no cursor, no foreground)+send_files '{"filePaths":["fp.spec","sym.spec","part.step"]}'+altium_import_part '{"schLib":"…\\Lib.SchLib","pcbLib":"…\\Lib.PcbLib",+                     "footprintSpec":"…\\fp.spec","symbolSpec":"…\\sym.spec","step":"…\\part.step",+                     "params":{"Mouser Part #":"…","DigiKey Part #":"…","LCSC Part #":"…",+                               "MPN":"…","Manufacturer":"…","Package":"…","Value":"…"},+                     "comment":"…","description":"…"}'+```++`import_part` returns its step counts — **check them**. `{"add_models": true, "models": 1, "params": 7}`+is a complete import; `models: 0` means the symbol has NO footprint and would place on a schematic+with nothing attached. The verb now fails on a zero-model attach rather than reporting success.++## Gotchas paid for in real imports++* **The first part into a NEW library can silently skip models+params** if the bridge process is+  mid-reload. Verify with `altium_read_symbol` (params) and the step counts; repair with+  `altium_remove_symbol` + re-import.+* **`add_models` is not idempotent** — each call appends another implementation. Calling it twice+  leaves duplicate footprint models on one symbol. Repair = `remove_symbol` + re-import.+* **Fresh libraries carry factory strays** (`Component_1`, `PCBComponent_1`) — `altium_cleanup_libs`.+* **`read_symbol` does not report models.** `models: null` is a REPORTING GAP, not proof the+  footprint link is missing. Do not conclude from it.+* Wiki parts can carry real data faults — a live example: `93LC56BT-I` has **8 symbol pins against a+  6-pad SOT-23-6 footprint**. Report it to the page owner; do not silently "fix" someone else's page.++## Proving it to the front-end++The wiki needs evidence, and the user must not be disturbed:++```bash+altium_show_symbol    '{"libraryPath":"…SchLib","symbolName":"…"}'+altium_show_footprint '{"libraryPath":"…PcbLib","footprintName":"…"}'+altium_show_3d_chip   '{"libraryPath":"…PcbLib","footprintName":"…"}'+altium_show_library   '{"libraryPath":"…"}'      # the whole library, with componentCount+```++Each returns `shot.image` (base64 PNG) and `windowEtiquette: "background"`. If Altium is MINIMIZED+the bridge restores it into the background, captures, and hands the user's foreground straight back+— it never moves the window off-screen, because a user reaching for Altium must find it where they+left it. A capture under 400 px wide is refused as `proof_unavailable` rather than returned: a+title-bar sliver is not evidence.++There is no single "ganged" symbol+footprint+3D view in Altium's library editors, so three captures+are the proof set.
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.32+0.1.33
src/handlers/altium_detect.py+6
@@ -264,6 +264,12 @@ def licence_state() -> dict:     licensed = None     if st:         licensed = st.lower() not in ("no license", "no licence", "viewer")+    elif mw.get("edition") == "Develop":+        # Altium Designer DEVELOP does not put a licence tail in its title when it HOLDS a seat —+        # it only writes one when it does not ("- Viewer"). Reading "no tail" as "no licence" made+        # `instances` report ADD unlicensed while its agent happily imported five parts+        # (measured 2026-09-01). Absent tail on Develop means licensed, kind unknown.+        licensed, st = True, "Licensed (Develop reports no licence tail)"     return {"running": True, "licensed": licensed, "state": st, "adVersion": mw.get("adVersion"),             "hwnd": mw["hwnd"], "pid": mw["pid"]} 
src/handlers/dialogs.py+16
@@ -40,6 +40,22 @@ CATALOG = [      "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),     ("do you want to save", "save_prompt", None, "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),     ("save changes", "save_prompt", None, "Unsaved changes. Decide explicitly."),+    ("license maximum usage reached", "licence_seat_stranded", "OK",+     "The seat is held by an instance that never released it — almost always an Altium that was "+     "KILLED rather than closed. The licence page will still show Seats 0/1, which looks free and "+     "is not. FIX: close every other Altium POLITELY (altium_close, never process_kill), then "+     "altium_license {action:'use_license'}. Waiting does not clear it."),+    ("license renewal failed", "licence_seat_stranded", "OK",+     "Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a "+     "killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is "+     "reclaimed, so clearing it is not the fix."),+    ("cannot focus a disabled or invisible window", "altium_internal", "Send and Close",+     "Altium's own crash-report dialog, usually raised when a window's state changed while it was "+     "focusing a document view (e.g. a restore/minimize cycle during a background capture). Its "+     "only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),+    ("unsaved changes", "save_prompt", None,+     "Names the document(s) at risk — READ IT. On a shared machine the document may belong to "+     "ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),     ("license", "licence", None, "Licence prompt — read it; usually informational."),     ("trial", "licence", None, "Trial notice."), ]
src/server.py+64−6
@@ -77,6 +77,44 @@ def h_readiness(args):     return out  ++# ---- licence-seat hints (John, 2026-09-01: "ai hints are way more valuable ... the AI is bad at+# reading skills") ------------------------------------------------------------------------------+#+# One On-Demand seat serves ALL Altium apps on the machine — Designer and Designer Develop draw+# from the SAME seat. Everything below exists because two hours were lost to a stranded seat that+# the licence page reported as free.++SEAT_RULE = ("One On-Demand seat serves EVERY Altium app on this machine — Altium Designer and "+             "Altium Designer Develop share it. Only one can be licensed at a time.")++SEAT_STRANDED_FIX = (+    "If a claim fails while the licence page shows Seats 0/1 (looks free, is not), the seat is "+    "STRANDED by an Altium that was KILLED instead of closed. Fix: altium_close on every other "+    "instance (polite WM_CLOSE releases the lease), then altium_license {action:'use_license'}. "+    "Waiting does not clear it, and Refresh is a link the UIA layer cannot invoke."+)++SEAT_SWAP_HOWTO = (+    "To move the seat between apps: altium_close the one holding it (answer any Unsaved Changes "+    "prompt deliberately — on a shared machine the document may belong to another AI thread), then "+    "LAUNCH the other app; it claims the free seat silently at startup. No clicking required."+)+++def _seat_hints(out: dict, licence_state, instance_count: int = 1) -> dict:+    """Attach the seat guidance to any reply where the licence is the thing about to bite."""+    unlicensed = str(licence_state or "").lower() in ("no license", "no licence", "viewer")+    if unlicensed:+        out["_licenceHint"] = f"{SEAT_RULE} {SEAT_STRANDED_FIX}"+    if instance_count > 1:+        out["_seatHint"] = (f"{SEAT_RULE} {SEAT_SWAP_HOWTO} "+                            "NOTE: Altium Designer Develop writes NO licence tail in its title when it "+                            "HOLDS the seat (only '- Viewer' when it does not), so a blank licenceState "+                            "on Develop does not mean unlicensed — try the verb.")+    return out++ def h_status(args):     r = h_readiness(args)     r["uptimeSec"] = int(time.time() - _boot_at)@@ -208,8 +246,24 @@ def h_close(args):         dl = dialogs.scan(main_pid_only=True)         if dl:             if args.get("force"):+                # A forced kill STRANDS the On-Demand licence seat: the lease is never released, and+                # the licence page then shows Seats 0/1 (free) while every claim fails with "License+                # maximum usage reached". Two hours were lost to this on 2026-09-01. Make the caller+                # say it out loud.+                if not args.get("acceptSeatLoss"):+                    return _seat_hints({"success": False, "errorCode": "would_strand_licence_seat",+                        "dialogs": dl,+                        "error": "force:true KILLS Altium, which strands the On-Demand licence seat — "+                                 "no other Altium will be able to claim it, and the licence page will "+                                 "report it as free while refusing every claim.",+                        "_hint": "Answer the dialog instead (it is usually an Unsaved Changes prompt that "+                                 "names the document — on a shared machine it may belong to another AI "+                                 "thread). If you truly must kill, repeat with acceptSeatLoss:true and "+                                 "expect to reclaim the seat afterwards."},+                        altium_detect.licence_state().get("state"), len(altium_detect.instances()))                 subprocess.run(["taskkill", "/F", "/IM", altium_detect.EXE_NAME], capture_output=True)                 return {"success": True, "forced": True, "dismissedDialogs": [d.get("title") for d in dl],+                        "_seatWarning": "The licence seat is now STRANDED. " + SEAT_STRANDED_FIX,                         "_hint": "force:true kills even when a dialog blocks the polite close (e.g. the "                                  "'script stopped on a breakpoint' Error that refuses WM_CLOSE)."}             return {"success": False, "errorCode": "close_blocked_by_dialog", "dialogs": dl,@@ -228,8 +282,11 @@ def _need_licensed():     if not lic.get("running"):         return {"success": False, "errorCode": "altium_not_running", "_hint": "altium_launch first."}     if lic.get("licensed") is False:-        return {"success": False, "errorCode": "not_licensed", "licenceState": lic.get("state"),-                "_hint": "Altium refuses scripts without a licence. altium_license '{}'."}+        n = len(altium_detect.instances())+        return _seat_hints({"success": False, "errorCode": "not_licensed", "licenceState": lic.get("state"),+                            "_hint": "Altium refuses scripts without a licence. altium_license "+                                     "{\"action\":\"use_license\"} claims a free seat."},+                           lic.get("state"), n)     dl = dialogs.scan(main_pid_only=True)     if dl:         _attach_dialog_shots(dl)@@ -1437,10 +1494,11 @@ def h_instances(args):         i["agentAlive"] = agent.is_alive()         i["agentWorkspace"] = str(agent.workspace_dir())     agent.use_instance(None)-    return {"success": True, "count": len(found), "instances": found,-            "_hint": ("Pass instance:<pid|key|word> to any verb to target one. "-                      "With several running, verbs without it fail with errorCode ambiguous_instance.")-            if len(found) > 1 else "One Altium running — `instance` is optional."}+    out = {"success": True, "count": len(found), "instances": found,+           "_hint": ("Pass instance:<pid|key|word> to any verb to target one. "+                     "With several running, verbs without it fail with errorCode ambiguous_instance.")+           if len(found) > 1 else "One Altium running — `instance` is optional."}+    return _seat_hints(out, None, len(found))