AgentDE

AgentDE architecture

AgentDE is one native process: a SwiftUI window whose terminal areas are long-lived AppKit views. There’s no daemon and no multiplexer underneath; each tab’s shell lives in a small holder process of its own (see “Terminal holders”). Features beyond the core come from native extensions, Swift packages compiled into the app that implement hooks in AgentDEKit (see EXTENDING.md).

AgentDE.app (one process)
├── SwiftUI chrome           sidebar, tab strip, header, status bar, ⌘K palette, settings
├── Store (@Observable)      groups, tabs, settings, live per-tab runtime state; the extensions' host
├── extensions               AgentDEExtension instances, in extensions.json order, then JSON plugins
├── TerminalRegistry         tab id → TerminalSession (SwiftTerm view ↔ agentde-pty holder)
│   └── BrowserRegistry      tab id → WKWebView
├── AgentMonitor             1 Hz poll of ~/.claude/sessions → tab states
└── ~/.config/agentde/state.json

Packages

Package Products Role
agentde (this repo) AgentDEKit (library), AgentDE (library), agentde-pty (executable) The extension API, the core app, the terminal holder
An extension, e.g. agentde-widgets One library Depends on AgentDEKit; never on AgentDE
The build package AgentDEBuild (executable) Generated by scripts/assemble.py from extensions.json into .build/app-package: its main.swift imports AgentDE and each extension and calls AgentDEMain.run(extensions:)

The core is a library so that the app’s main can live in a package that also sees the extensions. AgentDE re-exports AgentDEKit (Model/Brand.swift), so every core file sees the API.

Source map

Sources/AgentDEKit/ (the API, and what extensions share with the core):

File Responsibility
Extension.swift AgentDEExtension (hooks, each with a do-nothing default), AgentDEHost, TabInfo, GroupInfo, ExtensionAction, LinkRule, ThemeDefinition, PaletteDefinition, TicketInfo, TicketProvider
PluginManifest.swift The JSON plugin format: decoding, loading a plugins folder, and turning links, themes and palettes into the types above
AppBrand.swift The build’s name, data folder and default icon, from Info.plist (AgentDEBrandName, AgentDEDataFolder, AgentDEDefaultIcon), else AgentDE’s
Theme.swift Theme (every color the app paints), Color(hex:), the \.theme environment value
Components.swift, TicketRow.swift, FileIcons.swift Shared views: AskRing, .activeOutline(...) (High contrast outlines), TicketRow, file icons
Hint.swift View.hint(_:), the hover labels
DebugLog.swift The debug log (rotated at 5 MB), the main-thread hang watchdog that saves sample stacks, and process stats for the heartbeat
SafeID.swift Validation for ids that reach shell commands
Worktrees.swift Git worktrees: name checks, and git worktree add --no-track -b <name> into <repo>-worktrees/<name> beside the repo, from the remote’s default branch (origin/HEAD) after a fetch (no prompts: GIT_TERMINAL_PROMPT=0, ssh in batch mode), else from HEAD
Tabs.swift TabKind, AgentState

Sources/AgentDE/, by folder:

Folder Files Responsibility
App/ AgentDEApp.swift AgentDEMain.run(extensions:), the app delegate, the single Window scene, menu commands and shortcuts
  AppRestart.swift Restart, and the once-a-minute check for a newer installed build
  JSONPlugins.swift JSON plugins’ links, themes and palettes, as one more extension (id “plugins”), loaded after the native ones
Model/ Tabs.swift TabModel, GroupModel (saved) and TabRuntime (live)
  Settings.swift AgentDESettings, its enums, and lenient decoding
  Theme.swift Built-in themes and group palettes (Theme.all, GroupPalette.all add extensions’ at launch), terminal fonts
  ViewerLinks.swift Which links open in the viewer: Claude artifacts plus extensions’ LinkRules
  AppIcon.swift AppIcons: the PNGs in the bundle’s AppIcons/, their order and names (icons.json), applying one to the Dock
  GitInfo.swift Reads the branch from .git, including worktrees, cached for 2 s
  GitChanges.swift The side panel’s Changes: parses git status, git diff --numstat and git log, and makes diffs (read-only)
  Brand.swift @_exported import AgentDEKit
Store/ Store.swift The @Observable store: state, lookup, persistence (lenient load, debounced save), the extension list
  Store+Extensions.swift Store as AgentDEHost, and the helpers that call extensions’ hooks
  Store+ClaudeRestart.swift Restart Claude on a tab, and Restart Idle Claude Tabs
  Store+Selection.swift, +Tabs, +Links, +Groups, +Agents, +AgentStatus, +Worktrees All changes to that state, by area. +Worktrees makes worktree tabs (AgentDEKit/Worktrees) and keeps their PR status current for the sidebar; +AgentStatus turns registry entries into tab states and notifications; +Links routes ⌘-clicks and drives the viewer and side panel
