<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mike Meshok</title>
    <description>The latest articles on DEV Community by Mike Meshok (@meshoklv).</description>
    <link>https://dev.to/meshoklv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201357%2F70904677-5168-4694-914e-44b7a0c6defb.png</url>
      <title>DEV Community: Mike Meshok</title>
      <link>https://dev.to/meshoklv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/meshoklv"/>
    <language>en</language>
    <item>
      <title>Claude on a Nokia 3310: a J2ME client, HMAC instead of TLS, and 41 bundled games</title>
      <dc:creator>Mike Meshok</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:42:22 +0000</pubDate>
      <link>https://dev.to/meshoklv/claude-on-a-nokia-3310-a-j2me-client-hmac-instead-of-tls-and-41-bundled-games-3gpm</link>
      <guid>https://dev.to/meshoklv/claude-on-a-nokia-3310-a-j2me-client-hmac-instead-of-tls-and-41-bundled-games-3gpm</guid>
      <description>&lt;p&gt;I wrote VibeNokia: a Java ME client for the 2017 Nokia 3310 feature phone that turns it into a chat terminal for Claude, plus a small FastAPI server that stores history, verifies signatures and runs &lt;code&gt;claude -p&lt;/code&gt;. The menu also has Telegram, voice messages and a catalogue of 41 mini games. It works on my physical phone, and along the way I had to re-solve several things that a smartphone gets from a one-line library call.&lt;/p&gt;

&lt;p&gt;A caveat up front: the client has been tested on one real Nokia 3310 (TA-1030). I haven't tried other Series 30+ or Series 40 phones. The channel between the phone and the server is not encrypted; more on that below, without softening it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F22wqmuo59yg3qvdxr52b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F22wqmuo59yg3qvdxr52b.png" alt="Simple home menu of the VibeNokia client, rendered in MicroEmulator" width="480" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The platform
&lt;/h2&gt;

