Agentz #refactoring #typescript #ai-assisted-development

Paying down the vibe-coding debt: refactoring an AI-built Electron app

Agentz was built fast, feature-first, with heavy AI assistance — and for four months that was the right call. You cannot discover what an agent app should be by architecting; you discover it by shipping the orb, the specialists, the messengers, the sandbox, and seeing what sticks. But the bill for that speed compounds quietly, and this month it came due. This post is the itemised receipt.

The state of the patient

By early April, src/main.ts — the Electron main process — was 14,525 lines. It contained roughly 650 inline new Database() / close() pairs and ~455 inline SQL statements sitting directly inside IPC handlers. The codebase had 787 any types. Individual functions had grown into small municipalities: sendPrompt at 342 lines, checkScheduledJobs at 279, startCallbackServer at 227.

None of this was blocking day-to-day feature work, which is exactly why it had accumulated. What it was blocking: any hope of extracting the agent core into a reusable library, which is where this project needs to go next. You can't extract a module from a file that is itself the app.

Prologue: the React migration and what it cost

The cleanup arc really started in February, when the agent window went from vanilla JS to React in one commit: 110 files changed, 8,372 insertions, 32,373 deletions. A ~550KB hand-rolled renderer — 28 modules, a 1,697-line HTML file, 19 CSS files — became 48 React components at ~215KB.

A big-bang rewrite of a UI is a bet that you know everything the old UI did. I lost that bet in a dozen small ways, discovered over the following weeks: theme persistence broke on tab switches, a code panel lost its library and jobs views, race conditions crept into the chat hooks, and some per-agent controls simply vanished — recoverable only by reading the deleted code out of git history. The April cleanup commits that fixed all this also deleted 24 dead vanilla modules the migration had left behind like shed skin. Lesson recorded: migrate against an inventory of observed behaviors, not against what the new framework makes easy.

The four-day paydown

Days one and two: get the SQL out. Every inline query moved into repository classes — 21 of them now — behind a connection manager, with the commit claiming, and the grep confirming, 100% extraction from main.ts. This is the least clever and most valuable refactor in the whole exercise: business logic that owns its own database connections can never be tested, profiled, or moved.

Day three, part one: types. 787 any types went to 1. That number sounds like a stunt but the work is mundane: fifty-odd row interfaces for repository results, typed IPC bridge methods, every catch (error: any) replaced with catch (error) plus narrowing helpers in a shared errors.ts. Stricter compiler flags went on to hold the line. A 1,073-line coding standards document now pins all of it down — 500-line file targets, no silent catches, no non-null assertions — less as prose for humans than as standing instructions for the AI assistants that write most of this code. That's the part I'd underline for anyone doing AI-assisted development at scale: the standards doc isn't documentation, it's configuration.

Day three, part two: the decomposition. With SQL and types handled, main.ts split along its natural seams into 30+ focused modules — IPC handlers by domain, integrations, tool definitions, scheduler, Docker sandbox. The god functions dissolved: sendPrompt's 342 lines became 13 named helpers. Final size of main.ts: 656 lines of orchestration. That's a 95% reduction, and honestly the file reads like a table of contents now, which is what a main should be.

Day four: the linter breaks the app

Then, riding the high of a clean codebase, I added a linter and let it auto-fix 226 files.

Biome is fast and its formatter is excellent. But its useExhaustiveDependencies auto-fix — run in --unsafe mode, a flag whose name I chose to interpret as bravado rather than information — has a failure mode where it doesn't add missing React hook dependencies, it sometimes empties the dependency array. useEffect(fn, [html]) became useEffect(fn, []). The effect runs once, on mount, never again.

The damage was silent and surgical: trace trees rendered but their click handlers never attached; an elapsed-time timer never toggled; the chat input stopped auto-resizing; scroll state stopped resetting; integration status stopped refreshing. Nothing crashed. Nothing logged. The app was simply, subtly, about 10% dead — and because each symptom looked like an unrelated small bug, I fixed six of them by hand before running a diff of every dependency array against the pre-Biome commit and finding the auto-fix had touched more than thirty components.

The recovery: revert every renderer file to its pre-Biome state, re-apply formatting only, and re-lint with ESLint 9 plus eslint-plugin-react-hooks, where exhaustive-deps is a warning that never auto-fixes. Biome stays as the formatter — the tools split along the line of what each is trustworthy at. Final state: zero errors, 122 warnings, each warning a conscious decision rather than a machine's guess.

The general lesson isn't "Biome bad". It's that auto-fixes are code changes by an author who read this line but not your program — reviewed by nobody, in bulk. In an AI-assisted codebase that irony lands hard: I carefully review the code the LLM writes, then let a linter rewrite 226 files unreviewed because it felt mechanical. Dependency arrays are semantics, not style, and no tool that treats them as style should be allowed to write to them.

The receipt, totalled

Four days bought: a 656-line main, 21 repositories, one any, an enforced standards doc, and a linting setup that can't silently break hooks. The vibe-coded months built the right product; the paydown made it a codebase something can be built from. Neither phase works without the other — debt let the product exist, and the product justified paying the debt. The only real mistake in the ledger is the one that got fixed at 30-component scale: never let anything unreviewed write to your semantics, no matter how fast it is. Especially if it's fast.