Terminal/ TerminalSession.swift One tab’s terminal: reattach or launch (startup command, agent, background mode), environment, holder events, delegate callbacks, ending the process tree
  PtyHost.swift Finding, starting and connecting to agentde-pty holders; PtyConnection, the framed socket connection
  TerminalRegistry.swift, TerminalPane.swift Ownership of terminals, web views and explorers by tab id, and the SwiftUI bridge that reparents a long-lived view
  ShellIntegration.swift The zsh ZDOTDIR wrapper that loads the user’s dotfiles, then runs a tab’s startup command and agent
  ProcessTree.swift Process-table snapshots for descendants and this app’s own shells
Agents/ AgentMonitor.swift Polls Claude’s session registry, maps sessions onto tabs, opens new background sessions (/fork) as tabs, consumes background-job receipts, logs the heartbeat
  ClaudeVersion.swift ClaudeVersion (running and installed Claude Code versions, outdated rule) and ClaudeRestart (who may restart, and the resume commands)
  ClaudeJobs.swift A /fork’s parent session, from its job record
  Notifications.swift Notifier (macOS banners, click to jump) and TranscriptPeek (the last reply, for Done banners)
  AgentCatalog.swift Agents beyond Claude and Codex from extensions and JSON plugins (AgentDefinition); .agent tabs keep its id in TabModel.agentID and run its command (TerminalSession.agentCommand). They have no resume or Recent list; status comes from HookStatus
  HookStatus.swift Status any agent reports through its hooks: AGENTDE_STATUS_FILE per tab (~/.config/<data folder>/status/<tab id>), one line <working|needs-you|idle> <pid> [detail]. AgentMonitor turns a line into a registry-like entry when its pid runs inside that tab and Claude’s registry has nothing for it, so the same state rules, notifications and sparkles apply; files of exited pids are removed
  RecentSessionScanner.swift, CodexSessionScanner.swift, RecentSessions.swift Named recent Claude sessions (transcript heads and tails) and Codex threads (Codex’s thread database), and the per-group Recent and Older lists
  PullRequests.swift PullRequestMonitor (gh pr view <branch>, rolled-up checks) and Fix with Claude
Search/ SessionSearchIndex.swift TranscriptIndex (incremental index of conversational text) and SearchMatch. Foundation-only, so it can be tested on its own
  SessionSearch.swift, SessionSearchPanel.swift ⌘⇧F: the search model (snapshot, background scan, streamed results), jumping to hits, in-tab find, and the panel
Views/ RootView.swift, HeaderBar.swift, TabStrip.swift, WorkArea.swift, StatusBar.swift The window’s layout, top to bottom; RootView adds extensions’ window overlays, WorkArea the side panel (SidePanel.swift: the switcher; SidePanelTabs.swift: Files and Changes)
  Sidebar.swift, Palette.swift, SettingsView.swift, Browser.swift, PullRequestChip.swift Sidebar (with an extension’s footer), ⌘K, Settings (with an Extensions tab, and an Icon tab when the build has more than one icon), browser pane, PR chip
  Viewer/ The viewer above a tab’s chat: ViewerArea (header with extensions’ accessories, and the find bar ⌘F opens when the viewer has focus: Store.findInTab decides between it and the terminal), ViewerPane, and ViewerHost/ViewerRegistry, which own each tab’s web, PDF, image, AVKit player, text and Quick Look views, reload files when they change (not Quick Look: it aborts the app when its file changes mid-load, so only ↻ refreshes it), and search them (ViewerHost.find: WebKit’s find, PDFKit’s, or a text search)
  FileExplorer/ The File Explorer tab: FileExplorerModel (lazy tree, git ls-files -z filter), its views, the Quick Look preview, and the Markdown preview (MarkdownRenderer, MarkdownView)
  Components.swift Status dot (Core Animation), sparkle burst, icon button, rename field, app logo (a theme’s logo, else the app icon)

Sources/agentde-pty/main.c is the holder: it owns one tab’s pseudo-terminal, output ring and terminal modes, and serves the app over a Unix socket.

Tests/AgentDETests/ has Swift Testing suites for the logic that doesn’t need a window: id validation, launch commands, background-session tab placement, search matching and the incremental transcript index, settings and saved-state recovery, git branch reading and worktrees, the background-launch script (run in a real zsh with a stand-in claude), process trees (what closing a tab ends), the holder, link routing and viewer kinds, previews, app icons, and JSON plugins.

Extensions

AgentDEMain.run(extensions:) stores the list and starts the app. Store.extensions is that list plus JSONPlugins at the end. At launch, Store.startExtensions (from the app delegate, after state is loaded) gathers every extension’s linkRules into ViewerLinks.rules, themes into Theme.all and palettes into GroupPalette.all, then calls each start(host:). At quit, after saving, it calls each stop().

