<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jerome</title>
    <description>The latest articles on DEV Community by Jerome (@jeromefromhk).</description>
    <link>https://dev.to/jeromefromhk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024992%2F863898dc-f71f-4d89-b3d2-bf82893e303b.jpg</url>
      <title>DEV Community: Jerome</title>
      <link>https://dev.to/jeromefromhk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeromefromhk"/>
    <language>en</language>
    <item>
      <title>236 Tests Passed. The Release Was Still Broken.</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Fri, 24 Jul 2026 05:31:18 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/236-tests-passed-the-release-was-still-broken-5j8</link>
      <guid>https://dev.to/jeromefromhk/236-tests-passed-the-release-was-still-broken-5j8</guid>
      <description>&lt;p&gt;I fixed a small timezone bug, ran the suite, and watched all 236 tests go green. Out of habit more than doubt, I pushed to CI before tagging the release. CI went red — and then it went redder. That one small fix had two more bugs stacked underneath it, and every one of them was invisible on the machine where I'd just watched the tests pass.&lt;/p&gt;

&lt;p&gt;The setting is a tool I maintain called claude-code-notify: it pings me when Claude Code hits a usage limit, and again when the limit resets. You don't need to know the tool. The bug is an ordinary timezone bug, and what it cost me is a lesson about tests — about how a completely green local suite can be lying to your face.&lt;/p&gt;

&lt;p&gt;The original bug lived in the part that computes &lt;em&gt;when&lt;/em&gt; the limit resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Right only by coincidence
&lt;/h2&gt;

&lt;p&gt;When Claude Code tells you a limit will reset, it writes the time into the message as plain text, with the zone in parentheses: &lt;code&gt;resets 5:20am (Asia/Hong_Kong)&lt;/code&gt;. The function that read that time, &lt;code&gt;parse_reset()&lt;/code&gt;, pulled out the hour and minute and did all the arithmetic in the host machine's local time. It never looked at the zone in the parentheses.&lt;/p&gt;

&lt;p&gt;The host machine, in my case, is a remote server pinned to Hong Kong time. So the zone Claude Code embedded and the zone my code silently assumed were the same zone — Asia/Hong_Kong on both sides — and the answer came out right every single time. Not because the code was correct. Because two timezones I'd never thought to compare happened to be identical. Run the same tool on a laptop in London, or catch a reset that Claude Code reports in another zone, and the "your limit resets at…" ping lands hours off, with no exception and no log line to explain it — just a confident, wrong time.&lt;/p&gt;

&lt;p&gt;The fix was small: capture the zone name out of the text and resolve it through Python's stdlib &lt;code&gt;zoneinfo&lt;/code&gt;, falling back to host-local time only when the zone is missing or unresolvable — which is exactly what the code used to do always, now demoted to a deliberate last resort. The tests passed. This is the point where I should have been suspicious and wasn't, because locally there was nothing to be suspicious of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that couldn't fail
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ubuntu-latest&lt;/code&gt; runs in UTC. The first CI run came back &lt;code&gt;4 failed, 232 passed&lt;/code&gt;, and two of the failures were in tests I hadn't touched at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;tests/test_usagelimit.py::test_parse_reset_returns_next_local_occurrence FAILED
tests/test_usagelimit.py::test_parse_reset_rolls_to_tomorrow_when_past FAILED
E   assert (13, 0) == (21, 0)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both checked &lt;code&gt;parse_reset()&lt;/code&gt; against &lt;code&gt;resets 9pm (Asia/Hong_Kong)&lt;/code&gt;. Both built the "current time" they fed in from a plain &lt;code&gt;datetime&lt;/code&gt; — read as host-local — and then verified the parser's answer by converting it back, also as host-local. The same ambient timezone sat on both sides of the assertion.&lt;/p&gt;

&lt;p&gt;Under the old, buggy &lt;code&gt;parse_reset()&lt;/code&gt;, which also worked in host-local time, that symmetry was fatal in a quiet way: feed in host-local, compute in host-local, read back in host-local, and the timezone cancels straight out of the equation. The two sides matched on every machine on earth. The test read like it was verifying timezone handling. It was verifying that a number equals itself. It could not fail — not on my box, not on anyone's.&lt;/p&gt;

&lt;p&gt;Bug 1's fix broke the symmetry, which is the only reason any of this surfaced. &lt;code&gt;parse_reset()&lt;/code&gt; now resolves 9pm Hong Kong to a real instant, the same instant no matter what the host clock says. On my Hong Kong machine, reading that instant back as host-local still shows 21:00 — still green. On the UTC runner, the identical instant reads back as 13:00, because 9pm in Hong Kong &lt;em&gt;is&lt;/em&gt; 1pm UTC. &lt;code&gt;(13, 0) == (21, 0)&lt;/code&gt;: an eight-hour gap that appears the moment the code runs somewhere my clock doesn't.&lt;/p&gt;

&lt;p&gt;This is the one to keep. A test that derives its expected value from the same environment the code reads is self-consistent, and on a green dashboard self-consistent is indistinguishable from correct. It passes, so you trust it, and it is checking nothing. The repair is to anchor the expectation to something external and explicit — both the input time and the assertion pinned to &lt;code&gt;zoneinfo.ZoneInfo("Asia/Hong_Kong")&lt;/code&gt; instead of "whatever this machine calls local." I re-ran it under &lt;code&gt;TZ=UTC&lt;/code&gt;, &lt;code&gt;TZ=America/Los_Angeles&lt;/code&gt;, and &lt;code&gt;TZ=Pacific/Kiritimati&lt;/code&gt; before pushing again, so the test now asserts a fact about the world instead of a fact about my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests didn't fail the way the code would
&lt;/h2&gt;

&lt;p&gt;Second push. Red again, and differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;tests/test_usagelimit.py::test_parse_reset_uses_reported_timezone_not_host FAILED
E   ModuleNotFoundError: No module named 'zoneinfo'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zoneinfo&lt;/code&gt; only entered the standard library in Python 3.9. The tool supports 3.8 — &lt;code&gt;pyproject.toml&lt;/code&gt; declares &lt;code&gt;requires-python = "&amp;gt;=3.8"&lt;/code&gt; — and the CI matrix runs 3.8 on purpose. The production code already accounted for that: the import of &lt;code&gt;ZoneInfo&lt;/code&gt; sits behind a &lt;code&gt;try/except ImportError&lt;/code&gt;, and when it fails the code falls back to host-local time, the same fallback Bug 1's fix leans on. It bends.&lt;/p&gt;

&lt;p&gt;The tests didn't bend. The four that exercise timezone resolution — the two I'd just anchored, plus two I'd added alongside the fix — each did a bare &lt;code&gt;from zoneinfo import ZoneInfo&lt;/code&gt;. On 3.8 that isn't a graceful fallback; it's a hard &lt;code&gt;ModuleNotFoundError&lt;/code&gt; before the test body even runs. The code under test degraded, and the tests meant to cover it snapped instead.&lt;/p&gt;

&lt;p&gt;The fix is one line apiece — &lt;code&gt;pytest.importorskip("zoneinfo")&lt;/code&gt; — which skips the test cleanly when the module isn't there. The principle behind it is duller than either earlier bug and matters more than both: a test has to degrade the same way the code it covers degrades. If production tolerates a missing optional dependency, a test that hard-requires that dependency isn't testing production. It's testing a stricter promise the code never made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red that meant something, and red that didn't
&lt;/h2&gt;

&lt;p&gt;One more job in that run was red, and it was bait. &lt;code&gt;macos-latest&lt;/code&gt; on Python 3.8 showed as failed, sitting right next to the genuine 3.8 failure on Ubuntu — close enough to look like the same bug. It wasn't. Its log said &lt;code&gt;The operation was canceled&lt;/code&gt;, at the &lt;code&gt;setup-python&lt;/code&gt; step, before a single test ran. When one leg of a matrix fails, GitHub Actions cancels the rest by default, and my workflow never turns that off — so the real Ubuntu failure dragged the entire macOS column down with it, 3.8, 3.11 and 3.12 all cancelled, not one of them an actual bug. Chase that red as a fourth problem and you lose an afternoon debugging a cancellation. Telling a real failure apart from fail-fast noise is its own step; the color alone won't do it for you.&lt;/p&gt;

