Skip to content
Brad Johnson, home
All words · 6 min read

Coding Agents That Outlive Their Window

Tags:#ai#agents#tauri

aludra is built for reaching my projects on several machines: Tailscale, an HTTP API, a client protocol, Firecracker sandboxes. Sometimes all I want is an agent working in a project on the Mac in front of me. damodred is that, and leaving out the rest of aludra was the point. There’s no network listener, no Tailscale, no HTTP API, no sandboxes and no second machine.

The one rule I started from: sessions belong to the agent process, and quitting the app must never stop work. I started it on the night of November 4, and the first milestone was done the next day.

Two processes

 Tauri app (window, tray)            the agent: node agent/src/main.ts
 app/ React + TanStack Router         ├─ Pi Durable 1.3.0 Harness on damodred.sqlite
   │ invoke / Channel                 │    extensions: CodingTools · project-context · claude-code
 src-tauri/src/agent.rs ── agent.sock ┤    one ownerless conversation per session
   connects, or starts the agent      └─ scripted test model (script/script-1), always there
   detached in its own process group

The agent is a Node process with one Pi Durable harness over a SQLite file in its data directory. Each session is an ownerless conversation whose working directory is a project folder, and damodred’s own record of it (project, title, when) is a document written in the same commit that creates the conversation. Nothing in the agent depends on a client being connected. On start it calls harness.resume(), which carries on whatever the last process left unfinished.

Model turns come from the Claude Code that’s installed and logged in on the Mac, through Earendil’s pi-claude-code provider, the same way aludra does it. Claude Code returns the model’s answer and tool calls without running anything, and Pi Durable owns the history, compaction, tools and restarts. damodred never asks for or stores that login. At start, the provider’s model list is narrowed to what the logged-in account offers.

One socket, owner-only

The app talks to the agent over a Unix socket, agent.sock, in the data directory. The directory is 0700 and the socket is 0600 once bound, and the agent runs with a umask of 077, so only my user can connect or read what it writes. The file permissions are the authentication.

A stale socket left by a killed agent gets removed at start, but only after checking that nothing answers on it. If something does, the new agent exits. Pi Durable expects one process per store, so two agents on one data directory is the case to refuse.

The protocol takes its shape from amyrlin’s: hello and welcome with a version, request IDs, and watches whose events carry a seq and start with a snapshot. It’s JSON lines instead of WebSocket frames:

{"type":"hello","protocols":[1],"client":"damodred-app 0.1.0"}
{"type":"request","id":"r1","method":"sessions.list","params":{}}

There are ten methods: agent info, projects, models, list, create, send, stop, two watches, and unwatch. session.send takes a requestId, and sending the same one again returns the same submission instead of a duplicate, so a client that reconnects can resend safely. A message sent while a run is going either waits for the answer or joins the run after the current tool round. sessions.create takes a directory, and it has to be a project directory under the projects root after symlinks are resolved.

Nothing is replayed on reconnect. A client watches again and gets a fresh snapshot. Pi Durable commits partial answers and tool output at most every 100 ms, so that snapshot is never more than about 100 ms behind what the model has sent.

Starting the agent from the app

The Rust side is small. agent.rs connects to the socket, and if nothing answers, it starts the agent and waits up to 20 seconds for the socket to open, trying every 150 ms. The agent gets its own process group:

let mut cmd = agent_command();
cmd.stdin(Stdio::null()).stdout(log).stderr(err);
#[cfg(unix)]
{
    use std::os::unix::process::CommandExt;
    // Its own process group, so closing the app (or a Ctrl-C in `tauri dev`) doesn't signal it.
    cmd.process_group(0);
}
cmd.spawn().map_err(|e| format!("can't start the agent: {e}"))?;

Its output goes to agent.log in the data directory. After that, Rust relays lines between the socket and the webview through a Tauri channel and knows nothing else about the protocol. Closing the window hides it to the tray, Quit exits the app, and the agent keeps running after either one.

damodred’s session view with a conversation titled hello from playwright in the westwood project. A bash call to echo one; sleep 1; echo two; ls -a is marked done with its output under it, followed by Ran it. Output: one two . .., then a message asking for slow 80 with the answer streaming in as word1 through word15. The header reads Running with a Stop button, and the sidebar footer says Agent connected, 1 running.

From damodred’s Playwright run, with the scripted test model in place of Claude. Everything in the transcript is made up.

Three durability checks

The first milestone was mostly about proving three things, and each one is a test that runs against real agent processes with the scripted model. That model’s answer depends only on the transcript it’s sent, so it behaves the same after a restart. The last user message picks what it does: bash: <command> makes a tool call, flaky 2: <text> fails twice the way a dropped connection does and then answers, and slow 40: <text> streams 40 numbered words first.

Killing the agent mid-command

The test starts echo started; sleep 3; echo done > finished.txt, waits until the output shows started, and sends the agent SIGKILL. Then it starts a new agent on the same data directory:

agent.child.kill("SIGKILL");
await new Promise((r) => agent.child.once("exit", r));
client.close();

agent = await startAgent(dirs);
client = await connect(agent.socket);
transcript = await follow(client, session.id);
await transcript.until((t) => t.lastAssistant()?.stopReason === "stop", 15_000, "the answer after restart");

resume() finishes the turn. bash isn’t marked safe to replay, so Pi Durable doesn’t run it a second time. The model gets an error result saying the tool was interrupted and may have partly run, with the output committed before the kill, and then it answers. The transcript ends as user, assistant, tool, assistant.

A dropped model connection

Pi Durable retries a failed request when pi-ai’s isRetryableAssistantError() says the error is retryable. The default is three retries starting at 2 seconds and doubling, capped at 60, which comes to about 14 seconds in all. The test sends flaky 2:, sees two retries scheduled and shown in the live state, and gets the answer on the third attempt. The app shows the wait as it happens:

The same conversation after a message reading flaky 1: after one retry. An error line in red reads API Error: Connection error., and under it an amber line reads Retrying after “API Error: Connection error.” in 1 s (attempt 2).

The tests shorten the first retry delay so the run doesn’t take 14 seconds.

A second test runs pi-claude-code’s error messages through the same function, so the list of what gets retried is written down.

Quitting the app mid-answer

At the agent level, a client disconnects in the middle of a streamed answer. The session keeps going, and a new client connecting 800 ms later gets one snapshot that’s further along than what the first client saw, then the rest. In Chromium, the Playwright test reloads the page during a long answer and checks that the whole transcript comes back, including the messages from before the reload.

I also checked the real Tauri window on Linux, in WebKitGTK under Xvfb, driven with xdotool. The app started the agent itself and a session streamed through the Rust relay. Killing the app with kill -9 mid-answer left the agent running. Reopening the app connected to the same agent, with the same pid and no second agent, and showed the finished answer.

Small safeguards

Every @earendil-works package is pinned to 1.3.0, and pi-claude-code to a specific commit. pnpm check:lockstep reads the installed tree and fails if any copy of a Pi package differs from the pin or the commit changes. Pi’s packages are meant to move together.

Answers render as Markdown with raw HTML shown as text. The e2e test sends an <img onerror>, a <script> and a javascript: link through the scripted model, and checks that no image or script lands in the page and the only link is the https one.

scripts/doctor.sh reports what’s installed and what’s missing, and installs nothing.

damodred was built in a Linux sandbox that couldn’t reach the Mac, working from aludra’s and amyrlin’s public repositories. pnpm test is nine agent tests, pnpm e2e drives the app in Chromium through Vite’s dev relay to a real agent, and the Tauri window got its check by hand.