Hook Called from
launchEnvironment(for:) TerminalSession’s environment, merged in order (a later extension’s value wins)
agentSessionsChanged() Store.applyClaudeSessions, after a poll that changed saved tab data (a session id, a name)
liveSessionsChanged(_:) Store.updateLiveSessions, after each AgentMonitor poll
agentWentIdle(_:) Store.notifyTransition, when a tab’s state changes to idle or done
wantsAttention(_:) The sidebar, tab strip and side-panel strip, for the soft ring
tabClosing(_:keepAgent:) Store.dispose; keepAgent only for a background-session tab closed with Close, Keep Agent Running
backgroundSessionOpened(_:session:forkParent:) Store.adoptBackgroundSession, with the parent from Store.forkParent(job:)
sidePanelTabs(for:) Store.sidePanelTabs: every extension’s tabs, then Files and Changes; TabModel.panelTab remembers the one showing. Store.panelVisible: TabModel.panelHidden when the user chose (Layout → Side Panel), else shown when an extension adds a tab
sidebarFooter() Sidebar: the first non-nil footer
windowOverlay() RootView: every non-nil overlay, over the window
viewerAccessory(for:item:), title(forLink:in:) ViewerArea’s header
newMenuItems(for:) A group’s right-click New menu
settingsView() Settings → Extensions, one segment per extension that has one

The host side (Store+Extensions.swift) stores per-tab values in TabModel.extensionData under "<extension id>.<key>", and settings as JSON strings in AgentDESettings.extensionSettings[<extension id>]; both are saved with the rest of the state.

Key design decisions

Tabs outlive the app. A tab’s shell doesn’t run under AgentDE. TerminalSession connects a SwiftTerm TerminalView to an agentde-pty holder: a small C program, one per tab, that owns the pseudo-terminal and the shell. Quitting, updating or crashing the app only drops the connection, and the next launch reconnects. See “Terminal holders” below.

Terminal views outlive SwiftUI. SwiftUI rebuilds views freely, but a terminal must keep its process and scrollback. Each tab gets one TerminalSession, owned by TerminalRegistry and keyed by tab id. TerminalPane is a thin NSViewRepresentable that reparents the session’s view into whatever container SwiftUI currently shows. That container (TerminalContainer) sets the terminal’s frame to its own bounds on every layout and whenever the terminal’s frame changes, rather than relying on autoresizing, which only adds size changes to a frame that may already be wrong; a terminal taller than its pane reported rows that hung under the status bar. Switching tabs or groups never recreates a terminal. Browser panes work the same way through BrowserRegistry.

Lazy start. At launch, Store.reattachRunningTabs connects every tab whose holder is still running, so their status shows at once. Before that, Store.adoptLegacyDrawers turns a shell drawer saved by an older version into an ordinary shell tab beside its owner when its shell is still running (same id, so it reattaches to the same holder), and drops drawers with nothing running. Holders with no matching tab are only logged, never killed: the state file may be the thing that’s wrong. Any other tab’s shell launches in startIfNeeded the first time its view is attached; until then the tab is “sleeping” (Store.isSleeping) and shows lastAgentName (saved whenever the registry reports a name), else the name from the recent-sessions scan. Restoring 20 tabs spawns nothing until you look at them. That matters because each Claude Code process takes far more memory than AgentDE itself.

Resume when the holder is gone. Tabs store claudeSessionId, learned from the registry. When a tab has no running holder (after a reboot, say), a Claude tab runs claude --resume <id>, and so does a shell tab that still had a live Claude session. When Claude exits inside a shell tab, the monitor clears the id so the tab reopens as a plain shell.

Launch commands. Every tab runs the user’s $SHELL as a login shell. For zsh, the tab gets ZDOTDIR=~/.config/<data folder>/zsh, whose wrapper files (written by ShellIntegration.install() at launch) source the user’s own .zshenv, .zprofile and .zshrc. They fix HISTFILE, which /etc/zshrc derives from ZDOTDIR, and then restore ZDOTDIR, so the user’s .zlogin and any child shells behave normally. The wrapper’s .zshrc installs a one-shot precmd hook that evals AGENTDE_RUN before the first prompt: the startup command (group override, else the app setting), then the agent from TerminalSession.agentCommand. Because the agent is a child of the interactive shell, exiting it returns to the same prompt with the same environment, such as an active conda env. Other shells fall back to -l -i -c "<run>; exec $SHELL -l". Command tabs (diff, Agent View) use -l -i -c <command>. If the tab’s folder no longer exists, the shell opens in ~ with a note and runs neither. The environment is the app’s, minus anything that would confuse a nested agent (CLAUDECODE*, CLAUDE_CODE_*, CLAUDE_AGENT*, TERM_PROGRAM*, PWD, SHLVL and others) and any inherited WORKSTREAM_ID/WORKSTREAM_TAB_ID, plus TERM=xterm-256color, COLORTERM=truecolor, TERM_PROGRAM=<app name>, FORCE_HYPERLINK=1, AGENTDE_TAB_ID, and whatever extensions’ launchEnvironment returns.