&lt;p&gt;Line the three real bugs up and they rhyme. Bug 1 needs a host whose timezone differs from the reported one. So does Bug 2. Bug 3 needs a Python without &lt;code&gt;zoneinfo&lt;/code&gt;. My dev machine is in Hong Kong, on Python 3.12 with &lt;code&gt;zoneinfo&lt;/code&gt; built in — the one configuration on which not a single one of the three can happen. I didn't get unlucky and overlook them. The machine I built on was, structurally, the exact machine where all three were invisible. Every green run I'd trusted was true and worthless in the same breath: the code agreed with the computer that ran it. That is the whole of what a green suite proves. Whether the code is &lt;em&gt;correct&lt;/em&gt; is a separate claim, and the space between those two claims is precisely where a machine's timezone, its Python version, and its installed modules go to hide.&lt;/p&gt;

&lt;p&gt;The fix that actually mattered wasn't any of the three patches. It was running the matrix before tagging, instead of reading "236 passed" on one box as if it meant "shippable." Any of the three patches was ten minutes' work; the step I'd been skipping was the cheap, redundant-feeling one — letting a machine that isn't yours try the code before you call it done.&lt;/p&gt;

&lt;p&gt;The tool is claude-code-notify — MIT-licensed, at &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;github.com/Jeromefromcn/claude-code-notify&lt;/a&gt;, correct reset timezones now included. If Claude Code's usage limits have ever cost you an afternoon, it might be worth a look. And if you'd rather read the bugs than the writeup, they're three commits in the history — one per bug.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ci</category>
      <category>python</category>
      <category>timezones</category>
    </item>
    <item>
      <title>Claude Code Hit Its Limit — and the Notification Never Came.</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Fri, 24 Jul 2026 05:30:57 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/claude-code-hit-its-limit-and-the-notification-never-came-4dd4</link>
      <guid>https://dev.to/jeromefromhk/claude-code-hit-its-limit-and-the-notification-never-came-4dd4</guid>
      <description>&lt;p&gt;A while back I wrote about &lt;code&gt;claude-code-notify&lt;/code&gt;, a small tool I built because I kept losing time to work I wasn't watching. It pings me on Telegram when a Claude Code turn finishes, when it needs my input, or when it dies with an error — so I can kick off a long task, switch windows, and stop thinking about it. The push reaches me whichever session has focus; being reachable when I'm &lt;em&gt;not&lt;/em&gt; looking at the terminal is the whole point.&lt;/p&gt;

&lt;p&gt;One case slipped through: the usage limit.&lt;/p&gt;

&lt;p&gt;Hit your account's usage limit and every running session stops at once. The tool did fire — my error hook caught the dead turn and sent its generic "stopped with error" ping. But that ping tells you &lt;em&gt;something&lt;/em&gt; broke, not that it was a limit, and not when it'll lift. Then a few hours later the limit quietly resets and nothing fires at all. So the shape was always the same: hit the limit, get a vague error, put the phone down, forget. By the time I wandered back, the limit had been open for who-knows-how-long — the exact dead time the tool exists to kill.&lt;/p&gt;

&lt;p&gt;So I set out to add the one notification that would close the gap: a limit-specific message — something like &lt;em&gt;usage limit reached, resets 5:20am&lt;/em&gt; — and a second ping when it actually reset.&lt;/p&gt;

&lt;p&gt;It should have been an afternoon. Instead, just to add this one notification, I fell into a string of silent failures: the tool breaking in the one way a notification tool can't afford to, and never telling me it had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version that never fired
&lt;/h2&gt;

&lt;p&gt;I wired it up the way you'd expect. On the error hook, read the session transcript, find the last assistant message, check whether it's a rate-limit envelope — Claude Code tags those with &lt;code&gt;isApiErrorMessage: true&lt;/code&gt; and &lt;code&gt;error: "rate_limit"&lt;/code&gt; — and if so, pull the reset time out of the text and send the limit-specific ping. I tested it against a saved transcript, it worked, I shipped it.&lt;/p&gt;

&lt;p&gt;Then I hit two real usage limits over the next week. Both times: the generic "stopped with error" ping, and nothing else. No limit-specific message, no reset ping. The new detection had run and decided, both times, that this was &lt;em&gt;not&lt;/em&gt; a usage limit — on a turn that had died of nothing else.&lt;/p&gt;

&lt;p&gt;The generic ping was the tell. It's the consolation prize of a detection that has silently failed: the turn errored, so the old error hook fired regardless, which meant every real miss still looked like &lt;em&gt;a&lt;/em&gt; notification arrived. I only noticed anything was wrong because I knew a limit-specific message was supposed to be there and wasn't.&lt;/p&gt;

&lt;p&gt;So I added debug logging around the transcript read and waited to hit the limit again. When I did, the log showed the read returning nothing — no rate-limit envelope in the transcript at the moment my hook looked. Then I compared the read timestamp in my log against the file's own modification time. In that one incident, my code had read the transcript at &lt;code&gt;00:13:14.735881&lt;/code&gt;; the file's mtime was &lt;code&gt;00:13:14.755575&lt;/code&gt;. I had read it 19.7 milliseconds &lt;em&gt;before&lt;/em&gt; Claude Code finished writing it. One measurement, one incident — I don't know the distribution — but the direction was unambiguous: the read beat the write.&lt;/p&gt;

&lt;p&gt;Here's the mechanism, and it's the whole article. &lt;code&gt;transcript_path&lt;/code&gt; in the hook payload guarantees the file &lt;em&gt;exists&lt;/em&gt;. It does not guarantee the current turn is &lt;em&gt;in&lt;/em&gt; it. Claude Code writes the transcript asynchronously, and the &lt;code&gt;StopFailure&lt;/code&gt; hook can fire before the rate-limit envelope has been flushed to disk — the same local disk, same machine, no network anywhere in it. My detection was reading a file Claude Code hadn't finished writing, seeing no rate-limit envelope, classifying that as "not a limit," and moving on. Correct, given what was on disk. Useless, given what was true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patch, and the fix I should have started with
&lt;/h2&gt;

&lt;p&gt;The first fix was the obvious one: if the first read comes up empty, wait and read again. One retry at 200 milliseconds — &lt;code&gt;_STOP_FAILURE_RETRY_DELAYS = (0.2,)&lt;/code&gt;, roughly ten times the gap I'd measured — gave the write enough slack to land by the second look. It worked; the next few limits sent proper reset pings.&lt;/p&gt;

&lt;p&gt;But a 200ms sleep to paper over a race I didn't fully understand is a patch, not an answer. Before building anything else on top of it, I went and read Claude Code's official hooks documentation — which I should have done before writing a line of transcript-parsing code, because the answer was sitting in it twice over.&lt;/p&gt;

&lt;p&gt;The race is documented behavior, not something I'd stumbled into. The reference says the transcript file "is written asynchronously and may lag the in-memory conversation, so it may not yet include the current turn's most recent messages when a hook fires," and it says what to do instead: use &lt;code&gt;last_assistant_message&lt;/code&gt; "instead of reading the transcript." There's even a public issue for the exact symptom — &lt;a href="https://github.com/anthropics/claude-code/issues/15813" rel="noopener noreferrer"&gt;anthropics/claude-code#15813&lt;/a&gt; — though a stale bot closed it for inactivity rather than any fix, so this is just how it works: documented, and unowned.&lt;/p&gt;

&lt;p&gt;The bigger miss was the part I'd assumed away. &lt;code&gt;StopFailure&lt;/code&gt; hands you the error &lt;em&gt;in the payload&lt;/em&gt;. It arrives in the hook's stdin JSON, right next to &lt;code&gt;transcript_path&lt;/code&gt;: a structured &lt;code&gt;error&lt;/code&gt; field drawn from a fixed enum — &lt;code&gt;rate_limit&lt;/code&gt; is one of the values — plus &lt;code&gt;last_assistant_message&lt;/code&gt;, which on an error turn holds the API error string itself. No file, no flush, no race. I'd built the entire transcript-reading path on the belief that the hook gave me nothing structured and I'd have to reconstruct the error myself. It had been handing it to me the whole time.&lt;/p&gt;

