<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://ankitg12.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ankitg12.github.io/" rel="alternate" type="text/html" /><updated>2026-07-22T21:41:16+05:30</updated><id>https://ankitg12.github.io/feed.xml</id><title type="html">Ankit Gaur</title><subtitle>Notes on agentic AI, infrastructure, and engineering.</subtitle><entry><title type="html">When your agent’s auto-learned skills start competing with each other</title><link href="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/22/auto-learned-agent-skills-shadowing.html" rel="alternate" type="text/html" title="When your agent’s auto-learned skills start competing with each other" /><published>2026-07-22T00:00:00+05:30</published><updated>2026-07-22T00:00:00+05:30</updated><id>https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/22/auto-learned-agent-skills-shadowing</id><content type="html" xml:base="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/22/auto-learned-agent-skills-shadowing.html"><![CDATA[<p>I asked my coding agent to audit its own configuration, and the single worst finding was self-inflicted: <strong>108 auto-generated skills, only 2 of which I wrote by hand.</strong> The rest had been minted silently by the agent’s own “learn as you go” feature — and a good fraction of them were near-duplicates competing to answer the same question.</p>

<p>This is a failure mode specific to agents that can write their own skills. If yours does (OMP’s <code class="language-plaintext highlighter-rouge">autolearn</code>, or any equivalent that persists <code class="language-plaintext highlighter-rouge">SKILL.md</code> files), you are probably accumulating the same debt without seeing it.</p>

<hr />

<h2 id="what-a-skill-is-and-why-duplication-hurts">What a skill is, and why duplication hurts</h2>

<p>A “skill” here is a small markdown file with frontmatter — a name, a one-line <code class="language-plaintext highlighter-rouge">description:</code> that says <em>when to use it</em>, and a body with the actual procedure. The agent loads every skill’s name + description into context on every turn, then picks the relevant one when a task matches.</p>

<p>That description line is the whole game. It’s how the agent decides which skill fires. And it’s always in context, whether or not you use the skill that session.</p>

<p>So duplication costs you twice:</p>

<ol>
  <li><strong>Context tax</strong> — every description is always-loaded. My 108 skills were ~5.6k tokens of descriptions alone, paid on every single turn.</li>
  <li><strong>Selection tax</strong> — this is the subtle one. When five skills all say “use me for parsing that report,” the agent has to disambiguate between near-identical options. More overlapping choices means worse picks. This is the same “too many tools degrades tool-use accuracy” effect documented for MCP tool sprawl — skills are just tools by another name.</li>
</ol>

<p>The second tax is why this matters more than “some wasted tokens.” The feature designed to make the agent <em>smarter over time</em> was quietly making it <em>worse at choosing</em>.</p>

<hr />

<h2 id="how-it-happens">How it happens</h2>

<p>Auto-learn is greedy and has no memory of what it already knows. Each time the agent solves a fiddly problem, it may decide “this is worth codifying” and write a new skill. It does <strong>not</strong> check whether a skill covering that ground already exists. So over weeks you get clusters like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>report-parse
report-parse-fix
report-parsing
report-truncation-fix
report-triage
</code></pre></div></div>

<p>Five files. One actual topic. ~90% identical bodies, each written on a different day when the agent forgot it had already learned this.</p>

<p>The tell is the naming. <strong>Cluster your skills by name prefix — any prefix with two or more entries is a duplication suspect.</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">glob</span><span class="p">,</span> <span class="n">os</span>
<span class="kn">from</span> <span class="nn">collections</span> <span class="kn">import</span> <span class="n">defaultdict</span>

<span class="n">skills</span> <span class="o">=</span> <span class="p">[</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">basename</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">dirname</span><span class="p">(</span><span class="n">f</span><span class="p">))</span>
          <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">glob</span><span class="p">.</span><span class="n">glob</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">expanduser</span><span class="p">(</span><span class="s">"~/.omp/agent/managed-skills/*/SKILL.md"</span><span class="p">))]</span>

<span class="n">clusters</span> <span class="o">=</span> <span class="n">defaultdict</span><span class="p">(</span><span class="nb">list</span><span class="p">)</span>
<span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">skills</span><span class="p">:</span>
    <span class="n">clusters</span><span class="p">[</span><span class="s">"-"</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">name</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">"-"</span><span class="p">)[:</span><span class="mi">2</span><span class="p">])].</span><span class="n">append</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>

<span class="k">for</span> <span class="n">prefix</span><span class="p">,</span> <span class="n">members</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">clusters</span><span class="p">.</span><span class="n">items</span><span class="p">(),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">x</span><span class="p">:</span> <span class="o">-</span><span class="nb">len</span><span class="p">(</span><span class="n">x</span><span class="p">[</span><span class="mi">1</span><span class="p">])):</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">members</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">2</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"[</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">members</span><span class="p">)</span><span class="si">}</span><span class="s">] </span><span class="si">{</span><span class="n">prefix</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">members</span><span class="p">):</span>
            <span class="k">print</span><span class="p">(</span><span class="s">"    "</span><span class="p">,</span> <span class="n">m</span><span class="p">)</span>
</code></pre></div></div>

<p>Prefix collision is a heuristic, not proof — but it turns “I have 100+ skills and no idea which overlap” into a ranked worklist in one pass.</p>

<hr />

<h2 id="the-consolidation-protocol">The consolidation protocol</h2>

<p>Not every same-prefix cluster is a duplicate, and this is where a careless cleanup destroys knowledge. The rule that kept it lossless:</p>

<p><strong>A cluster is one of two things.</strong></p>

<p><strong>Near-duplicates</strong> (≈80%+ shared body, same trigger). Collapse to one:</p>

<ol>
  <li>Pick the <strong>superset</strong> skill as the keeper — the one that already covers the most ground.</li>
  <li>Read the others and extract any <em>unique</em> one-liner — a caveat, a gotcha, a distinct step — that the keeper is missing.</li>
  <li>Fold those gems into the keeper.</li>
  <li><strong>Only then</strong> delete the redundant skills.</li>
</ol>

<p>The ordering is the whole discipline: never delete until the unique content is preserved somewhere. Merging is lossy by default; you make it lossless by hand.</p>

<p><strong>Genuinely distinct siblings</strong> that happen to share a prefix. Keep them all — but fix the descriptions:</p>

<table>
  <thead>
    <tr>
      <th>Skill</th>
      <th>Bad description (shadows)</th>
      <th>Fixed description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">x-breakout-triage</code></td>
      <td>“diagnose x port issues”</td>
      <td>“capability triage: can the port reach target speed”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">x-readiness-gate</code></td>
      <td>“diagnose x port issues”</td>
      <td>“config-to-runtime gate that drops the change”</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">x-config-fix</code></td>
      <td>“diagnose x port issues”</td>
      <td>“durable fix: edit config, regenerate, reboot”</td>
    </tr>
  </tbody>
</table>

<p>Same-prefix ≠ duplicate. Four skills about four <em>different</em> facets of one subsystem should stay four skills — but each description needs a unique trigger and, ideally, an explicit “NOT for X — see sibling” pointer so they stop competing.</p>

<hr />

<h2 id="parallelize-the-judgement">Parallelize the judgement</h2>

<p>The assessment (“is this cluster a merge or a keep-distinct?”) is real work, and it’s embarrassingly parallel: each cluster is independent, and if you shard by name prefix, no two workers touch the same files.</p>

<p>I handed each prefix cluster to a separate subagent with one shared instruction set (the protocol above) and let them run concurrently. One pass took the library from 110 skills to 95 with zero content loss — and the subagents caught two bugs <em>inside the surviving skills</em> along the way (a reference to a script that no longer existed, and a stale command superseded months ago). Fresh eyes on skills you stopped reading are a bonus dividend.</p>

<p>A caution worth stating: run the <em>judgement</em> in parallel, but keep the <em>decomposition</em> — which clusters exist, how to shard them — as your own job. Sharding is the prerequisite every worker depends on; it can’t itself be parallelized away.</p>

<hr />

<h2 id="the-part-i-cant-fix-yet">The part I can’t fix yet</h2>

<p>Nothing stops recurrence. Auto-learn keeps minting, so this is a periodic pass, not a one-time cleanup — the same way a memory store needs occasional compaction, not a single dedup.</p>

<p>The industry direction that would actually fix it is <strong>retrieval-based loading</strong>: instead of putting all N skill descriptions in context every turn, embed them and retrieve only the top-k relevant to the current task. That caps both the context tax and the selection tax regardless of library size. The <a href="https://arxiv.org/abs/2410.14594">Toolshed paper</a> works through exactly this for tool-equipped agents. Until agent frameworks ship dedup-on-write or retrieval-on-demand, a scheduled consolidation pass is the pragmatic stopgap.</p>

<p>The broader lesson is one worth internalizing about any self-improving system: <strong>a feature that accumulates without ever consolidating will eventually degrade the thing it was meant to improve.</strong> Measure it, or it compounds against you.</p>

<hr />

<h2 id="source">Source</h2>

<ul>
  <li><a href="https://arxiv.org/abs/2410.14594">Toolshed: Scale Tool-Equipped Agents with Advanced RAG-Tool Fusion</a> — retrieval-based tool/skill loading</li>
  <li><a href="https://www.anthropic.com/engineering/writing-tools-for-agents">Anthropic — Writing effective tools for agents</a> — why tool/skill descriptions are the selection surface</li>
  <li><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents">Anthropic — Effective context engineering for AI agents</a> — the always-loaded-context cost model</li>
</ul>]]></content><author><name></name></author><category term="ai" /><category term="agents" /><category term="productivity" /><category term="tools" /><summary type="html"><![CDATA[I asked my coding agent to audit its own configuration, and the single worst finding was self-inflicted: 108 auto-generated skills, only 2 of which I wrote by hand. The rest had been minted silently by the agent’s own “learn as you go” feature — and a good fraction of them were near-duplicates competing to answer the same question.]]></summary></entry><entry><title type="html">Clickable file paths in your terminal: why they work in your AI agent but not your shell</title><link href="https://ankitg12.github.io/wezterm/terminal/tools/productivity/2026/07/22/clickable-file-paths-wezterm-osc8-vs-hyperlink-rules.html" rel="alternate" type="text/html" title="Clickable file paths in your terminal: why they work in your AI agent but not your shell" /><published>2026-07-22T00:00:00+05:30</published><updated>2026-07-22T00:00:00+05:30</updated><id>https://ankitg12.github.io/wezterm/terminal/tools/productivity/2026/07/22/clickable-file-paths-wezterm-osc8-vs-hyperlink-rules</id><content type="html" xml:base="https://ankitg12.github.io/wezterm/terminal/tools/productivity/2026/07/22/clickable-file-paths-wezterm-osc8-vs-hyperlink-rules.html"><![CDATA[<p>My AI coding agent prints a file path and I click it — VS Code opens the file. I run <code class="language-plaintext highlighter-rouge">ls</code> in the same terminal, it prints the same path, and clicking does nothing. Same terminal, same path, different outcome. That gap turns out to be the entire story of how terminals make text clickable, and closing it takes two mechanisms that are easy to confuse.</p>

<h2 id="two-mechanisms-one-visual-result">Two mechanisms, one visual result</h2>

<p>There are exactly two ways a path becomes clickable in a terminal, and they are not the same thing:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>OSC 8</th>
      <th><code class="language-plaintext highlighter-rouge">hyperlink_rules</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Who acts</td>
      <td>the <strong>program</strong> writes escape bytes into its output</td>
      <td>the <strong>terminal</strong>, at render time</td>
    </tr>
    <tr>
      <td>Where it lives</td>
      <td>in the byte stream — survives pipes, <code class="language-plaintext highlighter-rouge">tee</code>, logs, <code class="language-plaintext highlighter-rouge">ssh</code></td>
      <td>only in the terminal’s screen model</td>
    </tr>
    <tr>
      <td>Form</td>
      <td><code class="language-plaintext highlighter-rouge">ESC ]8;;file://… ESC \ text ESC ]8;; ESC \</code></td>
      <td>an internal regex overlay on screen cells</td>
    </tr>
    <tr>
      <td>Scope</td>
      <td>only output from programs that choose to emit it</td>
      <td><strong>any</strong> text on screen, from any command</td>
    </tr>
    <tr>
      <td>Ambiguity</td>
      <td>none — the producer <strong>declares</strong> the exact target</td>
      <td>the terminal must <strong>guess</strong> where the path ends</td>
    </tr>
  </tbody>
</table>

<p>That last row is the whole game. <strong>OSC 8 declares. Regex guesses.</strong></p>

<p>My coding agent (OMP) uses OSC 8: it knows the exact path it’s printing, so it wraps it in an <a href="https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda">OSC 8 hyperlink</a> escape sequence before the bytes ever hit the terminal. Node’s <code class="language-plaintext highlighter-rouge">url.pathToFileURL(p).href</code> does the encoding. Raw shell output (<code class="language-plaintext highlighter-rouge">ls</code>, <code class="language-plaintext highlighter-rouge">git</code>, <code class="language-plaintext highlighter-rouge">find</code>, build logs) emits no such thing — a path there is just plain text bytes. For those, the terminal’s only route to clickability is <code class="language-plaintext highlighter-rouge">hyperlink_rules</code>: regexes it runs over the screen to auto-detect links.</p>

<p>WezTerm’s default <code class="language-plaintext highlighter-rouge">hyperlink_rules</code> match URLs (<code class="language-plaintext highlighter-rouge">http</code>, <code class="language-plaintext highlighter-rouge">mailto</code>, …) but <strong>never bare file paths</strong>. That’s the gap.</p>

<h2 id="fix-part-1-hyperlink_rules-for-any-command">Fix part 1: hyperlink_rules for any command</h2>

