<?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: Qian_Xiao</title>
    <description>The latest articles on DEV Community by Qian_Xiao (@pbxqdown).</description>
    <link>https://dev.to/pbxqdown</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%2F4121297%2F1ec7112f-7c6b-4e65-a6f4-eface667d4f5.jpg</url>
      <title>DEV Community: Qian_Xiao</title>
      <link>https://dev.to/pbxqdown</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pbxqdown"/>
    <language>en</language>
    <item>
      <title>Was bash the wrong language for my agent?</title>
      <dc:creator>Qian_Xiao</dc:creator>
      <pubDate>Thu, 17 Sep 2026 20:36:47 +0000</pubDate>
      <link>https://dev.to/pbxqdown/was-bash-the-wrong-language-for-my-agent-4i6l</link>
      <guid>https://dev.to/pbxqdown/was-bash-the-wrong-language-for-my-agent-4i6l</guid>
      <description>&lt;p&gt;I have a small agent that handles one piece of routine work at a time. It looks&lt;br&gt;
at what needs doing, picks the thing most worth doing, shows me the plan, and&lt;br&gt;
does it if I say yes. Underneath, it is glue: it drives a few command-line&lt;br&gt;
tools, calls a model, reshapes a lot of JSON, and prints a readable summary.&lt;/p&gt;

&lt;p&gt;It was 2150 lines of bash across seven files. It is now Python.&lt;/p&gt;

&lt;p&gt;So the answer looks like yes. I don't think it is, and why I don't is most of&lt;br&gt;
the reason I'm writing this down.&lt;/p&gt;
&lt;h2&gt;
  
  
  The argument for staying was sound. Its inputs weren't.
&lt;/h2&gt;

&lt;p&gt;I had been through this question before and decided to stay — carefully enough&lt;br&gt;
that the reasoning became a section of the project README titled &lt;strong&gt;Why this is&lt;br&gt;
still bash&lt;/strong&gt;. Nine tenths of the program is subprocess orchestration, which is&lt;br&gt;
bash's home ground. Rewriting 2000 lines with no test coverage is the standard&lt;br&gt;
way to lose behavior silently. And the bug ledger said the expensive bugs were&lt;br&gt;
design errors that any language would have permitted.&lt;/p&gt;

&lt;p&gt;I still think all of that is true.&lt;/p&gt;

&lt;p&gt;What moved was a requirement. I had been treating &lt;em&gt;runs with no build step&lt;/em&gt; as&lt;br&gt;
hard, which made "python3 is already installed" the load-bearing argument. Then&lt;br&gt;
the requirement got clarified: no build step isn't a rule, it just shouldn't be&lt;br&gt;
complicated to start.&lt;/p&gt;

&lt;p&gt;That one sentence killed my best argument. So I measured what was actually at&lt;br&gt;
stake — 89ms for Python plus every standard library module it needs, against&lt;br&gt;
3ms for bash. Eighty-six milliseconds, in a program that waits thirty to a&lt;br&gt;
hundred seconds on a model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My reasoning was valid; its inputs weren't, and I had spent almost no effort&lt;br&gt;
checking them.&lt;/strong&gt; That ratio was backwards, and I don't think that's unusual.&lt;/p&gt;
&lt;h2&gt;
  
  
  What decided it was 162 calls to &lt;code&gt;jq&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Every list length, every filter, forking a process to handle data that should&lt;br&gt;
have been sitting in memory.&lt;/p&gt;

&lt;p&gt;The cost was not performance. 162 forks are nothing next to a minute of model&lt;br&gt;
latency. &lt;strong&gt;The cost was expressiveness.&lt;/strong&gt; Every structure in the program either&lt;br&gt;
fit in a one-line &lt;code&gt;jq&lt;/code&gt; expression or got split into three pieces. What I wrote&lt;br&gt;
was never the structure I wanted; it was the structure &lt;code&gt;jq&lt;/code&gt; could state on one&lt;br&gt;
line. That cost is invisible in any single line of code and shows up in the&lt;br&gt;
designs you never consider.&lt;/p&gt;

&lt;p&gt;I had three lists: the files a change actually touched, the files the agent&lt;br&gt;
itself had written, and the files I had approved in advance. I needed two&lt;br&gt;
differences between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;later&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-nc&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; a &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$actual&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; w &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$written&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'if $w == null then [] else ($a - $w) end'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-nr&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; w &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$written&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--argjson&lt;/span&gt; p &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$planned&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'($w - $p) | join(", ")'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;later&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;written&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;planned&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is barely shorter. It is what I would have written on the first&lt;br&gt;
attempt; the first took me several tries to get right.&lt;/p&gt;

