Seahelm macOS 14+ · Swift + AppKit · 247 files

A native macOS workspace for coding agents

Your agents run. First Mate watches. You steer.

Your fleet sits on one roster: a row per worktree, mustered by repo, by status, or by what moved last, one agent to a split pane. Seahelm draws every one of them on the Ghostty engine, watches who is working and who is waiting on you, and after a reboot every session is where you left it. Twelve agent CLIs work out of the box.

$ curl -fsSL https://seahelm.dev/install.sh | sh
github.com/BetaYao/seahelm

Forty-seven seconds of it working

A real session, not a mock-up: worktrees down the sidebar, an agent working in one of them, and a suggestion card asking which way to go.

Deck one · on deck

What you steer

Seahelm has no editor and no diff-review ceremony. The whole surface is about placing agents — which repo, which worktree, which pane — and then showing you which of them needs a human.

APP PROJECT WORKTREE PANE seahelm — one window, one keyboard openbeta/seahelm main claude codex feat/island-usage claude codex acme/api fix/auth-retry claude — needs input main aider
Four tiers, one source of truth. A project is a git repo you added; its worktrees come from git worktree list --porcelain; a pane is a leaf of that worktree's split tree, and holds exactly one agent. AgentRegistry.shared knows the status of every leaf in the picture.
Splits & layouts
A binary split tree per worktree, dragged by dividers or driven from the keyboard, and serialized to config so relaunch restores it. The fleet list groups by repo, by status, by last activity, or expands to every pane.
Side panel
File tree, code editor, Markdown preview, and git diff review — without leaving the worktree or interrupting the agent in it.
Token usage
Claude and Codex session logs parsed into token and quota figures, rotating one readout at a time in the status pill.
Continuous integration
Every time an agent finishes a turn, the whole fleet's work is folded onto trunk and checked out somewhere you can build it — without committing anything, and without touching a single working directory. How it works.
Remote helm
A browser client, seahelm-web, served by a gateway inside the app itself. Pair once with an eight-digit code: it lists the fleet and opens a real terminal on any pane — xterm.js over a single WebSocket, carrying the same control calls the desktop makes and the VT bytes beside them. Taking orders over Telegram and mail is experimental.

The Island

A pill at the top of the screen that stays shut until a worktree needs you. It opens for one of three things: something is running, something is waiting, something broke. Agent suggestions arrive in it as clickable cards.

First Mate

An autonomous supervisor on status transitions. Green-zone actions it just takes — watch, inspect, auto-commit. Red-zone actions — broadcast an order, return a worktree to port — queue up for your approval first.

BEFORE AFTER main claude codex feat/x — just created empty placeholder hook cwd moveLeaf main codex feat/x claude Station + zmx session kept placeholder destroyed
The pane follows its agent. Claude Code makes a worktree and works in it; every hook payload carries cwd, so Seahelm matches the longest worktree prefix and moves that one pane — keeping its Ghostty surface and its zmx session — instead of standing up a stranger beside it. Two gates keep it honest: the destination must belong to the same repo, and after any move auto-follow holds for ten minutes, because an agent's cwd bounces while it works.
Twelve agents, three depths of integration
AgentStatusEvent hooksSuggestion cards
Claude Code✓ manifest + hooks✓ native
Codex✓ manifest + hooks✓ native
opencode✓ manifest + hooks✓ pluginmodel-volunteered
Nine more✓ screen scan

agent · aider · amp · claude · cline · codex · cursor · gemini · goose · kiro · opencode · pi

Deck two · below deck

What runs it

247 Swift files in four layers, over the Ghostty terminal engine. No Combine, no SwiftUI — AppKit and delegates, because the thing on screen is a Metal surface with a PTY behind it.

LayerPathFilesWhat lives there
CoordinatorsSources/App/15 Window, tabs, split operations, side panels, the modal keyboard machine.
UISources/UI/75 Dashboard layouts, the Island, split container, worktree sidebar, diff viewer, settings, onboarding.
Core servicesSources/Core/ · Status/133 AgentRegistry, the detection pipeline, the manifest engine, First Mate, the control socket, hook installers.
Terminal & gitSources/Terminal/ · Git/19 The Ghostty C API bridge, Station surface lifecycle, the split tree, worktree discovery.
SOURCE BRIDGE INTERPRET DECODE REDUCE CONSUME Claude Code Codex · opencode seahelm-hook socket ▸ webhook hook events SessionStart · Stop Pre/PostToolUse HookDecoder exec JSON map any of 12 CLIs no integration Ghostty screen text under a lock StatusDetector exit ▸ OSC 133 ▸ text patterns ScanDecoder 12 JSON manifests rules renders poll verdict per event per poll NormalizedEvent PaneReducer · pure AgentRegistry source of truth delta publishes to all three Island · dashboard status bar First Mate green ▸ red zone EventHub CLI subscribers
Two lanes, one event shape. Agents that report hooks take the fast lane; the other nine are read off the screen every two seconds and matched against a priority-ordered manifest — process exit beats an OSC 133 shell phase, which beats a text pattern. Both lanes decode into the same NormalizedEvent, so the reducer stays a pure function and the UI never learns which lane a status came from. Ask any pane which rule decided it: seahelm pane explain <pane>.
Runninga tool call is in flight
Needs inputblocked on a human
Idleprompt is back, waiting
Errorthe agent broke
Dormantprocess exited

The same five states drive the dot, the card, the Island and the rollup. There is no sixth.

