<?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: Fred 🍓</title>
    <description>The latest articles on DEV Community by Fred 🍓 (@fxdgif).</description>
    <link>https://dev.to/fxdgif</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%2F4200015%2F1dd5c9be-a05a-4788-80c4-1bbaf2c6cbc9.png</url>
      <title>DEV Community: Fred 🍓</title>
      <link>https://dev.to/fxdgif</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fxdgif"/>
    <language>en</language>
    <item>
      <title>My ASCII hero redrew every frame from scratch. Only 55 of 12,198 cells had changed.</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Thu, 12 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/fxdgif/my-ascii-hero-redrew-every-frame-from-scratch-only-55-of-12198-cells-had-changed-12pk</link>
      <guid>https://dev.to/fxdgif/my-ascii-hero-redrew-every-frame-from-scratch-only-55-of-12198-cells-had-changed-12pk</guid>
      <description>&lt;p&gt;Last week I rebuilt the website for my open-source app. The hero is a rain cloud made of characters (&lt;code&gt;.·:;!/?*%$&amp;amp;8#@&lt;/code&gt;). It drifts, it rains, and it bends a little around your cursor. Plain Canvas 2D, no WebGL, no library.&lt;/p&gt;

&lt;p&gt;I built it with the usual performance tricks. Then I turned each one off and measured. Most of them did less than I thought. The biggest win was one I hadn't built.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the field works
&lt;/h2&gt;

&lt;p&gt;The canvas is a grid of cells, 9 × 15 CSS pixels each. On a 1920 px wide screen the hero canvas is 1920 × 846, so that's 214 × 57 = &lt;strong&gt;12,198 cells&lt;/strong&gt;. Most of them are dark sky. About 700 show a character at any moment.&lt;/p&gt;

&lt;p&gt;Every frame, each cell asks the scene for a brightness between 0 and 1. For the cloud that's a few soft blobs, value noise on top, a flat underside like a real cumulus, and light from above. Brightness picks a character from a density ramp and one of 12 opacity levels. Rain drops are separate particles that write &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt; and &lt;code&gt;.&lt;/code&gt; into the cells they pass.&lt;/p&gt;

&lt;p&gt;The loop runs at most 30 fps, and only while the hero is on screen and the tab is visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;
  &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rootMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;60px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The tricks I built in
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A glyph atlas.&lt;/strong&gt; Every character is pre-rendered once into an offscreen canvas, in 12 opacity levels and 2 colours. Drawing a cell is a single &lt;code&gt;drawImage&lt;/code&gt; from that atlas instead of &lt;code&gt;fillText&lt;/code&gt; with a new &lt;code&gt;fillStyle&lt;/code&gt; and &lt;code&gt;globalAlpha&lt;/code&gt; each time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Interlacing.&lt;/strong&gt; The noise field is cached. The cloud moves slowly, so each frame only recomputes every second or third row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseFull&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frameNo&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;K&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. A bounding box.&lt;/strong&gt; Once per frame I compute a rectangle around the cloud. Outside it, a cell skips the noise and only gets the odd dust speck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Cheap cursor effects.&lt;/strong&gt; The lens and the ripples don't sample the noise again. They read the cached field at a shifted position.&lt;/p&gt;

&lt;p&gt;One small bug came with the atlas. My first version showed stray dash-like fragments in the cloud. The fix was to clip every glyph to its own cell while building the atlas, so nothing from one character can end up in the neighbouring cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;acw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ach&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CHARS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;acw&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ach&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At today's font and cell sizes I checked every glyph, and none of them leaves its cell anymore. The clip stays anyway. It costs nothing, and it matters for the trick at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I measured
&lt;/h2&gt;

&lt;p&gt;Setup: Playwright driving Chromium 153 (headless, but on the real GPU through ANGLE/D3D11), an Intel Iris Xe laptop, viewport 1920 × 1080, device pixel ratio 1, nobody touching the mouse. Two numbers per run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JS per frame&lt;/strong&gt;: I wrapped &lt;code&gt;requestAnimationFrame&lt;/code&gt; and timed my own callback (update plus all the &lt;code&gt;drawImage&lt;/code&gt; calls).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whole browser&lt;/strong&gt;: CPU time of every Chromium process over 10 seconds, as a percentage of one core. That includes the GPU process and the compositor, which the JS timer never sees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran each variant several times, alternating, and the table shows the medians. The laptop's clock moves a lot, so identical runs differed by up to ±30 %. Read the numbers as rough.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;JS per frame&lt;/th&gt;
&lt;th&gt;Whole browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hero switched off&lt;/td&gt;
&lt;td&gt;–&lt;/td&gt;
&lt;td&gt;~10 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Everything on (what's live)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~49 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recompute every row (no interlacing)&lt;/td&gt;
&lt;td&gt;5.0 ms&lt;/td&gt;
&lt;td&gt;~61 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No bounding box&lt;/td&gt;
&lt;td&gt;3.6 ms&lt;/td&gt;
&lt;td&gt;~44 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fillText&lt;/code&gt; instead of the atlas&lt;/td&gt;
&lt;td&gt;3.0 ms&lt;/td&gt;
&lt;td&gt;~40 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things surprised me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;My JavaScript was never the problem.&lt;/strong&gt; 3.5 ms at 30 fps is about a tenth of a core. With the hero running, the whole browser used about half a core, against ~10 % without it. The rest of the bill was the browser itself: clearing a 1920 × 846 canvas, repainting about 700 glyphs and compositing the result, 30 times a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interlacing helped, the bounding box didn't.&lt;/strong&gt; Interlacing saved about 1.5 ms per frame. The bounding box was within noise, because the blob test it skips was already cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The atlas didn't beat &lt;code&gt;fillText&lt;/code&gt;.&lt;/strong&gt; In this Chrome, on this laptop, plain &lt;code&gt;fillText&lt;/code&gt; was the same or a little cheaper. With only about 700 glyphs per frame there isn't much for an atlas to win, and the advice I had in my head is older than the browser I was testing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The trick I hadn't built: skip what didn't change
&lt;/h2&gt;

&lt;p&gt;So I counted. Between two frames, how many cells actually show a different character, colour or opacity?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A median of 55 out of 12,198.&lt;/strong&gt; The cloud drifts so slowly that almost every cell lands on the same glyph and level as last frame. Most of the changes are rain.&lt;/p&gt;

&lt;p&gt;And every frame I was clearing the whole canvas and drawing about 700 glyphs to show those 55 changes.&lt;/p&gt;

&lt;p&gt;The fix: no full clear anymore. Remember what each cell showed, and only touch the cells whose key changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cellKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// glyph, colour and opacity level, or -1 for empty&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;   &lt;span class="c1"&gt;// same as last frame: leave the pixels alone&lt;/span&gt;
  &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;cols&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;acw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ach&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;acw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ach&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;drawCell&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// one drawImage from the atlas&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the atlas and the clip pay off after all. Every glyph lives exactly inside its cell, so clearing and redrawing one cell can never cut a neighbour in half. With &lt;code&gt;fillText&lt;/code&gt;, a wide glyph could spill over and leave half a character behind.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;JS per frame&lt;/th&gt;
&lt;th&gt;Whole browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Everything on (what's live)&lt;/td&gt;
&lt;td&gt;3.5 ms&lt;/td&gt;
&lt;td&gt;~49 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Redraw only changed cells&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.0 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~27 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole browser went from about half a core to about a quarter. The renderer and the GPU process both dropped by about half. The visible result is identical, because every cell ends up with exactly the same pixels.&lt;/p&gt;

&lt;p&gt;This version isn't on the live site yet. I wanted the measurement first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduced motion
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt;, the field renders one finished frame (rain already placed) and stops. No loop, no cursor reaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(prefers-reduced-motion: reduce)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;still&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// one complete frame, then nothing&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's two lines, and nobody who asked for less motion gets a cloud that keeps moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't prove
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One laptop, one Chromium build. Firefox and Safari draw text and images differently, and the atlas-vs-&lt;code&gt;fillText&lt;/code&gt; result may well flip there.&lt;/li&gt;
&lt;li&gt;Nobody moved the mouse. The lens and the ripples can change hundreds of cells per frame, so while you play with the hero, the redraw trick saves much less.&lt;/li&gt;
&lt;li&gt;The JS timer only covers my callback. The real cost was elsewhere, which is the point of the second column.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Measure the whole browser, not just your &lt;code&gt;requestAnimationFrame&lt;/code&gt; callback.&lt;/li&gt;
&lt;li&gt;Cache what moves slowly. Interlacing was worth about 1.5 ms per frame here.&lt;/li&gt;
&lt;li&gt;Don't assume a glyph atlas beats &lt;code&gt;fillText&lt;/code&gt; in a current browser. Measure it.&lt;/li&gt;
&lt;li&gt;Count how many cells really change. Mine: 55 out of 12,198. Then stop clearing the whole canvas.&lt;/li&gt;
&lt;li&gt;Give reduced motion a still frame, not a slower animation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole engine is one file, a bit over 700 lines, no dependencies: &lt;a href="https://github.com/fxd-gif/vibedesk/blob/gh-pages/assets/ascii.js" rel="noopener noreferrer"&gt;assets/ascii.js&lt;/a&gt; (some comments are in German, sorry). You can see it running on the &lt;a href="https://fxd-gif.github.io/vibedesk/" rel="noopener noreferrer"&gt;VibeDesk website&lt;/a&gt;. VibeDesk is a free, open-source Windows app around Claude Code that I build, and the site is where I tried all this.&lt;/p&gt;

&lt;p&gt;If you've built something like this: did a glyph atlas ever beat plain &lt;code&gt;fillText&lt;/code&gt; for you? &lt;strong&gt;A)&lt;/strong&gt; yes, clearly &lt;strong&gt;B)&lt;/strong&gt; about the same &lt;strong&gt;C)&lt;/strong&gt; never measured. And which browser?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
      <category>performance</category>
    </item>
    <item>
      <title>Starting Claude Code from Node on Windows: 6 things that broke for me</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Tue, 10 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/fxdgif/starting-claude-code-from-node-on-windows-6-things-that-broke-for-me-akb</link>
      <guid>https://dev.to/fxdgif/starting-claude-code-from-node-on-windows-6-things-that-broke-for-me-akb</guid>
      <description>&lt;p&gt;Most Claude Code tooling I read about assumes macOS or Linux. I build a Windows desktop app that starts Claude Code sessions in terminals, writes commit messages with &lt;code&gt;claude -p&lt;/code&gt; and runs hooks all day. Here's what went wrong on Windows, in the order I hit it, so you don't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;claude&lt;/code&gt; isn't an executable
&lt;/h2&gt;

&lt;p&gt;If you installed Claude Code with npm, &lt;code&gt;claude&lt;/code&gt; on Windows is a &lt;code&gt;claude.cmd&lt;/code&gt; shim. &lt;code&gt;spawn('claude', ...)&lt;/code&gt; fails, and since Node's 2024 security fix (CVE-2024-27980) spawning a &lt;code&gt;.cmd&lt;/code&gt; without a shell throws &lt;code&gt;EINVAL&lt;/code&gt; on purpose.&lt;/p&gt;

&lt;p&gt;What works reliably for me is going through &lt;code&gt;cmd.exe&lt;/code&gt; explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;settingsFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--mcp-config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mcpFile&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;pty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cmd.exe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/d&lt;/code&gt; skips any AutoRun commands the user has in the registry, so their shell setup can't interfere.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Don't add &lt;code&gt;/s&lt;/code&gt;, and don't quote the first token
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cmd /c&lt;/code&gt; has odd rules for quotes. With &lt;code&gt;/s&lt;/code&gt;, or when the line starts with a quote, it strips the first and last quote character of the whole command line. A settings path like &lt;code&gt;"C:\Users\Jane Doe\...\settings.json"&lt;/code&gt; then loses one of its quotes and splits at the space.&lt;/p&gt;

&lt;p&gt;Keep the first token (&lt;code&gt;claude&lt;/code&gt;) unquoted and leave out &lt;code&gt;/s&lt;/code&gt;. Then cmd leaves the quotes around your paths alone. Users with spaces in their username will thank you.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Everything on that command line is an injection point
&lt;/h2&gt;

&lt;p&gt;Once you go through &lt;code&gt;cmd.exe&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt; and friends mean something. Two rules I now follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validate anything that becomes an argument.&lt;/strong&gt; A session ID for &lt;code&gt;--resume&lt;/code&gt; has to match &lt;code&gt;^[A-Za-z0-9-]{1,64}$&lt;/code&gt;, or the app refuses to start it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never pass user text as an argument.&lt;/strong&gt; Commit diffs, PR drafts, notes typed on the phone: they all go to &lt;code&gt;claude -p&lt;/code&gt; through &lt;strong&gt;stdin&lt;/strong&gt;. The prompt argument is a fixed sentence like &lt;code&gt;"Follow the instructions in the input."&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;execFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cmd.exe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-p&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Write the commit message as instructed in the input.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;haiku&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--output-format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// the diff and the instructions are written to the child's stdin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Your Electron environment leaks into Claude
&lt;/h2&gt;

&lt;p&gt;The child inherits your whole environment. If &lt;code&gt;ELECTRON_RUN_AS_NODE&lt;/code&gt; ends up in your app's environment (for example because it was started from a tool that sets it), every Electron-based program started further down runs as plain Node instead. I delete it before spawning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;MY_APP_SESSION_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ELECTRON_RUN_AS_NODE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small bonus trap: Windows already has an environment variable called &lt;code&gt;PROMPT&lt;/code&gt; (it's &lt;code&gt;$P$G&lt;/code&gt;, the cmd prompt format). If your app reads its own &lt;code&gt;PROMPT&lt;/code&gt; variable, it gets that.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Hook commands have to survive three shells
&lt;/h2&gt;

&lt;p&gt;A hook command in &lt;code&gt;settings.json&lt;/code&gt; can end up running in cmd, Git Bash or PowerShell, depending on the setup. The form that worked everywhere for me:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;path with spaces&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;hook-forwarder.js&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;The program (&lt;code&gt;node&lt;/code&gt;) is unquoted, and only the path is quoted. Quoting the program too breaks in cmd for the same reason as trap 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Line endings, when Claude edits through scripts
&lt;/h2&gt;

&lt;p&gt;This one isn't about spawning, but it bit me hardest. My repo is LF. Sometimes Claude Code decides to change a file with a small Python script instead of its edit tool. Python's &lt;code&gt;open(path, 'w')&lt;/code&gt; on Windows writes &lt;strong&gt;CRLF&lt;/strong&gt;, so a one-line change shows up as a diff of the entire file.&lt;/p&gt;

&lt;p&gt;Things that help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;.gitattributes&lt;/code&gt; with &lt;code&gt;* text=auto eol=lf&lt;/code&gt;, so Git normalises on commit.&lt;/li&gt;
&lt;li&gt;A line in &lt;code&gt;CLAUDE.md&lt;/code&gt;: prefer the edit tool, and when a script is needed, open files with &lt;code&gt;newline=''&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Look at &lt;code&gt;git diff --stat&lt;/code&gt; before every commit. A 900-line change for a typo is your hint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: backslashes inside bash heredocs and non-ASCII characters in &lt;code&gt;curl -d&lt;/code&gt; from Git Bash can arrive mangled. If the content matters, write it to a file first and send the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;p&gt;I only use Claude Code installed through npm. The native installer may avoid traps 1 and 2 completely, and newer Claude Code versions run sessions through a background service, which changes how a terminal is attached. Check your own setup before copying any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question
&lt;/h2&gt;

&lt;p&gt;If you use Claude Code on Windows: &lt;strong&gt;how do you run it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A) PowerShell / Windows Terminal&lt;br&gt;&lt;br&gt;
B) Git Bash&lt;br&gt;&lt;br&gt;
C) WSL&lt;br&gt;&lt;br&gt;
D) inside an editor (VS Code, JetBrains)&lt;/p&gt;