&lt;p&gt;So I flipped the priority. The payload is the primary source now: if &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;rate_limit&lt;/code&gt; and &lt;code&gt;last_assistant_message&lt;/code&gt; is there, classify and send, with no disk read at all. The transcript read, retry and all, stays only as the fallback for the edge the payload doesn't cover — the racy path runs when it has to and never when it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it fired when it shouldn't have
&lt;/h2&gt;

&lt;p&gt;The classifier had a second bug waiting, and this one broke in the opposite direction: one day it sent me a usage-limit notification that was a lie.&lt;/p&gt;

&lt;p&gt;The turn hadn't hit my account's usage limit at all. It had tried to use Fable 5 — a model that bills against its own usage-credits balance instead of the subscription allowance — without credits. Claude Code's message said so plainly: &lt;em&gt;Fable 5 requires usage credits. Run /usage-credits to continue or switch models with /model.&lt;/em&gt; My tool pinged "usage limit reached" anyway.&lt;/p&gt;

&lt;p&gt;It fooled the classifier because Claude Code tags this error with the &lt;em&gt;same&lt;/em&gt; fields a real limit uses. Same &lt;code&gt;isApiErrorMessage: true&lt;/code&gt;, same &lt;code&gt;error: "rate_limit"&lt;/code&gt;, same &lt;code&gt;429&lt;/code&gt; status. If your rule is "it's a limit when &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;rate_limit&lt;/code&gt;" — which was exactly my rule, on both the payload and the transcript path — a per-model credits gate is indistinguishable from an account limit. That one field is doing double duty for two conditions that mean completely different things to me.&lt;/p&gt;

&lt;p&gt;What set them apart sat a level deeper. On disk, the genuine limits I'd inspected carried no &lt;code&gt;errorDetails&lt;/code&gt; at all; the credits error carried one, with a structured code inside it: &lt;code&gt;error.details.error_code == "credits_required"&lt;/code&gt;. That field, and only that field, is the ground truth.&lt;/p&gt;

&lt;p&gt;So the fix is one extra clause, on both paths: it's a usage limit only when &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;rate_limit&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; the error body isn't a &lt;code&gt;credits_required&lt;/code&gt; error. It keys on the structured code, never on the words "usage credits" in the message — text-matching is how you build a classifier that breaks the moment the wording changes, and not doing it is a standing rule in this project. The most specific structured field wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both failures had in common
&lt;/h2&gt;

&lt;p&gt;Put the two bugs side by side. One never fired; one fired when it shouldn't. Opposite symptoms — the same mistake. Both are what happens when you read one ambiguous, half-documented signal that another process hands you and trust your first reading of it. The miss trusted an empty file. The false alarm trusted an overloaded field. Wiring the hook was never the hard part; classifying the signal on the other end was the entire job.&lt;/p&gt;

&lt;p&gt;And here's what stings: neither bug was catchable by the test suite as it stood. My fixtures encoded the happy path — a fully-written transcript with a clean rate-limit envelope. A fixture is an already-written file, so it can't reproduce a race against a file still being written. And I had no credits-error fixture at all, because I didn't know that error existed until Claude Code sent me one. The tests were green the whole time. They were confirming that my code agreed with my assumptions, which is a different thing from confirming it was right.&lt;/p&gt;

&lt;p&gt;Both bugs surfaced the same way: a real event I couldn't have predicted, plus enough debug logging already in place to see what actually happened when it hit. The retry, the payload switch, the credits check — those were the easy part once I could see the signal. The discipline that made them possible was instrumenting the thing before I understood it, so the next strange event would leave a trace instead of a shrug. That, more than any single fix, is the skill.&lt;/p&gt;

&lt;p&gt;The worked-out version — retry, payload-first, the credits check — lives in &lt;code&gt;claude-code-notify&lt;/code&gt; on GitHub, MIT-licensed: &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;github.com/Jeromefromcn/claude-code-notify&lt;/a&gt;. What I'd still like to collect is other people's version of the same story: a notification that fired for the wrong reason, or stayed quiet for the right one. That's usually where the interesting bug is hiding — in the signal you trusted without reading twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>debugging</category>
      <category>cli</category>
      <category>automation</category>
    </item>
    <item>
      <title>Every Field Was Correct. It Still Wouldn't Connect.</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Thu, 23 Jul 2026 05:13:28 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/every-field-was-correct-it-still-wouldnt-connect-44ik</link>
      <guid>https://dev.to/jeromefromhk/every-field-was-correct-it-still-wouldnt-connect-44ik</guid>
      <description>&lt;p&gt;The VPN I share with a few other people ran out of traffic for the month. I had an idle OCI ARM instance sitting there doing nothing, so I decided to stop waiting and build my own node: one VLESS + Reality endpoint, self-hosted, good enough for personal use through Clash Verge. I drove the whole thing with Claude — chatted through the plan, had Claude Code do the Docker deploy, then went back to chat to walk the panel config step by step. The build was an afternoon. The debugging was the rest of the evening, and every minute of it was spent on failures where the configuration was, provably, correct.&lt;/p&gt;

&lt;p&gt;That's the part worth writing down. Not the happy path — the four traps, and especially the last one, where every field matched on every screen and it still refused to connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standing up the panel
&lt;/h2&gt;

&lt;p&gt;The management panel is &lt;a href="https://github.com/MHSanaei/3x-ui" rel="noopener noreferrer"&gt;3x-ui&lt;/a&gt;, run as a container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /etc/x-ui:/etc/x-ui &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 443:443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 2096:2096 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 54321:2053 &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/mhsanaei/3x-ui:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things here are not optional. Persist &lt;code&gt;/etc/x-ui&lt;/code&gt; to the host, or every container recreate wipes your config. Set a restart policy (&lt;code&gt;unless-stopped&lt;/code&gt; or &lt;code&gt;always&lt;/code&gt;) so the node comes back on its own after a reboot — a proxy you have to SSH in and restart by hand is a proxy you'll abandon. And map only the ports you actually use with &lt;code&gt;-p&lt;/code&gt;: the node (&lt;code&gt;443&lt;/code&gt;), the subscription port (&lt;code&gt;2096&lt;/code&gt; here), and the panel. After it's running, &lt;code&gt;docker ps&lt;/code&gt; and confirm every one shows up in the PORTS column.&lt;/p&gt;

&lt;p&gt;The moment it's up, before anything else: log in and change the default credentials. This panel is exposed to the public internet, and default logins are exactly what scanners look for. While you're in there, move the panel off its default &lt;code&gt;2053&lt;/code&gt; to a random port — that's the &lt;code&gt;54321:2053&lt;/code&gt; mapping above — and consider changing its base access path. None of this is real security on its own; it just takes you out of the easy-target bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two firewalls, or nothing works
&lt;/h2&gt;

&lt;p&gt;On a normal VPS this is one step. On OCI it's two, and missing either one gives you the &lt;em&gt;identical&lt;/em&gt; symptom — a plain connection timeout — which makes it genuinely hard to tell which layer is at fault.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OCI's own network layer.&lt;/strong&gt; In the console, add ingress rules to the instance's Security List (or NSG) for every port you need: TCP, source &lt;code&gt;0.0.0.0/0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The instance's host firewall.&lt;/strong&gt; Ubuntu's &lt;code&gt;ufw&lt;/code&gt; is separate and just as real:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp     &lt;span class="c"&gt;# the node&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 2096/tcp    &lt;span class="c"&gt;# subscription port, etc.&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open one and not the other and you'll spend twenty minutes &lt;code&gt;curl&lt;/code&gt;-ing from the wrong side of the problem. Open both first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inbound — and the button that rewrites your config
&lt;/h2&gt;

