← Commit history

v1.9.51: foreground etiquette contract

John Lauer ·97207e50d2 ·1mo ago ·parent 451dd51
6 files changed +95−13
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.50\ No newline at end of file+1.9.51\ No newline at end of file
CHANGELOG.md+11
@@ -3,6 +3,17 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.9.51 - 2026-08-16++- **Foreground etiquette contract (John, after ab's demo stole his focus).** (1) The tour's+  single est-sized background envelope EXPIRED mid-run on slow tours (485-890s vs ~376s armed),+  so later steps foregrounded Fusion while the user typed - the envelope now RE-ARMS per step.+  (2) foreground:true now REQUIRES foregroundReason and shows it as an ab caption for 3 seconds+  BEFORE any focus change, so the user knows why and can yield (foreground_reason_required+  teaching refusal otherwise). (3) Every backgrounded window-raising verb annotates its response+  with windowEtiquette:"background" + a hint so the calling AI knows no focus was taken and never+  compensates by foregrounding. Contract recorded in dev-skills/fusion-background-etiquette.+ ## 1.9.50 - 2026-08-16  - **Third tour failure, last gap closed**: the tour's own bundled sample docs (ADS8588SIPM,
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.50",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.50/adom-bridge-fusion-v1.9.50.zip",-    "sha256": "3f2dfb5688c5785302b6c823bdc81f7f57fdf6d0f970fe1534cfe454e23d661e",-    "size": 397998,+    "version": "1.9.51",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.51/adom-bridge-fusion-v1.9.51.zip",+    "sha256": "916f14373464d461ab0e074c79849ef855f5c1bd22869d99359c829b995130f5",+    "size": 398944,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.50",+  "version": "1.9.51",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dev-skills/fusion-background-etiquette/SKILL.md+31
@@ -23,6 +23,37 @@ exports, view switches, and builds all work on background windows (PrintWindow r occluded windows fine), so there is no functional excuse for a focus steal. A stolen foreground mid-keystroke is the single most user-hostile thing this bridge can do. +## The foreground CONTRACT (John, 2026-08-16, after ab's demo run stole his focus)++> you can allow the ai to foreground fusion, but you have to make it give you a reason+> and then you should show the reason for 3 seconds as an ab caption so the user knows+> why its being foregrounded and then can yeild. you should be returning hints to the+> caller, i.e. the ai, that you are opening these fusion windows in the background so+> it knows.++Implemented in dispatch (v1.9.51), and every future window-raising verb MUST keep it:++1. **`foreground:true` alone is REFUSED** (`errorCode: foreground_reason_required`).+   The caller must pass `foregroundReason:"<user-facing sentence>"`.+2. **The reason is shown as an ab caption for ~3 seconds BEFORE any focus change**+   (ad_client.notify + sleep(3)), so the user knows why and can yield.+3. **Background runs annotate the RESPONSE**: `windowEtiquette:"background"` plus a hint+   telling the calling AI no focus was taken, to render via fusion_state, and to never+   "helpfully" foreground afterward. The AI learns the etiquette from the verb itself.++## The envelope-expiry trap (the bug behind John's 2026-08-16 report)++The tour armed ONE envelope sized `estimate+60s` at start - but real tours ran 485-890s+(cold boots, dialog stalls), so the envelope EXPIRED mid-run and every later doc open+stole the foreground while the user typed in other threads. Rules:++- **Long orchestrations RE-ARM the envelope per step** (`begin_background_envelope(dur*3+240)`+  each loop iteration - nesting is refcounted, re-arming is free). Never size one envelope+  off an estimate that a slow cold boot can blow through.+- **Direct orchestrator calls bypass dispatch's envelope** (_orchestrate_*, _show_library_view,+  _test_project_activate called from inside another verb) - the CALLING orchestration owns+  the envelope for them.+ ## The escalation ladder (exhaust each rung before the next)  1. **API / text command** - the add-in API, EAGLE text commands, and modeling scripts
server.py+47−7
@@ -6431,11 +6431,18 @@ def _orchestrate_demo_tour(args: dict) -> dict:             pass      def _run():-        if not args.get("foreground"):+        fg = bool(args.get("foreground"))+        if not fg:             begin_background_envelope(float(est + 60))         done_est = 0         try:             for key, label, dur in steps:+                if not fg:+                    # RE-ARM per step: one est-sized envelope EXPIRED mid-tour on real runs+                    # (485-890s vs ~376s armed) and later steps stole the user's foreground+                    # while they typed in other threads (John, live during ab's demo,+                    # 2026-08-16). Nesting is refcounted, so re-arming is free.+                    begin_background_envelope(float(dur * 3 + 240))                 state.update(step=key, stepLabel=label,                              percent=int(100 * done_est / est),                              elapsedSec=int(time.time() - state["started"]))@@ -7724,19 +7731,52 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->         "show_symbol", "show_footprint", "show_3d_package", "make_test_project",         "bind_library_3d", "show_library", "show_project",     }+    _ran_backgrounded = False     if command in _WINDOW_RAISING:         if (args or {}).get("foreground"):-            # The USER's choice, always honored (John, 2026-08-15): they may want to WATCH.-            # No envelope - the window may come to front and stay there.-            args = {k: v for k, v in args.items() if k != "foreground"}+            # Foreground is allowed but never free (John, 2026-08-16): the CALLER must say+            # WHY, and the user gets that reason as an ab caption for ~3s BEFORE the steal,+            # so they know what is happening and can yield (or object). No reason = refuse+            # with a teaching hint - same pattern as every reason-gated Adom verb.+            fg_reason = str(args.get("foregroundReason") or "").strip()+            if not fg_reason:+                return {"success": False, "errorCode": "foreground_reason_required",+                        "error": "foreground:true needs foregroundReason (a short user-facing sentence).",+                        "_hint": ("Fusion verbs ALWAYS run in the background so the user is never "+                                  "interrupted mid-typing. If the user genuinely wants to WATCH, re-call "+                                  "with foreground:true AND foregroundReason:\"<why, in words the user "+                                  "will read>\" - it is shown as an ab caption for 3 seconds before the "+                                  "window comes forward, so they can yield.")}+            try:+                ad_client.notify("Fusion is coming to the foreground",+                                 fg_reason + " (Requested by your AI - Fusion verbs otherwise always "+                                 "run in the background.)", level="info", reach_user=True)+                _time.sleep(3.0)   # the user's 3-second heads-up BEFORE any focus change+            except Exception:+                pass+            args = {k: v for k, v in args.items() if k not in ("foreground", "foregroundReason")}         else:             _sec = ADDIN_COMMAND_TIMEOUTS.get(command, 60) + 20             begin_background_envelope(float(_sec), minimize=(command == "start"))+            _ran_backgrounded = True++    def _annotate_background(res):+        # Tell the CALLING AI what just happened window-wise (John, 2026-08-16): the verb+        # ran under the background envelope - no focus was taken, nothing to apologize for,+        # and the AI should not "helpfully" foreground anything afterward.+        if _ran_backgrounded and isinstance(res, dict):+            res.setdefault("windowEtiquette", "background")+            note = ("Window opened/changed in the BACKGROUND - the user's focus was NOT touched. "+                    "Render results via fusion_state. Only pass foreground:true (with "+                    "foregroundReason) if the USER asked to watch.")+            h = res.get("_hint")+            res["_hint"] = (h + " " + note) if isinstance(h, str) and note not in h else (h or note)+        return res      # Direct handlers (don't need the add-in)     handler = COMMAND_HANDLERS.get(command)     if handler is not None:-        return handler(fusion_info, args)+        return _annotate_background(handler(fusion_info, args))      # Check if Fusion is installed and running before any add-in-dependent command.     # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.@@ -7941,7 +7981,7 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->                 pass             _set_long_command(command)             try:-                return _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)+                return _annotate_background(_proxy_to_addin(addin_cmd, args, timeout=proxy_timeout))             finally:                 _clear_long_command()         result = _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)@@ -7950,7 +7990,7 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->         # blind. Read-only verbs are excluded to avoid per-call screenshot latency.         if command in MUTATING_COMMANDS:             result = _merge_dialog_array(result)-        return result+        return _annotate_background(result)      # Unknown command — check installation/running status for helpful errors     if not fusion_info.get("installed"):