&lt;p&gt;And what's the weirdest Windows-only bug you hit with it?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(All of the code above comes from &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;VibeDesk&lt;/a&gt;, an open-source Windows desktop for Claude Code, if you want to see it in context.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>windows</category>
      <category>node</category>
      <category>electron</category>
    </item>
    <item>
      <title>Claude Code wrote almost all of my app. My job turned into saying no.</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Thu, 05 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/fxdgif/claude-code-wrote-almost-all-of-my-app-my-job-turned-into-saying-no-dda</link>
      <guid>https://dev.to/fxdgif/claude-code-wrote-almost-all-of-my-app-my-job-turned-into-saying-no-dda</guid>
      <description>&lt;p&gt;Three weeks ago I started a desktop app. Since then: 169 commits, about 48,000 lines in &lt;code&gt;src/&lt;/code&gt;, and I typed very little of it myself. Claude Code did. I steered, tested and, more than anything, said no.&lt;/p&gt;

&lt;p&gt;That last part surprised me. I expected the bottleneck to be the model getting things wrong. It wasn't. The bottleneck was me letting too much in.&lt;/p&gt;

&lt;h2&gt;
  
  
  When code is cheap, everything looks worth building
&lt;/h2&gt;

&lt;p&gt;Here's what nobody warned me about. When a feature costs you a sentence and ten minutes, the question "is this worth building?" quietly stops being asked. Everything is worth building, because nothing costs anything.&lt;/p&gt;