<p><code class="language-plaintext highlighter-rouge">hyperlink_rules</code> is the universal lever precisely <em>because</em> it doesn’t need the program to cooperate — it works on whatever is already on screen. Start from the defaults (so URLs keep working) and add file-path rules:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span><span class="p">.</span><span class="n">hyperlink_rules</span> <span class="o">=</span> <span class="n">wezterm</span><span class="p">.</span><span class="n">default_hyperlink_rules</span><span class="p">()</span>
<span class="nb">table.insert</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="n">hyperlink_rules</span><span class="p">,</span> <span class="p">{</span>
  <span class="n">regex</span>  <span class="o">=</span> <span class="s">[[[A-Za-z]:[\\/][^\s"'&lt;&gt;|]*]]</span><span class="p">,</span>   <span class="c1">-- C:\Users\... or C:/Users/...</span>
  <span class="n">format</span> <span class="o">=</span> <span class="s1">'file://$0'</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>This works — until a path has a space. The <code class="language-plaintext highlighter-rouge">[^\s…]</code> stops at the first whitespace, so <code class="language-plaintext highlighter-rouge">C:\OneDrive - AMD Inc\file.md</code> links only as far as <code class="language-plaintext highlighter-rouge">C:\OneDrive</code>.</p>

<h2 id="test-the-regex-against-the-same-engine-the-terminal-uses">Test the regex against the <em>same engine</em> the terminal uses</h2>

<p>Before editing the live config, you can test WezTerm regexes for free: WezTerm uses the <strong>Rust <code class="language-plaintext highlighter-rouge">regex</code> crate</strong>, and so does <code class="language-plaintext highlighter-rouge">ripgrep</code>. So <code class="language-plaintext highlighter-rouge">rg -o</code> shows you the exact span WezTerm will linkify:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rg <span class="nt">-o</span> <span class="nt">-N</span> <span class="nt">-e</span> <span class="s1">'[A-Za-z]:[\\/][^\s"'</span><span class="s2">"'"</span><span class="s1">'&lt;&gt;|]*'</span> paths.txt
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-o</code> prints only the matched span — which is exactly the clickable region. This turns “edit config, reload, squint at the screen” into a tight unit-test loop. One caveat it also surfaces: the Rust crate has <strong>no lookahead</strong>, so any pattern you design has to live within that.</p>

<h2 id="handling-spaces-anchor-on-the-extension-not-on-whitespace">Handling spaces: anchor on the extension, not on whitespace</h2>

<p>The insight that beats the space problem: a file path runs <em>until its extension</em>, not until the next space. Anchor the regex on <code class="language-plaintext highlighter-rouge">.ext</code> at a word boundary and interior spaces become legal:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">config</span><span class="p">.</span><span class="n">hyperlink_rules</span> <span class="o">=</span> <span class="n">wezterm</span><span class="p">.</span><span class="n">default_hyperlink_rules</span><span class="p">()</span>
<span class="c1">-- Rule 1: extension-anchored — interior spaces allowed.</span>
<span class="nb">table.insert</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="n">hyperlink_rules</span><span class="p">,</span> <span class="p">{</span>
  <span class="n">regex</span>  <span class="o">=</span> <span class="s">[[(?:[A-Za-z]:|~)[\\/][^\r\n"'&lt;&gt;|]*\.[A-Za-z0-9]{1,8}\b]]</span><span class="p">,</span>
  <span class="n">format</span> <span class="o">=</span> <span class="s1">'file://$0'</span><span class="p">,</span>
<span class="p">})</span>
<span class="c1">-- Rule 2: whitespace-stop fallback — extensionless dirs.</span>
<span class="nb">table.insert</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="n">hyperlink_rules</span><span class="p">,</span> <span class="p">{</span>
  <span class="n">regex</span>  <span class="o">=</span> <span class="s">[[(?:[A-Za-z]:|~)[\\/][^\s"'&lt;&gt;|]*]]</span><span class="p">,</span>
  <span class="n">format</span> <span class="o">=</span> <span class="s1">'file://$0'</span><span class="p">,</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Verified with <code class="language-plaintext highlighter-rouge">rg -o</code> against a corpus:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C:\OneDrive - AMD Inc\file.md</code></td>
      <td>full path ✓ (spaces handled)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C:\Program Files\Some App\config.json</code></td>
      <td>full path ✓</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C:\Users\me\.config\nvim\init.lua</code></td>
      <td>full ✓ (doesn’t stop at <code class="language-plaintext highlighter-rouge">.config</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C:\Users\me\projects</code> (extensionless dir)</td>
      <td>✓ via rule 2</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C:\Program Files</code> (spaced <strong>dir</strong>)</td>
      <td>⚠ partial — <code class="language-plaintext highlighter-rouge">C:\Program</code> only</td>
    </tr>
  </tbody>
</table>

<p>Rule 1 must come first: on an overlap, WezTerm takes the earlier rule, so spaced <em>files</em> win over the whitespace-stop fallback.</p>

<p>The click needs somewhere to go. A single <code class="language-plaintext highlighter-rouge">open-uri</code> handler routes any <code class="language-plaintext highlighter-rouge">file://</code> link to the editor:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">wezterm</span><span class="p">.</span><span class="n">on</span><span class="p">(</span><span class="s1">'open-uri'</span><span class="p">,</span> <span class="k">function</span><span class="p">(</span><span class="n">_w</span><span class="p">,</span> <span class="n">_p</span><span class="p">,</span> <span class="n">uri</span><span class="p">)</span>
  <span class="kd">local</span> <span class="n">path</span> <span class="o">=</span> <span class="n">uri</span><span class="p">:</span><span class="n">match</span><span class="p">(</span><span class="s1">'^file:///?(.+)$'</span><span class="p">)</span>
  <span class="k">if</span> <span class="ow">not</span> <span class="n">path</span> <span class="k">then</span> <span class="k">return</span> <span class="k">end</span>                    <span class="c1">-- not a file link → default handler</span>
  <span class="n">path</span> <span class="o">=</span> <span class="n">path</span><span class="p">:</span><span class="nb">gsub</span><span class="p">(</span><span class="err">'%%</span><span class="p">(</span><span class="err">%x%x</span><span class="p">)</span><span class="s1">', function(h) return string.char(tonumber(h,16)) end)
  if path:match('</span><span class="o">^</span><span class="err">~</span><span class="s1">') then path = home .. path:sub(2) end
  path = path:gsub('</span><span class="o">/</span><span class="s1">', '</span><span class="err">\\</span><span class="s1">')
  wezterm.background_child_process({ '</span><span class="n">cmd</span><span class="p">.</span><span class="n">exe</span><span class="s1">', '</span><span class="o">/</span><span class="n">c</span><span class="s1">', '</span><span class="n">code</span><span class="s1">', path })
  return true
end)
</span></code></pre></div></div>

<h2 id="the-residual-and-why-regex-cant-fix-it">The residual, and why regex can’t fix it</h2>

<p>One case stays broken: a <strong>spaced, extensionless directory</strong> — the exact thing your prompt shows, <code class="language-plaintext highlighter-rouge">PS C:\Users\me\Process Dashboard&gt;</code>. Rule 1 needs an extension; rule 2 stops at the space. And no regex can fix this, because in raw text <code class="language-plaintext highlighter-rouge">C:\Users\me\Process Dashboard and then...</code> is <em>genuinely</em> ambiguous — there is no signal for where the directory name ends and the prose begins. The terminal is guessing, and it has nothing to guess with.</p>

<p>This is the wall that separates “guess” from “declare.” To get past it you have to stop guessing.</p>

<h2 id="fix-part-2-declare-the-prompts-path-with-osc-8">Fix part 2: declare the prompt’s path with OSC 8</h2>

<p>The prompt is output <em>you</em> control — so emit OSC 8 there, the same way the coding agent does. <code class="language-plaintext highlighter-rouge">.NET</code>’s <code class="language-plaintext highlighter-rouge">[uri]</code> gives you <code class="language-plaintext highlighter-rouge">pathToFileURL</code>-equivalent encoding (spaces → <code class="language-plaintext highlighter-rouge">%20</code>):</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">prompt</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$gt</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'&gt;'</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="p">(</span><span class="nv">$nestedPromptLevel</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w">
    </span><span class="nv">$p</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="nv">$executionContext</span><span class="o">.</span><span class="nf">SessionState</span><span class="o">.</span><span class="nf">Path</span><span class="o">.</span><span class="nf">CurrentLocation</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">WEZTERM_PANE</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="nv">$p</span><span class="o">.</span><span class="nf">Provider</span><span class="o">.</span><span class="nf">Name</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s1">'FileSystem'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$e</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="n">char</span><span class="p">]</span><span class="mi">27</span><span class="w">
        </span><span class="nv">$uri</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">([</span><span class="n">uri</span><span class="p">]</span><span class="nv">$p</span><span class="o">.</span><span class="nf">ProviderPath</span><span class="p">)</span><span class="o">.</span><span class="nf">AbsoluteUri</span><span class="w">   </span><span class="c"># file:///C:/Users/me/Process%20Dashboard</span><span class="w">
        </span><span class="kr">return</span><span class="w"> </span><span class="s2">"PS </span><span class="nv">$e</span><span class="s2">]8;;</span><span class="nv">$uri$e</span><span class="s2">\</span><span class="nv">$p$e</span><span class="s2">]8;;</span><span class="nv">$e</span><span class="s2">\</span><span class="nv">$gt</span><span class="s2"> "</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="kr">return</span><span class="w"> </span><span class="s2">"PS </span><span class="nv">$p$gt</span><span class="s2"> "</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Two guards matter. <code class="language-plaintext highlighter-rouge">$env:WEZTERM_PANE</code> (set only inside WezTerm) means other terminals get a plain prompt with no stray escape bytes. <code class="language-plaintext highlighter-rouge">$p.Provider.Name -eq 'FileSystem'</code> skips non-filesystem locations like <code class="language-plaintext highlighter-rouge">HKLM:\</code> where a <code class="language-plaintext highlighter-rouge">file://</code> URI makes no sense. Now the whole <code class="language-plaintext highlighter-rouge">Process Dashboard</code> cwd is one clickable link — spaces and all — because the producer declared the exact target instead of leaving the terminal to guess.</p>

<h2 id="the-model-worth-keeping">The model worth keeping</h2>

<blockquote>
  <p><strong><code class="language-plaintext highlighter-rouge">hyperlink_rules</code> = universal but space-blind. OSC 8 = per-command but exact.</strong></p>

  <p>Regex works on any command’s output without cooperation, but must infer boundaries — so it fumbles spaces and extensionless directories. OSC 8 is flawless — spaces, directories, survives pipes and <code class="language-plaintext highlighter-rouge">ssh</code> — but only for output whose producer you can make emit it.</p>
</blockquote>

<p>Use the regex as the always-on baseline for the whole terminal; reach for OSC 8 on the specific surfaces you own (your prompt, a wrapper around a command you run constantly).</p>

<h2 id="dont-reinvent-it-where-you-dont-have-to">Don’t reinvent it where you don’t have to</h2>

<p>If you want OSC 8 for an arbitrary command without writing a wrapper, <a href="https://github.com/mash/osc8wrap"><code class="language-plaintext highlighter-rouge">mash/osc8wrap</code></a> is a pipe filter: <code class="language-plaintext highlighter-rouge">somecmd | osc8wrap</code>. I skipped it here because the two surfaces I actually cared about — every command’s output (regex) and my prompt (a function I already control) — needed no new dependency. But for a one-off “make <em>this</em> command’s paths exact,” it’s the boring, correct tool.</p>

<h2 id="source">Source</h2>

<ul>
  <li>WezTerm <a href="https://wezterm.org/config/lua/config/hyperlink_rules.html"><code class="language-plaintext highlighter-rouge">hyperlink_rules</code></a> and the <a href="https://wezterm.org/config/lua/window-events/open-uri.html"><code class="language-plaintext highlighter-rouge">open-uri</code> event</a></li>
  <li><a href="https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda">OSC 8 hyperlink spec</a> (Egmont Koblinger)</li>
  <li>Rust <a href="https://docs.rs/regex/"><code class="language-plaintext highlighter-rouge">regex</code></a> crate — the engine behind both WezTerm and ripgrep</li>
  <li><a href="https://github.com/mash/osc8wrap"><code class="language-plaintext highlighter-rouge">mash/osc8wrap</code></a> — OSC 8 pipe filter</li>
</ul>]]></content><author><name></name></author><category term="wezterm" /><category term="terminal" /><category term="tools" /><category term="productivity" /><summary type="html"><![CDATA[My AI coding agent prints a file path and I click it — VS Code opens the file. I run ls in the same terminal, it prints the same path, and clicking does nothing. Same terminal, same path, different outcome. That gap turns out to be the entire story of how terminals make text clickable, and closing it takes two mechanisms that are easy to confuse.]]></summary></entry><entry><title type="html">The $/1M-ctx-growth meter: measuring what a coding agent’s context is actually costing you</title><link href="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/22/cost-per-context-growth-meter.html" rel="alternate" type="text/html" title="The $/1M-ctx-growth meter: measuring what a coding agent’s context is actually costing you" /><published>2026-07-22T00:00:00+05:30</published><updated>2026-07-22T00:00:00+05:30</updated><id>https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/22/cost-per-context-growth-meter</id><content type="html" xml:base="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/22/cost-per-context-growth-meter.html"><![CDATA[<p>A long-running AI coding agent session has a shape you don’t notice until you’ve been bitten by it: cost grows roughly quadratically, because every tool round-trip resends the <em>entire</em> live context back to the model. A session that starts at 20k tokens and drifts to 150k isn’t paying 7.5x more per call than it did at the start — it’s paying 7.5x more per call, for every remaining call, for the rest of the session. Two other people have already written the sharper version of this observation (<a href="https://dev.to/search?q=Context+Tax+Step+12+Costs+42x+Step+1">Context Tax: Step 12 Costs 42x Step 1</a>, <a href="https://blog.exe.dev/expensively-quadratic">the quadratic cost of long-running agents</a>), so I won’t re-derive it here. What I hadn’t seen anywhere was a <em>live, glanceable</em> number that tells you when it’s actually happening to you, not just that it happens in general.</p>

<p>The naive fix — show cost-per-turn in the status line — doesn’t work. Raw per-turn cost ÷ per-turn context growth is a ratio with a denominator that is often near zero and sometimes negative (context shrinks after compaction), so the number swings 40x between adjacent turns for no behavioral reason. A fixed time window (say, “cost over the last 10 minutes”) is stable but laggy — it keeps a stale cheap turn from three minutes ago averaged into a number that’s supposed to tell you what’s happening <em>now</em>.</p>

<h2 id="the-metric">The metric</h2>

<p>The thing that actually works is an exponentially-weighted moving average of the numerator and denominator <em>separately</em>, not of the ratio:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rate = ewma(turn_cost) / ewma(net_context_growth)   [× 1e6, reported as $/1M]
</code></pre></div></div>

<p>Each new turn moves both EWMAs toward its own values with weight <code class="language-plaintext highlighter-rouge">alpha</code> (0.4 here) — so the newest turn has real influence (the number is live) while the denominator stays a smoothed aggregate rather than division-by-near-zero noise (the number isn’t chaotic). This is the standard rate-smoothing pattern from systems monitoring, applied to something monitoring dashboards don’t usually track: the marginal cost of an LLM agent’s own memory.</p>

<p>Alongside the smoothed rate, the exact cost of the <em>last</em> completed turn is shown with zero smoothing — the ground truth, next to the trend.</p>

<p>Rendered in the status line, it looks like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>⛁141K  Δ$4.72  🟡$308/M
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">⛁141K</code> — live absolute context size</li>
  <li><code class="language-plaintext highlighter-rouge">Δ$4.72</code> — exact cost of the last turn, no smoothing</li>
  <li><code class="language-plaintext highlighter-rouge">🟡$308/M</code> — EWMA $/1M-ctx-growth, with a severity glyph</li>
</ul>

<p>The bands (derived first-principles from a representative model’s per-token pricing and cache economics — not a published benchmark, and deliberately made configurable since they’re pricing-dependent):</p>

<table>
  <thead>
    <tr>
      <th>Band</th>
      <th>Range</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>🟢</td>
      <td>&lt; $30/M</td>
      <td>Healthy accumulation — spend tracks real growth</td>
    </tr>
    <tr>
      <td>⚪</td>
      <td>$30–150/M</td>
      <td>Normal tool use, mild resend tax</td>
    </tr>
    <tr>
      <td>🟡</td>
      <td>$150–400/M</td>
      <td>Low net growth (churn) or partial cache misses</td>
    </tr>
    <tr>
      <td>🔴</td>
      <td>≥ $400/M</td>
      <td>Big context + cache miss + near-zero growth — branch or trim</td>
    </tr>
  </tbody>
</table>

<p>The peak that motivated this whole thing was $924.67/M in a session that had ballooned to a large context and then hit a cache miss on a turn that added almost nothing — full-price resend of the whole window, for close to zero retained value. That’s the number a glance at the status line should catch before it happens three more times in the same session.</p>

<h2 id="the-bug-that-made-this-a-real-debugging-story-not-just-a-formula">The bug that made this a real debugging story, not just a formula</h2>

<p>The obvious way to compute “current context size” from an OMP/Pi-style agent’s session state is to walk the assistant message history and sum two fields every message carries:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">message</span><span class="p">.</span><span class="nx">contextSnapshot</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">promptTokens</span><span class="p">,</span> <span class="nx">nonMessageTokens</span> <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">nonMessageTokens</code> looks like exactly what its name says: tokens <em>outside</em> the message history — system prompt, tool definitions, injected memory. The natural instinct is <code class="language-plaintext highlighter-rouge">total = promptTokens + nonMessageTokens</code>. I built it that way first. It showed 187k on a session OMP’s own native footer reported as 141k.</p>

<p>Tracing it across every turn of a live session confirmed the actual relationship: <code class="language-plaintext highlighter-rouge">promptTokens === usage.input + usage.cacheRead + usage.cacheWrite</code> exactly, on every single turn. <code class="language-plaintext highlighter-rouge">promptTokens</code> <strong>is</strong> the full billed context for that turn. <code class="language-plaintext highlighter-rouge">nonMessageTokens</code> (a near-constant ~45k — system prompt + tool schemas + memory, in this setup) is a <em>subset</em> of <code class="language-plaintext highlighter-rouge">promptTokens</code>, not an addend. Adding them double-counts the constant term.</p>

<p>The interesting part is why this bug is invisible in the sibling extension it was copied from. That extension only ever computes <em>deltas between turns</em> (<code class="language-plaintext highlighter-rouge">this_turn_ctx - previous_turn_ctx</code>) to drive a cost-per-growth ratio. A near-constant offset cancels perfectly in a delta — <code class="language-plaintext highlighter-rouge">(promptTokens_a + K) - (promptTokens_b + K) = promptTokens_a - promptTokens_b</code>. So the formula is <em>correct</em> for the thing it’s used for there, and <em>wrong</em> the moment you reuse it to render an absolute gauge instead of a difference. Same code, same field names, one consumer where the bug can’t show and one where it’s off by 33%. The fix was one line: drop <code class="language-plaintext highlighter-rouge">+ nonMessageTokens</code>, use <code class="language-plaintext highlighter-rouge">promptTokens</code> alone — which is also exactly what the platform’s own built-in context-percentage indicator does.</p>

<p>Lesson, generalized past this one extension: a formula validated against <em>one</em> use of a shared value is not validated against every use of that value. If you lift a computation for a new purpose, re-derive from the field semantics, don’t just trust that it already worked somewhere else.</p>

<h2 id="the-counter-intuitive-part">The counter-intuitive part</h2>

<p>After restarting a session (resume, not fresh start), the rate <em>drops</em> — even though the agent is, in some naive sense, “carrying the same context” it had before. This looks backwards until you remember what the metric actually measures: growth-<em>efficiency</em>, not context volume. A resumed session’s initial context arrives via cache reads, which are nearly free and add ~zero <em>net new</em> tokens relative to what was already established. The denominator (growth) stays near zero and the numerator (cost) stays low too — cheap turn, little growth, unremarkable ratio. The number was never telling you how much context you’re carrying (that’s what the absolute <code class="language-plaintext highlighter-rouge">⛁141K</code> gauge is for); it’s telling you how expensively you’re currently <em>adding to</em> that pile. Those are different questions, and conflating them is exactly the trap the naive “show me cost per turn” version falls into.</p>

<h2 id="design-notes">Design notes</h2>

<ul>
  <li><strong>Standalone, not a patch to existing code.</strong> This lives as its own extension rather than modifying the retro tool it borrows the branch-walking pattern from — that tool was stable and I didn’t want a display feature risking a regression in something unrelated. It replays the session’s message history read-only, so it’s stateless: no baseline to seed, safe across restarts and resumes by construction.</li>
  <li><strong>Configurable bands, not hardcoded.</strong> Cost thresholds are a function of model pricing, which changes. Hardcoding $30/$150/$400 as constants would have quietly gone wrong the next time the underlying model’s per-token cost shifted. They’re read from a small config file, with strict validation and a safe fallback to the derived defaults on anything malformed.</li>
  <li><strong>Minimal display, no verbs.</strong> The first version had color-coded action hints (“consider branching”). Cut in favor of just the number and a glyph — the point was a glanceable gauge, not another thing telling you what to do.</li>
</ul>

<h2 id="source">Source</h2>

<ul>
  <li>Public prior art on the underlying cost curve: <a href="https://blog.exe.dev/expensively-quadratic">Expensively Quadratic</a>, <a href="https://dev.to/search?q=Context+Tax+Step+12+Costs+42x+Step+1">“Context Tax: Step 12 Costs 42x Step 1”</a>, <a href="https://tianpan.co">tianpan.co on quadratic agent cost</a></li>
  <li>Earlier post in this series on the same context-cost-visibility thread: /ai/agents/productivity/tools/2026/07/20/omp-pause-extension-and-feature-discovery.html</li>
</ul>
<p>&lt;/content&gt;
&lt;/invoke&gt;</p>]]></content><author><name></name></author><category term="ai" /><category term="agents" /><category term="productivity" /><category term="tools" /><summary type="html"><![CDATA[A long-running AI coding agent session has a shape you don’t notice until you’ve been bitten by it: cost grows roughly quadratically, because every tool round-trip resends the entire live context back to the model. A session that starts at 20k tokens and drifts to 150k isn’t paying 7.5x more per call than it did at the start — it’s paying 7.5x more per call, for every remaining call, for the rest of the session. Two other people have already written the sharper version of this observation (Context Tax: Step 12 Costs 42x Step 1, the quadratic cost of long-running agents), so I won’t re-derive it here. What I hadn’t seen anywhere was a live, glanceable number that tells you when it’s actually happening to you, not just that it happens in general.]]></summary></entry><entry><title type="html">Placing windows by title on Windows: nircmd lands them wrong, and why the fix is DPI awareness</title><link href="https://ankitg12.github.io/windows/tools/productivity/2026/07/21/dpi-aware-window-placement-windows.html" rel="alternate" type="text/html" title="Placing windows by title on Windows: nircmd lands them wrong, and why the fix is DPI awareness" /><published>2026-07-21T00:00:00+05:30</published><updated>2026-07-21T00:00:00+05:30</updated><id>https://ankitg12.github.io/windows/tools/productivity/2026/07/21/dpi-aware-window-placement-windows</id><content type="html" xml:base="https://ankitg12.github.io/windows/tools/productivity/2026/07/21/dpi-aware-window-placement-windows.html"><![CDATA[<p>Every time I relaunch a set of terminal windows, they cascade into a stack in the middle of the primary monitor, and I rearrange them by hand. Three SSH consoles I use together — a server, a client, and a host box — should snap to a fixed layout on one command. This is a solved problem on Linux (any tiling WM) but on Windows the obvious tools quietly do the wrong thing when a 4K monitor is scaled, and the failure looks like the tool working.</p>

<p>Here’s the whole rabbit hole: the tool that <em>looks</em> right (<code class="language-plaintext highlighter-rouge">nircmd</code>) places windows in the wrong coordinate space on a scaled display, the fix is a DPI-awareness flag most examples omit, and there’s a second gotcha where a cross-monitor move rescales the window mid-flight.</p>

<h2 id="first-attempt-nircmd-the-boring-choice">First attempt: nircmd, the boring choice</h2>

<p><a href="https://www.nirsoft.net/utils/nircmd.html">nircmd</a> (NirSoft, ~2003) is the Lindy-effect answer for scripting Windows from the command line. It moves and sizes a window by title:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nircmd win setsize stitle "ssh-server" 0 0 957 597
</code></pre></div></div>

<p>I computed a two-up-plus-bottom layout from the monitor’s work area and fired three of these. On the laptop panel (100% scale) it was perfect. On the external 4K at 150% scale, the windows flew <em>above</em> the visible area. But nircmd returned success for every call.</p>

<h2 id="diagnosis-read-back-where-the-window-actually-landed">Diagnosis: read back where the window actually landed</h2>

<p>Rule I keep relearning: when a placement tool “succeeds” but the result is wrong, stop guessing and read back the actual geometry. <code class="language-plaintext highlighter-rouge">GetWindowRect</code> tells you exactly where the window is:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">ctypes</span>
<span class="kn">from</span> <span class="nn">ctypes</span> <span class="kn">import</span> <span class="n">wintypes</span>
<span class="n">user32</span> <span class="o">=</span> <span class="n">ctypes</span><span class="p">.</span><span class="n">windll</span><span class="p">.</span><span class="n">user32</span>

<span class="k">def</span> <span class="nf">rect_of</span><span class="p">(</span><span class="n">hwnd</span><span class="p">):</span>
    <span class="n">r</span> <span class="o">=</span> <span class="n">wintypes</span><span class="p">.</span><span class="n">RECT</span><span class="p">()</span>
    <span class="n">user32</span><span class="p">.</span><span class="n">GetWindowRect</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="n">ctypes</span><span class="p">.</span><span class="n">byref</span><span class="p">(</span><span class="n">r</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">r</span><span class="p">.</span><span class="n">left</span><span class="p">,</span> <span class="n">r</span><span class="p">.</span><span class="n">top</span><span class="p">,</span> <span class="n">r</span><span class="p">.</span><span class="n">right</span> <span class="o">-</span> <span class="n">r</span><span class="p">.</span><span class="n">left</span><span class="p">,</span> <span class="n">r</span><span class="p">.</span><span class="n">bottom</span> <span class="o">-</span> <span class="n">r</span><span class="p">.</span><span class="n">top</span>
</code></pre></div></div>

<p>Requested top-left <code class="language-plaintext highlighter-rouge">(-1234, -2700)</code>; actual <code class="language-plaintext highlighter-rouge">(-1403, -3069)</code>. Off by 169px horizontally, 369px vertically — and the offset scaled with the monitor’s 150%. That ratio is the tell: this is a DPI-scaling mismatch, not a bug in the coordinates I computed.</p>

<h2 id="root-cause-two-processes-two-coordinate-spaces">Root cause: two processes, two coordinate spaces</h2>

<p>Windows has per-process <em>DPI awareness</em>. What <code class="language-plaintext highlighter-rouge">GetMonitorInfo</code> reports for a monitor’s rectangle depends on the calling process’s awareness mode:</p>

<ul>
  <li><strong>System-DPI-aware</strong> (<code class="language-plaintext highlighter-rouge">SetProcessDPIAware()</code>, the one every old example calls) — you get <em>logical</em> coordinates, scaled by the primary monitor’s factor. On a mixed-DPI setup these do not match physical pixels on the secondary monitors.</li>
  <li><strong>Per-monitor-aware v2</strong> — you get true physical pixels for every monitor.</li>
</ul>

<p>My script called <code class="language-plaintext highlighter-rouge">SetProcessDPIAware()</code> and computed logical coordinates. nircmd is a <em>separate process</em> with its <em>own</em> (different) awareness, so it interpreted my numbers in a different space. Cross-process coordinate hand-off across a DPI boundary is the trap. No amount of coordinate math on my side fixes it reliably, because the two processes never agree on what <code class="language-plaintext highlighter-rouge">(-1234, -2700)</code> means.</p>

<h2 id="fix-do-everything-in-one-process-per-monitor-aware-v2">Fix: do everything in one process, per-monitor-aware v2</h2>

<p>The clean fix is to drop the external tool and place windows in-process with <code class="language-plaintext highlighter-rouge">SetWindowPos</code>, under the per-monitor-v2 context — so enumerate, place, and verify all share one physical-pixel space:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">ctypes</span>
<span class="kn">from</span> <span class="nn">ctypes</span> <span class="kn">import</span> <span class="n">wintypes</span>
<span class="n">user32</span> <span class="o">=</span> <span class="n">ctypes</span><span class="p">.</span><span class="n">windll</span><span class="p">.</span><span class="n">user32</span>

<span class="c1"># DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
</span><span class="n">user32</span><span class="p">.</span><span class="n">SetProcessDpiAwarenessContext</span><span class="p">(</span><span class="n">ctypes</span><span class="p">.</span><span class="n">c_void_p</span><span class="p">(</span><span class="o">-</span><span class="mi">4</span><span class="p">))</span>

<span class="n">SWP_NOZORDER</span><span class="p">,</span> <span class="n">SWP_NOACTIVATE</span> <span class="o">=</span> <span class="mh">0x0004</span><span class="p">,</span> <span class="mh">0x0010</span>

<span class="k">def</span> <span class="nf">find_hwnd</span><span class="p">(</span><span class="n">substr</span><span class="p">):</span>
    <span class="n">out</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">proc</span> <span class="o">=</span> <span class="n">ctypes</span><span class="p">.</span><span class="n">WINFUNCTYPE</span><span class="p">(</span><span class="n">ctypes</span><span class="p">.</span><span class="n">c_bool</span><span class="p">,</span> <span class="n">wintypes</span><span class="p">.</span><span class="n">HWND</span><span class="p">,</span> <span class="n">wintypes</span><span class="p">.</span><span class="n">LPARAM</span><span class="p">)</span>
    <span class="k">def</span> <span class="nf">cb</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="n">_</span><span class="p">):</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">user32</span><span class="p">.</span><span class="n">IsWindowVisible</span><span class="p">(</span><span class="n">hwnd</span><span class="p">):</span>
            <span class="k">return</span> <span class="bp">True</span>
        <span class="n">n</span> <span class="o">=</span> <span class="n">user32</span><span class="p">.</span><span class="n">GetWindowTextLengthW</span><span class="p">(</span><span class="n">hwnd</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">n</span><span class="p">:</span>
            <span class="n">b</span> <span class="o">=</span> <span class="n">ctypes</span><span class="p">.</span><span class="n">create_unicode_buffer</span><span class="p">(</span><span class="n">n</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>
            <span class="n">user32</span><span class="p">.</span><span class="n">GetWindowTextW</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">n</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">substr</span><span class="p">.</span><span class="n">lower</span><span class="p">()</span> <span class="ow">in</span> <span class="n">b</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">lower</span><span class="p">():</span>
                <span class="n">out</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">hwnd</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">True</span>
    <span class="n">user32</span><span class="p">.</span><span class="n">EnumWindows</span><span class="p">(</span><span class="n">proc</span><span class="p">(</span><span class="n">cb</span><span class="p">),</span> <span class="mi">0</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">out</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="k">if</span> <span class="n">out</span> <span class="k">else</span> <span class="bp">None</span>

<span class="k">def</span> <span class="nf">place</span><span class="p">(</span><span class="n">substr</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">h</span><span class="p">):</span>
    <span class="n">hwnd</span> <span class="o">=</span> <span class="n">find_hwnd</span><span class="p">(</span><span class="n">substr</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">hwnd</span><span class="p">:</span>
        <span class="k">return</span>
    <span class="n">user32</span><span class="p">.</span><span class="n">ShowWindow</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="mi">9</span><span class="p">)</span>  <span class="c1"># SW_RESTORE (un-maximize first)
</span>    <span class="n">user32</span><span class="p">.</span><span class="n">SetWindowPos</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">h</span><span class="p">,</span> <span class="n">SWP_NOZORDER</span> <span class="o">|</span> <span class="n">SWP_NOACTIVATE</span><span class="p">)</span>
</code></pre></div></div>

<p>Requested <code class="language-plaintext highlighter-rouge">(-1234, -2700)</code> → actual <code class="language-plaintext highlighter-rouge">(-1234, -2700)</code>. Pixel-exact, because there is no longer a coordinate hand-off between two differently-aware processes.</p>

<p>Under v2, the 4K monitor also finally reports its <em>real</em> <code class="language-plaintext highlighter-rouge">3840x2160</code> from <code class="language-plaintext highlighter-rouge">GetMonitorInfo</code>. Under system-DPI-aware it had been reporting a scaled-down <code class="language-plaintext highlighter-rouge">2743x1543</code> — the same lie that had thrown off the layout math in the first place.</p>

<h2 id="second-gotcha-cross-monitor-moves-rescale-mid-flight">Second gotcha: cross-monitor moves rescale mid-flight</h2>

<p>Moving a window from a 100% monitor to a 150% monitor fires <code class="language-plaintext highlighter-rouge">WM_DPICHANGED</code>, and well-behaved apps <em>resize themselves</em> in response. So a single <code class="language-plaintext highlighter-rouge">SetWindowPos</code> that crosses a DPI boundary sets the position, then the window rescales out from under you — the read-back comes back the wrong size.</p>

<p>The fix is a self-correcting retry: after the move, verify, and if it’s off, place once more. The second call has no DPI transition (the window is already on the target monitor), so it sticks:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">place</span><span class="p">(</span><span class="n">substr</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">h</span><span class="p">):</span>
    <span class="n">hwnd</span> <span class="o">=</span> <span class="n">find_hwnd</span><span class="p">(</span><span class="n">substr</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">hwnd</span><span class="p">:</span>
        <span class="k">return</span>
    <span class="n">user32</span><span class="p">.</span><span class="n">ShowWindow</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="mi">9</span><span class="p">)</span>
    <span class="n">r</span> <span class="o">=</span> <span class="n">wintypes</span><span class="p">.</span><span class="n">RECT</span><span class="p">()</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">2</span><span class="p">):</span>
        <span class="n">user32</span><span class="p">.</span><span class="n">SetWindowPos</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">w</span><span class="p">,</span> <span class="n">h</span><span class="p">,</span> <span class="n">SWP_NOZORDER</span> <span class="o">|</span> <span class="n">SWP_NOACTIVATE</span><span class="p">)</span>
        <span class="n">user32</span><span class="p">.</span><span class="n">GetWindowRect</span><span class="p">(</span><span class="n">hwnd</span><span class="p">,</span> <span class="n">ctypes</span><span class="p">.</span><span class="n">byref</span><span class="p">(</span><span class="n">r</span><span class="p">))</span>
        <span class="k">if</span> <span class="nb">abs</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">left</span> <span class="o">-</span> <span class="n">x</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="mi">2</span> <span class="ow">and</span> <span class="nb">abs</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">right</span> <span class="o">-</span> <span class="n">r</span><span class="p">.</span><span class="n">left</span> <span class="o">-</span> <span class="n">w</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="mi">2</span><span class="p">:</span>
            <span class="k">break</span>
</code></pre></div></div>

<h2 id="bonus-matching-a-monitor-by-name">Bonus: matching a monitor by name</h2>

<p>I wanted <code class="language-plaintext highlighter-rouge">--monitor acer</code> to target a specific physical display, not an index that reshuffles when I dock. The intuitive call returns nothing useful:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># DeviceString here is "Generic PnP Monitor" — useless
</span><span class="n">user32</span><span class="p">.</span><span class="n">EnumDisplayDevicesW</span><span class="p">(</span><span class="n">adapter</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">dd</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</code></pre></div></div>

<p>The EDID vendor code lives in the <em>device interface path</em>, which you only get by passing <code class="language-plaintext highlighter-rouge">EDD_GET_DEVICE_INTERFACE_NAME</code> (flag <code class="language-plaintext highlighter-rouge">1</code>) as the last argument:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">re</span>
<span class="n">user32</span><span class="p">.</span><span class="n">EnumDisplayDevicesW</span><span class="p">(</span><span class="n">adapter</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">dd</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>   <span class="c1"># flag 1, not 0
</span><span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s">"DISPLAY#([A-Z]{3})"</span><span class="p">,</span> <span class="n">dd</span><span class="p">.</span><span class="n">DeviceID</span> <span class="ow">or</span> <span class="s">""</span><span class="p">)</span>
<span class="n">code</span> <span class="o">=</span> <span class="n">m</span><span class="p">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="k">if</span> <span class="n">m</span> <span class="k">else</span> <span class="bp">None</span>   <span class="c1"># e.g. "ACR", "DEL", "CMN"
</span></code></pre></div></div>

<p>Those three-letter codes are the <a href="https://uefi.org/pnp_id_list">PNP vendor IDs</a> — <code class="language-plaintext highlighter-rouge">ACR</code> = Acer, <code class="language-plaintext highlighter-rouge">DEL</code> = Dell, <code class="language-plaintext highlighter-rouge">CMN</code> = Chi Mei / Innolux (common laptop panels). Map them to friendly names and you can select a monitor by manufacturer.</p>

<h2 id="dont-reinvent-the-wheel-check-why-a-script-at-all">Don’t-reinvent-the-wheel check: why a script at all?</h2>

<p>Before writing any of this I checked the established options, because a general tiling manager would beat a bespoke script if one fit:</p>

<table>
  <thead>
    <tr>
      <th>Option</th>
      <th>Verdict for “snap N named windows on command”</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/LGUG2Z/komorebi">komorebi</a></td>
      <td>Excellent tiling WM, but its license forbids commercial/work use without a paid subscription, and it shows a splash on MDM-enrolled machines. Great for a personal box; a non-starter on a managed work laptop.</td>
    </tr>
    <tr>
      <td><a href="https://learn.microsoft.com/en-us/windows/powertoys/fancyzones">FancyZones</a> (PowerToys)</td>
      <td>Free, MIT, but zone-based: you snap windows into <em>zones</em>, and it does not natively match a window by <em>title substring</em>. A <a href="https://github.com/microsoft/PowerToys/issues/25196">CLI landed recently</a> but it’s layout-management, not “place this named window here.”</td>
    </tr>
    <tr>
      <td>nircmd</td>
      <td>The right shape (place-by-title CLI) but DPI-unaware — the entire failure above.</td>
    </tr>
    <tr>
      <td>In-process <code class="language-plaintext highlighter-rouge">SetWindowPos</code></td>
      <td>~60 lines, no daemon, no license, DPI-correct. Fits “3 known windows, on demand.”</td>
    </tr>
  </tbody>
</table>

<p>For a handful of specific named windows, the script is the genuine structural fit. If I wanted <em>general</em> zone-snapping for every app, FancyZones would win. The point of the exercise wasn’t to build — it was to confirm building was correct, and it was for this narrow case.</p>

<h2 id="ergonomic-aside">Ergonomic aside</h2>

<p>Since the script picks a monitor and a layout, it’s worth encoding the ergonomics too: put the two <em>peer</em> windows (server and client, which you diff row-against-row) <strong>side by side at eye level</strong>, so comparison is a horizontal glance and your neck stays neutral. Stacking is better for a single long log, ideally on a portrait monitor. The eye-level display — not the laptop panel you look down at — is where the windows you watch for long stretches belong.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>On multi-monitor Windows, <strong>DPI awareness is a coordinate contract</strong>. A window-placement tool that runs in a different process with different awareness will land windows in the wrong space, and report success doing it.</li>
  <li><code class="language-plaintext highlighter-rouge">SetProcessDpiAwarenessContext(-4)</code> (per-monitor v2) + in-process <code class="language-plaintext highlighter-rouge">SetWindowPos</code> keeps enumerate/place/verify in one space.</li>
  <li>Cross-monitor moves fire <code class="language-plaintext highlighter-rouge">WM_DPICHANGED</code>; place twice.</li>
  <li><code class="language-plaintext highlighter-rouge">EnumDisplayDevices</code> needs flag <code class="language-plaintext highlighter-rouge">1</code> to expose the EDID vendor code for name matching.</li>
  <li>Always read back geometry — a placement tool’s exit code is not evidence the window went where you asked.</li>
</ul>

<h2 id="source">Source</h2>

<ul>
  <li><a href="https://www.nirsoft.net/utils/nircmd.html">nircmd</a> — NirSoft command-line Windows utility</li>
  <li><a href="https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setprocessdpiawarenesscontext">SetProcessDpiAwarenessContext</a> / <a href="https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context">DPI_AWARENESS_CONTEXT</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/win32/hidpi/wm-dpichanged">WM_DPICHANGED</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-enumdisplaydevicesw">EnumDisplayDevices</a> (note the <code class="language-plaintext highlighter-rouge">EDD_GET_DEVICE_INTERFACE_NAME</code> flag)</li>
  <li><a href="https://uefi.org/pnp_id_list">PNP vendor ID list</a></li>
  <li><a href="https://github.com/LGUG2Z/komorebi">komorebi</a> · <a href="https://learn.microsoft.com/en-us/windows/powertoys/fancyzones">FancyZones</a></li>
</ul>]]></content><author><name></name></author><category term="windows" /><category term="tools" /><category term="productivity" /><summary type="html"><![CDATA[Every time I relaunch a set of terminal windows, they cascade into a stack in the middle of the primary monitor, and I rearrange them by hand. Three SSH consoles I use together — a server, a client, and a host box — should snap to a fixed layout on one command. This is a solved problem on Linux (any tiling WM) but on Windows the obvious tools quietly do the wrong thing when a 4K monitor is scaled, and the failure looks like the tool working.]]></summary></entry><entry><title type="html">Running commands in WSL from Windows: stop fighting the quoting</title><link href="https://ankitg12.github.io/wsl/windows/shell/productivity/2026/07/21/running-commands-in-wsl-from-windows.html" rel="alternate" type="text/html" title="Running commands in WSL from Windows: stop fighting the quoting" /><published>2026-07-21T00:00:00+05:30</published><updated>2026-07-21T00:00:00+05:30</updated><id>https://ankitg12.github.io/wsl/windows/shell/productivity/2026/07/21/running-commands-in-wsl-from-windows</id><content type="html" xml:base="https://ankitg12.github.io/wsl/windows/shell/productivity/2026/07/21/running-commands-in-wsl-from-windows.html"><![CDATA[<p>You have a Windows shell open and you want to run one Linux command in WSL. You type what looks obviously correct:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wu 'cd ~/repo &amp;&amp; git show SHA:path/file | grep "one|two"'
</code></pre></div></div>

<p>…and WSL greets you with <code class="language-plaintext highlighter-rouge">rtr: command not found</code> or <code class="language-plaintext highlighter-rouge">unexpected EOF while looking for matching quote</code>. The command is fine. The <em>quoting</em> died three shells ago.</p>

<blockquote>
  <p>An <a href="/windows/wsl/tools/2026/07/16/wu-cmd-wsl-multiline-fix.html">earlier post</a> covered the <em>mechanical</em> fix for multi-line scripts in this same <code class="language-plaintext highlighter-rouge">wu</code> wrapper. This one is about the underlying <em>why</em> — the layered-quoting model that explains the whole class of failures, and the one principle that dissolves it.</p>
</blockquote>

<h2 id="why-this-breaks">Why this breaks</h2>

<p>When you invoke a Linux command from a Windows wrapper, the string you typed is re-parsed by <strong>three</strong> different parsers before a single byte reaches your program:</p>

<pre><code class="language-mermaid">graph LR
  A[MSYS / git-bash] --&gt; B[cmd.exe]
  B --&gt; C[wsl.exe arg split]
  C --&gt; D[bash -lc]
</code></pre>

<p>Each layer has its own idea of what a quote and a pipe mean:</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>What it does to your string</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>MSYS / git-bash</td>
      <td>strips one layer of quotes, expands <code class="language-plaintext highlighter-rouge">$vars</code>, splits on its rules</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cmd.exe</code></td>
      <td>does <strong>not</strong> treat <code class="language-plaintext highlighter-rouge">'single quotes'</code> as quotes at all; sees bare <code class="language-plaintext highlighter-rouge">|</code> as a <em>cmd pipe</em></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wsl.exe</code></td>
      <td>re-splits the remaining args on spaces</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bash -lc "$@"</code></td>
      <td>finally parses what’s left as a shell command</td>
    </tr>
  </tbody>
</table>

<p>The classic failure: <code class="language-plaintext highlighter-rouge">grep "one|two"</code> — <code class="language-plaintext highlighter-rouge">cmd.exe</code> sees the <code class="language-plaintext highlighter-rouge">|</code> <strong>outside</strong> any quoting it recognizes (it doesn’t honor single quotes), interprets it as a pipe, and tries to run <code class="language-plaintext highlighter-rouge">two</code> as a separate program. Hence <code class="language-plaintext highlighter-rouge">two: command not found</code>. Your carefully-quoted regex never survived layer two.</p>

<p>This is not a bug in any one tool — it is the unavoidable result of stacking parsers with incompatible quoting rules. Microsoft’s own WSL issue tracker has years of threads on it (<a href="https://github.com/microsoft/WSL/issues/3284">#3284</a>, <a href="https://github.com/microsoft/WSL/issues/11635">#11635</a>, <a href="https://github.com/microsoft/WSL/issues/1625">#1625</a>). There is no combination of escaping that is robust across all of them.</p>

<h2 id="the-fix-send-the-command-as-bytes-not-as-arguments">The fix: send the command as bytes, not as arguments</h2>

<p>Arguments get parsed. <strong>stdin does not.</strong> If you pipe your command into <code class="language-plaintext highlighter-rouge">bash</code> over stdin, none of the intermediate layers get to tokenize it — they only see an opaque byte stream. bash reads it as a script and runs it verbatim.</p>

<p>A tiny wrapper makes this ergonomic. Here is the whole thing (<code class="language-plaintext highlighter-rouge">wu.cmd</code>):</p>

<div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@echo <span class="na">off</span>
<span class="k">if</span> <span class="ow">not</span> <span class="ow">defined</span> <span class="kd">WU_TIMEOUT</span> <span class="kd">set</span> <span class="kd">WU_TIMEOUT</span><span class="o">=</span><span class="m">180</span>
<span class="k">if</span> <span class="s2">"</span><span class="err">%</span><span class="s2">~1"</span><span class="o">==</span><span class="s2">""</span> <span class="o">(</span>
    <span class="c">rem no args: read the command from stdin</span>
    <span class="kd">wsl</span> <span class="o">--</span> <span class="nb">timeout</span> <span class="na">-k </span><span class="m">5</span> <span class="nv">%WU_TIMEOUT%</span> <span class="kd">bash</span> <span class="na">-l -s
</span><span class="o">)</span> <span class="k">else</span> <span class="o">(</span>
    <span class="c">rem args: run them directly (fine for SIMPLE commands only)</span>
    <span class="kd">wsl</span> <span class="o">--</span> <span class="nb">timeout</span> <span class="na">-k </span><span class="m">5</span> <span class="nv">%WU_TIMEOUT%</span> <span class="kd">bash</span> <span class="na">-lc </span><span class="err">%</span><span class="o">*</span>
<span class="o">)</span>
</code></pre></div></div>

<p>Two modes fall out of this:</p>

<p><strong>Simple command → arg mode, double quotes:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wu "echo hi; whoami"
</code></pre></div></div>

<p><strong>Anything with pipes, quotes, <code class="language-plaintext highlighter-rouge">$vars</code>, or <code class="language-plaintext highlighter-rouge">colon:paths</code> → stdin mode:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">printf</span> <span class="s1">'%s\n'</span> <span class="s1">'cd ~/repo &amp;&amp; git show SHA:path | grep "one|two"'</span> | wu
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">printf '%s\n' '…'</code> hands one literal line to <code class="language-plaintext highlighter-rouge">wu</code>’s stdin. <code class="language-plaintext highlighter-rouge">bash -l -s</code> reads it as a script. The <code class="language-plaintext highlighter-rouge">|</code>, the <code class="language-plaintext highlighter-rouge">"quotes"</code>, the <code class="language-plaintext highlighter-rouge">$vars</code> all survive because nothing between you and bash was allowed to parse them.</p>

<p>Heredocs work too, which is lovely for multi-line scripts:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>wu <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
cd ~/repo
for f in *.log; do
  grep -c ERROR "</span><span class="nv">$f</span><span class="sh">" | sed "s/^/</span><span class="nv">$f</span><span class="sh">: /"
done
</span><span class="no">EOF
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">'EOF'</code> (quoted) means even <code class="language-plaintext highlighter-rouge">$f</code> passes through untouched — the remote bash expands it, not your local shell.</p>

<h2 id="the-rule-of-thumb">The rule of thumb</h2>

<ul>
  <li><strong>Reaching for a pipe, a nested quote, a <code class="language-plaintext highlighter-rouge">$var</code>, or a <code class="language-plaintext highlighter-rouge">path:with:colons</code>?</strong> Use stdin: <code class="language-plaintext highlighter-rouge">printf '%s\n' '…' | wu</code>.</li>
  <li><strong>Just a plain command or two?</strong> <code class="language-plaintext highlighter-rouge">wu "cmd; cmd"</code> is fine.</li>
</ul>

<p>Once you internalize <em>“arguments get re-parsed, stdin doesn’t,”</em> the whole class of Windows↔WSL quoting bugs disappears. The same principle applies well beyond WSL: any time you’re shipping a command across a boundary that re-tokenizes (SSH, <code class="language-plaintext highlighter-rouge">docker exec</code>, <code class="language-plaintext highlighter-rouge">kubectl exec</code>, CI YAML), prefer a stdin/heredoc channel over cramming it into arguments.</p>

<h2 id="source">Source</h2>

<ul>
  <li>Microsoft WSL quoting threads: <a href="https://github.com/microsoft/WSL/issues/3284">#3284</a>, <a href="https://github.com/microsoft/WSL/issues/11635">#11635</a>, <a href="https://github.com/microsoft/WSL/issues/1625">#1625</a></li>
  <li><a href="https://devblogs.microsoft.com/commandline/a-guide-to-invoking-wsl/">A Guide to Invoking WSL</a> — Microsoft Command Line blog</li>
  <li><code class="language-plaintext highlighter-rouge">bash -s</code> reads the script from stdin; <code class="language-plaintext highlighter-rouge">bash -lc</code> parses its argument — the whole trick is choosing the former for anything non-trivial.</li>
</ul>]]></content><author><name></name></author><category term="wsl" /><category term="windows" /><category term="shell" /><category term="productivity" /><summary type="html"><![CDATA[You have a Windows shell open and you want to run one Linux command in WSL. You type what looks obviously correct:]]></summary></entry><entry><title type="html">Building a quiet /pause for OMP — and discovering how much of the feature set I didn’t know</title><link href="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/20/omp-pause-extension-and-feature-discovery.html" rel="alternate" type="text/html" title="Building a quiet /pause for OMP — and discovering how much of the feature set I didn’t know" /><published>2026-07-20T00:00:00+05:30</published><updated>2026-07-20T00:00:00+05:30</updated><id>https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/20/omp-pause-extension-and-feature-discovery</id><content type="html" xml:base="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/20/omp-pause-extension-and-feature-discovery.html"><![CDATA[<p>I wanted to pause my AI coding agent — freeze it mid-session so I could step away or read something on screen without it charging ahead — and resume later with <code class="language-plaintext highlighter-rouge">/resume</code> or <code class="language-plaintext highlighter-rouge">/unpause</code>. Simple ask. It took a wrong build, a scrapped extension, a real concurrency bug, and an upstream issue before landing on something worth keeping.</p>

<h2 id="first-attempt-reinventing-something-that-already-existed">First attempt: reinventing something that already existed</h2>

<p>My first instinct was to write an extension from scratch: hook <code class="language-plaintext highlighter-rouge">before_agent_start</code> and <code class="language-plaintext highlighter-rouge">tool_call</code>, hold an unresolved promise until a <code class="language-plaintext highlighter-rouge">/resume</code> command released it. Textbook cooperative pause gate. I built it, wired it into config, and mid-smoke-test a full-screen “P A U S E D” splash appeared that I hadn’t written.</p>

<p>OMP already ships a native <code class="language-plaintext highlighter-rouge">/pause</code>. It freezes the main agent, every subagent, <em>and</em> the advisor via a process-global gate (<code class="language-plaintext highlighter-rouge">agentPauseGate</code>, exported from <code class="language-plaintext highlighter-rouge">@oh-my-pi/pi-agent-core</code>), polled at the two safe boundaries every agent loop passes through: before a model call, and before a tool call starts. My hand-rolled version only guarded the current session’s own two events — it didn’t touch subagents or the advisor at all. Strictly worse, and redundant.</p>

<p>Lesson, not for the first time: grep the tool’s own source for the feature name before building it.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node <span class="nt">-e</span> <span class="s2">"
const fs=require('fs'), path=require('path');
function walk(dir){
  for (const f of fs.readdirSync(dir, {withFileTypes:true})) {
    const p = path.join(dir, f.name);
    if (f.isDirectory()) walk(p);
    else if (f.name.endsWith('.js') &amp;&amp; fs.readFileSync(p,'utf8').includes('PAUSED')) console.log(p);
  }
}
walk('node_modules/@oh-my-pi/pi-coding-agent');
"</span>
</code></pre></div></div>

<p>I deleted the extension, the config file, and the log. Total loss: about twenty minutes and some context.</p>

<h2 id="the-actual-gap-fullscreen-only">The actual gap: fullscreen only</h2>

<p>Native <code class="language-plaintext highlighter-rouge">/pause</code> does exactly two things in its handler: <code class="language-plaintext highlighter-rouge">agentPauseGate.pause()</code>, then <code class="language-plaintext highlighter-rouge">await runPauseScreen(ctx)</code> — and <code class="language-plaintext highlighter-rouge">runPauseScreen</code> is a hardcoded fullscreen overlay (<code class="language-plaintext highlighter-rouge">fullscreen: true</code>, <code class="language-plaintext highlighter-rouge">anchor: "top-left"</code>, <code class="language-plaintext highlighter-rouge">maxHeight: "100%"</code>). No flag, no config key, no seam. If you want the freeze without losing sight of your terminal — say, you’re mid-read of a long diff or log output and just want to stop the agent from touching anything for a minute — there’s no way to ask for that from the outside.</p>

<p>That’s a real gap, and it’s not something I can fix by forking OMP’s source. So two things, in order:</p>

<ol>
  <li>File the actual feature request upstream, with exact file/line references, so it doesn’t get lost as “works for me, closing.”</li>
  <li>Build the workaround I actually needed in the meantime — reusing the <em>same</em> gate the native command drives, just with a different display.</li>
</ol>

<h2 id="reuse-the-primitive-replace-the-display">Reuse the primitive, replace the display</h2>

<p><code class="language-plaintext highlighter-rouge">agentPauseGate</code> is a plain exported singleton with a small surface:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">class</span> <span class="nx">AgentPauseGate</span> <span class="p">{</span>
  <span class="kd">get</span> <span class="nx">paused</span><span class="p">():</span> <span class="nx">boolean</span><span class="p">;</span>
  <span class="kd">get</span> <span class="nx">pausedAt</span><span class="p">():</span> <span class="kr">number</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
  <span class="nx">pause</span><span class="p">():</span> <span class="nx">boolean</span><span class="p">;</span>                          <span class="c1">// false if already paused</span>
  <span class="nx">resume</span><span class="p">():</span> <span class="kr">number</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>              <span class="c1">// pause duration in ms, or undefined</span>
  <span class="nx">onChange</span><span class="p">(</span><span class="nx">listener</span><span class="p">:</span> <span class="p">(</span><span class="nx">paused</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">):</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span><span class="p">;</span>
  <span class="k">async</span> <span class="nx">waitUntilResumed</span><span class="p">(</span><span class="nx">signal</span><span class="p">?:</span> <span class="nx">AbortSignal</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">agentPauseGate</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">AgentPauseGate</span><span class="p">();</span>
</code></pre></div></div>

<p>Since it’s importable from <code class="language-plaintext highlighter-rouge">@oh-my-pi/pi-agent-core</code> — the same package the native command imports it from — an extension can drive it directly. Freeze semantics become identical to the builtin (main + subagents + advisor, parked at the next safe boundary) without touching a single line of OMP’s own source. I named the commands <code class="language-plaintext highlighter-rouge">/hold</code> / <code class="language-plaintext highlighter-rouge">/unhold</code> / <code class="language-plaintext highlighter-rouge">/hold-status</code> rather than <code class="language-plaintext highlighter-rouge">/pause</code>/<code class="language-plaintext highlighter-rouge">/resume</code> — OMP’s extension loader silently skips (with a warning) any extension command whose name collides with a builtin, so there’s no “override” path; different names are the only option.</p>

<p>The display side is a <code class="language-plaintext highlighter-rouge">setWidget</code> call instead of the fullscreen component — a persistent, ticking status line above the editor instead of a takeover screen:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">widgetLines</span><span class="p">():</span> <span class="kr">string</span><span class="p">[]</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">heldMs</span> <span class="o">=</span> <span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">pausedAt</span> <span class="o">!==</span> <span class="kc">undefined</span>
    <span class="p">?</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">pausedAt</span> <span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
  <span class="k">return</span> <span class="p">[</span><span class="s2">`⏸ held for </span><span class="p">${</span><span class="nx">formatClock</span><span class="p">(</span><span class="nx">heldMs</span><span class="p">)}</span><span class="s2"> — /unhold to continue (transcript stays visible)`</span><span class="p">];</span>
<span class="p">}</span>

<span class="nx">pi</span><span class="p">.</span><span class="nx">registerCommand</span><span class="p">(</span><span class="dl">"</span><span class="s2">hold</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">description</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Pause the agent without covering the screen</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">handler</span><span class="p">:</span> <span class="p">(</span><span class="nx">_args</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">pause</span><span class="p">())</span> <span class="p">{</span>
      <span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">notify</span><span class="p">(</span><span class="dl">"</span><span class="s2">[hold] already held</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">info</span><span class="dl">"</span><span class="p">);</span>
      <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="nx">startTicking</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="c1">// ctx.setInterval(...) updating the widget every second</span>
    <span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">notify</span><span class="p">(</span><span class="dl">"</span><span class="s2">[hold] agent held — use /unhold to continue</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">info</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="the-bug-that-mattered-a-race-at-the-turn-boundary">The bug that mattered: a race at the turn boundary</h2>

<p>First live test looked right — the widget appeared, ticked, no fullscreen takeover. Then I typed a prompt while held: <code class="language-plaintext highlighter-rouge">say the word banana</code>. It answered anyway.</p>

<p><code class="language-plaintext highlighter-rouge">agentPauseGate</code> is polled only <em>inside</em> <code class="language-plaintext highlighter-rouge">agent-loop.ts</code>, at its own internal checkpoints:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before each model call within a turn</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">paused</span><span class="p">)</span> <span class="k">await</span> <span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">waitUntilResumed</span><span class="p">(</span><span class="nx">signal</span><span class="p">);</span>
<span class="p">...</span>
<span class="c1">// before each tool call</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">paused</span><span class="p">)</span> <span class="k">await</span> <span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">waitUntilResumed</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">signal</span><span class="p">);</span>
</code></pre></div></div>

<p>Sending <code class="language-plaintext highlighter-rouge">/hold</code> and then immediately submitting a plain-text prompt can race the loop’s first checkpoint — if the loop already grabbed its “not paused” read before <code class="language-plaintext highlighter-rouge">pause()</code> landed, a text-only turn (no tool calls, so the second checkpoint never fires) sails straight through. The fix was a second, earlier gate — the extension’s own <code class="language-plaintext highlighter-rouge">before_agent_start</code> hook, which fires before the agent is dispatched at all:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">pi</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">"</span><span class="s2">before_agent_start</span><span class="dl">"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span><span class="nx">_event</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">paused</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
  <span class="k">await</span> <span class="nx">agentPauseGate</span><span class="p">.</span><span class="nx">waitUntilResumed</span><span class="p">();</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Verified in a live session afterward: <code class="language-plaintext highlighter-rouge">/hold</code> → <code class="language-plaintext highlighter-rouge">say the word mango</code> → the “Working…” spinner sat there indefinitely, no output — until <code class="language-plaintext highlighter-rouge">/unhold</code>, at which point the queued prompt executed and “mango” printed. Belt and suspenders, but the belt was load-bearing.</p>

<h2 id="finding-the-rest-of-the-iceberg">Finding the rest of the iceberg</h2>

<p>Chasing down <code class="language-plaintext highlighter-rouge">/pause</code> meant reading OMP’s entire builtin command registry, and it turned up a lot I’d never used in months of daily driving:</p>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>What it actually does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/goal set &lt;objective&gt;</code></td>
      <td>Persistent autonomous objective for the session, with its own token budget (<code class="language-plaintext highlighter-rouge">/goal budget &lt;N\|off&gt;</code>), pause/resume/drop</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/branch</code>, <code class="language-plaintext highlighter-rouge">/fork</code>, <code class="language-plaintext highlighter-rouge">/tree</code></td>
      <td>Branch a new session off a <em>previous</em> message, not just “now” — git branching for conversations</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/shake [elide\|images]</code></td>
      <td>Strip heavy content (tool results, images) from context without a full <code class="language-plaintext highlighter-rouge">/compact</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/collab [start\|view\|stop]</code> + <code class="language-plaintext highlighter-rouge">/join &lt;link&gt;</code></td>
      <td>Share a live session via a relay; others watch or actively join</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/btw &lt;question&gt;</code></td>
      <td>Ephemeral side question using current context, doesn’t pollute the main transcript</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/tan &lt;work&gt;</code></td>
      <td>Spawn a full background agent on tangential work while the main thread keeps going</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/omfg &lt;complaint&gt;</code></td>
      <td>Forges a rule from a complaint to stop a recurring agent behavior</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/memory mm list/show/refresh/history</code></td>
      <td>Direct inspection of individual “mental models” in the memory bank</td>
    </tr>
  </tbody>
</table>

<p>None of these were hidden exactly — they’re all in <code class="language-plaintext highlighter-rouge">--help</code> and <code class="language-plaintext highlighter-rouge">/hotkeys</code> — but I’d never gone looking. The <code class="language-plaintext highlighter-rouge">/pause</code> rabbit hole is what forced the read.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li><strong>Grep before you build.</strong> A five-minute search of the tool’s own source would have saved the first scrapped extension entirely.</li>
  <li><strong>A missing feature and a missing config knob are different bugs.</strong> <code class="language-plaintext highlighter-rouge">/pause</code>’s freeze mechanism was exactly right; only its display was inflexible. Filing an upstream issue for the display, and building a stopgap against the <em>real</em> shared primitive, beat reimplementing the whole thing badly.</li>
  <li><strong>Cooperative gates race at boundaries you don’t own.</strong> If a shared pause/cancel primitive is polled only inside someone else’s loop, don’t assume your own trigger point is inside that loop too — verify with an actual concurrent test, not a single happy-path try.</li>
</ul>

<h2 id="source">Source</h2>

<ul>
  <li>Extension: <code class="language-plaintext highlighter-rouge">agent-pause-omp</code> (personal, unpublished) — commands <code class="language-plaintext highlighter-rouge">/hold</code> / <code class="language-plaintext highlighter-rouge">/unhold</code> / <code class="language-plaintext highlighter-rouge">/hold-status</code></li>
  <li>Upstream feature request: <a href="https://github.com/can1357/oh-my-pi/issues/6101">oh-my-pi#6101</a> — decouple <code class="language-plaintext highlighter-rouge">agentPauseGate</code> engagement from the fullscreen <code class="language-plaintext highlighter-rouge">runPauseScreen</code> display</li>
  <li>Prior post in this series: /ai/agents/productivity/2026/05/11/slowmo-for-ai-coding-agents.html</li>
</ul>]]></content><author><name></name></author><category term="ai" /><category term="agents" /><category term="productivity" /><category term="tools" /><summary type="html"><![CDATA[I wanted to pause my AI coding agent — freeze it mid-session so I could step away or read something on screen without it charging ahead — and resume later with /resume or /unpause. Simple ask. It took a wrong build, a scrapped extension, a real concurrency bug, and an upstream issue before landing on something worth keeping.]]></summary></entry><entry><title type="html">session-reminder-omp: periodic reminders inside a coding-agent session</title><link href="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/16/session-reminder-omp.html" rel="alternate" type="text/html" title="session-reminder-omp: periodic reminders inside a coding-agent session" /><published>2026-07-16T00:00:00+05:30</published><updated>2026-07-16T00:00:00+05:30</updated><id>https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/16/session-reminder-omp</id><content type="html" xml:base="https://ankitg12.github.io/ai/agents/productivity/tools/2026/07/16/session-reminder-omp.html"><![CDATA[<p>I was looking for an extension to periodically remind me of things during a coding-agent session — not calendar-style reminders, but ones scoped to a live session’s wall clock. Concretely: I run <code class="language-plaintext highlighter-rouge">/shake</code> by hand every so often, a mechanical operation in my coding agent (OMP) that trims stale tool output and images from the live context without touching the model’s understanding of the conversation. My agent’s <code class="language-plaintext highlighter-rouge">compaction.strategy: shake</code> config already auto-fires that once a session crosses a token threshold — but that’s size-triggered. What I wanted was time-triggered: a nudge every 10 minutes, independent of how big the context has gotten.</p>

<p>I couldn’t find an existing extension that did this, so I built one: <a href="https://github.com/ankitg12/session-reminder-omp"><code class="language-plaintext highlighter-rouge">session-reminder-omp</code></a>.</p>

<h2 id="what-it-does">What it does</h2>

<p>It’s a small OMP extension that fires arbitrary wall-clock-timed nudges — a notification plus a brief status-bar flash — on whatever cadence you configure, for as long as a session is open. Each reminder is independent: its own name, message, interval, icon, and flash duration. Nothing about it is specific to <code class="language-plaintext highlighter-rouge">/shake</code>; that’s just the first thing I needed a nudge for.</p>

<p>Reminders live in a plain JSON config file, <code class="language-plaintext highlighter-rouge">~/.omp/agent/session-reminder.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
	</span><span class="nl">"reminders"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
		</span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"shake"</span><span class="p">,</span><span class="w"> </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Context has been growing for 10min — consider running /shake to reclaim tokens."</span><span class="p">,</span><span class="w"> </span><span class="nl">"intervalMs"</span><span class="p">:</span><span class="w"> </span><span class="mi">600000</span><span class="p">,</span><span class="w"> </span><span class="nl">"icon"</span><span class="p">:</span><span class="w"> </span><span class="s2">"⏰"</span><span class="w"> </span><span class="p">},</span><span class="w">
		</span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"stretch"</span><span class="p">,</span><span class="w"> </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"You've been at this an hour — stand up and stretch."</span><span class="p">,</span><span class="w"> </span><span class="nl">"intervalMs"</span><span class="p">:</span><span class="w"> </span><span class="mi">3600000</span><span class="p">,</span><span class="w"> </span><span class="nl">"icon"</span><span class="p">:</span><span class="w"> </span><span class="s2">"🧘"</span><span class="w"> </span><span class="p">},</span><span class="w">
		</span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eyes"</span><span class="p">,</span><span class="w"> </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"20-20-20: look at something 20 feet away for 20 seconds."</span><span class="p">,</span><span class="w"> </span><span class="nl">"intervalMs"</span><span class="p">:</span><span class="w"> </span><span class="mi">1200000</span><span class="p">,</span><span class="w"> </span><span class="nl">"icon"</span><span class="p">:</span><span class="w"> </span><span class="s2">"👀"</span><span class="w"> </span><span class="p">},</span><span class="w">
		</span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hydrate"</span><span class="p">,</span><span class="w"> </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Drink some water."</span><span class="p">,</span><span class="w"> </span><span class="nl">"intervalMs"</span><span class="p">:</span><span class="w"> </span><span class="mi">2700000</span><span class="p">,</span><span class="w"> </span><span class="nl">"icon"</span><span class="p">:</span><span class="w"> </span><span class="s2">"💧"</span><span class="w"> </span><span class="p">},</span><span class="w">
		</span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"posture"</span><span class="p">,</span><span class="w"> </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Posture check — shoulders back, feet flat, screen at eye level."</span><span class="p">,</span><span class="w"> </span><span class="nl">"intervalMs"</span><span class="p">:</span><span class="w"> </span><span class="mi">1800000</span><span class="p">,</span><span class="w"> </span><span class="nl">"icon"</span><span class="p">:</span><span class="w"> </span><span class="s2">"🧍"</span><span class="w"> </span><span class="p">}</span><span class="w">
	</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Once I had the mechanism, the obvious move was to stop thinking of it as a shake-nudge tool and use it for what it actually is: a generic “tell me things periodically while I’m sitting here” utility. So alongside the shake reminder, my own config now nudges me to stretch, blink/rest my eyes, drink water, and check my posture — all things I already know I should do and reliably forget to, because nothing in my environment was timed to a session’s actual duration.</p>

<p>Each entry arms its own timer on session start and clears it on session end:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">arm</span><span class="p">(</span><span class="nx">entry</span><span class="p">:</span> <span class="nx">ReminderConfig</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">:</span> <span class="nx">ExtensionContext</span><span class="p">):</span> <span class="nx">Timer</span> <span class="p">{</span>
	<span class="k">return</span> <span class="nx">setInterval</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
		<span class="k">try</span> <span class="p">{</span>
			<span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">notify</span><span class="p">(</span><span class="nx">entry</span><span class="p">.</span><span class="nx">message</span><span class="p">,</span> <span class="dl">"</span><span class="s2">info</span><span class="dl">"</span><span class="p">);</span>
			<span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">setStatus</span><span class="p">(</span><span class="nx">entry</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="s2">`</span><span class="p">${</span><span class="nx">entry</span><span class="p">.</span><span class="nx">icon</span> <span class="o">??</span> <span class="dl">"</span><span class="s2">⏰</span><span class="dl">"</span><span class="p">}</span><span class="s2"> </span><span class="p">${</span><span class="nx">entry</span><span class="p">.</span><span class="nx">name</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
			<span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">setStatus</span><span class="p">(</span><span class="nx">entry</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kc">undefined</span><span class="p">),</span> <span class="nx">entry</span><span class="p">.</span><span class="nx">flashMs</span> <span class="o">??</span> <span class="mi">15</span><span class="nx">_000</span><span class="p">);</span>
		<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
			<span class="c1">// one bad reminder logs and skips — it doesn't take the others down</span>
		<span class="p">}</span>
	<span class="p">},</span> <span class="nx">entry</span><span class="p">.</span><span class="nx">intervalMs</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Malformed config entries — missing <code class="language-plaintext highlighter-rouge">intervalMs</code>, a non-positive interval, a missing <code class="language-plaintext highlighter-rouge">message</code> — are skipped individually with a logged reason rather than failing the whole extension. One bad reminder shouldn’t cost you the other four.</p>

<h2 id="the-one-gap-worth-naming">The one gap worth naming</h2>

<p>The extension nudges; it doesn’t act. There’s currently no supported way for an OMP extension to actually <em>invoke</em> the underlying <code class="language-plaintext highlighter-rouge">/shake</code> operation programmatically — only the built-in command controller can call that directly, so even the shake reminder above is a “go run this yourself” message, not an auto-triggered one. I filed that as a feature request upstream (<a href="https://github.com/can1357/oh-my-pi/issues/5661"><code class="language-plaintext highlighter-rouge">can1357/oh-my-pi#5661</code></a>) rather than reach for something uglier like monkeypatching the session internals. If an extension API doesn’t expose something, asking for the API beats routing around the boundary.</p>

<h2 id="a-bug-that-taught-me-something-about-extension-crash-isolation">A bug that taught me something about extension crash isolation</h2>

<p>Worth a specific callout since it surprised me: my first version called <code class="language-plaintext highlighter-rouge">ctx.notify(...)</code> and <code class="language-plaintext highlighter-rouge">ctx.setStatus(...)</code> directly — except those methods don’t exist at that path; they live under <code class="language-plaintext highlighter-rouge">ctx.ui.notify</code>/<code class="language-plaintext highlighter-rouge">ctx.ui.setStatus</code>. Every timer tick threw a <code class="language-plaintext highlighter-rouge">TypeError</code>, and because that throw happened inside a <code class="language-plaintext highlighter-rouge">setInterval</code> callback I’d scheduled myself — not inside the extension’s <code class="language-plaintext highlighter-rouge">session_start</code> handler invocation, which OMP does wrap in try/catch — it escaped all containment and crashed the entire session, not just the misbehaving extension.</p>

<p>That’s a sharper edge than it sounds: any extension that schedules its own timers or async work (which the SDK explicitly supports, and which this exact use case requires) can currently take the whole harness down on a runtime error in that code. I fixed my own extension (correct API path, defensive try/catch around every fire callback) and filed the underlying gap separately (<a href="https://github.com/can1357/oh-my-pi/issues/5664"><code class="language-plaintext highlighter-rouge">can1357/oh-my-pi#5664</code></a>) since it’s a real isolation hole, not something specific to my mistake.</p>

<h2 id="whether-the-reminders-are-even-good-advice">Whether the reminders are even good advice</h2>

<p>Before shipping the wellness set as defaults, I checked whether “everyone knows” habits like the 20-20-20 rule actually hold up. Turns out it’s more mixed than folklore suggests: recent ophthalmology literature shows it measurably helps <em>accommodative</em> eye fatigue (the focusing muscles) but does little for the dry-eye/reduced-blink-rate symptoms that dominate actual digital eye strain complaints (PubMed <a href="https://pubmed.ncbi.nlm.nih.gov/35963776/">35963776</a>, <a href="https://pubmed.ncbi.nlm.nih.gov/36473088/">36473088</a>). Separately, a 2025 comparison of break-taking techniques found structured Pomodoro/Flowtime cadences outperform unstructured self-regulated breaks for sustained focus. None of that changes what I shipped — 20-20-20 is still cheap and better than nothing — but “everyone knows you should do X every 20 minutes” was worth five minutes of verification before it became a default in my own tooling.</p>

<h2 id="source">Source</h2>

<ul>
  <li><a href="https://github.com/ankitg12/session-reminder-omp"><code class="language-plaintext highlighter-rouge">session-reminder-omp</code></a> — the extension</li>
  <li><a href="https://github.com/can1357/oh-my-pi/issues/5661"><code class="language-plaintext highlighter-rouge">can1357/oh-my-pi#5661</code></a> — feature request: expose <code class="language-plaintext highlighter-rouge">shake()</code> to extensions</li>
  <li><a href="https://github.com/can1357/oh-my-pi/issues/5664"><code class="language-plaintext highlighter-rouge">can1357/oh-my-pi#5664</code></a> — bug: extension-scheduled callbacks aren’t crash-isolated</li>
</ul>
<p>&lt;/content&gt;</p>]]></content><author><name></name></author><category term="ai" /><category term="agents" /><category term="productivity" /><category term="tools" /><summary type="html"><![CDATA[I was looking for an extension to periodically remind me of things during a coding-agent session — not calendar-style reminders, but ones scoped to a live session’s wall clock. Concretely: I run /shake by hand every so often, a mechanical operation in my coding agent (OMP) that trims stale tool output and images from the live context without touching the model’s understanding of the conversation. My agent’s compaction.strategy: shake config already auto-fires that once a session crosses a token threshold — but that’s size-triggered. What I wanted was time-triggered: a nudge every 10 minutes, independent of how big the context has gotten.]]></summary></entry><entry><title type="html">A terminal theme bug that kept coming back — and why the real fix was two directories away from where I was looking</title><link href="https://ankitg12.github.io/windows/terminal/tools/2026/07/16/terminal-theme-detection-race-condition.html" rel="alternate" type="text/html" title="A terminal theme bug that kept coming back — and why the real fix was two directories away from where I was looking" /><published>2026-07-16T00:00:00+05:30</published><updated>2026-07-16T00:00:00+05:30</updated><id>https://ankitg12.github.io/windows/terminal/tools/2026/07/16/terminal-theme-detection-race-condition</id><content type="html" xml:base="https://ankitg12.github.io/windows/terminal/tools/2026/07/16/terminal-theme-detection-race-condition.html"><![CDATA[<p>The report was simple: “OMP theme has regressed again — showing black even with light theme set. We fixed this before.” A screenshot showed a cream-colored welcome banner sitting right above a command-output box with a dark maroon background and an orange border. Same terminal, same session, two different color schemes rendering side by side.</p>

<p>“We fixed this before” is a dangerous sentence to trust literally. It usually means one of two things: the same bug came back, or a <em>similar-looking</em> bug has a different cause this time. This one was the second kind, and finding that out took a longer road than it should have — mostly because I kept re-verifying an old fix instead of asking what had changed since it was applied.</p>

<h2 id="first-wrong-turn-assuming-the-fix-from-last-time-still-fits">First wrong turn: assuming the fix from last time still fits</h2>

<p>There’s an OMP extension in this setup called <code class="language-plaintext highlighter-rouge">agent-hora-omp</code> that swaps color themes based on time-of-day (built around Vedic <em>hora</em> / planetary-hour timing — a previous post covered it). It had caused a theme bug before, tied to forcing a dark palette during certain hours regardless of the configured setting. Given “we fixed this before,” it was the obvious first suspect.</p>

<p>It wasn’t. A quick check of its log file showed the last entry was ten days old. The extension hadn’t run <em>today</em> at all. Zero evidence, but I’d already spent several tool calls reading its source before checking the log timestamp — which should have been the first thing I looked at, not the third.</p>

<p><strong>Lesson:</strong> when a system has multiple moving parts capable of producing the same symptom, check <em>activity recency</em> before <em>plausibility</em>. A component that hasn’t executed today cannot be today’s root cause, no matter how good its prior track record as a suspect.</p>

<h2 id="second-wrong-turn-matching-on-symptom-text-instead-of-terminal">Second wrong turn: matching on symptom text instead of terminal</h2>

<p>A GitHub issue search turned up something that looked promising: <code class="language-plaintext highlighter-rouge">#2365 — TUI output renders with unexpected black background blocks in Ghostty</code>. Same visual symptom — a black box appearing in an otherwise-correctly-themed terminal. Before chasing the fix in that thread, the terminal in the screenshot needed a second look: it wasn’t Ghostty, it was WezTerm. Different terminal, different rendering pipeline, different bug — a coincidence of symptom, not cause.</p>

<p><strong>Lesson:</strong> “the same bug in another terminal” is a red herring until you’ve confirmed you’re looking at the same terminal. Visual symptoms (black boxes, missing colors, garbled glyphs) are terminal-agnostic descriptions of what could be several unrelated root causes underneath.</p>

<h2 id="the-actual-mechanism">The actual mechanism</h2>

<p>Once both false leads were eliminated, tracing the OMP source directly (rather than searching issue trackers for prior art) turned up the real chain:</p>

<ol>
  <li>OMP determines whether to render light or dark by calling a <code class="language-plaintext highlighter-rouge">detectTerminalBackground()</code> function with three tiers: an async OSC 11 terminal query, a <code class="language-plaintext highlighter-rouge">COLORFGBG</code> environment variable check, and a macOS-only system-appearance check.</li>
  <li>On Windows, tier 3 doesn’t apply. Tier 1 (OSC 11) is asynchronous — it sends an escape sequence and waits for the terminal to answer. If the response hasn’t arrived by the time OMP needs an answer, that tier is skipped.</li>
  <li>If <code class="language-plaintext highlighter-rouge">COLORFGBG</code> isn’t set, all three tiers come up empty and the function <strong>silently falls back to <code class="language-plaintext highlighter-rouge">"dark"</code></strong>.</li>
  <li>Most UI elements (banner text, tips) don’t paint an explicit background — they render on whatever the terminal already shows, which in this case was WezTerm’s actual light <code class="language-plaintext highlighter-rouge">GruvboxLight</code> scheme. They <em>looked</em> correctly themed by accident.</li>
  <li>The bash tool’s result box explicitly sets a background color from the resolved theme’s <code class="language-plaintext highlighter-rouge">toolErrorBg</code> token. Resolved theme: <code class="language-plaintext highlighter-rouge">dark-volcanic</code>. Its <code class="language-plaintext highlighter-rouge">toolErrorBg</code> is a dark maroon. That’s the box in the screenshot.</li>
</ol>

<p>So the terminal was light. OMP’s <em>theme resolution</em> was dark. Nothing was actually broken in the sense of a crash or a config typo — it was a startup race that sometimes loses, which is exactly the kind of bug that “gets fixed” and then reappears months later under slightly different circumstances, because the underlying race was never removed, just avoided.</p>

<h2 id="why-the-previous-fix-didnt-cover-this">Why the previous fix didn’t cover this</h2>

<p>There already was a <code class="language-plaintext highlighter-rouge">COLORFGBG</code> auto-detection block in the PowerShell profile — added specifically to give tools like OMP a deterministic signal instead of relying on the OSC 11 race. It worked. It also only checked <strong>Windows Terminal’s</strong> settings file:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="bp">$_</span><span class="n">wtCfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json"</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">COLORFGBG</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="bp">$_</span><span class="nx">wtCfg</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="bp">$_</span><span class="n">scheme</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="bp">$_</span><span class="nx">wtCfg</span><span class="w"> </span><span class="nt">-Raw</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="nf">profiles</span><span class="o">.</span><span class="nf">defaults</span><span class="o">.</span><span class="nf">colorScheme</span><span class="w">
    </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">COLORFGBG</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$_</span><span class="n">scheme</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s1">'light'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s1">'0;15'</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s1">'15;0'</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The primary terminal had since moved to WezTerm. This block was still technically working — for a terminal that wasn’t being used anymore. It never errored, never warned, just quietly detected nothing useful and let <code class="language-plaintext highlighter-rouge">COLORFGBG</code> stay unset, which is exactly the condition that lets the race matter again.</p>

<p>Compounding it: theme switching had also moved to a pair of shell functions, <code class="language-plaintext highlighter-rouge">tlight</code> / <code class="language-plaintext highlighter-rouge">tdark</code>, that hot-edit <code class="language-plaintext highlighter-rouge">.wezterm.lua</code> directly and rely on WezTerm’s live config reload:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">Set-WeztermColorScheme</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">param</span><span class="p">([</span><span class="n">Parameter</span><span class="p">(</span><span class="n">Mandatory</span><span class="o">=</span><span class="bp">$true</span><span class="p">)][</span><span class="n">string</span><span class="p">]</span><span class="nv">$Scheme</span><span class="p">)</span><span class="w">
    </span><span class="nv">$cfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="bp">$HOME</span><span class="s2">\.wezterm.lua"</span><span class="w">
    </span><span class="nv">$content</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$cfg</span><span class="w"> </span><span class="nt">-Raw</span><span class="w">
    </span><span class="nv">$updated</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$content</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s2">"config\.color_scheme\s*=\s*'[^']*'"</span><span class="p">,</span><span class="w"> </span><span class="s2">"config.color_scheme  = '</span><span class="nv">$Scheme</span><span class="s2">'"</span><span class="w">
    </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$cfg</span><span class="w"> </span><span class="nv">$updated</span><span class="w"> </span><span class="nt">-Encoding</span><span class="w"> </span><span class="nx">utf8</span><span class="w"> </span><span class="nt">-NoNewline</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This function changes what the terminal <em>looks like</em>. It never touched <code class="language-plaintext highlighter-rouge">COLORFGBG</code>, so it had zero effect on what OMP (or anything else reading that variable) believes the appearance is. Two systems, both individually correct, silently disagreeing.</p>

<h2 id="the-fix">The fix</h2>

<p>Two changes, both small:</p>

<p><strong>1. Make <code class="language-plaintext highlighter-rouge">Set-WeztermColorScheme</code> export <code class="language-plaintext highlighter-rouge">COLORFGBG</code> when it switches schemes</strong>, so future shells and tools spawned after a <code class="language-plaintext highlighter-rouge">tlight</code>/<code class="language-plaintext highlighter-rouge">tdark</code> call get a deterministic signal instead of relying on the race:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">Set-WeztermColorScheme</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">param</span><span class="p">([</span><span class="n">Parameter</span><span class="p">(</span><span class="n">Mandatory</span><span class="o">=</span><span class="bp">$true</span><span class="p">)][</span><span class="n">string</span><span class="p">]</span><span class="nv">$Scheme</span><span class="p">)</span><span class="w">
    </span><span class="nv">$cfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="bp">$HOME</span><span class="s2">\.wezterm.lua"</span><span class="w">
    </span><span class="nv">$content</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$cfg</span><span class="w"> </span><span class="nt">-Raw</span><span class="w">
    </span><span class="nv">$updated</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$content</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s2">"config\.color_scheme\s*=\s*'[^']*'"</span><span class="p">,</span><span class="w"> </span><span class="s2">"config.color_scheme  = '</span><span class="nv">$Scheme</span><span class="s2">'"</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$updated</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$content</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Write-Warning</span><span class="w"> </span><span class="s2">"No color_scheme line found in </span><span class="nv">$cfg</span><span class="s2">"</span><span class="p">;</span><span class="w"> </span><span class="kr">return</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$cfg</span><span class="w"> </span><span class="nv">$updated</span><span class="w"> </span><span class="nt">-Encoding</span><span class="w"> </span><span class="nx">utf8</span><span class="w"> </span><span class="nt">-NoNewline</span><span class="w">
    </span><span class="c"># Bypass OMP's terminal-background auto-detect (OSC 11 races on Windows) entirely.</span><span class="w">
    </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">COLORFGBG</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$Scheme</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s1">'Light'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s1">'0;15'</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s1">'15;0'</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="s2">"OK: WezTerm color scheme set to '</span><span class="nv">$Scheme</span><span class="s2">'. COLORFGBG=</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">COLORFGBG</span><span class="s2">"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>2. Make the profile’s startup detection check WezTerm’s live config first</strong>, falling back to Windows Terminal only if that’s absent:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">COLORFGBG</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="bp">$_</span><span class="n">wtLua</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="bp">$HOME</span><span class="s2">\.wezterm.lua"</span><span class="w">
    </span><span class="bp">$_</span><span class="n">wtCfg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json"</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="bp">$_</span><span class="nx">wtLua</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="bp">$_</span><span class="n">m</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Select-String</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="bp">$_</span><span class="nx">wtLua</span><span class="w"> </span><span class="nt">-Pattern</span><span class="w"> </span><span class="s2">"config\.color_scheme\s*=\s*'([^']*)'"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-First</span><span class="w"> </span><span class="nx">1</span><span class="w">
        </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$_</span><span class="n">m</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="n">scheme</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$_</span><span class="n">m.Matches</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">Groups</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="nf">Value</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="bp">$_</span><span class="n">scheme</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="bp">$_</span><span class="nx">wtCfg</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="bp">$_</span><span class="n">scheme</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="bp">$_</span><span class="nx">wtCfg</span><span class="w"> </span><span class="nt">-Raw</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="p">)</span><span class="o">.</span><span class="nf">profiles</span><span class="o">.</span><span class="nf">defaults</span><span class="o">.</span><span class="nf">colorScheme</span><span class="w">
    </span><span class="p">}</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$_</span><span class="n">scheme</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">COLORFGBG</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="bp">$_</span><span class="n">scheme</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s1">'light'</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s1">'0;15'</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s1">'15;0'</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Verified with an isolated test script against the live <code class="language-plaintext highlighter-rouge">.wezterm.lua</code> — <code class="language-plaintext highlighter-rouge">GruvboxLight</code> correctly resolves to <code class="language-plaintext highlighter-rouge">COLORFGBG=0;15</code> before OMP ever gets a chance to race its own detection.</p>

<h2 id="the-unglamorous-part">The unglamorous part</h2>

<p>Committing the fix surfaced a second, unrelated problem: the file containing <code class="language-plaintext highlighter-rouge">Set-WeztermColorScheme</code> wasn’t tracked in the dotfiles repo at all. It existed only as a plain file in <code class="language-plaintext highlighter-rouge">Documents</code>, born from a refactor that split shared aliases out of the main profile — a split that never got committed. On top of that, the profile file itself carried 89 lines of accumulated, working-but-uncommitted changes from earlier sessions. And a pre-commit secret scanner blocked the push over a hardcoded personal path in an unrelated <code class="language-plaintext highlighter-rouge">Bash</code> helper function, twice, because the same hardcoded path existed in two copies of that function in two different files.</p>

<p>None of that was the bug. All of it had to get cleared before the bug fix could actually land somewhere durable. This is the part that doesn’t show up in “here’s the one-line fix” writeups: half the actual time went to making sure the fix wouldn’t just evaporate the next time a file got regenerated from a stale, unversioned copy.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li><strong>A working detection mechanism for terminal A is not a working detection mechanism for terminal B.</strong> If a fix reads a specific application’s config file, it silently stops applying the moment the environment moves to a different application — with no error, just quiet non-coverage.</li>
  <li><strong>Env vars set by a switcher function only help processes started <em>after</em> the switch.</strong> If you want a live-editing theme switcher to actually inform other tools, it has to touch the same signal those tools read, not just the thing it visually changes.</li>
  <li><strong>“We fixed this before” is a hypothesis, not a fact.</strong> Check what changed in the environment since that fix landed before assuming it still covers the current case.</li>
  <li><strong>Untracked “helper” files accumulate real, working logic with zero backup.</strong> If a refactor moves code into a new file, that file needs to enter version control in the same breath — not after the next person trips over it being unrecoverable.</li>
</ul>

<h2 id="source">Source</h2>

<ul>
  <li>Fix committed to <code class="language-plaintext highlighter-rouge">ankitg12/dotfiles</code> (<code class="language-plaintext highlighter-rouge">Microsoft.PowerShell_profile.ps1</code>, <code class="language-plaintext highlighter-rouge">ps-shared-aliases.ps1</code>)</li>
  <li>OMP theme resolution: <code class="language-plaintext highlighter-rouge">packages/coding-agent/src/modes/theme/theme.ts</code> in <code class="language-plaintext highlighter-rouge">can1357/oh-my-pi</code></li>
  <li>Related prior post: /ai/agents/productivity/2026/07/03/hora-aware-ai-agent-omp.html</li>
</ul>]]></content><author><name></name></author><category term="windows" /><category term="terminal" /><category term="tools" /><summary type="html"><![CDATA[The report was simple: “OMP theme has regressed again — showing black even with light theme set. We fixed this before.” A screenshot showed a cream-colored welcome banner sitting right above a command-output box with a dark maroon background and an orange border. Same terminal, same session, two different color schemes rendering side by side.]]></summary></entry><entry><title type="html">Fixing a Windows→WSL shim for multi-line scripts (and a Python detour I shouldn’t have taken)</title><link href="https://ankitg12.github.io/windows/wsl/tools/2026/07/16/wu-cmd-wsl-multiline-fix.html" rel="alternate" type="text/html" title="Fixing a Windows→WSL shim for multi-line scripts (and a Python detour I shouldn’t have taken)" /><published>2026-07-16T00:00:00+05:30</published><updated>2026-07-16T00:00:00+05:30</updated><id>https://ankitg12.github.io/windows/wsl/tools/2026/07/16/wu-cmd-wsl-multiline-fix</id><content type="html" xml:base="https://ankitg12.github.io/windows/wsl/tools/2026/07/16/wu-cmd-wsl-multiline-fix.html"><![CDATA[<p><code class="language-plaintext highlighter-rouge">wu</code> is a one-line <code class="language-plaintext highlighter-rouge">.cmd</code> shim I use on Windows to run things inside WSL without typing <code class="language-plaintext highlighter-rouge">wsl -d Ubuntu-26.04 --</code> every time:</p>

<div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@echo <span class="na">off</span>
<span class="kd">wsl</span> <span class="na">-d </span><span class="kd">Ubuntu</span><span class="o">-</span><span class="m">26</span>.04 <span class="o">--</span> <span class="kd">bash</span> <span class="na">-lc </span><span class="err">%</span><span class="o">*</span>
</code></pre></div></div>

<p>It works fine for <code class="language-plaintext highlighter-rouge">wu "cd ~/repo &amp;&amp; ls"</code>. It falls apart the moment the payload is a multi-line script — a heredoc, or a file piped in with nested quotes and <code class="language-plaintext highlighter-rouge">$</code> interpolation. <code class="language-plaintext highlighter-rouge">%*</code> round-trips the whole thing through cmd.exe’s argument expansion, which mangles line breaks and quoting before bash ever sees it. The known workaround was: write the script to a Windows scratch path, <code class="language-plaintext highlighter-rouge">cp</code> it into WSL, run it, delete it. Three steps for something that should be one.</p>

<h2 id="first-instinct-reach-for-python">First instinct: reach for Python</h2>

<p>My first move was to rewrite the shim as a Python script that would base64-encode the payload, or write it to a WSL temp file via <code class="language-plaintext highlighter-rouge">mktemp</code> and <code class="language-plaintext highlighter-rouge">cat</code>, then execute that file with <code class="language-plaintext highlighter-rouge">bash</code>. I got partway into building this — argument parsing, a <code class="language-plaintext highlighter-rouge">-f/--file</code> flag, a two-step “write then exec” subprocess dance — before testing it against a real multi-line payload.</p>

<p>It didn’t work, and debugging it burned time chasing the wrong thing. <code class="language-plaintext highlighter-rouge">mktemp</code> inside a <code class="language-plaintext highlighter-rouge">bash -c '...'</code> invocation kept returning empty when I called it through my coding agent’s <code class="language-plaintext highlighter-rouge">bash</code> tool, which made me suspect a WSL interop quirk with stdin redirection. It wasn’t WSL. It was the tool: that particular <code class="language-plaintext highlighter-rouge">bash</code> tool pre-expands literal <code class="language-plaintext highlighter-rouge">$NAME</code> patterns in the command <em>string</em> itself, before any real shell sees it — even inside single quotes. <code class="language-plaintext highlighter-rouge">$f</code> and <code class="language-plaintext highlighter-rouge">$y</code> were silently evaluating to empty strings inside the harness’s own preprocessing, not inside bash. Switching to a raw subprocess call (bypassing the tool’s string substitution) immediately showed <code class="language-plaintext highlighter-rouge">mktemp</code> and command substitution working exactly as expected.</p>

<p>Once that noise was out of the way, the two-file approach <em>did</em> work — but I’d built a whole Python wrapper (argument parsing, temp-file plumbing, cleanup logic) to solve a problem that already has a built-in one-line solution.</p>

<h2 id="the-actual-fix-bash--s">The actual fix: <code class="language-plaintext highlighter-rouge">bash -s</code></h2>

<p><code class="language-plaintext highlighter-rouge">bash</code> has had first-class support for “read the whole script from stdin” since forever: <code class="language-plaintext highlighter-rouge">bash -s</code> (or <code class="language-plaintext highlighter-rouge">bash -l -s</code> for a login shell). No temp file, no round-trip, no quoting through cmd.exe at all — the script bytes go straight to bash’s stdin exactly as sent.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ cat script.sh | wsl -d Ubuntu-26.04 -- bash -l -s
</code></pre></div></div>

<p>That’s the entire trick. The shim just needs to pick this path when it’s <em>not</em> given a command-line argument:</p>

<div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@echo <span class="na">off</span>
<span class="k">if</span> <span class="s2">"</span><span class="err">%</span><span class="s2">~1"</span><span class="o">==</span><span class="s2">""</span> <span class="o">(</span>
    <span class="kd">wsl</span> <span class="na">-d </span><span class="kd">Ubuntu</span><span class="o">-</span><span class="m">26</span>.04 <span class="o">--</span> <span class="kd">bash</span> <span class="na">-l -s
</span><span class="o">)</span> <span class="k">else</span> <span class="o">(</span>
    <span class="kd">wsl</span> <span class="na">-d </span><span class="kd">Ubuntu</span><span class="o">-</span><span class="m">26</span>.04 <span class="o">--</span> <span class="kd">bash</span> <span class="na">-lc </span><span class="err">%</span><span class="o">*</span>
<span class="o">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">wu "cmd"</code> keeps working exactly as before. <code class="language-plaintext highlighter-rouge">wu &lt;&lt;'EOF' ... EOF</code> or <code class="language-plaintext highlighter-rouge">cat script.sh | wu</code> now streams the whole script through stdin, untouched:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ cat &lt;&lt;'EOF' | wu
cd ~/repo
cat &lt;&lt;'INNER' &gt; foo.txt
literal $HOME and "quotes" and `backticks` survive here
INNER
ls -la
EOF
</code></pre></div></div>

<p>Every <code class="language-plaintext highlighter-rouge">$</code>, backtick, and quote in that payload comes out the other side exactly as typed, because it’s never been command-line text — it’s just bytes on a pipe.</p>

<h2 id="the-lesson">The lesson</h2>

<p>I skipped a “does something already do this” check before writing code, and paid for it twice: once in wasted implementation effort, once in wasted debugging effort chasing a false lead (WSL stdin quirk) caused entirely by my own test harness’s string substitution, not by the thing I was actually building. The fix ended up being four lines in an existing file with zero new dependencies, because <code class="language-plaintext highlighter-rouge">bash -s</code> was already the right tool — I just hadn’t asked whether it existed before reaching for a new one.</p>

<h2 id="source">Source</h2>

<ul>
  <li>Shim: <code class="language-plaintext highlighter-rouge">~/.local/bin/wu.cmd</code> (personal dotfiles, not public)</li>
  <li><code class="language-plaintext highlighter-rouge">bash(1)</code> — <code class="language-plaintext highlighter-rouge">-s</code> flag: “If there are no operands, <code class="language-plaintext highlighter-rouge">-s</code> causes bash to read commands from standard input.”</li>
  <li><a href="https://weblog.west-wind.com/posts/2020/May/19/Using-WSL-to-Launch-Several-Bash-Commands-from-an-Application">Using WSL to Launch Several Bash Commands from an Application</a> (Rick Strahl) — same underlying problem (getting a real multi-line bash session going from a Windows host app) from a different angle.</li>
  <li><a href="https://github.com/Azure/azure-cli/issues/32644"><code class="language-plaintext highlighter-rouge">az.cmd</code> truncates multi-line arguments on Windows</a> — the exact same class of bug: a <code class="language-plaintext highlighter-rouge">.cmd</code> shim silently mangling multi-line payloads passed as command-line args, on a much bigger project.</li>
  <li><a href="https://github.com/multica-ai/multica/pull/1718">Bypass npm <code class="language-plaintext highlighter-rouge">.cmd</code> shim on Windows to preserve multi-line prompts</a> — another AI-agent tool hitting the identical Windows <code class="language-plaintext highlighter-rouge">.cmd</code>-shim-eats-newlines failure mode, fixed the same way (stop routing the payload through argv).</li>
</ul>]]></content><author><name></name></author><category term="windows" /><category term="wsl" /><category term="tools" /><summary type="html"><![CDATA[wu is a one-line .cmd shim I use on Windows to run things inside WSL without typing wsl -d Ubuntu-26.04 -- every time:]]></summary></entry><entry><title type="html">Auto-AFK: detecting real-world idle instead of typing /afk</title><link href="https://ankitg12.github.io/ai/agents/productivity/2026/07/14/auto-afk-activitywatch-omp.html" rel="alternate" type="text/html" title="Auto-AFK: detecting real-world idle instead of typing /afk" /><published>2026-07-14T00:00:00+05:30</published><updated>2026-07-14T00:00:00+05:30</updated><id>https://ankitg12.github.io/ai/agents/productivity/2026/07/14/auto-afk-activitywatch-omp</id><content type="html" xml:base="https://ankitg12.github.io/ai/agents/productivity/2026/07/14/auto-afk-activitywatch-omp.html"><![CDATA[<p><strong>Update, same day:</strong> the ActivityWatch-based design below shipped, then broke on first real use — a desktop-wide AFK signal doesn’t know which OMP session you’re paying attention to, and doesn’t know the difference between “away from keyboard” and “watching the agent work through a long multi-turn run with your hands off the keys.” Both are real bugs, not edge cases. The fix, shipped a few minutes later: drop ActivityWatch entirely, track raw terminal keystrokes <em>in this specific session</em> via OMP’s own <code class="language-plaintext highlighter-rouge">onTerminalInput</code> hook, and pause the debounce clock while the agent itself is mid-turn (<code class="language-plaintext highlighter-rouge">agent_start</code>/<code class="language-plaintext highlighter-rouge">agent_end</code>). Simpler, exact, zero external dependency. The AW-based mechanics below are left as-written for the record of how the idea evolved — see the <a href="https://github.com/ankitg12/agent-afk-omp/commit/7d08f66">commit</a> for the corrected version.</p>