&lt;p&gt;The 2017 Nokia 3310 is Series 30+ on a MediaTek MT6260. It runs Java ME with CLDC 1.1 / MIDP 2.0, has a 240x320 screen, mobile data only and no Wi-Fi. It has no usable browser and no app store. A plain &lt;code&gt;HttpConnection&lt;/code&gt; with POST and JSON does work (I've seen this on my device), and the battery lasts a week.&lt;/p&gt;

&lt;p&gt;So the design is thin: the phone runs a client that draws the screen and sends requests, and my server does all the heavy work. The app installs as a &lt;code&gt;.jad/.jar&lt;/code&gt; pair from the memory card, or over the air: the server itself serves the files at &lt;code&gt;/ota/VibeNokia.jad&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docs/platform-notes.md&lt;/code&gt; collects observations I wish I'd had before starting. The ones that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CLDC has no cryptography at all. SHA-256 and HMAC in &lt;code&gt;Crypto.java&lt;/code&gt; are written by hand.&lt;/li&gt;
&lt;li&gt;The standard &lt;code&gt;List&lt;/code&gt; and &lt;code&gt;Form&lt;/code&gt; don't deliver &lt;code&gt;keyRepeated&lt;/code&gt;, i.e. a held key. Because of that the whole UI is a custom &lt;code&gt;Canvas&lt;/code&gt;, with home-screen tiles, scrolling and input.&lt;/li&gt;
&lt;li&gt;There is no timeout API on the HTTP connection, and the system timeout is measured in minutes. The client runs its own watchdog threads and aborts hung requests itself.&lt;/li&gt;
&lt;li&gt;Server responses are cut at roughly 25 KB. The screen shows far less anyway.&lt;/li&gt;
&lt;li&gt;Voice is recorded through JSR-135 as AMR, about 1.5 KB per second; the server converts and transcribes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why not HTTPS
&lt;/h2&gt;

&lt;p&gt;This is where I hit a wall. According to public sources, the Java stack in these phones stops at old TLS versions and an old certificate store, so modern HTTPS URLs don't open. In my notes this is marked "reported": I did not deliberately re-verify it on my own device. The outcome for the project is the same either way: the client uses plain HTTP.&lt;/p&gt;

&lt;p&gt;That means you can't rely on channel secrecy, so the app is built not to depend on it. Hence signed requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing instead of TLS
&lt;/h2&gt;

&lt;p&gt;The phone is paired with the server once and receives its own key. After that the key never crosses the network. Every request carries an HMAC-SHA256 computed over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the method and path;&lt;/li&gt;
&lt;li&gt;a request counter;&lt;/li&gt;
&lt;li&gt;a hash of the body;&lt;/li&gt;
&lt;li&gt;the server address.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server checks the signature and the counter. A replayed request is dropped: a window of the 64 most recent counters works like the sliding window in IPsec. The signature includes the server address (&lt;code&gt;VIBENOKIA_PUBLIC_HOST&lt;/code&gt;), so a request signed for a different address is rejected. Repeated wrong signatures or codes get the source banned for 15 minutes.&lt;/p&gt;

&lt;p&gt;Pairing works like this. Set &lt;code&gt;VIBENOKIA_PAIRING=1&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;. Press &lt;code&gt;5&lt;/code&gt; on the phone, and the server writes a one-time six-digit code to &lt;code&gt;data/pair_code.txt&lt;/code&gt; (mode 600); if a Telegram token and my chat are configured, the code is also sent there. The code lives for 10 minutes and works once. You type it on the phone, the device gets its key and stores it. Then you set &lt;code&gt;VIBENOKIA_PAIRING=0&lt;/code&gt; and the &lt;code&gt;/pair/*&lt;/code&gt; paths answer 404. A phone can be revoked locally with &lt;code&gt;tools/devices.py&lt;/code&gt; against the database file; there is deliberately no network admin API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the signature does not give you
&lt;/h2&gt;

&lt;p&gt;Signing protects against forgery, guessing and replay. It does not protect against eavesdropping at all. Anyone on the path between the phone and the server can read your questions and Claude's answers. Don't send anything secret from this phone. I say this in the README and in the deployment docs and repeat it here, because it's worse when people discover such things themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server and security
&lt;/h2&gt;

&lt;p&gt;The server is Python with FastAPI and SQLite: sessions, devices and contacts live in one file. To answer, it runs &lt;code&gt;claude -p&lt;/code&gt; with a short system prompt written for a tiny screen. What matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;By default the server listens only on &lt;code&gt;127.0.0.1:8085&lt;/code&gt;, and Docker publishes the port on loopback only. To expose it, do it deliberately through a proxy that forwards only the phone's paths (&lt;code&gt;/ping&lt;/code&gt;, &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/pair/*&lt;/code&gt;, &lt;code&gt;/device/*&lt;/code&gt;, &lt;code&gt;/ota/*&lt;/code&gt;) and answers 404 for everything else. The docs have Caddy and nginx examples.&lt;/li&gt;
&lt;li&gt;No default passwords. The admin key is generated on first run.&lt;/li&gt;
&lt;li&gt;Claude has no shell by default: it runs with &lt;code&gt;--permission-mode dontAsk&lt;/code&gt; and no pre-approved tools. If you allow tools via &lt;code&gt;VIBENOKIA_CLAUDE_ARGS&lt;/code&gt;, a paired phone gets access to them. Treat such a phone like an SSH key and keep the allow-list narrow. The server removes its own secrets from Claude's environment.&lt;/li&gt;
&lt;li&gt;The second engine (OpenCode) is off by default, because its agents may run tools according to your own configuration.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VIBENOKIA_FEATURES&lt;/code&gt; sets a ceiling on features: if you don't need Telegram or voice, their API paths answer 404 for every phone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before opening the port, the docs have a short check: &lt;code&gt;ss -ltn | grep -v 127.0.0.1&lt;/code&gt; should show only the proxy port, and &lt;code&gt;curl&lt;/code&gt; against &lt;code&gt;/ping&lt;/code&gt; and &lt;code&gt;/api/chat&lt;/code&gt; without a signature should return 403 and never 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  The menu: two tiles instead of a cockpit
&lt;/h2&gt;

&lt;p&gt;The first version opened with every feature at once. In the second (v0.2.0) the app starts with a simple menu of two tiles: chat with Claude, and Settings. Everything else (Telegram, voice, sessions, a second tile for OpenCode, and the "advanced" group: server list, in-app update, key rotation) is switched on in Settings &amp;gt; Features. The choice is kept in the phone's RMS storage. The server-side ceiling is applied on top: a feature the server forbids is hidden in the app, and the list arrives in the &lt;code&gt;/ping&lt;/code&gt; reply. Upgrading from an older version keeps everything that was visible before, while a fresh install starts simple.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqqzqazdonywrrwg0qy9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqqzqazdonywrrwg0qy9.png" alt="Chat with Claude on the 240x320 screen" width="480" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Always available: chat, model choice (Opus, Sonnet or Haiku), new chat, language (Russian or English) and font size. Tiles are numbered in the order shown, the digit opens a tile, and &lt;code&gt;5&lt;/code&gt; opens the selected one. Hold &lt;code&gt;*&lt;/code&gt; to dictate: the recording goes to the server, is transcribed there (Gemini via OpenRouter, Groq Whisper as a fallback) and lands in the chat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81ul9gpb4zp185tdud4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81ul9gpb4zp185tdud4q.png" alt="Settings &gt; Features, a list of switches" width="480" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxv7slaovb72zqpcnqvm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxv7slaovb72zqpcnqvm.png" alt="Home menu after enabling Telegram and Sessions" width="480" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots are from MicroEmulator at 240x320.&lt;/p&gt;

&lt;h2&gt;
  
  
  41 games
&lt;/h2&gt;

&lt;p&gt;The catalogue has 41 standalone apps with no permissions and no network. Nine were written in this repository; the other 32 are MIT-licensed games from the &lt;code&gt;agneay/j2me-100-games&lt;/code&gt; archive. Third-party components are listed in &lt;code&gt;THIRD_PARTY_NOTICES.md&lt;/code&gt;. One script, &lt;code&gt;scripts/build-games.sh&lt;/code&gt;, builds them all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it
&lt;/h2&gt;

&lt;p&gt;You need Docker with Compose, &lt;code&gt;make&lt;/code&gt;, and either a Claude API key or a Claude Code login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/meshoklv/vibenokia &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;vibenokia
./scripts/quickstart.sh     &lt;span class="c"&gt;# creates .env with a fresh key and starts the server on 127.0.0.1:8085&lt;/span&gt;
make &lt;span class="nt"&gt;-C&lt;/span&gt; client &lt;span class="nv"&gt;SERVER_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://your.host:8080    &lt;span class="c"&gt;# -&amp;gt; client/dist/VibeNokia.jar + VibeNokia.jad&lt;/span&gt;
./scripts/build-games.sh                           &lt;span class="c"&gt;# optional -&amp;gt; games/dist/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client builds inside a public Docker image with a J2ME toolchain. Install &lt;code&gt;VibeNokia.jad&lt;/code&gt; from the memory card, then pair with a code as described above. Without Docker the server runs with &lt;code&gt;scripts/run-local.sh&lt;/code&gt;; you then need Python 3.10+, &lt;code&gt;ffmpeg&lt;/code&gt; for voice and &lt;code&gt;claude&lt;/code&gt; on &lt;code&gt;PATH&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is and isn't tested
&lt;/h2&gt;

&lt;p&gt;Tested: the client on a real Nokia 3310. For this release the client and all 41 games build in the J2ME container, and I drove the new menu and Features screen by hand in MicroEmulator (the screenshots come from there). The server image builds and answers 403 on loopback without auth, and pairing plus signed chat were exercised end to end with the phone emulator and a stub in place of &lt;code&gt;claude&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not tested: a real Claude call inside the published Docker image, other Series 30+ and 40 phones, and a TLS front together with the phone. Planned: prebuilt &lt;code&gt;.jar&lt;/code&gt; files in releases and per-device tool allow-lists.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the AI assistant
&lt;/h2&gt;

&lt;p&gt;Part of the code was written together with an AI assistant (Claude); the commit history shows this through a &lt;code&gt;Co-Authored-By&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/meshoklv/vibenokia" rel="noopener noreferrer"&gt;github.com/meshoklv/vibenokia&lt;/a&gt;, MIT license. Issues and PRs are open; the rules are in &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;. If you have another J2ME phone and try it, tell me what happened. I post notes on Telegram at &lt;a href="https://t.me/meshoklv" rel="noopener noreferrer"&gt;t.me/meshoklv&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mike Meshok - &lt;a href="https://github.com/meshoklv" rel="noopener noreferrer"&gt;github.com/meshoklv&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>python</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>thumbmux: a mobile web UI for tmux and AI agents that is password-protected from the first second</title>
      <dc:creator>Mike Meshok</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:42:15 +0000</pubDate>
      <link>https://dev.to/meshoklv/thumbmux-a-mobile-web-ui-for-tmux-and-ai-agents-that-is-password-protected-from-the-first-second-38n2</link>
      <guid>https://dev.to/meshoklv/thumbmux-a-mobile-web-ui-for-tmux-and-ai-agents-that-is-password-protected-from-the-first-second-38n2</guid>
      <description>&lt;p&gt;Claude Code, Codex, OpenCode and similar tools run in a terminal for minutes or hours. Checking on such a session from a phone through an ordinary web terminal is miserable: the keyboard fights the TUI, autocorrect mangles prompts, there is no scrolling, and all sessions look the same. I built thumbmux, a mobile web interface for your own tmux: a tab per session, a normal text field, swipe scrolling, photo and file upload. What I think matters most isn't the interface but that the terminal is behind a password and listens on no open port.&lt;/p&gt;

&lt;p&gt;A caveat up front: the interface was designed for iOS Safari. It works in desktop browsers, but I haven't poked at it there. The Docker image and the VPN compose file were not tested on the build machine, because it has no Docker. The server side is covered by tests; the screenshots below come from a test instance opened in Chrome at iPhone size.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flm39aodx1r7tpg1mma0w.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flm39aodx1r7tpg1mma0w.jpg" alt="The session list on an iPhone-sized screen: two agents working, one waiting for an answer, two done" width="800" height="1731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmh476cc08mjb5elgo5n4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmh476cc08mjb5elgo5n4.jpg" alt="An open session: the agent asks before editing a file, the text box and buttons are at the bottom" width="800" height="1731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Screenshots from a test instance with demo sessions, opened in Chrome at iPhone size.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with what can go wrong
&lt;/h2&gt;

&lt;p&gt;A web terminal is a shell on your machine reachable over HTTP. If it's open without a password, that isn't an inconvenience, it's a hole: anyone who can reach it runs commands as you. Every decision in the project was made with that one thought in mind, so I'll start with them rather than with pretty screens.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loopback only by default.&lt;/strong&gt; Without explicit configuration the server listens only on &lt;code&gt;127.0.0.1&lt;/code&gt;. Docker also publishes the port on the host's loopback only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A password is mandatory.&lt;/strong&gt; There is no default password. If you don't set one, the server generates a random one, saves it to &lt;code&gt;~/.thumbmux/password&lt;/code&gt; (mode 600) and prints it once in the log. A weak or placeholder password (shorter than 8 characters or from a list of obvious ones) is simply refused, and the server doesn't start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The terminal doesn't listen on the network at all.&lt;/strong&gt; The live terminal comes from ttyd, which has no proper access control of its own. So thumbmux starts ttyd itself on a unix socket inside a private temporary directory with mode 0700. There is no TCP port to find or connect to. The terminal reaches the outside only through the main server, at &lt;code&gt;/m/term/&lt;/code&gt;, and only after the login and origin checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is behind the login&lt;/strong&gt; except &lt;code&gt;/login&lt;/code&gt; and &lt;code&gt;/healthz&lt;/code&gt;. Session cookies are signed (HMAC-SHA256), expiring, &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;SameSite=Strict&lt;/code&gt;, and &lt;code&gt;Secure&lt;/code&gt; over HTTPS. Failed logins are throttled per address: five tries, then a one-minute lock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSRF and WebSocket hijacking.&lt;/strong&gt; Every POST and every WebSocket upgrade must carry a same-origin &lt;code&gt;Origin&lt;/code&gt; or &lt;code&gt;Referer&lt;/code&gt; and a non-cross-site &lt;code&gt;Sec-Fetch-Site&lt;/code&gt;, or it gets a 403. This closes the attack where another site, open in your browser, tries to connect to your logged-in terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookies don't reach the terminal.&lt;/strong&gt; The proxy strips &lt;code&gt;cookie&lt;/code&gt; and &lt;code&gt;authorization&lt;/code&gt; from requests to ttyd, and the login password is not exported into the terminal's environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From this follows an operating rule: put thumbmux behind TLS. The repository has Caddy and nginx examples in &lt;code&gt;deploy/&lt;/code&gt;. Use a separate (sub)domain rather than a path prefix, forward the &lt;code&gt;Host&lt;/code&gt; header and set &lt;code&gt;THUMBMUX_TRUST_PROXY=1&lt;/code&gt;. Or keep it on loopback and reach it through an SSH tunnel or an overlay network such as Tailscale. Don't publish the plain-HTTP port to the internet. And to be honest about it: one shared password protects a whole shell, so choose a long, unique one.&lt;/p&gt;

&lt;p&gt;For stronger insurance there is &lt;code&gt;docker-compose.vpn.yml&lt;/code&gt;: the container goes through a VPN with a kill-switch, so if the tunnel drops it has no network at all. This variant is on my "not tested" list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the interface does
&lt;/h2&gt;

&lt;p&gt;thumbmux doesn't replace tmux, it sits in front of it. The sessions are ordinary ones, so you can SSH in at the same time and run &lt;code&gt;tmux attach -t phone&lt;/code&gt; to see the same windows. Sessions keep running whether or not the phone is connected.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tabs and a session list.&lt;/strong&gt; A new tab with a shell or an agent (&lt;code&gt;claude&lt;/code&gt;, &lt;code&gt;codex&lt;/code&gt;, &lt;code&gt;opencode&lt;/code&gt;, &lt;code&gt;gemini&lt;/code&gt;, &lt;code&gt;aider&lt;/code&gt;, or any command from your &lt;code&gt;THUMBMUX_AGENTS&lt;/code&gt; list). The list looks like Telegram: the last lines of each session and a working / waiting / done indicator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An input box that behaves.&lt;/strong&gt; You type into a normal text field and the text is pasted into the pane. Swipe typing and autocorrect work as in any app. Esc, Tab, arrows, Ctrl-C and your own quick commands are buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swipe scrolling.&lt;/strong&gt; A finger movement scrolls the tmux history. The page forwards touches as wheel events, and in the recommended &lt;code&gt;deploy/tmux.conf&lt;/code&gt; one wheel tick moves exactly one line instead of five, because a swipe produces many ticks in a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uploads.&lt;/strong&gt; JPEG or PNG photos up to 15 MB (with a thumbnail) and any file up to 100 MB go to the pane's working directory; the path is typed into the agent's prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A file manager.&lt;/strong&gt; Browse, download, make folders, rename, move, delete to trash, and undo the last 50 operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A folder as a tab&lt;/strong&gt; with favourites, split panes, reopening past Claude Code chats through &lt;code&gt;claude --resume&lt;/code&gt;, and a deep link &lt;code&gt;/m/?go=claude&amp;amp;cwd=/path&lt;/code&gt; for a keyboard shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional voice input:&lt;/strong&gt; recording in the browser, transcribed by any OpenAI-compatible service, local or cloud. While &lt;code&gt;THUMBMUX_STT_URL&lt;/code&gt; is empty the feature is off and no audio goes anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server is a single file of plain Python, standard library only, plus ttyd and tmux. Installing without Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;tmux ttyd       &lt;span class="c"&gt;# or: brew install tmux ttyd&lt;/span&gt;
git clone https://github.com/meshoklv/thumbmux &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;thumbmux
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env             &lt;span class="c"&gt;# optionally set THUMBMUX_PASSWORD&lt;/span&gt;
./run.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Docker it's simpler: &lt;code&gt;docker compose up --build&lt;/code&gt;, a random password is printed once in the logs, and you open &lt;code&gt;http://127.0.0.1:7682&lt;/code&gt;. If you set &lt;code&gt;THUMBMUX_TMUX_SOCKET&lt;/code&gt;, every tmux call goes to that explicit socket instead of your normal server, which is handy for keeping it away from your main sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Telegram bot: convenient and dangerous
&lt;/h2&gt;

&lt;p&gt;New in Unreleased: an optional built-in bot. It messages you when a tab finishes or asks a question, with "Reply" and "Open" buttons. Answer with text or a voice message and it is typed into the right tab. There are &lt;code&gt;/sessions&lt;/code&gt;, &lt;code&gt;/new&lt;/code&gt; and &lt;code&gt;/help&lt;/code&gt;. It runs inside the server using long-polling, so only outbound HTTPS, no webhook and no open ports.&lt;/p&gt;

&lt;p&gt;And here is the main warning. The bot is remote input into your shell: whoever can message it runs commands as you. So the integration is off by default and refuses to start if a token is set while &lt;code&gt;THUMBMUX_TG_ALLOWED_CHAT_IDS&lt;/code&gt; is empty or malformed. Both the chat and the sender are checked; everyone else gets silence (the log keeps a line without their text). The token is read only from the environment and never logged, input is limited to 20 messages per minute per chat, and the long-poll offset is stored in a file with mode 600 so old messages aren't replayed after a restart.&lt;/p&gt;

&lt;p&gt;A second caveat: notifications contain terminal output, so it goes to Telegram. Don't enable the integration where sessions print secrets. And so far I have tested it only against a fake Bot API in the tests, not against the real Telegram server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tests/&lt;/code&gt; has security tests (login, session cookies, CSRF and origin checks, the WebSocket tunnel) and tests for the Telegram part against a fake Bot API: notification, reply routing, a foreign chat ignored, no allowlist means no start, voice, rate limit, offset after restart. Run them with &lt;code&gt;python3 -m unittest discover -s tests -v&lt;/code&gt;; you need &lt;code&gt;tmux&lt;/code&gt; and &lt;code&gt;ttyd&lt;/code&gt;. Tested on Ubuntu 24.04, Python 3.12, tmux 3.4 and ttyd 1.7.4.&lt;/p&gt;

&lt;p&gt;The changelog also has an honest line about a fixed bug: starting the server on an already existing tmux session crashed it because of an undefined variable. Fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the AI assistant
&lt;/h2&gt;

&lt;p&gt;Part of the code was written together with an AI assistant (Claude); the commit history shows this through a &lt;code&gt;Co-Authored-By&lt;/code&gt; line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/meshoklv/thumbmux" rel="noopener noreferrer"&gt;github.com/meshoklv/thumbmux&lt;/a&gt;, MIT license. Issues and PRs are open; please report vulnerabilities privately through GitHub Security Advisories. I post notes and new versions on Telegram at &lt;a href="https://t.me/meshoklv" rel="noopener noreferrer"&gt;t.me/meshoklv&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mike Meshok - &lt;a href="https://github.com/meshoklv" rel="noopener noreferrer"&gt;github.com/meshoklv&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tmux</category>
      <category>python</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A Claude Code status line that warns you before you hit the limit</title>
      <dc:creator>Mike Meshok</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:10:37 +0000</pubDate>
      <link>https://dev.to/meshoklv/a-claude-code-status-line-that-warns-you-before-you-hit-the-limit-3i4b</link>
      <guid>https://dev.to/meshoklv/a-claude-code-status-line-that-warns-you-before-you-hit-the-limit-3i4b</guid>
      <description>&lt;p&gt;The built-in footer in Claude Code doesn't tell you that the 5-hour limit is about to run out, or that 420K tokens in a 1M window already costs real money. So I wrote my own status line in plain Python with no dependencies: a context bar, remaining 5-hour and weekly limits, a "when will it run out" projection, session cost, git and uptime. Below: how it works, the decisions I made, and where it has holes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Opus 5.5] | ━━━━━━━━━━ 12% (120K/1000K) | H:91% 4h21m | W:73% | ⚙M+t | session $2.84 | my-app | ⏱ 1h 12m
 model       context bar                    5h left+reset  week    mode   cost           dir      uptime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repository: &lt;a href="https://github.com/meshoklv/claude-statusline" rel="noopener noreferrer"&gt;claude-statusline&lt;/a&gt;, MIT license.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmuyco1qmuk6phgldv9cl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmuyco1qmuk6phgldv9cl.png" alt="The status line in three states: a fresh session, a heavier one, and one where the 5-hour limit is almost gone. Real output of statusline.py for sample session data." width="800" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Claude Code draws a status line
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;settings.json&lt;/code&gt; you can set a command in the &lt;code&gt;statusLine&lt;/code&gt; field. Claude Code runs it whenever the session updates and passes it JSON on stdin: model, context window, limits, cost and so on. Whatever the command prints to stdout is shown at the bottom. That gives the script its main property: no network, no tokens spent, one short Python process per render.&lt;/p&gt;

&lt;p&gt;You can test it without Claude Code, straight from the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"model":{"display_name":"Opus"},"context_window":{"used_percentage":12,"context_window_size":1000000}}'&lt;/span&gt; | python3 statusline.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three decisions that make it useful
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Context color is based on absolute tokens
&lt;/h3&gt;

&lt;p&gt;Typical indicators color the bar by percentage. On a million-token window that is misleading: 42% looks fine, but it is 420K tokens and every request is already expensive. So the bar's length shows the share of the window, while its color depends on the absolute load. The threshold is in the config: &lt;code&gt;thresholds.context_red_tokens&lt;/code&gt;, 300,000 by default. From that value the bar is fully red, whatever the window size.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Limits are shown as what's left
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;H:91%&lt;/code&gt; reads as "91% of the 5-hour window remains", not "used". What I care about is runway, and having green at the start of the scale lines up naturally with "plenty left". The default yellow threshold is 50% remaining, red is 20%. &lt;code&gt;W:&lt;/code&gt; is the same for the weekly window. These segments appear for Pro and Max subscribers after the first response and are hidden when there is no data.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Exhaustion projection
&lt;/h3&gt;

&lt;p&gt;This is the only place where the script computes something instead of just displaying it. When the value changes (or more than 10 minutes have passed), it appends a sample to a small trend file: time, percent of the 5-hour window used, and the reset timestamp. The file is trimmed to the last 300 lines, and only samples from the current window count. Once there are at least 5 minutes of history and usage is growing, the script makes the simplest possible estimate: it takes the rate from the window's first sample to now and divides the remaining percent by it. The projection code is short, and this is the gist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;five_used&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;u&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;burn_min_span_s&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;five_used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;five_used&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is shown only if the limit will run out before the window resets; otherwise there's no point alarming you. In the red zone the line looks like &lt;code&gt;H:7% 2h10m&lt;/code&gt;, with a flame icon and "14m" next to it: at this pace the window ends in 14 minutes. The projection is linear and crude. It's the average rate since the start of the window, so a sudden spike in usage gets smoothed out and shows up late. That is a trade-off: I wanted neither a network call nor storage more complex than one small file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest
&lt;/h2&gt;

&lt;p&gt;Segments: &lt;code&gt;model context five_hour weekly mode tokens cost dir git mcps uptime&lt;/code&gt;. Order, set, colors, bar width, separator and labels are configurable in &lt;code&gt;~/.config/claude-statusline/config.json&lt;/code&gt;, all optional. There are presets &lt;code&gt;full&lt;/code&gt;, &lt;code&gt;minimal&lt;/code&gt; (model, context, five hours) and &lt;code&gt;compact&lt;/code&gt;, with English and Russian labels.&lt;/p&gt;

&lt;p&gt;Notes on some segments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mode&lt;/strong&gt; shows the effort level &lt;code&gt;L/M/H/XH/MAX&lt;/code&gt;, a thinking flag &lt;code&gt;+t&lt;/code&gt; and &lt;code&gt;+fast&lt;/code&gt; mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cost&lt;/strong&gt; is hidden while it's zero and turns red from a threshold you set (10 dollars by default).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tokens&lt;/strong&gt; is off by default because it reads the session and sub-agent transcripts, counting each streamed message once. The result is cached incrementally, but the files are read on every render, which is why it isn't enabled out of the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;git&lt;/strong&gt; and &lt;strong&gt;mcps&lt;/strong&gt; are cached: the branch for 5 seconds, the MCP server count for 60.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"segments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"context"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"five_hour"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tokens"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dir"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"colors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cyan"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Colors can be names, &lt;code&gt;#rrggbb&lt;/code&gt; or a number 0-255. The &lt;code&gt;NO_COLOR&lt;/code&gt; variable is respected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation, and what I tried not to break
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/meshoklv/claude-statusline.git
&lt;span class="nb"&gt;cd &lt;/span&gt;claude-statusline &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Options: &lt;code&gt;--lang ru&lt;/code&gt;, &lt;code&gt;--preset minimal&lt;/code&gt;, &lt;code&gt;--uninstall&lt;/code&gt;. Editing someone's &lt;code&gt;settings.json&lt;/code&gt; is a little scary, so the installer does three things: it backs the file up first; it won't replace a different, already configured status line without &lt;code&gt;--force&lt;/code&gt;; and &lt;code&gt;--uninstall&lt;/code&gt; puts the previous one back. If &lt;code&gt;settings.json&lt;/code&gt; is invalid JSON, the installer changes nothing and says so.&lt;/p&gt;

&lt;p&gt;The second rule is that the script must never take down the status line. A broken segment is skipped, missing data is not an error, and if the cache can't be written it just runs without it. The state directory is created with mode 0700 (&lt;code&gt;~/.cache/claude-statusline&lt;/code&gt; by default, overridable with &lt;code&gt;CLAUDE_STATUSLINE_STATE_DIR&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Tests use the standard &lt;code&gt;unittest&lt;/code&gt;: 29 in &lt;code&gt;test_statusline.py&lt;/code&gt; and 8 in &lt;code&gt;test_install.py&lt;/code&gt;. CI on GitHub Actions runs them on Python 3.8 and 3.12 and also checks that the demo page &lt;code&gt;docs/demo.html&lt;/code&gt; hasn't drifted from the code. The demo is generated by the script itself (&lt;code&gt;tools/render_demo.py&lt;/code&gt;) and shows real colored output in four variants: full, minimal, Russian, and "almost out of limit".&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't verified
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tested on Linux with Python 3.8 and 3.12 in CI. It should work on macOS but I haven't checked; Windows is not supported.&lt;/li&gt;
&lt;li&gt;The JSON format Claude Code passes to the status line isn't mine and may change. If a field disappears, the corresponding segment silently disappears.&lt;/li&gt;
&lt;li&gt;The projection is simple and linear (see above), with no rigorous science behind it.&lt;/li&gt;
&lt;li&gt;I haven't measured render time or memory use, so the repo has no such numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Also: timestamps on every message in the VS Code chat
&lt;/h2&gt;

&lt;p&gt;While on the subject of small missing things around Claude Code: in the official VS Code extension, the chat never shows when you sent what. After a week-long session you can't remember what you asked for in the morning. There is a separate project for that, &lt;a href="https://github.com/meshoklv/claude-native-timestamps" rel="noopener noreferrer"&gt;claude-native-timestamps&lt;/a&gt;: a small badge with the time, in your time zone and format, appears above each message.&lt;/p&gt;

&lt;p&gt;A warning is due here. There is no API for extending the chat, so the project works as a patch: it appends to the end of &lt;code&gt;webview/index.js&lt;/code&gt; inside the Claude Code extension a map of "normalized message text to earliest time" (built from your local transcripts in &lt;code&gt;~/.claude/projects&lt;/code&gt;) and a small script with a &lt;code&gt;MutationObserver&lt;/code&gt; that inserts the labels. The original is saved next to it as &lt;code&gt;index.js.cc-ts-backup&lt;/code&gt;, a Remove command restores everything, and a Claude Code update brings a clean file by itself. After an update the patch is applied again. If the chat markup changes, the labels simply won't appear and nothing breaks. Messages are matched by text, so where there is no transcript there will be no label. There is one label per turn, meaning a question and its answer count as one block. The project is not affiliated with Anthropic; it was checked on Claude Code 2.1.x on remote Linux and not tested from a desktop on macOS or Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/meshoklv/claude-statusline" rel="noopener noreferrer"&gt;github.com/meshoklv/claude-statusline&lt;/a&gt;: the status line, &lt;code&gt;./install.sh&lt;/code&gt; in a minute, one-command rollback.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/meshoklv/claude-native-timestamps" rel="noopener noreferrer"&gt;github.com/meshoklv/claude-native-timestamps&lt;/a&gt;: message times in the VS Code chat.&lt;/li&gt;
&lt;li&gt;Telegram: &lt;a href="https://t.me/meshoklv" rel="noopener noreferrer"&gt;t.me/meshoklv&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the status line shows wrong values on your plan or Claude Code version, open an issue with the output of &lt;code&gt;echo '&amp;lt;your JSON&amp;gt;' | python3 statusline.py&lt;/code&gt;. A star on GitHub helps others looking for the same thing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mike Meshok - &lt;a href="https://github.com/meshoklv" rel="noopener noreferrer"&gt;github.com/meshoklv&lt;/a&gt; - &lt;a href="https://meshoklv.github.io" rel="noopener noreferrer"&gt;meshoklv.github.io&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>python</category>
      <category>cli</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Benchmarking Russian speech recognition on CPU: T-one, GigaAM-v3 and cloud models</title>
      <dc:creator>Mike Meshok</dc:creator>
      <pubDate>Fri, 23 Oct 2026 01:10:36 +0000</pubDate>
      <link>https://dev.to/meshoklv/benchmarking-russian-speech-recognition-on-cpu-t-one-gigaam-v3-and-cloud-models-3cgo</link>
      <guid>https://dev.to/meshoklv/benchmarking-russian-speech-recognition-on-cpu-t-one-gigaam-v3-and-cloud-models-3cgo</guid>
      <description>&lt;p&gt;Most speech-model benchmarks are in English. The Russian ones I found rarely say what hardware a number was taken on, which license the weights carry, or what broke during installation. Russian is also a harder target than the headline English models suggest: the Cyrillic alphabet, rich morphology, and everyday speech full of English tech terms that have to come out in the right script. If you ship anything for Russian speakers, you end up having to measure for yourself.&lt;/p&gt;

&lt;p&gt;I needed a voice agent that understands Russian speech over the phone and answers out loud (the project is &lt;a href="https://github.com/meshoklv/ru-voice-agent" rel="noopener noreferrer"&gt;ru-voice-agent&lt;/a&gt;). I installed and measured everything I could get running: recognition, synthesis and turn detection. The results, scripts and license checks are in &lt;a href="https://github.com/meshoklv/ru-speech-bench" rel="noopener noreferrer"&gt;ru-speech-bench&lt;/a&gt;. This post covers recognition: what I got on a plain CPU with no GPU, and why I don't put WER in the table.&lt;/p&gt;

&lt;p&gt;First, the constraints, because they determine how to read the numbers. The measurements were taken in September 2026 on two machines: an 8-vCPU VM with a Xeon E5-2695 v2 (CPU only, no AVX2) and a desktop with an RTX 3090 Ti on Windows 11. Numbers are comparable within a table row and its hardware, not across rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not WER
&lt;/h2&gt;

&lt;p&gt;An honest WER needs a human-transcribed reference. I don't have one. The recordings are personal: voice messages from 7 seconds to two minutes of ordinary conversational speech with English terms, and they are not in the repo. So you won't find "accuracy" or "WER" for my measurements. Instead I compute disagreement: how many words you have to change to turn one engine's text into another's. It is word-level Levenshtein distance divided by the length of the reference, after lowercasing, replacing "ё" with "е" and stripping punctuation.&lt;/p&gt;

&lt;p&gt;What that means in practice is covered in &lt;a href="https://github.com/meshoklv/ru-speech-bench/blob/master/docs/methodology/stt-disagreement-and-streaming.md" rel="noopener noreferrer"&gt;docs/methodology/stt-disagreement-and-streaming.md&lt;/a&gt;. In short, 20% disagreement does not mean every fifth word is wrong. It includes insertions, spelling variants and errors in the "reference" itself. The metric is good for comparing variants of one system with each other (greedy decoder vs beam search, 16 kHz vs 8 kHz) and bad as a ranking.&lt;/p&gt;

&lt;p&gt;To compare many engines at once I use the mean distance to all the others, "distance to consensus". That has its own catch: the metric rewards resembling the majority, and as you'll see below, the majority in my set is Gemini models.&lt;/p&gt;

&lt;h2&gt;
  
  
  T-one: streaming on one core
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/voicekit-team/T-one" rel="noopener noreferrer"&gt;T-one&lt;/a&gt; from T-Bank is a streaming CTC Conformer with roughly 71.7M parameters (per the model card), trained on 80,000+ hours, mostly telephone speech. Apache-2.0. It takes audio in 300 ms chunks and finds phrase boundaries itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hardware results
&lt;/h3&gt;

&lt;p&gt;On a 53-second clip, greedy decoder, best of four runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ONNX Runtime threads&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;RTF&lt;/th&gt;
&lt;th&gt;CPU time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;16.39 s&lt;/td&gt;
&lt;td&gt;0.309&lt;/td&gt;
&lt;td&gt;16.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;11.39 s&lt;/td&gt;
&lt;td&gt;0.215&lt;/td&gt;
&lt;td&gt;22.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;12.78 s&lt;/td&gt;
&lt;td&gt;0.241&lt;/td&gt;
&lt;td&gt;41.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 (default)&lt;/td&gt;
&lt;td&gt;21.80 s&lt;/td&gt;
&lt;td&gt;0.411&lt;/td&gt;
&lt;td&gt;105.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By default the library takes all cores, and that is the worst option: slower, and more than six times as expensive in CPU time as a single thread. I kept one thread. The table is noisy: the VM shared its host with another container, and the host load average in some runs was 8.9-19.3. Source: &lt;code&gt;stt/results_threads.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In streaming mode on 8 kHz clips, one thread spends 99-123 ms on average per 300 ms chunk, p95 104-175 ms. With the greedy decoder, none of the 1091 chunks exceeded 300 ms, so the stream doesn't fall behind.&lt;/p&gt;

&lt;p&gt;Latency until a phrase appears is determined not by CPU speed but by how long the splitter waits for silence. The default is 20 frames of 30 ms, i.e. 0.6 s. Average lag came out at 1.05-1.09 s. Setting &lt;code&gt;MIN_SILENCE_DURATION = 6&lt;/code&gt; brings it down to 0.64-0.65 s, and on the two clips I checked no words were lost. At a value of 2 things break: words get chopped, and disagreement jumps from 20% to 46% and from 41.5% to 64.6%. Two clips is not much, and the repo says so.&lt;/p&gt;

&lt;p&gt;You can run several lines at once with a shared model and shared language model in one process: 1, 2 and 4 lines stay real-time, p95 around 110 ms. I did not measure more than four lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  What T-one can't do
&lt;/h3&gt;

&lt;p&gt;The output alphabet is the 33 Russian letters and a space. No Latin letters, no digits, no punctuation. "Excel", "4417" or "12 500" come out as Cyrillic words, spelled as heard. The language model can't help, because those symbols are simply not in the vocabulary. Another unpleasant thing: the language model (kenlm) turns letter noise into real words, but sometimes the wrong ones. A smooth wrong phrase is harder to notice than visible garbage, and the gain from the model is only 1.4-2.3 points of disagreement.&lt;/p&gt;

&lt;p&gt;Disagreement against GigaAM as the reference: 20-22% on a clean clip, 41.5-46.8% on a quiet recording of two people. Compression for a phone channel (G.711 mu-law) did not hurt quality, and 8 kHz was no worse than 16.&lt;/p&gt;

&lt;p&gt;Installation, separately. Beam search is the default, and without kenlm &lt;code&gt;from_hugging_face()&lt;/code&gt; fails, so you must explicitly ask for &lt;code&gt;DecoderType.GREEDY&lt;/code&gt;. kenlm has no wheels on PyPI, and &lt;code&gt;pypi-kenlm&lt;/code&gt; doesn't build on Python 3.12. I built it without root: the compiler was the &lt;code&gt;ziglang&lt;/code&gt; wheel, headers came from a standalone Python from uv, I removed the cmake step from &lt;code&gt;setup.py&lt;/code&gt;, and the compiler wrappers dropped the unsupported &lt;code&gt;-Wl,--exclude-libs&lt;/code&gt; flag. If the greedy decoder is enough for you, none of this is needed. One more thing: the upstream demo server listens on &lt;code&gt;0.0.0.0:8080&lt;/code&gt; with no authentication, so bind it to 127.0.0.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  GigaAM-v3: more accurate, but not streaming
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/ai-sage/GigaAM-v3" rel="noopener noreferrer"&gt;GigaAM-v3&lt;/a&gt; from SberDevices is a family of models on a HuBERT-like encoder pretrained on roughly 700,000 hours of Russian speech. I used the sherpa-onnx export: RNN-T with punctuation and an int8 encoder, about 232 MB on disk. MIT license.&lt;/p&gt;

&lt;p&gt;It is an offline model: text appears after the whole file or segment is processed. In return it adds punctuation and capital letters, and handles digits and Latin text.&lt;/p&gt;

&lt;p&gt;On my seven recordings on the same VM, four threads, under parallel load: median 12.8 s per file for lengths of 7 to 130 s, range 3.8-26.6 s. On the desktop I saw roughly 40x faster than real time, but I didn't record whether that ran on CPU or GPU, so treat the figure as approximate.&lt;/p&gt;

&lt;p&gt;The vendor-reported average WER over five domains is 8.4% for RNN-T, 9.2% for CTC and 25.1% for Whisper in the same table; T-one with a language model is at 19.4%. Those are model-card numbers and I did not reproduce them.&lt;/p&gt;

&lt;p&gt;GigaAM's weak spot is Latin terms and brand names. It writes them phonetically in Cyrillic or mangles them. This is fixed with dictionary post-processing; an example is in &lt;code&gt;stt/gigaam_words.example.json&lt;/code&gt;, hooked up in &lt;code&gt;gigaam_serve.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Gotchas: input must be mono, 16-bit, 16 kHz (run &lt;code&gt;ffmpeg&lt;/code&gt; first). A stereo recording of a conversation is better split by channel, recognized separately and merged by time; &lt;code&gt;stt_one_shot.py --dialog&lt;/code&gt; does that. On the noisy recording of two people, GigaAM did noticeably better than T-one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud vs local: a small check
&lt;/h2&gt;

&lt;p&gt;I ran the same seven recordings through Gemini models with audio input (via an API aggregator) and compared them with GigaAM. The full table and caveats are in &lt;a href="https://github.com/meshoklv/ru-speech-bench/blob/master/stt/cloud-compare/SUMMARY.md" rel="noopener noreferrer"&gt;stt/cloud-compare/SUMMARY.md&lt;/a&gt;. An excerpt, distance to consensus and median time per file:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fducf46d2kj2dc22h6x3m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fducf46d2kj2dc22h6x3m.png" alt="Distance of each engine to the other engines' transcripts, with median time per file. GigaAM-v3 on CPU is highlighted." width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Succeeded&lt;/th&gt;
&lt;th&gt;Distance&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.1-pro&lt;/td&gt;
&lt;td&gt;5/7&lt;/td&gt;
&lt;td&gt;7.0%&lt;/td&gt;
&lt;td&gt;61 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.6-flash&lt;/td&gt;
&lt;td&gt;7/7&lt;/td&gt;
&lt;td&gt;7.5%&lt;/td&gt;
&lt;td&gt;12 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.5-flash&lt;/td&gt;
&lt;td&gt;7/7&lt;/td&gt;
&lt;td&gt;8.2%&lt;/td&gt;
&lt;td&gt;17 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.8-flash&lt;/td&gt;
&lt;td&gt;7/7&lt;/td&gt;
&lt;td&gt;8.4%&lt;/td&gt;
&lt;td&gt;13 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3-pro&lt;/td&gt;
&lt;td&gt;7/7&lt;/td&gt;
&lt;td&gt;8.5%&lt;/td&gt;
&lt;td&gt;44 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-3.7-flash&lt;/td&gt;
&lt;td&gt;6/7&lt;/td&gt;
&lt;td&gt;8.6%&lt;/td&gt;
&lt;td&gt;12.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GigaAM-v3, local on CPU&lt;/td&gt;
&lt;td&gt;7/7&lt;/td&gt;
&lt;td&gt;9.4%&lt;/td&gt;
&lt;td&gt;13 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What you can and can't take from this. The spread is only 7.0-9.4%: on this material the engines mostly agree, and they differ in terms, verbal filler and small substitutions. GigaAM is the "farthest" from the group, but the group is mostly Gemini variants, so the metric is biased against it. The pro models were the most verbatim but 3-5 times slower and the least reliable: 3.1-pro had two timeouts at 180 seconds. On Latin terms 3.6-flash did best, but results are unstable from version to version. A strict prompt ("don't add or guess, mark anything unclear") gave no gain on 3.6-flash and slowed it from 12 to 58 seconds. All ~55 calls cost about $0.04 by the aggregator's balance, which I did not verify independently.&lt;/p&gt;

&lt;p&gt;Whisper and dedicated recognition endpoints through the aggregator were unavailable or had no keys, so they are not in the comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the repo
&lt;/h2&gt;

&lt;p&gt;It didn't stop at recognition, since the agent also has to talk. Briefly; details are in the cards under &lt;code&gt;docs/engines/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kokoro-82M (Apache-2.0), Russian via espeak: RTF 0.76 on the Xeon, 0.2 on a 5950X, 0.05 on GPU; intelligible and lively, stress sometimes wrong.&lt;/li&gt;
&lt;li&gt;ESpeech-TTS-1 RL-V2 on the 3090 Ti: RTF 0.17-0.36, first audio chunk in 1.1-1.5 s, 6 of 6 phrases spoken verbatim, places stress itself.&lt;/li&gt;
&lt;li&gt;XTTS-v2: good streaming (first audio in 0.47-0.53 s), but the CPML license is non-commercial. F5-TTS with a Russian fine-tune: CC BY-NC 4.0.&lt;/li&gt;
&lt;li&gt;Silero VAD 6.2.1: 0.51-0.58 ms per 32 ms chunk, roughly 55-62x faster than real time.&lt;/li&gt;
&lt;li&gt;Smart Turn v3.2: about 171 ms per decision, but the default threshold over-triggered on my material.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I checked licenses against the official pages and put the check date in every card. The README's licenses section says commercial use is closed for XTTS-v2, F5-TTS, Fish open weights and Piper's &lt;code&gt;ruslan&lt;/code&gt; voice, and that all four Russian Piper voices are fine-tuned from an English voice that is research-only, so commercial use there is unverified. This is not legal advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to know before quoting these numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Four clips for T-one and seven for the cloud comparison; the recordings are personal, mostly one speaker, and there is no reference.&lt;/li&gt;
&lt;li&gt;The splitter threshold was checked on two clips.&lt;/li&gt;
&lt;li&gt;Timings from the cloud run are noisy: runs of different engines overlapped in time on one machine and network.&lt;/li&gt;
&lt;li&gt;Before publication the scripts passed &lt;code&gt;python -m compileall&lt;/code&gt;, but I didn't re-run most of them. The numbers come from the original runs, before cleanup. You will probably need to adjust paths and model names.&lt;/li&gt;
&lt;li&gt;No audio or weights are included; put your own recordings in &lt;code&gt;./refs/&lt;/code&gt; and &lt;code&gt;./models/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;sherpa-onnx numpy
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GIGAAM_MODEL_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./models/sherpa-onnx-nemo-transducer-punct-giga-am-v3-russian-2025-12-16
python stt/gigaam_serve.py clip.wav &lt;span class="nt"&gt;--timing&lt;/span&gt;        &lt;span class="c"&gt;# mono, 16 kHz, 16-bit&lt;/span&gt;
python stt/stt_one_shot.py clip.ogg                 &lt;span class="c"&gt;# any format, ffmpeg is called for you&lt;/span&gt;
python stt/cloud-compare/analyze.py raw stt/cloud-compare/timing.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have your own recordings and hardware, send a card for a new engine or numbers from another machine. The template and rules are in &lt;a href="https://github.com/meshoklv/ru-speech-bench/blob/master/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;: license with a link and date, hardware, command.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/meshoklv/ru-speech-bench" rel="noopener noreferrer"&gt;github.com/meshoklv/ru-speech-bench&lt;/a&gt;, MIT for code and docs; the engines keep their own licenses. Updates on Telegram at &lt;a href="https://t.me/meshoklv" rel="noopener noreferrer"&gt;t.me/meshoklv&lt;/a&gt;. If this was useful, a star on GitHub helps others looking for the same thing find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mike Meshok - &lt;a href="https://github.com/meshoklv" rel="noopener noreferrer"&gt;github.com/meshoklv&lt;/a&gt; - &lt;a href="https://meshoklv.github.io" rel="noopener noreferrer"&gt;meshoklv.github.io&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>speech</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Running Dark Souls on an iPhone: what I had to fix in Madeira</title>
      <dc:creator>Mike Meshok</dc:creator>
      <pubDate>Fri, 23 Oct 2026 00:20:19 +0000</pubDate>
      <link>https://dev.to/meshoklv/running-dark-souls-on-an-iphone-what-i-had-to-fix-in-madeira-4kdn</link>
      <guid>https://dev.to/meshoklv/running-dark-souls-on-an-iphone-what-i-had-to-fix-in-madeira-4kdn</guid>
      <description>&lt;p&gt;Since late September I have been running PC builds of Dark Souls on an iPhone Air through the Madeira emulator. Remastered is now playable with an on-screen gamepad. In heavy scenes of Dark Souls III the median went from roughly 20 to 36.7 FPS on an experimental build. Along the way I collected 5 merged and 11 open PRs against Madeira, Wine, FEX and DXMT, plus a repository of scripts, which I'm publishing now.&lt;/p&gt;

&lt;p&gt;A caveat up front: I have one phone (an iPhone Air on iOS 27.2 beta) and one tester, me, so every number below comes from that single device. The games are my own, bought legitimately. The repository contains no game files, no save editors and no copy-protection circumvention, and I won't accept any.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Madeira is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/willfaust/Madeira" rel="noopener noreferrer"&gt;Madeira&lt;/a&gt; runs x64 Windows programs directly on an iPhone. It has three layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FEX-Emu translates x86-64 to ARM64 on the fly (JIT);&lt;/li&gt;
&lt;li&gt;Wine provides the Windows API;&lt;/li&gt;
&lt;li&gt;DXMT turns Direct3D 11 calls into Metal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For JIT on a non-jailbroken phone I use &lt;a href="https://github.com/StikDebug/StikDebug" rel="noopener noreferrer"&gt;StikDebug&lt;/a&gt;. Madeira's built-in helper needs a separate App ID, and my signing script strips it.&lt;/p&gt;

&lt;p&gt;The project is strong, but "install it and run a heavy game" doesn't work out of the box. You need to know which entitlements to re-sign the app with, which of the dozens of config keys actually change anything, and how to tell from a log what went wrong. I wrote that unwritten knowledge down and automated the boring parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dark Souls Remastered: eight problems in a row
&lt;/h2&gt;

&lt;p&gt;I worked on it from September 27 to October 19, 2026. Every step looked the same: one run, one log, one change. A small script, &lt;code&gt;tools/analyze-log.py&lt;/code&gt;, reads the log and reports what Madeira actually accepted from the environment, the module load order, exceptions with register dumps, whether graphics, window and audio came up, and how the run ended.&lt;/p&gt;

&lt;p&gt;The full table of causes is in &lt;a href="https://github.com/meshoklv/madeira-kit/blob/main/docs/bugs-and-fixes.md" rel="noopener noreferrer"&gt;docs/bugs-and-fixes.md&lt;/a&gt;. Here are the ones I remember best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash at startup, &lt;code&gt;c0000018&lt;/code&gt;.&lt;/strong&gt; The exe has a fixed base below 64 MB, but it needs a window at &lt;code&gt;0x140000000&lt;/code&gt;, and that range was not being handed out. This was already fixed upstream (Madeira #111), so I only had to pull it in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash after the first writes to code.&lt;/strong&gt; The game is protected by a layer that rewrites its own code at runtime, and that made everything fall over. The cause turned out to be a leak in Wine: on the &lt;code&gt;[exec-req]&lt;/code&gt; return path in ARM64EC &lt;code&gt;ntdll&lt;/code&gt;, &lt;code&gt;leave_syscall_callback()&lt;/code&gt; was never called, so the "we are inside a syscall" flag stayed set. A one-line fix, &lt;a href="https://github.com/willfaust/wine/pull/18" rel="noopener noreferrer"&gt;wine #18&lt;/a&gt;, merged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash in &lt;code&gt;steamclient64&lt;/code&gt;, &lt;code&gt;c0000005&lt;/code&gt;.&lt;/strong&gt; This one is more interesting. The Steam client truncates an &lt;code&gt;HMODULE&lt;/code&gt; to 32 bits, and iOS can't map memory below the 4 GB line, so the module lives at a high address and the truncated pointer points nowhere. I made an image with a "narrowed" base get a low-address name, and made &lt;code&gt;VirtualQuery&lt;/code&gt; report as free only the memory that can actually be taken. That is &lt;a href="https://github.com/willfaust/Madeira/pull/385" rel="noopener noreferrer"&gt;Madeira #385&lt;/a&gt;, still open. Because of it, my phone build is upstream plus one unmerged commit, and without that commit DSR crashes at startup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash while looking up the settings folder.&lt;/strong&gt; The Wine profile &lt;code&gt;C:\users\&amp;lt;user&amp;gt;&lt;/code&gt; simply had no &lt;code&gt;AppData&lt;/code&gt;. A script creates the folders (&lt;code&gt;device.py profile fix&lt;/code&gt;), and &lt;code&gt;apply-config.sh&lt;/code&gt; calls it too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gamepad: three bugs in one chain
&lt;/h3&gt;

&lt;p&gt;This took the longest, because the symptoms differed and three independent places were at fault.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The game doesn't see the gamepad. The game folder contains its own &lt;code&gt;xinput1_3.dll&lt;/code&gt;, and the loader picked it over the built-in one. Fix: the loader prefers the built-in XInput (&lt;a href="https://github.com/willfaust/wine/pull/19" rel="noopener noreferrer"&gt;wine #19&lt;/a&gt;, merged).&lt;/li&gt;
&lt;li&gt;The game sees the gamepad but ignores it. The game window wasn't active, and on iOS there is nobody to activate it. Now &lt;code&gt;win32u&lt;/code&gt; makes the process's main window active while it polls XInput (&lt;a href="https://github.com/willfaust/Madeira/pull/149" rel="noopener noreferrer"&gt;Madeira #149&lt;/a&gt;, merged).&lt;/li&gt;
&lt;li&gt;A tap that missed an on-screen button turned into a mouse click. While the gamepad layout is shown, such taps no longer count as mouse input (&lt;a href="https://github.com/willfaust/Madeira/pull/150" rel="noopener noreferrer"&gt;Madeira #150&lt;/a&gt;, merged).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two things helped: return addresses in the diagnostics, and logging of the key-press chain. That is how the gap was found. I now keep such diagnostic patches around.&lt;/p&gt;

&lt;h3&gt;
  
  
  A crash 35 seconds into the settings menu
&lt;/h3&gt;

&lt;p&gt;This was the emulator's own watchdog. It counted handled self-modifying-code writes cumulatively, and in the settings menu the game rewrites code every frame, cycling through seven keys. It reached the threshold of 2000 in about half a minute, and the process was killed. Now only consecutive identical failures on one thread are counted (&lt;a href="https://github.com/willfaust/Madeira/pull/148" rel="noopener noreferrer"&gt;Madeira #148&lt;/a&gt;, merged).&lt;/p&gt;

&lt;p&gt;After these fixes I was listed in Madeira's own Settings &amp;gt; Credits: "Controller fixes ... and a crash-guard fix".&lt;/p&gt;

&lt;p&gt;Two lessons from this stretch. First: check what is actually loaded, not what the config says. Image size and base address in the log lie less often than the settings file. Second: a clean CI build beats a build assembled from whatever was lying around. One of my builds, made from a mix of old and new parts, crashed in &lt;code&gt;signal_set_full_context&lt;/code&gt;, while the clean CI one did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dark Souls III: from 20 to 36.7 FPS
&lt;/h2&gt;

&lt;p&gt;DS3 is heavier. Steam is installed from inside Madeira (Madeira Dock) with your own account. Cutscenes run at 60 FPS; in combat on default settings it was 17-20.&lt;/p&gt;

&lt;p&gt;From there it was a measurement exercise. I broke logs down by phase: FPS, GPU load, core frequencies, draw calls per frame. The parsers are in &lt;code&gt;tools/ds3perf/&lt;/code&gt;, and the conclusions, each tagged "fact" or "hypothesis", are in &lt;a href="https://github.com/meshoklv/madeira-kit/tree/main/docs/perf" rel="noopener noreferrer"&gt;docs/perf/&lt;/a&gt;. There are 22 notes there, in Russian.&lt;/p&gt;

&lt;h3&gt;
  
  
  The phone heats up and clamps its clocks
&lt;/h3&gt;

&lt;p&gt;The least pleasant finding, from &lt;code&gt;DS3-POWER.md&lt;/code&gt;. In one long session, the P-cores ran at 2.8 GHz for the first seconds and gave about 34 FPS. Around the 59th second a clamp kicked in: frequency dropped toward 1.8 GHz and median FPS fell to 20.6. At 175 seconds iOS reported the &lt;code&gt;serious&lt;/code&gt; thermal state, which changed nothing further. So any optimization has to be measured on a hot phone, otherwise the numbers are pretty and useless.&lt;/p&gt;

&lt;h3&gt;
  
  
  What worked
&lt;/h3&gt;

&lt;p&gt;I named the change sets with letters. This is the table from &lt;a href="https://github.com/meshoklv/madeira-kit/blob/main/docs/compatibility.md" rel="noopener noreferrer"&gt;docs/compatibility.md&lt;/a&gt;, median in heavy scenes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;FPS&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;base set C2&lt;/td&gt;
&lt;td&gt;~17-20&lt;/td&gt;
&lt;td&gt;pool separation, no image copying, orphaned locks get flagged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;G1&lt;/td&gt;
&lt;td&gt;21.5&lt;/td&gt;
&lt;td&gt;lighter diagnostics, in-process synchronization, fast path for x64 critical sections, write windows for hot code pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H2&lt;/td&gt;
&lt;td&gt;36.7&lt;/td&gt;
&lt;td&gt;buffer and command-list reuse in DXMT, fast TLS, lighter SRW locks, thread QoS by name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For H2 in heavy scenes: median 36.7 FPS, p10 28.8 over the first minutes of a 12-minute run. After warm-up, with the P-cores sitting at 1.31 GHz, it holds 32-34 FPS. The GPU is about 88% busy, so now it is the bottleneck rather than the CPU.&lt;/p&gt;

&lt;p&gt;Now the honest part. The 36.7 figure is the sum of several changes. I did not measure the contribution of each one separately, and every PR says so explicitly. G1 had a 26-minute run with no hangs and a median of 21.5.&lt;/p&gt;

&lt;p&gt;What I tried and dropped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MetalFX: got worse;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pool-low&lt;/code&gt;: got worse;&lt;/li&gt;
&lt;li&gt;skipping self-modifying-code writes that have the same bytes: pointless, since 93% of the writes change the bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A separate note about DXMT. It turned out to create about a thousand Metal buffers per frame, and roughly 50 times per frame it allocates and frees 2 MB for command lists. Two keys (&lt;code&gt;DXMT_DYN_KEEP&lt;/code&gt; and &lt;code&gt;DXMT_CMDLIST_KEEP_BLOCK&lt;/code&gt;) remove that churn, and both are off by default (&lt;a href="https://github.com/willfaust/dxmt/pull/42" rel="noopener noreferrer"&gt;dxmt #42&lt;/a&gt;, &lt;a href="https://github.com/willfaust/dxmt/pull/43" rel="noopener noreferrer"&gt;#43&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Black screen and a hang in DS3
&lt;/h3&gt;

&lt;p&gt;Two separate DXMT bugs, both described in &lt;code&gt;DS3-CRASH.md&lt;/code&gt; and &lt;code&gt;DS3-FREEZE.md&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Black screen: when asked for an SRV description covering "all mip levels", DXMT returned &lt;code&gt;MipLevels = -1&lt;/code&gt;. The game takes that number as the level count and allocates an array from it, then crashes. Now the resolved value is returned (&lt;a href="https://github.com/willfaust/dxmt/pull/40" rel="noopener noreferrer"&gt;dxmt #40&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;DS3 hang: a backport of a fix from 3Shain/dxmt, where the draw-argument marshaling loops for GS/TS did not advance (&lt;a href="https://github.com/willfaust/dxmt/pull/41" rel="noopener noreferrer"&gt;dxmt #41&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have not fully tested the gamepad or saves in DS3; the repository lists this under "not tested".&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the repository
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/meshoklv/madeira-kit" rel="noopener noreferrer"&gt;madeira-kit&lt;/a&gt; is tooling around the loop of build, sign, install, configure, run, read the log, change one thing. It is not a fork of Madeira: I sent fixes upstream, and &lt;code&gt;patches/&lt;/code&gt; only holds reference copies.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;unpack.sh&lt;/code&gt;, &lt;code&gt;swap-dll.sh&lt;/code&gt;, &lt;code&gt;sign.sh&lt;/code&gt;, &lt;code&gt;install.sh&lt;/code&gt;: unpack a build, drop in patched DLLs, re-sign with your own certificate, install while keeping app data;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;doctor.sh&lt;/code&gt;: a pre-flight check (phone visible, app installed, at least 3 GB free, game copy present);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apply-config.sh&lt;/code&gt;: upload &lt;code&gt;madeira.cfg&lt;/code&gt;, read it back and compare, create any missing profile folders;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pull-log.sh&lt;/code&gt; and &lt;code&gt;analyze-log.py&lt;/code&gt;: fetch the log and analyze it right away;&lt;/li&gt;
&lt;li&gt;ready-made profiles for DS1 and DS3 in &lt;code&gt;configs/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The minimal path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/meshoklv/madeira-kit &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;madeira-kit
&lt;span class="nb"&gt;cp &lt;/span&gt;scripts/config.example.env scripts/config.env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$EDITOR&lt;/span&gt; scripts/config.env
scripts/unpack.sh ~/Downloads/Madeira-build.zip &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; scripts/sign.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; scripts/install.sh

scripts/doctor.sh
scripts/apply-config.sh configs/ds1-play.cfg
&lt;span class="c"&gt;# swipe Madeira away, enable JIT in StikDebug, start the game&lt;/span&gt;
scripts/pull-log.sh first-run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need a Mac with Xcode command line tools, an iPhone on USB, a certificate and provisioning profile for your bundle id, and &lt;code&gt;pip3 install pymobiledevice3&lt;/code&gt;. The entitlements for extended memory and extended virtual address space require a paid developer account.&lt;/p&gt;

&lt;p&gt;If you build Madeira on GitHub Actions yourself, the pitfalls are described in &lt;a href="https://github.com/meshoklv/madeira-kit/blob/main/docs/guide.md" rel="noopener noreferrer"&gt;docs/guide.md&lt;/a&gt;: LLVM as static libraries only, &lt;code&gt;TUNE_CPU=cortex-a78&lt;/code&gt; for FEX, and a stub for &lt;code&gt;ri_page_wait_time_mach&lt;/code&gt;. The Windows part of DXMT is not rebuilt by this CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I don't know
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The scripts are my working pipeline with personal identifiers removed. After that I syntax-checked them, but I did not re-run them end to end on a device.&lt;/li&gt;
&lt;li&gt;Other iPhone models and iOS versions are untested, as is a fresh clone of the repo on a second machine.&lt;/li&gt;
&lt;li&gt;I did not measure FPS or heat in Dark Souls Remastered itself, only "it's playable".&lt;/li&gt;
&lt;li&gt;All DS3 numbers come from one phone and a handful of runs, mostly a single run per configuration.&lt;/li&gt;
&lt;li&gt;A free developer account may not grant the required entitlements.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to help
&lt;/h2&gt;

&lt;p&gt;If you have a different iPhone or iPad, send a compatibility report: model, iOS version, game, config and result. Profiles with measured numbers are welcome. Cracks, game files and anything that bypasses protection are not.&lt;/p&gt;

&lt;p&gt;Part of the code and patches was written together with an AI assistant (Claude); in the commits of the upstream PRs this shows as a &lt;code&gt;Co-Authored-By&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/meshoklv/madeira-kit" rel="noopener noreferrer"&gt;github.com/meshoklv/madeira-kit&lt;/a&gt;, MIT license for the scripts and docs; the patches follow the licenses of the projects they target. Issues and PRs are open. I post notes and new builds on Telegram at &lt;a href="https://t.me/meshoklv" rel="noopener noreferrer"&gt;t.me/meshoklv&lt;/a&gt;. If the article was useful, a star on the repo helps others find it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mike Meshok - &lt;a href="https://github.com/meshoklv" rel="noopener noreferrer"&gt;github.com/meshoklv&lt;/a&gt; - &lt;a href="https://meshoklv.github.io" rel="noopener noreferrer"&gt;meshoklv.github.io&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>emulation</category>
      <category>opensource</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