&lt;p&gt;So I built everything.&lt;/p&gt;

&lt;p&gt;Between October 13 and 19 I shipped &lt;strong&gt;29 releases in 7 days&lt;/strong&gt;. Version 0.7 to 1.9. Every idea went straight in, every release had something new. It felt amazing. It also made the app loud. New windows popped up on top of the terminal. The dock filled with icons. The first-start screen asked "How much do you want to see?" before people had seen anything at all.&lt;/p&gt;

&lt;p&gt;Nothing was broken. It was just &lt;em&gt;too much&lt;/em&gt;, and I couldn't see it while I was the one adding things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The no's that made it better
&lt;/h2&gt;

&lt;p&gt;Looking back through my commits, the changes I'm proudest of are mostly removals or restraint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"New windows never cover Claude."&lt;/strong&gt; Every new window now finds free space instead of opening on top of the terminal you're working in. One rule, no new features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New features go into a "More" menu, not the dock.&lt;/strong&gt; The dock is for the five things you use every hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "How much do you want to see?" question is gone.&lt;/strong&gt; You start calm, and you find more when you look for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stats card ("Vibe Wrapped") lost its AI-style gradients&lt;/strong&gt; and got a calm, printed look instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The phone connection lost one of its two tunnel options.&lt;/strong&gt; Two choices meant two things to explain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No account, no sign-up, no Discord server.&lt;/strong&gt; Questions go to GitHub issues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bigger tested release at a time&lt;/strong&gt; instead of 30 a week. Feature freeze a few days before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these needed much code. All of them needed someone to say "no, not like that".&lt;/p&gt;

&lt;h2&gt;
  
  
  The model is great at "yes"
&lt;/h2&gt;

&lt;p&gt;Claude Code is extremely good at doing what you ask. That's the problem. Ask for a toggle and you get a well-built toggle with tests. It won't tell you that nobody will ever find it between all the other toggles.&lt;/p&gt;

&lt;p&gt;So these are the questions I try to ask now, before saying "build it":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Who opens this, and how often?&lt;/li&gt;
&lt;li&gt;What does it push off the screen?&lt;/li&gt;
&lt;li&gt;Would I notice if it were missing?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If I can't answer the first one with a real person, it waits. Ideas that people actually ask for in GitHub issues (with a 👍) go into the next release. The rest stays an idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taste is the job now
&lt;/h2&gt;

&lt;p&gt;I don't think "AI will replace developers" or "AI is just autocomplete" describes what I'm doing. It feels more like being an editor with an infinitely fast writer. The writer never gets tired and never pushes back. The editor has to.&lt;/p&gt;

&lt;p&gt;And editing is hard in a new way. You're not judging whether the code works. Usually it does. You're judging whether it should exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't prove
&lt;/h2&gt;

&lt;p&gt;This is one solo project with no team, no users to negotiate with and no deadline except my own. In a team, "no" is spread over code review, product people and design. I'm doing all of it alone, so maybe I just feel it more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question
&lt;/h2&gt;

&lt;p&gt;If you build with an agent most of the day: &lt;strong&gt;what's the hardest part of your job now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A) checking the code is actually right&lt;br&gt;&lt;br&gt;
B) deciding what to build at all&lt;br&gt;&lt;br&gt;
C) keeping the codebase understandable for future me&lt;br&gt;&lt;br&gt;
D) something else, tell me&lt;/p&gt;

