Living map of evenTermidex: every component, what it does, and what depends on what. Keeping this current is mandatory — see
CLAUDE.md→ "Codemap & audit rules". Update it in the same change that alters structure.
Last updated: 2026-07-29 — Phase 4 + HW feedback (scroll fix = native OS lists): menu/picker screens now ship as frame.list {items,selected} and render as a native ListContainerProperty on the glasses (the OS moves a selection border — no page-scroll animation, which text containers always play and nothing disables); the OS reports the landed index back as gesture.item, which the daemon adopts as highlight truth; menu/picker highlights start at 0 on entry. Both fields additive — old clients still get the text rendering. menu now 8 entries (comm screen, dictate, quick actions, mode, sessions, Esc, output, terminal — 8 is the HARD CAP: title+items must fit TAIL_LINES=9). One generic picker overlay (state on controller, _render_picker sliding window, priority dictation > picker > prompt-fallback > selected) serves four kinds: session (fresh list_sessions + + new session auto-named + - kill session entry when others exist; switching mutates the shared driver.session), kill (sessions minus active; tap kills, stays open for bulk cleanup), mode (picker + verify: mode_steps ring distance + _set_mode step-and-recapture BTab loop; blind cycle retired), action (hardcoded QUICK_ACTIONS, prompt-guarded send_text+Enter). serve auto-creates the claude session at boot (zero onboarding). Done-news DEBOUNCED (NEWS_DEBOUNCE 1.0s — spinner flickers mid-turn; raw falling edge flashed false "done"). ws contract untouched. 167 tests green
[G2 glasses] --BLE--> [glasses-client] --ws--> [daemon] --tmux--> [claude]
(owns BLE) (contract) (host brain) (capture/send-keys)
[phone] Tailscale carries the ws link (tailnet IP); Termux/SSH is interactive-shell only
Dependency direction (who knows about whom):
glasses-client→ depends on → the ws contract only (never on daemon internals).daemon→ depends on → tmux/claude + the ws contract.phone→ depends on → nothing (pure transport; ssh tunnel).docs→ describes all; depended on by humans.
| File | Responsibility | Depends on | Used by |
|---|---|---|---|
src/eventermidex/__init__.py |
Package marker + __version__ |
— | __main__ |
src/eventermidex/__main__.py |
CLI: doctor (default; env self-check incl. optional voice extra), launch (start claude in named tmux session), serve (run ws daemon; --host/--port/--session/--interval/--stt-model; transcriber loaded eagerly at boot; auto-creates the session if missing — zero onboarding, Phase 4); default_host() = tailscale ip -4 → fail-closed fallback 127.0.0.1+warning |
tmux_driver, server, stt, argparse |
console script eventermidex |
src/eventermidex/protocol.py |
v1 ws contract: hello() (carries the only v), frame(lines, list_=None) (additive list {items,selected} = native-list hint for menu/picker screens), mic(open), app_exit(), parse_event() validator (voice needs str chunk or error; gesture item must be int if present — the OS list's selected index), ProtocolError, GESTURES/EVENT_TYPES |
stdlib json |
server, controller, glasses-client (shape only) |
src/eventermidex/server.py |
asyncio ws server, transport-only since Phase 3: HudState (global view index + last PaneState + dismissed prompt + wake event — one wearer, one broadcast frame); current_frame(driver, hud, ctl) = capture→parse_pane→effective_view→render + list_payload → (lines, payload) (TmuxError→3-line placeholder+None); poll() diffs (lines, payload), sleeps on wait_for(wake, interval); make_handler(driver, clients, hud, ctl) delegates gesture→ctl.on_gesture(name, item) (item = OS-list index), voice→ctl.on_voice, disconnect→ctl.on_disconnect; connect resets hud.view="menu" + menu_index=0 (fresh OS list border opens at 0); run(..., transcriber=None) builds the Controller with send_all=broadcast |
websockets, protocol, renderer, controller, stt, tmux_driver |
__main__ serve, tests |
src/eventermidex/controller.py |
Events→actions brain: MENU (8 entries = HARD CAP so title+items fit TAIL_LINES: 3 screens via SCREEN_KEYS comm/output/terminal, 3 pickers via PICKER_KEYS session/mode/action, dictate, esc), ControllerState (menu index, dictation machine, PCM buffer, gen stale-guard, news + debounce fields running/last_active, picker state: kind/items/index/active), Controller executor (on_gesture(name, item=None) applies route_gesture actions — item = OS-list selected index, _sync_selection adopts it as highlight truth (scroll-with-item = sync only, tap executes the OS-selected item); menu/picker index resets to 0 on entry to match a fresh OS list border; fresh-capture prompt approve; note_pane(pane) debounced done-news — spinner must stay gone ≥ NEWS_DEBOUNCE 1.0s; dictation machine: mic open/close, asyncio.to_thread transcription, cancel-by-gen; on_voice chunk accumulation; on_disconnect resets dictation AND picker). Phase 4 picker (kinds session/kill/mode/action): _open_picker (session=fresh list_sessions+NEW_SESSION_LABEL+KILL_SESSION_LABEL when others exist, kill=sessions minus active, mode=MODE_RING, action=QUICK_ACTIONS; picker_active marks current), _picker_select → _switch_session (mutate shared driver.session; new = _next_session_name claude-N in daemon cwd, revert on TmuxError) / _kill_session (kill + stay open for bulk cleanup, fall back to session picker when empty) / _set_mode (one BTab per fresh capture, bounded MODE_ATTEMPTS, refuses w/ prompt open) / _run_action (prompt-guarded send_text+Enter, lands comm). User-authored policies: route_gesture() (menu=home wraps/executes; universal 2tap→menu; comm tap follows notifications; prompt tap=approve; dictation per-state; picker: scroll=move, tap=select, 2tap=close — branch BEFORE universal 2tap), finish_review() (send=strip+send_text+Enter; empty=discard), mode_steps() (modular 4-ring BTab distance; None on unknown/bypass = safe noop) |
protocol, renderer, stt, tmux_driver (via injected driver), os, time |
server, tests |
src/eventermidex/stt.py |
faster-whisper wrapper (optional [voice] extra): Transcriber(model_size) eager model load, blocking transcribe(pcm)->str (int16 LE mono → float32), load_transcriber()→None+log-hint when extra missing; SAMPLE_RATE=16000/SAMPLE_WIDTH=2 are HW-spike assumptions |
faster-whisper (optional), numpy (transitive) |
controller, __main__ |
src/eventermidex/renderer.py |
Pure pane→HUD pipeline (no I/O): parse_pane()→PaneState{lines,mode,activity,message,prompt} (never raises; unknown TUI degrades to None/empty), PendingPrompt (numbered-menu detection, ❯-marker false-positive guard), ControlView (frozen controller snapshot incl. picker kind/items/index/active — defined here so renderer never imports controller), reflow() (textwrap at COLS=36), render(pane,view,ctl=None) for VIEWS=(menu,calm,message,tail) (menu=home w/ live mode in title; calm=comm screen: blank while executing, notifications ! input needed+[Tool(args)]+question > * done) + prompt pseudo-view, dictation overlay, and picker overlay (_render_picker: title + sliding 8-row window centered on highlight; >=highlight *=active); activity = live spinner with parenthesized (Ns status only (output invariant: ≤9 rows, ASCII, ≤36 cols). User-authored policies: effective_view(pane,selected,ctl=None) (priority dictation > picker > answered-prompt fallback > selected), extract_message() (bottom-up boundary scan) |
stdlib re, textwrap |
server, controller, tests |
src/eventermidex/tmux_driver.py |
The only tmux toucher: has_session/new_session(command,width,height,cwd=)/list_sessions() ([] when no server)/kill_session(name)/capture(ansi=)/tail/send_text(-l literal)/send_key(named); TmuxError; DEFAULT_SESSION="claude"; session field is MUTABLE — Phase 4 session switching retargets by assignment |
subprocess |
server, controller, __main__, tests |
tests/ |
pytest: conftest.py shared FakeDriver (capture/send_key/send_text + Phase 4 sessions list/list_sessions/new_session/has_session; session is per-instance) + PROMPT_LINES; protocol round-trip/rejects; tmux driver vs throwaway real session (skipif no tmux; incl. list_sessions + cwd pin); server via real make_handler+poll on ephemeral port; controller unit tests (menu exec, dictation machine, stale-gen, disconnect, picker open/select/switch/create-revert/kill-flow, news debounce, ModeDriver _set_mode convergence, mode_steps/_next_session_name tables) with fake transcribers |
pytest, websockets |
dev |
pyproject.toml |
Build + deps + extras (voice=faster-whisper) + eventermidex script |
setuptools | pip/venv |
Form decided: Even Hub WebView app (Kotlin fallback dropped — WebSocket confirmed to work on sideload). Even's runtime owns BLE. Talks to daemon only via the ws contract.
| File | Responsibility | Depends on |
|---|---|---|
src/main.ts |
Thin client, two page shapes (HW 2026-07-29: the OS scroll-animates any capturing TEXT container — capture-overlay trick did NOT help, reverted): text page (single capturing text container; comm/output/terminal/prompt/dictation) updated via textContainerUpgrade splice, rebuild as fallback; list page (frame.list present: title text container + native ListContainerProperty w/ isItemSelectBorderEn:1 — the OS moves the selection border, no page slide; geometry 48px title + 240px list is a HW-tune guess) rebuilt only when items/page-mode change, title-only via upgrade; listEvent gestures relayed with item=currentSelectItemIndex; startup create retried once, content never '' (first-open crash); handles mic/exit, relays PCM as base64 voice{chunk}; FOREGROUND_ENTER → force-rebuild repaint |
@evenrealities/even_hub_sdk |
index.html |
WebView entry (#app mirrors HUD text for phone-screen debugging) |
— |
app.json |
Even Hub manifest (package_id com.aplaino.eventermidex; permissions: [g2-microphone] — without it audioControl(true) fails; valid names: g2-microphone, phone-microphone, album, location, network, camera) |
evenhub-cli (pack) |
vite.config.ts |
Dev server on 0.0.0.0:5173 (reachable over Tailscale) |
vite |
- ws target defaults to
ws://100.115.25.7:8765, override with?ws=. - Gesture map (spike): tap→
tap, double-tap→double-tap+exit, scroll→scroll-up/scroll-down.
Transport is Tailscale (WireGuard mesh): the phone reaches the host daemon at its tailnet
IP (ws://100.115.25.7:8765), proven both directions 2026-07-28. Works off-LAN, no firewall/
port-forward. SSH also rides the tailnet. No app logic here — pure transport.
Throwaway round-trip test to decide client + relay path. Disposable.
host/echo_server.py— stand-in daemon; ws server on0.0.0.0:8765, pushes text frames, prints events. Depends on:websockets.host/probe_client.py— local stand-in for the glasses client (verify daemon without hardware); updated to the v1 flow (hello → frame → tap → change-triggered frames). Depends on:websockets.echo_server.pystill speaks the pre-v1 provisional shape; superseded byeventermidex serve.
architecture.md: condensed plan, data path, phases, reuse list.
protocol.md: v1 ws contract mirror (evolution rule pending user TODO).
| Dep | Kind | Where | Why | Phase |
|---|---|---|---|---|
websockets |
Python runtime | daemon | ws server/client | 1 |
bleak, pillow |
Python optional ([ble]) |
daemon | only if desktop/BLE-bridge reference path | fallback |
faster-whisper |
Python optional ([voice]) |
daemon | local STT for dictation | 3 |
pytest |
Python dev ([dev]) |
daemon | tests | 2+ |
setuptools |
Python build | daemon | build backend | — |
tmux |
system binary | host | run/read/inject the claude session |
1 |
| OpenSSH | system binary | host + phone | secure transport / -L tunnel |
0/1 |
| Node/npm | system | glasses-client | Even Hub SDK toolchain | 0 |
@evenrealities/even_hub_sdk (0.0.10) |
npm runtime | glasses-client | HUD text/containers + tap/scroll events | 0 |
@evenrealities/evenhub-simulator (^0.7.2) |
npm dev | glasses-client | desktop simulator for iteration | 0 |
@evenrealities/evenhub-cli (^0.1.12) |
npm dev | glasses-client | evenhub qr (sideload), evenhub pack |
0 |
vite + typescript |
npm dev | glasses-client | build/dev server | 0 |
- WebSocket contract (
daemon↔glasses-client): v1, defined indaemon/src/eventermidex/protocol.py, mirrored indocs/protocol.md. Out:hello {v:1}(once, on connect — only place the version lives),frame {lines, list?}(full replacement, on connect then on change only;list {items,selected}additive Phase 4 — native-list hint for menu/picker screens, older clients ignore it),mic {open}(Phase 3: toggle glasses mic),exit(Phase 3: client shuts down, no reconnect). In:gesture {name, item?}(4 names;itemadditive = OS-list selected index),voice {chunk:base64-PCM | error}(Phase 3),commandreserved. Evolution rule: receivers silently ignore unknown types/fields — older clients keep working against the new daemon until re-sideload. - tmux interface (
daemon↔claude): read viatmux capture-pane -p -e; inject viatmux send-keys.
glasses-clientanddaemonshare no code — only the ws contract.daemonis the only component that touches tmux/claude.phonecarries bytes; it holds no app logic.- The ws contract is identical whichever glasses-client form Phase 0 picks.
- Trust boundary today = the Tailscale tailnet. The ws server has no peer
authentication; any device on the tailnet that reaches
:8765can send events, and with--tap-approveson, injectEnterinto the Claude session.default_host()fails closed to loopback (never0.0.0.0) so a Tailscale outage can't silently expose it. - OPEN — token auth on the ws contract (flagged CRITICAL by security review). Needs a handshake/token in the connect URL + a client rebuild/re-sideload. Defense-in-depth on top of the tailnet; schedule with Phase 5 (or sooner if the tailnet isn't fully trusted).
- OPEN — Origin allowlist (
serve(origins=[...]), likely[]for a non-browser client). Blocked on one hardware check: confirm whether the Even Hub WebView sends anOriginheader before setting it, or it may lock out the real client. - Tap→Enter is now prompt-aware and always on (Phase 2 replaced the
--tap-approvessmoke flag): the daemon injects Enter only when a numbered permission menu with a❯default is detected on a fresh capture. Still unauthenticated on the tailnet — the token auth item above is what gates untrusted peers, not the tap heuristic.