&lt;p&gt;Create a VLESS inbound on port &lt;code&gt;443&lt;/code&gt; (blending in with ordinary HTTPS). Most defaults are fine. Two tabs need care.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Trap 1 — the Generate button has a side effect.&lt;/strong&gt; The Protocol tab has Decryption/Encryption fields; for standard VLESS both should read &lt;code&gt;none&lt;/code&gt;. But clicking &lt;strong&gt;Generate&lt;/strong&gt; to create the Reality X25519 key pair &lt;em&gt;also&lt;/em&gt; silently overwrites those two fields with an experimental post-quantum encryption string — something like &lt;code&gt;mlkem768x25519plus.native.600s...&lt;/code&gt;. This is a newer Xray feature, and Mihomo (the core inside Clash Verge) can't parse it; it errors out with &lt;code&gt;invaild vless encryption value&lt;/code&gt; (yes, misspelled that way in Mihomo itself). The fix: after you generate the Reality keys, go back and manually set Decryption and Encryption to &lt;code&gt;none&lt;/code&gt;. And don't click Generate again afterward — it re-overwrites the fields &lt;em&gt;and&lt;/em&gt; regenerates your key pair, which just adds a fresh variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decoy site that's too big
&lt;/h2&gt;

&lt;p&gt;On the Security tab, set the security type to &lt;strong&gt;Reality&lt;/strong&gt; and pick a camouflage target — a real TLS 1.3 site your handshake will impersonate. Set uTLS to &lt;code&gt;chrome&lt;/code&gt;; leave short IDs and SpiderX auto-generated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Trap 2 — some targets are literally too large.&lt;/strong&gt; With &lt;code&gt;www.microsoft.com&lt;/code&gt; as the target, every parameter matched and the keys were confirmed identical, but the server log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REALITY: processed invalid connection ... handshake did not complete successfully
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't your config. Xray-core reads the target's TLS Certificate record during the Reality handshake and rejects anything over a hardcoded 8192 bytes. &lt;code&gt;www.microsoft.com&lt;/code&gt; returns a record around 8273 bytes — just over the line — so the handshake dies. It's a known, reproduced upstream limit (&lt;a href="https://github.com/XTLS/Xray-core/issues/6356" rel="noopener noreferrer"&gt;Xray-core #6356&lt;/a&gt;, &lt;a href="https://github.com/XTLS/Xray-core/issues/6402" rel="noopener noreferrer"&gt;#6402&lt;/a&gt;, &lt;a href="https://github.com/XTLS/Xray-core/discussions/6387" rel="noopener noreferrer"&gt;discussion #6387&lt;/a&gt;). The fix is to pick a target with a smaller certificate. &lt;code&gt;www.cloudflare.com&lt;/code&gt; works, and the error vanished the instant I switched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client and the subscription that Clash refuses
&lt;/h2&gt;

&lt;p&gt;On the Add Client page, attach the client to the port-443 inbound you just made, or it's linked to nothing. Then, on the Credentials tab, change &lt;strong&gt;Flow&lt;/strong&gt; from its default &lt;code&gt;None&lt;/code&gt; to &lt;code&gt;xtls-rprx-vision&lt;/code&gt; to match the server's Reality setup — leave it at &lt;code&gt;None&lt;/code&gt; and the connection behaves inconsistently or fails outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Trap 3 — Clash needs YAML, not the default link.&lt;/strong&gt; 3x-ui's default subscription link (&lt;code&gt;http://ip:port/sub/&amp;lt;id&amp;gt;&lt;/code&gt;) returns the generic Base64 format that clients like V2RayNG expect. Paste that into Clash Verge and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;the remote profile data is invalid yaml: invalid type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string, expected a YAML mapping&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clash/Mihomo needs a YAML subscription, which 3x-ui serves from a &lt;em&gt;different&lt;/em&gt; path. In panel settings, confirm the Clash/Mihomo subscription is enabled, check its URI path (usually &lt;code&gt;/clash/&lt;/code&gt;), and use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://server-ip:subscription-port/clash/&amp;lt;subscription-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import that under Profiles → New → Remote. Only this path returns parseable YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that ate the evening
&lt;/h2&gt;

&lt;p&gt;Subscription imported, node listed, everything green. Every dial failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[TCP] dial ... error: server-ip:443 connect error: REALITY authentication failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We worked it in order — I say "we" because Claude was driving the diagnosis the whole way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nc -zv server-ip 443&lt;/code&gt; from my laptop — TCP connects. Network's fine.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; from the server to the Reality target — clean 200. Target's reachable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec ... netstat -tlnp | grep 443&lt;/code&gt; — the port is listening inside the container.&lt;/li&gt;
&lt;li&gt;Then the careful part: read the actual running &lt;code&gt;config.json&lt;/code&gt; from &lt;em&gt;inside&lt;/em&gt; the container and compare Public Key, Short ID, SNI, UUID, and Flow against the panel UI and against the subscription Clash was consuming. Field by field.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything matched. An assistant that will happily read every value in every file had just confirmed all of them correct — and the connection still failed. The thing that was wrong wasn't &lt;em&gt;in&lt;/em&gt; any file.&lt;/p&gt;

&lt;p&gt;The answer was the version of the running binary. 3x-ui v3.5.0 ships Xray-core &lt;strong&gt;v26.7.11&lt;/strong&gt;, and that release is deliberately incompatible with Mihomo — a documented breaking change (a new default &lt;code&gt;minClientVer&lt;/code&gt;), not a local misconfiguration. Multiple people reproduced the exact &lt;code&gt;REALITY authentication failed&lt;/code&gt; symptom on v3.5.0, and Mihomo has stated outright it won't support Xray v26.7.11+ (&lt;a href="https://github.com/MHSanaei/3x-ui/issues/5957" rel="noopener noreferrer"&gt;3x-ui #5957&lt;/a&gt;, &lt;a href="https://github.com/MHSanaei/3x-ui/issues/5922" rel="noopener noreferrer"&gt;#5922&lt;/a&gt;; &lt;a href="https://github.com/XTLS/Xray-core/issues/6477" rel="noopener noreferrer"&gt;Xray-core #6477&lt;/a&gt;). The fix is to downgrade the core to &lt;strong&gt;v26.6.27&lt;/strong&gt;. Recreating clients, regenerating keys, rebuilding the inbound — none of it matters, because none of it touches the version.&lt;/p&gt;

&lt;p&gt;There was one more layer to it, and it's the reason the fix &lt;em&gt;looked&lt;/em&gt; like it didn't work. After downgrading, the error was still there. What I hadn't accounted for: the panel applies changes through Xray's core API and reports &lt;code&gt;config changes applied through the core API, no restart needed&lt;/code&gt;. That hot-reload path does not swap the running Xray process. The old v26.7.11 binary was still serving traffic. A full &lt;code&gt;docker restart 3x-ui&lt;/code&gt; loaded the downgraded core, and it connected immediately.&lt;/p&gt;

&lt;p&gt;So the two facts sit together. The fix was the downgrade. It only took effect on a real process restart — because the panel's "no restart needed" is true for config edits and quietly false for the one thing that actually mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;When every file says the config is correct and it still fails, stop re-reading the files. The mismatch you can't see is the process, not the config: what version is actually running, and did it actually reload. The panel telling you "no restart needed" is a claim about config edits, not a guarantee about the process — so after any change that matters, restart the container and check the running core yourself.&lt;/p&gt;

