{"authors":[{"name":"OneLegDave","url":"https://www.onelegdave.dev/"}],"description":"Linux, self-hosted projects and AI coding tools, usually right after something has gone wrong.","feed_url":"https://www.onelegdave.dev/feed.json","home_page_url":"https://www.onelegdave.dev/","icon":"https://www.onelegdave.dev/images/avatar.jpg","items":[{"content_html":"\u003cp\u003eI pay for more AI coding tools than any reasonable person should, and for a couple of months I\u0026rsquo;ve been tinkering with one question: what if they worked together, and none of them could do anything I hadn\u0026rsquo;t agreed to?\u003c/p\u003e\n\u003cp\u003eNot \u0026ldquo;agreed to\u0026rdquo; in the sense of clicking Accept four hundred times until my finger goes numb. Agreed to in the sense that the operating system itself won\u0026rsquo;t let them do anything else.\u003c/p\u003e\n\u003cp\u003eThat idea now has a name, a public repository, a website, and as of tonight a first release candidate. It\u0026rsquo;s called GovernCode. It\u0026rsquo;s free, it\u0026rsquo;s open source, and it will stay that way.\u003c/p\u003e\n\u003ch2 id=\"one-boss-a-few-contractors-and-a-building-inspector\"\u003eOne boss, a few contractors, and a building inspector\u003c/h2\u003e\n\u003cp\u003eGovernCode lets you pick one AI tool to lead a project. That\u0026rsquo;s the Controller. It keeps the big picture and hands small, bounded jobs to the others, the Runners, the way a contractor hands a room to a subcontractor. Each job gets written down first: what to do, which files it may touch, how much of my usage it may burn, and what \u0026ldquo;done\u0026rdquo; means.\u003c/p\u003e\n\u003cp\u003eEvery one of them runs inside a sandbox that Linux enforces. Not a polite instruction in the prompt asking the AI to behave. The kernel\u0026rsquo;s own locks (Landlock and seccomp, if you want the names) decide which folders it can read, which it can write, and where on the network it can go. If my machine can\u0026rsquo;t enforce those rules, GovernCode refuses to start any AI tool at all and tells me why. There is no \u0026ldquo;just this once\u0026rdquo; switch, on purpose, because I know myself.\u003c/p\u003e\n\u003cp\u003eWhen a tool wants to run something that matters, it stops and shows me exactly what will run, byte for byte. Not a friendly summary. The actual command.\u003c/p\u003e\n\u003ch2 id=\"the-part-where-i-nearly-gave-up-on-my-own-demo\"\u003eThe part where I nearly gave up on my own demo\u003c/h2\u003e\n\u003cp\u003eThe first time I tried to show it off, I spent more time clicking Allow than watching the AI work. Every \u003ccode\u003enpm test\u003c/code\u003e, every file edit, every harmless \u003ccode\u003els\u003c/code\u003e. It was secure the way a locked filing cabinet at the bottom of a lake is secure.\u003c/p\u003e\n\u003cp\u003eSo now I can say yes once, yes for the rest of this job, or yes for this project, and the plain read-only stuff just runs. The sandbox doesn\u0026rsquo;t move an inch when I do that. It only stops asking me the same question forty times. Deleting things, git, the network, and handing work to another AI still ask every single time.\u003c/p\u003e\n\u003ch2 id=\"i-let-the-ais-audit-the-ai-babysitter\"\u003eI let the AIs audit the AI babysitter\u003c/h2\u003e\n\u003cp\u003eBefore putting a release candidate out, I had other AI tools try to break it. They found real holes. A tool could start a background process, \u0026ldquo;finish\u0026rdquo;, and leave that process running. A cleverly placed shortcut in a folder could have widened what a later job was allowed to write. A carefully worded command could sneak past the \u0026ldquo;this is just a harmless read\u0026rdquo; check.\u003c/p\u003e\n\u003cp\u003eAll of that is fixed, each fix has a test, and the sandbox now proves its rules on your machine every time it starts, including against those exact tricks. I\u0026rsquo;d rather find out from a robot at my desk than from someone\u0026rsquo;s bug report.\u003c/p\u003e\n\u003ch2 id=\"local-models-because-quota-is-money\"\u003eLocal models, because quota is money\u003c/h2\u003e\n\u003cp\u003eSmall jobs can go to a model running on my own graphics card. It gets no tools and can\u0026rsquo;t run anything. It reads the files it\u0026rsquo;s given and proposes changes, GovernCode checks every file it wants to touch, and I review it like anyone else\u0026rsquo;s work. The first real one wrote a usage section for a README in six seconds and cost exactly zero of my subscription.\u003c/p\u003e\n\u003ch2 id=\"the-rule-i-care-about-most\"\u003eThe rule I care about most\u003c/h2\u003e\n\u003cp\u003eGovernCode makes building software with AI a lot more approachable. It does not make anyone less responsible for what they ship. If I accept a change, it\u0026rsquo;s mine. I read the diff, I understand the code, I run the tests. The AI can write it. Only I can answer for it.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s written into the project\u0026rsquo;s philosophy as rule number two, right after \u0026ldquo;you hold the controls\u0026rdquo;, and it\u0026rsquo;s staying there.\u003c/p\u003e\n\u003ch2 id=\"where-it-stands\"\u003eWhere it stands\u003c/h2\u003e\n\u003cp\u003eIt\u0026rsquo;s a release candidate for Linux, for people who like poking at things from a terminal. macOS and Windows are next, together, same priority, because I\u0026rsquo;m not making either one wait for the other. I\u0026rsquo;m dual-booting my laptop with Windows tonight so I can actually test it there.\u003c/p\u003e\n\u003cp\u003eNone of this would exist if Omarchy hadn\u0026rsquo;t made my computer fun again and dragged a passion I\u0026rsquo;ve had forever back out of the drawer where time had shoved it. So: thank you, DHH, and everyone behind Omarchy.\u003c/p\u003e\n\u003cp\u003eGovernCode is at \u003ca href=\"https://governcode.com\"\u003egoverncode.com\u003c/a\u003e. It\u0026rsquo;s free forever. If it saves you an afternoon, there\u0026rsquo;s a coffee button. My AI crew runs on tokens. I run on coffee. Only one of us has a button.\u003c/p\u003e\n","date_modified":"2026-09-26T20:00:00-07:00","date_published":"2026-09-26T20:00:00-07:00","id":"https://www.onelegdave.dev/posts/i-made-my-ai-tools-ask-permission/","image":"https://www.onelegdave.dev/images/posts/i-made-my-ai-tools-ask-permission.jpg","summary":"For a couple of months I tinkered with an idea: let several AI coding tools work as one crew without letting any of them touch what they should not. Tonight it became a real, free, open-source release candidate called GovernCode.","tags":["AI crew","Linux"],"title":"I Made My AI Tools Ask Permission","url":"https://www.onelegdave.dev/posts/i-made-my-ai-tools-ask-permission/"},{"content_html":"\u003cp\u003eAt about five in the afternoon I typed this into the terminal cockpit I\u0026rsquo;d finished the night before: \u0026ldquo;what is the possibility of having this as a GUI? something that really looks awesome!\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s the whole brief. No spec, no sketch, one vague sentence with an exclamation mark on it. By half past nine it had a name, a logo, four animated characters, a launcher with an icon, a laptop layout, two native Mac apps and a private network to run on. This is the version of vibe coding people keep promising: describe the thing, get the thing. It really can be like that. It was, for most of the evening. The rest of the evening I spent telling it I couldn\u0026rsquo;t see anything.\u003c/p\u003e\n\u003ch2 id=\"hearth-lasted-two-minutes\"\u003eHearth lasted two minutes\u003c/h2\u003e\n\u003cp\u003eThe first mockup came back in about five minutes. Then it went through seven versions in half an hour, because every time I looked at it I wanted something else. Keep the delegation feed but let it collapse. Let the terminal pop out or get taller. Give me a switch for the multiplexer that runs the agents. A project selector, and a way to make a new one. A button at the top that flashes when something needs me. Zoom for the text, per pane, independently. Each one turned up in the next version with the controls actually working in the mockup, which is a dangerous thing to show a man who was only asking hypothetically.\u003c/p\u003e\n\u003cp\u003eClaude named it Hearth. The reasoning was sound: the team is Anvil and Flint, the whole thing is forge-themed, and the hearth is where everyone gathers. I said \u0026ldquo;let\u0026rsquo;s see Crucible.\u0026rdquo; Crucible it is. A crucible is the pot the metal actually melts in, which felt like a more honest description of what happens to my quota.\u003c/p\u003e\n\u003cp\u003eBefore a line of real code, one rule went into the plan as rule one: closing Crucible never stops the multiplexer. The multiplexer is what holds every agent session. Crucible is a window onto it, nothing more. There is a switch in the header, that switch is the only thing allowed to stop it, and it asks first. Because stopping it kills every pane, including the one Claude was building Crucible from.\u003c/p\u003e\n\u003cp\u003eWhich is why the one thing in the plan that never got tested live is the off switch. The test would kill the tester. There\u0026rsquo;s a unit test for the guard. I have decided to believe the unit test.\u003c/p\u003e\n\u003ch2 id=\"i-dont-see-it\"\u003eI don\u0026rsquo;t see it\u003c/h2\u003e\n\u003cp\u003eThe build ran in phases, and each one landed in the window on my desk while I watched: a little server feeding the page, real terminals in the browser, the alarm, the selectors, the art, the packaging. Six phases in an hour and a half, all of them committed and pushed, and my contribution to each was roughly the same four words.\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;I don\u0026rsquo;t see it.\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eTwice the answer was that my window was still running the old page. The server serves new files the instant they exist, but a browser tab only fetches them when it reloads, so the tabs I was clicking were the inert ones from two phases ago and the project selector was the disabled placeholder. The fix was to make the page reload itself whenever the build changes, and it did, and the very next time I couldn\u0026rsquo;t see anything it was because I\u0026rsquo;d closed the window entirely and was staring at a terminal. Claude relaunched it for me, straight into the scratchpad next to that terminal at half width, where the layout stacks and the crew column falls off the bottom of the page. So the characters were there. They were just below the floor.\u003c/p\u003e\n\u003cp\u003eAt no point in any of this was anything wrong with the software. Every single one was the man looking at it.\u003c/p\u003e\n\u003ch2 id=\"keystone-needs-you\"\u003eKeystone needs you\u003c/h2\u003e\n\u003cp\u003eWhile Grok was off drawing, my desktop started pinging. A notification, a sound, a red button in the header: Keystone needs you. I went to the main pane. Nothing. No question, no prompt, nobody needed anything. It did it again. \u0026ldquo;I\u0026rsquo;m getting alerts from you but I can\u0026rsquo;t see any questions?\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eThe alarm was working perfectly. It was just listening to the wrong person. Grok\u0026rsquo;s command line tool runs the same hook configuration Claude Code does, and I\u0026rsquo;d wired those hooks to post to Crucible\u0026rsquo;s alarm endpoint. So every time Grok approved one of its own file writes, which it does automatically because that\u0026rsquo;s what it\u0026rsquo;s allowed to do, it announced that the lead needed a human. The server assumed any hook it heard came from the lead. It never occurred to me, or to the thing that wrote it, that the cat would be wearing the same collar. It now checks which agent a hook came from and ignores the ones that aren\u0026rsquo;t Claude, since the multiplexer already reports a teammate as blocked when it genuinely is.\u003c/p\u003e\n\u003cp\u003eThe art run itself went the other way. Grok delivered every character and logo, and I was reviewing them in a browser before the delegate wrapper, the supervisor I wrote and complained about in the last post, gave up on the run at its 25 minute ceiling and logged it as a timeout. Last night nobody was finished when they said they were. Tonight one of them finished and the babysitter said it hadn\u0026rsquo;t. I\u0026rsquo;m not sure that\u0026rsquo;s progress but it is at least variety.\u003c/p\u003e\n\u003cp\u003eI picked a sitting cat for Flint, asked for more movement, and now the crew animate while they work and hold up a hand when they\u0026rsquo;re blocked. I would have accepted a static PNG.\u003c/p\u003e\n\u003ch2 id=\"the-lodger\"\u003eThe lodger\u003c/h2\u003e\n\u003cp\u003ePackaging had its own small fights. The launcher hung on the first attempt because the browser-launch helper blocks until the window closes, so a script waiting for it to return waits forever. Then the window insisted on opening in the scratchpad, and the launcher had to learn to find it, move it to a real workspace and focus it.\u003c/p\u003e\n\u003cp\u003eThe server now starts at login and stays resident, the window opens on demand, and when Crucible starts Claude in a folder that already has a conversation it picks that conversation back up. So I can close the whole thing, reboot, click the icon in the launcher and be exactly where I was. I quit for the night at seven, pleased.\u003c/p\u003e\n\u003cp\u003eAt eight I came back. The multiplexer\u0026rsquo;s own sidebar, a list of spaces and agents that Crucible already draws far more prettily in its own columns, was sitting in the main pane like a lodger. I\u0026rsquo;d hidden it by hand before the reboot. The multiplexer has a setting to start it collapsed, but that setting only takes effect when its server starts, and rule one says I never restart that.\u003c/p\u003e\n\u003cp\u003eSo the Crucible server now watches the main pane, waits until it sees the sidebar drawn, and types the keyboard shortcut to hide it. Then it checks the repaint so it never toggles twice. It presses the button for me. I\u0026rsquo;m not proud of it. It works.\u003c/p\u003e\n\u003ch2 id=\"the-wrong-direction-first\"\u003eThe wrong direction first\u003c/h2\u003e\n\u003cp\u003e\u0026ldquo;Can we install this on the laptop, and have this work via ssh?\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eNot ten minutes later there was a second window with the laptop\u0026rsquo;s name in its header and a shell on the laptop answering. Crucible was running on the laptop, tunnelled back to the desktop. Which is when I explained what I\u0026rsquo;d actually meant: the desktop is the workhorse, the laptop is where I sit. Other direction. Same mechanism, reversed, and the laptop already reached the desktop over Tailscale\u0026rsquo;s SSH without a password, so no keys to sort out. Verified from the laptop\u0026rsquo;s own screen two minutes later, this very conversation in the main pane.\u003c/p\u003e\n\u003cp\u003eThe laptop\u0026rsquo;s screen is smaller, and the page had been laid out for the big monitor. A screenshot, three fixes, a compact layout that collapses the header to one row and narrows the side columns, and the main terminal gained roughly forty percent more height. Then the laptop got the same AI setup as the desktop, Headroom in its bar and all, and I signed into the four accounts. Three of the four verified straight away. Antigravity reported not signed in after I\u0026rsquo;d just signed in, because checking it over SSH makes it look in a different credential store than the one I\u0026rsquo;d just written to. Checked the way Crucible actually launches it, it was fine. That\u0026rsquo;s two nights running this tool has been reported logged out, and this time it wasn\u0026rsquo;t even its fault.\u003c/p\u003e\n\u003ch2 id=\"the-crazy-pitch\"\u003eThe crazy pitch\u003c/h2\u003e\n\u003cp\u003e\u0026ldquo;Okay, now the crazy pitch, can we get native apps for Crucible and Headroom on the Mac?\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eYes. Claude built them on the Mac over SSH with nothing but the command line tools, in Swift, in a few minutes including one type error. Crucible.app is a WebKit window onto the desktop\u0026rsquo;s server, with the page\u0026rsquo;s confirm dialogs turned into native sheets and copy and paste wired into the terminals. Headroom.app is a ring in the menu bar showing the fullest of my four weekly allowances, red at ninety percent, with a panel of meters and reset countdowns behind it. Screenshots over SSH aren\u0026rsquo;t permitted on a Mac, so the build could confirm a window existed and that the server saw its connections. Whether it looked any good was my job. It did.\u003c/p\u003e\n\u003cp\u003eBoth apps were running over SSH tunnels. Then I said \u0026ldquo;we should use tailscale,\u0026rdquo; and the tunnel code that had been written at twenty past eight was deleted at ten past nine. The desktop now publishes Crucible on the tailnet with Tailscale\u0026rsquo;s own certificate, while the server itself still only listens on the loopback address. Every request that arrives that way carries the caller\u0026rsquo;s Tailscale login, and the server refuses anyone but me. A forged login header was tried and refused. A foreign origin on a terminal socket was tried and refused. The laptop and the Mac reach it from a coffee shop the same way they reach it from the sofa, and nothing is open to the internet. One quirk: the first request to a freshly published machine can sit for over ten seconds while Tailscale issues the certificate. After that it\u0026rsquo;s instant.\u003c/p\u003e\n\u003ch2 id=\"half-past-nine\"\u003eHalf past nine\u003c/h2\u003e\n\u003cp\u003eEverything went to GitHub at 21:24. Twenty-nine commits in four and a half hours, from \u0026ldquo;something that really looks awesome\u0026rdquo; to a cockpit I can open from a laptop, a Mac, or a coffee shop. I didn\u0026rsquo;t write a line of it. I typed wishes and looked at the results, and when I couldn\u0026rsquo;t see the results, that was usually me too.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s the sometimes. The easy part was genuinely easy. Nothing about tonight required me to know Swift, or how a browser talks to a terminal, or what Tailscale does with a certificate. What it required was somebody in the chair who\u0026rsquo;d notice when the alarm was lying, when the supervisor was lying, and when the fault was the man looking at the screen. The code writes itself now. The noticing still doesn\u0026rsquo;t.\u003c/p\u003e\n\u003cp\u003eNot done: alarm notifications on the Mac, Headroom starting itself at login, and Codex\u0026rsquo;s review of the server code, which waits for its weekly quota to reset on Saturday. Former boss, back on the tools, hasn\u0026rsquo;t been allowed near a file since it hit the ceiling.\u003c/p\u003e\n\u003cp\u003eAnd the off switch. I have a cockpit with an animated cat in it, reachable from anywhere on earth, and an off switch I have never once pressed.\u003c/p\u003e\n","date_modified":"2026-09-18T21:33:00-07:00","date_published":"2026-09-18T21:33:00-07:00","id":"https://www.onelegdave.dev/posts/something-that-really-looks-awesome/","image":"https://www.onelegdave.dev/images/posts/something-that-really-looks-awesome.jpg","summary":"One vague sentence at five in the afternoon. By half past nine my AI team had a real GUI with a name, a cat, two Mac apps and a private network. Vibe coding can be that easy. Sometimes.","tags":["AI crew"],"title":"Something That Really Looks Awesome","url":"https://www.onelegdave.dev/posts/something-that-really-looks-awesome/"},{"content_html":"\u003cp\u003eA few days ago I wrote a post about how I only wanted two AI subscriptions, and how Claude was the one getting cancelled. Claude is now in charge of the other three.\u003c/p\u003e\n\u003cp\u003eI would like to say there was a dramatic reason for this. There wasn\u0026rsquo;t. Codex had been running the show, it was doing fine, and I moved the job across because I wanted to see what happened. That\u0026rsquo;s the entire justification. A promotion, a demotion, and a backup of the old config taken at quarter past eleven at night, because I have learned exactly one lesson in my life and it was about backups.\u003c/p\u003e\n\u003cp\u003eSo the roster is four: Codex, Claude, Grok, and Google\u0026rsquo;s Antigravity. One of them plans and reviews. The other three get handed bounded jobs. And because saying \u0026ldquo;ask the Google one\u0026rdquo; out loud thirty times a day is its own special punishment, they have names now. Anvil is Codex. Keystone is Claude. Flint is Grok. Albatross, Alby for short, is agy.\u003c/p\u003e\n\u003cp\u003eThe nicknames are for talking. Every log line, quota check and credit keeps the real provider name, because the one thing worse than four assistants is four assistants and no way to tell which one did the thing.\u003c/p\u003e\n\u003cp\u003eThe rules are blunt. Each provider has a 90 percent ceiling on its weekly allowance, which leaves at least 10 percent for me, because these things are meant to be doing my work, not eating my quota so I can\u0026rsquo;t. Nobody quietly switches to paid billing to get around a limit. Every job I hand off needs a concrete result, the files it\u0026rsquo;s allowed to touch, acceptance criteria, and a budget check.\u003c/p\u003e\n\u003cp\u003eThen there\u0026rsquo;s the genuinely stupid part: one standing policy, 228 lines, mirrored into four separate instruction files, because each tool only reads its own. Miss one and you have a Head Developer that three of the four assistants have never heard of.\u003c/p\u003e\n\u003ch2 id=\"the-gate-the-launcher-the-pane\"\u003eThe gate, the launcher, the pane\u003c/h2\u003e\n\u003cp\u003eThere\u0026rsquo;s a quota gate that has to run before every delegated job and again afterwards. Delegated, in this house, means I handed the work to one of them instead of doing it myself. There\u0026rsquo;s a launcher that bounds the work, keeps the streamed partial output so a death mid-job isn\u0026rsquo;t a total blank, and classifies failures as timeout, empty success, or agent failure instead of trusting an exit code. Empty success is the fun one: it walked off claiming it was fine and produced nothing.\u003c/p\u003e\n\u003cp\u003eAnd in the small hours I wrote the one that runs a teammate in a pane of a terminal multiplexer, requires a stated task label and acceptance criteria, waits for a completion marker, and appends every run to an event log.\u003c/p\u003e\n\u003cp\u003eA completion marker is a word I tell them to print when the job is actually done. You can already see the hole in that plan. It assumes \u0026ldquo;done\u0026rdquo; is a thing the furniture will admit to.\u003c/p\u003e\n\u003ch2 id=\"idle-is-not-evidence\"\u003eIdle is not evidence\u003c/h2\u003e\n\u003cp\u003eAsking the multiplexer whether an agent has finished is worth precisely fuck all.\u003c/p\u003e\n\u003cp\u003eGrok reported idle at 23 seconds into 35 seconds of work, with its command still running. agy reported idle at 7 seconds, which was impressive given its command hadn\u0026rsquo;t started yet.\u003c/p\u003e\n\u003cp\u003eagy also parks in idle between tool calls, and it echoes prompt text into its own visible thinking, so neither a status of idle nor a matching completion word is enough for it. A review got declared finished at 168 seconds while the pane still read \u0026ldquo;Running command\u0026hellip;\u0026rdquo;. The wrapper then closed the pane and destroyed the work. The fix waits for the pane\u0026rsquo;s content to stop changing, with a longer settle time for agy.\u003c/p\u003e\n\u003cp\u003eThe panes themselves had a second trick. Each kept pane leaves a split behind, and the next split is narrower. At three or more panes each one is about 6 columns wide. That is not a workspace. That is a breadstick. Every completion marker wraps across lines, and the match can never succeed. A correct 30 second task timed out at 306 seconds with its marker counted zero times.\u003c/p\u003e\n\u003cp\u003eThe multiplexer also truncates a multi-line prompt echo to its first line, which made my original baseline, count the marker in the prompt and require more than that, impossible to satisfy.\u003c/p\u003e\n\u003cp\u003eSo the supervisor I wrote would declare victory because the pane looked quiet, or because a wrapped word no longer matched itself, or because the prompt it was trying to count had already been chopped down to one line. Meanwhile the job was still running. Or hadn\u0026rsquo;t started. Or had been killed for being finished.\u003c/p\u003e\n\u003ch2 id=\"the-stale-quota-that-wasnt\"\u003eThe stale quota that wasn\u0026rsquo;t\u003c/h2\u003e\n\u003cp\u003eagy had a long-standing reputation in my setup for reporting stale usage numbers, so it kept getting held back from work. Can\u0026rsquo;t verify what\u0026rsquo;s left of the allowance, don\u0026rsquo;t spend it.\u003c/p\u003e\n\u003cp\u003eThe bug was in my own quota gate. It threw away a snapshot it had just verified as fresh whenever its refresh step tripped over anything. The check was manufacturing the exact staleness it was complaining about. I benched a teammate for a crime the babysitter committed. That one\u0026rsquo;s fixed now, with a regression test so that particular piece of shit can\u0026rsquo;t sneak back in the same shape. The real refresh takes about a second and a half.\u003c/p\u003e\n\u003cp\u003eAn exit code of 0 from a usage collector doesn\u0026rsquo;t mean it collected anything, either. One collector returns 0 without writing when another copy holds its lock. agy\u0026rsquo;s returns 0 for any record under 60 seconds old without republishing. Both are \u0026ldquo;working.\u0026rdquo; Neither tells you the thing you asked. The gate now judges the age of the record it gets back instead of the exit status.\u003c/p\u003e\n\u003cp\u003eThen I had Grok and Antigravity review the code that decides whether Grok and Antigravity are allowed to run, and they found two more. Grok found an unguarded error path that leaked a pseudo terminal handle every time the child process died at the wrong moment. A pseudo terminal is the fake terminal session the helper opens in order to run that child, and leak enough of them and it can never open another one. Antigravity found that a sixty second cache shortcut made a forced retry a no-op, so the one case where I really needed a fresh number was the case guaranteed not to get one. Ask again, get back the number you just rejected.\u003c/p\u003e\n\u003ch2 id=\"tonight\"\u003eTonight\u003c/h2\u003e\n\u003cp\u003eCodex sits at 100 percent of its weekly allowance and is held out of the rota until that resets, which is a hell of a way for a former boss to spend its first week back on the tools. Flint is at four percent and cheerful. Alby spent a chunk of the evening held as well, and this time the tooling was right to hold it: the thing had quietly signed itself out of its own account, so there was no allowance to check at all. Fixing that needed a human, a browser and about thirty seconds, which is the most honest description of my job here that I\u0026rsquo;ve managed all week.\u003c/p\u003e\n\u003cp\u003eI set out to build an AI team. What I\u0026rsquo;ve actually built, so far, is a very thorough system for finding out that nobody is finished when they say they are.\u003c/p\u003e\n","date_modified":"2026-09-17T20:00:00-07:00","date_published":"2026-09-17T20:00:00-07:00","id":"https://www.onelegdave.dev/posts/promoted-the-one-i-was-cancelling/","image":"https://www.onelegdave.dev/images/posts/promoted-the-one-i-was-cancelling.jpg","summary":"I gave four AI assistants a chain of command, a spending limit and code names, then spent the night finding out that the tools I built to supervise them kept reporting jobs finished while the jobs were still running.","tags":["AI crew"],"title":"I Promoted the One I Was Going to Cancel","url":"https://www.onelegdave.dev/posts/promoted-the-one-i-was-cancelling/"},{"content_html":"\u003cp\u003eI have three paid AI subscriptions. I want two. OpenAI\u0026rsquo;s ChatGPT Pro stays, Google AI Pro stays, Claude goes. A small administrative decision which has somehow involved a server, a searchable archive, two Linux machines, a Mac, and a laptop insisting it has absolutely no idea who I am while I\u0026rsquo;m already logged into it.\u003c/p\u003e\n\u003cp\u003eI should probably stop describing things as small administrative decisions.\u003c/p\u003e\n\u003cp\u003eClaude has been useful. There\u0026rsquo;s a lot of work in those conversations: code, project decisions, explanations, failed attempts, and the eventual discovery of which stupid little thing was causing the problem. I want to save twenty bucks. I would also quite like to avoid spending the rest of my life explaining my own projects back to a succession of helpful strangers.\u003c/p\u003e\n\u003cp\u003eSo before I cancel anything, I need to take the useful history with me.\u003c/p\u003e\n\u003ch2 id=\"packing-apparently\"\u003ePacking, apparently\u003c/h2\u003e\n\u003cp\u003eThe account export arrived as five ZIP files on the Mac. I copied them across and checked that they matched the originals using SHA-256 hashes, essentially digital fingerprints for the files. I also checked that the archives opened without errors. Everything passed.\u003c/p\u003e\n\u003cp\u003eAfter my previous adventure in deleting this website while carefully preserving my terminal colours, I\u0026rsquo;m trying to get better at checking what I\u0026rsquo;ve actually saved. The bar is low. I put it there myself.\u003c/p\u003e\n\u003cp\u003eThe export contained 59 conversations, along with project documents, saved memories, and summaries. I also collected the separate Claude Code histories from my Linux machines. Those are the records from using Claude in the terminal, where much of the coding work happened. Some of that history overlaps with the account export, so I kept track of where it came from rather than pretending every saved message was a unique contribution to human knowledge.\u003c/p\u003e\n\u003cp\u003eThe original files are preserved. Alongside them, I now have readable text versions and a searchable database, built with SQLite. That means I can look up a project or an old problem without opening dozens of conversations and trying to remember which one contained the useful bit.\u003c/p\u003e\n\u003cp\u003eThere\u0026rsquo;s also a starting document pointing to the relevant topics and correcting things that have changed. The old summaries are labelled as historical material. An assistant confidently saying something was true six conversations ago doesn\u0026rsquo;t make it true now. It might not even have been true then.\u003c/p\u003e\n\u003cp\u003eThis gives the next assistant somewhere to look before asking me to explain the whole fucking setup again. It still has to find the relevant history and check it against the current work. The old chats don\u0026rsquo;t magically appear in ChatGPT\u0026rsquo;s sidebar, and putting a ZIP file within reach of an AI does not mean it has read it.\u003c/p\u003e\n\u003ch2 id=\"a-note-saying-i-once-had-a-document\"\u003eA note saying I once had a document\u003c/h2\u003e\n\u003cp\u003eThe export also contained 180 references to files whose originals weren\u0026rsquo;t included.\u003c/p\u003e\n\u003cp\u003eReferences. Lovely.\u003c/p\u003e\n\u003cp\u003eSome attachment text survived, but text pulled out of a document doesn\u0026rsquo;t give me the original document back. A reference to an image is even less useful when what I need is the bloody image. The code repositories, manuscripts, and other original files still need their own preservation. A conversation about building something is a poor substitute for the thing I built.\u003c/p\u003e\n\u003cp\u003eThe archive now lives on my server, with a working copy on my main Linux machine. I\u0026rsquo;ve checked the server copy against the originals and tested the search. It\u0026rsquo;s in a directory covered by my existing local and offsite backup jobs, although I still need to verify a completed backup and restore containing this new material.\u003c/p\u003e\n\u003cp\u003eI have already written one blog post about discovering what my backups didn\u0026rsquo;t contain. I\u0026rsquo;d prefer not to establish a series.\u003c/p\u003e\n\u003cp\u003eThe archive is a snapshot of the history I\u0026rsquo;ve collected so far. It won\u0026rsquo;t quietly gather every future conversation by itself. My assistants have instructions pointing to it, but they need access to those files to use it. The apps on my phones haven\u0026rsquo;t suddenly acquired my entire project history either.\u003c/p\u003e\n\u003ch2 id=\"the-other-subscription\"\u003eThe other subscription\u003c/h2\u003e\n\u003cp\u003eGoogle AI Pro was already paid for, so getting proper use out of it was the next job. For coding work, that meant installing Google\u0026rsquo;s Antigravity tool, launched with the command \u003ccode\u003eagy\u003c/code\u003e, on the Linux machines and the Mac. Gemini is on both phones too.\u003c/p\u003e\n\u003cp\u003eThe second laptop needed a small networking repair before any of that: I had forgotten to reinstall Tailscale after reinstalling the operating system. Tailscale is what lets my machines reach each other securely when they\u0026rsquo;re elsewhere.\u003c/p\u003e\n\u003cp\u003eThe laptop wasn\u0026rsquo;t on the network because I hadn\u0026rsquo;t installed the software that puts it on the network. A difficult bug to report without the entire report being about me.\u003c/p\u003e\n\u003cp\u003eWith that fixed, I got its remaining history archived and signed into agy. A test in the laptop\u0026rsquo;s own terminal worked. The same basic test through SSH, the secure connection I use to run commands remotely, returned \u003ccode\u003eauthentication required\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eOf course it fucking did.\u003c/p\u003e\n\u003ch2 id=\"logged-in-depending-on-whos-asking\"\u003eLogged in, depending on who\u0026rsquo;s asking\u003c/h2\u003e\n\u003cp\u003eLinux keeps saved sign-in credentials in a protected store called a keyring. Mine was unlocked, and agy could use it from the desktop. The remote session still couldn\u0026rsquo;t sign in. Matching the desktop\u0026rsquo;s session settings didn\u0026rsquo;t fix it either.\u003c/p\u003e\n\u003cp\u003eWhat finally worked was letting agy use the existing desktop connection to that keyring while removing the environment markers that told the program it had been launched through SSH. The secure remote connection itself stayed intact. Only the information passed to the agy process changed.\u003c/p\u003e\n\u003cp\u003eI put that into a small launcher called \u003ccode\u003eagy-desktop\u003c/code\u003e, opened a fresh SSH connection, and tested again. It answered correctly, including following my project instructions. No copying passwords around, no disabling keyring protection, no surgery on the actual agy program.\u003c/p\u003e\n\u003cp\u003eIt still needs the desktop keyring to be accessible and unlocked. I haven\u0026rsquo;t proved it will work unattended after a reboot. Two successful tests are useful evidence, but I\u0026rsquo;m not promoting them to a lifetime guarantee because I\u0026rsquo;d like this paragraph to end happily.\u003c/p\u003e\n\u003cp\u003eI now have searchable history on the server and a working way to use the Google subscription I\u0026rsquo;m keeping. There are still original files to account for before I call the preservation job finished, and I haven\u0026rsquo;t cancelled Claude yet.\u003c/p\u003e\n\u003cp\u003eI set out to save twenty bucks and built a searchable archive with remote access. Apparently the cancel button is the only part of this process I haven\u0026rsquo;t needed to troubleshoot.\u003c/p\u003e\n","date_modified":"2026-09-11T18:00:00-07:00","date_published":"2026-09-11T18:00:00-07:00","id":"https://www.onelegdave.dev/posts/i-only-wanted-two-subscriptions/","image":"https://www.onelegdave.dev/images/posts/i-only-wanted-two-subscriptions.jpg","summary":"I wanted to save twenty bucks on AI subscriptions. That somehow required a searchable archive, a server, and a laptop refusing to recognise its own login.","tags":["AI crew"],"title":"I Only Wanted Two Subscriptions","url":"https://www.onelegdave.dev/posts/i-only-wanted-two-subscriptions/"},{"content_html":"\u003cp\u003eSecond plugin. This one puts your Android phone on your desktop: mirrored screen, mouse and keyboard control, no root, no Samsung account, no paid tier, no separate app installed on the phone begging for permissions it doesn\u0026rsquo;t need.\u003c/p\u003e\n\u003cp\u003eUnder the hood it\u0026rsquo;s scrcpy doing the actual mirroring, because scrcpy is excellent and rewriting it would be idiotic. KDE Connect is optional on top for battery status and Ring. What I built is the part that makes those two things pleasant to live with instead of a pile of terminal commands you look up every single time.\u003c/p\u003e\n\u003cp\u003eCurrently sitting in the Omarchy marketplace queue waiting on security review, which feels appropriate given how much of this post is about security decisions.\u003c/p\u003e\n\u003ch2 id=\"the-thing-that-took-the-most-thought\"\u003eThe thing that took the most thought\u003c/h2\u003e\n\u003cp\u003eGetting a phone mirrored is not hard. Getting a phone mirrored is \u003ccode\u003escrcpy\u003c/code\u003e and about four minutes of reading. The hard part is everything around it, and specifically one question that came up over and over in different costumes: when is this software allowed to guess?\u003c/p\u003e\n\u003cp\u003eTake phone identity. Multiple connections can point at the same physical phone. USB and Wi-Fi at the same time, IPv4 and IPv6, an address that changed when your router handed out a new lease. You want those grouped into one card, obviously, because they\u0026rsquo;re one phone.\u003c/p\u003e\n\u003cp\u003eBut grouping is a claim. If I merge two connections into one card, I\u0026rsquo;m asserting they\u0026rsquo;re the same device, and if I\u0026rsquo;m wrong your input goes somewhere you didn\u0026rsquo;t intend. So merging only happens on hardware identity that Android actually reports. Matching model names never merges anything. Matching nicknames never merges anything. Two Pixels with the same nickname are two phones until the hardware says otherwise, because \u0026ldquo;they\u0026rsquo;re both called Pixel\u0026rdquo; is a coincidence, not evidence.\u003c/p\u003e\n\u003cp\u003eIf Android refuses the identity query and discovery can\u0026rsquo;t work it out, the connection stays separate as an unresolved entry rather than getting quietly filed under whichever phone looks closest. An unmerged duplicate is a minor annoyance. A wrong merge sends your keystrokes to the wrong device.\u003c/p\u003e\n\u003ch2 id=\"reconnecting-without-being-creepy-about-it\"\u003eReconnecting without being creepy about it\u003c/h2\u003e\n\u003cp\u003eRemembered phones reconnect automatically when discovery advertises the same identity, even if the address and port both changed. That\u0026rsquo;s genuinely convenient and it\u0026rsquo;s the behavior you want.\u003c/p\u003e\n\u003cp\u003eWhat it does not do is automatically pair with phones it\u0026rsquo;s never seen. It does not start a mirror session on its own. Retries are capped at once per address every 30 seconds, and you can turn the whole thing off. After a successful pairing there\u0026rsquo;s a ten minute window where discovery can finish the connection, and then it stops.\u003c/p\u003e\n\u003cp\u003eThe difference matters. Reconnecting to a phone you already explicitly authorized is completing something you started. Pairing with an unknown phone because it happened to show up on the network is a decision nobody asked it to make.\u003c/p\u003e\n\u003cp\u003eSame principle as the GPU selection in my last plugin: auto mode gets to be clever, explicit choices get respected exactly as made.\u003c/p\u003e\n\u003ch2 id=\"wake-is-not-unlock\"\u003eWake is not unlock\u003c/h2\u003e\n\u003cp\u003eThere\u0026rsquo;s a Wake/unlock button for when the phone\u0026rsquo;s asleep. It sends Android\u0026rsquo;s wake command and asks Android to show its normal unlock prompt. Then it stops and you unlock the phone yourself.\u003c/p\u003e\n\u003cp\u003eIt does not submit credentials. It does not bypass a secure lock. It cannot, and it shouldn\u0026rsquo;t be able to, and I\u0026rsquo;m not interested in building the version that tries. Lock screens and capture-protected apps keep enforcing their restrictions through the mirror exactly as they should, and if that\u0026rsquo;s inconvenient in some specific app, good. That\u0026rsquo;s the lock working.\u003c/p\u003e\n\u003ch2 id=\"what-it-does-not-do-deliberately\"\u003eWhat it does not do, deliberately\u003c/h2\u003e\n\u003cp\u003eNo telemetry. No advertising. No cloud relay. No automatic update check. No crash upload. Nothing phones home, ever.\u003c/p\u003e\n\u003cp\u003eRuntime phone endpoints are restricted to private local-network addresses. There\u0026rsquo;s no support for pointing it at a public Internet endpoint or overriding the remote ADB server, because a phone-mirroring tool that can be aimed at an arbitrary remote host is a substantially different and worse thing than one that can only talk to your own LAN.\u003c/p\u003e\n\u003cp\u003eAutomatic clipboard sharing is off. You paste with an explicit keystroke or it doesn\u0026rsquo;t happen. Pairing codes go through standard input, get cleared from the panel, and never touch disk or appear in a process command line where anything reading the process list could scoop them up.\u003c/p\u003e\n\u003cp\u003eNothing gets installed just because you opened the panel. There are Install buttons for missing tools, and they do exactly nothing until clicked.\u003c/p\u003e\n\u003cp\u003eAnd it\u0026rsquo;s honest about what authorization actually means: ADB pairing gives this computer debugging access to your phone, disabling the plugin does not revoke that, and if you stop trusting a machine you need to forget it in Android\u0026rsquo;s own settings. That\u0026rsquo;s in the README rather than buried, because someone who installs this should understand what they just handed out.\u003c/p\u003e\n\u003ch2 id=\"the-bit-im-quietly-pleased-with\"\u003eThe bit I\u0026rsquo;m quietly pleased with\u003c/h2\u003e\n\u003cp\u003eThe four-step connect guide. Desktop, Phone, Pair, Ready. Every one of those steps can fail in its own specific way and each failure gets its own specific message instead of a generic \u0026ldquo;could not connect\u0026rdquo; that leaves you guessing which of five things went wrong.\u003c/p\u003e\n\u003cp\u003eWireless pairing on Android has a genuine trap in it: the pairing port and the connection port are different numbers, and nothing about the Android UI makes that obvious. People get it wrong constantly and then have no idea why nothing works. So the plugin calls it out directly, in bold, right where you\u0026rsquo;d hit it.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s not clever engineering. It\u0026rsquo;s just having actually used the thing and noticed where I personally got stuck.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eomarchy plugin add https://github.com/onelegdave/omadroid --enable\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eWaiting on security review for the marketplace. In the meantime it installs fine straight from the repo, and every command it can run and every network endpoint it can reach is inventoried in SECURITY.md if you\u0026rsquo;d rather check before trusting it. Which, for something that gets debugging access to your phone, you probably should.\u003c/p\u003e\n","date_modified":"2026-09-11T14:00:00-07:00","date_published":"2026-09-11T14:00:00-07:00","id":"https://www.onelegdave.dev/posts/omadroid/","image":"https://www.onelegdave.dev/images/posts/omadroid.jpg","summary":"My second Omarchy plugin mirrors an Android phone onto the desktop. Most of the actual work was deciding when software is allowed to guess on your behalf, and when it absolutely is not.","tags":["Plugins","Linux"],"title":"The Droid You Are Looking For","url":"https://www.onelegdave.dev/posts/omadroid/"},{"content_html":"\u003cp\u003eIn the last week Omarchy has quietly run my GPU at half power, left a 165Hz monitor sitting at 60, and been the direct cause of me deleting my own website. Genuinely rough introduction. Both of the first two got their own full autopsy, and the third one was entirely my fault but would not have happened if I hadn\u0026rsquo;t been reinstalling in the first place.\u003c/p\u003e\n\u003cp\u003eI\u0026rsquo;m still running it. I\u0026rsquo;m going to keep running it. Not out of stubbornness, and not because I\u0026rsquo;ve talked myself into pretending the problems weren\u0026rsquo;t real. Because for the first time in a long time my computer feels like mine instead of like a rental.\u003c/p\u003e\n\u003ch2 id=\"thirty-nine-seconds\"\u003eThirty-nine seconds\u003c/h2\u003e\n\u003cp\u003eI timed an install at thirty-nine seconds. Full desktop, Hyprland running, tiling, themed, done. Windows took over an hour on comparable hardware and spent a good chunk of that time asking me to sign into things and declining to take no for an answer.\u003c/p\u003e\n\u003cp\u003eThat gap isn\u0026rsquo;t just a fun number to post. Fast installs change your relationship with the machine. When reinstalling costs you an hour of babysitting plus an evening of putting everything back, you don\u0026rsquo;t experiment. You get conservative. You leave broken things broken because fixing them might mean starting over, and starting over is a whole weekend.\u003c/p\u003e\n\u003cp\u003eWhen it costs thirty-nine seconds, \u0026ldquo;let me just wipe it and try that differently\u0026rdquo; stops being a threat and becomes a shrug. That single change quietly unlocks every other good thing here, because it makes being wrong cheap.\u003c/p\u003e\n\u003ch2 id=\"the-extension-surface-is-actually-reachable\"\u003eThe extension surface is actually reachable\u003c/h2\u003e\n\u003cp\u003eI wrote a plugin for this thing. An actual published one, a native system monitor that lives in the bar, reads telemetry straight off the kernel, and pulls every color it draws from the active theme.\u003c/p\u003e\n\u003cp\u003eI want to be clear about how unusual that is from where I\u0026rsquo;m standing, because I had zero programming experience not that long ago. On most desktops, \u0026ldquo;write your own panel widget\u0026rdquo; is a project you research for a week and then abandon. Here the plugin API was documented, the validator told me what I\u0026rsquo;d broken before anyone else had to, and installing it was one command. The distance between \u0026ldquo;I wish this existed\u0026rdquo; and \u0026ldquo;this exists and I\u0026rsquo;m using it\u0026rdquo; was days, not never.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s not the distro being magic. That\u0026rsquo;s a system that assumed from the start you might want to change it, versus systems built on the assumption you\u0026rsquo;d better not try.\u003c/p\u003e\n\u003ch2 id=\"everything-matches-because-everything-can\"\u003eEverything matches, because everything can\u003c/h2\u003e\n\u003cp\u003eMy desktop is a specific set of colors I picked. My terminal matches. My bar matches. My plugin matches, automatically, because it reads the theme rather than hardcoding anything. When I built this blog, the palette came out of my own desktop, and the banner came out of the same aesthetic.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s a stupid thing to care about. I care about it a lot. A machine that looks like something you chose rather than something you were assigned is a machine you actually want to sit down at, and I refuse to be embarrassed about that.\u003c/p\u003e\n\u003ch2 id=\"the-part-where-it-broke-everything\"\u003eThe part where it broke everything\u003c/h2\u003e\n\u003cp\u003eNow the honest bit, because a post that only lists the good parts is an advert and I\u0026rsquo;m not writing one of those.\u003c/p\u003e\n\u003cp\u003eThe GPU thing was genuinely bad. \u003ccode\u003envidia-powerd\u003c/code\u003e never gets enabled on install, so my RTX 5060 sat pinned at 50 watts instead of boosting to 100, silently, with no error and no warning, on a laptop that had been flawless for a year prior. I only found it because I got suspicious enough to check numbers instead of trusting vibes. That\u0026rsquo;s a real bug with a real cost, and it shipped.\u003c/p\u003e\n\u003cp\u003eThe monitor thing was the same flavor: 165Hz panel running at 60 because the config takes whatever refresh rate the monitor advertises first rather than the highest one available. Two unrelated bugs stacked on top of each other, both invisible, both making the machine feel worse than it was in ways I couldn\u0026rsquo;t immediately articulate.\u003c/p\u003e\n\u003cp\u003eAnd then I reinstalled and lost my entire blog, because I\u0026rsquo;d been building it locally without a remote. Not Omarchy\u0026rsquo;s fault in any meaningful sense. But it happened because I was reinstalling, and I was reinstalling because the install is cheap, which is exactly the freedom I just spent three paragraphs praising. Cheap experiments cut both ways.\u003c/p\u003e\n\u003ch2 id=\"why-thats-not-a-contradiction\"\u003eWhy that\u0026rsquo;s not a contradiction\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s the thing though: I could actually fix all of it.\u003c/p\u003e\n\u003cp\u003eThe GPU bug, I proved with a controlled experiment, masking and unmasking the daemon on two different distros until the number moved exactly where I predicted, then filed a bug report with reproduction steps clean enough that there was nothing left to argue about. The monitor bug was a config line. The lost blog got rebuilt from my own notes in fifteen minutes and now has three separate layers of backup that didn\u0026rsquo;t exist before, which is strictly better than what I had.\u003c/p\u003e\n\u003cp\u003eNone of that is possible on a system that treats you as a consumer. When something\u0026rsquo;s broken on a sealed appliance, your options are wait, or accept it, or buy a different appliance. Here, every single problem was mine to dig into, understand, and either fix or report properly. Even the failures were interesting.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s what I actually mean by fun. Not that nothing goes wrong. Plenty goes wrong. It\u0026rsquo;s that when something does go wrong, I get to open it up and find out why, and I usually can, and sometimes the fix I send back helps someone else who would\u0026rsquo;ve hit the same wall.\u003c/p\u003e\n\u003cp\u003eI had forgotten computers could be like that. Somewhere between work machines I don\u0026rsquo;t control and phones I\u0026rsquo;m not allowed to open, I\u0026rsquo;d drifted into treating my own hardware as something that happens to me rather than something I do things with.\u003c/p\u003e\n\u003cp\u003eThirty-nine seconds to install, an afternoon to write a plugin, a week to find a bug nobody else had pinned down. It broke my GPU and I like it anyway.\u003c/p\u003e\n","date_modified":"2026-09-10T16:00:00-07:00","date_published":"2026-09-10T16:00:00-07:00","id":"https://www.onelegdave.dev/posts/fun-again/","image":"https://www.onelegdave.dev/images/posts/fun-again.jpg","summary":"Omarchy cost me half a GPU, a monitor running at a third its refresh rate, and an entire website. I am still here, and my computer is fun again for the first time in years.","tags":["Linux","Omarchy"],"title":"It Broke My GPU and I Like It Anyway","url":"https://www.onelegdave.dev/posts/fun-again/"},{"content_html":"\u003cp\u003eI wanted a system monitor in my bar that actually matched my theme instead of sitting there as a mismatched floating terminal window I had to manually position every single time I logged in. The honest first version of this project was embarrassing: wrap btop, throw it in a panel, call it done. It even shipped under that name internally. Then I actually sat with it for a week and got annoyed enough to rebuild the whole thing properly.\u003c/p\u003e\n\u003cp\u003eWhat exists now is System QuikView, a native Omarchy Shell plugin. No btop. No root service. No cloud account, no pip packages, nothing phoning home. It reads Linux telemetry directly off \u003ccode\u003e/proc\u003c/code\u003e and sysfs, renders it through Quickshell\u0026rsquo;s own QML, and pulls every single color it displays from whatever Omarchy theme happens to be active. Switch themes, the widget updates live, because it was never allowed to hardcode a color to begin with.\u003c/p\u003e\n\u003ch2 id=\"the-migration-path-i-owe-past-me\"\u003eThe migration path I owe past me\u003c/h2\u003e\n\u003cp\u003eThe installer still knows how to find and migrate that original btop-wrapper prototype: same placement, same preferences, straight upgrade path, no starting over. Small thing, but I\u0026rsquo;m glad I didn\u0026rsquo;t just delete the evidence and pretend the finished version sprang out fully formed. It didn\u0026rsquo;t. It started as duct tape around someone else\u0026rsquo;s tool and got replaced piece by piece until none of the original tape was left.\u003c/p\u003e\n\u003ch2 id=\"the-design-decision-that-actually-took-thought\"\u003eThe design decision that actually took thought\u003c/h2\u003e\n\u003cp\u003eMost of the surface area here is fairly mechanical: read a number, put it on a graph, color it by theme. The one part that made me stop and actually think came from GPU handling, and specifically from a question that sounds trivial until you sit with it: what does the widget show when a sensor genuinely has no reading?\u003c/p\u003e\n\u003cp\u003eThe lazy answer is zero. Zero is wrong, and it\u0026rsquo;s wrong in a way that\u0026rsquo;s actively dangerous for exactly the same reason a blank medication list is dangerous. Zero degrees is not \u0026ldquo;no reading available,\u0026rdquo; it\u0026rsquo;s a specific, false, freezing-cold claim about a piece of hardware that might currently be on fire. A missing value and an actual value of zero are two completely different facts, and collapsing them into the same digit is a decision to lie every single time the sensor doesn\u0026rsquo;t answer.\u003c/p\u003e\n\u003cp\u003eSo a missing sensor shows a plain dash. Not a number pretending to be data. An honest admission that nothing came back.\u003c/p\u003e\n\u003cp\u003eThat same principle runs through the multi-GPU handling too. If you explicitly pick a specific GPU by name and it disconnects, the widget does not quietly fail over to whichever GPU happens to still be plugged in and keep drawing a graph like nothing happened. It shows that reading as unavailable and waits for you to notice, because silently substituting a different device\u0026rsquo;s numbers under the same label is its own kind of lie, just dressed up as helpfulness.\u003c/p\u003e\n\u003ch2 id=\"auto-mode-gets-to-be-smart-explicit-mode-does-not\"\u003eAuto mode gets to be smart, explicit mode does not\u003c/h2\u003e\n\u003cp\u003eThere\u0026rsquo;s a real tension between \u0026ldquo;just work\u0026rdquo; and \u0026ldquo;never guess wrong,\u0026rdquo; so I split it into two modes on purpose instead of picking one philosophy and forcing it everywhere.\u003c/p\u003e\n\u003cp\u003eAuto mode is allowed to be clever: prefer a dedicated GPU when one\u0026rsquo;s available and reporting, fall back to whatever else exists otherwise, and it can pick different actual devices for usage versus temperature versus VRAM if that\u0026rsquo;s genuinely what\u0026rsquo;s available. That\u0026rsquo;s a real convenience and I don\u0026rsquo;t think it\u0026rsquo;s dishonest, because nobody who picks \u0026ldquo;auto\u0026rdquo; is claiming to know or care which specific piece of silicon answers.\u003c/p\u003e\n\u003cp\u003eBut the moment you pin a specific device by name, you\u0026rsquo;ve made a claim, and the widget\u0026rsquo;s job flips from being helpful to being accurate to that claim. It doesn\u0026rsquo;t get to quietly reinterpret \u0026ldquo;show me this GPU\u0026rdquo; as \u0026ldquo;show me a GPU\u0026rdquo; just because the first one went away. Same underlying data source, two completely different obligations depending on whether a human explicitly chose a specific thing or asked for the software\u0026rsquo;s best guess.\u003c/p\u003e\n\u003ch2 id=\"what-actually-shipped\"\u003eWhat actually shipped\u003c/h2\u003e\n\u003cp\u003eBar reading you can set to CPU, memory, GPU usage, temperature, VRAM, network speed, disk usage, or nothing but an icon. Click it, get a full dashboard: history graphs, per-process ranking by CPU then memory, temperature gauges that use the hardware\u0026rsquo;s own reported warning thresholds when it has them and fall back to a plain labeled scale when it doesn\u0026rsquo;t. Sections reorder and collapse, and that layout survives a restart instead of resetting itself every time like it\u0026rsquo;s personally offended you touched it.\u003c/p\u003e\n\u003cp\u003eTests cover the boring-but-load-bearing stuff: no GPU present, one GPU, several GPUs, a GPU that vanishes mid-session, missing readings across the board. \u003ccode\u003epython3 -m unittest\u003c/code\u003e, a separate Node suite for the selection logic in \u003ccode\u003eModel.js\u003c/code\u003e, a QML linter, and a plugin validator Omarchy itself ships to catch structural mistakes before they become someone else\u0026rsquo;s bug report.\u003c/p\u003e\n\u003cp\u003eInstalled like any other Omarchy plugin:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eomarchy plugin add https://github.com/onelegdave/system-quikview.git --enable\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eFirst one of these I\u0026rsquo;ve actually finished and put out publicly rather than leaving half-built in a folder labeled \u0026ldquo;someday.\u0026rdquo; The btop wrapper would have shipped faster. It also would have kept lying to me about GPU temperature the first time a sensor didn\u0026rsquo;t answer, and I\u0026rsquo;d rather have spent the extra week than ship a monitor I can\u0026rsquo;t actually trust the numbers on.\u003c/p\u003e\n","date_modified":"2026-09-06T11:00:00-07:00","date_published":"2026-09-06T11:00:00-07:00","id":"https://www.onelegdave.dev/posts/zero-is-a-lie/","image":"https://www.onelegdave.dev/images/posts/zero-is-a-lie.jpg","summary":"My first published Omarchy plugin started as a lazy wrapper around btop and ended with me ripping btop out entirely. Along the way I had to decide what a system monitor is allowed to say when it genuinely does not know something.","tags":["Plugins","Linux"],"title":"Zero Is a Lie","url":"https://www.onelegdave.dev/posts/zero-is-a-lie/"},{"content_html":"\u003cp\u003eSame medication tracker as last time, different day, different problem. This one isn\u0026rsquo;t about the app going dark. It\u0026rsquo;s about everything technically working and still being wrong.\u003c/p\u003e\n\u003cp\u003e147 tests. Nearly 2,000 lines of Kotlin. Everything green. I deployed it, used it for real for about ten minutes, and found four separate ways it was lying to me, none of which a single one of those 147 tests could ever have caught, because they weren\u0026rsquo;t bugs in what the code did. They were bugs in what the running app was telling a human standing in front of it.\u003c/p\u003e\n\u003ch2 id=\"arithmetic-first-because-the-rest-doesnt-matter-if-this-is-wrong\"\u003eArithmetic first, because the rest doesn\u0026rsquo;t matter if this is wrong\u003c/h2\u003e\n\u003cp\u003eBefore any database, any HTTP, anything, I wrote the days-remaining math as plain Kotlin with zero dependencies, because if this number\u0026rsquo;s wrong, the app isn\u0026rsquo;t useless, it\u0026rsquo;s actively dangerous. Confidently wrong about whether you\u0026rsquo;re about to run out of insulin is worse than not having an app at all.\u003c/p\u003e\n\u003cp\u003eThe spec had it as two separate divisions to average weekly and as-needed doses onto one timeline. I collapsed both into one exact fraction, floored once, instead of doing the math in floating point:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003edays_remaining = floor( units * 210 / (weekly * 30 + prn_total * 7) )\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eNot being clever for the sake of it. Binary floating point flat-out gets this wrong on exact boundaries: 4.5 units on hand, 4.5 used weekly, the honest answer is 7 days, and a \u003ccode\u003eDouble\u003c/code\u003e hands you 6. I swept roughly 56,000 schedule combinations against exact rational math to check. About 0.2% disagreed, always off by exactly one, always sitting right on a boundary. Rare enough to sail through code review unnoticed. Common enough to flip a real alert on a real person.\u003c/p\u003e\n\u003cp\u003eThere\u0026rsquo;s now a test that asserts the naive floating-point version returns the wrong number on purpose, so future-me, or anyone else who thinks they\u0026rsquo;re \u0026ldquo;simplifying\u0026rdquo; this, finds out immediately why it\u0026rsquo;s written the ugly way.\u003c/p\u003e\n\u003ch2 id=\"breaking-my-own-tests-on-purpose\"\u003eBreaking my own tests on purpose\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s the practice that earned its keep hardest, and it\u0026rsquo;s embarrassingly simple: for anything a test claims to guard, break the actual mechanism on purpose and confirm the test goes red. Then put it back.\u003c/p\u003e\n\u003cp\u003eSounds like paranoid busywork. It caught four tests that were passing for entirely the wrong reason, quietly guarding nothing, forever, until I forced the question.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMoved the error-catch inside the transaction.\u003c/strong\u003e Ten repeat taps took stock down to 50 instead of 59. SQLite aborts the one bad statement, not the whole transaction, so a failed deduction silently committed without its matching dose row. Three tests caught it, that\u0026rsquo;s a test doing its job.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDeleted the HTTP method guard in the service worker.\u003c/strong\u003e Every test still passed. Turns out every write case in the test suite happened to route through \u003ccode\u003e/api\u003c/code\u003e and got caught by a completely different check, so that guard was dead weight nobody would\u0026rsquo;ve noticed missing until it mattered.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDeleted the offline navigation fallback.\u003c/strong\u003e Still green. The tests requested \u003ccode\u003e/\u003c/code\u003e, which is precached and always resolves fine, but Android tacks a query string onto \u003ccode\u003estart_url\u003c/code\u003e on a real home-screen launch, and cache matching compares the full URL including that string. A real phone launch would\u0026rsquo;ve missed the cache entirely and gotten nothing, while every single test sailed past it.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDisabled the SQLite foreign-key pragma.\u003c/strong\u003e Exactly one test failed, which is the correct outcome, and the one I actually wanted to see. Foreign keys are off by default in SQLite, enabled per-connection, and any connection that forgets the pragma just quietly accepts orphaned rows forever.\u003c/p\u003e\n\u003cp\u003eTwo of those four had the actual mechanism removed and the suite stayed green. A green test suite proves the tests ran. It proves nothing about the code until you\u0026rsquo;ve personally watched each one fail for the reason it claims to exist.\u003c/p\u003e\n\u003ch2 id=\"things-only-the-real-server-was-rude-enough-to-tell-me\"\u003eThings only the real server was rude enough to tell me\u003c/h2\u003e\n\u003cp\u003eRan clean locally. Deployed to the actual box and immediately hit three problems no amount of local testing was ever going to surface.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eA tmpfs mounted \u003ccode\u003enoexec\u003c/code\u003e.\u003c/strong\u003e First run died instantly: \u003ccode\u003eUnsatisfiedLinkError: failed to map segment from shared object\u003c/code\u003e. The SQLite driver extracts its native library to the JVM temp directory and loads it from there. Docker mounts \u003ccode\u003e--tmpfs noexec\u003c/code\u003e by default, so the load just failed on the very first database open. The error message says nothing whatsoever about mount flags. \u003ccode\u003e/tmp\u003c/code\u003e is now mounted exec, with a comment explaining exactly why, because the \u0026ldquo;obvious\u0026rdquo; future hardening move is a crash loop.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eA port that was already spoken for.\u003c/strong\u003e Installing to a phone needs a service worker, which needs a secure context: HTTPS or bust. Plain HTTP over the tailnet serves the app fine and just silently never offers to install, no warning. So: real cert via Tailscale Serve, which defaults to 443, except 443 on that box was already doing something completely unrelated. Publishing there would\u0026rsquo;ve shadowed an existing service\u0026rsquo;s cert and taken it down as a side effect of deploying a medication app, which is the kind of blast radius nobody signs up for on purpose. Moved to a different port. Costs nothing, a secure context cares about scheme, not port number.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eCompose stacks that fail without telling anyone.\u003c/strong\u003e On that server, containers aren\u0026rsquo;t wrapped in systemd units, they lean entirely on Docker\u0026rsquo;s own restart policy. Which means a crash-looping container restarts forever, silently, and nothing ever reports it. I\u0026rsquo;d written that exact fact down months earlier and completely forgotten I had. A dead media server is obvious the second you try to watch something. A dead medication tracker is invisible until the specific morning you go to check it and it isn\u0026rsquo;t there.\u003c/p\u003e\n\u003cp\u003eFix ended up being the nightly low-stock alert script, since that one \u003cem\u003edoes\u003c/em\u003e run as a proper systemd timer with real failure notification wired up, but only if it\u0026rsquo;s built to actually notice a dead server instead of politely assuming an empty response means everything\u0026rsquo;s fine:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eALERTS=\u0026#34;$(curl -fsS --max-time 20 \u0026#34;$API/api/alerts\u0026#34;)\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eThat \u003ccode\u003e-f\u003c/code\u003e flag is doing the entire job. Without it, a dead container hands back an error page, the script parses zero alerts out of it, and reports \u0026ldquo;all clear\u0026rdquo; every single night the app is down. Silence dressed up as reassurance. The gap between a real alerting system and one that just resembles one was a single missing character in a shell script.\u003c/p\u003e\n\u003cp\u003eI didn\u0026rsquo;t just trust that. Stopped the container, ran the check by hand, watched it die with exit 7, watched systemd catch the failure, watched the notification land on my phone. Good time to prove that chain: database was still empty.\u003c/p\u003e\n\u003ch2 id=\"what-ten-minutes-of-actually-using-it-found\"\u003eWhat ten minutes of actually using it found\u003c/h2\u003e\n\u003cp\u003eEverything above, tested and proven. Then I installed it for real, put in my actual medications, and tapped \u0026ldquo;take all\u0026rdquo; for the morning. Four bugs inside ten minutes, and not one of them was about what the code computed. Every single one was about what the app told a person looking at the screen.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eOffline hung instead of failing.\u003c/strong\u003e Airplane mode, app opens to its splash screen, and just\u0026hellip; stays there. Forever. No app, no error, nothing. An unreachable server on a tailnet doesn\u0026rsquo;t always politely refuse the connection. The name still resolves, nothing rejects the socket, the fetch just never resolves either way. My service worker was awaiting it with zero timeout, so the whole page had nothing to render and nothing to say. Every network call now has an actual number on it. Reads give up after 4 seconds since someone\u0026rsquo;s standing there waiting and a read is cheap to retry, writes get 8 because a timed-out write might have landed and the only honest move is to make you go check.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eA blank screen making a claim it hadn\u0026rsquo;t earned.\u003c/strong\u003e With the hang fixed, it correctly showed a red \u0026ldquo;can\u0026rsquo;t reach server\u0026rdquo; banner, sitting directly above a completely empty medication list. Which is the single worst thing this specific app is allowed to display, because an empty list and \u0026ldquo;nothing due today\u0026rdquo; are visually identical, and one of those two things at six in the morning is dangerous to get wrong. Fixed: no list is ever blank by default now. Loading says loading. Failure says failure, explicitly, as \u0026ldquo;can\u0026rsquo;t reach the server,\u0026rdquo; never silently rendered as an empty day.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDoses landing on the wrong calendar day.\u003c/strong\u003e Tapped \u0026ldquo;take all,\u0026rdquo; and it logged against yesterday. Today then cheerfully offered to let me take everything again, since as far as it could tell, nothing had happened yet. The write itself was fine, stock even decremented correctly, the client had simply sent the wrong date. I never fully pinned down how it drifted onto the wrong day, so the fix targets the whole category rather than one cause: a loud banner when you\u0026rsquo;re not viewing today, a confirmation before bulk-logging against a past date, and the fix that\u0026rsquo;ll actually matter, following the real date across a midnight rollover on resume, since this is an app that gets opened right before bed and first thing in the morning.\u003c/p\u003e\n\u003cp\u003eThe fourth bug that day was the app going completely dark on every device while the server was perfectly healthy. That one got its own post, \u003ca href=\"https://www.onelegdave.dev/posts/server-never-down/\"\u003eThe Server Was Never Down\u003c/a\u003e.\u003c/p\u003e\n\u003ch2 id=\"the-default-i-refused-to-ship\"\u003eThe default I refused to ship\u003c/h2\u003e\n\u003cp\u003eLast thing that came out of actually using it: an as-needed medication was projecting \u003cstrong\u003e690 days remaining\u003c/strong\u003e on a bottle of ibuprofen. Technically derived correctly from the math. Read as complete nonsense the second a human looked at it, because a rate based on whatever happened to occur in the last thirty days swings wildly on a single dose and dresses that swing up as precision it doesn\u0026rsquo;t have.\u003c/p\u003e\n\u003cp\u003eFix: as-needed medications alert on raw units left instead of a days projection. That also patched a hole nobody had noticed. Under the days-based rule, an as-needed medication with nothing logged in the past month has no projection \u003cem\u003eat all\u003c/em\u003e, and a null projection never alerts, so it could sit at two tablets left and say absolutely nothing about it.\u003c/p\u003e\n\u003cp\u003eThe migration wanted a sensible default threshold backfilled onto every existing as-needed medication, ten units seemed reasonable. Checked it against the real data before shipping it, because \u0026ldquo;seemed reasonable\u0026rdquo; is not a threshold, it\u0026rsquo;s a guess wearing a lab coat:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eGabapentin          64 units\nLispro (Insulin)  1385 units   \u0026lt;- 10-unit threshold\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eA ten-unit warning on 1385 units of insulin would functionally never fire, on the exact medication my own original spec singled out by name as the one where running short is not a minor inconvenience. Ten\u0026rsquo;s fine for a bottle of tablets and dangerously wrong for that. There is no single default that\u0026rsquo;s safe across both, so the migration adds the column and backfills nothing. Existing meds keep their days-based threshold until a real number gets chosen for each one, and the reasoning\u0026rsquo;s written directly into the migration file so nobody \u0026ldquo;helpfully\u0026rdquo; finishes the job for me later without reading why it was left undone.\u003c/p\u003e\n\u003cp\u003eWorth naming the general shape of that mistake: a default is a decision made on someone else\u0026rsquo;s behalf without knowing their actual situation. Usually that\u0026rsquo;s a harmless convenience. The moment that number is the thing deciding whether a warning fires at all, it stops being convenient and starts being a bet you didn\u0026rsquo;t ask permission to make.\u003c/p\u003e\n\u003ch2 id=\"breaking-my-own-rule-on-purpose-in-writing\"\u003eBreaking my own rule, on purpose, in writing\u003c/h2\u003e\n\u003cp\u003eThat change directly contradicted a line in my own spec, filed under a heading that literally said \u003cem\u003esettled, do not reopen during implementation\u003c/em\u003e. That rule exists for a good reason: it\u0026rsquo;s there to stop you relitigating design decisions mid-build out of boredom or anxiety.\u003c/p\u003e\n\u003cp\u003eBut that rule was written before the thing existed, and actually running it produced evidence that plain didn\u0026rsquo;t exist at the time I wrote the rule down. So I reopened it anyway, and documented in the spec itself that it was reopened, when, and specifically why, instead of just quietly contradicting a document I\u0026rsquo;d told myself not to touch. The original math was never wrong. It was unhelpful in a way you can only discover from the far side of a system that actually runs.\u003c/p\u003e\n\u003cp\u003e147 tests, all green, the whole time. None of them were ever going to catch a number that was correct and useless, a screen that was blank and honest at the same time, or a default that was reasonable for one drug and reckless for another. That\u0026rsquo;s not a gap in the test suite. It\u0026rsquo;s the actual, permanent boundary of what a test suite can tell you: it verifies what code does. Only running the thing in front of an actual person tells you what it means to them.\u003c/p\u003e\n","date_modified":"2026-09-05T09:00:00-07:00","date_published":"2026-09-05T09:00:00-07:00","id":"https://www.onelegdave.dev/posts/tested-deployed-wrong/","image":"https://www.onelegdave.dev/images/posts/tested-deployed-wrong.jpg","summary":"147 tests, a proven arithmetic fix, a clean deployment. Then I actually used the thing for ten minutes and found four bugs the tests were structurally incapable of seeing.","tags":["Self-hosted"],"title":"Tested. Deployed. Wrong Anyway.","url":"https://www.onelegdave.dev/posts/tested-deployed-wrong/"},{"content_html":"\u003cp\u003eI built myself a medication tracker. Single user, me, running on my own server, reachable only over my own private network. Not a health app in the App Store sense — no dose advice, no interactions, no clinical logic anywhere in it. It counts what I have, tells me what\u0026rsquo;s due, and yells at me before I run out. That\u0026rsquo;s the entire scope, on purpose, because the second a personal project starts pretending to be a doctor is the second it becomes a fucking liability instead of a tool.\u003c/p\u003e\n\u003cp\u003eI finished it. Deployed it. Started actually using it for real medications, which is the part that matters — a supply tracker that only ever gets tested with fake data hasn\u0026rsquo;t actually been tested for shit. And within days it started telling me it couldn\u0026rsquo;t reach the server. From my phone. From my desktop. Every device, same error, connection refused, like the whole thing had just died in the night.\u003c/p\u003e\n\u003cp\u003eThe server had not died in the night. The server had never been healthier. I was staring at a goddamn ghost.\u003c/p\u003e\n\u003ch2 id=\"guilty-until-proven-guilty\"\u003eGuilty until proven guilty\u003c/h2\u003e\n\u003cp\u003eFirst instinct, obviously: check the server. It was fine. Container healthy, every endpoint answering, curl from a completely different device on the network getting clean 200s back like nothing was wrong, because nothing fucking was.\u003c/p\u003e\n\u003cp\u003eSecond instinct: check the network. Also fine. Nothing had changed there in weeks.\u003c/p\u003e\n\u003cp\u003eThird instinct, the one that should embarrass me a little: assume the phone was lying, restart it, uninstall the app, reinstall it from the home screen, watch it fail exactly the same way, immediately, with total confidence, like it had done this before.\u003c/p\u003e\n\u003cp\u003eIt had done this before. It had done this every single time, because the thing I was reinstalling wasn\u0026rsquo;t actually broken — it was faithfully, perfectly doing exactly what a Progressive Web App is designed to do, and the design was the fucking problem.\u003c/p\u003e\n\u003ch2 id=\"the-ghost-was-mine\"\u003eThe ghost was mine\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s the part that stung once I found it: a PWA doesn\u0026rsquo;t get installed from an app store with a name and a version number. It gets installed from a URL, and it stays bound to that exact origin forever. Not the app. The address. Reinstalling doesn\u0026rsquo;t fix a stale origin, it just re-commits to the same dead one, because as far as the phone\u0026rsquo;s concerned that origin \u003cem\u003eis\u003c/em\u003e the app\u0026rsquo;s whole identity.\u003c/p\u003e\n\u003cp\u003eMonths earlier, before there was anywhere real to deploy this thing, I\u0026rsquo;d stood up a quick throwaway address on my laptop just to test how the layout looked on an actual phone screen instead of squinting at a browser window pretending to be one. Totally reasonable thing to do. Tore it down the moment real deployment happened, also totally reasonable. Except at some point during that testing, I\u0026rsquo;d installed the damn thing to my home screen from that scratch address, and never thought about it again, like a dumbass.\u003c/p\u003e\n\u003cp\u003eSo the icon on my phone was never talking to the server. It had never once talked to the server. It had been faithfully, patiently trying to reach a machine that stopped listening for that address weeks ago, and every single time it failed, it failed with an error that is byte-for-byte identical to \u0026ldquo;the server is down.\u0026rdquo; \u003ccode\u003eERR_CONNECTION_REFUSED\u003c/code\u003e doesn\u0026rsquo;t editorialize. It doesn\u0026rsquo;t say \u0026ldquo;hey, this domain doesn\u0026rsquo;t exist anymore, you absolute muppet, might want to check that.\u0026rdquo; It just refuses, flatly, and leaves you to guess why.\u003c/p\u003e\n\u003cp\u003eFixed by pointing the install at the actual live address and pinning that URL somewhere obnoxiously obvious so future-me doesn\u0026rsquo;t get to relive this shit.\u003c/p\u003e\n\u003ch2 id=\"what-the-silence-was-hiding\"\u003eWhat the silence was hiding\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s where it stopped being embarrassing and started being useful. The server had no access log. None. Which meant when this started, I had zero way to tell the difference between \u0026ldquo;the phone tried to connect and got rejected\u0026rdquo; and \u0026ldquo;the phone never tried at all.\u0026rdquo; Those are completely different failures with completely different fixes, and I couldn\u0026rsquo;t tell them apart because I\u0026rsquo;d never built the thing that would let me.\u003c/p\u003e\n\u003cp\u003eSo I added one. And the very first thing it proved was that every single entry in it, for the entire time this had supposedly been \u0026ldquo;down,\u0026rdquo; was the container\u0026rsquo;s own healthcheck pinging itself every thirty seconds like clockwork. Nothing else. No failed request. No rejected connection. Not one single byte from my phone had ever arrived, because it was never being sent anywhere near this fucking machine in the first place.\u003c/p\u003e\n\u003cp\u003eThat log — something I only built because I got burned — is also what turned up two real bugs hiding underneath the fake one, which is exactly what happens when you finally go looking properly instead of vibing it. The app\u0026rsquo;s reconnection logic was trusting the browser\u0026rsquo;s \u003ccode\u003eonline\u003c/code\u003e event to know when the network came back, and that event is basically decorative on a phone — so a genuinely brief network drop could get treated as permanent, forever, until you manually intervened like an idiot. And separately, when the app \u003cem\u003edid\u003c/em\u003e recover, it only bothered refreshing two of its three screens and left a stale \u0026ldquo;can\u0026rsquo;t reach server\u0026rdquo; panel sitting on the third, so even a fully working reconnect still looked broken if you happened to glance at the wrong tab.\u003c/p\u003e\n\u003cp\u003eNeither of those caused the outage. Both would have made a \u003cem\u003ereal\u003c/em\u003e outage worse, invisibly, and I only found them because a fake one forced me to build the tooling to actually see what was happening instead of guessing at it.\u003c/p\u003e\n\u003ch2 id=\"the-pattern-underneath-all-of-it\"\u003eThe pattern underneath all of it\u003c/h2\u003e\n\u003cp\u003eI\u0026rsquo;d already built this thing around one rule, from the start, mostly out of paranoia: nothing in this app is allowed to be blank or silent by accident, because for a medication tracker, blank and silent both read as \u0026ldquo;nothing to worry about,\u0026rdquo; and that\u0026rsquo;s exactly the one lie this app is not allowed to tell, ever. An empty list has to mean the server confirmed there\u0026rsquo;s nothing due — never \u0026ldquo;the request hasn\u0026rsquo;t come back yet\u0026rdquo; wearing an empty list\u0026rsquo;s clothes like a coward.\u003c/p\u003e\n\u003cp\u003eThis whole outage was that exact philosophy failing at a layer I hadn\u0026rsquo;t built it into yet. The client had no way to say \u0026ldquo;I am not even reaching the network you think I am,\u0026rdquo; so it said nothing, and nothing looks exactly like everything being fine, which looks exactly like everything being fucked, depending on which side of the silence you\u0026rsquo;re standing on. A dead request and a healthy one that just hasn\u0026rsquo;t finished look identical if you never bother asking which one you\u0026rsquo;re actually looking at.\u003c/p\u003e\n\u003cp\u003eTurns out the same rule that governs whether an empty dose list can be trusted also governs whether \u0026ldquo;can\u0026rsquo;t connect\u0026rdquo; means what you think it means. Silence is never neutral. It\u0026rsquo;s just a claim wearing a disguise, and the whole job is making sure the disguise never fucking fits.\u003c/p\u003e\n","date_modified":"2026-09-04T10:00:00-07:00","date_published":"2026-09-04T10:00:00-07:00","id":"https://www.onelegdave.dev/posts/server-never-down/","image":"https://www.onelegdave.dev/images/posts/server-never-down.jpg","summary":"A medication tracker I built for myself started refusing to connect, from every device, while the server sat there perfectly healthy the entire time. The bug was a ghost, and I built it myself months earlier without noticing.","tags":["Self-hosted"],"title":"The Server Was Never Down","url":"https://www.onelegdave.dev/posts/server-never-down/"},{"content_html":"\u003cp\u003eI bought a laptop with an RTX 5060 in it a year ago. It has been, until recently, completely unremarkable — no weird crashes, no thermal bullshit, no gremlins. A full year of just fucking working. Then I put Omarchy on it, and in under a week I owned a laptop with half an RTX 5060 in it, and didn\u0026rsquo;t know.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s the thing about a hobbled GPU: it doesn\u0026rsquo;t announce itself. It doesn\u0026rsquo;t throw an error. It just quietly does less work than it\u0026rsquo;s capable of, while you sit there thinking \u0026ldquo;huh, this feels a bit sluggish for the hardware\u0026rdquo; and blaming everything except the actual cause, because the actual cause is invisible unless you go looking for it with a multimeter\u0026rsquo;s worth of paranoia.\u003c/p\u003e\n\u003ch2 id=\"the-vibe-was-off-fast\"\u003eThe vibe was off, fast\u003c/h2\u003e\n\u003cp\u003eThis is the part that annoys me most in hindsight: this is a machine with a full year of clean behavior on record. Same laptop, same silicon, same everything, running perfectly fine on a previous install for twelve straight months. Then a new distro goes on, and within days something feels off. The correct instinct there is obvious — suspect the thing you just changed, not the hardware that\u0026rsquo;s been flawless for a year. I did not have this instinct on day one. I had it by about day four, after several days of increasingly irritated vague dissatisfaction.\u003c/p\u003e\n\u003cp\u003eNew distro, new install, everything looking gorgeous, performance feeling\u0026hellip; fine. Not bad. Just fine. Games ran. Renders rendered. Nothing was on fire. Which is exactly the problem with a silent performance regression — there\u0026rsquo;s no crash to point at, no log line screaming for attention, just a persistent, low-grade sense that something\u0026rsquo;s leaving performance on the table and you can\u0026rsquo;t fucking prove it. So you sit there for days quietly resenting your own laptop like an idiot, forgetting it had been perfect for a year right up until you changed the one thing you changed less than a week ago.\u003c/p\u003e\n\u003cp\u003eSo I did what any reasonable person does when they can\u0026rsquo;t prove a feeling: I went and got numbers.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003envidia-smi\u003c/code\u003e while gaming. Power draw sitting at a suspiciously round \u003cstrong\u003e50 watts\u003c/strong\u003e, pinned there like a coward. Not \u0026ldquo;50 watts because the game is light on the GPU.\u0026rdquo; Fifty watts \u003cem\u003eflat\u003c/em\u003e, under a load that should have been begging for more.\u003c/p\u003e\n\u003cp\u003eFor context: this is a laptop 5060, and 50W is roughly its baseline TGP — the floor, not the ceiling. These chips are supposed to boost well above that under load. Sitting exactly at the floor, permanently, isn\u0026rsquo;t \u0026ldquo;conservative,\u0026rdquo; it\u0026rsquo;s \u0026ldquo;someone forgot to flip a fucking switch.\u0026rdquo;\u003c/p\u003e\n\u003ch2 id=\"proving-it-wasnt-in-my-head\"\u003eProving it wasn\u0026rsquo;t in my head\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s where it stopped being a vibe and became an actual investigation, because \u0026ldquo;the GPU feels capped\u0026rdquo; is not a bug report anyone can act on, including me.\u003c/p\u003e\n\u003cp\u003eI had the same physical laptop running CachyOS not long before this, for that entire clean year. Different distro, different config, identical silicon, a full year of prior history saying this hardware has zero problems. So I had something no bug report ever gets: a perfect A/B test on the same chip.\u003c/p\u003e\n\u003cp\u003eBooted CachyOS. Same game, same settings, same everything. \u003ccode\u003envidia-smi\u003c/code\u003e again.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e85 to 93 watts, 78 to 80°C under load.\u003c/strong\u003e Same chip. Same cooler. Same room temperature. Nearly double the power draw, just from being a different pile of software sitting on top of it.\u003c/p\u003e\n\u003cp\u003eSo it wasn\u0026rsquo;t the silicon. It wasn\u0026rsquo;t age. It wasn\u0026rsquo;t thermal throttling. It wasn\u0026rsquo;t my imagination, my expectations, or a shit game engine. It was Omarchy specifically doing something — or rather, not doing something — that CachyOS was doing right, and that this exact laptop had apparently been quietly capable of for a year without complaint.\u003c/p\u003e\n\u003ch2 id=\"finding-the-leash\"\u003eFinding the leash\u003c/h2\u003e\n\u003cp\u003eThe actual culprit, once I went hunting: \u003cstrong\u003e\u003ccode\u003envidia-powerd\u003c/code\u003e\u003c/strong\u003e, a background service Nvidia ships specifically to manage Dynamic Boost — the mechanism that lets the GPU actually claim higher power states under load instead of sitting at its conservative default. CachyOS enables it automatically on install. Omarchy just\u0026hellip; doesn\u0026rsquo;t. No warning, no prompt, no \u0026ldquo;hey, you might want this.\u0026rdquo; It simply isn\u0026rsquo;t part of the install, so the GPU spends its entire life politely capped at whatever the silicon defaults to out of the box, and nobody bothers to tell you.\u003c/p\u003e\n\u003cp\u003eI didn\u0026rsquo;t want to believe a missing background daemon was really the whole fucking story, so I did the only thing that would actually settle it: I broke the \u003cem\u003eworking\u003c/em\u003e install on purpose. Masked \u003ccode\u003envidia-powerd\u003c/code\u003e on CachyOS, the one that had been running fine for a year.\u003c/p\u003e\n\u003cp\u003ePower draw dropped to exactly 50 watts. Same number, same ceiling, same everything as Omarchy.\u003c/p\u003e\n\u003cp\u003eThen I went back to Omarchy and forced the daemon on by hand.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e100 watts.\u003c/strong\u003e Unlocked, on command, on the exact install that had spent less than a week quietly ripping me off while I sat there blaming a laptop that had done nothing wrong for a year.\u003c/p\u003e\n\u003cp\u003eThat\u0026rsquo;s as close to a controlled experiment as you get outside an actual lab: flip one service off, lose the performance. Flip it on, get it back. Do it in both directions, on both distros, and watch the number move exactly where you told it to. At that point it\u0026rsquo;s not a theory anymore, it\u0026rsquo;s just a fact you\u0026rsquo;ve now personally reproduced four times.\u003c/p\u003e\n\u003ch2 id=\"the-compounding-bug-that-made-it-so-much-worse\"\u003eThe compounding bug that made it so much worse\u003c/h2\u003e\n\u003cp\u003eWhile I was in there, I found a second, completely unrelated problem stacked right on top, because apparently the universe wanted this diagnosis to be as fucking annoying as possible.\u003c/p\u003e\n\u003cp\u003eMy external monitor does 165Hz. Omarchy had it running at 60Hz. Not because of the wattage nonsense — because its config picks whatever refresh rate the monitor \u003cem\u003eadvertises first\u003c/em\u003e in its own list, rather than the highest one available, and apparently this monitor\u0026rsquo;s firmware lists 60Hz before 165Hz for reasons known only to whoever wrote its EDID data at 2am on a Friday and never thought about it again.\u003c/p\u003e\n\u003cp\u003eSo for a good chunk of that week I was troubleshooting \u0026ldquo;why does everything feel underpowered\u0026rdquo; while actually looking at two completely independent bugs at once — a genuinely underpowered GPU, and a display quietly running at over a third of its actual refresh rate. Neither one caused the other. Both made the machine feel like garbage, which was extra insulting given it had been flawless for a year before this install existed. Untangling \u0026ldquo;is this the wattage or is this the refresh rate\u0026rdquo; took longer than either fix did once I\u0026rsquo;d actually separated the two idiots living in the same house.\u003c/p\u003e\n\u003ch2 id=\"filing-the-report\"\u003eFiling the report\u003c/h2\u003e\n\u003cp\u003eOnce I had reproducible numbers in both directions, I wrote it up properly and filed it against Omarchy\u0026rsquo;s own tracker on GitHub. Exact steps to reproduce, exact wattage numbers, the masking test, all of it — not \u0026ldquo;this feels slow,\u0026rdquo; but \u0026ldquo;do X, get 50W, do Y, get 100W, here\u0026rsquo;s the log,\u0026rdquo; cross-linked to the existing discussion thread so nobody has to go hunting for context.\u003c/p\u003e\n\u003cp\u003eA bug report that says \u0026ldquo;performance seems bad\u0026rdquo; gets ignored, correctly, because it\u0026rsquo;s unfalsifiable and everyone who\u0026rsquo;s ever run a helpdesk has learned to tune it the fuck out. A bug report that says \u0026ldquo;here is a lever, here is what happens when you pull it, twice, in both directions\u0026rdquo; gets fixed, because there\u0026rsquo;s nothing left to argue with.\u003c/p\u003e\n\u003ch2 id=\"the-actual-takeaway\"\u003eThe actual takeaway\u003c/h2\u003e\n\u003cp\u003eModern hardware doesn\u0026rsquo;t fail loud anymore. It fails by quietly doing less than it\u0026rsquo;s capable of and daring you to notice, and it can do it in under a week flat if you let it. When a machine that\u0026rsquo;s been rock solid for a year suddenly feels off right after you changed exactly one thing, trust that math immediately. Don\u0026rsquo;t spend four days doubting a laptop that\u0026rsquo;s earned better than that.\u003c/p\u003e\n\u003cp\u003eCheck anyway. Pull the lever. Watch the number move.\u003c/p\u003e\n","date_modified":"2026-09-02T14:00:00-07:00","date_published":"2026-09-02T14:00:00-07:00","id":"https://www.onelegdave.dev/posts/half-a-gpu/","image":"https://www.onelegdave.dev/images/posts/half-a-gpu.jpg","summary":"My laptop GPU was quietly running at half power on Omarchy, after a full year of this exact machine giving me zero problems. Took less than a week to notice, and I still almost talked myself out of it.","tags":["Linux","Omarchy"],"title":"Half a GPU","url":"https://www.onelegdave.dev/posts/half-a-gpu/"},{"content_html":"\u003cp\u003eSo I built this whole site the other day. Hugo, PaperMod, a custom stylesheet with colours I sampled straight out of the banner image, a hero banner that behaves itself when you shrink the window. Took hours. Committed it to git twice, felt very pleased with myself, went to bed.\u003c/p\u003e\n\u003cp\u003eThen I reinstalled my distro.\u003c/p\u003e\n\u003cp\u003eYou already know how this ends.\u003c/p\u003e\n\u003ch2 id=\"the-part-where-i-find-out\"\u003eThe part where I find out\u003c/h2\u003e\n\u003cp\u003eFresh install, home directory looked fine. My notes vault was there. Pictures were there. Downloads, full of the usual crap, also there. My Projects folder was sitting right where I left it, containing exactly two things, both belonging to a different project entirely.\u003c/p\u003e\n\u003cp\u003eNo blog.\u003c/p\u003e\n\u003cp\u003eI ran a \u003ccode\u003efind\u003c/code\u003e across the entire filesystem for the project directory, because denial is a process and I wanted to watch it return nothing before I\u0026rsquo;d accept it. It returned nothing. Beautiful. Very quick about it, too.\u003c/p\u003e\n\u003cp\u003eThen I remembered the migration snapshots on my server. Three of them, taken at various points when I\u0026rsquo;ve moved this laptop between distros like a man who cannot commit to anything. Surely one of those had it.\u003c/p\u003e\n\u003cp\u003eThe most recent one, taken \u003cem\u003eright before this reinstall\u003c/em\u003e, contained: Desktop, Documents, my shell config, my terminal config, and Pictures. No Projects. Fantastic. Really glad I saved my colour scheme.\u003c/p\u003e\n\u003cp\u003eThe one before that did have a Projects folder. My heart did a thing. I opened it. Same two unrelated projects, no blog — because that snapshot predates the blog by weeks. So the site was born and died in the gap between two backups, which is a hell of a way to go.\u003c/p\u003e\n\u003ch2 id=\"but-you-used-git\"\u003e\u0026ldquo;But you used git\u0026rdquo;\u003c/h2\u003e\n\u003cp\u003eI did use git. Two clean commits, nice descriptive messages, branch named \u003ccode\u003emain\u003c/code\u003e like a well-adjusted adult who has his shit together.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s the thing about a local git repo that nobody enjoys learning the hard way: the entire history lives in a \u003ccode\u003e.git\u003c/code\u003e folder \u003cem\u003einside the project directory\u003c/em\u003e. Delete the project, delete the history. Git isn\u0026rsquo;t a backup. Git is a very good undo button that lives in the same building as the thing it\u0026rsquo;s undoing. Push it somewhere else or it\u0026rsquo;s just a diary you keep in the glovebox of a car you\u0026rsquo;re about to set on fire.\u003c/p\u003e\n\u003cp\u003eSetting up a remote was literally on my todo list from that session. It was item two. I got to item zero.\u003c/p\u003e\n\u003cp\u003eThe banner image went too, since that was sitting in Downloads, and Downloads is where files go to be deleted by a man doing a spring clean at midnight who is absolutely certain he doesn\u0026rsquo;t need any of this.\u003c/p\u003e\n\u003ch2 id=\"what-actually-protected-me-fuck-all\"\u003eWhat actually protected me: fuck all\u003c/h2\u003e\n\u003cp\u003eLet\u0026rsquo;s be honest about the failure. It wasn\u0026rsquo;t bad luck. My server has a genuinely decent backup setup — Restic running twice, once to an external drive and once offsite to Backblaze B2, 7 daily / 4 weekly / 12 monthly retention, integrity checks, failure notifications straight to my phone. It\u0026rsquo;s good. I\u0026rsquo;m proud of it. I\u0026rsquo;d show it to people at parties if I went to parties.\u003c/p\u003e\n\u003cp\u003eIt backs up \u003ccode\u003e/srv/appdata\u003c/code\u003e, \u003ccode\u003e/srv/sync\u003c/code\u003e, \u003ccode\u003e/srv/backup\u003c/code\u003e, and my compose files. All of which live \u003cem\u003eon the server\u003c/em\u003e.\u003c/p\u003e\n\u003cp\u003eThe laptop was contributing precisely fuck all to that. Every \u0026ldquo;backup\u0026rdquo; of my laptop was me, by hand, going \u0026ldquo;hmm, I should probably copy some folders before I nuke this thing,\u0026rdquo; and then copying whichever folders I happened to think of in that moment. Which is exactly why I have a pristine copy of my terminal theme and no copy of my actual work.\u003c/p\u003e\n\u003cp\u003eManual backups back up whatever you remembered. Not whatever mattered. Those are very different lists and you only find out how different at the worst possible moment.\u003c/p\u003e\n\u003ch2 id=\"the-fix\"\u003eThe fix\u003c/h2\u003e\n\u003cp\u003eThe elegant bit is that I didn\u0026rsquo;t need any new tooling. Syncthing was already running on both machines, already talking to each other, already doing its job for other folders. So:\u003c/p\u003e\n\u003cp\u003eMy Projects folder is now a synced Syncthing folder, landing at \u003ccode\u003e/srv/sync/projects\u003c/code\u003e on the server. That path is \u003cem\u003ealready inside both Restic jobs\u003c/em\u003e. So the moment a file hits Projects on the laptop, it replicates to the server, and the next Restic run sweeps it into the local repo and B2. Nothing to configure in the backup scripts. It just inherited the entire chain.\u003c/p\u003e\n\u003cp\u003eStaggered file versioning is on at both ends, because plain Syncthing is a mirror, not a backup — delete a file here and it will cheerfully, instantly delete it there, like an over-eager intern. Versioning means the server keeps the old copy in \u003ccode\u003e.stversions\u003c/code\u003e instead of enthusiastically helping me destroy my own work.\u003c/p\u003e\n\u003cp\u003eOne gotcha, and it cost me a genuinely confusing five minutes: Syncthing on my server runs in Docker, with \u003ccode\u003e/srv/sync\u003c/code\u003e mounted as \u003ccode\u003e/var/syncthing/data\u003c/code\u003e inside the container. So when I typed the host path into the web UI, it threw \u003ccode\u003emkdir /srv/sync: permission denied\u003c/code\u003e and refused to start the folder. It wasn\u0026rsquo;t a permissions problem at all — that path simply does not exist from inside the container. You have to give it the container path. Obvious in hindsight. Deeply fucking irritating at the time.\u003c/p\u003e\n\u003cp\u003eAnd finally, the thing I should have done first, before any of the rest of it: a bare git repo on the server, sitting inside a path Restic already backs up. \u003ccode\u003egit push\u003c/code\u003e now sends my history somewhere that isn\u0026rsquo;t this laptop, which is the entire point of a remote and something I will not be forgetting again.\u003c/p\u003e\n\u003ch2 id=\"rebuilding\"\u003eRebuilding\u003c/h2\u003e\n\u003cp\u003eHere\u0026rsquo;s the one genuinely smart thing I did last session: at the end of it, I wrote the whole setup up properly. Every CSS variable with its hex value. The full config file. The exact ImageMagick command. The layout override trick and \u003cem\u003ewhy\u003c/em\u003e it works. All of it in my notes vault, which — being a synced folder — survived without a scratch.\u003c/p\u003e\n\u003cp\u003eRebuild took about fifteen minutes. Install Hugo, scaffold the site, add the theme submodule, paste three files in, done.\u003c/p\u003e\n\u003cp\u003eThe thing that took hours the first time was never the typing. It was the working out — the hunting through theme files, the trial and error, the six attempts at centering a div. Working out doesn\u0026rsquo;t need to be redone if you wrote it down. Typing is cheap. Figuring it out again is not.\u003c/p\u003e\n\u003cp\u003eThe only real casualty was the banner. I had to re-source it, and the copy I got back is 1855px wide instead of the original 3710px. Not the end of the world — I dropped the CSS max-width to match, so it isn\u0026rsquo;t being upscaled and going soft on big monitors. Half the resolution, none of the blur. You\u0026rsquo;d never know unless I told you, which I just did, because apparently I can\u0026rsquo;t help myself.\u003c/p\u003e\n\u003ch2 id=\"the-lesson-such-as-it-is\"\u003eThe lesson, such as it is\u003c/h2\u003e\n\u003cp\u003eBackups you have to remember to run are not backups. They\u0026rsquo;re a hobby you do occasionally, and the gap between \u0026ldquo;when I last felt like it\u0026rdquo; and \u0026ldquo;right now\u0026rdquo; is precisely where your work goes to die.\u003c/p\u003e\n\u003cp\u003eAutomate it or accept that you\u0026rsquo;re going to lose something eventually. There\u0026rsquo;s no third option where you\u0026rsquo;re just really diligent about it forever, no matter how much you\u0026rsquo;d like there to be. I thought I was that guy. I was not that guy.\u003c/p\u003e\n\u003cp\u003eAnyway. Site\u0026rsquo;s back. It\u0026rsquo;s now backed up three separate ways, which feels like wild overkill until you remember I\u0026rsquo;m the same idiot who deleted it in the first place.\u003c/p\u003e\n","date_modified":"2026-09-01T17:30:00-07:00","date_published":"2026-09-01T17:30:00-07:00","id":"https://www.onelegdave.dev/posts/whoopsie/","image":"https://www.onelegdave.dev/images/posts/whoopsie.jpg","summary":"I deleted my own website, went looking for a backup, and found a beautifully preserved copy of my terminal colour scheme instead.","tags":["Linux","Omarchy"],"title":"Whoopsie","url":"https://www.onelegdave.dev/posts/whoopsie/"}],"language":"en-us","title":"onelegdave.dev","version":"https://jsonfeed.org/version/1.1"}