&lt;p&gt;There was also a run that died on &lt;code&gt;line 567: 1: command not found&lt;/code&gt;, which I&lt;br&gt;
never located. It went away when that section was rewritten for unrelated&lt;br&gt;
reasons. &lt;strong&gt;A bug you can't find after the fact doesn't just go unfixed — it&lt;br&gt;
tells you the next one of its kind will too.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The line count did not go down
&lt;/h2&gt;

&lt;p&gt;The port took a day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash      2150 lines
Python    2258 lines   ← up 108

            of which:
  code      1278       ← down 40%
  comments   980
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most people expect the opposite, so it's worth being blunt about. The code&lt;br&gt;
shrank by forty percent; the difference is comments and docstrings — the notes&lt;br&gt;
recording which specific incident each safety check exists to prevent, which&lt;br&gt;
were exactly what I'd been afraid of losing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evaluate this rewrite by total line count and it accomplished nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What made it cheap: two seams built for other reasons
&lt;/h2&gt;

&lt;p&gt;This is the part I actually wanted to write down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompts are files, not strings.&lt;/strong&gt; Every prompt lives in its own Markdown&lt;br&gt;
file and the code fills &lt;code&gt;{{placeholder}}&lt;/code&gt; holes in it. I did that for unrelated&lt;br&gt;
reasons: prompts get edited constantly, they want to be read as prose, and a&lt;br&gt;
stray &lt;code&gt;$&lt;/code&gt; or backtick has to stay inert instead of being eaten by the shell.&lt;/p&gt;

&lt;p&gt;The result was that the migration &lt;strong&gt;did not touch one word of any prompt&lt;/strong&gt;. I&lt;br&gt;
checked modification times afterward to be sure. Everything that determines&lt;br&gt;
this program's behavior — the criteria I've tuned over and over, the order&lt;br&gt;
judgments get made in — lives in those files. Changing languages only replaced&lt;br&gt;
the glue that assembles them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each kind of task is a separate executable that speaks JSON.&lt;/strong&gt; Verb on argv,&lt;br&gt;
JSON on stdin, JSON on stdout. I built it that way because bash has no modules,&lt;br&gt;
and putting them behind a process boundary beat having them scribble on each&lt;br&gt;
other's variables. Pure coping.&lt;/p&gt;