&lt;p&gt;A short checklist, in the order that would have saved me the evening:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;nc -zv server-ip port&lt;/code&gt; — reachability from the client.&lt;/li&gt;
&lt;li&gt;Confirm &lt;strong&gt;both&lt;/strong&gt; the OCI security list and the host &lt;code&gt;ufw&lt;/code&gt; allow the port.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker exec ... netstat -tlnp&lt;/code&gt; — the port is really listening inside the container.&lt;/li&gt;
&lt;li&gt;Decryption/Encryption are &lt;code&gt;none&lt;/code&gt;, not the &lt;code&gt;mlkem768...&lt;/code&gt; post-quantum string.&lt;/li&gt;
&lt;li&gt;Camouflage target has a small certificate — avoid &lt;code&gt;www.microsoft.com&lt;/code&gt;; &lt;code&gt;www.cloudflare.com&lt;/code&gt; is safe.&lt;/li&gt;
&lt;li&gt;Client Flow is &lt;code&gt;xtls-rprx-vision&lt;/code&gt;; subscription is the &lt;code&gt;/clash/&lt;/code&gt; YAML path, not &lt;code&gt;/sub/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the &lt;strong&gt;running Xray-core version&lt;/strong&gt;. If it's v26.7.11 and your client is Mihomo/Clash Verge, downgrade to v26.6.27.&lt;/li&gt;
&lt;li&gt;After any change, &lt;code&gt;docker restart&lt;/code&gt; the container — don't trust "no restart needed" — then retest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The config was never the hard part. The hard part was trusting, for one evening too long, that a correct file meant a correct process.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>docker</category>
      <category>networking</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Bug That Kept Coming Back</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:41:27 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/the-bug-that-kept-coming-back-47lp</link>
      <guid>https://dev.to/jeromefromhk/the-bug-that-kept-coming-back-47lp</guid>
      <description>&lt;p&gt;The first sign something was wrong wasn't a crash. It was a pattern.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;blockly-platform&lt;/code&gt; was the first real thing I built with Claude Code end to end — a Blockly-based platform for university programming exercises, driven entirely through Claude Code's Telegram channel. No editor open, no repo checked out on my machine, just a chat thread. I'd describe what I wanted, Claude Code would build it on a box I never looked at directly, and I'd judge the result by clicking around the deployed app.&lt;/p&gt;