External links from the viewer. A page in the viewer can open another app’s URL scheme (mailto:, cursor:…) only through a user link click in the main frame (ViewerHost.opensExternally); any other navigation to one is cancelled and logged. [link] log lines time each ⌘-click and page click from the click to the item on screen (LinkTiming).

open in the viewer. OpenInViewer.install() writes <config>/bin/open, a shell script. In a tab (AGENTDE_TAB_ID set) with one argument that is an existing file, or an http(s) URL opened from a shell (the parent process is sh, bash, zsh, dash, ksh or fish; a program such as a CLI signing in gets the browser) that isn’t a sign-in link (redirect_uri=, code_challenge=, /oauth, /authorize), it writes the absolute path or URL to <config>/open/<tab id>.<pid> (via a dot-file and a rename) and exits; anything else runs /usr/bin/open. The tab environment puts <config>/bin first on PATH, and the zsh wrapper puts it first again after the user’s startup files, since a login shell’s path_helper moves system folders ahead. Claude’s Bash tool (and ! commands) runs in its own shell snapshot taken from a fresh login shell, so each tab also gets CLAUDE_ENV_FILE=<config>/claude-env/<tab id>.sh, which Claude Code sources before each command and which puts <config>/bin first (unless the user set CLAUDE_ENV_FILE already). AgentMonitor reads the requests each poll (OpenInViewer.pending) and calls Store.openInViewer for tabs it has, deleting each request; requests older than a day are dropped. settings.openInViewer turns it off for new tabs.

Status without hooks. Claude Code maintains ~/.claude/sessions/<pid>.json with pid, sessionId, name, status and cwd. AgentMonitor reads that directory once a second on a utility queue. It drops dead pids (kill(pid, 0)), then walks each pid’s parents with sysctl(KERN_PROC_PID) until it reaches a tab’s shell pid. This works for Claude started any way inside a tab, typed by hand or launched by the tab, with no configuration. SessionRegistryReader re-decodes a file only when its modification time or size changes, and each pid’s owning tab is cached until the set of tab shells changes. The monitor never waits on the main thread. Its inputs (shell pids and the recent-session settings) are handed over at the end of each tick’s main-thread update, so a tab’s new shell is picked up on the following tick.

Status mapping, in Store.applyClaudeSessions:

Registry status Tab state
busy, shell working (records busySince)
waiting needs you
idle after working done, if the tab isn’t visible in the active app; otherwise cleared. A sparkle plays if the turn ran at least sparkleAfterSeconds
no entry cleared

These values come from the Claude Code binary (["busy","shell","idle","waiting"]). Treating waiting as “needs the user” is inferred, not documented. If needs-you looks wrong, that mapping is the first thing to check.

Forks and other background sessions. Claude’s /fork copies the conversation into a background session (registry kind: "bg", with a short jobId), which runs under Claude’s daemon rather than in any tab. That daemon is a single per-user process started by whichever claude first needed it, so it and every background session sit in that tab’s process tree. AgentMonitor therefore never matches a bg entry to a tab by ancestry, only by jobId (attachJobId). Otherwise the parent tab would take on the fork’s session id and name. AgentMonitor remembers the job ids it has seen. Any new one goes to Store.adoptBackgroundSession, which treats it as an ordinary new tab: attachJobId set, running claude attach <jobId>, inserted after the most recently focused tab with the same cwd. If no tab has that cwd, it goes at the end of the group whose folder contains it (Store.placement(forBackgroundIn:)). Jobs outside every group’s folder are ignored. Extensions hear about it through backgroundSessionOpened, with the fork parent from the job record. ⌘⌥F is the in-app alternative: it opens and selects a tab with forkOnStart, which runs claude --resume <id> --fork-session. The flag clears once the registry reports the fork’s new session id.

Recent sessions. Every 30 s, AgentMonitor runs two scanners and merges their results by recency.

A group shows the sessions whose cwd equals its folder exactly, minus any already open in a tab (claudeSessionId or codexSessionId) or pinned. When several groups share a folder, its sessions belong to the first of them in the sidebar (Store.sessions(_:for:in:open:)). Clicking a Claude one opens a resuming Claude tab. If it’s live in the background (liveBackgroundJobs), the tab attaches instead. If it’s live elsewhere (liveSessionIds), AgentDE asks before opening a second copy. Clicking a Codex one opens a Codex tab with codexSessionId, which runs codex resume <id>. Both record lastAgentName, so the tab shows the session’s name.