an agent, in a pane SEAHELM_PANE_ID seahelm CLI ~/.local/bin/seahelm ControlSocketServer 0600 unix socket EventHub bounded ring buffer pane.split pane.run wait.agent_status runs JSON-RPC mutates panes events an agent can drive the app that is watching it
The loop closes. Seahelm watches the agent; the agent, holding $SEAHELM_PANE_ID, can call back and split a pane, run a command in a sibling, read its scrollback, or block until another pane goes idle.

The surface an agent gets

A python3 wrapper on PATH, newline-delimited JSON-RPC over a 0600 socket. Nothing to authenticate to, nothing on the network.

$ seahelm pane list
$ seahelm pane read <pane> --lines 50
$ seahelm pane split <pane> --direction right
$ seahelm pane run <pane> "npm test"
$ seahelm wait agent-status <pane> --status Idle
$ seahelm pane explain <pane>   # which rule?
$ seahelm layout export

Hook shims install non-destructively into each tool's own config — and stay out of the way. CwdChanged is deliberately not registered: taking it made Claude Code hand worktree creation to us and broke --worktree.

seahelm.app window · SplitTree · Station · Metal surface GONE ON QUIT you quit the app · the machine reboots zmx session  seahelm-<repo>-<worktree> PTY · shell · the agent process, still thinking SURVIVES git worktree + branch on disk SURVIVES re-attach by name
Sessions outlive the app. Each split leaf claims a zmx session named for its worktree; a health check three seconds after creation catches a stale one and rebuilds it. Quit, reboot, reopen — the agent is where you left it, mid-thought.

Two threads, one terminal engine

The status poll runs on a background queue while you type on the main one, and both call into the same C library. A single lock serializes them — except key input, which deliberately takes no lock: Ghostty is thread-safe for keys, and holding it there would deadlock on a synchronous callback.

The poll is not uniform either. The worktree you are looking at is read every cycle; the rest every third, so a fleet of twenty panes costs about what three do.

Built on

Ghostty for the terminal engine, linked as a C xcframework — zmx for session persistence — Sparkle 2 for updates, rendered into an inline banner instead of its own modals.

Deck three · the rendezvous

Do they still compose?

Six agents, six worktrees, six branches that are each green on their own. Whether they still work together is a question nobody answers until merge day. Seahelm answers it after every turn — without asking anyone to commit, and without touching a single working directory.

WORKING DIRECTORIES OBJECT DATABASE · .git feat/auth HEAD + edits feat/island mid-turn fix/zmx HEAD + edits integration checkout detached — no branch to pick the only write that crosses back up is that one reset scratch index worktree untouched snapshot + untracked HEAD only still working snapshot + untracked trunk + feat/auth + feat/island integrated commit merge merge = result conflict — Config.swift excluded from this round, reported reset --hard already merged each step: merge-tree --write-tree → commit-tree
One round. Every worktree is captured as a commit, the commits are folded onto trunk one merge-tree at a time, and the checkout is moved onto the result. Everything except the last arrow happens inside .git — so a round that conflicts, holds, or fails outright leaves every working directory exactly as it was.
Nobody has to commit
An agent's turn ends with work that is edited and not committed. Asking for a commit first brings back everything wrong with git add -A && git commit: the repo's pre-commit hooks, whatever -A sweeps up, and a history full of machine commits. So the snapshot is built in a scratch index instead — GIT_INDEX_FILE points read-tree and add -A at a temp file, write-tree makes a tree, commit-tree makes a commit. The worktree, the real index and HEAD are never touched, so the agent working in that directory cannot tell it happened. (git stash create looks like the answer and is not: it omits untracked files, which is most of what an agent produces.)
Merging without a checkout
merge-tree --write-tree merges two commits and writes a tree; commit-tree turns it into a commit; that commit is the next merge's left side. Nothing here touches a worktree or an index, so nothing here can leave one wedged mid-merge — which is exactly why it can run unattended. A git merge in a real checkout could not: one conflict and that directory sits in a conflicted state until a human clears it.
A conflict is a result
Not a failure that stops the round. The colliding worktree is dropped and reported with the paths it collided on; the rest still integrates, so you still get something you can build and test. Sources are applied in a stable order, so the same fleet gives you the same report twice running.
Detached on purpose
The checkout sits on no branch. A branch would be a thing to manage — a name to reset, something pushable, and an entry in git branch -a, which is what the new-branch dialog offers as a base. Start a worktree from that and you have inherited a half-tested mixture of everyone's work. Detached, there is no ref to pick.
One irreversible step, never automatic
Publishing is a single reset --hard onto a commit that is already merged, so it cannot fail partway and cannot leave a conflicted checkout. But reset --hard discards anything uncommitted — so if you have been editing files in the integration checkout, the round builds the commit, holds it, and offers it. That one destructive moment always needs a person.

Trees, not commits

commit-tree stamps a timestamp, so an unchanged fleet rebuilt an hour later yields a different commit holding identical files. Compare commits and every round looks like a change, and the checkout gets reset for nothing. The tree is the change key.

Mid-turn worktrees give HEAD

One agent finishing triggers the round, but the round folds in everyone — and the others may be mid-write: half a file, half a rename. Those contribute their last self-consistent state instead of a torn one that would fail in a way that looks like a real conflict. They come in whole on the round their own turn ends.

The trigger is a status edge

An agent leaving Running — the same edge that drives the status dot — is a stage of work reaching a resting point. Agents finishing within two seconds of each other coalesce into one round rather than one round each.

Opt in by existence

Only repos that already have an integration checkout are touched. Running /integrate once is what opts a repo in; nothing creates a directory on its own, so turning this on cannot surprise you with new state on disk.