&lt;p&gt;And what's the last thing you said no to?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The app is &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;VibeDesk&lt;/a&gt;, a calm desktop for running several Claude Code sessions on Windows. Wishes go in its GitHub issues.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>productivity</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>I was sure it was a memory leak. It was a glowing border using 131 % of a CPU core</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Wed, 04 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/fxdgif/i-was-sure-it-was-a-memory-leak-it-was-a-glowing-border-using-131-of-a-cpu-core-7kn</link>
      <guid>https://dev.to/fxdgif/i-was-sure-it-was-a-memory-leak-it-was-a-glowing-border-using-131-of-a-cpu-core-7kn</guid>
      <description>&lt;p&gt;My app is an Electron desktop for Claude Code: glass windows with &lt;code&gt;backdrop-filter&lt;/code&gt; blur, a WebGL rain background, a few terminals. One day I wrote in my own bug list: &lt;em&gt;"If I leave it running for a while, it gets really slow."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Slow after a while" sounds like a memory leak. I was so sure that I nearly went looking for one. Then I measured first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;One Claude Code session working in a terminal, the app in front. Numbers are CPU time per process, as a percentage of one core:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;GPU process&lt;/th&gt;
&lt;th&gt;Renderer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;installed 1.9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory was flat. Main process around 110 MB, renderer heap around 20 MB, no growth. No leak.&lt;/p&gt;

&lt;p&gt;What actually happened: the laptop (integrated Intel graphics) got hot and throttled after a few minutes. "Slow after a while" was thermals, not memory.&lt;/p&gt;

&lt;p&gt;So what was burning a core and a third on the GPU process while I was just watching a terminal?&lt;/p&gt;

&lt;h2&gt;
  
  
  Suspect 1: the glow
&lt;/h2&gt;

&lt;p&gt;When Claude is working, its window gets a soft, breathing glow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.gwin.claude-working&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claude-glow&lt;/span&gt; &lt;span class="m"&gt;2.8s&lt;/span&gt; &lt;span class="nb"&gt;ease-in-out&lt;/span&gt; &lt;span class="nb"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;claude-glow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="m"&gt;50%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;159&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;184&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;.3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;40px&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;124&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;156&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;.26&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pretty, harmless, right? Not with glass around it.&lt;/p&gt;

&lt;p&gt;A smooth infinite animation means the compositor has to produce a new frame 60 times a second. And every new frame recomputes the big &lt;code&gt;backdrop-filter&lt;/code&gt; blurs of every glass panel on screen. My rain shader was carefully throttled to 15–30 FPS. One CSS animation quietly overrode that and pushed everything to 60.&lt;/p&gt;

&lt;p&gt;Two things surprised me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Size doesn't matter.&lt;/strong&gt; An 8-pixel equalizer icon in the dock cost the same as the big window glow. Any animated pixel means a new frame, and the blur gets recomputed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;transform&lt;/code&gt; instead of &lt;code&gt;box-shadow&lt;/code&gt; didn't help either.&lt;/strong&gt; The usual "animate only transform and opacity" advice is about layout and paint cost. Here the expensive part was the frame itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is one word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.gwin.claude-working&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claude-glow&lt;/span&gt; &lt;span class="m"&gt;2.8s&lt;/span&gt; &lt;span class="nf"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;steps(5)&lt;/code&gt; makes the glow breathe in five visible stages instead of a smooth curve. Between the steps nothing changes, so no new frames are produced. At normal viewing distance it still reads as "breathing". I moved every long-running infinite animation to &lt;code&gt;steps()&lt;/code&gt;: the waiting dot (which can sit there for hours), the equalizer, a live indicator. Short loading spinners stay smooth, because they disappear after a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Suspect 2: the rain that wasn't raining
&lt;/h2&gt;

&lt;p&gt;The WebGL rain has an intensity slider. At 0 % you see a still background. But the shader kept rendering 15–30 frames a second of zero raindrops, because the "paused" flag was never passed down.&lt;/p&gt;

&lt;p&gt;Now the frame scheduler returns "no frames" at 0 %:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;frameInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FrameState&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rain&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rain&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="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;FPS_IDLE&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saver&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;FPS_SAVER&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FPS_FOCUS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Suspect 3: every line of terminal output
&lt;/h2&gt;

&lt;p&gt;This one I can't fully fix. A terminal that's streaming output changes pixels all the time, and every change under a glass panel recomputes its blur. For battery mode the glass now turns into plain, nearly opaque panels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.app.saver&lt;/span&gt; &lt;span class="nc"&gt;.gwin&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.app.saver&lt;/span&gt; &lt;span class="nc"&gt;.dock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;backdrop-filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;.9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  After
&lt;/h2&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;GPU process&lt;/th&gt;
&lt;th&gt;Renderer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.9 (smooth glow)&lt;/td&gt;
&lt;td&gt;131 %&lt;/td&gt;
&lt;td&gt;30 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glow on &lt;code&gt;steps()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~26 %&lt;/td&gt;
&lt;td&gt;~14 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rain off really means off&lt;/td&gt;
&lt;td&gt;~20 %&lt;/td&gt;
&lt;td&gt;~12 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;battery saver without live blur&lt;/td&gt;
&lt;td&gt;~11 %&lt;/td&gt;
&lt;td&gt;~11 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From 131 % to about 11 %, mostly by deleting frames nobody needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured (and two traps)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I started a second, isolated copy with Playwright's &lt;code&gt;_electron&lt;/code&gt;, a copy of my real settings and its own user-data folder, so the measurement didn't touch my working setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap 1:&lt;/strong&gt; &lt;code&gt;app.getAppMetrics()&lt;/code&gt; reports CPU divided by the number of cores. On 8 cores, "16 %" is actually more than a full core. I switched to measuring the difference in CPU seconds per process from the OS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap 2:&lt;/strong&gt; a hidden or covered Electron window stops &lt;code&gt;requestAnimationFrame&lt;/code&gt;. My first measurement showed almost 0 % GPU because the test window was behind another one. Bring it to the front (&lt;code&gt;setAlwaysOnTop&lt;/code&gt;, &lt;code&gt;focus&lt;/code&gt;) before you measure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't prove
&lt;/h2&gt;

&lt;p&gt;It's one app on one laptop with integrated graphics. On a desktop GPU you'd likely never notice the glow. But if your Electron or web app combines &lt;code&gt;backdrop-filter&lt;/code&gt; with any infinite animation, it's worth five minutes with the profiler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question
&lt;/h2&gt;

&lt;p&gt;Have you ever chased a "leak" that turned out to be something else? What was it?&lt;/p&gt;

&lt;p&gt;A) thermals / throttling&lt;br&gt;&lt;br&gt;
B) a runaway animation or render loop&lt;br&gt;&lt;br&gt;
C) a cache doing its job&lt;br&gt;&lt;br&gt;
D) an actual leak, finally&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The app is &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;VibeDesk&lt;/a&gt;, open source, so all of the CSS above is in the repo.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>electron</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>15 YouTube Shorts, 2,674 views, 1 subscriber: promoting a dev tool without showing my face</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/fxdgif/15-youtube-shorts-2674-views-1-subscriber-promoting-a-dev-tool-without-showing-my-face-2m8j</link>
      <guid>https://dev.to/fxdgif/15-youtube-shorts-2674-views-1-subscriber-promoting-a-dev-tool-without-showing-my-face-2m8j</guid>
      <description>&lt;p&gt;I build a free, open-source desktop app on my own. I don't want to show my face or record my voice, so when it was time to find users, YouTube Shorts looked perfect: a screen recording, some music, one line of text on top. No camera.&lt;/p&gt;

