← Back to blog
EngineeringTerminalArchitecture

Why we built a persistent terminal daemon

NKNitesh K.FounderApril 2, 20268 min read

Every AI coding tool gives you a terminal. Claude Code drops you into a shell. Cursor has an integrated terminal panel. Codex runs commands inline. But every single one of them has the same fatal flaw: close the window and everything vanishes. The running process, the buffered output, the scrollback — gone.

For a quick one-shot task, that's fine. But AI-assisted development sessions aren't quick. They run for hours. You switch views to check the activity timeline. You open a second session to look at a different branch. You close your laptop for lunch. When you come back, you want your terminal exactly where you left it.

What 'persistent' actually means

Persistence isn't just about keeping the process alive. That part is relatively easy — just don't kill it. The hard problem is state. When you switch away from the terminal view and switch back, or when the Electron renderer process restarts, you need the terminal to look exactly as it did. Every line of output. The current cursor position. The scroll offset.

We model this around three independent layers: the PTY process (owned by the daemon), the terminal emulator state (captured as a snapshot), and the output buffer (stored as raw bytes in SQLite). Each layer has a different survival boundary, and restoring the terminal means reassembling all three.

Architecture: the DaemonManager

The core idea is straightforward: move PTY ownership out of the Electron main process and into a dedicated Node.js daemon process. The daemon runs as a child process spawned at app startup and communicates with Electron over a Unix domain socket on macOS/Linux, or a named pipe on Windows. The Electron app can crash, restart, or reload the renderer without affecting any running PTY.

typescript
// terminal-daemon/index.ts
class DaemonManager {
  private sessions: Map<string, PTYSession> = new Map();
  private db: Database;
  private ipc: DaemonIPC;

  attach(sessionId: string, opts: PTYOptions): PTYSession {
    const existing = this.sessions.get(sessionId);
    if (existing) return existing; // idempotent

    const pty = nodePty.spawn(opts.shell ?? detectShell(), [], {
      name: "xterm-256color",
      cols: opts.cols ?? 220,
      rows: opts.rows ?? 50,
      cwd: opts.cwd,
      env: { ...process.env, ...opts.env },
    });

    const session: PTYSession = {
      pty,
      buffer: new RingBuffer(512 * 1024), // 512KB cap
      sessionId,
    };

    pty.onData((data) => {
      session.buffer.push(data);
      this.persistChunk(sessionId, data);
      this.ipc.broadcast({ type: "pty:data", sessionId, data });
    });

    this.sessions.set(sessionId, session);
    return session;
  }

  detach(sessionId: string): void {
    // Persist snapshot but keep PTY alive
    const session = this.sessions.get(sessionId);
    if (session) this.persistSnapshot(sessionId, session);
  }
}

The attach call is idempotent — if the Electron app reconnects after a crash, it gets back the same live PTYSession. This is the key invariant. No matter what happens to the Electron process, the PTY inside the daemon keeps running.

Snapshot system: headless terminal emulator

Keeping the PTY alive is necessary but not sufficient. When the renderer reconnects, it needs to see the current screen — not replay potentially thousands of lines of scrollback from scratch. We use xterm-headless, the same terminal emulator as xterm.js but running in Node without a DOM, to maintain a shadow copy of the terminal state inside the daemon.

typescript
import { Terminal } from "@xterm/headless";

class SnapshotEngine {
  private mirror: Terminal;

  constructor() {
    this.mirror = new Terminal({ cols: 220, rows: 50 });
  }

  feed(data: string): void {
    this.mirror.write(data);
  }

  capture(): TerminalSnapshot {
    const buffer = this.mirror.buffer.active;
    const lines: string[] = [];

    for (let i = 0; i < buffer.length; i++) {
      lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
    }

    return {
      lines,
      cursorX: buffer.cursorX,
      cursorY: buffer.cursorY,
      scrollTop: buffer.viewportY,
      capturedAt: Date.now(),
    };
  }
}

Snapshots are captured on a 5-second interval when the session is not the active view, and immediately on detach. On restore, we serialize the snapshot as a sequence of VT100 escape sequences and write it directly to the xterm.js instance in the renderer — the terminal renders the exact state it was in when you left.

Output buffering: nothing lost on view switch

There's a subtle race condition that every terminal emulator in an Electron app hits: you switch away from the terminal view, the React component unmounts (or is hidden), and output keeps arriving from the PTY. If you re-render the component from scratch, you get a blank terminal — all that output landed in the void.

Our solution is to keep the xterm.js terminal element mounted but hidden when you switch views, using CSS visibility rather than unmounting. Output continues flowing into the xterm.js instance. When you switch back, it's all there. But for session switching — switching to a completely different session's terminal — we need the buffer replay approach.

Key invariant: xterm.js terminal instances are never unmounted during a session. They are hidden with CSS and kept alive in the DOM. This eliminates the entire category of 'terminal went blank when I switched views' bugs.

Session auto-naming

A small but impactful feature: we hate sessions named 'Session #47'. When a new session starts, we parse the working directory for a git repository, extract the current branch name, and combine it with the project name to generate something like cockpit / terminal-engineering. If there's no git repo, we use the directory basename.

After the session has some activity, we also watch for the AI's first substantive response and offer to rename the session based on the inferred task. This runs through a lightweight heuristic — looking for imperative verbs near the beginning of the response — rather than burning a Haiku call on every session start.

Port detection and file link providers

Two small features that dramatically improve the terminal experience. First: when the AI spins up a dev server, we scan terminal output with a regex for localhost:\d{2,5} patterns and render clickable links. The link opens in the system browser — no manual copy-paste.

Second: file paths in terminal output — both absolute paths and relative paths resolved against the session's working directory — become clickable. Clicking opens the file in the user's configured editor via the EDITOR environment variable, or falls back to VS Code if unset. This makes error traces in terminal output navigable: click the filename:linenum and jump straight to the error.

The result

Sessions that survive Electron restarts, renderer crashes, view switches, and machine reboots via snapshot restore from SQLite. A terminal that behaves like a proper development tool — not a temporary shell that forgets everything the moment you look away.

The architecture does add complexity: a separate daemon process, IPC protocol, snapshot serialization, and a headless terminal emulator running in Node. But for the use case — long-running AI coding sessions where every piece of context matters — that complexity is the right trade.

Related posts

Stay in the loop

Subscribe to updates

New engineering posts, architecture deep dives, and product updates. No noise — only signal.

No spam. Unsubscribe anytime.