Claude Code updates. Claude Code installs versions as ~/.local/share/claude/versions/<version> and points ~/.local/bin/claude at the current one; a running process keeps its version. Each registry entry carries version (else AgentMonitor reads it from the process’s executable with proc_pidpath, cached per pid), and applyClaudeSessions stores it with the pid in TabRuntime.agentPid/claudeVersion; for a background tab that’s the job’s process, not the claude attach client. Every 3 minutes the monitor reads the link’s target (else claude --version from a login shell) into Store.installedClaudeVersion. A tab is outdated when both versions are known and differ (ClaudeVersion.isOutdated). ClaudeRestart.check allows a restart only for a Claude tab with a valid session id, a running agent, and state none or done.

Notifications. Store.notifyTransition (Store+AgentStatus.swift) fires on state changes: entering needs-you while the tab isn’t visible, or working → done-unseen. Notifier posts through UNUserNotificationCenter, with the tab id in userInfo and as the request id, so a newer banner for a tab replaces the older one, and viewing the tab clears it (markSeen). Done banners read the transcript tail at ~/.claude/projects/<encoded cwd>/<session>.jsonl for the last assistant text. waitingFor comes straight from the registry entry.

Background mode. With backgroundAgents on, a Claude tab’s agent command is TerminalSession.backgroundLaunch: claude --bg [--resume …], parse the job id from backgrounded · <id> (with color codes stripped), write it to ~/.config/<data folder>/attach/<tab id>, then claude attach <id>. If parsing fails, it falls back to a foreground claude. AgentMonitor consumes those files and calls recordAttachedJob, which sets attachJobId, so restore re-attaches. A launching tab is listed in pendingBackgroundLaunches for 30 s, so its own new background session isn’t opened as a second tab. Recent-session pills attach to a session that’s live in the background (liveBackgroundJobs) instead of resuming a second copy. Likewise at launch, Store.attachToLiveBackgroundJobs gives attachJobId to any restored Claude tab whose session is a live bg registry entry. Closing a background-session tab stops its agent; right-click → Close, Keep Agent Running (closeTab(keepAgent:)) leaves it running.

Focus. TerminalPane only takes focus when no terminal or text field has it, so the viewer’s and panel’s fields keep theirs.

Pull requests. PullRequestMonitor.refresh runs gh pr view --json number,title,url,isDraft,state,statusCheckRollup in the active tab’s folder, at most once a minute per folder and branch, killing it after 20 s. Results are cached in Store.pullRequests, keyed by folder and branch, where nil means no open PR. Check runs roll up into passing, failing and pending, and failing checks’ Actions run ids are pulled from their details URLs. Fix with Claude types its prompt with TerminalRegistry.typeIntoTerminal, which sends Enter separately after 0.35 s so Claude’s input doesn’t treat it as part of a paste.

Scrollback is set when a terminal is created (TerminalOptions.scrollback, from scrollbackLines). SwiftTerm’s default is 500. A row that scrolls into history keeps only its cells up to the trailing run of identical (usually blank) cells (vendored patch 7, BufferLine.compact), since history rarely changes; a resize re-expands every line, so Buffer.resize compacts the history again. With mostly short agent output in 160-column tabs that cut the scrollback’s memory by about two thirds (10 tabs × 6,000 lines: 253 → 84 MB).

Search. TranscriptIndex keeps, per transcript file, a byte offset, an unterminated tail and parsed entries: user and assistant text parts, skipping sidechains, tool output and <…> harness text. Each entry is lowercased once at index time. Each call reads only bytes appended since the last one, and a shrunk file is re-read from scratch. Transcripts are found under the cwd-derived project folder first, then in any project folder, with results cached. SessionSearch.run snapshots sources on the main thread: open tabs (transcript if the tab has a session id, else scrollback via the vendored Terminal.scrollbackLines()), plus recent sessions if ticked. It scans them on a background queue, posting sorted partial results after each source. A generation counter drops stale runs, and the query is debounced by 180 ms. With about 18 live sessions, a cold index took about 8 s and warm searches about 0.3 s. Jumping to a tab calls findPrevious(term) on its terminal, which scrolls to and selects the latest occurrence still in the scrollback. ⌘F/⌘G/⌘⇧G replace the standard Find commands and drive SwiftTerm’s own find bar through performTextFinderAction.

File Explorer. TabKind.files tabs render FileExplorerView in place of a terminal; they have no process, so agent, search and launch code skip them. TerminalRegistry owns one FileExplorerModel per tab, alongside terminals and web views. Directory children load on first expand and are cached until Reload. The filter’s file list (git ls-files --cached --others --exclude-standard, else a walk capped at 50,000 files) is built on a background queue when the explorer opens. The preview is QLPreviewView from Quartz. The selection persists in TabModel.explorerSelection, and the folders leading to it are expanded on restore. Send Path to Agent uses typeIntoTerminal without a trailing return, with the path relative to the receiving tab’s folder when the file is inside it.

Performance. Measured with a test instance (AGENTDE_STATE=/path/state.json points a second copy at its own state file):