&lt;p&gt;I made 15 of them in two weeks. Here are the real numbers from YouTube Studio and GitHub, because I think a flop with numbers is more useful than another "how I hit 10k" post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers (last 28 days)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2,674&lt;/strong&gt; Shorts views&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;70 %&lt;/strong&gt; swiped away almost immediately&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;32&lt;/strong&gt; likes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;+1&lt;/strong&gt; subscriber from Shorts. One.&lt;/li&gt;
&lt;li&gt;GitHub: &lt;strong&gt;21&lt;/strong&gt; unique visitors in 14 days, &lt;strong&gt;5&lt;/strong&gt; of them from YouTube&lt;/li&gt;
&lt;li&gt;GitHub stars: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly 3,000 views turned into about five people looking at the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fast and flashy.&lt;/strong&gt; Quick cuts, zooms, 15 seconds, glowing UI. Between 19 % and 25 % of viewers stayed. When I watched them back, even I couldn't tell what was happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow and simple.&lt;/strong&gt; One idea per Short, a big crop of the app instead of the whole window, a text line you can read twice. No retention data yet, but the view count landed exactly where the fast ones did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A plain "it just does the thing".&lt;/strong&gt; My best Short was 26 seconds of typing "sort my windows" and watching it happen. 42 % stayed. No effects. Just something you'd want.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The view count barely depends on the video
&lt;/h3&gt;

&lt;p&gt;Every Short landed between 50 and 370 views. YouTube Studio says my Shorts "usually get 140–220". Fast, slow, funny, technical: same band. As far as I can tell the feed shows each Short to a small test group, and if that group swipes, that's it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Shorts feed is the wrong room
&lt;/h3&gt;

&lt;p&gt;81 % of the views came from the Shorts feed: people scrolling for fun, not people with the problem my app solves. And even someone who likes it can't click a link in a Short. A like costs them nothing. Installing a Windows app from a GitHub release page costs them a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A 75-second video beat all 15 Shorts
&lt;/h3&gt;

&lt;p&gt;My one longer video, a 1:15 teaser, got 332 views. People watched &lt;strong&gt;51 %&lt;/strong&gt; of it on average, and &lt;strong&gt;80 %&lt;/strong&gt; of the views came from the YouTube home page, so YouTube showed it to people who watch similar things. It also brought one subscriber, as many as all the Shorts together.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. One honest post here beat everything
&lt;/h3&gt;

&lt;p&gt;Two weeks ago I wrote a short post here: &lt;em&gt;I kept missing Claude Code's permission prompts, so I built a desktop around it.&lt;/em&gt; No polish. It got 15 comments, two feature requests (both shipped in the next release) and real conversations.&lt;/p&gt;

&lt;p&gt;My more "useful" technical posts after that got zero or one comment each.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The real funnel is after the click
&lt;/h3&gt;

&lt;p&gt;Getting someone to the repo is step one. Then there's a release page, an &lt;code&gt;.exe&lt;/code&gt;, and a SmartScreen warning because the installer isn't signed. Every step loses people. I spent hours on video edits and almost no time on that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't prove
&lt;/h2&gt;

&lt;p&gt;It's one small channel, one niche tool, four weeks. Shorts clearly work for some people. Maybe my hooks were just bad. But "make more Shorts" was the advice I kept hearing, and for a dev tool with no face behind it, it was the most expensive way I found to get almost nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm changing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two Shorts a week at most, instead of one a day.&lt;/li&gt;
&lt;li&gt;Longer, calm videos that answer something people actually search for.&lt;/li&gt;
&lt;li&gt;More posts like the first one: a real problem, what I tried, what broke.&lt;/li&gt;
&lt;li&gt;A shorter download path: one page, one button, SmartScreen explained before it shows up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you build something alone: &lt;strong&gt;what brought you your first 10 real users?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A) a post somewhere like this&lt;br&gt;&lt;br&gt;
B) Reddit / Hacker News&lt;br&gt;&lt;br&gt;
C) video&lt;br&gt;&lt;br&gt;
D) something I haven't thought of&lt;/p&gt;

&lt;p&gt;I'd really like to know, especially the things that looked promising and didn't work.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The app is &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;VibeDesk&lt;/a&gt;, a free Windows desktop for running several Claude Code sessions. But this post is about the numbers, not the app.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>showdev</category>
      <category>marketing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>8 rules for Claude Code hooks that never get in Claude's way</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Sun, 01 Nov 2026 20:24:46 +0000</pubDate>
      <link>https://dev.to/fxdgif/8-rules-for-claude-code-hooks-that-never-get-in-claudes-way-5ap1</link>
      <guid>https://dev.to/fxdgif/8-rules-for-claude-code-hooks-that-never-get-in-claudes-way-5ap1</guid>
      <description>&lt;p&gt;My app runs one small Node script on every hook event Claude Code fires. Eleven events, every session, all day. If that script is slow, Claude is slow. If it crashes the wrong way, Claude stops.&lt;/p&gt;

&lt;p&gt;After a few weeks of that, these are the rules the script follows. None of them are clever. Each one is there because I broke it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Exit 0. Always.
&lt;/h2&gt;

&lt;p&gt;Claude Code reads your exit code. Exit 2 blocks the action and hands your stderr to Claude as feedback. Any other non-zero code shows up as a hook error. Neither is what you want when your JSON parse fails or your server is down.&lt;/p&gt;

&lt;p&gt;So the script has exactly one way out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing environment variable: &lt;code&gt;done()&lt;/code&gt;. Broken JSON on stdin: &lt;code&gt;done()&lt;/code&gt;. Connection refused: &lt;code&gt;done()&lt;/code&gt;. The only time a hook should change what Claude does is when you decided it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Bring your own deadline
&lt;/h2&gt;

&lt;p&gt;Claude Code waits for your hook before it moves on, and the default timeout is generous. Don't rely on it. Set a short &lt;code&gt;timeout&lt;/code&gt; in the hook config (I use 5 seconds) and add a hard stop inside the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unref()&lt;/code&gt; matters: the timer never keeps the process alive on its own, it only cuts it off. The HTTP request to my app gets its own 1.2 s timeout on top. If the app is frozen, the session doesn't notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. stdout is not a log
&lt;/h2&gt;

&lt;p&gt;For some events, whatever you print goes straight into Claude's context. On &lt;code&gt;SessionStart&lt;/code&gt;, that's a feature. A stray &lt;code&gt;console.log("got event")&lt;/code&gt; there is a few tokens of noise in every session, forever.&lt;/p&gt;

&lt;p&gt;For others, stdout is read as a decision. So print only when you mean it. My script forwards the event and writes to stdout only if the app answers with a body, which it does in exactly two cases: context at session start and a permission decision. Nothing else ever reaches stdout.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. PermissionRequest is the one hook that should wait
&lt;/h2&gt;

&lt;p&gt;Every rule above says "be quick". &lt;code&gt;PermissionRequest&lt;/code&gt; is the exception, and it's the most useful hook there is.&lt;/p&gt;

