Repository navigation
Ergopti v1.0.6
Ergopti 1.0.6
Ergopti — keyboard layout: install the ergonomic layout without running an application.
Ergopti Plus — advanced typing tools: the companion application adds remapping, shortcuts and text expansion.
Downloads
Ergopti — keyboard layout only
No Ergopti Plus application is required for these keyboard layouts.
| Platform | Download | Installation |
|---|---|---|
| Windows | Ergopti_windows.exe | Run as Administrator, log out and back in, then select the layout in Settings → Time & Language → Language & region. |
| macOS | Ergopti_macOS.zip | Unzip, copy the .bundle folder to /Library/Keyboard Layouts/, log out and back in, then add it in System Settings → Keyboard → Input Sources. |
| Linux | Ergopti_xkb.zip | Follow the XKB installation guide. Includes standard, ANSI, plus and plus-ANSI variants. |
Ergopti Plus — application with advanced typing tools
Select your platform and download the corresponding file.
Windows — Ergopti Plus
| Asset | Description |
|---|---|
| ErgoptiPlus.exe | Compiled AutoHotkey executable (AHK v2 runtime bundled) |
Installation — app:
- Download
ErgoptiPlus.exe. - Double-click to run. On first launch, resources are extracted to
%LOCALAPPDATA%\Ergopti. - Your personal configuration is stored in
%USERPROFILE%\.config\ergopti_plus. If this folder does not exist yet (first launch), the setup wizard will open automatically.
macOS — Ergopti Plus
| Asset | Description |
|---|---|
| ErgoptiPlus.app.zip | macOS application (Hammerspoon bundled; Karabiner-Elements installed on first run) |
Installation with Homebrew:
brew tap adrienm7/ergopti
brew install --cask ergoptiplusergoptiplus installs the stable releases and ergoptiplus@dev the dev prereleases; this release is in ergoptiplus. brew upgrade then updates it with your other casks, and the app still updates itself from its About menu.
Installation — app, by hand:
- Unzip
ErgoptiPlus.app.zipand moveErgoptiPlus.appto/Applications. - Remove the quarantine flag (the app is not yet Apple-notarised):
xattr -dr com.apple.quarantine /Applications/ErgoptiPlus.app- Launch the app. On first run, Karabiner-Elements will request a System Extension approval — required for key remapping.
- Your personal configuration is stored in
~/.config/ergopti_plus/. If this folder does not exist yet (first launch), the setup wizard will open automatically.
Linux — Ergopti Plus
| Asset | Description |
|---|---|
| ErgoptiPlus-linux-amd64.deb | Driver package for Debian, Ubuntu and Mint |
| ErgoptiPlus-linux-x86_64.rpm | Driver package for Fedora, RHEL and openSUSE |
| ErgoptiPlus-x86_64.AppImage | Portable driver — any distribution, no root required |
| ErgoptiPlus-linux-x86_64.flatpak | Sandboxed driver, for a distribution-agnostic install |
| ergopti-plus-linux.tar.gz | Driver bundle with install.sh, installs under your home directory |
Which file for your distribution:
- Debian, Ubuntu, Mint, Pop!_OS, elementary →
ErgoptiPlus-linux-amd64.deb - Fedora, RHEL, CentOS, openSUSE, Mageia →
ErgoptiPlus-linux-x86_64.rpm - Arch, Manjaro, EndeavourOS → build it from
tools/build/PKGBUILDin the repository; no Arch package is attached to this release - Flatpak →
ErgoptiPlus-linux-x86_64.flatpak, installed withflatpak install --bundle. It requests--device=all, which is the only permission that reaches/dev/inputand/dev/uinput; the sandbox still cannot grant access your user does not have on the host, so the udev rule in step 2 is required either way - Any other distribution, an immutable one, or no root access →
ErgoptiPlus-x86_64.AppImageorergopti-plus-linux.tar.gz
Alpha. CI exercises virtual input devices, display sessions and package launches. Physical keyboard behavior and desktop-shell integration still need validation on the target distribution.
Installation — app:
- Open the downloaded package with your distribution's software installer and install it.
- Open Ergopti Plus from the applications menu. Input permissions are prepared by the package; if additional authorization is needed, Ergopti opens a graphical administrator prompt.
- Follow the first-use window. If group membership changed, sign out and back in when prompted.
- Choose Global actions → Start at login to enable or disable automatic startup.
Removal: choose Global actions → Uninstall Ergopti, or remove the package through your software manager. The standalone archive also includes an ownership-checked uninstall script. Personal settings are preserved.
Changelog (click to expand)
Timestamps are in Paris time (Europe/Paris, CET/CEST).
Commits since v1.0.5.
Features
-
Linux: Recommend what Windows recommends, CapsWord on chords
The maintainer decided on 2026-10-22 that restoring the recommended
values on Linux gives the Windows preset. LAlt then CapsLock now
recommends caps_word; the three- and four-finger gestures and the
four-finger tap recommend the Windows bindings; the WPM pill is neutral
as on Windows. Fresh configurations still start neutral. Two values stay
different, and the gate proves why: the four-finger vertical swipes stay
unassigned because win_app_prev and win_app_next do not exist on Linux,
and the number row stays native because Linux only reports the layout's
own row.CapsWord is an executor action that locks CapsLock, so the engine's own
per-letter mode had no production caller. It is removed: its engine
owner, the hook's arm/plan/ack ports and output custody, the native
semantic adapter, the shared policy and their tests. Simultaneous chord
slots, which refused caps_word because only that mode could run it, now
hand it to the executor like a pair tap.test-defaults-single-source compares every recommendation both drivers
share and rejects the former LAlt then CapsLock value; the key-catalogue
gate fails if the hook regains arm_caps_word; Lua tests publish and fire
a CapsWord chord through the real picker and manager executor. One
commit because the key-catalogue gate pins all three changes and the
pre-commit hook refuses a partially staged file. -
Linux: Copy the selected files' paths in a file manager
copy_selected_path only opened the primary selection on Linux, while
Explorer and Finder copy the selected files' paths. In a known file
manager (Nautilus, Dolphin, Thunar, Nemo, Caja, PCManFM...) it now
copies the selection through the clipboard's text/uri-list read,
decodes the file: URIs through _shared/lua/file_selection and leaves
the paths in the clipboard, one per line, with the shared "Path
copied" notice. No file selected falls back to opening the primary
selection, as on macOS; an unreadable or remote selection is refused
without copying part of it. Ctrl+C goes to file managers only, since a
terminal would interrupt its program, so GNOME and KDE under Wayland,
which name no focused application, keep the opening behaviour. -
Open the shared action picker for the Windows and Linux hold
The maintainer decided on 2026-10-22 that a tap-hold key's hold and a key
combination's hold slot open the shared action picker on every system, as
macOS does since 61ab0fb and 1eb85e2. Windows and Linux still
unfolded a submenu (a list dialog for a Linux pair) of the hold options.The picker lists what a hold can be: the options of [tap_hold.hold_picker],
every modifier and combination and the navigation layer, without the none
sentinel, which the picker's own none row stands for. Windows builds them in
TapHoldHoldPickerItems, Linux in hold_options.picker_items, from the same
shared catalogue. The tap_hold_key_hold group is replaced by the one command
row tap_hold_key_hold_action on the three drivers.A pick goes through the same owners as the submenu did (TapHoldSetHold with
the configuration folder, SetKeyCombinationHold, tap_hold_writer.set_hold,
the pair scope edit). An id the hold does not offer, or a refused write,
keeps the picker open: on Windows ShowActionPicker gains a slot mode that
commits the pick while the window is open and reloads only after it closed;
the Linux bridge already kept a refused pick open. The Linux titles use the
hold presentations, which now declare linux.The group-row caption tests move to the agent's System 1 group, the last
caption group Windows and Linux share. -
Macos: Count unmatched, late and stuck key events per session
Issues #99 and #100 report swallowed keys and an accent popup on a
plain tap, and no log line could say whether a key-down or a key-up
went missing, or whether the taps saw them late.infra/key_event_health.lua counts what the keymap keyDown and keyUp
taps see of physical input: key-ups with no key-down, key-downs whose
key-up was lost, keys silent for 3 s while down, character keys held
500 ms or more for macOS (its own event stamps, with the autorepeats
and the rule switch they crossed: a lease fence, a karabiner.json
reload or a tap outage), callbacks 250 ms or more late, and Karabiner
lease failures by kind. Lines are written off the tap, coalesced per
run-loop turn, and carry the session totals; no key code or character
is ever logged. adapters/event_clock.lua measures lateness against the
freshest recent events, so the two clock domains (raw ticks on Apple
silicon) and any constant offset cancel out. classify_with_fence names
a physical replay so its copy is not counted twice.macOS only: the Windows and Linux reports have not shown this class.
-
Updater: Hand a feed-only release to Sparkle from Versions
When the GitHub API gives the Versions window no list, the window reads
the releases Atom feed, which names no files and no digests, so a direct
install is refused with the no-details warning and nothing could be
installed from the window at all.On macOS, Sparkle verifies a release on its own against the EdDSA
signature of the channel appcast, which carries that channel's newest
release only. The page now offers, for a feed-only list, an install of
the channel's newest release when the shared offer rule offers it; the
click posts install_through_updater, the window checks the tag again
against the feed it read and asks Sparkle to check that channel through
modules/updater/feed_handoff.lua, and the page shows the new handed_off
phase while Sparkle's own dialog asks the user. Older rows keep the
existing warning. The hand-off lives in its own module so every file
that asks Sparkle for an update still owns no install primitive.Windows and Linux seed no hand-off: neither has an updater that verifies
a release without the API list (the Windows exe is unsigned on the dev
channel and may be on stable; the Linux tarball has no signed update
metadata and its .sha256 is only an asset the API names), so their
feed-only lists keep the no-details warning, stated where it is raised.The three page strings landed in all 21 locales with 7b01b5f.
-
Metrics: Label typing data built from an incomplete history read
The maintainer chose to show the counts that a failed SQLite read still
produced instead of refusing the whole history. The shared dashboard
now takes an incomplete flag per part, the manifest and the n-gram
range, from macOS publications and from Windows and Linux blobs, and
the freshness banner says the history is incomplete until both parts
arrive complete again. The notice is localized in all 21 locales. -
Menu: Restore and clear each keyboard modifier group
Each modifier submenu of the keyboard shortcuts (Alt, Ctrl, Ctrl+Shift,
Win on Windows; Option, Ctrl, Ctrl+Shift, Cmd, Cmd+Shift on macOS; Ctrl,
Ctrl+Shift, Alt, Super on Linux) now opens with the shared « Restaurer
les valeurs conseillées » and « Tout effacer » rows, then a separator.One manifest fragment, keyboard_group_scope_controls, declares them on
the three drivers with the two existing locale keys. Both narrow the
Shortcuts scope to the group's slots and their action parameters: the
restore puts the shipped slots back to their recommended action and
removes the user's additions, the clear empties the group. Like every
scope row they apply at once, after a backup, without a question.
Fix
-
Ci: Read a run listing again when it shifts between pages
Run 39878950422 failed the macOS build with "Duplicate qualified managed
Ollama inputs". The run listing is read 50 records a page, newest first;
an artifact uploaded between two page reads pushes the last record of
page 1 onto page 2, so one artifact came back twice. listAll now reads
such a torn listing again from its first page, up to five times, and
fails when it keeps shifting. Two records with distinct ids still reach
the duplicate refusal. -
Linux: Name the lost-click notice title before sending it
c280aa4 added the notice for held tray clicks the refreshed menu
cannot match to ui/menu/rebuild_owner.lua. Its notifier options wrote
title = I18n.get(...), which the menu-row census of ui/menu/ reads as
a static row built outside the manifest, so both menu-row ratchets
failed (linux 1, baseline 0). The title is read into a local first, as
local_model_offer does; the notice is unchanged. -
Linux: Rebuild the tray menu after a hotstring reload
A reload (tray row, SIGHUP, magic key, local reload control) re-read
every source but never asked for a menu rebuild, so the pre-reload rows
stayed live: counts were stale and a click made against an old
checkmark toggled the reloaded state back. hotstrings_config.reload now
notifies its change owner, which retires those rows; clicks made on
them replay on the refreshed menu with the state they asked for. -
Linux: Replay a tray click on a retired row on the refreshed menu
A refresh request retires every published row at once, but the desktop
shell keeps showing that tree until the next pump publishes a new one.
A click made in that window (a second event in the same tray pump, a
click queued while a synchronous reload ran, a refused publication)
returned false and was dropped without a log line.The rebuild owner now holds such a click under its row path with the
state it asks for (menu.click_intent) and replays it once against the
next acknowledged tree, never through the retired closure. A row that
already shows the requested state is skipped and logged, a row the new
tree lacks is logged and reported with the localized notice, and a
click dropped at shutdown or past the hold limit is logged. -
Macos: Hold menubar clicks during a reload for the reloaded menu
The static menubar stays clickable through the whole controlled reload
(lease fence, input drain, teardown), while run_exclusive refuses every
save there. A toggle clicked then reached the log only: some rows rolled
back, the hotstrings master stayed switched in RAM, and the reload
discarded it. A click on the cached tree during its refresh delay also
flipped the state the stale checkmark no longer showed.Every generated row now runs through ui.menu.click_gate. A click made
while a terminal transition is pending is held; the menu teardown step
hands it to the successor through the settings store when the reload
proceeds, logs it dropped on a quit, and runs it in place when the
reload goes away, with a bounded wait. The successor consumes the record
once, rejects a malformed or expired one, and replays each row by path
after the menu prime. A click on a dirty tree resolves on a fresh one.
Each click carries the state it asks for (shared menu.click_intent, as
the Windows replay schema v3), so a late click whose row already shows
it is skipped and logged; an unmatched row gets the localized notice. -
Ci: Fail a stalled Ubuntu mirror download within 30 s
Run 39850037912 lost the desktop-webview row twice the same way: the
package lists came at 14 MB/s, then one 9 MB package sat on a stalled
azure.archive.ubuntu.com connection for 9.5 minutes until apt gave up
on it, and the 10-minute install step timed out. Earlier runs today
lost other Linux rows to the same stall.The apt prefix shared by every CI install now sets
Acquire::http::Timeout=30 and Acquire::Retries=5, so a stalled
connection fails in 30 s and the retry takes the next mirror the
runner lists, instead of holding the step. -
Linux: Ask Ollama for no model while the AI is off
Every tray build called the model rows provider, which read the Ollama
inventory (a GET of /api/tags behind a deferred timer) whatever the AI
switch said, so a Linux boot with the AI off still probed Ollama. The
provider now lists installed models only with the AI on; the model
browser row stays reachable. The model-list cases that relied on the
read with the AI off now say the AI is on. -
Macos: Keep the AI inert while it is switched off
With the AI off the macOS driver still bound every profile shortcut,
resumed the MLX download a reload interrupted, and ranollama list
from the models manager constructor and from every menu build. Windows
and Linux wait for the switch-on.The startup controller now logs the Windows line and returns before
the reattachment and the shortcut restore when the AI boots off. A
shortcut chosen while off stays a saved preference without a native
hotkey, and the Ollama manager neither pre-warms nor refreshes its
installed-model list. The switch commit arms the profile hotkeys and
the waiting reattachment at the switch-on and disarms them at the
switch-off; a startup that turns the AI off releases the hotkeys too.Fixtures that exercise binding, reattachment or the warm-up refresh
now say the AI is on, the premise those behaviours require. -
Ci: Admit a Unit lot whose job record stays in progress
Run 39836200270 refused the Common Unit admission of Shared UI
rendering because the API still reported Integration tests (Packaging
and boot) as "in_progress" with no conclusion, half an hour after every
one of its steps, the runner's "Complete job" included, had completed
with success and its dependants had started. The record is never
refreshed, so the re-read added in 4487bce cannot help.Such a job is now admitted on its step records: it must be in progress
with no conclusion, end with a successful "Complete job", and every step
must have completed with success or a skip. A queued job, a missing or
failed "Complete job" or any failed step still refuses. -
Linux: Refuse an over-ceiling injector pause without FFI (L105)
Only the FFI nanosleep path checked the shared timer ceiling. On a
runtime without FFI the injector handed 2^53 + 4096 ms, NaN or an
infinite pause straight to luv.sleep, or forked the sleep command with
it: a wait that never ends taken as an ordinary inter-phase pause.sleep_ms now refuses any value outside NumberPolicy.within_timer_ceiling
before choosing a fallback, so FFI, luv and the sleep command share one
admission. The regression test reaches the pause through the selected
output ports and fails on the old code for both fallbacks. -
Lua: Keep backslash bytes in shared and Linux POSIX paths
A backslash is an ordinary filename byte on macOS and Linux, but the
shared Lua both drivers load and the Linux driver still split, trimmed
and walked paths with their own [/] classes. A home folder "/home/a"
redacted the different folder "/home/a\b" as "~\b", a reloaded file
"back\slash.toml" was reported as "slash.toml", and a configuration,
shared or checkout folder ending in a backslash lost that byte.The runtime log, diagnostic snapshot, reload gate, extension scan,
evdev loader, layer files, layer preset and driver control folder, plus
the Linux manifest reader, tap-hold scope and manager and webview root,
now go through _shared/lua/native_path.lua. The two Linux bootstraps
that run before the shared tree is on package.path split at slashes
alone, as the macOS init.lua block does.The separator guard now reads _shared/lua and linux/ beside macos/.
Genuine Windows-path parsers are listed with a reason (program_parameter
drive and UNC checks, the redaction of both slash spellings, open_target
Windows-only targets, the portable backup name check). Four sites stay
in the baseline because concurrent changes own their files:
config_migrate, config_unused_keys, llm/profile_selector and the Linux
keylogger sqlite_writer. Windows AHK path handling is out of scope. -
Linux: Read the xclip sentinel back as no file selection
The file-selection probe writes a text sentinel before Ctrl+C and
expected a uri-list read to fail when nothing was copied. wl-paste does
refuse the missing target, but xclip answers every target with the text
it owns: measured with xclip 0.13 under X11, the read returned the
sentinel with status 0, so make_executable_selection logged an
unreadable selection instead of saying no file was selected. A read
equal to the sentinel now means no file selection. -
Linux: List X11 windows with their application in one query
The excluded-applications picker reads WindowInfo.getAll(), which ran
xdotool search and then one synchronous getwindowname per window while
the menu waited, and never set appId: on X11 the picker offered no
application at all. getAll() now runs a single wmctrl -lp, names each
window by its process from /proc (the identity getFocused() reports)
without a spawn per window, logs a missing wmctrl once and a failed
query as an error. The .deb and the Nix wrapper now carry wmctrl, which
the workspace and show-desktop actions already used. -
Linux: Take the instance lock for every daemon launch
launch.sh took its flock only for --tray and --session-start, so a
terminal launch through the packaged command started a second daemon
next to the tray one, both grabbing the keyboard. Every launch that
starts the daemon now takes the lock; --help stays lock-free. A
refused launch says the session lock is held: a duplicate session
start still exits 0, a direct launch exits 75. -
Macos: Decode each key-down once and log key pairing off the taps
macOS CI showed the key pairing counters cost the hot path more than the
suite allows: the keyDown wrapper read the key code a second time, an
anomaly line armed a timer from inside the event tap, and the stale
verdict widened onKeyDownRaw's pinned (event, provenance, status) shape.The wrapper now hands the code it read and the stale verdict to
onKeyDownRaw through one context table cleared after every call, so each
key-down is decoded once and the raw handler keeps its signature; the
keyUp tap reuses its code the same way. key_event_health no longer arms
timers: the keymap tap watchdog (every second) and a lease failure flush
its queued lines. The audit-v5 guard follows the one decode site. -
Macos: List the event clock adapter in the diagnostics wiring
Every adapter must carry an ADAPTER_SPECS entry so the healthcheck can
report whether it is wired; adapters/event_clock.lua, read by the keymap
taps and the physical fence, had none (adapter wiring meta test). -
Macos: Keep the lease worker fed while its answers are unread
25411ee stopped failing a lease on an on-time deadline while an
Ergopti menu kept task output unread, but Lua sends the next PING only
once it has read the last PONG, and the worker's inner fences itself
after 17.5 s without a private command. A menu open that long still
lost a healthy lease (exit 73), switched every Ergopti rule off under
the fingers and redeployed karabiner.json (issues #99 and #100). A
grace that ran out after a modal window also failed without a read
turn, and an answer read after the verdict was discarded silently.While the output-turn probe is pending, the lease writes a keepalive
PING at the deadline and on every heartbeat tick (READY gets its own
cadence), never two within the outer's 2.5 s command budget, which
refuses an overlapping PING. Their PONGs are consumed without a
deadline. The grace expiry goes through the same probe, its ceiling
rises to 120 s now that the worker stays fed, and a late answer is
logged. No Swift change: the outer already accepts a PING once the
previous one is answered. macOS only (Karabiner lease). -
Macos: Pass a key that waited behind the main thread untouched
When the Hammerspoon main thread is held (1.3 s to 5.5 s stalls in the
issue #100 log, 13.4 s in #99), macOS stops waiting for the tap
(kCGEventTapDisabledByTimeout) and delivers the key without it, while
the queued callback still runs afterwards. The physical fence then
replayed a copy of a key the application already had, and the keymap
built expansions on it that deleted or retyped the wrong text: doubled
or swallowed keys around every stall.A physical event whose callback is 500 ms later than the freshest
recent events is no longer consumed, replayed or answered: it passes
untouched, the keymap restarts its buffer, and the session counters
record it. Windows (AutoHotkey's own hook thread) and Linux (the evdev
reader) never replay a delayed physical event, so neither has this path. -
Macos: Hold a key-up back only behind its own replayed key-down
A physical event arriving while Ergopti output is still being typed is
consumed and replayed after it, so typing order holds. A key-up was
held back the same way even when its key-down had already reached the
application: rolling a letter into a hotstring terminator kept that
letter down for the application as long as the expansion took to type,
long enough for the macOS press-and-hold popup on a plain tap
(issue #100).The fence now holds a key-up only while its own consumed key-down still
waits unposted in the FIFO; a release types nothing that could overtake
the expansion. A replayed key-down is still followed by exactly one
key-up, after it. Windows and Linux do not consume and replay physical
key events behind their output, so only macOS changes. -
Macos: Bound every Accessibility call to one second
Every Accessibility call runs on the Hammerspoon main thread, where the
keyboard event taps also run, and macOS lets a call to an application
that does not answer wait six seconds. Issue #100 logged the
ignored-window probe holding the main thread 1.5 s at an application
switch, and the 13.4 s freeze of #99 was a chain of such waits; keys
typed meanwhile came out late, held or lost.Boot now sets the process-wide messaging timeout on the system-wide
element (hs.window.timeout, AXUIElementSetMessagingTimeout) to 1 s
before the first input owner starts, and logs an ERROR when macOS
refuses it. A hung application's read fails instead of freezing input.
Accessibility is the macOS driver's own API, so only macOS changes. -
Macos: Read the typing application without Accessibility in key taps
TextSender.focusedAppIdentity and terminalInputTarget run inside the
keyboard event tap: on every expansion that deletes (terminal pacing)
and, since 1.0.5, on every bare Return, Cmd+A and Cmd+L. They asked
hs.window.focusedWindow(), an Accessibility round trip to the focused
application. An application that does not answer holds it up to six
seconds while every key of every application waits behind the tap;
macOS then disables the tap and releases the queued keys late or held,
which issues #99 and #100 report as swallowed keys and as the
press-and-hold accent popup on a plain tap.The typing application now comes from NSWorkspace, which never asks the
application. Accessibility is the macOS driver's own API, so only macOS
changes. -
Windows: Run the updater stages as bundled -File workers
Antivirus sandboxes score the Windows updater as a malware stage: it
published its worker as Base64 fragments in environment variables,
decoded them, compiled them with [ScriptBlock]::Create and started
PowerShell with -EncodedCommand. The crash-report minimal writer also
launched an encoded command. The compiled exe carries that text.The staging worker (download, size and SHA-256 checks, swap worker
publication) is now vendor/ergopti_updater_staging.ps1, started with
-File and a single -TransportPrefix argument. The release data stays in
inherited environment values under that prefix: data, never source,
and out of reach of the cmd.exe wrapper's %VAR% expansion. The swap
worker is vendor/ergopti_updater_swap.ps1, which the staging worker
copies next to the staged executable exactly as it wrote the decoded
text before, and the crash-report fallback is
vendor/ergopti_crash_fallback_worker.ps1. A missing bundled worker
fails the install before any launch, with the usual logged error.Download, digest verification, replace, restart, rollback and failure
receipts are unchanged. -ExecutionPolicy Bypass stays on these -File
launches: it is process scope only, and the default Windows client
policy (Restricted) refuses every script file without an override.
macOS and Linux have no PowerShell stage and no equivalent pattern.The compiled script loses 3 -EncodedCommand, 1 [ScriptBlock]::Create,
2 FromBase64String, 1 ExecutionPolicy Bypass and 1 hidden window (the
swap worker's Start-Process moved to the bundle). Tests now read the
bundled files, run the real worker through the production argv and
cmd.exe, and fail if driver code contains an encoded stage again. -
Config: Keep the Lua table-array cleanup LuaJIT-safe and driver-neutral
The table-array cleanup compared numbers with math.type, which LuaJIT
(the Linux daemon and its CI suite) does not have; the LuaJIT gate
rejected the call. It now reads math.type once into a local that is nil
under LuaJIT, as the codec owners do.The contract case also counted every [hotstrings] row, so the Linux
driver, which does not read trigger_char, saw two rows instead of the
one array (CI run 39797045031, Linux ui). It now selects only the array
rows; whether a driver reads trigger_char is that driver's policy.
Verified under LuaJIT 2.1 (WSL Debian) and Lua 5.4 (macOS suite). -
Windows: Show partial typing history after a failed SQLite read
Any refused SQLite query failed the whole background projection, so the
dashboard kept its previous snapshot and said nothing about it. The
maintainer chose to show what was read instead, as macOS and Linux now
do.Inside the projection worker each reader query is now tolerated on its
own: a refused one contributes no row, every other keeps its exact
counts, and the blob carries metrics_manifest_incomplete or
_prefetch_data.incomplete, the flags the shared page turns into its
incomplete-history notice. Defects other than a refused read stay
fatal. The worker exits with a new "incomplete" status: the stage is
delivered to its window but never published as the reusable snapshot
nor kept in RAM, the reader image is saved only after a complete read,
and the window arms its bounded full retry, so the next read starts
over. The resident logs the incomplete read and its recovery once each.
The Edge fallback, which only reads published snapshots, logs and does
not open on such a read. -
Windows: Keep a NUL token or title distinct in dashboard JSON
The reader built its historical and today n-gram indexes and the window
title map with json_group_object, which the vendored sqlite3.dll cuts
at an embedded NUL: a token "a\0b" came back as a second "a" key beside
the real one and the page kept whichever arrived last. The walker's
histogram merges were fixed the same way. Every SQL-built reader object
is now spelled with json_quote over group_concat, and a real in-memory
SQLite read pins the exact keys of each one. -
Linux: Show and never keep a partial typing history read
The Linux dashboard already showed a partial manifest after a refused
SQLite query, but said nothing about it, read n-grams with no
completion signal, and kept the first "ready" payload as the cached
answer whatever its completeness, so a transient failure stayed on
screen for every later opening of the session.The reader now reports n-gram completeness, the payload carries the
same incomplete flags as macOS for the shared notice, and the bridge
never keeps an incomplete payload, so the next opening retries a full
read. The incomplete read and its recovery are logged once each. -
Macos: Show partial typing history after a failed SQLite read
Since the completion receipts landed, a transient SQLite error made the
typing dashboard refuse the whole history: the job asserted, the page
kept an old snapshot marked as failed, and a range request answered
null. The maintainer chose to show what was read instead.Full, live and range projections now publish their partial counts with
an incomplete flag that the shared banner turns into a notice. Such a
read is never saved as the first-paint snapshot and leaves the
projection cache untouched, so the next open retries a full read. An
incomplete n-gram extension is merged into a copy for display only.
The incomplete read and its recovery are logged once each. -
Config: Offer an unread table array below a read root on macOS and Linux
The shared Lua cleanup offered whole unread roots and addressable
records only. A table array under a root a reader consumes
([[hotstrings.old_rows]] next to [hotstrings] trigger_char) has no
addressable record and its root is not unread, so it was never offered.With the explicit whole-value capability both drivers already pass,
each such outermost array is one entry, removed with every generation
and proven against the typed document: only that path may leave it. -
Macos: Find webview windows among our own, never every window
Opening any webview froze Hammerspoon 2.8 s to 3.2 s on macOS 26
(issue #100). webview:hswindow() and hs.console.hswindow() call
hs.window.windowForID, an alias of hs.window.get, which runs find()
over hs.window.allWindows(): every window of every application read
through Accessibility on the main thread. force_focus paid it on every
open and up to twenty more times in its retry while a new window was
still being composited; the focus checks paid it again.adapters/own_window.lua makes the native call with windowForID swapped
for a recorder of the window number, for that synchronous call only,
then picks the window carrying that number among this process's
windows, found by PID. Number 0 or a window not listed yet reads as
nil, so the existing bounded retry still applies; a missing own
process raises. Every caller (ui_builder focus and focus test, metrics
dashboards, download window, console placement) goes through it.The anti-scan meta test now admits no other hswindow() caller and
ratchets every global enumeration (allWindows, orderedWindows,
visibleWindows, get, find, windowForID, hs.window(), window filters);
the remaining ones answer user gestures or ports, none on an open path. -
Ci: Re-read a Unit lot whose writer step record lags its job
Run 39785389458 refused the Common Unit admission of Shared UI
rendering: the Generators and parity lot was completed and successful,
but the API still reported its writer step as "pending". GitHub
publishes a job conclusion before its step records, so the admission
raced the API, not the lot.A refusal whose only cause is a single writer step still pending,
queued or in progress is now re-read up to six times, 10 s apart. Any
other writer state still refuses at once, with the same diagnostic,
and a writer that never completes is refused after the last read.The previous commit 90da28c carries this subject by a shared-index
race but holds another change (test_config_snapshot_obsolete_parent.ahk). -
Ci: Re-read a Unit lot whose writer step record lags its job
Run 39785389458 refused the Common Unit admission of Shared UI
rendering: the Generators and parity lot was completed and successful,
but the API still reported its writer step as "pending". GitHub
publishes a job conclusion before its step records, so the admission
raced the API, not the lot.A refusal whose only cause is a single writer step still pending,
queued or in progress is now re-read up to six times, 10 s apart. Any
other writer state still refuses at once, with the same diagnostic,
and a writer that never completes is refused after the last read. -
Macos: Ask the updater owner whether admission runs packaged
The instance admission compared the packaged bundle id itself, which the
one-owner gate refuses: Updater.is_local_source() answers whether this
is the embedded runtime. Admission now takes that answer, and an
unanswerable identity is treated as a foreign Hammerspoon, so a refusal
never quits a process that may not be ours. -
Windows: Offer bare root keys and unknown table arrays for cleanup
Boot warned about a bare root key ([].stale_root) and only counted an
unknown [[x]] table array as "key(s) in skipped sections", while the
unused-key cleanup skipped section "" and never saw a table array, so
neither ever reached the offer and both stayed in config.toml forever.The semantic snapshot now names each outermost table array once
(ArrayRows), and TomlConfigTableArrayKind judges it for both the loader
and the cleanup, so boot names an unknown array and the cleanup offers
what boot names. A root row is a leaf of the tree root. The document
writer drops every generation header of a deleted array, which it
otherwise re-emitted as empty generations, and a listed value covers
the records and headers beneath its path when its unknown parent
section is removed. The cleanup page and text name a root row without
an empty "[]" section. -
Windows: Own a physical keyboard slot parameter by its publication
The action-parameter owner judged every binding by a lowercase grammar
before asking the binding's published catalogue. A physical keyboard
slot keeps its scan-code spelling (win_SC03A), so its parameter
(keyboard__win_SC03A__open_url) failed the grammar: boot reported it as
an unused key, the cleanup offered a live parameter for deletion and the
scope inventory dropped it.A binding its owner publishes as current is now owned whatever its case;
a retired one stays refused and an unpublished domain keeps the
lowercase grammar. -
Launcher: Keep the sole-launcher startup on the main actor
Splitting applicationDidFinishLaunching behind the instance lock moved
the Sparkle wiring into a nonisolated method, which the macOS 26 build
refuses: the user driver, the consent policy and the command router are
main-actor isolated. The continuation and the lock retry now run on the
main actor, and the hand-over uses activate(options:), available on the
macOS 11 deployment target. -
Launcher: Own the embedded runtime as the only launcher
LSMultipleInstancesProhibited only covers other user sessions, so
open -n, a direct exec or an update relaunch racing the old process
could start a second launcher and a second embedded runtime. The
launcher now takes a per-user non-blocking flock on a close-on-exec
descriptor before it seeds MJConfigFile; a held lock is retried for
5 s (an update relaunch) and then hands over to the running launcher.
The sole launcher then ends any leftover runtime of the embedded
bundle identity before starting its own; a Hammerspoon the user
installed has another identity and is never selected. -
Macos: Refuse a stray runtime before it arms any producer
On 2026-10-21 and 2026-10-22 macOS started the embedded runtime beside
the launcher's own, without the launcher environment. It resolved its
paths (writing paths.toml into the signed bundle), armed the KC ledger
timer and path watcher, then aborted behind a blocking fatal dialog
whose modal run loop kept those producers writing to the shared log.
The same abort ran os.exit in any Hammerspoon, so a stock Hammerspoon
pointed at Ergopti was quit.Instance admission now runs right after privacy admission, before any
settings write, timer or path resolution: our own runtime started
without the launcher exits silently, a foreign Hammerspoon is told why
the driver did not start and keeps running. Every pre-runtime fatal
abort also stops the timer scheduler and the KC bridge before its
dialog. -
Linux: Chain the n-grams after a correction as Windows and macOS do
Windows and macOS keep every Backspace in the typing chain: it ends
"x[BS]" and "yx[BS]", and the next characters continue from it
("[BS]x", "y[BS]x", ... up to the heptagrams). The Linux walker pushed
the sequences ending in a correction but cleared its run after one, so
"[BS]x" was never counted there and the three dashboards disagreed on
the same keystrokes. The run now holds the marker; a pause or synthetic
output still breaks it, and a correction still ends no token longer
than a trigram.A shared corpus pins the exact n-gram tables for four correction
sequences, one of them across a flush, and replays on all three
walkers; the Linux test that pinned "[BS]b" at zero now pins the shared
behaviour. -
Windows: Extract the exe bundle with tar.exe, not hidden PowerShell
The compiled exe's first launch expanded its runtime bundle through a
hidden "powershell -ExecutionPolicy Bypass -Command Expand-Archive"
child. That is the first thing an antivirus sandbox sees when it
detonates a downloaded ErgoptiPlus.exe, and an unsigned exe that drops
a payload through a policy-bypassing hidden script host scores as a
dropper. A corporate proxy blocked v1.0.4 and later v1.0.5 after such a
sandbox verdict.Windows ships bsdtar as System32\tar.exe since Windows 10 1803; it reads
the deflated ZIP natively, extracts the v1.0.5 bundle byte for byte in
about 0.5 s (Expand-Archive took 8-19 s on hosted runners) and starts no
script host. The absolute path keeps a planted tar.exe out, and paths
that cannot be quoted for the C runtime argument rules are refused. -
Windows: Keep a NUL map key distinct when SQLite merges a histogram
The walker merged every numeric map it owns (n-gram sources, hourly and
five-minute error buckets, burst lengths) through json_group_object,
which the vendored sqlite3.dll cuts at an embedded NUL: a key "a\0b"
came back as "a" and folded into the real "a" key on the second flush.
The Linux writer had the same defect and was fixed the same way. The
object is now spelled with json_quote over group_concat, and a real
in-memory SQLite replay of each of the four merges pins the exact keys. -
Macos: Name the keyboard group commands in the template call
c80bd4d moved the keyboard group command table into a local before
passing it to ManifestMenu.template_rows. The handler bijection credits
only a literal table of bare local functions at the call, so the five
Add rows lost their handler (hs 5, baseline 0). The table is literal
again at both calls, with named restore and clear functions.The same commit opened every bindable group with restore, clear and a
separator but left two suites on the old shape: the declared frame
order and the row data cases now expect the three scope rows first,
and skip the separator where they read labels or the disabled flag. -
Macos: Find the AI prompts' own app by PID, never by name
The model download prompt and the Hugging Face token prompt activated
Hammerspoon through hs.application.get("Hammerspoon"), then find(). When
the name does not match the embedded runtime, both fall back to reading
every window of every application on the main thread, the freeze that
cost the lease in issue #99. Both now use RunningApplication.current(),
and the direct-lookup baseline is empty. -
Macos: Let the user's own Ollama daemon pull behind a PAC proxy
Issue #100 (and #99 before it): on a Mac whose network settings use a
PAC file, enabling the AI with the user's Ollama.app always failed with
"the system proxy configuration cannot be used by this client" and the
model was never downloaded.ollama pullonly asks the running daemon to fetch. A daemon Ergopti
never starts (Ollama.app, Homebrew, PATH) downloads with its own network
settings, which the CLI's environment cannot change, so the opaque
admission refused a fetch it did not govern. A foreign daemon's pull now
gets the static settings prelude and its own failure is reported; a
daemon of unknown or Ergopti origin keeps the fail-closed admission. -
Macos: Read pending lease output before failing an on-time deadline
Issue #100 lost the Karabiner lease seven times in twenty minutes, each
PONG or READY deadline firing "0.00 s late", with no worker stderr and a
worker that exited 0 once Hammerspoon closed its input. Tap-hold keys
lost their Karabiner rules during every fence and the recovery after it.Hammerspoon 1.1.1 reads a streaming hs.task with
readInBackgroundAndNotify, which delivers only in the default run loop
mode, while hs.timer and task completions run in every mode. With the
Ergopti menu open (event tracking mode) the worker's answer sat unread
in the pipe while its deadline expired on time, and the completion then
beat the worker's last stderr chunk, which the runner drops.An on-time PONG, READY or mode deadline now starts a /bin/cat echo probe
(adapters/task_output_turn.lua) and judges the worker only once that
echo is read, so every answer written before it has been delivered. The
wait is capped at 30 s; STOPPED keeps its direct verdict, its fallback
revoker reaching the same safe state. -
Linux: Log a privacy-filter click refused by a metrics transaction
The Windows driver refused a private-browsing filter click that landed
on an open configuration transaction, with an error window. On Linux
the same refusal happens while a metrics scope still owes its rollback,
but the setter returned false without a word, so the click vanished.
The three privacy-filter setters now log the refusal; nothing else
changes, and the state stays as it was. -
Windows: Keep a late check-row click from toggling the row back
A menu toggle flipped whatever state it found when it finally ran. A
click retained during startup committed late, the row still showed its
old checkmark, and a second click against it waited behind the write,
crossed the reload and flipped the private-browsing filter back on:
the row "did nothing" and stayed checked (2026-10-22).Check rows now register the predicate that draws their checkmark
(manifest check and toggle rows, feature rows). A click records the
state it asks for, the opposite of the checkmark it was made on, and
that state travels through the write deferral, the startup retention
and the reload hand-off (replay schema v3). A late command whose row
already shows that state is skipped and logged instead of run. -
Ui: Name the unused-settings window with the brand once
The Windows cleanup window read "ErgoptiPlus — ErgoptiPlus — Réglages
inutilisés". Its opener passed a label it had already branded as the
WebView host Title, and Gui_Create brands every caption again. The
title audit missed it: it resolves the Gui_Create argument, which the
host reads back from Opts at build time, so it counted the call as an
unresolved dynamic input. The locale guard only checked translations,
which were clean.The host now names a manifest window from its title_key, so the cleanup
window passes no caption at all; Title stays for contextual labels. The
macOS cleanup window opens by app identity through the same manifest
path, and the Linux GTK window composes its caption through one owner,
app_caption.Guards: on each driver, (window-title-brand-once) builds every manifest
window through its real caption path in all 21 locales and refuses a
second brand or a repeated segment; on Windows it also opens the real
cleanup window, which reproduced the doubled caption before the fix.
The audit now follows Map("Title", ...) and Opts["Title"] := writers. -
Windows: Defer a tray click behind an in-flight config transition
A scoped configuration commit (ConfigScopeCommitOperations) holds the
terminal bundle across its durable I/O and until its reload launches.
AutoHotkey let the tray open over that thread, and the clicked row ran
on top of it. The menu deferral treated any terminal bundle as lasting
until exit, so it ran the command at once and the commit was refused:
"another configuration transaction is already in progress", with an
error window (private-browsing filter, 2026-10-22).MenuCommandRun now waits on MenuCommandConfigBusy, which also counts an
active bundle while no reload successor is pending and no recovery
barrier is retained. Once the successor launches, the existing reload
hand-off takes the command; a retained barrier is still not awaited. -
Menu: Open the tap-hold key delay prompt from its own row
The per-key tap delay of every tap-hold key was a submenu whose only
row opened the delay prompt, and a row that opens a submenu is never
clicked. The row that shows the current delay is now the command
itself on Windows, macOS and Linux, with the same caption and the same
prompt and setter behind it. The macOS "use global delay" check, the
submenu's second row there, moves up next to it, so no tap-hold key
keeps a nested delay menu. The retired row's label leaves the 21
locales. The renderer caption-group contracts used that submenu as
their fixture and now use a group each driver still draws. -
Windows: Stop Win+S cancelling its own selection read
copy_selected_path (Win+S) did nothing. GetSelectionAsync cancelled the
capture as "superseded by input" when A_TimeIdlePhysical moved more
than 20 ms after the shortcut, but releasing S and then Win moves it
too, so whenever the application copied more slowly than the key
release the copied selection was dropped, silently. The keylogger
ledger shows the copies arriving and nothing opening.The capture now takes KS_GetPhysicalInputGeneration as its ticket: the
dispatcher counts physical key-downs, mouse-downs and wheels, never
releases, and its InputHook callback runs before the hotkey thread, so
only a later action cancels. The copy chord goes out at
TEXT_SENDER_SEND_LEVEL: from the #InputLevel 2 Win broker it was sent
at level 2, where the driver's own I1 InputHooks took it for typing
(the keylogger logged a "Win+C") and it would advance that ticket.Every ending without a selection, and an empty selection, is now
logged with its reason and foreground process. An empty selection
opens nothing, as on Linux and macOS. -
Windows: Name the clipboard holder when a selection read fails
At 08:30:32 Win+S (copy_selected_path) failed because another process
kept the clipboard open past ClipboardAll's own one-second wait (a
600 ms foreign hold succeeds after ~440 ms, a 1.6 s hold fails at
1000 ms). GetSelectionAsync turned that external condition into an
ERROR, which opened the generic error window, and no log said who held
the lock, so the occurrence could not be diagnosed.CB_DescribeClipboardHolder reads GetOpenClipboardWindow (process, pid,
whether it is this driver) and GetClipboardOwner; CB_SaveAll logs it
with every failure. A failed selection snapshot now logs a warning with
that holder and shows a notice naming it (selection.clipboard_busy in
every locale) instead of raising an error. No further retry: the
snapshot already waited the bound CB_OpenClipboardWithin uses. macOS
and Linux have no clipboard lock, so they have no equivalent path.
Performance
-
Toml: Build the expected model from the one source parse
TOML_BuildConfigDocumentCandidate parsed the same source twice: once
for the source model and again for the expected model it then mutates.
The semantic setter mutates tables only and replaces values whole, so
the expected model is now a table copy of the first parse.A changed document-mode build drops from 3 to 2 parses, an unchanged
one from 2 to 1, and a real write from 4 to 3 (the schema journal keeps
its own independent admission parse). The post-ready full save is an
unchanged-source build, so each start saves at least one full parse
(about 110 ms on the maintainer's 830-line file). A parse counter pins
the cost per transaction. -
Changelog: Switch Versions tabs locally over the fetched list
Every host fetches the same GitHub release list whatever the channel,
and the page filters it by the shared registry. A tab still posted a
fetch, so each switch paid a 1.0 to 1.7 s download of about 1.7 MB,
the Windows escape of that text on the hook thread, a re-parse and a
spinner, for a list the page already held.The page now keeps the last complete list and a tab filters it locally:
no bridge message, no spinner, about 15 ms to paint. A switch still asks
the host while a load is in flight, after the Linux interim answer that
carries one cached release, and after an error, when the hosts drop the
list an install is looked up in, so failures stay visible. Retry always
reaches the host. This covers Windows, macOS and Linux, which share the
page and fetch the same list. -
Windows: Escape JSON string literals in native passes
The Versions window hands its page the GitHub release list as one JSON
string literal, about 1.7 MB of release notes. JsonStringLiteral walked
it one code unit at a time in script, which took 2.7 to 3.3 s on the
thread that also runs the keyboard hook, on every open and on every tab
switch.Escape through StrReplace passes and walk only the rare C0 units that
have no short escape. StrReplace stops at the first NUL, so the text is
split on NUL first and each run escaped on its own. Output is identical
on the release list, the Atom feed and every code unit; the same list
now takes 66 to 115 ms.
Refactoring
-
Windows: Draw the personal submenus as renderer template children
The default-section, file and folder submenus of the personal hotstrings
were the last three rows Windows driver code drew itself: each created a
Menu and appended its declared frame into it, and the default-section
callbacks then checked, unchecked and renamed rows of that Menu by label.The renderer now owns these children. MenuRenderer_TemplateChild reserves
one into the caller's release ledger (before the commands that repaint it
exist), MenuRenderer_FillTemplateChild draws the admitted template once,
and the declared parent rows (group, personal file, personal folder) take
the handle and attach the child only while it still holds exactly the
rows the renderer drew. MenuRenderer_RepaintTemplateChild moves the tick
and renames the parent row by position, with literal ampersands escaped,
and refuses before the preference write once the child was released or
is not attached to the given parent. A callback retained past a rebuild
used to write the preference and then repaint a deleted menu.Captions, order, unavailable reasons and child commands are unchanged.
The native menu row census drops from 3 to 0 on Windows.
Documentation
-
Todo: Keep the CI follow-ups from the October 23 green run
-
Todo: Keep the luajit command on one wrapped line
dacee69 split theluajit ergopti_hotstrings.luacode span across
two lines of a list item, which Prettier rewrites, so the formatting
check of run 39871833938 failed. The span now starts its own line. -
Todo: Record the Linux file-manager, window-list and instance-lock results
-
Todo: Mark menu clicks during a reload done on macOS and Linux
-
Todo: Mark the last three native Windows menu rows done
-
Todo: Keep the October 22 agent findings and the open decisions
Record the keystats rename plan, the 1.0.6 order, the macOS input follow-ups, the
remaining separator, metrics, dashboard, config, Linux and CI items reported by
the agents, the commit-message hygiene list, and three new maintainer questions. -
Skills: Read the Paris clock with Get-Date, not TZ in Git Bash
Git Bash on this Windows host has no tzdata, so TZ=Europe/Paris date silently
prints UTC; a schedule given to the maintainer was two hours off. -
Macos: State what the event clock assumes about stamp units
CGEventGetTimestamp is documented as nanoseconds since startup, yet may
hand over raw Mach ticks on Apple silicon; the adapter tries both scales,
and its header no longer states either as a fact. -
Architecture: Regenerate the diagram with the event clock adapter
npm run gen:diagram after adapters/event_clock.lua was added. -
Todo-agents: Hand off the key tap fixes for issues #99 and #100
Lists the local commits behind the swallowed keys, the accent popup on
a plain tap and the lease keepalive, and what stays open: the
karabiner.json redeploy per recovery, the per-keystroke AXValue read and
stale events in the taps other than the keymap. -
Todo: Hand over the Windows and Linux hold action picker
The handoff listed the Windows and Linux hold as kept on purpose until the
maintainer decided; record what landed in 7eaf277 and the manual checks
on those two systems. -
Memory: Record the key tap invariants behind issues #99 and #100
The physical fence pairs key-ups with their own replayed key-down and
never replays a stale event, key taps never ask Accessibility and every
AX call is bounded, the lease keeps its worker fed while answers are
unread, and the session key counters are the first lines to read in a
report of swallowed keys or an unasked accent popup. -
Architecture: Regenerate the diagram with the own window adapter
0ab0bd4 added adapters/own_window.lua without regenerating
architecture.md, so the diagram sync check failed on run 39797045031.
Regenerated with npm run gen:diagram. -
Todo-agents: Hand off the v1.0.5 proxy verdict and SignPath application
v1.0.5 passed a corporate proxy once and was blocked two hours later on
another PC, while v1.0.2 still passes. The static comparison of the
three released exes shows identical PE images apart from resources and
no pattern in v1.0.5 that v1.0.2 lacks, so the verdict follows a new
unsigned hash through sandbox and reputation scoring. The handoff
records the hashes, the comparison, the Microsoft false-positive text
and what remains; the new SignPath file holds the eligibility table,
the form answers and the switch-on steps for the maintainer. Memory
keeps the lesson that the compiled script is scanned as plain text. -
Memory: Record that macOS starts the embedded runtime alone
Two reports show a second embedded runtime started by macOS beside the
launcher's own; the entry routes the next reader to the admission gate
and the launcher lock instead of hunting for an Ergopti launch path. -
Readme: Publish the code signing policy and privacy statement
SignPath Foundation signs only projects whose home page states a
"Code signing policy" with the team roles and a privacy statement. The
section names what is signed (only the CI-built ErgoptiPlus.exe), who
approves each request, and every network request the application
makes, with the typing metrics off by default and kept local. The
install notes also state the Windows 10 1803 floor that the bundle
extraction through tar.exe sets. -
Memory: Note the Windows walker shares the NUL map key trap
-
Todo: Record the maintainer's second batch of October 22 answers
The site deploy design is confirmed (main to ergopti.fr, dev to
ergopti.fr/dev/) and its workflow definition being read from main is
accepted; the macOS dashboard shows a partial history's counts with an
incomplete-history mention; the Versions window hands the newest feed
release to Sparkle; the per-row number-row switches do not cover the
October 13 request, which becomes big item 4, a per-layer layout
composer. The WPAD question is closed: the maintainer's Mac uses only a
corporate PAC file, so resolving that PAC for an Ergopti-started Ollama
becomes a checklist item. -
Todo: Record the maintainer's answers of October 22
Issue #100 keeps 2353a72 (a foreign Ollama daemon pulls through a
PAC proxy); the macOS webview-open freeze is fixed now; SignPath
Foundation is applied for now; the Windows and Linux tap-hold hold rows
open the shared action picker with holdable actions only. The four
questions move to answered and their work joins Wave 2 as in progress. -
Todo: Record the ISO key left of 1 as confirmed on the Mac
The maintainer confirmed on 2026-10-22 that an action assigned to the
key left of "1" now lands on that key, not on the ISO 105th key, so
the Karabiner-EventViewer check and its revert option for 356d14a
are gone. -
Todo: Shrink the checklist to three big items and checklists
v1.0.5 is released (17d4a3f), so the v1.0.4 header, the October 21
state, handoff table, dev-red notes and the after-release plan are
history. Finished items are removed after checking each against Git and
the code: exe metadata (965ff75), timers and tap-binding tests
(d5c520c, e9a50f5), the updater freeze (8a43bd8), the Codex
branch integration, the Report-a-bug locale wording (all 21 locales
already carry it) and the empty app-lookup baseline (e57f4cc).What remains is three big items (Windows corporate networks and trust,
macOS Karabiner input reliability, Windows input hooks), a Wave 2 list
with the work agents are doing now, a one-line backlog, the maintainer
checks (post-release actions, Windows PC, Mac, Linux), and one list of
CI-only qualification receipts. Pending decisions move to the questions
file, with two new ones from October 22 (issue #100 PAC pulls through a
foreign Ollama, the webview window lookup freeze).The ten finished handoff files this replaces were deleted from the
shared index and landed by accident inside 18ca214; their open lines
and physical checks are in the checklist from this commit on. -
Memory: Keep the catalogue tray-click and backspace limits
The retired AppImage catalogue and backspace handoff files held two
durable lessons: the catalogue's right click closes the menu its left
click opened, so only a status window reaches the screenshot; and the
word-start rules have two known limits (browser inline autocompletion,
Linux Shift+Enter in a terminal TUI) that are not bugs. -
Memory: Record the NUL-bounded StrReplace and brandless captions
StrReplace ends its haystack at the first NUL while RegExMatch and
SubStr do not, which JsonStringLiteral now works around. The window
composer is the only place that adds the product name, so a branded
caller label or locale caption shows it twice. -
Architecture: Regenerate the diagram with the task output turn
25411ee added adapters/task_output_turn.lua without regenerating
architecture.md, so the diagram sync check failed. Regenerated with
npm run gen:diagram. -
Todo: Hand off the keyboard group restore and clear rows
The handoff names where each driver draws the two rows, what the
narrowed Shortcuts scope restores and clears, the tests and the manual
check left.
Tests
-
Windows: Read the loading request in the Critical that publishes it
The placeholder-loading case of the LLM nav event owner read
_TooltipPendingRequest on the line after LLM_TooltipShowLoading. The
tooltip render is debounced by 1 ms, so on a slow runner its timer ran
between those two lines, consumed the request and the assertion read 0
(run 39887661465; the same commit passed on 39882515538). The case now
publishes and reads the request and serial in one Critical section, and
asserts on what it read. -
Windows: Read a quoted table header in the installed profile
Run 39878950422 refused the compiled upgrade before launch with
"Unsupported installed section layout": the prior dev release now saves
each extension module under a quoted key segment, such as
[hotstrings.modules."ext:ergopti-demo:demo-phrases"], and the
installed-profile reader of the upgrade contract admitted only bare
dotted headers. It now admits bare and basic-quoted key segments, and
the installed-user fixture carries such a table. -
Ci: Mutate the 20-minute audio budget in the wiring self-check
ce3f736 moved the audio install pin to 20 minutes but left the
self-check mutating "timeout-minutes: 10", which no longer occurs, so
the mutation changed nothing and the rule reported it could not fail
(run 39875037375). The self-check now mutates the real 20. -
Ci: Pin the virtual audio install budget at 20 minutes
74b5fe5 raised the install-only audio step to 20 minutes, but the
pipeline wiring contract still required 10, so it refused the step on
run 39873463783. -
Linux: Remember the colour mode away from the shipped default
fa965e4 aligned the Linux recommendations on Windows and shipped
metrics.wpm_widget_colors as false. The widget stores only a choice that
differs from what ships, so the case that chose false now chose the
default, left no entry and failed. It now chooses the opposite of the
manifest default, whichever it is, and also checks that choosing the
shipped mode again drops the entry. -
Windows: Load the partial read owner in the apps DOM child
0010418 made the metrics projection call KLR_BeginPartialRead and
KLR_ProjectionQuery from the new keylogger_reader_partial.ahk. The apps
DOM integration builds its child from an explicit list of owner files,
which did not name that one, so every projection worker died on an
UnsetError at keylogger_prefetch.ahk:1022 and the child exited 1. The
first line of its output, a long-standing LocalSameAsGlobal warning in
vendor/WebView2.ahk, hid the cause in the CI summary. The owner is now
in the list and the owner count is 44. -
Linux: Bind the picker in the hidden tap row case
7eaf277 made the hold row of a key open the shared action picker, and
the "hides declared tap row" case clicks it and confirms through
picked, but its callback still named that parameter_, so it read
an undefined global and failed on every run. The parameter is bound. -
Macos: Pin the hold picker contexts shared with Linux
The cross-platform refusal case used tap_hold_hold_action as its example
of a macOS-only context, and 7eaf277 declared it for Linux too, on
purpose: the Linux hold opens the shared picker. The refusal now uses
key_combination_tap_action, still macOS-only, and the case pins that the
hold context is Linux's and not Windows'. -
Macos: Admit the stale verdict after the one keyDown classification
The audit-v5 and audit-v6 guards pinned the exact onKeyDownRaw call to
(e, provenance, provenance_status). The wrapper now also passes whether
the key waited behind the main thread, a verdict derived after its one
fenced classification; the guards still require that classification to
feed the only raw route, and accept further arguments after it. -
Macos: Pin that a lease fence leaves no key down
Issue #100 lost the Karabiner lease eight times in an hour, each fence
switching every Ergopti rule off while keys may be held. A held output
key is what macOS repeats or turns into the press-and-hold popup.The generated tap-hold graph is replayed through the Karabiner model
for every tap-hold key and hold kind, fenced before the threshold,
after the hold went down and before the press: once every physical key
is up, no output key, modifier or layer stays on. -
Windows: Seed the NUL map key in SQL, where a Map cannot hold it
The NUL map key regression built its stored map from an AutoHotkey Map,
whose string keys end at the first NUL: "a\0b" and "a" were one key, the
encoded batch never held the NUL key and the four cases failed against
the fixed merge as well. The stored column now takes the NUL key from
SQL, as another writer's row can hold it, and a walker delta merges into
it through the vendored DLL. Against the json_group_object merge the
same data yields "61|20,61|1", a duplicated "a" key; the json_quote
merge keeps "61|20,610062|1". -
Macos: Match the foreign daemon pull count in the receipt line
eb6de67 raised the pull activation assertion to 17 but left the
printed-receipt match at 16, so the managed Ollama protocol check
still refused the 17-control run. Both now name the same count. -
Hs274: Run the producer tooling tests in the shared lane
Deleting the hs274-*.yml dispatch workflows left the HS-274 Python
suites and the portable C++ tests run by no workflow, while the owned
runtime build pins the producer patch set and stream headers they
cover. A regression there would only surface in the native build.The runner discovers every hs274_*_test.py, names why each excluded
suite runs elsewhere, fails on a stale exclusion or a missing
interpreter or compiler, and compiles the two C++ tests that need no
vendor headers. It runs on the Linux shared lane only, where python3
and c++ are present. -
Macos: Count the foreign daemon pull activation control
2353a72 added the (ollama-foreign-daemon-pull) case to the pull
activation suite, which now passes 17 controls, while the protocol
harness still required exactly 16. -
Macos: Read arm_ack_timer through its forward declaration
25411ee forward-declared arm_ack_timer so it and the output-turn
probe can call each other, which the hardening-d contract no longer
found ("arm_ack_timer was not found"). It now also reads the
arm_ack_timer = function(definition and still checks the three
deadlines that function picks. -
Windows: Read the replay command from a parameter, not the loop
af404db/fb877dbd51 refused invalid requested states with a closure
over the for variable inside AssertThrows. AutoHotkey 2.0 rebinds that
variable away from the captured cell, so each case threw UnsetError and
passed whatever the decoder did. A helper now builds the document from
its parameter, and the three refusals are real. -
Linux: Find the tap-hold delay row one level up
0d5bd45 made the row that shows a key's delay the command itself,
with no nested delay submenu, but the prompt-cancel cases still looked
for the zenity --entry leaf two levels down and found no row. They now
search the per-key submenu's own leaves. -
Menus: Authenticate the native menu routes through the checkmark ABI
fb877db gave RegisterMenuItem a CheckedFn parameter and a guard, and
let _MR_AppendTemplateRowsAdmitted admit a checked_fn field. The native
startup binding and the shortcut construction proof pin those exact
bodies, so both stopped crediting their sections: the cold command,
inert and shortcut result frames lost their reader and the manifest
readers, parity and category coverage gates went red.The pins now name the new reviewed bodies. New mutation controls refuse
a required or nonzero CheckedFn default and a removed checkmark guard,
so the three-argument cold caller stays authenticated. -
I18n: Refuse a caption translation that carries the brand
menu.llm.api_dialog_title read "ErgoptiPlus — API endpoint" in all 21
locales, and both callers hand it to a brand-adding composer: the
Windows API test verdict goes through Ui_MsgBox and the Linux entry
prompts through the zenity title composer, so both showed the brand
twice. The audit could not resolve it because the key reaches the
dialog through a helper parameter. The translations themselves were
corrected in 0d5bd45, which picked them up from the shared tree.The title audit now scans the translations of every caption key (each
manifest app and presentation title, and every *window_title or
*dialog_title key) and refuses one that already starts with the brand;
on the old translations it lists the 21 API-entry captions. -
Windows: Keep the chord-release case off the runner's clipboard
On CI the production restore of the hand-driven poll stays in retry
when the runner cannot open the clipboard, so asserting that it settled
failed there while the cancellation reason it checks was right. Accept
the job's own retained debt, and hold Critical while stopping the armed
poll timer: under load it fired before the test could stop it.
Chores
-
Release: Publish v1.0.6
Release Ergopti+ 1.0.6 from dev 996013a; full run 39889203911 at e0dd662
passed on all lanes and only the TODO changed after it.macOS: swallowed keys and accent popups on a tap (no Accessibility call in
the event tap, key-ups never held behind an expansion, late keys pass
through), Karabiner lease kept alive while a menu is open, webview windows
open without a 3 s freeze, AI off is inert, Hammerspoon coexistence.
All systems: Win+S opens what the selection names, instant Versions tabs,
reset and clear rows per shortcut group, tap delay edited from its row,
shared action picker for holds, partial typing history shown, menu clicks
during a reload replayed. Windows: updater stages run as bundled -File
scripts, no encoded PowerShell, first launch extracts with tar.exe.Release-As: 1.0.6
-
Hs274: Retire the complete fork candidate and two dead probes
ADR 011 decision 1 ships a headless three-product runtime; the
karabiner_candidate tooling rebuilt all ten Karabiner-Elements products
and installed them over the official application, which that decision
rules out, and no workflow or build script uses it since the
hs274-producer-build.yml deletion. Its package-identity fixture goes
with it.hs274-native.swift and hs274-hid-device.c were one-shot capability
probes of the deleted hs274-native.yml; nothing reads their receipts and
their findings (Quartz fields cannot tell a remapped key from a physical
one, root lacks the IOHIDUserDevice entitlement) stand in the ADR
alternatives and in macOS memory.The rest of tools/diagnostics/hs274* stays: the owned runtime build
pins the producer patches, stream headers and hs274_native_build.py by
hash, macOS tests load the context and persistence helpers, and
hs274-remap.py with hs274-core.py and hs274-provider.py is the native
fixture acceptance builds on. ADR 011 now records that ownership.
Other
-
Linux: Install the window-switch prerequisites in their own step
The cursor-display window switching step downloaded its packages, fonts
included, inside its 4-minute test budget. Runs 39862893510 and
39876412279 both timed out in the download, and the killed apt-get then
held the dpkg lock for every later install of the input-display row.
The packages now install in "Install window-switch prerequisites" (20
minutes); the test keeps its 4 minutes and the registration contract
refuses an apt install back in it. The shard and scoped-branch
inventories list the new step. -
Linux: Give the virtual audio install step a 20-minute budget
pulseaudio depends on libasound2-plugins, which brings about 40 MB of
codec libraries. On run 39862893510 the Azure mirror served them at
about 60 kB/s, steadily rather than stalled, so the 10-minute install
step timed out; its killed apt-get then held the dpkg lock and every
later install of the desktop-webview row failed with exit 100. The step
only installs, as the WebKit one does, and gets the same 20 minutes. -
Windows: Reseal the updater source pins after the -File workers
self_update.ahk is the only pinned source that changed since the last
seal: the staging transport now runs the bundled staging worker with
-File and a transport prefix, and no longer builds its worker and swap
scripts as encoded text. The updater cohorts pass on that source. -
Windows: Forbid encoded PowerShell stages in the compiled exe
The updater and the crash-report fallback no longer launch encoded
commands, so the antivirus surface gate forbids -EncodedCommand,
[ScriptBlock]::Create and FromBase64String in the compiled script and
-EncodedCommand in the bundle, instead of holding them at a ceiling.
The measured ceilings drop to ExecutionPolicy Bypass 17 and hidden
windows 3; the bundle gains measured ceilings for hidden windows (3)
and ScriptBlock creation (1, the Ollama model worker importing its
verified local functions). A local build of the previous source fails
the new policy on all five counts. -
Windows: Reseal the updater source pins after the config writer changes
toml_document.ahk is the only pinned source that changed since the last
seal: the document writer now drops every generation header of a
deleted table array, builds its expected model from the one source
parse and counts its parses. The three updater cohorts pass on that
source. -
Linux: Install the model pull window packages in their own step
The network-ai row fetched 43 MB of GTK, WebKit and lgi packages inside
the model pull test step, whose budget is 2 minutes. On run 39776223751
the Azure mirror took longer than that and the step timed out before a
single assertion ran, which says nothing about the code. As for the
desktop-webview row, the packages now install in a separate step with a
20-minute budget, and the test keeps its own 2 minutes. The shard and
scoped-branch inventories list the new step. -
Windows: Sign stable releases through SignPath once configured
Reputation-based engines block a new unsigned exe until its hash has
history, which is why v1.0.5 passed a corporate proxy once and was
blocked two hours later on another PC. Every release resets that
history; an Authenticode publisher keeps it across releases. Modern
code-signing keys live in an HSM, so the existing PFX path cannot carry
a public certificate any more.The SignPath Foundation flow is wired and stays off until the
maintainer sets the repository variables SIGNPATH_ORGANIZATION_ID,
SIGNPATH_PROJECT_SLUG and SIGNPATH_SIGNING_POLICY_SLUG and the secret
SIGNPATH_API_TOKEN. A stable release then uploads the unsigned exe,
submits it with the tracked .signpath artifact configuration through
the SignPath action pinned to its v3.0 commit, waits for the manual
approval, and replaces the exe only after its signature is Valid and
its publisher equals WINDOWS_SIGNER_SUBJECT. A partial configuration,
or SignPath and a PFX together, fails the step. Dev prereleases stay
unsigned, since each request waits for a human approval. The release
job counts SignPath as configured signing, so the unsigned stable
exception no longer applies once it is on. -
Windows: Gate the antivirus surface of the compiled exe
ErgoptiPlus.exe embeds its AutoHotkey script as plain text, so proxy
and antivirus engines read every PowerShell stage in it. v1.0.4 was
blocked after an inline Add-Type/DllImport clipboard stage reached that
script, and nothing in CI would have noticed it coming back.The Windows build now parses the compiled exe's PE resources, reads the
script and the deflated runtime bundle, and checks them against
tools/ci/windows-exe-av-surface.json: inline C# P/Invoke, PowerShell
screen capture, Expand-Archive, Invoke-Expression, Run keys, scheduled
tasks and process injection are forbidden; the remaining PowerShell
launch patterns (ExecutionPolicy Bypass, -EncodedCommand, hidden
windows, base64 decoding, ScriptBlock creation, RunAs) cannot exceed
their measured counts. The keylogger wording is reported on every build
so its rename can be measured. The released v1.0.4 exe fails the gate. -
Windows: Reseal the updater source pins after the keyboard group rows
c80bd4d changed infra/config_io.ahk and tests/run_all.ahk, two of
the 21 sealed sources, without resealing, so the full run 39767542845
refused the stale pins. The independent label oracle and the frozen
child diagnostics are unchanged and npm run
test:windows-updater-original-source passes. Sealed against e57f4cc
with the repository sealer.
Generated by CI
Windows signature qualification
WINDOWS AUTHENTICODE UNSIGNED / qualified:false (source 950979d): ErgoptiPlus.exe has no Windows code-signing certificate for this v1.0.6 release. All tests, build, package, source and asset integrity, and installation checks remain mandatory. Windows may show an unknown-publisher warning. Signature evidence. Future signing requires SignPath Foundation enrollment and qualified GitHub integration.