&lt;p&gt;On March 22nd, the home page came up empty. &lt;code&gt;GET /api/exercises/published&lt;/code&gt; was returning 403. I said so in the chat; a few messages later, Claude Code said it was fixed — the endpoint hadn't been added to Spring Security's &lt;code&gt;permitAll()&lt;/code&gt; list. I moved on, tried the category filter. Also empty, also 403, also missing from the same &lt;code&gt;permitAll()&lt;/code&gt; list — same file, same class of fix, different line. Then the exercise detail page. Same story, third time, same day. Three days later, the like button stopped working — root cause, again: &lt;code&gt;POST /api/exercises/*/like&lt;/code&gt; had never been whitelisted either. Four times, one file, one recurring gap.&lt;/p&gt;

&lt;p&gt;None of these were hard bugs. Each one, in isolation, is a one-line fix a competent engineer makes without thinking twice. What bothered me, once I noticed the pattern, was that I hadn't noticed it &lt;em&gt;as&lt;/em&gt; it happened. I had no diff to scroll through, no file to glance at and think "wait, didn't we just fix this exact class of thing twice already?" I had a chat log and a live app to poke at. The fourth fix looked, from where I sat, exactly like the first: a message telling me it was resolved.&lt;/p&gt;

&lt;p&gt;That was the moment I started to suspect the problem wasn't the model. It was that nobody — not the model, not me — had anything to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chat-only vibe coding breaks down
&lt;/h2&gt;

&lt;p&gt;Here's what makes that pattern more interesting than "the AI made a mistake": every one of those four fixes was correct. Claude Code read the error, found the missing &lt;code&gt;permitAll()&lt;/code&gt; entry, added it, and moved on — each time, in isolation, exactly the right diagnosis and exactly the right fix. Grade any single one of those four turns on its own and it passes.&lt;/p&gt;

&lt;p&gt;What failed wasn't a single decision. It was the absence of anything connecting the four decisions to each other. A human engineer touching &lt;code&gt;SecurityConfig.java&lt;/code&gt; for the third time in a week, over the same kind of hole, stops and thinks "let me just check the whole file" — not because they're smarter than Claude Code, but because they've &lt;em&gt;been there before&lt;/em&gt; and remember it. Chat-only, Telegram-driven vibe coding doesn't give either party that continuity. Claude Code sees the current turn's error and fixes the current turn's error. I see a message saying "fixed" and move to the next thing on my list. Neither of us is holding the shape of the file over time, because neither of us is looking at the file — I'm looking at a phone screen, and the model's context is whatever's in that turn's window, not an accumulated sense that this file has a recurring problem.&lt;/p&gt;

&lt;p&gt;That's the first layer. The second layer worried me more, because it doesn't have an obvious fix inside the chat-only model at all: some of Claude Code's decisions came back to me to approve. But I hadn't read the code structure — I'd never opened the repo, never seen &lt;code&gt;SecurityConfig.java&lt;/code&gt;, never seen how exercises, submissions, and grading fit together. So when a decision &lt;em&gt;did&lt;/em&gt; land in my lap, I was approving it with roughly the same information the model had: none, beyond the current message. "Human in the loop" only adds oversight if the human has something the model doesn't. Mine didn't.&lt;/p&gt;

&lt;p&gt;To be clear about the boundary of this claim: none of it means chat-only driving is always wrong. For something small and throwaway, it's fine — there's nothing to lose track of. The failure mode shows up specifically as complexity and stakes climb, once a project has enough moving parts that "the shape of the file over time" is actually worth tracking.&lt;/p&gt;

&lt;p&gt;The repo backs this up in ways that aren't just my memory of it. &lt;code&gt;blockly-platform&lt;/code&gt; went from an empty repository to a merged, working platform in 80 commits over 23 days — fast, and it shows. There's no design doc anywhere in it, no architecture note, nothing that records why a decision was made — only a &lt;code&gt;RUNBOOK.md&lt;/code&gt;, a &lt;code&gt;TODO.md&lt;/code&gt;, and an &lt;code&gt;ERROR_LOG.md&lt;/code&gt; that reads like a list of things discovered after the fact. A &lt;code&gt;CLAUDE.md&lt;/code&gt; — the file that would have told Claude Code the project's own rules, things like "don't forget the security config when you add an endpoint" — didn't exist until April 2nd. Every dated entry in &lt;code&gt;ERROR_LOG.md&lt;/code&gt;, all eighteen of them, including all four &lt;code&gt;permitAll()&lt;/code&gt; misses, falls between March 22nd and March 28th. The guardrails arrived after the bugs they would have prevented, not before them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rebuild
&lt;/h2&gt;

&lt;p&gt;The project that replaced it, &lt;code&gt;programming-learning-platform&lt;/code&gt;, started the same day &lt;code&gt;blockly-platform&lt;/code&gt;'s pull request merged. That's not a coincidence I engineered — it's genuinely where the first project ended and the lesson from it started applying. It isn't a separate idea, either: its own &lt;code&gt;CLAUDE.md&lt;/code&gt; describes it as a platform for Blockly &lt;em&gt;and&lt;/em&gt; Python exercises, the same tool rebuilt from the ground up rather than a new one started from scratch.&lt;/p&gt;

&lt;p&gt;This time I changed two things at once. First, visibility: I moved to IntelliJ IDEA's remote development mode, with the project checked out and running on the same kind of remote box as before, but now with an actual editor attached to it — I could see the file tree, open a class, read a diff before it landed. Second, process: I turned on the &lt;code&gt;superpowers&lt;/code&gt; skill and wrote it into the project's &lt;code&gt;CLAUDE.md&lt;/code&gt; as non-negotiable — "Every task: Brainstorm → Plan → Implement (TDD). No skipping brainstorm. No code without a plan. No implementation before a failing test." — with "No skipping Superpowers" listed again under the project's explicit red lines, in case that wasn't clear enough the first time.&lt;/p&gt;

&lt;p&gt;The difference shows up in the repo's shape, not just in my memory of how it felt. &lt;code&gt;programming-learning-platform&lt;/code&gt; is 458 commits deep and still active more than three months later. It has a real &lt;code&gt;docs/&lt;/code&gt; tree: a PRD, per-role user flows, an architecture doc, feature specs split by priority — and a &lt;code&gt;docs/superpowers/plans/&lt;/code&gt; directory holding forty-four dated implementation plans, running from within its first two weeks to earlier this month, each one a record of a brainstorm-and-plan step that happened &lt;em&gt;before&lt;/em&gt; the corresponding code did. Where &lt;code&gt;blockly-platform&lt;/code&gt;'s only paper trail was a log of things that had already gone wrong, this one has a paper trail of decisions made on purpose, in order, before they were implemented.&lt;/p&gt;

&lt;p&gt;It worked, in the sense that mattered most: I stopped approving things blind, because I finally had a diff to actually read and a plan to check the diff against.&lt;/p&gt;

&lt;p&gt;It also had friction I didn't expect, and I want to be precise that this is my own experience with a specific setup at a specific time, not a verdict on the products themselves. IntelliJ IDEA's remote development client, even with the actual computation happening entirely on the server, still felt heavy on my local machine — noticeably more than I expected from something meant to be a thin window onto remote work. And the JetBrains Claude Code plugin had gaps that got in the way of the one thing visibility is supposed to buy you: clear communication. I couldn't attach an image to a message — no screenshot of a broken layout, no annotated diagram — so some things I could have shown in five seconds took several paragraphs to describe instead, with more room for the description to be wrong.&lt;/p&gt;

&lt;p&gt;Visibility and process had fixed the real problem. The tool I'd wrapped them in was still fighting me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that stopped fighting me
&lt;/h2&gt;

&lt;p&gt;A friend who'd been running a similar remote setup suggested I drop IntelliJ for VS Code partway through the project — specifically, VS Code's Remote-SSH extension to manage &lt;code&gt;programming-learning-platform&lt;/code&gt; directly on the remote box, with the Claude Code extension running against that same connection, instead of a JetBrains-specific remote protocol and its own plugin.&lt;/p&gt;

&lt;p&gt;Nothing about the visibility or the process changed, and that's the point: the file tree was still there, diffs still landed in front of me before I approved anything, and &lt;code&gt;superpowers&lt;/code&gt;' brainstorm-plan-TDD loop carried over untouched, because it lives in the project's &lt;code&gt;CLAUDE.md&lt;/code&gt;, not in whichever editor happens to be open. What changed was the tool wrapped around both of those things. The client was lighter — it stopped being noticeably heavy on my local machine the way IDEA's had been. And the Claude Code extension let me attach a screenshot directly to a message, which matters more than it sounds for a platform built around a visual block editor and a grading UI: a broken Blockly toolbox or a misaligned grading panel is a five-second screenshot and a two-word question, not three paragraphs trying to describe a layout in words.&lt;/p&gt;

&lt;p&gt;I've written before about the mechanics of &lt;em&gt;why&lt;/em&gt; this specific combination — VS Code's Remote-SSH plus the Claude Code extension — is worth trusting with long-running, walk-away-able work: &lt;a href="https://dev.to/jeromefromhk/the-question-that-disappeared-remote-claude-code-and-the-3-hour-trap-1d8c"&gt;the process survives your laptop sleeping, and the compute never touches your local machine&lt;/a&gt;. I won't re-run that ground here. What matters for this piece is narrower: this was the first setup where visibility, process, and the tool itself all pulled in the same direction, instead of two out of three fighting the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last mile
&lt;/h2&gt;

&lt;p&gt;With visibility and process both solid, the last piece was the one that let me actually step away: knowing &lt;em&gt;when&lt;/em&gt; Claude Code needed me, and being able to answer &lt;em&gt;without&lt;/em&gt; being at my desk.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;when&lt;/em&gt; is &lt;a href="https://dev.to/jeromefromhk/the-notification-that-lied-to-me-46lm"&gt;&lt;code&gt;claude-code-notify&lt;/code&gt;&lt;/a&gt;, a tool I built after getting tired of either babysitting a terminal or discovering twenty minutes later that a long TDD-driven turn had been sitting blocked on my sign-off the whole time. It pings me on Telegram at exactly the moments that matter — a turn is genuinely finished, background work included, or it needs my input, or it errored out. The &lt;em&gt;how&lt;/em&gt; is Remote Control (&lt;code&gt;/remote-control&lt;/code&gt;), which lets me approve prompts and answer questions from the Claude app on my phone instead of reopening a laptop. Get pinged that Claude needs me, answer from my pocket — that's the workflow, and &lt;a href="https://dev.to/jeromefromhk/the-question-that-disappeared-remote-claude-code-and-the-3-hour-trap-1d8c"&gt;I've written separately about the one way it can quietly fail&lt;/a&gt; if the underlying remote session doesn't survive long enough to still be there when you answer.&lt;/p&gt;

&lt;p&gt;It's worth being precise about what this layer is and isn't. It isn't what fixed the &lt;code&gt;permitAll()&lt;/code&gt; problem — visibility and &lt;code&gt;superpowers&lt;/code&gt;' plan-before-code discipline did that. What notify and Remote Control add is the ability to be &lt;em&gt;away&lt;/em&gt; from the keyboard without losing either one: I still see the diff, Claude Code still follows the plan, I just don't have to be sitting there for either to keep happening. Bolt the same walk-away layer onto the Telegram-only setup from the first project, and it wouldn't have fixed anything — you'd just get notified faster that a decision you couldn't evaluate had already been made. The order matters: visibility and process first, walk-away convenience on top of that foundation, not instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, one more time
&lt;/h2&gt;

&lt;p&gt;I sometimes wonder what would have happened if the fourth &lt;code&gt;permitAll()&lt;/code&gt; miss — the one on the like button — had happened under the later setup instead of the first one. My honest guess: it still would have gotten written, because forgetting a security rule on a new endpoint is an easy thing for anyone, human or model, to do once. But it wouldn't have taken three prior occurrences to catch, because a plan step would have said "add the endpoint and its security rule" as one unit, and a diff would have shown both halves — or shown only one, right there, before I approved it. The bug wouldn't have needed a human to notice a &lt;em&gt;pattern&lt;/em&gt; across a week of chat messages. It would have needed a human, or a model, to notice a &lt;em&gt;gap in one diff&lt;/em&gt; — a much smaller ask, and one that visibility actually makes possible.&lt;/p&gt;

&lt;p&gt;That's the whole shift, underneath all the tool names. Pure chat-only vibe coding doesn't fail because the model is bad at its job — every individual &lt;code&gt;permitAll()&lt;/code&gt; fix in &lt;code&gt;blockly-platform&lt;/code&gt; was correct. It fails because past a certain size, nobody involved, model or human, has enough in front of them to notice the second time a mistake happens, let alone stop it before the fourth. Visibility and a process that makes you plan before you build don't make Claude Code smarter. They give whoever's watching, model or human, something to actually watch.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>agentic</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Question That Disappeared: Remote Claude Code and the 3-Hour Trap</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Mon, 13 Jul 2026 17:01:35 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/the-question-that-disappeared-remote-claude-code-and-the-3-hour-trap-1d8c</link>
      <guid>https://dev.to/jeromefromhk/the-question-that-disappeared-remote-claude-code-and-the-3-hour-trap-1d8c</guid>
      <description>&lt;p&gt;The task had been running a while on my remote box — one of those long, multi-step jobs I kick off and step away from — and I was somewhere else entirely when my phone buzzed. It was a Telegram ping from a small notifier I'd built, &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;claude-code-notify&lt;/a&gt;, which reaches me whenever a Claude Code turn needs my input, finishes, or dies — so I don't have to sit and watch. This one was the first kind: Claude had hit a decision it couldn't make alone and stopped to ask.&lt;/p&gt;

&lt;p&gt;I didn't catch the ping in time. When I finally saw it, I reached for my phone to answer the way I'd planned: this session had Remote Control turned on, so I should have been able to reply straight from the Claude app. But the session there was already &lt;code&gt;disconnected&lt;/code&gt; — nothing to type into. So I went back to the VS Code extension panel on my laptop, reconnected, and the question wasn't there either. When the session picked back up it never asked again; it had quietly stepped over the gap where I was supposed to be and kept going. Not answered — gone, with no record in the history of what it had decided. The only proof the moment had ever existed was that notification still sitting on my phone — &lt;em&gt;Claude needs you&lt;/em&gt; — pointing at a question that no longer had anywhere to be answered.&lt;/p&gt;

&lt;p&gt;That mismatch is what nagged at me. I knew, from the ping, that an exchange had happened and that I'd missed my half of it — but I couldn't find the exchange anywhere to see what I'd missed. It took me the better part of a day to work out where the break actually was. The fix, once I found it, was a single line of configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I run agents on a remote box
&lt;/h2&gt;

&lt;p&gt;Back up, because the setup is the whole story.&lt;/p&gt;

&lt;p&gt;I've been writing software for over a decade, and that leaves you with an intuition that's hard to switch off: every system fails in ways you didn't plan for, so you don't hand unvetted software unbounded reach over anything you care about. AI agents don't get an exception — if anything they earn the rule, because they act fast, confidently, and across your entire filesystem. So from the first day I used one, I ran it somewhere it could do its worst without touching my laptop: a personal cloud VM, isolated and disposable.&lt;/p&gt;

&lt;p&gt;That instinct got paid back. Claude Code once printed a git token in plaintext, right there in the chat window. It caught the mistake itself and told me to treat the token as leaked and rotate it — which I did. It caught that one. The point of isolation is the one it &lt;em&gt;doesn't&lt;/em&gt; catch. If that had been a wallet key instead of a git token, "oops, rotate it" isn't a sentence that saves you.&lt;/p&gt;

&lt;p&gt;My first attempt at remote was Claude Code's Telegram channel — drive the agent entirely from chat messages. It worked the way pure vibe-coding works: you can't see the files, you can't see the diffs, you're steering blind, and the results showed it. Then I found the combination that actually fit: VS Code's Remote-SSH manages the project on the remote box directly, and the Claude Code extension runs against it. Safe and legible at the same time. The compute lives on the server, so it doesn't touch my laptop's resources — and, the part I loved, the task keeps running when my laptop sleeps or shuts down. I could start something heavy, close the lid, and it would just keep going.&lt;/p&gt;

&lt;p&gt;To close the last gap — actually answering the agent when it needs me, without sitting at the machine — I leaned on two things. The notifier from the top of this post was one half: it tells me &lt;em&gt;when&lt;/em&gt; Claude needs me. The other half was Remote Control (&lt;code&gt;/remote-control&lt;/code&gt;), which lets me approve prompts and answer questions from the Claude app on my phone — the &lt;em&gt;how&lt;/em&gt;. Get pinged that it needs me, answer from my pocket. That was the workflow. (Hold onto that split between the &lt;em&gt;when&lt;/em&gt; and the &lt;em&gt;how&lt;/em&gt;; it's exactly where this broke.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it broke — just enough mental model
&lt;/h2&gt;

&lt;p&gt;To figure out what had actually happened, I spent some time digging into Claude Code's mental model and how it runs under the hood.&lt;/p&gt;

&lt;p&gt;A Claude Code session isn't a running program. It's data on disk — the transcript, the message history — that a process loads and continues. When the process is idle, nothing is computing; it's waiting for input. Kill the process and the session doesn't die, it just goes cold: the transcript is still on disk, and you can resume it later.&lt;/p&gt;

&lt;p&gt;So what actually runs? A process. And in my setup — the VS Code extension panel driving a remote box — that process has a parent I'd never thought about. I only saw it when I finally looked at the process tree on the server: the claude process is a &lt;em&gt;child of the VS Code Server's extension host&lt;/em&gt;, the thing that lives on the remote machine and backs your editor windows.&lt;/p&gt;

&lt;p&gt;Now put my laptop to sleep. From the remote server's point of view, my laptop is a &lt;em&gt;client&lt;/em&gt;, and it just disconnected. VS Code Server doesn't tear things down immediately — it holds the extension host open for a grace period, waiting for the client to come back. But past that window it gives up and disposes the extension host. And when the host goes, every child goes with it — including the claude process, including the paused question it was holding in memory, waiting for my answer.&lt;/p&gt;

&lt;p&gt;That's the whole failure. My laptop slept longer than the grace period. The remote server reclaimed the extension host. The process died mid-question. When I later reconnected and resumed, I got the transcript back — but the transcript ends at the point of interruption, with a question and no answer, and resume restores a &lt;em&gt;conversation&lt;/em&gt;, not a paused execution that knows how to press play. The task didn't re-ask, and it didn't pick up the gated step. (That's what I saw, at least; resume's exact behavior around a dangling prompt isn't something I'd bet is identical across versions.) The exchange was simply gone.&lt;/p&gt;

&lt;p&gt;And here's the part that stung, given why I chose this setup in the first place: I went remote &lt;em&gt;so that sleeping my laptop wouldn't stop the work&lt;/em&gt;. And it didn't stop the compute — the server never slept. What it stopped was the process's right to stay alive, on a timer I didn't know existed.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;disconnected&lt;/code&gt; I hit in the Claude app wasn't a fluke, and it's worth being precise about why the answer-from-my-phone plan couldn't save me. Remote Control gives you a channel to reach the session — the docs are clear that it routes outbound through the Anthropic API, phone to relay to the machine running claude, never touching my laptop. But a channel to answer is worthless once the thing you'd answer is dead. By hour three the process was gone, and with it the session's registration; there was nothing left on the other end to say "yes" to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — one config line
&lt;/h2&gt;

&lt;p&gt;The grace period is the whole game, and it's a setting: &lt;code&gt;remote.SSH.reconnectionGraceTime&lt;/code&gt;. Its default is three hours. That's the trap I fell into — my laptop had been asleep longer than three hours.&lt;/p&gt;

&lt;p&gt;You set it in your local VS Code &lt;strong&gt;User&lt;/strong&gt; settings. It's a client-side Remote-SSH setting, even though it governs how long the &lt;em&gt;remote&lt;/em&gt; server waits for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"remote.SSH.reconnectionGraceTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unit is seconds, which I confirmed the hard way — &lt;code&gt;86400&lt;/code&gt; is 24 hours. Now my laptop can sleep overnight and the remote process stays put, question and all.&lt;/p&gt;

&lt;p&gt;Two things that will waste your afternoon if you don't know them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reloading the window isn't enough.&lt;/strong&gt; The value is applied when the remote server &lt;em&gt;starts&lt;/em&gt;. If a server is already running under the old value, reconnecting just reattaches to it — old value intact. You have to force a fresh server. From a remote terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkill &lt;span class="nt"&gt;-f&lt;/span&gt; vscode-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then reconnect, and VS Code brings up a new server carrying the new value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify it, don't trust it.&lt;/strong&gt; After reconnecting, on the remote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'--reconnection-grace-time'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remote-SSH passes your setting to the server as that flag. If you see your number, it's live. If you see nothing, you reattached to an old server — &lt;code&gt;pkill&lt;/code&gt; and reconnect.&lt;/p&gt;

&lt;p&gt;One nuance worth respecting: this grace period is for &lt;em&gt;unexpected&lt;/em&gt; disconnects — sleep, a dropped network. Deliberately quitting VS Code is a different event that can dispose the session immediately, no grace. (Remote Control's own docs say it plainly: close the terminal or quit VS Code and the session ends.) "Sleep" and "quit" both make your editor go away, but they are not the same thing to the server.&lt;/p&gt;

&lt;p&gt;And don't confuse this with Remote Control's &lt;em&gt;other&lt;/em&gt; timer — the roughly ten-minute one. That fires when the machine running claude is awake but can't reach the network for ten-odd minutes; it's about the server's uplink to Anthropic, not about your laptop being gone. Two different clocks, and my laptop's sleep only ever touched the grace-period one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detour
&lt;/h2&gt;

&lt;p&gt;Worth admitting how I actually found this, because it's a lesson in itself: I debugged it &lt;em&gt;with&lt;/em&gt; Claude Code, and Claude Code was wrong more than once on the way. It first told me &lt;code&gt;/remote-control&lt;/code&gt; wasn't a real command — it was; I'd just run past its knowledge cutoff. Then, chasing the grace period, it quoted me an old figure — "a few seconds to a few minutes" — from a stale GitHub issue, which sent me down a "there's no way to fix this" hole before we checked the current docs and found the real default was three hours with a real knob to turn.&lt;/p&gt;

&lt;p&gt;The takeaway isn't "AI is unreliable." It's the same instinct that put my agents on a remote box to begin with: don't trust the first answer about how a tool behaves — check it against &lt;code&gt;ps&lt;/code&gt; and the current docs. The model corrected itself both times, which is the good case. The safety net is for when it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;I'd assumed a problem this disorienting — a task silently skipping a decision, an exchange I couldn't even locate — would have a proportionally complicated cause. It didn't. It was a three-hour timer with a one-line override.&lt;/p&gt;

&lt;p&gt;If you run a walk-away workflow — kick off long tasks, answer from your phone — the weak point isn't the notification channel everyone reaches for first. It's whether the process is still alive when you get back to it. Remote isolation is what lets me let go of a running agent at all; but letting go &lt;em&gt;safely&lt;/em&gt; needs two things held at once — the process kept alive long enough to answer (grace time), and the blast radius kept small enough to survive its mistakes (isolation). Hold both, and closing your laptop on a running agent stops being a gamble.&lt;/p&gt;

&lt;p&gt;If you run Claude Code against a remote box, check your &lt;code&gt;reconnectionGraceTime&lt;/code&gt; before you next trust a closed lid — and if your resume behaves differently than mine did when it lands on a question with no answer, I'd genuinely like to hear about it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>vscode</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Notification That Lied to Me</title>
      <dc:creator>Jerome</dc:creator>
      <pubDate>Sat, 11 Jul 2026 14:19:37 +0000</pubDate>
      <link>https://dev.to/jeromefromhk/the-notification-that-lied-to-me-46lm</link>
      <guid>https://dev.to/jeromefromhk/the-notification-that-lied-to-me-46lm</guid>
      <description>&lt;p&gt;I was three projects deep. Project A had a review open in one VS Code window. Project B was running under Claude Code with the &lt;code&gt;superpowers&lt;/code&gt; skill set, which means TDD: red, green, refactor, repeat — a mode that produces solid code but stretches a task from minutes into an hour or more. Somewhere in that hour, Claude hit a point where it needed my sign-off to keep going. I didn't notice. I was in Project A. By the time I switched back to Project B, it had been sitting there, blocked, for the better part of twenty minutes — doing nothing, waiting on me.&lt;/p&gt;

&lt;p&gt;That's the first problem: long, TDD-driven tasks create real decision points, and if you're not watching, you bleed time waiting for nobody.&lt;/p&gt;

&lt;p&gt;The second problem is the same failure wearing a different hat. I run multiple projects in parallel, each in its own VS Code window, each with its own Claude Code session. "Is anything done? Is anything stuck?" isn't a question I can answer without clicking through every window and every session, one at a time. Multiply that by a normal afternoon and it's a meaningful amount of dead time spent just checking.&lt;/p&gt;

&lt;p&gt;Both problems have the same shape: Claude Code knows when it's blocked or finished. I don't, because I'm not watching. So I built &lt;code&gt;claude-code-notify&lt;/code&gt; — a tool that pings me on Telegram at exactly the moments that matter: when a turn is truly done, and when it needs me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version, and why it lied
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a &lt;code&gt;Stop&lt;/code&gt; hook: Claude Code exposes one, you wire it to a script, the script fires a Telegram message. I did exactly that, first. It worked — until it didn't.&lt;/p&gt;

&lt;p&gt;The bug showed up as a notification that arrived too early. I'd get "finished," switch over, and find the terminal still working. The cause, once I found it, was almost embarrassing: Claude Code's &lt;code&gt;Bash&lt;/code&gt; tool, when run with &lt;code&gt;run_in_background=true&lt;/code&gt;, immediately returns an acknowledgment — something like &lt;code&gt;"Command running in background with ID: …"&lt;/code&gt; — the instant the command is &lt;em&gt;dispatched&lt;/em&gt;, not when it &lt;em&gt;completes&lt;/em&gt;. A naive &lt;code&gt;Stop&lt;/code&gt; hook that treats any matching tool result as "resolved" reads that ack as done. It isn't. The command is still running. Background &lt;code&gt;Agent&lt;/code&gt; subagents have the same issue from a different angle: they don't emit an ack at all, so a hook that only tracks explicit "finished" signals just never sees them.&lt;/p&gt;

&lt;p&gt;So the naive hook was worse than useless for exactly the cases I cared about most — the long, multi-step, background-heavy turns that made me build this in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "finished" actually has to mean
&lt;/h2&gt;

&lt;p&gt;The fix is a single rule: a background task — a subagent or a backgrounded &lt;code&gt;Bash&lt;/code&gt; command — counts as resolved only when a &lt;code&gt;&amp;lt;task-notification&amp;gt;&lt;/code&gt; shows up in the session transcript carrying that task's original &lt;code&gt;tool_use_id&lt;/code&gt;. An immediate acknowledgment never counts. At &lt;code&gt;Stop&lt;/code&gt; time, the hook computes &lt;code&gt;pending = launched − resolved&lt;/code&gt;; if anything is still pending, it stays silent. Only once every background task has actually reported back does it check a rate limit and send.&lt;/p&gt;

&lt;p&gt;I'd expected to just use Claude Code's native &lt;code&gt;SubagentStop&lt;/code&gt; hook for this and skip the transcript parsing entirely. It turns out that hook can't do the job: background agents (&lt;code&gt;run_in_background=true&lt;/code&gt;) bypass &lt;code&gt;Stop&lt;/code&gt;/&lt;code&gt;SubagentStop&lt;/code&gt; entirely, subagent completion isn't reliably reported through it, and even when it does fire, it carries the parent session's shared &lt;code&gt;session_id&lt;/code&gt; with no way to tell which subagent finished. All three are open issues against Claude Code itself, not quirks of my setup. Transcript-based matching on &lt;code&gt;tool_use_id&lt;/code&gt; is the only signal that's actually reliable today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case I didn't build for
&lt;/h2&gt;

&lt;p&gt;I added the error hook almost as an afterthought — a turn can die for any reason, and a silent dead turn is the same lie as an early "finished." Then one afternoon it turned out to be the most useful of the three.&lt;/p&gt;

&lt;p&gt;I had three sessions running: two long autonomous turns and a review. Somewhere in there my account hit its usage limit. A usage limit doesn't stop one turn — it stops all of them, at once, mid-task, in work I couldn't see. On the old workflow I'd have found out the way I always did: clicking into each window and hitting a wall of red where I'd expected progress.&lt;/p&gt;

&lt;p&gt;Instead my phone buzzed three times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Claude Code stopped with error | Refactor the transcript parser | ~/work/foo | 11/07/2026 10:29:28
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— and two more like it. Each ping named the project it came from and the turn's own title, because the error notification carries the same identifying fields as every other one: Claude Code's title for the turn, the working directory, and the time.&lt;/p&gt;

&lt;p&gt;That's exactly the information a limit erases from your head. When it resets a few hours later, the question isn't "did something break" — everything broke — it's "where was I, in each project, when it did." The three messages sitting in my Telegram history answered that without my opening a single window: which projects, which tasks — enough to walk straight back into each one instead of reconstructing from scratch where I'd left off. The parallel-checking problem from the top of this post, in its worst form — several stalled sessions at once — collapses into a scrollback I can read on my phone while I wait for the limit to lift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like now
&lt;/h2&gt;

&lt;p&gt;I kick off a long task, switch to another project, and stop thinking about it. One message arrives — on Telegram, so it reaches me regardless of which window has focus — and only for one of three reasons: the turn is genuinely finished, background work and all; it needs my input; or it errored out. No early "finished" while something's still churning, no manually checking five sessions to find the one that's stuck.&lt;/p&gt;

&lt;p&gt;Both of my original problems turn out to be the same problem, and this is the same fix: stop relying on me to notice, and make the notification itself trustworthy.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/Jeromefromcn/claude-code-notify/main/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One dependency (&lt;code&gt;python3&lt;/code&gt;), and config lives outside &lt;code&gt;settings.json&lt;/code&gt; so your token never gets committed by accident. Changed your mind? The same line with &lt;code&gt;--uninstall&lt;/code&gt; pulls the hooks and code back out (it leaves your &lt;code&gt;config.env&lt;/code&gt; alone, so reinstalling later doesn't mean re-entering your token):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/Jeromefromcn/claude-code-notify/main/install.sh | bash &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--uninstall&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's MIT-licensed: &lt;a href="https://github.com/Jeromefromcn/claude-code-notify" rel="noopener noreferrer"&gt;github.com/Jeromefromcn/claude-code-notify&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run Claude Code across parallel sessions or lean on long autonomous turns, I'd genuinely like to know if this solves the same problem for you — or if you hit a case it doesn't handle.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>cli</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
