TL;DR
with several shells and a many AI-agent sessions running at once, I got annoyed having to click through every tab. so I built terminal-harbor, a GUI for managing many terminal sessions with features such as a tab strip tells you what is open, whats waiting on your input, and an easy way to organize and move between a lot of terminals.
try it out - link to github repo terminal harbor is a macOS desktop app: one large focus area with up to 4 live split panes, plus a left rail of live thumbnails of every session. each session is a real PTY, and the thumbnail is a running mini-preview of it, so you can watch a session do something without switching to it. click a thumbnail to load it into the active pane.
features:
⌘F across every session's scrollback⌘⇧J to jump to the next session that is waiting⌘⇧T to reopen the last terminal you closed,
the headless terminal is the source of truth
every session owns an @xterm/headless terminal that is always fed the byte stream, whether or not anything on screen is showing that session. 2000 lines of scrollback each. that headless model is what the thumbnails draw from, what a pane replays from when you point it at a different session (serialize() written into a reset() pane, lossless), and what ⌘F searches when it searches across sessions rather than within one.
the obvious alternative is one visible xterm.js instance per session, hidden when it isn't focused, promoted into the focus area when it is. it loses on all 3 of the features:
separating the model from the renderer means a pane is a view and nothing more — you can put any session in any pane, and closing a pane costs a session nothing.
so the focus panes are xterm.js on WebGL, and the thumbnails are not terminals at all. one shared requestAnimationFrame loop throttled to 3fps walks the tiles and redraws only the ones that are visible and whose model is marked dirty, which means idle off-screen sessions cost effectively nothing. each tile is the model's current viewport drawn as tiny monospace text on a 2D canvas, with the font sized so cols columns fit the tile width. a freshly-registered tile paints on the next frame instead of waiting for the throttled tick, so switching workspaces doesn't flash a rail of blank boxes.
one packaging note. @xterm/headless@6 is CJS-only, so vite.config.ts carries a regex-anchored alias pointing at lib-headless/xterm-headless.js.
with no shell integration present, the state comes from 2 signals together:
if the process group leader is the shell's own pid and output has gone quiet, that's considered an idle prompt and no badge. if some other program owns the group and has gone quiet, that's a program probably blocked on stdin, so amber. anything still producing output is running. when shell integration is there, OSC 133 markers outrank the heuristic completely:
A and B mean at promptC means command output startedD means the command finished.
no timing guess at all. the scanner runs over raw bytes and keeps its state across chunk boundaries, so a sequence split across 2 reads still parses, and it only observes (the bytes reach xterm unchanged).
it has to be bytes, not text: a 64KB read can cut a multibyte. character in half, and String::from_utf8 on a PTY chunk will eventually mangle someone's output.
the blind spot that survives all of this is read -p. a shell builtin prompting for input is still the shell as process group leader with no output coming, which is indistinguishable from an idle prompt by the heuristic. OSC 133 injection would fix it exactly, and that's why it's on the list.
agents needed their own path, because an agent parked on a permission prompt is silent, and the thing you actually want on the tile is what it's asking for. so the app binds a loopback HTTP server on a random port with a bearer token, and every shell it spawns gets HARBOR_SESSION_ID, HARBOR_HOOK_URL, and HARBOR_HOOK_TOKEN in its environment. turning it on in settings merges 3 hooks into ~/.claude/settings.json — Notification, Stop, and UserPromptSubmit — after backing the file up, and every entry it writes is tagged with the hook script's filename so installing is idempotent and uninstalling removes its own hooks and nothing else. notification and stop set waiting, userpromptsubmit clears it, and typing into the session clears it too, since you're plainly already there.
correlation degrades instead of lying. a claude started over ssh, or inside tmux, or in a container doesn't inherit those variables, the hook script no-ops on the missing URL, and that session falls back to the heuristic.
PTY output reaches the frontend as raw bytes over a per-session tauri Channel — InvokeResponseBody::Raw on the rust side arriving as an ArrayBuffer on the JS side, never a JSON array of numbers.
flow control is end to end. the reader thread tracks bytes it has sent but that the frontend hasn't acknowledged, and stops reading once that backlog crosses 2MB. the frontend acks through ack_pty once the headless model has finished writing the chunk. the part that makes it real backpressure rather than a dropped-frames policy is what happens while the reader is d: it isn't draining the PTY, so the OS PTY buffer fills, so the shell's own write() blocks. the producer is the thing that slows down. a yes in one pane can't outrun the renderer and take the window with it.
the state evaluator is a separate thread that samples every session every 200 ms and emits a session-state event only when that session's state actually changed, so a room full of busy terminals isn't also a firehose of IPC.
HTML5 drag-and-drop is unreliable in WKWebView, and 2 of the features are drags:
because of this, both run on @dnd-kit instead, with a pointer sensor, an explicit grip handle, and a DragOverlay using snapCenterToCursor and pointerWithin. native alert and confirm are avoided for the same reason, so deleting a workspace asks inline instead of through the browser dialog.
thumbnail tiles are also fixed-size on purpose — flex: 0 0 auto — so a rail with a dozen sessions in it scrolls rather than squashing every preview into an unreadable sliver.
what persists is the arrangement, not the processes: workspaces, terminals, names, colors, rail order, each workspace's split layout, and each session's working directory. on relaunch the shells come back in their saved cwd. live process state can't come back, and nothing pretends otherwise. saves are debounced 1.5s and flushed on
beforeunload, and suppressed entirely while a restore is in progress so the restore can't overwrite the file it is reading from. reading the cwd shells out to lsof, because macOS has no /proc.
and the one that was mine. the registry is a globalThis singleton, which is the right shape for the thing that owns every PTY and has to survive a hot reload, and the restore runs from a react effect at boot, and in dev those 2 facts collide — fast refresh re-ran the effect while the singleton sat there unchanged, so every restored session got spawned a second time, and I had a rail of duplicates and twice the PTYs I asked for. boot() is guarded by a booted flag now, and editing registry.ts deliberately triggers a full page reload rather than a fast refresh, because a shape change to a stateful singleton must NOT be allowed to leave a stale instance behind.
what I built: the rust core, the PTY and flow-control layer, the detection, the claude code hook integration, and the whole frontend. MIT.
notification banners are unreliable in an unsigned dev build and only fire properly out of a signed, notarized .app, which is the next thing on the list, ahead of a command palette and OSC 133 injection. no automated test suite yet, so you verify it by running it.
macOS only for v1, and not incidentally — the cwd lookup is lsof, and the detection reads the PTY's process group leader.