<p>The <a href="/ai/agents/productivity/2026/05/27/afk-toggle-for-ai-coding-agents.html">AFK toggle</a> I built in May required remembering to type <code class="language-plaintext highlighter-rouge">/afk</code> before stepping away. Half the time I forgot — I’d just get up, and the agent would sit idle waiting for a prompt that never came. The fix isn’t a better reminder, it’s removing the step: detect that I’ve actually left the desk and flip the switch automatically.</p>

<hr />

<h2 id="the-missing-piece">The missing piece</h2>

<p><code class="language-plaintext highlighter-rouge">/afk</code> and <code class="language-plaintext highlighter-rouge">/back</code> already did everything needed once engaged — status bar indicator, autonomy prompt injection, guard against double-engagement. What was missing was the <em>trigger</em>. Manual commands only fire if you remember to fire them, and “remember to tell the computer you’re about to forget about the computer” is a bad UX pattern.</p>

<p>The obvious signal already existed on the machine: <a href="https://activitywatch.net/">ActivityWatch</a>, which I already run for time tracking, ships <code class="language-plaintext highlighter-rouge">aw-watcher-afk</code> — a small watcher that samples mouse/keyboard activity and writes <code class="language-plaintext highlighter-rouge">afk</code>/<code class="language-plaintext highlighter-rouge">not-afk</code> events to a local bucket, queryable over a REST API on <code class="language-plaintext highlighter-rouge">localhost:5600</code>. No new dependency, no new permission prompt, just a bucket that was already being written to.</p>

