feat(automation): bridge fidelity — slice tx, key/PTT, menu reachability, lineedit submit, resize, multi-instance + station identity#3819
Conversation
…chability, submit, resize, multi-instance Phase-2 "fidelity" verbs that let the agent automation bridge drive code paths invoke/get couldn't reach, plus per-PID instance isolation. - slice tx <id>: make a slice the TX slice (external-split transition), set-only via SliceModel::setTxSlice — the same chokepoint the rigctld split path uses; radio-authoritative (no client-side clear). - key ptt on|off / key mox: drive PTT/MOX via RadioModel::setTransmit — the spacebar-PTT event filter and mox_toggle QShortcut that invoke cannot target. Keying gated by AETHER_AUTOMATION_ALLOW_TX (same rail as txtest/atu) and arms the force-unkey watchdog; unkey is ungated. - invoke <le> submit [value]: emit returnPressed to commit a line edit (retune/login/send). setText stays side-effect-free on purpose, since other reachable fields attach irreversible actions to returnPressed. - menu reachability: resolveMenuBarAction() resolves a QAction anywhere in the menu bar even while the menu is CLOSED, so invoke can open menu-launched dialogs (AetherControl/Network/Radio Setup). Handles the macOS native menu bar, where Qt reparents menus to top-level QMenu widgets that menuBar()->actions() never sees. Adds menu list/open. - resize <w> <h> [target]: resize a top-level window so the panadapter x_pixels (== SpectrumWidget width) reaches a realistic value for headless render-size tests; returns spectrumWidth. Does not force xpixels (would desync the local FFT decoder). - station <name> + auto-apply on connect: set the per-GUI-client station name (client station) so MultiFlex peers see the agent; NEVER the radio callsign. Restored on stop, re-applied on reconnect. - whoami + per-PID isolation: default socket is aethersdr-automation-<pid> (no env override needed), discovery written to a per-pid <temp>/aethersdr-automation/<pid>.json entry plus a back-compat legacy pointer that self-deletes only. AETHER_AUTOMATION_LABEL tags an instance; whoami returns pid/socket/label/station. All 18 live checks pass on a FLEX-8400M (ANT2 dummy load). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pter zoom - MainWindow: a robot status-bar chip, shown only under AETHER_AUTOMATION, mirroring AETHER_AUTOMATION_STATION (default "Claude") so the operator sees at a glance that an agent is driving. Kept separate from the station-nickname label so it never fights radio status updates. - SpectrumWidget: objectName + accessibleName on the waterfall zoom buttons (panZoomInBtn/panZoomOutBtn/panZoomBandBtn/panZoomSegBtn) so the automation bridge can target them by a stable handle instead of the U+2212 minus glyph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the full diff against the codebase. This is a well-structured, defensively-coded phase-2 pass on the automation bridge — thanks @jensenpat. All 6 CI checks pass (build, macOS, Windows, CodeQL, a11y static analysis, cpp analyze), and the changes stay within the stated scope.
Verified against the tree:
- Every API the new verbs call exists with matching signatures:
RadioModel::setTransmit(bool, PttSource=Mox)(RadioModel.h:329),SliceModel::setTxSlice(bool)(SliceModel.h:211),RadioModel::slice(int)(:287),transmitModel()(:65),TransmitModel::isTransmitting(),sendCommand,isConnected. AppSettings::instance().value("StationName", "")mirrors the connect handshake exactly (RadioModel.cpp:2345) — correct use ofAppSettings(notQSettings), and reads the existing key rather than introducing a new flat key, so Principle V is fine.- Good conventions throughout:
QPointerfor the deferred station re-apply, best-effort I/O withopen()/mkpath()checks, the legacy discovery file removed only after confirming it still points at this PID,triggerMenuActioncorrectly hardened for themenu == nullptr(closed/top-level) case, and TX keying gated behindAETHER_AUTOMATION_ALLOW_TXwith the watchdog armed viam_txKeyedSinceMs. The SpectrumWidget objectName/accessibleName additions are a nice a11y bonus.
A few minor, non-blocking notes:
-
doResizezeroesminimumWidthwithout restoring it (AutomationServer.cpp, theif (win->minimumWidth() > w) win->setMinimumWidth(0)line). Harmless under the offscreen test path this targets, but it permanently mutates window state — ifresizeis ever run against a normal on-screen window the min-width floor is silently lost for the session. Since the bridge is dev-only this is fine; just flagging the side effect. -
The 1000 ms
singleShotstation re-apply races against the handshake's ownclient station <user>send. It works because the handshake fires well within 1 s, but it's a timing assumption — if that send is ever delayed past the window the agent name could be overwritten. A signal-driven "after handshake completes" hook would be more robust if this ever proves flaky. -
Default socket rename (
aethersdr-automation→aethersdr-automation-<pid>in main.cpp) is a behavior change for any driver that hardcoded the literal socket name. The back-compat legacy discovery file mitigates this for discovery-based drivers, andAETHER_AUTOMATION_SOCKETpins it — but worth a line in whatever driver docs exist so the multi-instance default doesn't surprise existing callers.
None of these block merge. Nice work on the closed-menu QAction resolver and the macOS native-menu-bar fallback in particular — the per-PID discovery directory + self-deleting legacy pointer is a clean answer to the concurrent-instance collision.
🤖 aethersdr-agent · cost: $9.0479 · model: claude-opus-4-8
…lback (crash) invoke "<label>" trigger on a menu-bar QAction ran action->trigger() synchronously inside the QLocalSocket readyRead callback. Several menu-bar actions open MODAL dialogs (Configure Shortcuts, Slice Troubleshooting, Memory, Profile Manager, ...) via dlg.exec(), which spins a nested event loop. Running that nested loop inside the CFSocket read callback (qt_mac_socket_callback -> canReadNotification -> QIODevice::channelReadyRead -> handleLine) re-enters socket I/O and corrupts the socket notifier, segfaulting on a later readyRead (EXC_BAD_ACCESS in doActivate). It also blocked the response until the dialog was dismissed. Defer the trigger to a clean main-loop turn via QTimer::singleShot(0) so any nested dialog loop runs on a normal stack, never inside socket I/O. The response returns immediately with "deferred":true; post-state must be re-read (dumpTree / menu list). This was newly reachable because the menu-bar reachability feature lets invoke fire a CLOSED-menu action; the same hazard also applied to the pre-existing visible-menu-action path, which this fix likewise covers. Verified: triggering Configure Shortcuts / Slice Troubleshooting / Profile Manager / Memory back-to-back returns in 1-3 ms, the bridge stays responsive, and the app survives with no crash report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Reviewed with a focus on the TX-keying safety surface (Principle VI) — it's airtight across all three layers:
invokestill refuses TX menu actions via the new closed-menu path: the isTransmitAction guard (AutomationServer.cpp:1262) is preserved between resolution and trigger, and detection is null-owner-safe — it checks the action's own kTxKeyingProperty marker AND an action-name deny-match (mox/ptt/transmit/cwx), neither of which depends on the owning menu.- The
keyverb gates keyOn behind AETHER_AUTOMATION_ALLOW_TX, leaves unkey ungated, andkey moxtoggles by reading isTransmitting() so only the key-direction is gated; routes through RadioModel::setTransmit. - It arms the pre-existing force-unkey watchdog (m_txKeyedSinceMs → onTxWatchdog), so a key-on with no key-off can't transmit indefinitely.
Also verified: the deferred menu-trigger (singleShot(0)) correctly avoids re-entering the event loop inside the QLocalSocket read callback when a menu action opens a modal dialog; setText stays side-effect-free with an explicit submit; station identity never touches the persisted callsign; multi-instance per-pid sockets + enumerable discovery are clean.
Directly resolves the three gaps in #3813 (line-edit submit, closed-menu reachability, render resize) plus slice-tx, semantic keying, and multi-instance. Live-tested 18/18 on a dummy load, CI green. Excellent, defensively-coded work — thanks @jensenpat.
…lback — Principle XI. (#3826) ## Summary Follow-up crash fix to #3819. A bridge `invoke <target> click` could segfault the app. `invoke <target> click` (and `toggle`) ran `QAbstractButton::click()` **synchronously inside the `QLocalSocket` read callback**. When the target is a **`QToolButton` with a dropdown menu**, clicking it opens that menu's popup loop (`popupTimerDone` → `QMenu::exec` → `QMenu::popup`) — a **nested event loop** — right inside the CFSocket read callback. That re-enters socket/window state and crashes: ``` QWindow::geometry() ← SIGSEGV (null window, far=0x8) QCocoaWindow::setVisible(bool) QMenu::popup → QMenuPrivate::exec QToolButtonPrivate::popupTimerDone QAbstractButton::click() AutomationServer::doInvoke (AutomationServer.cpp:1555) AutomationServer::handleLine AutomationServer::onReadyRead qt_mac_socket_callback → __CFSocketPerformV0 ``` (The app was `Role: Background` at the time, which makes the popup's native window resolution especially fragile.) ## Root cause & fix This is the **latent sibling** of the bug #3819 already fixed: that PR deferred the menu-**action** trigger path out of the socket callback; the widget **click/toggle** path was left synchronous and opens popups/dialogs the same way. Fix: defer `click`/`toggle` to a clean main-loop turn via `QTimer::singleShot(0, qApp, …)` (QPointer-guarded), so any nested popup/dialog loop runs on a normal stack — never inside socket I/O. The response returns `{deferred:true}`; **state setters** (`setValue`/`setText`/`setChecked`/`setCurrentText`/`setCurrentIndex`/`submit`) stay synchronous and keep their `newValue` round-trip. ## Verification Built on `main`, launched a **backgrounded** automation instance, and clicked the menu-bar overflow popup button (`qt_menubar_ext_button`) repeatedly: - `invoke … click` → `{deferred:true}` in <2 ms (was: nested popup → crash) - bridge stayed responsive (`ping` immediate) - app survived every click with **no crash report** ## Note The general principle now holds across both paths: **a bridge verb must never run a nested event loop (modal dialog / popup menu) synchronously inside the socket read callback.** If a *state-setter*'s slot is ever found to open a modal, the same one-line deferral applies; an alternative is to dispatch all command handling off the socket callback entirely (kept out of scope here to preserve the synchronous `newValue` model). --- 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…tcuts (#3791) (#3820) ## Summary Adds **Master Volume Up** and **Master Volume Down** to the configurable keyboard-shortcut list (Audio category), as requested in #3791. ## Root cause `MainWindow::registerShortcutActions()` (`src/gui/MainWindow_Shortcuts.cpp`) registers the Audio category with per-slice AF Gain Up/Down, mute/mute-all, master mute, and squelch — but **no master volume up/down**. The user is correct that this is simply a gap: the identical nudge logic already ships for external controllers (the Aether Control / StreamDeck `"VolumeUp"` / `"VolumeDown"` dispatch in `MainWindow_Controllers.cpp`). ## Fix Register `master_volume_up` / `master_volume_down` in the Audio category, reusing that exact, already-shipped path: - clamp `MasterVolume` ±5 into `[0,100]`, - update the title-bar slider via `TitleBar::setMasterVolume()`, - push to the audio path via `applyMasterVolume()` — keeping GUI, persistence, and TCI in sync. Registered with **no default key** (Up/Down are taken by per-slice AF Gain, and master volume is a distinct concept — the user binds keys themselves) and **`autoRepeat=true`** so holding the bound key ramps continuously (consistent with `rf_gain_up/down` and the tune actions). Single TU; one added include (`TitleBar.h`, needed for the `setMasterVolume` call since `MainWindow.h` only forward-declares it). ## How the agent automation bridge proved it Built and driven through the bridge (`AETHER_AUTOMATION=1`). The two new entries had to be shown to actually appear in the **Configure Shortcuts…** list. Reaching that dialog needed the bridge fidelity work in #3819 (closed-menu `QAction` trigger + per-pid sockets), so the proof was captured against a local merge of this branch with `feat/automation-bridge-fidelity`. This branch itself is the single-file change above. **1 — machine-readable.** The dialog's Action selector is a non-editable `QComboBox` populated directly from `ShortcutManager::actions()`. `setCurrentText` only takes if the item exists, so an exact echo is proof of registration: ``` invoke AetherSDR::ShortcutDialog/QComboBox setCurrentText "[Audio] Master Volume Up" -> newValue="[Audio] Master Volume Up" PROVEN invoke AetherSDR::ShortcutDialog/QComboBox setCurrentText "[Audio] Master Volume Down" -> newValue="[Audio] Master Volume Down" PROVEN ``` **2 — visual.** With the dialog's search filter set to `Master Volume`, a `grab` of `AetherSDR::ShortcutDialog` shows exactly two rows — **Master Volume Up** and **Master Volume Down**, both **Audio** category, both with empty Current/Default Key (no default binding, as designed). | Field | Detail | |---|---| | Issue | #3791 — add master volume up/down to configurable shortcuts | | Root cause | feature simply not registered in the Audio shortcut table (`MainWindow_Shortcuts.cpp`) | | Fix | +2 `registerAction` entries reusing the shipped controller `VolumeUp/Down` nudge; +1 include | | Bridge proof | non-editable action-combo `setCurrentText` exact-echo (both entries) + filtered dialog `grab` showing both Audio rows | | Build | clean (Ninja, all cores) | 💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Release prep for v26.6.5 Docs + version-string updates for the **50 merges** since v26.6.4. No code change — safe to merge without a rebuild. ### Changes - **CHANGELOG.md** — new `[v26.6.5] — 2026-06-28` section, headlined by **KiwiSDR receive sync** (#3872), **SmartMTR TX meters** (#3776), the **PROF profile-switcher applet** (#3829), and a large **agent automation bridge** expansion (#3851/#3856/#3883/#3842/#3832/#3819); categorized Added/Changed/Fixed/Performance/Removed compiled from the full merge list. Folds in the prior `[Unreleased]` entries and resets `[Unreleased]` to empty. - **CMakeLists.txt / README.md / AGENTS.md** — `26.6.4` → `26.6.5`. - **ROADMAP.md** — current cycle → `post-v26.6.5`; four v26.6.5 highlights added atop *Recently shipped*. ### Not included - Tagging `v26.6.5` is a separate, explicit step (not done here). - The untracked `docs/aetherd-headless-engine-design.md` RFC and `prototypes/` are intentionally left out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Phase-2 fidelity pass on the agent automation bridge (
AETHER_AUTOMATION). It adds verbs that let an agent drive code pathsinvoke/getcould not reach, hardens concurrent-instance discovery, and gives the bridge a visible identity — addressing 8 pieces of operator feedback collected from running 12 bug-fixes through a design → code → prove loop.Builds on
origin/main(which already carries the slice/observability/TX-rails work). All 18 live checks pass on a FLEX-8400M into an ANT2 dummy load.What's new
client station <name>auto-applied on connect (defaultClaude,AETHER_AUTOMATION_STATION); propagates to other MultiFlex clients; restored on stop. Never touches the radio callsign. Plus a 🤖 status-bar chip.slice tx <id>→SliceModel::setTxSlice(true)— the external-split transition, set-only, radio-authoritative.key ptt on|off,key mox→RadioModel::setTransmit(the spacebar-PTT filter + mox_toggle shortcutinvokecan't target). Keying gated byAETHER_AUTOMATION_ALLOW_TX+ force-unkey watchdog; unkey ungated.aethersdr-automation-<pid>; discovery written to a per-pid<temp>/aethersdr-automation/<pid>.jsondirectory entry (enumerable) plus a back-compat legacy pointer that self-deletes only.AETHER_AUTOMATION_LABEL+whoami.invoke "<label>" triggernow resolves a QAction anywhere in the menu bar while the menu is closed → opens AetherControl…/Network…/Radio Setup… etc.menu list/menu open. Zoom buttons got stable objectNames.submitaction emitsreturnPressed(optional inline value).setTextstays side-effect-free by design.resize <w> <h> [target]resizes the window so panadapterx_pixels(== SpectrumWidth) reaches a realistic value; returnsspectrumWidth.Design decisions (two open questions from the feedback)
setTextauto-commit? No — kept it pure, added an explicitsubmit. Three other bridge-reachable line edits attach irreversible actions toreturnPressed(SmartLink login, manual connect, live DX-cluster/RBN send); auto-commit would fire those from a plain value-set.x_pixelsis the radio's FFT bin count / stream rate — the thing render-size-dependent code needs. Screenshot/token economy is a separate axis on thegrabpath, so we resize the window (not forcexpixels, which would desync the local FFT decoder).Multi-instance: what PID isolation does and doesn't solve
The per-pid socket + discovery directory fix the software collision (two bridges no longer steal each other's socket — verified with two instances coexisting). They do not lift the radio's hardware limits: the MultiFlex GUI-client ceiling and the radio-wide slice cap are unchanged. Guidance: non-radio tests (UI/menu/dumpTree/grab) scale freely with PID isolation; radio-touching tests are bounded by MultiFlex and must serialize or target distinct radios; only one instance should hold
ALLOW_TX.Notable fix found during live test
menu listreturned 0 on macOS: AetherSDR doesn't setAA_DontUseNativeMenuBar, so Qt reparents menus to top-levelQMenuwidgets thatQMainWindow::menuBar()->actions()never sees. The resolver now also walks top-levelQMenus unconditionally — confirmed by enumerating 9 menus and opening the AetherControl dialog from a closed menu.Safety
key ptt on,key mox-to-key) is gated behindAETHER_AUTOMATION_ALLOW_TX, identical totxtest/atu, and arms the existing force-unkey watchdog; unkey is always allowed. No raw key-event injection verb (rejected as leakier).client stationname (session-scoped, dropped on disconnect), never the persisted radio callsign.Test plan / live results
Exercised end-to-end via the in-repo harness (
tools/automation_probe.py+automation_logwatch.py) with a safety gate that asserts the TX antenna is the dummy load before any keying:whoami(pid/socket/label/station/txAllowed), radio connected, TX-antenna safety gatewhoamistation,station <name>verbmenu list(9 menus),invoke "AetherControl…" triggeropens dialog from closed menuslice tx <id>flipstxSlice(radio-confirmed)setTextdoes not retune;submitretunes 3.6→3.63resize→ SpectrumWidth 1590key ptt on→ transmitting;key ptt off→ unkeyed;key moxtoggles; final unkeyed — all on ANT218 / 18 PASS. The gate also correctly refused to key in an earlier run when the TX antenna was still ANT1.
💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat