html.surf
Sign in
public

Treacle: a control plane for the coding agents on your Mac

Run six Claude Code or Codex sessions across six worktrees and you spend the day tabbing between terminals to find the one that stopped. Treacle puts them on one screen, turns their questions into buttons, and follows you to your phone. tmux and git worktrees underneath, your own Mac as the server, nothing in a cloud.

Terminals don't scale past three agents

Agentic coding changes the shape of a working day. Instead of one editor and one train of thought, you have a handful of agents, each in its own git worktree on its own ticket, each grinding through a plan for twenty minutes at a time. The work is parallel. The tooling is not.

A terminal shows one pane at a time. To know whether the agent in worktree four has stopped on a permission prompt, you switch to it and look. To know what the agent in worktree two has changed, you switch and scroll. Most of the time nothing needs you, so you learn to stop checking, and then an agent sits for ten minutes on a yes-or-no question you could have answered in four seconds.

The information exists. Claude Code writes a transcript of everything it does, fires hooks when it stops or asks, and exposes a tool protocol. Codex writes a rollout log. tmux knows which panes exist and what is running in them. Nobody had stitched it into a view.

The bet: keep the terminal as the agent's home, and build a visual layer that reads what it already writes. Never get between the agent and the model, never proxy a subscription, never hide the pane.

One binary on the Mac you leave on

Treacle is a single executable. Run treacle setup once on a Mac that stays on and you get a background service, a web app, a tmux server and a Tailscale address. The same binary is the CLI, the server, the MCP server the agents talk to, and the hook that Claude Code calls. State lives in ~/.treacle.

You use it two ways. At the desk it is a Chrome app in the Dock, one window beside your editor, with a badge that counts the sessions waiting on you. Away from the desk it is the same app on your phone, added to the home screen, with push notifications. Neither device holds any state; both talk to the server over your tailnet.

CountOf what
1binary, playing four roles: CLI, server, MCP server, hook
0ports opened on your router
0accounts, backends, or telemetry
2agents read natively: Claude Code and Codex

What you get on the screen

The home screen and the inbox

Every registered repo lists its worktrees as cards. Each card carries the branch, a colour, the Linear ticket and the pull request with its checks, and a one-line subject written by a small model from the newest session in it: "Adding a per-inviter token bucket, then a 429 with a retry hint." Under the card, each tmux tab is a chip with a lamp: green and blinking while the agent works, solid green when a shell is alive, amber when a session needs you.

Above the cards sits the inbox: every session waiting on you, with the question it asked and its options as buttons. Tap "Yes, open it" and the answer goes into the pane; the row clears; the badge counts down. Folders with nothing going on for a day fold away behind one line.

The feed

Open a session and the transcript becomes a conversation you can read on a bus. The agent's reasoning appears as dim prose between tool calls, because with Claude's newer models that reasoning is the running commentary: "Good news, the preview passed; now the Bugbot comment." Each shell command is titled by the agent's own description of what it is for, with the command one tap beneath, and each row learns how long it took and whether it failed. Runs of routine calls fold behind "9 earlier steps"; edits show as real diffs; a proposed plan opens in a reader. When a turn ends the feed says so, and while one runs the live line counts up with the tokens it has cost.

Decisions in place

Claude Code's hooks tell treacle the instant a session waits, so the lamp flips before you would have noticed in the terminal. Questions become cards with buttons and a box for answering in your own words. Start a session with TREACLE_REMOTE_PERMISSIONS=1 and gated shell commands arrive as an Allow or Deny card with the full command shown; ignore it and the terminal prompt takes over after two minutes, as it always did. Nothing is ever auto-approved. Over MCP an agent can also put up a choice or a whole proposal with Approve and Reject, and block until you answer.

The board

A session is a conversation. A task is a thing that has to be finished, and the board holds those. Every task needs a definition of done and, ideally, a check command. Tap start on a ready task and treacle cuts a worktree from the base ref, writes the brief to .treacle/task.md, launches the agent you chose on the account with the most headroom, and sends the opening message once the agent is actually at its prompt. The agent reports evidence back over MCP; done runs the check in the worktree and moves the task to review, where it waits with the evidence and a diff against base.

Documents

The plans an agent writes live as markdown in the worktree. A docs button lists the brief, the plans and the specs, newest first, and opens them in a full-screen reader. Any path ending in .md or .pdf in the feed becomes a link; images the agent saved show inline; an HTML review opens sandboxed.

Workflows

When Claude Code runs a multi-agent Workflow, treacle watches the run's files and shows its phases as a stepper, its agents as rows with duration, calls, tokens and their last thought, and each agent's own transcript read-only. The terminal's /workflows can't show that last part.

Accounts as a pool

Each Claude profile is a separate login with its own usage cache. The account picker shows every account's 5h and 7d headroom; a start that names no account takes the one with the most room; a session that hits its limit gets a card offering the others, and a tap moves the conversation across.

The real terminal

One tap away, always. Cursor-true tmux mirroring over control mode, with Select, Paste and Copy, and a key bar for esc, tab, the control keys and arrows. It is the same pane you would attach to from a shell, so nothing treacle shows is a summary of something you can't see.

Worktrees without a shell

Add a worktree with a branch name and a base; add a tab with Terminal, Claude or Codex. Deleting refuses while anything is running or uncommitted, lists exactly what, and makes you slide a switch before the red button will press. One approval, the whole picture on it.

Anywhere

Web Push from your own host, only while the app is closed and only when a session needs you. A mic button sends audio to whisper.cpp on the host. The compose box types straight into the pane, so iOS dictation works with no setup at all.

Why this shape

  • Nothing is hidden. Every card, row and lamp is derived from something the agent wrote or tmux reported. The pane is one tap away, and quitting treacle leaves every shell exactly where it was.
  • Control stays with you. Questions come to you; nothing is answered on your behalf. The mode chip cycles plan and auto. Send becomes stop while the agent works. Destructive actions show their consequences before they happen.
  • No cloud in the middle. Your code never leaves your machines because there is nothing for it to leave to. treacle.dev serves a landing page and a download; that is everything it can see.
  • Inside the lines. Treacle drives the agent CLIs as a person would and reads what they publish. It never reuses a subscription as an API, which is both against the terms and the fastest way to lose plan mode, permissions and hooks.
  • Any device, no state on it. Close the Chrome app, open the phone, and you are where you were. The server is the memory.

Under the hood

Three places, one server. The Mac runs the service; your devices run the web app; your tailnet joins them. Every arrow below is either a local file read or a WebSocket inside WireGuard.

The Mac that stays on treacle-server, under launchd · binds 127.0.0.1:4711 tmux server (own socket) session · paneclaude session · panecodex session · panezsh (plain tab) git worktrees ~/.treacle/worktrees/<repo>/<branch> or {repo}/.treacle/worktrees SQLite events · work · push · subjects · native timeline, board, push subscriptions, subject lines, session → worktree what the agents publish, what treacle reads transcripts~/.claude/projects/… JSONL, tailed as it grows → the feed hooksStop / Notification / PreToolUse → POST /api/hook → lamps, cards MCPtreacle mcp (stdio) → show_document, ask_choice, work_report … whisper.cpp voice → text, on the host control-mode bridge tmux -C ⇄ WebSocket ⇄ xterm.js your tailnet WireGuard Serve for HTTPS Any browser no state on the device Chrome appDock badge · push Phonehome screen · push · mic iPadterminal with a key bar WebSockets + REST bearer token in a header
One server, three places. Everything on the left is a file read or a local socket; everything crossing the middle is inside WireGuard.

What it is written in

All of it is TypeScript, and all of it runs on Bun. There is no second language and no second runtime: the server, the CLI, the MCP server, the hook and the browser app are one Bun workspace, five packages, about twenty-seven thousand lines, type-checked as one project and tested with Bun's own test runner.

PackageWhat it isLeans on
sharedThe types both sides speak: panels, actions, launchers, doc paths. Pure TypeScript, no dependencies.nothing
serverHTTP and WebSocket server, tmux control-mode bridge, transcript mirror, hook receiver, git and worktree actions, push, board, SQLite stores.Bun.serve, bun:sqlite, web-push
webThe browser app: home, inbox, feed, board, terminal, document reader, workflow view.xterm.js, and nothing else
clisetup, doctor, voice, the launchd plist, the tmux config, the hook script. Compiles the whole thing into one binary.bun build --compile, qrcode-terminal
mcpThe stdio MCP server an agent talks to: show_document, ask_choice, ask_approval, update_plan, work_task, work_report.@modelcontextprotocol/sdk, zod

The web app has no framework. No React, no Svelte, no virtual DOM, no client-side router library. Each screen is a module with render functions that build DOM directly and hold their own small piece of state; a session screen knows how to paint a feed row, apply an outcome to an existing row, fold a run of tool calls, and keep a live timer ticking, and it does that by touching the elements it made. The one dependency is xterm.js, for the terminal, and even that is imported on demand so the home screen never pays for it. Bun bundles the app into a single minified app.js, copies xterm's stylesheet next to it, and stamps a build id into both the bundle and a build.json beside it.

The server is Bun.serve with one handler for HTTP and one for WebSockets. Persistence is SQLite through bun:sqlite, five small databases in ~/.treacle, no ORM. tmux, git, whisper and the agent CLIs are driven as child processes; nothing is linked in. Web Push uses the web-push library with VAPID keys minted at setup, so the push origin is your own host.

The binary is bun build --compile over the CLI's entry point. The web bundle, the launchd plist, the tmux config and the hook script are imported with { type: "file" }, which embeds them in the executable, so a fresh Mac needs nothing but the one file. The build then re-signs the result with an ad-hoc signature, because Bun appends its bundle to an executable the linker has already signed, and launchd refuses a binary whose signature no longer covers its bytes. It fails from launchd and nowhere else, which is the kind of bug you only find once.

The marketing site at treacle.dev is the one thing outside that workspace: a tiny Hono app on Cloudflare Workers that serves a page and the install script, and hands out the binary from R2.

How a session becomes a feed

Nothing in treacle asks the model anything. The feed is a reading of files and events the agent produces anyway, joined by one fact: which pane a transcript belongs to.

tmux pane TREACLE_SURFACE_ID in the env, so hooks and MCP know their pane transcript.jsonl one record per block: thinking, text, tool_use, tool_result, usage transcript mirror binds file ↔ pane by hook id, verified against the pane title tails new bytes, turns records into feed events; a result merges into its call by id timeline → WebSocket → app plus MCP panels and your inputs, in SQLite for 30 days, one order, replayed on every reconnect hooks Stop → needs you · UserPromptSubmit → working · PreToolUse → a permission card (TREACLE_REMOTE_PERMISSIONS=1) MCP the agent pushes a document, a diff, a plan or a question straight to the timeline, and can block on the answer
From pane to feed. The mirror never calls a model; it reads what Claude Code has already written.

The binding is the hard part and it is deliberately conservative. A hook arrives carrying the pane's surface id and the transcript path, but hook-supplied ids can be stale ghosts from an earlier process in the same pane. So a binding is only trusted once the pane's tmux title matches the transcript's own title record, and a verified binding is never evicted by a rival claim; it is released only when its pane dies. Codex has no hooks, so its rollout is matched to a pane by working directory, and its questions are read off the screen.

Reading the screen when there is no signal

Two things never reach any structured channel: the numbered dialogs a TUI draws at launch (folder trust, model switch, sign-in), and the shell's own prompts before the agent has started. Treacle captures the pane and parses shape rather than words: a run of options with a cursor on one and a footer that says Enter confirms, or a bottom line that ends in a shell glyph versus one that ends in [Y/n]. A launch waits for a real prompt before typing anything, because the one time it didn't, a task brief landed in zsh and the backticks in it ran as a command.

Capability URLs

Everything the app fetches carries a bearer token in a header. An <iframe> or an <img> cannot send headers, so for a PDF, a screenshot, an agent's HTML review or a proxied dev server the server mints a capability URL: a random UUID that stands for one file or one port for six hours, mintable only with the token, served before the auth gate. An HTML document also gets a Content-Security-Policy sandbox, so a page an agent generated runs in an opaque origin with no route to the app's storage.

Staying fresh on a phone

The app is a progressive web app with a manifest and a push-only service worker that holds no token and intercepts no navigation. The bundle carries its build id and compares it against the server's on every launch, so a phone picks up a new deploy on its next foreground without being reinstalled. That check exists because an iOS home-screen app caches its boot document beyond the reach of HTTP cache headers, and a stale bundle once survived force-quits for days. Everything that takes the screen pushes its own history entry, so back-swipe unwinds document → agent → workflow → session → home.

Why tmux is underneath

tmux is a terminal multiplexer: a server process that owns terminals, and clients that attach to look at them. A session is a group of windows; a window holds one or more panes; a pane is a pseudo-terminal with a process in it. The property that matters is that the server keeps running whether or not a client is attached. Close every window, close the app you opened it from, and the shell in the pane carries on.

tmux server one process · outlives every client session sc-9A2F… pane $ claude cwd: worktree A env: TREACLE_ SURFACE_ID session sc-51C0… pane $ codex cwd: worktree B session sc-E7D1… pane $ zsh cwd: worktree A you, in a terminal tmux attach, then detach. Nothing dies. treacle, in control mode tmux -C attach: a text protocol of %output events, bridged to browsers
Every treacle tab is one tmux session with one pane. You and treacle are both just clients.

Treacle runs its own tmux server on a dedicated socket, separate from any tmux you use by hand, so its config and its sessions never collide with yours. Each tab you create is one tmux session with one pane, started in the worktree's directory with the pane's surface id in its environment. That id is how the hook and the MCP server, running inside the agent in that pane, know which pane they belong to.

Control mode is what makes the phone's terminal possible. Instead of attaching a real terminal, treacle attaches with tmux -C, which speaks a line-oriented text protocol: every burst of output arrives as a %output event with the bytes octal-escaped, and input goes back as send-keys. Treacle decodes those bytes at the byte level (a UTF-8 character arrives as several escaped single bytes, and decoding the stream as text first mangles them), forwards them over a WebSocket, and renders them with xterm.js in the browser. On connect it replays the current screen with capture-pane, resizes the pane to the viewer with refresh-client, and uses bracketed paste for multi-line input so a TUI receives a paste rather than a stream of Enters.

The same mechanism gives treacle its read of the pane for everything else: which command is running (an idle shell versus an agent), the pane title (which is how transcripts are verified), the bottom lines (which is how modes and dialogs are read), and whether a run is in flight.

What survives what. A treacle restart: everything, since tmux is a separate process and the timeline is in SQLite. A closed laptop or a dropped connection: everything, the phone just reconnects. A reboot: the tmux server dies with the machine, so the panes are gone, but the conversations are files, and a resumed session picks them up with claude --resume or codex resume.

How your phone reaches a Mac behind your router

Treacle binds to localhost. Nothing on your router is opened, no relay of ours exists, and the Mac has no public address. The phone still reaches it because both are on your tailnet: a private network Tailscale builds between your own devices.

Tailscale coordination server who is on the tailnet, their keys and endpoints. Never your traffic. the Mac mini.tail0000.ts.net · 100.x.y.z tailscale serve terminates HTTPS with a Let's Encrypt cert for the ts.net name, then forwards to treacle on 127.0.0.1:4711 WireGuard tunnel, device to device direct when NAT allows, via a DERP relay when it doesn't. The relay forwards ciphertext it cannot read. your phone also 100.x.y.z, same tailnet Safari / Chrome opens https://mini.tail0000.ts.net a real certificate, so it can be added to the home screen and receive push
The coordination server only introduces the two devices. The bytes go device to device, encrypted with keys only they hold.

WireGuard is the tunnel. Each device holds a private key and publishes the public half; traffic between two devices is encrypted end to end with those keys, and only those two devices can read it. Tailscale's contribution is the plumbing around WireGuard: a coordination server that tells your devices about each other, their keys and their current network addresses, and the NAT traversal that gets two devices behind two home routers talking directly. When that fails, a DERP relay forwards the packets, but it forwards ciphertext; the relay never holds a key.

MagicDNS gives each device a stable name, mini.tail0000.ts.net, that resolves only inside the tailnet. That name is what makes HTTPS possible without owning a domain: with HTTPS certificates enabled for your tailnet, tailscale serve obtains a certificate for the name from Let's Encrypt, terminates TLS on the Mac, and forwards plain HTTP to treacle on localhost. Setup runs tailscale serve --bg 4711 once and treacle is at a real https:// address.

HTTPS matters for two features rather than for secrecy, since WireGuard already encrypts the path: a web app can only be installed to the home screen and receive push notifications from a secure origin. Without Serve, treacle falls back to binding the Tailscale IP over plain HTTP, still inside WireGuard, still reachable from your devices, just without the badge and the push.

What the token is for. Being on the tailnet gets a device to the server; the bearer token gets it in. Setup prints the token as a QR code whose URL carries it in the fragment, so it never appears in an access log; the app stores it locally and sends it as a header on every request. WebSockets, which cannot send headers, get a single-use ticket that lives for thirty seconds.

From curl to a Dock icon

One line installs the binary; one command sets up the host. Setup is a walkthrough rather than a checklist: six steps tick on their own (the token, a tmux config, the web app, a launchd service that keeps the server up across reboots), it asks which repos to manage and whether to wire up Claude Code's hooks and MCP, and it does not call the host reachable until a device of yours has actually loaded the app.

curl -fsSL https://treacle.dev/install | sh   # install the binary
treacle setup                                  # walk the host through setup
treacle doctor                                 # check every piece is alive
treacle voice install                          # whisper.cpp for the mic button
treacle --version                              # which build you are on
treacle update                                 # install a newer build, if there is one

The remote-access step has one screen per state it can find you in: Tailscale not installed, not signed in, HTTPS certificates off, no second device on the tailnet yet. Each offers to do the fix, opens the right page, and checks again. If the Mac you are sitting at is the host, you can skip it and open the app on localhost; come back when you want other devices in. Setup is idempotent, and the service is given your login shell's PATH, so an agent installed through asdf or mise is as visible to the server as it is to your terminal.

Updates work the same way in either direction. treacle update compares its own build id with a small manifest published beside the binary, streams the new build to a sibling file, checks its size, hash and signature, keeps the running build as a fallback for treacle update --rollback, swaps, and restarts the service. The server runs the same check once a day; when a newer build exists the app shows one line above the inbox, and updating from the phone is one tap. A setting picks the behaviour: ask, the default, tells you and waits; auto installs and restarts, but only when no session is working or waiting on you; off makes no request at all. That check is the one request treacle makes to treacle.dev, a GET for a public file with no token or identifier on it.

What it is not

  • macOS on Apple silicon, today. The server leans on launchd, and the binary is built for one platform. The client side is any browser that can install a web app.
  • Two agents natively. Claude Code gets the most: hooks, permission cards, MCP tools, the transcript-backed feed. Codex has a feed and question cards. Anything else runs in a plain terminal tab with the raw mirror.
  • Some of it is reading a screen. Sign-in and launch dialogs have no structured signal, so they are parsed by shape. A redesign of a TUI can break that, which is why every such reader has the terminal as its visible fallback.
  • Not a proxy, on purpose. Treacle does not expose subscriptions as an API and will not. What it borrows from that world is the idea of accounts as a pool, applied at launch and restart time where the CLI's own commands can carry it.

Treacle is free to use on machines you own or administer. The source is not open; the binary may not be rehosted. It lives at treacle.dev.

treacle · written for the people running more agents than they can watch · September 2026