<hr />

<h2 id="what-it-does">What it does</h2>

<p>Same <code class="language-plaintext highlighter-rouge">/afk</code> / <code class="language-plaintext highlighter-rouge">/back</code> commands as before, plus:</p>

<ul>
  <li>A 15-second poll of ActivityWatch’s AFK bucket</li>
  <li>After a <strong>sustained 2-minute</strong> AFK reading (not the first tick — a 2-minute debounce avoids auto-engaging because you looked away to read a Slack notification for 20 seconds), it calls the exact same <code class="language-plaintext highlighter-rouge">engage()</code> path as typing <code class="language-plaintext highlighter-rouge">/afk</code></li>
  <li>The moment activity resumes, it calls the exact same <code class="language-plaintext highlighter-rouge">disengage()</code> path as <code class="language-plaintext highlighter-rouge">/back</code> — but <strong>only if the current engagement was auto-triggered</strong>. If you typed <code class="language-plaintext highlighter-rouge">/afk</code> yourself and then, say, walk to get coffee and the AFK watcher fires again, it does <em>not</em> silently disengage a session you engaged on purpose.</li>
</ul>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">engage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">:</span> <span class="nx">ExtensionContext</span><span class="p">,</span> <span class="nx">viaAuto</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">):</span> <span class="k">void</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">afkActive</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
  <span class="nx">afkActive</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
  <span class="nx">autoEngaged</span> <span class="o">=</span> <span class="nx">viaAuto</span><span class="p">;</span>
  <span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">setStatus</span><span class="p">(</span><span class="nx">STATUS_KEY</span><span class="p">,</span> <span class="nx">viaAuto</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">AFK 🔴 auto</span><span class="dl">"</span> <span class="p">:</span> <span class="dl">"</span><span class="s2">AFK 🔴</span><span class="dl">"</span><span class="p">);</span>
  <span class="nx">ctx</span><span class="p">.</span><span class="nx">ui</span><span class="p">.</span><span class="nx">notify</span><span class="p">(</span><span class="s2">`[afk] engaged</span><span class="p">${</span><span class="nx">viaAuto</span> <span class="p">?</span> <span class="dl">"</span><span class="s2"> (auto)</span><span class="dl">"</span> <span class="p">:</span> <span class="dl">""</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="dl">"</span><span class="s2">info</span><span class="dl">"</span><span class="p">);</span>
  <span class="nx">pi</span><span class="p">.</span><span class="nx">sendUserMessage</span><span class="p">(</span><span class="nx">buildAfkPrompt</span><span class="p">(</span><span class="nx">sessionId</span><span class="p">),</span> <span class="p">{</span> <span class="na">deliverAs</span><span class="p">:</span> <span class="dl">"</span><span class="s2">followUp</span><span class="dl">"</span> <span class="p">});</span>
<span class="p">}</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">poll</span><span class="p">(</span><span class="nx">ctx</span><span class="p">:</span> <span class="nx">ExtensionContext</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetchLatestAfkStatus</span><span class="p">(</span><span class="nx">config</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">afk</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">afkSince</span> <span class="o">??=</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">afkActive</span> <span class="o">&amp;&amp;</span> <span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="nx">afkSince</span> <span class="o">&gt;=</span> <span class="nx">config</span><span class="p">.</span><span class="nx">debounceMs</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">engage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">return</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="nx">afkSince</span> <span class="o">=</span> <span class="kc">undefined</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">afkActive</span> <span class="o">&amp;&amp;</span> <span class="nx">autoEngaged</span><span class="p">)</span> <span class="nx">disengage</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The manual and automatic paths share <code class="language-plaintext highlighter-rouge">engage()</code>/<code class="language-plaintext highlighter-rouge">disengage()</code> — there’s exactly one code path that does the actual work, and two ways to trigger it.</p>

<hr />

<h2 id="bucket-auto-discovery-not-a-hardcoded-name">Bucket auto-discovery, not a hardcoded name</h2>

<p>ActivityWatch names buckets <code class="language-plaintext highlighter-rouge">&lt;watcher&gt;_&lt;hostname&gt;</code> — e.g. <code class="language-plaintext highlighter-rouge">aw-watcher-afk_MYMACHINE</code>. Hardcoding that string ties the extension to one machine. Instead, the extension queries <code class="language-plaintext highlighter-rouge">GET /api/0/buckets/</code> once, finds whichever bucket has <code class="language-plaintext highlighter-rouge">type: "afkstatus"</code> (the type is stable; the name isn’t), and caches the result for the life of the process:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">let</span> <span class="nx">discoveredBucket</span><span class="p">:</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">discoverAfkBucket</span><span class="p">(</span><span class="nx">baseUrl</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">discoveredBucket</span><span class="p">)</span> <span class="k">return</span> <span class="nx">discoveredBucket</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">baseUrl</span><span class="p">}</span><span class="s2">/`</span><span class="p">);</span>
  <span class="kd">const</span> <span class="na">buckets</span><span class="p">:</span> <span class="nx">unknown</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">value</span> <span class="k">of</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">values</span><span class="p">(</span><span class="nx">buckets</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">isAwBucketInfo</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">value</span><span class="p">.</span><span class="kd">type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">afkstatus</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">discoveredBucket</span> <span class="o">=</span> <span class="nx">value</span><span class="p">.</span><span class="nx">id</span><span class="p">;</span>
      <span class="k">return</span> <span class="nx">discoveredBucket</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One request per process lifetime, not one per poll — at a 15-second poll interval that’s the difference between 1 request/day and ~5,760.</p>

<hr />

<h2 id="the-mistake-worth-writing-down">The mistake worth writing down</h2>

<p>I built the first version of this from scratch — new repo, new poller, new prompt injection — without checking whether <code class="language-plaintext highlighter-rouge">agent-afk-omp</code> already existed. It did: five commits, already deployed in my OMP config, with the exact <code class="language-plaintext highlighter-rouge">/afk</code>/<code class="language-plaintext highlighter-rouge">/back</code> mechanism this post’s predecessor documented. I overwrote its <code class="language-plaintext highlighter-rouge">agent-afk.ts</code> in place before noticing.</p>

<p>The recovery: <code class="language-plaintext highlighter-rouge">git show HEAD:agent-afk.ts</code> to pull the original content back, then a manual merge — keep every original function and command signature untouched, add the poller as a new trigger path into the <em>same</em> <code class="language-plaintext highlighter-rouge">engage</code>/<code class="language-plaintext highlighter-rouge">disengage</code> functions rather than replacing them. Nothing about the original interface changed; <code class="language-plaintext highlighter-rouge">/afk</code> and <code class="language-plaintext highlighter-rouge">/back</code> behave identically to how they did in May.</p>

<p>The actual lesson isn’t “check git log before writing code” (obviously true, not interesting). It’s that <strong>“build X” and “add X to what already exists” are different requests, and the difference only shows up if you check for existing work before deciding which one you’re doing.</strong> I had a note (<code class="language-plaintext highlighter-rouge">~/Notes/omp-extensions.md</code>) that would have answered this in one line and didn’t read it first.</p>

<hr />

<h2 id="source">Source</h2>

<ul>
  <li><a href="https://github.com/ankitg12/agent-afk-omp">agent-afk-omp</a> — the extension, now with auto-detection</li>
  <li><a href="/ai/agents/productivity/2026/05/27/afk-toggle-for-ai-coding-agents.html">AFK toggle for AI coding agents</a> — the original manual version</li>
  <li><a href="https://activitywatch.net/">ActivityWatch</a> — the AFK-detection source, already running for time tracking</li>
</ul>]]></content><author><name></name></author><category term="ai" /><category term="agents" /><category term="productivity" /><summary type="html"><![CDATA[Update, same day: the ActivityWatch-based design below shipped, then broke on first real use — a desktop-wide AFK signal doesn’t know which OMP session you’re paying attention to, and doesn’t know the difference between “away from keyboard” and “watching the agent work through a long multi-turn run with your hands off the keys.” Both are real bugs, not edge cases. The fix, shipped a few minutes later: drop ActivityWatch entirely, track raw terminal keystrokes in this specific session via OMP’s own onTerminalInput hook, and pause the debounce clock while the agent itself is mid-turn (agent_start/agent_end). Simpler, exact, zero external dependency. The AW-based mechanics below are left as-written for the record of how the idea evolved — see the commit for the corrected version.]]></summary></entry></feed>