<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Developer Tooling | Grigoris Thanasoulas</title><link>https://gregth.com/tag/developer-tooling/</link><atom:link href="https://gregth.com/tag/developer-tooling/index.xml" rel="self" type="application/rss+xml"/><description>Developer Tooling</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Tue, 27 Oct 2026 00:00:00 +0000</lastBuildDate><image><url>https://gregth.com/media/icon_hu8c26edd374f5d41e0fc31d5329905b74_17311_512x512_fill_lanczos_center_3.png</url><title>Developer Tooling</title><link>https://gregth.com/tag/developer-tooling/</link></image><item><title>How I run coding agents locally</title><link>https://gregth.com/post/local-agents-setup/</link><pubDate>Tue, 27 Oct 2026 00:00:00 +0000</pubDate><guid>https://gregth.com/post/local-agents-setup/</guid><description>&lt;p>Most of my day is merge requests: reviewing other people&amp;rsquo;s, getting my own through review, answering threads, chasing pipelines. In mid-October I started letting coding agents do the first pass on all of it, in the background, on my own machine. This post is how that setup works, what broke, and what I learned along the way.&lt;/p>
&lt;p>The rule the whole thing hangs on: &lt;strong>the agents prepare, I decide.&lt;/strong> An agent can read anything, draft a review note, write a fix and commit it locally. It can&amp;rsquo;t post a comment, push a branch, or resolve a thread. Each of those waits for my yes.&lt;/p>
&lt;details class="toc-inpage d-print-none " open>
&lt;summary class="font-weight-bold">Outline&lt;/summary>
&lt;nav id="TableOfContents">
&lt;ul>
&lt;li>&lt;a href="#the-loop">The loop&lt;/a>&lt;/li>
&lt;li>&lt;a href="#choose-models-wisely">Choose models wisely&lt;/a>&lt;/li>
&lt;li>&lt;a href="#one-task-one-folder">One task, one folder&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#version-one-the-repo-inside-the-task-folder">Version one: the repo inside the task folder&lt;/a>&lt;/li>
&lt;li>&lt;a href="#what-other-people-do-and-what-each-costs">What other people do, and what each costs&lt;/a>&lt;/li>
&lt;li>&lt;a href="#version-two-the-worktree-is-the-task-folder">Version two: the worktree is the task folder&lt;/a>&lt;/li>
&lt;li>&lt;a href="#the-sharp-edges">The sharp edges&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#guarding-the-agents">Guarding the agents&lt;/a>&lt;/li>
&lt;li>&lt;a href="#files-over-memory">Files over memory&lt;/a>&lt;/li>
&lt;li>&lt;a href="#lessons-learned">Lessons learned&lt;/a>&lt;/li>
&lt;li>&lt;a href="#next-steps">Next steps&lt;/a>&lt;/li>
&lt;/ul>
&lt;/nav>
&lt;/details>
&lt;h2 id="the-loop">The loop&lt;/h2>
&lt;p>The setup is a handful of Python scripts around &lt;a href="https://code.claude.com/docs" target="_blank" rel="noopener">Claude Code&lt;/a> run headless (&lt;code>claude -p&lt;/code>):&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A poller&lt;/strong> runs every 10 minutes from launchd. It fetches my queue from the forge&amp;rsquo;s API (reviews requested, my open merge requests, threads waiting on me, mentions) and diffs it into one JSON state file. No model calls, no tokens.&lt;/li>
&lt;li>&lt;strong>For each item that changed&lt;/strong>, it decides whether an agent should run. A fingerprint of the item&amp;rsquo;s state (whose move it is, which threads wait on me, whether the pipeline failed) must differ from what the last run handled. Timestamps don&amp;rsquo;t count, because my own pushes move them.&lt;/li>
&lt;li>&lt;strong>Holds and caps&lt;/strong> stop it from spending money on noise: a cooldown after a fix, one run per item at a time, a daily cap per run type, and nothing while a bot reviewer is still working.&lt;/li>
&lt;li>&lt;strong>A run&lt;/strong> is one &lt;code>claude -p&lt;/code> in that item&amp;rsquo;s own folder. It writes everything it produced into files: a briefing, numbered draft notes, a state file with what it checked and what&amp;rsquo;s left.&lt;/li>
&lt;li>&lt;strong>A menu bar app&lt;/strong> (&lt;a href="https://github.com/swiftbar/SwiftBar" target="_blank" rel="noopener">SwiftBar&lt;/a>) shows one row per item: what I need to do, plus &amp;ldquo;Next:&amp;rdquo; when a draft is ready. Clicking it opens the agent&amp;rsquo;s session, and there I read, edit, and say yes or no, one note at a time.&lt;/li>
&lt;/ol>
&lt;h2 id="choose-models-wisely">Choose models wisely&lt;/h2>
&lt;p>Two things cost money in a setup like this: a big model on a small job, and a long session dragged along into every follow-up. Both showed up in the logs in the first week.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Pick the model by the size of the diff.&lt;/strong> One day, eight of nine reviews touched one to four files, and running the largest model on each cost about $1.33 a review: more model than diffs that size need. Now small diffs (four files and 250 lines at most, or docs only) get the mid-size model, and only big diffs get the largest. Triaging mentions uses the smallest model, and many &amp;ldquo;Next:&amp;rdquo; hints come from a fixed rule, with no model at all.&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t run when nothing changed.&lt;/strong> The poller itself makes plain API calls and spends no tokens, and the fingerprint, cooldown and caps above keep a quiet merge request from waking an agent every ten minutes.&lt;/li>
&lt;li>&lt;strong>Compact before resuming.&lt;/strong> A follow-up run continues the item&amp;rsquo;s existing session, so it doesn&amp;rsquo;t re-read the code from scratch. But every resumed turn pays for the whole conversation it carries: resuming one 8.7 MB session for a single small fix cost $4.79. So the size of the session decides what happens next:
&lt;ol>
&lt;li>Under 1 MB, the run resumes it as it is.&lt;/li>
&lt;li>Between 1 and 20 MB, it first runs &lt;code>/compact&lt;/code> with an explicit list of what to keep (every decision and why, each draft&amp;rsquo;s path and whether it was posted, commit SHAs, each thread&amp;rsquo;s status, what&amp;rsquo;s left) and what to drop (raw tool output, file contents, diffs: they can be read again), then resumes in the same conversation. The list has to be explicit: in a test, a code word I didn&amp;rsquo;t list was gone after compaction. The summary and the path to the full transcript go to &lt;code>_scratch/HANDOFF.md&lt;/code>, so nothing is lost.&lt;/li>
&lt;li>Over 20 MB, it starts fresh and reads &lt;code>_scratch/STATE.md&lt;/code> instead.&lt;/li>
&lt;/ol>
&lt;/li>
&lt;li>&lt;strong>Never resume my own sessions in the background.&lt;/strong> That $4.79 run was in one of my conversations, and it wrote its output into it, too. Background runs now only continue sessions they started. When I worked on something myself, they start fresh and read the state file.&lt;/li>
&lt;/ul>
&lt;h2 id="one-task-one-folder">One task, one folder&lt;/h2>
&lt;p>Every merge request or issue gets its own folder under &lt;code>~/workspace/_worktrees/&lt;/code>, named by type so a listing groups them: &lt;code>mr-&amp;lt;repo&amp;gt;-&amp;lt;id&amp;gt;&lt;/code>, &lt;code>wi-&amp;lt;repo&amp;gt;-&amp;lt;id&amp;gt;&lt;/code>, &lt;code>adhoc-&amp;lt;repo&amp;gt;-&amp;lt;topic&amp;gt;&lt;/code>.&lt;/p>
&lt;p>Why not just work in my normal clones? Two things bit me early:&lt;/p>
&lt;ul>
&lt;li>My main checkouts hold local-only work, branches with commits that exist nowhere else. An agent that runs &lt;code>git checkout&lt;/code> there moves my HEAD out from under me.&lt;/li>
&lt;li>A checkout has one HEAD. Two agents sharing one silently read each other&amp;rsquo;s branch. Git reports no error, and the agent reasons, plausibly and wrongly, about code that isn&amp;rsquo;t in its merge request.&lt;/li>
&lt;/ul>
&lt;p>So each task gets a &lt;a href="https://git-scm.com/docs/git-worktree" target="_blank" rel="noopener">git worktree&lt;/a>: its own checkout, index and HEAD, while sharing the clone&amp;rsquo;s objects and refs. Twelve tasks cost twelve checkouts, not twelve clones.&lt;/p>
&lt;h3 id="version-one-the-repo-inside-the-task-folder">Version one: the repo inside the task folder&lt;/h3>
&lt;p>My first layout put the worktree one level down:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">mr-repo-123/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> repo/ ← git worktree
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> scratch/ ← drafts, STATE.md, notes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> CLAUDE.md ← &amp;#34;@repo/CLAUDE.md&amp;#34;, so the agent still sees the repo&amp;#39;s instructions
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> .claude/ ← symlinks to the repo&amp;#39;s skills
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The appeal was safety: &lt;code>scratch/&lt;/code> sat outside the worktree, so no git command could touch it. It cost me two workarounds (the &lt;code>CLAUDE.md&lt;/code> import and the symlinks, because Claude Code looks for those in the session&amp;rsquo;s folder, not below it), and one thing I couldn&amp;rsquo;t work around: &lt;strong>the desktop app&amp;rsquo;s diff pane runs git in the session&amp;rsquo;s folder.&lt;/strong> The task folder wasn&amp;rsquo;t a repo, so the diff pane stayed empty. Watching what an agent changed is the whole point of a review setup.&lt;/p>
&lt;h3 id="what-other-people-do-and-what-each-costs">What other people do, and what each costs&lt;/h3>
&lt;p>Before changing it I looked at what others had settled on. &lt;a href="https://www.conductor.build/blog/context" target="_blank" rel="noopener">Conductor&lt;/a> keeps notes in a gitignored &lt;code>.context/&lt;/code> inside each worktree. &lt;a href="https://github.com/ddyy/repoyard" target="_blank" rel="noopener">repoyard&lt;/a> puts an untracked folder around the repo, because &amp;ldquo;.gitignore is a promise, not a guarantee&amp;rdquo;. No standard exists, and each option gives something up:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Where the notes live&lt;/th>
&lt;th>Good&lt;/th>
&lt;th>Costs you&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Beside the worktree (my version one, repoyard)&lt;/td>
&lt;td>no git command can touch them&lt;/td>
&lt;td>the session folder isn&amp;rsquo;t a repo: empty diff pane, the repo&amp;rsquo;s agent instructions need workarounds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inside, hidden by &lt;code>.git/info/exclude&lt;/code> (Conductor&amp;rsquo;s approach, my version two)&lt;/td>
&lt;td>diff pane and agent instructions just work; nothing committed&lt;/td>
&lt;td>&lt;code>git clean -x&lt;/code> deletes them; &lt;code>git worktree add&lt;/code> and &lt;code>remove&lt;/code> need helpers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inside, in the repo&amp;rsquo;s &lt;code>.gitignore&lt;/code>&lt;/td>
&lt;td>no per-clone setup&lt;/td>
&lt;td>a commit to every repo, so your team sees your tooling&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inside, as a dot-folder&lt;/td>
&lt;td>out of the way&lt;/td>
&lt;td>hidden in Finder, &lt;code>ls&lt;/code> and the Claude file pane&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Symlinked in from outside&lt;/td>
&lt;td>survives &lt;code>git clean&lt;/code>&lt;/td>
&lt;td>Claude Code checks the link&amp;rsquo;s target, so writes can prompt for permission&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Version one was the first row. I moved to the second.&lt;/p>
&lt;h3 id="version-two-the-worktree-is-the-task-folder">Version two: the worktree is the task folder&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">mr-repo-123/ ← git worktree, and the session&amp;#39;s folder
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> _scratch/ ← drafts, STATE.md: hidden from git
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> _shared -&amp;gt; ../_shared ← per-repo notes shared across tasks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> (the repo&amp;#39;s files, its own CLAUDE.md and .claude/)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The repo&amp;rsquo;s own instructions and skills now load without any import or symlink, and the diff pane works. The interesting part is keeping &lt;code>_scratch/&lt;/code> out of git without touching the repo:&lt;/p>
&lt;ol>
&lt;li>Git checks three ignore sources: &lt;code>.gitignore&lt;/code> (committed, shared with the team), your global &lt;code>core.excludesFile&lt;/code>, and &lt;code>.git/info/exclude&lt;/code>, which is local and can never be committed.&lt;/li>
&lt;li>A linked worktree has no &lt;code>.git/&lt;/code> folder, just a &lt;code>.git&lt;/code> file pointing into the clone&amp;rsquo;s &lt;code>.git/worktrees/&amp;lt;name&amp;gt;/&lt;/code>, which holds only HEAD and the index.&lt;/li>
&lt;li>Everything else, &lt;code>info/&lt;/code> included, lives in the clone&amp;rsquo;s &lt;strong>common dir&lt;/strong>, so every worktree of a repo reads the same &lt;code>info/exclude&lt;/code>.&lt;/li>
&lt;li>Writing &lt;code>/_scratch/&lt;/code> there once per repo hides it in every task folder: not in &lt;code>git status&lt;/code>, not in the diff pane, not in &lt;code>git add -A&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>&lt;code>git check-ignore -v _scratch/STATE.md&lt;/code> names the exact rule doing it, which is how I checked.&lt;/p>
&lt;p>Why &lt;code>_scratch&lt;/code> and not &lt;code>.scratch&lt;/code>? Finder, &lt;code>ls&lt;/code> and the Claude desktop file pane all hide dot-folders, and drafts are what I open most. The underscore also sorts it to the top in Finder and VS Code. Not in &lt;code>ls&lt;/code>, though, which sorts by byte value and puts &lt;code>README.md&lt;/code> first.&lt;/p>
&lt;h3 id="the-sharp-edges">The sharp edges&lt;/h3>
&lt;p>Moving inside the worktree swapped one set of problems for another. These are the ones I hit:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>git clean -fdx&lt;/code> deletes &lt;code>_scratch/&lt;/code>.&lt;/strong> &lt;code>-x&lt;/code> removes ignored and excluded files too. Agents run this when a build gets confused, so a hook now blocks &lt;code>-x&lt;/code> and &lt;code>-X&lt;/code> in task folders. It still allows a dry run, and &lt;code>-e _scratch&lt;/code> (git honours &lt;code>-e&lt;/code> even with &lt;code>-x&lt;/code>).&lt;/li>
&lt;li>&lt;strong>&lt;code>git worktree add&lt;/code> refuses a folder that isn&amp;rsquo;t empty.&lt;/strong> A task folder often exists before its worktree, with notes already in &lt;code>_scratch/&lt;/code>. The helper moves the folder&amp;rsquo;s entries aside, adds the worktree, and moves them back. It moves the entries rather than renaming the folder, so an agent already running in it keeps a valid working directory.&lt;/li>
&lt;li>&lt;strong>&lt;code>git worktree remove&lt;/code> refuses while ignored files exist.&lt;/strong> &lt;code>git status&lt;/code> calls the folder clean, &lt;code>remove&lt;/code> calls it dirty. The cleanup archives &lt;code>_scratch/&lt;/code> first, then removes the worktree.&lt;/li>
&lt;li>&lt;strong>Git hooks export &lt;code>GIT_DIR&lt;/code>.&lt;/strong> My first commit of this change failed: the pre-commit tests ran &lt;code>git -C &amp;lt;temp repo&amp;gt;&lt;/code>, and the inherited &lt;code>GIT_DIR&lt;/code> pointed them back at the repo being committed. The tests now strip &lt;code>GIT_*&lt;/code> first.&lt;/li>
&lt;/ul>
&lt;h2 id="guarding-the-agents">Guarding the agents&lt;/h2>
&lt;p>Headless agents get no prompts, so they need hard walls. Every background run starts with a settings file that adds two layers:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A PreToolUse hook on every tool call&lt;/strong>, built on allowlists, not denylists. The forge CLI and git get read-only subcommands only; there&amp;rsquo;s no push and no remote change. &lt;code>Write&lt;/code> and &lt;code>Edit&lt;/code> reach only the task&amp;rsquo;s &lt;code>_scratch/&lt;/code> and a shared notes folder. A fix run can additionally edit and commit inside its own worktree, and nowhere else. The hook resolves symlinks before checking a path, so &lt;code>_scratch/../Dockerfile&lt;/code> doesn&amp;rsquo;t slip through. Its self-test runs on every commit.&lt;/li>
&lt;li>&lt;strong>The OS sandbox&lt;/strong> for shell commands: writes only to the working folder and a few caches, network only to the forge and package registries, and no reads of &lt;code>~/.ssh&lt;/code>, cloud credentials or kubeconfig.&lt;/li>
&lt;/ul>
&lt;p>My own interactive sessions get lighter hooks that ask rather than block:&lt;/p>
&lt;ul>
&lt;li>one asks before &lt;strong>any&lt;/strong> write to the forge, whatever the allowlist says;&lt;/li>
&lt;li>one lets a push to my own branch through without a prompt and asks for everything else (protected branches, force pushes, branches with someone else&amp;rsquo;s commits);&lt;/li>
&lt;li>one blocks the &lt;code>git clean -x&lt;/code> above.&lt;/li>
&lt;/ul>
&lt;h2 id="files-over-memory">Files over memory&lt;/h2>
&lt;p>The design decision I&amp;rsquo;d keep above all: &lt;strong>an agent&amp;rsquo;s memory is its files, not its conversation.&lt;/strong> Every task has a &lt;code>_scratch/STATE.md&lt;/code> with decisions and why, what&amp;rsquo;s done, what&amp;rsquo;s left, and open questions. Each run reads it first and updates it last. A later run, a resumed session, or I can pick the task up cold.&lt;/p>
&lt;p>Drafts are one file each, revised in place; older versions move to &lt;code>_scratch/history/&lt;/code>. A note is posted from its file, so my last-minute edit ships exactly as written, and the record of what was drafted, edited and posted stays next to the code it&amp;rsquo;s about.&lt;/p>
&lt;h2 id="lessons-learned">Lessons learned&lt;/h2>
&lt;p>If you run agents locally, these are the parts that paid for themselves:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>One worktree per task&lt;/strong>, never your main checkout.&lt;/li>
&lt;li>&lt;strong>Make the worktree the session&amp;rsquo;s folder.&lt;/strong> Tools assume it, and fighting that costs more than the &lt;code>git clean&lt;/code> risk.&lt;/li>
&lt;li>&lt;strong>Hide agent files with &lt;code>.git/info/exclude&lt;/code>&lt;/strong>, not the repo&amp;rsquo;s &lt;code>.gitignore&lt;/code>: it&amp;rsquo;s per clone, shared by all its worktrees, and never shows up in a merge request.&lt;/li>
&lt;li>&lt;strong>Allowlist what headless agents can do&lt;/strong>, and test the guard like code.&lt;/li>
&lt;li>&lt;strong>Put the human at the edge, not in the loop.&lt;/strong> Agents run without asking; anything that leaves the machine waits for a yes.&lt;/li>
&lt;li>&lt;strong>Make files the source of truth&lt;/strong>, so no task depends on a session&amp;rsquo;s memory.&lt;/li>
&lt;/ul>
&lt;h2 id="next-steps">Next steps&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Isolation beyond files.&lt;/strong> A worktree keeps each agent&amp;rsquo;s files apart, but the agents still share ports, databases and credentials. That&amp;rsquo;s fine for reviews and small fixes. Once an agent needs to run services, I expect to need a container per task, and I haven&amp;rsquo;t worked out where that line is.&lt;/li>
&lt;li>&lt;strong>Review load.&lt;/strong> Agents make drafts cheap, so the bottleneck is now reading: mine first, before anything goes out, then the author&amp;rsquo;s, when a note lands on their merge request. I post one note at a time, and only after I&amp;rsquo;ve read it, so nothing goes out unread. Next I want to find out whether that pace works for the people on the other end, or whether I&amp;rsquo;ve only moved the pile from my desk to theirs.&lt;/li>
&lt;/ul></description></item></channel></rss>