In a real session, Metal drawing was about 18% of the main thread: MetalTerminalRenderer.buildDrawData rebuilds every row and re-measures glyphs (CTFontCopyPostScriptName, CTFontGetBoundingRectsForGlyphs) on each change. So metalRenderer defaults to off.

Terminal titles are kept out of observed state except for shell tabs, which publish at most once a second with spinner glyphs stripped. Claude Code animates its title, and every change would otherwise re-render the whole window. GitInfo.branch is cached for 2 s, because views call it while rendering.

Diagnostics. DebugLog appends to ~/Library/Logs/<app name>/agentde.log (AGENTDE_LOG_DIR redirects it; the test script uses that), with each line stamped with the time, pid and category. A watchdog queue pings the main thread every 0.5 s. When the main thread hasn’t answered for 2 s of uptime, it logs the stall and runs /usr/bin/sample <pid> 3 into hang-*.txt (at most every 5 minutes, newest 10 kept). Uptime pauses during sleep, so waking the Mac doesn’t count as a stall. AgentMonitor logs a heartbeat every 60 ticks: RSS, CPU over the last minute (from getrusage), live terminals, and tabs over 500 KB of output in the minute, counted in AgentDETerminalView.dataReceived. It also reaps any exited child process of the app that nobody collected. Tab shells are their holders’ children, and the holders reap them.

Safety rules:

Terminal holders. agentde-pty <socket> <rows> <cols> <ring-bytes> <cwd> <executable> <argv0> [args…] forks. The first process exits 0 once the second has its socket listening, which is what PtyHost.spawn waits for (off the main thread). The second process calls setsid, clears its signal mask, ignores SIGHUP and SIGPIPE, and runs the shell with forkpty, restoring default signals before execve. It’s spawned with POSIX_SPAWN_CLOEXEC_DEFAULT, so none of the app’s descriptors leak into tabs, and with the tab’s environment, which it passes on unchanged. Sockets are ~/.config/<data folder>/pty/<FNV-1a of the state path>/<tab id>.sock, in folders that are mode 0700 and checked for owner and symlinks. Hashing the state path keeps test instances apart, and the path stays well under sun_path’s 104 bytes. One client at a time is served; a new connection replaces the old one.

Holder safety. Input goes to the holder in frames of at most 64 KB (PtyConnection.inputChunk), since the holder drops any frame over 1 MB. If the holder can’t chdir into the tab’s folder it starts a plain login shell in $HOME with a notice and without AGENTDE_RUN, so a startup command or agent never runs in the wrong folder; the app checks the folder can be entered (TerminalSession.canEnter(folder:)) before asking for the agent at all. A tab closed while its holder is still starting stays in TerminalRegistry.retiring until its first connection ends its processes (or 15 s pass), so no agent is left running without a tab. One app instance runs per state file: InstanceLock takes an flock on <state>.lock at launch and a second copy shows an alert and quits. Terminal programs can’t read the clipboard (OSC 52) unless Settings → Terminal allows it, and a tab replaying history never writes it.

Restart and updates. AppRestart.restart starts a detached sh that waits for this pid to exit and then opens the bundle, and quits. watchForUpdates reads the on-disk Info.plist once a minute; when its CFBundleVersion differs from the running one, the status bar offers Update installed · Restart. Quitting doesn’t confirm anything (applicationShouldTerminate returns .terminateNow), because nothing stops.

Selection. Each group has selectedTabID, the active tab, which gets keystrokes; the work area shows only that tab. terminalFocused records focus that arrives by mouse click; that needs becomeFirstResponder to be overridable, one of the vendored SwiftTerm patches. Older state files may carry splitPanes (from split view) and a tab’s showShell/companionID (from the shell drawer); they still decode, splitPanes is ignored and dropped on save, and drawers are migrated at launch as described under Lazy start.

Hover hints. View.hint(_:) (AgentDEKit/Hint.swift) labels icon controls. One shared, mouse-transparent NSPanel at pop-up-menu level shows the text under the pointer after 0.35 s, or right away within 0.6 s of the last hint. It hides on leave, click, key, scroll or when the app deactivates. Being a separate window, it isn’t clipped by the tab strip’s scroll view. Longer explanations (settings, file paths, tab and recent-session descriptions) stay as native .help tooltips. The tab strip measures its tabs’ width (TabsWidthKey) and caps the scroll view to it, so + follows the last tab and stays visible when the tabs overflow.