&lt;p&gt;The result: &lt;strong&gt;there was no big-bang rewrite to choose.&lt;/strong&gt; The orchestrator could&lt;br&gt;
be Python while the task types were still bash, or the reverse. I moved one&lt;br&gt;
file at a time and ran each one on its own afterward. (That boundary is&lt;br&gt;
probably also why this never hit the wall bash projects hit — the largest bash&lt;br&gt;
agent I know of reached 4700 lines as a single file assembled by &lt;code&gt;cat src/*.sh&lt;/code&gt;,&lt;br&gt;
and what broke was module structure, not correctness.)&lt;/p&gt;

&lt;p&gt;Neither seam was built with portability in mind. Both were built to solve&lt;br&gt;
something annoying at the time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether a rewrite will be cheap is decided before you start it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I kept the process boundary afterward, by the way. Folding the task types into&lt;br&gt;
Python imports would save a JSON round trip, and it is the best structural&lt;br&gt;
decision in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real risk of Python is not the build step
&lt;/h2&gt;

&lt;p&gt;With a shebang and standard library only, it is invoked exactly the way it was&lt;br&gt;
before. No virtualenv, no install.&lt;/p&gt;

&lt;p&gt;The risk is that &lt;strong&gt;Python invites dependencies&lt;/strong&gt;, and bash's poverty was itself&lt;br&gt;
a form of protection. &lt;code&gt;requests&lt;/code&gt; when &lt;code&gt;urllib&lt;/code&gt; is right there; a schema&lt;br&gt;
validator when the model CLI already enforces the schema; an argument parser&lt;br&gt;
for 25 lines of parsing; a formatting library for a display layer that exists.&lt;br&gt;
Each has a plausible case, and after all four "quick to start" is gone.&lt;/p&gt;

&lt;p&gt;So there is one rule in the README now: &lt;strong&gt;standard library only, and say why it&lt;br&gt;
can't be done with it before adding anything.&lt;/strong&gt; The whole program needs six&lt;br&gt;
modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not verify
&lt;/h2&gt;

&lt;p&gt;I exercised every path after the port, including a full dry run of the&lt;br&gt;
expensive one — isolated checkout, model writes the code, formatter, vet,&lt;br&gt;
build, tests, commit — stopping short of pushing. Fifteen tests on the pure&lt;br&gt;
functions pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two lines that push a branch and open a change request never ran&lt;/strong&gt;,&lt;br&gt;
because running them means actually opening one. And the safety checks inside&lt;br&gt;
the task types I translated by hand, one at a time. I believe I got them right,&lt;br&gt;
and this project still has no test that can prove it.&lt;/p&gt;




&lt;p&gt;I don't think bash was the wrong choice. It carried this to 2150 lines, and for&lt;br&gt;
all of that time I was changing judgment logic rather than fighting the&lt;br&gt;
language. &lt;strong&gt;Its problem was never that it couldn't do the job. Its problem was&lt;br&gt;
that its expressiveness had started deciding my designs.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>bash</category>
      <category>refactoring</category>
      <category>ai</category>
    </item>
    <item>
      <title>Everything was running. The port belonged to the wrong process.</title>
      <dc:creator>Qian_Xiao</dc:creator>
      <pubDate>Fri, 11 Sep 2026 18:24:59 +0000</pubDate>
      <link>https://dev.to/pbxqdown/everything-was-running-the-port-belonged-to-the-wrong-process-3e1i</link>
      <guid>https://dev.to/pbxqdown/everything-was-running-the-port-belonged-to-the-wrong-process-3e1i</guid>
      <description>&lt;p&gt;My browser sat there spinning on "connecting".&lt;/p&gt;

&lt;p&gt;The setup is common enough: &lt;code&gt;x11vnc&lt;/code&gt; shares a screen on port 5900, and&lt;br&gt;
&lt;code&gt;websockify&lt;/code&gt; forwards to it so a browser can connect. I checked the services.&lt;br&gt;
websockify was running. Everything was running. Nothing worked.&lt;/p&gt;

&lt;p&gt;The question I actually needed answered was simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who is using port 5900?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's normally &lt;code&gt;ss -ltnp&lt;/code&gt; or &lt;code&gt;lsof -i :5900&lt;/code&gt;, and neither was installed on that&lt;br&gt;
box — which is why I'd written a small tool for this one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ portclue 5900
NOT EXPOSED LOCALLY

TCP port 5900

  127.0.0.1:5900/tcp  [NOT_EXPOSED_LOCALLY]
    -&amp;gt; LISTEN             ... bound to 127.0.0.1:5900/tcp
    -&amp;gt; OWNED              PID 1087636 (x11vnc), systemd unit session-1911.scope
    -&amp;gt; LOOPBACK_ONLY      127.0.0.1 is reachable only from this network namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;x11vnc&lt;/code&gt; had the port — just not the one I'd started. It was a leftover from&lt;br&gt;
a session days earlier that had never shut down, so the new one could never get&lt;br&gt;
the port, and websockify had been faithfully forwarding to a dead screen the&lt;br&gt;
whole time.&lt;/p&gt;

&lt;p&gt;(PortClue gave me the PID. &lt;code&gt;ps&lt;/code&gt; is what confirmed the process was far older than&lt;br&gt;
everything around it — the tool doesn't report process age yet.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I reach for it
&lt;/h2&gt;

&lt;p&gt;Same facts &lt;code&gt;ss&lt;/code&gt; would give you, but written out instead of encoded.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;127.0.0.1&lt;/code&gt; isn't a number you have to interpret; it says "reachable only from&lt;br&gt;
this network namespace". On a port bound to &lt;code&gt;0.0.0.0&lt;/code&gt; it says &lt;code&gt;ALL_INTERFACES&lt;/code&gt;,&lt;br&gt;
then reads your nftables or iptables rules to see whether anything is actually&lt;br&gt;
allowed through. If it can't read them, it says &lt;code&gt;UNKNOWN&lt;/code&gt; instead of guessing.&lt;/p&gt;

&lt;p&gt;It gets all of that without &lt;code&gt;ss&lt;/code&gt; or &lt;code&gt;lsof&lt;/code&gt; installed, by asking the kernel&lt;br&gt;
directly.&lt;/p&gt;

&lt;p&gt;It's read-only: it never connects to the port you ask about, and it can't kill&lt;br&gt;
anything. Scope is Linux TCP listeners — that's the whole promise.&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/pbxqdown/portclue/v0.1.2/scripts/install.sh | sh

portclue        &lt;span class="c"&gt;# everything listening&lt;/span&gt;
portclue 5900   &lt;span class="c"&gt;# one port, explained&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/pbxqdown/portclue" rel="noopener noreferrer"&gt;https://github.com/pbxqdown/portclue&lt;/a&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>debugging</category>
      <category>devops</category>
      <category>cli</category>
    </item>
  </channel>
</rss>
