- base.html adds shared readout colors (blue, teal, violet, orange), a row-action component (per-action color with a light outline, as on Contacts) and tinted tag chips
- Radio and Feeds stat tiles read in their own colors; Feeds' Active Errors turns red only when there are errors
- Greeter's 311 solid red "Mark Ungreeted" buttons are red-outline row actions, Manual/Rollout are tinted chips instead of lamp green and amber, and names are plain text instead of pink code
- Feeds' four solid row buttons use the row-action style
- Radio's writes are primary blue; amber stays only on the frequency/bandwidth write, which changes the channel for every node on it
- the dashboard's ranked lists (commands, users, channels, repeaters, paths) show counts as tinted chips in each list's own color with a muted rank, instead of lamp-colored pills
The polish passes had turned these gray in the name of keeping lamp colors for state. They now carry color that means something:
- Real-time's filter lenses take the colors of what they show (Everything in the link blue, Messages in the message blue, Commands in the bot's violet), filling when chosen; Pause fills while the tape is paused, and the 10-minute activity bars are blue
- the byte breakdown's segments are fuller tints (34% instead of 20%) with darker or lighter ink, 5.4:1 and up in light and 11.9:1 in dark, and each segment card's swatch is solid
- Contacts' Active Contacts counts read in the link blue and its Advertisements counts in teal, with the card icons matching
Contacts and Cache inserted their alerts at the top of the content, so every save, star or error pushed the whole page down, and Radio, Feeds and the connection manager each pinned their own fixed alert at the same spot, so two messages covered each other. base.html now has one toast stack (window.viewerToast) that floats over the page below the navbar, or along the bottom on a phone, keeps at most four, announces them politely (errors as alerts) and takes either a string or a node the caller filled with text. Every page routes through it.
The Contacts polish had turned the row actions into muted bare icons, which made them hard to find and to tell apart. Each action now has its own color and a light outline: a gold star (filled when starred), blue info, teal location and red delete, with icon contrast from 3.8:1 in light to 8.7:1 in dark. The new-device counts take their role colors, matching the chips and the activity chart.
Fixes from a rendered pass at desktop and phone width in both themes:
- stat tiles: counts are grouped and set as tabular readouts in the text color, so 257,052 and "Companions" no longer break mid-number or mid-word, and the blue, teal and green number colors are gone
- device roles are tinted chips in the dashboard's categorical data colors (repeater blue, companion orange, room server indigo, sensor teal) instead of lamp amber and green badges, and the activity chart reads the same --role-* tokens; the chips measure 6.6:1 or better
- SNR, distance, bytes/hop and advert counts are plain tabular readings, with a dash for unknown values instead of N/A and green or amber SNR badges
- the hop count that opens a contact's paths is a button (it was a span with a click handler, so unreachable by keyboard), labeled with the hop and path counts
- row actions are bare icons that take the link color on hover, red only for delete; a starred contact shows a filled star with aria-pressed
- sort icons stay attached to their header labels, and the table still fits at 1440px with all four actions
- paths modal: "N paths this radio has heard to <name>" instead of "from observed_paths table", correct plurals ("1 hop", "heard 2 times"), path bytes in the text color in both themes instead of Bootstrap's pink code color, a neutral Primary chip instead of a green badge and border, scrollable and full-screen on phones
- advert modal: the JSON wraps instead of running off the window, and a missing contact shows the page's error toast instead of a blocking alert()
- phone cards label distance and SNR and drop the bare "N/A · N/A" line; the sort select wraps under "Select all on page" with its full label
- "Just now" is no longer green
The web viewer's static/js/meshcore-browser.mjs was an esbuild bundle of @michaelhart/meshcore-decoder at 0.2.7 or older, added without a record of its version or how it was built. Those versions read the path-length byte as a plain byte count, so every packet with 2- or 3-byte hops was misparsed from the path onward: wrong header and type, a path split into single bytes, garbage payload fields, garbled advert names and group text that never decrypted.
The bundle is rebuilt from 0.3.0, which takes the hop count from bits 0-5 and the hash size from bits 6-7. Twelve real packets from the bot's database (adverts, group text, direct messages and returned paths at 1, 2 and 3 bytes per hop) decode to the same hop count, path and payload as the bot's own decoder; multibyte adverts get their real names and a 2-byte-hop Public message decrypts, on the tape and in the byte breakdown.
scripts/meshcore-decoder-browser/ holds the browser entry the old bundle was built from (recovered from the bundle: it re-exports the library and routes Ed25519's SHA-512 through crypto-js so signatures verify on a plain-HTTP page) and build.sh, which fetches the published package, installs its runtime dependencies with install scripts disabled and bundles it with a pinned esbuild in a temporary directory. It reproduces the committed bundle byte for byte. The unused meshcore-browser.min.mjs is removed, and the multibyte workarounds from the byte breakdown polish (skipping the browser decode and saying so) are taken out again.
The bundled browser decoder reads the path-length byte as a plain byte count, so on a packet with 2- or 3-byte hops it misplaces everything after it. The modal took its header fields, path and payload from that decode first: a flood group-text packet (0x15) showed as 0x3D Custom, the path came out as dozens of single bytes, and the advert and decrypted sections read garbage. The header now comes from the byte on the wire, path and payload from the bot's own decode, and multibyte packets skip the browser decode with a note saying why. The tape skips browser decoding for them too ("multibyte path, not decoded here") rather than risk a garbled advert name or a false "no key here".
The rest is the polish pass:
- the modal shows the 16-character packet hash the tape, search and Copy hash use, not the decoder's 8-character one
- byte ranges account for transport codes ("Byte 4", not a fixed "Bytes 0-0"), paths are grouped by hop and payloads by byte so the hex wraps between bytes, and the path-length byte's bits are explained (hop count in bits 0-5, hash size in bits 6-7)
- colors come from the base.html theme tokens, light first with dark overrides, replacing a separate bluish dark palette and its light-mode patches; byte segments use the dashboard's categorical data colors as tints (5.3:1 and up in light, 8.9:1 in dark) instead of lamp green, amber and red, the hash is no longer amber and the binary column no longer red
- one level of cards: the raw bytes are a single well instead of a card holding a box with a 4px amber stripe, the segment cards carry a swatch that ties them to the bytes, and labels follow the viewer's small-caps style
- Copy raw hex is the primary button, and copying falls back to execCommand when the Clipboard API is missing (plain-HTTP LAN viewers), saying so if the browser still refuses
On a phone the strip was forced onto one line with items allowed to shrink below their text, so once the tape was held and the "N new · show" button joined Pause, "heard 10 s ago" ran into the rate and the button's label spilled out of it. The readings now keep their width, the controls drop to their own line at the right when there isn't room, and the button reads just "N new" on a phone (its accessible name keeps the full wording).
Real-time was three equal panels (commands, packets, messages) that each scrolled on their own, couldn't be held still, and showed the same transmission up to three times. It's now one tape with one row per transmission: a packet, the channel message decrypted from it and a command it triggered share a row, joined by packet hash, and the bot's reply hangs under the command with how many times a repeater was heard echoing it. Those echoes fold into the reply instead of getting rows of their own, and a packet heard several times is one row with a count and every reception (SNR, RSSI, hops, path) when opened.
A receiver strip at the top says whether the bot is listening or the radio is offline or not responding (from the banner poll base.html already runs, now published as a banner-status event), when it last heard a packet, the packet rate and a 10-minute activity bar. The tape holds still while you read it: Pause (P), scrolling down, a moving mouse pointer or keyboard focus queue new rows behind an "N new" button. Rows can be kept (safe from the 300-row limit and from Clear), filtered to messages or commands, by packet type or channel, and searched (/). Replayed history sits under a "Heard before you opened this page" divider, and adverts and keyed group text are decoded in the browser and labeled as such. Each row's glyph chip carries its packet type's icon in the dashboard chart's color for that type; decoded messages get a stronger tint, and the bot's commands and replies are violet.
Server side, command rows carry trigger_packet_hash (from the message's routing info) and, once the transmission tracker matches a repeat, reply_packet_hash. Replayed rows carry stream_ts and replayed, and live rows stream_ts. Commands stored before this pair with their message only on an exact sender, channel and text match within 20 seconds.
Real-time is admin-only now: its socket already was, so anonymous visitors got three "Admin login required" badges over spinners that never filled. They're redirected to login and the nav link is hidden from them.
The byte breakdown sliced multibyte paths by hop count, so 2- and 3-byte hops spilled into the payload; it uses the path's byte count and shows the raw path-length byte. Route types 0 and 2 were swapped, and the channel key store was often built before the decoder had loaded, so it never decrypted anything. js-sha256 is vendored under static/vendor instead of loading from cdnjs.
bot.radio_offline is written only when the breaker changes state. A run that went offline and was then restarted left 'true' in bot_metadata, and the new process, which starts with no send failures, never transitioned, so nothing overwrote it: the web viewer showed Radio Offline indefinitely while the bot sent normally. The viewer-clear check couldn't help either, since it only runs while the bot itself thinks it's offline.
start() now publishes the fresh offline state right after setting bot.initializing, before the early web viewer starts, so the stored flag always belongs to the current run. The zombie flag didn't need this; a successful connect already writes it false.
The pre-Bootstrap-5 #007bff and #28a745 (and their rgba forms) still sat on the navbar brand, status lamps, dark form focus, the conversation rail, the Contacts focus ring, the login page's brand tokens, and Realtime's hex path and newest-row glow. They now use Signal Blue (#0d6efd) and Lamp Green (#198754), which also brings the hex path's white text from 3.1:1 to 4.5:1. The brand stays above 3:1 on the rail as large bold text. The unused .log-entry, .command-entry and .packet-entry rules in base.html are removed.
Realtime packet rows carried an 11-color stripe down the left of each rounded card, repeating what the colored type badge in the row already says. The stripes are gone, so the stream reads like the dashboard's cards.
DESIGN.md adds the 2px (hair) and 5px (meter) radii that legend swatches, focus rings and meters already use, drops the drift notes these changes resolve, and records the touch-target sizing from the adapt pass.
The radio offline and zombie banners printed the raw UTC ISO string (2026-10-15T14:42:58.701279+00:00). They now show the time in the viewer's own time zone with a relative age, such as "Oct 15, 7:42 AM · 2 days ago" (just the time when it's today), in a <time> element that keeps the exact value in datetime= and a full date in its tooltip. The 10-second banner poll reformats it, so the age stays current.
Every page loaded Chart.js (~200KB), Moment and js-sha256 from a CDN whether it used them or not. Chart.js now loads only on the dashboard, Contacts and Multibyte, and js-sha256 only on Realtime, through a page_libs block in base.html. Moment is gone: its one call wrote to a #last-update element that no page renders, and that formatting is now done natively. The remaining base scripts stay at the end of <body>, so they don't block first paint.
The Mesh page loaded Leaflet 1.9.4, MapLibre GL 5.6.0, maplibre-gl-leaflet 0.1.4 and vis-network 9.1.9 from unpkg, and the login page loaded DM Sans and Outfit from Google Fonts, so neither worked on a LAN without internet. They are now vendored under static/vendor/ and static/fonts/ with their licenses and a README recording versions and sources (source-map comments stripped, since the maps aren't vendored). With nothing left on unpkg or Google Fonts, the CSP drops those hosts; the characterization golden is regenerated for that. package-data gains a fourth directory level for Leaflet's images.
Bootstrap, Font Awesome, Socket.IO, Chart.js and js-sha256 still come from CDNs; PRODUCT.md records that as the remaining offline gap.
On a phone most controls were under 32px: Bootstrap's btn-sm, form-select-sm and 16px checkboxes are used everywhere, with 103 of 214 controls on Contacts and 61 of 127 on Plugins under 24px. A shared (pointer: coarse) block in base.html grows standard controls to 44px and small ones to 36px, checkboxes, radios and switches to 24px, and dropdown items, nav links, close buttons, summaries and Leaflet's zoom buttons to match. The dashboard's bare icon buttons and the log level toggles get the same treatment in their own stylesheets. Desktop with a mouse is unchanged. What remains under 32px is the 24px checkboxes and inline text links, both within WCAG 2.5.8.
Bigger controls squeezed some card-header titles into a column, so card headers now wrap their controls onto a second row when both don't fit.
/config was 429px wide on a 390px phone because its single-column grid used 1fr, which can't shrink below its widest child; it now uses minmax(0, 1fr). The radio offline and zombie banners took about 200px of a phone screen; below 576px they drop the secondary sentence (the reconnect warning stays), put the time on its own line and tighten their padding, for 132px. Their small text also loses an opacity that put it at 3.96:1.
Realtime message times were 9.3px (0.58rem), Realtime meta chips and phone Mesh stat labels 9.9px, Mesh, Plugins and Contacts badges about 10.5px, and the Contacts path count compounded to 10.5px as a <small> inside a badge. All now sit at the 0.7rem floor DESIGN.md sets, and the path count inherits the badge size in a lighter weight. Phone layouts still fit at 390px.
Monospace was five different stacks, three of them ending in 'Courier New'. base.html now defines --font-mono (the ui-monospace stack from DESIGN.md) and points Bootstrap's --bs-font-monospace at it, and logs, realtime, mesh, contacts, greeter, config and the legacy .log-entry all read it.
Bootstrap's text-success, text-warning, text-info and text-danger are fill tones, so used as text they failed contrast (amber on white was 1.6:1, info 2.0:1), and nothing adjusted them for dark mode. base.html now defines per-theme status text tokens that pass 4.5:1 on every surface tier, and the text utilities, text-muted, form-text, code, links and the outline buttons resolve through them. Info and warning badges carry dark text. Ink Muted moves a step darker so it holds on paper-sunk.
The dark message bubbles on Realtime were solid navy, forest green and purple, which overpowered the page and dropped the sender names to 1.7:1. They are now low-alpha tints of the signal and lamp hues over the card, like the dashboard delta chips, and the error bubble is red in both themes instead of purple in dark.
Also: the light log levels and level toggles move to tones that pass on the log well, the dark ERROR toggle uses dark text, Realtime drops its local orange text-warning and its opacity on message times, and the four copies of the dark .table td * rule no longer force body text color onto badges and buttons. DESIGN.md records the tokens and the rule that a fill hue never goes on text directly.
A scan of the rendered pages found about 300 Plugins settings fields whose visible label wasn't tied to the control, so screen readers announced them without names. renderField now links each label (and its help text, via aria-describedby) to the control it renders. Contacts gets names for its search box, export range, purge threshold and the select-all and per-row checkboxes ("Select <name>"); the Radio create-channel labels gain for=; and the Multibyte time range and repeater search get aria-labels.
Icon-only buttons were named only by title, or not at all: the Realtime scroll buttons, the Mesh toolbar, contact row actions, the Plugins remove and reveal buttons, the Feeds and Radio row actions, every Bootstrap close button and the navbar toggler. Each now has an aria-label (scoped where it helps, such as "Scroll packets to newest" or "Remove Webhook 2"), with its icon hidden from assistive tech. Toggles that hold state expose it: the Mesh hot-connections button and the password reveal buttons set aria-pressed. The Plugins filter box and the dynamic key/value fields also get names, and repeating blocks name their enable switch after the block.
The Feeds form labels had no for=, so screen readers announced the fields without names, and the new-channel box, the three JSON config boxes and the Greeter search relied on placeholder text alone. Labels are now linked to their fields, the unlabeled boxes get aria-label, and the Output Format Help button sits beside its label instead of inside it. The empty spacer label above Preview Format is now a hidden div, and the accordion toggles declare aria-expanded and aria-controls.
Packet rows with raw hex only opened their detail modal on a mouse click. They now act as buttons (role, tabindex, Enter and Space), show the hover treatment plus a focus ring when focused, and closing the modal sends focus back to the row that opened it, since Bootstrap doesn't do that for a modal opened from script.
The header that opens a plugin's settings was a <div> with a click handler, so Expand all was the only keyboard route into a card. The title area is now a <button> with aria-expanded and aria-controls, kept separate from the enable switch, and Expand/Collapse all update it through the same path. The enable switch also gets an accessible name, since it had no label.
The sort headers were plain <th> cells with a click handler, so keyboard and screen reader users couldn't sort. Each header label is now a <button> inside the <th>, the active column carries aria-sort, and the focus ring follows the header text color. The Hops info tip sits outside the sort button, so opening it no longer re-sorts the table.
Fixes from a Codex review of the previous commit, plus the shared settings the rebase onto dev showed were still missing.
Settings that misstated or changed what runs:
- sports never read [Sports_Command] api_timeout (documented in the example config); the new field did nothing. It now sets the ESPN timeout.
- The path-byte requirement help on test, multitest and path said "bytes per hop"; the gate checks the path's total size (at least 2, or exactly 3).
- feed's allow_private_urls showed false while it inherited true from [Feed_Manager]. Fields can now name an inherit_section whose value they show when unset.
- notify_on_prefix_bytes was an int field for a value the service reads as a list (2,3), so the form refused a valid setting.
- The greeter's form defaults differed from the fallbacks it uses when the keys are missing. Both now come from shared constants.
- The MQTT weather limits accepted values the service clamps (payload 256 to 1 MiB, staleness 5 s to 7 days, length 64 to 4000).
Shared settings: the wx card now carries the [Weather] temperature, wind and precipitation units and the three temperature_*_format templates (with previews through format_temperature_high_low), next to wx's own [Wx_Command] unit overrides, which default to "Same as [Weather]". Two fields with one key in different sections are told apart as key@Section in the save payload; a bare key still resolves when only one field has it. An enum's "" option, like a blank number, removes the key. Packet capture brokers gain username, password, topic prefix, JWT timings and include_decoded; blank inheriting values are left out of block saves, since the service reads them with getint/getboolean when present.
Previews:
- They use the bot's saved config: a blank test format falls back to [Keywords] test, multitest lays out paths per condense_paths, and MQTT applies passthrough_max_length.
- A quoted empty template ("") counts as blank, as the commands strip the quotes first.
- A str.format width or precision over 500 isn't rendered, so `{sender:100000000}` can't make the endpoint build a huge string.
- The page invalidates an in-flight preview as soon as the field changes, and shows the server's note about fallbacks instead of guessing it.
The endpoint inventory golden gains the preview route (401 for anonymous requests when a password is set).
The Plugins page now has a field for every setting the plugins read. I compared each plugin's settings_schema against config.ini.example and the keys the code reads; fourteen plugins had gaps (greeter, trace, stats, path, wx/gwx, rain, test, multitest, sports, worldcup, cmd, feed, the webhook, weather, packet capture, earthquake and prefix-collision services) and the World Cup service had no form at all. Webhook URLs, bot tokens and channel keys use the password field. A guard test needed four [Rain_Command] keys documented in config.ini.example.
Template fields get a { } button that opens a reference of the placeholders they take, with click-to-insert. Fields rendered by the piped engine (test's response_format, path's reply_prefix) also list the filters and conditionals. Every template field previews as you type: piped fields against a 2-byte, a 1-byte and a direct sample message, so a conditional's effect shows before saving, and the str.format fields (greeter, mesh info, multitest, MQTT weather) as their plugin renders them, including what it sends when the template has a mistake. The specs and renderers live in modules/template_reference.py; the preview endpoint only renders and never saves. The greeter's channel_greetings parser and multi-part split moved into module functions so the command and the preview share them.
Fixes the new fields would otherwise have exposed:
- Clearing a number field saved `key =`, which makes getint/getfloat raise and the plugin fail to load. A cleared number now removes the key.
- The weather service raised in __init__ when only some blitz_area_* corners were set. It now needs all four and logs a warning otherwise.
- A blank MQTT weather json_template saved an empty template, so every reply said no message had arrived. Blank now saves the default.
Nominatim was never given a language, so place names in wx, gwx and other location replies came back in each place's local language ("千代田区, JP" for Tokyo). Forward and reverse lookups now pass [Localization] language, with English as the fallback where OpenStreetMap has no name in it. [Localization] place_name_language overrides it: "local" keeps the previous behavior, a language tag picks another language. Reverse-lookup cache keys include the language, so addresses cached in local languages are looked up again; "local" keeps the old keys.
Conflict in wx_command.py: the refactor split _get_weather_for_location_sync into _locate_*, _noaa_forecast_body and _with_noaa_alerts. The fallback now checks coverage after _locate_* and when _noaa_forecast_body returns its fetch error. _openmeteo_weather takes no address_info: a city's label comes from city_to_lat_lon again, answered from the geocoding cache the first lookup filled.
Conflict in modules/core.py: #345 changed _validate_config_snapshot, which this branch moved to config_reload.py. The method there now matches dev's version; reload_config's warning merged cleanly.
_validate_config_snapshot expanded '%' interpolation on every value, so a value read raw with a literal '%' (an [MqttWeather] json_template with "% RH", which config.ini.example documents as fine) failed every reload, although startup accepts the same file. Such values are now returned and named in a warning, and the reload goes ahead. Typed schema keys are still checked as before.
[Weather] openmeteo_fallback (default false, only with weather_provider = noaa) answers wx from Open-Meteo when NWS reports a point as outside its coverage (HTTP 400/404 from /points), in gwx's format and labels and wx's units. Timeouts and server errors keep today's error. The point goes into the existing NWS no-coverage cache, so later requests skip NWS for 24 hours. wx <place> alerts there replies that alerts are not available.
gwx's per-point body moves into _weather_for_point so wx can reuse it without geocoding twice; gwx's output is unchanged.
Codex on f0649558: inspect.signature follows __wrapped__, so an operator-installed command under modules/commands that wraps PingCommand.get_help_text with a get_help_text(self) override looked like it accepted the message, was called with it, and raised where dev retried and returned its help. _accepts_message now uses follow_wrapped=False. New test covers that override. No bundled get_help_text is decorated.
Space bunny on d60e7cf1: operators may install their own command files inside modules/commands or modules/commands/alternatives (documented, and preserved across upgrades), so a class under modules. is not necessarily bundled. Such a command written as get_help_text(self) was treated as bundled, called with the message and raised, so help returned nothing where dev returned its text. help_text_for now calls once with the message only when the method comes from a class under modules. and its signature binds the message; otherwise, including when the signature can't be read, it uses dev's call-then-retry. New test: an operator command in the modules.commands namespace with get_help_text(self) gets its help.
Codex on d60e7cf1 found two more edges where dispatch differed from dev: the retry called the captured method instead of reading command.get_help_text again, so a local plugin that replaced its help callable before raising got the original back; and a local class that reuses a bundled function (get_help_text = PingCommand.get_help_text) was classified as local, so a TypeError inside the bundled body ran it twice. The retry reads the attribute again, and a function counts as bundled when any bundled class in the MRO holds it in its __dict__. Both reproductions now match dev.
Accepted: a local override that forwards *args to a bundled get_help_text now passes the message through (the bundled method accepts it), and adapters that pass a message to a formerly no-message bundled method now succeed instead of raising.
Codex on ba293bb2: functools.wraps copies __module__, so a local subclass that wraps PingCommand.get_help_text with a no-message override looked bundled and lost the retry it needs (it raised instead of returning its help), and callables with odd __module__ values could fail or be misclassified. help_text_for now treats a method as bundled only when it is a plain bound method of this command whose function is defined, per the command class's MRO, by a class whose own __module__ is under modules. Everything else (local classes and overrides, partials, Mocks, other callables) gets dev's call-then-retry. New test: a wrapped local override of PingCommand still gets its help.
Accepted residual, besides joke and rain: an instance-level wrapper (functools.partial, Mock(wraps=...)) around a bundled get_help_text whose body raises TypeError now runs the body twice, since the bundled method accepts the message.
Codex on 495df80e: choosing the help call by signature also dropped the TypeError retry for local plugins written as get_help_text(self, message=None), which used to fall back to generic help when the message call raised, and inspect.signature itself can raise TypeError on odd callables and then take the wrong branch. help_text_for now decides by where get_help_text is defined: bundled commands (defined under modules.) are called once with the message, so an error from their body propagates as it did before 7aba432b; anything else gets exactly the old call-then-retry. Tests pin both: a bundled body's TypeError runs once, a local plugin's is retried without the message. joke and rain stay the accepted residual (no retry for a TypeError inside their help bodies).
Codex also found two inaccuracies in b569ed5d's docs: ChannelBridgeBase subclasses must also supply _open_http_session and _init_queues, and an empty [Keywords] format means no reply.
S7's units item. GlobalWxCommand and WeatherService each read [Weather] temperature_unit, wind_speed_unit and precipitation_unit, lowercased them and fell back to fahrenheit/mph/inch with the same three warnings; weather_common.load_open_meteo_units does that once, next to load_open_meteo_model. Both still accept kn. wx keeps its own NOAA unit reading (_unit_setting), which has different fallbacks and sources. Old-vs-new over 5,000 random configs matched on values and warnings.
S3, scoped to what was still duplicated. effective_channel_send_scope exists to predict the scope send_channel_message will apply, and the two carried the same resolve → outgoing override → global-marker test → normalize steps. Both now call _send_scope(scope, channel), which returns (resolved, scope_to_use, is_global); send_channel_message still uses resolved for its scope-source log line, and the predictor still maps a global scope to None and any exception to "#unknown". The plan's ScopeResolution record is not needed: flood_scope.py already holds the shared lookups (channel_scope_entry, outgoing_override, section_flood_scope) and the three public contracts call them. The scope matrix golden passes unchanged.
Plan rule 3c said every module this refactor creates is type-checked from its first commit; web_viewer/tracking.py was on the ignore list because it moved out of app.py with app's existing errors, and mypy skipped the bodies of untyped helpers in all the new modules. tracking.py is off the ignore list, and the 30 new modules get check_untyped_defs. The 25 errors that surfaced were all missing annotations: container annotations (paths_by_key, tracking, server_stats, result, daily_by_role, feed params), casts for page/page_size where pagination guarantees ints, FeedSubscriptionsMixin's two host attributes, and an ignore for Flask-SocketIO's request.sid. No runtime change.
Codex found that after 7aba432b a TypeError raised inside one of the 40 newly widened help bodies reached the retry in help and command_manager, so the body ran a second time (and could then succeed); before, the no-message call ran it once and the error propagated. Both call sites now go through base_command.help_text_for(command, message), which binds the message against get_help_text's signature first: commands that accept it get get_help_text(message), local plugins written as get_help_text(self) get get_help_text(), and an error from the body propagates after one call. A callable with no introspectable signature keeps the old try-and-retry.
Accepted residual difference: joke and rain already took the message, and a TypeError raised inside their help bodies used to be retried without it; it now propagates. help's no-message test now uses a real get_help_text(self) instead of a Mock that raised TypeError, and a new test pins the single call.
Phase 9's _broadcast item, scoped down. The six live-stream handlers (command, packet, mesh edge, mesh node, message and log line) each copied the same "take the clients lock, list the sids with this subscribed_* flag" block. LiveStreamMixin._subscribed_clients(flag) now returns that list; each handler keeps its own emit, event name, payload, debug line and error handling, in the same order. Moving to Socket.IO rooms would change delivery and stays out.
Phase 8's mechanical part. The 19 asyncio.get_event_loop() calls that run directly in an async def (found by walking each file's AST; calls inside nested sync functions or lambdas don't count) become asyncio.get_running_loop(). Inside a running coroutine both return the same loop; get_running_loop says so and never creates one. The five calls in sync functions (maintenance.run_data_retention, radio_link._handle_serial_probe_error, scheduler._run_in_current_loop and run_scheduler, weather_service._run_daily_forecast) can run on threads without a loop, where the two differ, so they stay. No test patches get_event_loop.
S4. _check_admin_access and announcements' _check_announcements_access ran the same pubkey validation and logging, differing only in wording; both now call BaseCommand._pubkey_in_acl(message, acl, kind, denied), which builds each log line from those two words, so every line keeps its exact text and level. The admin check still runs it inside its try, announcements outside, as before.
ping and magic8 had identical get_response_format overrides reading [Keywords] <name>; a keywords_format_key class attribute on BaseCommand now does that, and commands without one still get None. typing.Optional stays importable from both modules.
Old-vs-new over 20,000 randomized configs and senders (missing, malformed, mixed-case and listed keys; missing sections; quoted and empty formats) matched on results and log calls; three mutations were each caught.
S4. BaseCommand and the 40 bundled overrides that took no argument now take `message: MeshMessage | None = None`, matching joke and rain, which already use the message. None of the 40 bodies read it, so their help text is unchanged. help and command_manager keep their TypeError retry, since local plugins may still implement get_help_text(self). For bundled commands the retry no longer runs: `help <command>` calls get_help_text(message) once instead of failing and calling it again. A new test pins the signature for every bundled command class. The offline smoke run against the real config produces the same help text for all 46 commands.
S6. The sender's latest position (airplanes, path, test) and the repeater-or-room-server lookup by name (prefix, solarforecast) were each pasted into the commands, alongside location.py's own copies for get_companion_lat_lon and lookup_repeater_lat_lon. They now run through location.latest_contact_position_rows and location.repeater_by_name_rows. The copies differed only in how they drop a zero coordinate, so that is a zero_rule parameter: "both" (location.py's helpers: drop only 0,0) or "either" (the commands: drop a fix with either coordinate zero), and each caller passes the rule it had. Each command keeps its private wrapper, its pubkey access, try/except, log text and value conversion; airplanes' _get_bot_location now calls get_bot_lat_lon, which is the same code.
I did not move the commands onto resolve_location: its labels and geocoding rules differ from what each command does today. Old-vs-new over 20,000 randomized cases against a real SQLite table (zero coordinates, ties, near-duplicate names, missing pubkeys, database errors, no db_manager) matched on results and log calls for all eight functions; four mutations were each caught.
Codex's final review found that moving code out of modules dropped imports that local plugins could import from them, for example `from modules.service_plugins.discord_bridge_service import EventType`, which would make such a plugin fail to load. Comparing every top-level name bound on dev against the branch (both trees imported, so names under TYPE_CHECKING are excluded) found 24 in 11 modules: EventType, copy and contextlib in both bridges; sha256, Iterable and hmac_mod in message_handler; RotatingFileHandler, colorlog, contextlib, contextvars and functools in core; hashlib and re in utils; contextlib in feed_manager, earthquake_service and weather_service; Any in cmd and help; time in path. Each is re-imported exactly as dev imported it.
The public-import guard only covered `from modules... import` statements, so it missed these. It now records every unconditional top-level import and assignment, regenerated from dev's tree (461 → 1,597 names), and fails if the discord bridge stops exposing EventType.
_start_cleanup_scheduler and _cleanup_old_data move verbatim to modules/web_viewer/cleanup.py (AST-identical; the other 23 BotDataViewer methods are unchanged). app.py keeps importing delete_timestamp_rows_in_chunks and retention_delete_settings so modules.web_viewer.app still exposes them; as with the earlier mixins, patching those names on app no longer reaches the moved code. app.py is 4,808 lines.
_setup_socketio_handlers, _mark_subscribed, _replay_packet_stream, _disconnect_login_sockets and _cleanup_stale_clients move verbatim to modules/web_viewer/socket_clients.py (AST-identical; the other 25 BotDataViewer methods are unchanged). app.py keeps importing disconnect and emit so modules.web_viewer.app.disconnect and .emit still resolve; as with the earlier mixins, patching those names on app no longer reaches the moved handlers. app.py is 4,868 lines.
_collect_alert_entry parses one ATOM entry and appends it to the new-alert list (its early continues become returns; the append stays before the debug line, so a timestamp the log cannot format still leaves the alert queued, as before); _send_new_alerts sends them two seconds apart. Each loop keeps its own try/except in place. AST comparison shows the helpers and the rest of _check_weather_alerts identical to the original apart from continue → return.
_forecast_location_name reverse geocodes the bot's position once and caches it; _append_tomorrow_forecast appends tomorrow's sky and high/low. Both bodies and the rest of _get_weather_forecast_result are AST-identical to the original statements, called at the same points inside the same try, so its error handling and log lines are unchanged.
_visibility_miles converts Open-Meteo's visibility from feet or meters; _open_meteo_extra_conditions builds the dew point, visibility and pressure strings. Both bodies and the rest of _open_meteo_current are AST-identical to the original statements, called at the same points, so translate calls and log lines keep their order. The Open-Meteo characterization passes unchanged.
The "wx <place> alerts" branch (geocode coordinates, zipcode or city, reply with the error or the full alert list) moves verbatim into one async helper; execute calls it inside the same try. The helper body and the rest of execute are AST-identical to the original, and the execute dispatch characterization (which covers the alert inputs for each location type) passes unchanged.