The viewer and the side panel. In WorkArea, agent tabs (and shell tabs running Claude) lay out as viewer + resize handle + chat in a column, with the side panel at full height on the right when it’s shown (a 28 pt strip under 760 pt). Its Files tab reuses the tab’s FileExplorerModel (TerminalRegistry.explorer) and opens files in the viewer. Its Changes tab runs GitChanges.load off the main thread (git status --porcelain=v1 -z, git diff --numstat -z HEAD, git log) on appear, when the tab’s agent status changes and on ↻; a click writes the diff (git diff HEAD -- <file>, --no-index for untracked files, or git show for a commit) to a .diff file in the temporary folder and shows it in the viewer. File Explorer and command tabs keep their full-area view. ViewerRegistry owns each tab’s ViewerHost (an NSView that swaps between a WKWebView, PDFView, NSImageView, a read-only NSTextView and QLPreviewView), so views outlive SwiftUI bodies; a DispatchSource on the shown file reloads it, following atomic saves. A local key monitor closes the viewer on Esc only when focus is inside it. Web items (localhost pages, and links ViewerLinks claims: Claude artifacts and extensions’ and plugins’ link rules) share one WKWebViewConfiguration with the default persistent data store, so a sign-in is remembered, use Safari’s user agent, browse freely within the viewer, and open pages’ pop-ups (sign-in windows) in real windows; an “Open in browser” button hands any web item to the browser. TabModel.viewerItem, viewerHeight, viewerExpanded and viewerRaw keep the viewer’s state with the tab.

Terminal links. SwiftTerm finds explicit OSC 8 links and implicit URLs and paths, and calls requestOpenLink on ⌘-click. AgentDETerminalView overrides that to call Store.openLink. Files are resolved by resolveFileLink: file://, absolute or ~ paths, or paths relative to the tab’s cwd and then the group’s dir, with any :line:col stripped; failing that, a Spotlight search (mdfind) of those folders. LinkTarget.classify sorts links into viewer links (.artifact, from ViewerLinks), websites (the browser), local pages (localhost, 127.0.0.1, *.localhost) and files; files, local pages and viewer links open in the clicked tab’s viewer (Store.openInViewer; diff and command tabs, which have no viewer, use the default app), and folders in a new File Explorer tab rooted there. In the viewer, a link clicked in a local page (ViewerHost’s decidePolicyFor) goes back through ViewerRegistry.onOpen to Store.followViewerLink, which shows it next and drops history ahead of the current item (ViewerHistory.following); a link within the same file just scrolls, and web pages shown in the viewer browse inside it as before. ⌥ opens them with the default app instead. Tabs get FORCE_HYPERLINK=1, because Claude Code (via supports-hyperlinks) only emits OSC 8 for terminals it recognises. Markdown previews use MarkdownRenderer, a small dependency-free Markdown-to-HTML converter, in a themed WKWebView (MarkdownView) that reloads when the file’s mtime changes; link clicks open outside.

Cheap animations. Status dots are CALayers with CABasicAnimations, which run in the render server, so breathing and pulsing cost no app CPU. Only the one-shot sparkle uses a SwiftUI animation.

Persistence. Store.load() decodes group by group and tab by tab. Anything unreadable, such as a tab kind from a newer version, is dropped individually, and the original file is copied to state.json.unreadable-<time> before anything is saved over it. Settings merge key by key, so one bad value resets only itself (AgentDESettings.decodeLenient): keys missing from older files take their defaults, so adding a setting never breaks an existing state file. New GroupModel and TabModel fields should be optional for the same reason. A tab’s workstreamID and panelSections from files written before extensions are moved into extensionData (workstreams.id, workstreams.sections) at load. Store.scheduleSave() debounces writes by 0.6 s, and the app also saves on quit. Transient tabs (diffs) aren’t saved.

Saved state

~/.config/<data folder>/state.json (AGENTDE_STATE overrides the path):

{
  "selectedGroupID": "UUID",
  "groups": [{
    "id": "UUID", "name": "api", "colorIndex": 0, "dir": "/path",
    "selectedTabID": "UUID", "collapsed": false, "hideRecent": false, "startupCommand": "optional override",
    "tabs": [{
      "id": "UUID",
      "kind": "claude | codex | shell | command | files",
      "cwd": "/path",
      "customTitle": "optional user name",
      "claudeSessionId": "optional, used to resume",
      "codexSessionId": "optional, Codex tabs: the thread to resume",
      "attachJobId": "optional, background session this tab attaches to",
      "forkOnStart": "optional, start with --fork-session",
      "lastAgentName": "optional, shown while the tab sleeps",
      "explorerSelection": "optional, File Explorer tabs",
      "browserURL": "optional", "showBrowser": false,
      "viewerItem": "optional file path or link", "viewerHeight": 0, "viewerExpanded": false, "viewerRaw": false,
      "panelWidth": 0, "panelHidden": false,
      "extensionData": { "<extension id>.<key>": "value" },
      "command": "for kind=command", "transient": false
    }]
  }],
  "settings": { "theme": "claude", "palette": "soft", "fontSize": 13,
                "extensionSettings": { "<extension id>": "<its JSON, as a string>" }, "...": "see AgentDESettings" }
}

Build notes