&lt;p&gt;It fires the moment Claude shows a permission prompt, and you can answer it from the hook. Keep the hook open until a human decides somewhere else (a card in an app, a phone), then print:&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="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"PermissionRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"behavior"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"allow"&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;A deny can carry a &lt;code&gt;message&lt;/code&gt;, and Claude reads it as the reason. Printing nothing means "no decision": Claude falls back to the normal prompt in the terminal, so the worst case is the behaviour you had without the hook.&lt;/p&gt;

&lt;p&gt;One exception, pointed out in the comments: in sessions that can't show a prompt (&lt;code&gt;claude -p&lt;/code&gt;, background subagents in non-interactive mode), &lt;code&gt;PermissionRequest&lt;/code&gt; hooks still run, and if no hook returns a decision, the tool call is denied. So if you reuse a forwarder like this in a headless run and the app behind it is down, the silent exit 0 from rule 1 turns into a deny.&lt;/p&gt;

&lt;p&gt;For this one event I set the hook timeout to 600 s and the script's own deadline to 595 s, so the script always gives up first and cleanly.&lt;/p&gt;

&lt;p&gt;Side note: the &lt;code&gt;Notification&lt;/code&gt; hook also tells you Claude is waiting, but the permission notification only arrives after a short idle delay. For "Claude needs me right now", use &lt;code&gt;PermissionRequest&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. On Windows, quote the path, not the program
&lt;/h2&gt;

&lt;p&gt;The hook command in my settings looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node "C:\Users\me\AppData\Local\Programs\My App\resources\hook-forwarder.js"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The program is unquoted, the path is quoted. That one line runs the same in cmd, Git Bash and PowerShell. Quote the program too and PowerShell reads the first token as a string instead of a command. Install paths on Windows love spaces, so this is not hypothetical.&lt;/p&gt;

&lt;p&gt;Newer Claude Code versions have a cleaner way: give the hook an &lt;code&gt;args&lt;/code&gt; array and it runs in exec form, with no shell at all:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"args"&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;"C:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Users&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;me&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;AppData&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Local&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;Programs&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;My App&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;resources&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;hook-forwarder.js"&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;Each element is one argument exactly as written. On Windows, &lt;code&gt;command&lt;/code&gt; has to be a real executable like &lt;code&gt;node.exe&lt;/code&gt;; the &lt;code&gt;.cmd&lt;/code&gt; shims in &lt;code&gt;node_modules/.bin&lt;/code&gt; still need shell form. I haven't switched VibeDesk over yet, because it writes settings for whatever Claude Code version you have installed.&lt;/p&gt;

&lt;p&gt;Thanks to &lt;a class="mentioned-user" href="https://dev.to/skillselion"&gt;@skillselion&lt;/a&gt; in the comments for this one and the exception in rule 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. No dependencies, no bundler
&lt;/h2&gt;

&lt;p&gt;The whole forwarder is about 85 lines of plain CommonJS. The only import is &lt;code&gt;http&lt;/code&gt;. It runs with the system &lt;code&gt;node&lt;/code&gt;, straight from the app's resources folder.&lt;/p&gt;

&lt;p&gt;Startup time is paid on every single event. A bundled script that pulls in a framework to send one POST request is a few hundred milliseconds you pay hundreds of times a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Survive your own restarts
&lt;/h2&gt;

&lt;p&gt;Claude sessions outlive the app that started them. When my app updates and restarts, the port and token in each session's environment are stale.&lt;/p&gt;

&lt;p&gt;So when the server answers 403, the script rereads a small endpoint file that the app rewrites on every start, and retries exactly once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;again&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// reread endpoint.json, try once more, else done()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once, not in a loop. Rule 1 still wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Stop hooks: check &lt;code&gt;stop_hook_active&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Stop&lt;/code&gt; hook can answer &lt;code&gt;{"decision":"block","reason":"..."}&lt;/code&gt; and Claude keeps going with your reason as its next instruction. I use that once per turn: if Claude changed files but didn't write down what it learned, it gets a short nudge to do that before it finishes.&lt;/p&gt;