No Xcode. Everything builds with the Command Line Tools via SwiftPM. scripts/build-app.sh:

  1. Runs scripts/assemble.py on EXTENSIONS (default extensions.json), which writes .build/app-package (a Package.swift with the core and each extension as path dependencies, and a main.swift) and prints the helper executables to bundle. A package folder that isn’t there is cloned from the entry’s repo first, and so are the sibling packages it depends on (.package(path: "../x")), from the same owner. Unchanged files aren’t rewritten, so SwiftPM’s cache stays warm.
  2. Builds that package’s AgentDEBuild product, then agentde-pty and each helper from their own packages.
  3. Assembles the .app: the binary (named after the app), helpers in Contents/MacOS, the SwiftTerm resource bundle, the icon, Resources/AppIcons, a brand’s BRAND_RESOURCES, and an Info.plist with the brand keys; then signs it.

A workspace with its own app target skips step 1 by setting APP_PACKAGE (its folder), APP_PRODUCT (its executable) and APP_HELPERS (executables from the same package, default agentde-pty).

Brands. The app’s name and folders come from AppBrand, which reads AgentDEBrandName, AgentDEDataFolder and AgentDEDefaultIcon from Info.plist and falls back to AgentDE’s values. Helpers inside the app (the workstream-mcp server) read the same keys, since Bundle.main is the enclosing app. scripts/brand.sh sets APP_NAME, BUNDLE_ID, DATA_FOLDER, DEFAULT_ICON, ICON and BRAND_RESOURCES from a brand file (BRAND=…), with AgentDE’s values as defaults; build-app.sh also takes SIGN_ID. A renamed build keeps its own state, holders, logs and Keychain items, so it installs beside AgentDE. Keychain access is skipped outside an .app, so the test runner never asks for the login password.

Icons. Settings → Icon offers every Resources/AppIcons/<id>.png in the bundle (AgentDE ships one, agentde.png, so the tab is hidden), ordered and named by an optional AppIcons/icons.json ({"order": [ids], "names": {id: name}}); a brand adds more through BRAND_RESOURCES. AppIcons.apply sets NSApp.applicationIconImage (the app switcher) and draws the image into NSApp.dockTile.contentView, because on macOS 26 the Dock doesn’t show applicationIconImage; both last only while the app runs. The default (AppBrand.defaultIcon) is also the bundle’s AppIcon.icns, so choosing it clears the override, and it’s what macOS shows while the app isn’t running. Changing the bundle’s own icon on disk would break its code signature, so the choice stays a runtime one. make icons (scripts/make-icon.sh, drawing with scripts/make-icon.swift) redraws agentde.png and AppIcon.icns.

Signing. TCC (macOS privacy permissions) remembers grants by an app’s designated requirement. For an ad-hoc signature that’s the exact code hash, so every rebuild is a new app and permissions are asked again; child processes such as Claude inherit the app’s grants, so this shows up as constant prompts. scripts/setup-signing.sh creates a self-signed “AgentDE Local Signing” identity (code signing only, made with the system LibreSSL so security import accepts the PKCS#12), and the build signs with it whenever it exists and SIGN_ID isn’t set. The designated requirement then names the certificate, which stays the same across builds.

Tests with the Command Line Tools. Swift Testing works, but SwiftPM sometimes drops the macro plugin from incremental test builds, so scripts/test.sh passes -plugin-path …/host/plugins/testing explicitly. It also sets AGENTDE_LOG_DIR to a scratch folder, so tests never write the real log.

macOS 26 SDK on purpose. In the macOS 27 CLT SDK, SwiftUI’s @State is a macro whose compiler plugin (SwiftUIMacros) ships only with Xcode. The 26 SDK still has the property wrapper. It comes with CLT 26 (installs on macOS 15.6 or later) and also ships beside the 27 SDK in CLT 27, so building needs macOS 15.6 or later even though the app runs on 14. scripts/env.sh sets SDKROOT; override with AGENTDE_SDK=/path/to/sdk. @Observable is fine because the Observation macro plugin does ship with the CLT.

Vendored SwiftTerm. Vendor/SwiftTerm is SwiftTerm v1.20.0 (MIT) with small patches, listed in Vendor/SwiftTerm/PATCHES.md. The important one: Shaders.metal ships as a plain resource rather than being compiled at build time (which needs Xcode’s metal tool). SwiftTerm already compiles it from source at runtime when no precompiled library exists, so GPU rendering still works.

Why SwiftTerm rather than libghostty. libghostty (Ghostty’s engine) is the stronger renderer, but building it needs Zig and full Xcode for Metal. The terminal is isolated to TerminalSession, TerminalRegistry and TerminalPane. Swapping engines means re-implementing those three against a different view while keeping the same public surface: start, focus, terminate, appearance, title and exit callbacks, and the shell pid.

Known limitations

Ideas not yet built

Codex status via hooks; per-tab context % via a status-line tee; “while you were away” digest; port detection with one-click browser; ⌘K resume of any past Claude session by name; screenshot-to-agent hotkey; a jobs tray for background runs; libghostty renderer.