&lt;p&gt;The input then has &lt;code&gt;stop_hook_active: true&lt;/code&gt;. If you see that, never block again. Otherwise you've built a loop where Claude finishes, gets sent back, finishes, gets sent back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;exit 0, every path&lt;/li&gt;
&lt;li&gt;a hard deadline inside the script&lt;/li&gt;
&lt;li&gt;stdout only when you mean it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PermissionRequest&lt;/code&gt; waits, everything else hurries&lt;/li&gt;
&lt;li&gt;quote the path, not the program (or skip the shell with &lt;code&gt;args&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;no dependencies&lt;/li&gt;
&lt;li&gt;reread your endpoint once after a restart&lt;/li&gt;
&lt;li&gt;respect &lt;code&gt;stop_hook_active&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this comes from &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;VibeDesk&lt;/a&gt;, the free, open-source Windows desktop I build for running several Claude Code sessions side by side. The forwarder is a single file if you want to copy it: &lt;a href="https://github.com/fxd-gif/vibedesk/blob/main/resources/hook-forwarder.js" rel="noopener noreferrer"&gt;hook-forwarder.js&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the worst hook bug you've run into? I'm collecting them.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>node</category>
      <category>productivity</category>
    </item>
    <item>
      <title>VibeDesk 2.0 is out – every feature request shipped, and why 1.9 burned 131 % of a CPU core</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Sun, 01 Nov 2026 17:05:45 +0000</pubDate>
      <link>https://dev.to/fxdgif/vibedesk-20-is-out-every-feature-request-shipped-and-why-19-burned-131-of-a-cpu-core-317d</link>
      <guid>https://dev.to/fxdgif/vibedesk-20-is-out-every-feature-request-shipped-and-why-19-burned-131-of-a-cpu-core-317d</guid>
      <description>&lt;p&gt;VibeDesk is a free, open-source Windows desktop for Claude Code: several sessions side by side, a coloured dot that tells you which one is waiting for you, permission prompts as one-click cards (on your PC or your phone) and a project "brain" Claude reads and writes over MCP. It runs your own Claude Code CLI – nothing is proxied.&lt;/p&gt;

&lt;p&gt;Today 2.0 is out. Two things I want to share: what's in it, and a performance bug that is worth knowing about if you build anything with Electron, &lt;code&gt;backdrop-filter&lt;/code&gt; or a WebGL background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every open wish is in
&lt;/h2&gt;

&lt;p&gt;Since 1.9 I've only collected feature requests as GitHub issues and shipped them in one bigger release instead of a stream of small ones. All six open requests made it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session wrap-up&lt;/strong&gt; – when Claude changed files in a turn, you get Claude's summary, every changed file with line counts and a suggested commit message. One click commits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull requests from the Git window&lt;/strong&gt; – Claude drafts title and description from your commits; VibeDesk creates the branch, pushes and opens the PR through the GitHub CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tray status&lt;/strong&gt; – a dot in the notification area shows the most urgent session, and approvals can be answered from the tray menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget per project&lt;/strong&gt; – cost or tokens per day or week, with a quiet heads-up at 75 % and 100 %. Claude is never stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"waiting · 3 min"&lt;/strong&gt; – how long a session has been waiting, on the session, in the tray and on your phone. (This one came from a comment on my last post here – thank you.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-off between sessions&lt;/strong&gt; – when one session sends another a task, a short summary and the changed files come along. (Also from the comments here.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus: living scenes (Claude writes an interactive wallpaper for you), Gemini CLI and Codex as a beta, your own music from a folder, project templates and a second-screen beta.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: 131 % of a core for a terminal spinner
&lt;/h2&gt;

&lt;p&gt;A user (me, honestly) said: &lt;em&gt;"After running VibeDesk for a while it gets really slow."&lt;/em&gt; My first guess was a memory leak. I was wrong.&lt;/p&gt;

&lt;p&gt;I measured the installed 1.9 with one Claude Code session working in the terminal. Windows said the &lt;strong&gt;GPU process used 131 % of a CPU core and the renderer 30 %&lt;/strong&gt;. Nothing was leaking: main process ~110 MB, renderer heap ~20 MB, flat. The laptop simply got hot and throttled after a while – that was the "slow after a while".&lt;/p&gt;

&lt;p&gt;To find the cause I started a second, isolated instance with Playwright's &lt;code&gt;_electron&lt;/code&gt;, a copy of the real settings and its own user-data folder. Then I started a real Claude Code session in it and measured CPU per process with the Windows counters while Claude streamed a long answer. Three things added up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A smooth infinite CSS animation forces 60 fps compositing.&lt;/strong&gt; The "Claude is working" glow on the terminal was &lt;code&gt;animation: claude-glow 2.8s ease-in-out infinite&lt;/code&gt;. It doesn't matter how small or cheap the animated property looks – every frame is a new compositor frame, and every frame re-ran the large &lt;code&gt;backdrop-filter: blur()&lt;/code&gt; of the glass windows. With &lt;code&gt;steps(5)&lt;/code&gt; the glow changes about twice a second, and the compositor rests in between. (Already fixed in a 2.0 build; it was most of the 131 %.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The WebGL rain kept rendering at 0 % rain.&lt;/strong&gt; The background shader was throttled to 15–30 fps, but nobody told it that at intensity 0 the image doesn't depend on time. Now &lt;code&gt;rain === 0&lt;/code&gt; means "draw one still frame and stop the loop".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;backdrop-filter&lt;/code&gt; is re-computed whenever anything inside the glass element changes.&lt;/strong&gt; A terminal that receives ~10 writes per second means ~10 re-blurs per second of a window-sized area at device pixel ratio 2. In energy-saver mode the windows now use a nearly opaque background instead of live blur.&lt;/p&gt;

&lt;p&gt;Measured the same way, with a real Claude session working:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;GPU process&lt;/th&gt;
&lt;th&gt;Renderer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;td&gt;131 %&lt;/td&gt;
&lt;td&gt;30 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0 (glow fix)&lt;/td&gt;
&lt;td&gt;~26 %&lt;/td&gt;
&lt;td&gt;~14 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ still frame at 0 % rain&lt;/td&gt;
&lt;td&gt;~20 %&lt;/td&gt;
&lt;td&gt;~12 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ no live blur in energy saver&lt;/td&gt;
&lt;td&gt;~11 %&lt;/td&gt;
&lt;td&gt;~11 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two measuring traps that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app.getAppMetrics()[i].cpu.percentCPUUsage&lt;/code&gt; in Electron is divided by the number of logical cores. On my 8-core laptop "16 %" meant 128 % of one core. Diffing &lt;code&gt;Get-Process&lt;/code&gt; CPU seconds was the honest number.&lt;/li&gt;
&lt;li&gt;Chromium stops &lt;code&gt;requestAnimationFrame&lt;/code&gt; for occluded windows. If your test window is behind another one, everything looks free. I had to call &lt;code&gt;setAlwaysOnTop(true)&lt;/code&gt;, &lt;code&gt;maximize()&lt;/code&gt; and &lt;code&gt;focus()&lt;/code&gt; on the test window.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Windows 10/11, your own Claude Code plan or API key. The installer isn't signed yet, so SmartScreen will ask – "More info → Run anyway".&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Download and a page per feature: &lt;a href="https://fxd-gif.github.io/vibedesk/" rel="noopener noreferrer"&gt;https://fxd-gif.github.io/vibedesk/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;https://github.com/fxd-gif/vibedesk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Wishes for the next release: open an issue or 👍 an existing one – the most-wanted ones go into the next release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not affiliated with Anthropic.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>showdev</category>
      <category>electron</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Approve Claude Code permission prompts from anywhere with the PermissionRequest hook</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Sun, 25 Oct 2026 14:54:24 +0000</pubDate>
      <link>https://dev.to/fxdgif/approve-claude-code-permission-prompts-from-anywhere-with-the-permissionrequest-hook-45d9</link>
      <guid>https://dev.to/fxdgif/approve-claude-code-permission-prompts-from-anywhere-with-the-permissionrequest-hook-45d9</guid>
      <description>&lt;p&gt;A few days ago I wrote about &lt;a href="https://dev.to/fxdgif/i-kept-missing-claude-codes-permission-prompts-so-i-built-a-desktop-around-it-8fc"&gt;the desktop I built around Claude Code&lt;/a&gt; because I kept missing its permission prompts. In the comments someone asked the question I like most: &lt;em&gt;do you detect "waiting" by reading the terminal, or from hooks?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hooks. And one hook in particular, &lt;code&gt;PermissionRequest&lt;/code&gt;, turned out to be much more useful than I expected. You don't need my app for any of this, so here's how it works and what bit me on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;

&lt;p&gt;Claude Code runs your command whenever it is about to show a permission prompt. Your command gets JSON on stdin, roughly:&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;"hook_event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PermissionRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool_input"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm test"&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;"permission_suggestions"&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="err"&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="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;and it can answer on stdout:&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;"hookSpecificOutput"&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;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PermissionRequest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"decision"&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;"behavior"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allow"&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;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;&lt;code&gt;behavior&lt;/code&gt; is &lt;code&gt;allow&lt;/code&gt; or &lt;code&gt;deny&lt;/code&gt; (a deny can carry a &lt;code&gt;message&lt;/code&gt;, which Claude reads and reacts to). If you print &lt;strong&gt;nothing&lt;/strong&gt;, Claude Code just shows its normal prompt. That last part is what makes the hook safe to experiment with: you only decide the cases you're sure about.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny example you can use today
&lt;/h2&gt;

&lt;p&gt;Allow a few harmless commands, block one obviously bad one, stay out of everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// approve.js – PermissionRequest hook&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tool_input&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hookSpecificOutput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hookEventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PermissionRequest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;git &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;status|diff|log&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;|ls|pwd|npm test&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;;&amp;amp;|&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;rm&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+-rf&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;[\/&lt;/span&gt;&lt;span class="sr"&gt;~&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not deleting from / or ~. Ask me first.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// anything else: print nothing -&amp;gt; normal prompt&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in &lt;code&gt;.claude/settings.json&lt;/code&gt;:&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;"hooks"&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;"PermissionRequest"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node /path/to/approve.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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;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="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;(Yes, &lt;code&gt;allow&lt;/code&gt;/&lt;code&gt;deny&lt;/code&gt; rules in settings cover a lot of this too. The hook gets interesting when the decision needs logic, or a human somewhere else.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Answering from another window, or your phone
&lt;/h2&gt;

&lt;p&gt;This is the part I actually built it for. The hook doesn't have to answer right away. It can just &lt;strong&gt;wait&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;VibeDesk&lt;/a&gt; every session gets a settings file that points all hook events to a small forwarder script. For &lt;code&gt;PermissionRequest&lt;/code&gt; the forwarder POSTs the JSON to the app on &lt;code&gt;127.0.0.1&lt;/code&gt; and keeps the request open. The app shows a card with the command and &lt;em&gt;Allow / Always / Deny / In terminal&lt;/em&gt;, on the desktop and on a paired phone. Whatever you tap becomes the HTTP response, the forwarder prints it, Claude Code continues. Nothing has to type &lt;code&gt;y&lt;/code&gt; into a PTY. (Claude Code still shows its normal prompt in the terminal while the hook waits, so you can answer in either place.)&lt;/p&gt;

&lt;p&gt;"Always" sends back the &lt;code&gt;permission_suggestions&lt;/code&gt; Claude Code offered as &lt;code&gt;updatedPermissions&lt;/code&gt;, so it's the same as picking "don't ask again" in the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The hook must never break Claude.&lt;/strong&gt; It runs on every single prompt. If it crashes, hangs or exits with an error, you've made Claude Code worse. My forwarder always exits with code 0, has a hard timer, and if the app isn't running it just exits silently, so Claude falls back to the normal prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Timeouts.&lt;/strong&gt; Claude Code gives a command hook 600 seconds by default, which is plenty for a human. The catch is the other direction: my forwarder runs on &lt;em&gt;every&lt;/em&gt; event, so I set all other events to 5 s (a stuck app must never slow Claude down) and only &lt;code&gt;PermissionRequest&lt;/code&gt; keeps 600. The script gives up by itself at 595 s, so it always returns before Claude Code kills it. Otherwise you get a confusing state instead of a clean fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. People answer in the terminal anyway.&lt;/strong&gt; Sometimes you're already looking at that window and just press enter. Then the card in the app is stale. The fix: when &lt;code&gt;PostToolUse&lt;/code&gt; arrives for the same tool and the same input, close the open card. Same for &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt; and &lt;code&gt;SessionEnd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Restarts.&lt;/strong&gt; After an app update the port and token change, but sessions are still running with the old ones in their environment. The forwarder now re-reads a small endpoint file when it gets a 403 and retries once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Don't parse the terminal.&lt;/strong&gt; My first version looked for the prompt text in the output. It broke on every small UI change. Hooks are the stable interface, use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting "waiting" in general
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PermissionRequest&lt;/code&gt; tells you a session is blocked on you, and it fires right away. &lt;code&gt;Notification&lt;/code&gt; also fires for permission waits, but only after a few seconds (and not always), so don't rely on it for that. &lt;code&gt;Stop&lt;/code&gt; tells you a turn is done. With just those three you can drive a status light, a Stream Deck key, a tray icon or a phone notification. That's all the yellow dot in my app really is.&lt;/p&gt;




&lt;p&gt;VibeDesk is free and MIT licensed (Windows): &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;https://github.com/fxd-gif/vibedesk&lt;/a&gt;. Version 2.0 comes out on &lt;strong&gt;Sunday, Nov 1&lt;/strong&gt;, with session wrap-ups, tray status and sessions that can hand tasks to each other. If you build something with this hook, I'd love to see it in the comments.&lt;/p&gt;

&lt;p&gt;And here's the short teaser for 2.0:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/a0L7gfn9wa0" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I kept missing Claude Code's permission prompts, so I built a desktop around it</title>
      <dc:creator>Fred 🍓</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:40:29 +0000</pubDate>
      <link>https://dev.to/fxdgif/i-kept-missing-claude-codes-permission-prompts-so-i-built-a-desktop-around-it-8fc</link>
      <guid>https://dev.to/fxdgif/i-kept-missing-claude-codes-permission-prompts-so-i-built-a-desktop-around-it-8fc</guid>
      <description>&lt;p&gt;I usually have two or three Claude Code sessions open: a frontend, an API, sometimes a side project. And the most annoying thing wasn't the model or the cost. It was this:&lt;/p&gt;

&lt;p&gt;One session finishes. Another one is working. And the third one has been sitting there for fifteen minutes, asking "Do you want to run &lt;code&gt;npm test&lt;/code&gt;?" in a window I wasn't looking at.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;VibeDesk&lt;/strong&gt;, a full-screen Windows desktop around the real Claude Code CLI.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/SLLYiK34I88" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: one dot per session
&lt;/h2&gt;

&lt;p&gt;Every session gets a short name in a dock and a colored dot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;green: Claude is working&lt;/li&gt;
&lt;li&gt;yellow (pulsing): Claude is waiting for you&lt;/li&gt;
&lt;li&gt;blue: done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That alone fixed most of my problem. I glance down, see a yellow dot, click it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permission prompts as cards
&lt;/h2&gt;

&lt;p&gt;When Claude wants to run a command, VibeDesk shows a card with the command and four buttons: In terminal, Deny, Always, Allow. One click, and the session keeps going. No hunting for the right terminal.&lt;/p&gt;

&lt;p&gt;The same card shows up on your phone if you pair it (6-digit code, local network by default). Your phone buzzes, you tap Allow from the kitchen.&lt;/p&gt;

&lt;h2&gt;
  
  
  A memory for each project
&lt;/h2&gt;

&lt;p&gt;Claude Code forgets your project between sessions. VibeDesk gives every project a small "brain": Markdown notes with links between them (Obsidian-compatible) that Claude reads and writes through MCP. Decisions, pitfalls, conventions. New sessions start from there instead of exploring the whole repo again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude can drive the desktop
&lt;/h2&gt;

&lt;p&gt;VibeDesk also ships an MCP server for the desktop itself. In any session you can say "sort my windows", "zen mode on" or "tokyo scene with more rain", and Claude does it. It can even open your local dev server in the preview window after starting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Electron, React, xterm.js with WebGL, node-pty. It doesn't replace or proxy anything: each terminal runs the normal &lt;code&gt;claude&lt;/code&gt; command with your own Pro/Max plan or API key. MIT licensed, Windows 10/11, no telemetry.&lt;/p&gt;

&lt;p&gt;And yes, most of the code was written by Claude Code itself, with me steering, testing and saying no a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Download: &lt;a href="https://github.com/fxd-gif/vibedesk/releases/latest" rel="noopener noreferrer"&gt;https://github.com/fxd-gif/vibedesk/releases/latest&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Website (one page per feature): &lt;a href="https://fxd-gif.github.io/vibedesk/" rel="noopener noreferrer"&gt;https://fxd-gif.github.io/vibedesk/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/fxd-gif/vibedesk" rel="noopener noreferrer"&gt;https://github.com/fxd-gif/vibedesk&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Windows SmartScreen will warn because the installer isn't signed yet ("More info" → "Run anyway").&lt;/p&gt;

&lt;p&gt;VibeDesk 2.0 ships on November 1, and the most-upvoted wish on GitHub goes in. I'd love feedback from people who run several sessions in parallel: what would you want to see at a glance?&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
