plume
CLAUDE.md0 starsChanged 10 days agoApache-2.0View on GitHub
Native macOS app for organizing and parallelizing coding-agent work: a sidebar of tasks in groups, agent/terminal tabs per task, and at-a-glance status. The roadmap lives in Linear, team Plume — projects by user outcome, one issue per item. Use the roadmap skill rather than the MCP tools directly; it knows the queue's conventions. Read the issue before starting work. Move it to Done once the user has reviewed and approved it directly, or once it merges into main, which needs…
- Installs packages
In this repository4 other files it gives its agents.
- drive-plumeSkill
- implementSkill
- release-cycleSkill
- roadmapSkill
The file
We split this CLAUDE.md into the rules it gives an agent
6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 20
Every change ends with a clean build and a manual run.
- Very specific: line 68
Keep every tab's TerminalTabView mounted and toggle visibility (opacity).
- Very specific: line 72
Call SurfaceManager.closeSession(for:) when a tab or task is deleted.
- Distinctive: line 114
Verify from outside the app rather than by screenshot — the surfaces are real processes, so the process tree is better evidence than a …
- Very specific: line 131
Always walk down from Plume's own PID.
- Very specific: line 167
Every commit an agent creates ends with a Co-Authored-By: trailer naming the model.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Plume
Native macOS app for organizing and parallelizing coding-agent work: a sidebar of tasks in groups, agent/terminal tabs per task, and at-a-glance status.
The roadmap
The roadmap lives in Linear, team Plume — projects by user outcome, one issue per item. Use the roadmap skill rather than the MCP tools directly; it knows the queue's conventions. Read the issue before starting work. Move it to Done once the user has reviewed and approved it directly, or once it merges into main, which needs the user's confirmation. Merging into a release branch is not Done.
To do means queued, Backlog means wanted but not queued. estimate is the size: 2 = S, 3 = M, 5 = L, 8 = XL. An issue states the item and any real blocker, not what the code already does — read the code for that.
Build and test
Builds need Xcode 27. Check xcodebuild -version if a build fails on a missing SDK symbol.
xcodebuild -scheme Plume -destination 'platform=macOS' build
xcodebuild -scheme Plume -destination 'platform=macOS' test -only-testing:PlumeTestsEvery change ends with a clean build and a manual run. PlumeUITests launches the app, so a full test run is slow — prefer -only-testing:PlumeTests while iterating.
The console hides Swift Testing's Expectation failed: … text and prints only that the test failed. Pass -resultBundlePath /tmp/x.xcresult, then read xcrun xcresulttool get test-results tests --path /tmp/x.xcresult. A Crash: node there means the test host died; its .ips in ~/Library/Logs/DiagnosticReports/ has the stack.
CONTRIBUTING.md covers first-time setup, including the tests that fail on a fresh machine for environmental reasons.
Releasing locally
Plume is installed by hand — no archive, no DMG — but scripts/install-release.sh signs with Developer ID and notarizes, because Apple documents that the sleep helper daemon requires it (a Debug build can register it too, but only for a human: Debug builds skip the helper unless defaults write com.ryanmoelter.Plume.debug PlumeRegisterSleepHelperInDebug -bool true is set, and agents must never set it — launchd has one job for every install, so a Debug registration repoints the installed app's helper at DerivedData, and a rebuild breaks it, PLUME-217). docs/releasing.md is the reference: version bump, build, verify, tag. Read it before cutting a release.
Release links Ghostty statically, so the only embedded framework is Sparkle's — otool -L on the binary shows exactly @rpath/Sparkle.framework and nothing else non-system. install-release.sh fails the build if that's not true. PlumeSleepHelper is the other exception: a LaunchDaemon embedded in Contents/MacOS with its plist in Contents/Library/LaunchDaemons. The plume command-line tool is embedded at Contents/Helpers/plume and links only system libraries. scripts/lib/sign-bundle.sh signs everything inside out — Sparkle's nested code, then the helper and the tool, then the outer bundle — since codesign without --deep leaves nested code alone.
Layout
The Xcode project uses file-system synchronized groups: files added under Plume/ join the target automatically. Adding a source file needs no project.pbxproj edit; adding a package or build setting does.
Plume/
App/ PlumeApp, MainWindow, PlumeCommands, AppDelegate
App/Updates/ UpdateController (Sparkle wiring, Homebrew-vs-DMG detection)
Models/ SwiftData models, TaskStore (CRUD/ordering), TitleStore, enums
Ghostty/ GhosttyRuntime, GhosttyConfigLoader, GhosttyThemeResolver, TerminalSession, TerminalTabView, ThemeChrome
Sessions/ SurfaceManager, LoginShellCommand
Agent/ AgentProvider, ClaudeCodeProvider, AgentLauncher, hook plumbing, SessionJSONLReader
Status/ StatusEngine, StatusPersistence
Workspace/ WorkspaceProvisioner, GitRunner, ProjectRoot (the key Settings ▸ Projects shares with `plume wt`)
Linear/ LinearStore, LinearSyncCache (delta-synced disk cache; docs/linear-sync.md), LinearClient, LinearAccounts
Android/ Observable emulator and adb discovery, DeviceSession(Manager), DeviceLeases, connection adapters, input mappers, Remote/
Support/ Log, AppPaths, AppSettings, FileWatcher, HexColor, CommandLineHelper, SmokeHarness (DEBUG)
CLI/ CLIServer (the socket the `plume` tool talks to), CLICommandHandler
Control/ The debug control server: PlumeID, ControlRegistry, ControlServer, InProcessControlBackend (DEBUG)
Resources/ Fonts, Themes, Mermaid, Skills
UI/ Sidebar/, Task/, Settings/, Device/
PlumeCLI/ The `plume` tool's entry point and command dispatch (target PlumeCLI)
Shared/CLI/ Compiled into both the app and PlumeCLI: wire types, socket path, Presence, argument parsing
Devices/ `plume devices`: the lease registry, DevicePool, and the command
Shared/Android/ SDK lookup, AVDCatalog, adb and emulator discovery, EmulatorLauncher, EmulatorBootWaiter, DeviceForm
Packages/
EmulatorControl/ The emulator's gRPC client: generated code plus a small public facade
ScrcpyControl/ A physical device over scrcpy-server: launch, demuxer, H.264 decoder, control messagesShared/ is a synchronized group in two targets with different default isolation, so every declaration in it is marked nonisolated. docs/cli.md is the reference for the tool.
Terminals
SurfaceManager.shared owns every live terminal, keyed by tab ID. Views never create or destroy surfaces — they ask for a session and host it. This is what keeps processes alive across tab and task switches, so:
- Keep every tab's
TerminalTabViewmounted and toggle visibility (opacity). Never unmount to hide. TerminalSessionholds its platform view strongly, and that is what keeps the process alive. The view owns the ghostty surface, which owns the PTY child, and the wrapper'sattachedViewis weak — so without that reference, unmounting a view (switching tasks, say) frees the surface and kills the terminal. The view is handed back on remount through the wrapper'smakePlatformViewhook. Hold the session, not just the surface state.- Hidden tabs set
isSurfaceVisible = false(TabVisibility), which stops rendering only. It never gates surface creation, so an unselected tab still spawns its PTY. - On screen is not focused. A split task has a pane per
TaskTab.paneIndex, each with its own strip and selected tab (paneSelectedTabIDsRaw), soTabVisibilityanswers both: rendering and bell-seen follow on-screen, and only the focused pane (selectedTabID) claims first responder, throughclaimsPaneFocus. Every tab of the task stays in the oneForEach, placed by frame, so a tab moving between panes, or a split opening or closing, never changes container.TaskStoreowns pane membership, keepsorderIndexdense per pane, and closes a pane left empty. - Call
SurfaceManager.closeSession(for:)when a tab or task is deleted. A SwiftData cascade delete does not reap the terminal. TerminalSurfaceOptionsset surface identity. Re-requesting an existing session ignores new options by design — changing them would rebuild the surface and kill the process.- Surfaces spawn their PTY lazily, when first attached to a view with a usable size — not on selection. A hidden tab still spawns.
- The wrapper ticks ghostty only while some terminal view is in a window. It drops every wakeup otherwise, so with a chat-only task selected, other tasks' bells, titles and exits would sit in ghostty's mailbox until their task is shown again.
DetachedSurfaceTickerticks for it in that state.
Agent transports
An agent tab runs one of two CLIs, recorded in TaskTab.providerID (AgentProviderKind), over one of two transports, chosen by TaskTab.transport and defaulted by a setting. Provider and transport are independent axes, and AgentLauncher.launch branches on the pair — which is what keeps Claude Code's hook instrumentation, ~/.claude.json trust check and event watching off the Codex path. The chat renders against the AgentSession protocol, so both CLIs share every view.
Headless (.headless, the default). claude -p speaking stream-json over pipes, owned by HeadlessSession and keyed by tab ID in AgentSessionManager — the same shape as SurfaceManager, and no PTY at all. docs/headless-protocol.md is the wire reference. Read it before touching Plume/Agent/Headless/.
--permission-prompt-tool stdioplus aninitializecontrol request is what makes permission requests reach the host. Without both, anything needing approval is auto-denied and the turn ends having done nothing.- Answering a running prompt, approving a plan, denying a tool with a reason, setting mode and model, and interrupting all ride the control plane. Interrupt is a control request — never a signal, which abandons the turn.
- One process serves the whole conversation.
session_idis stable and gets persisted to the tab so a restart can--resume. - A control response is correlated by
request_id, throughpendingControlRequests. Sniffing replies for a field you recognize works only while one request matters; add a request by recording its kind when you send it. /rcis served by Plume, not the CLI.remote-controlis an interactive TUI command with no non-interactive variant, so it never reaches the headlesscommandslist.PlumeSlashCommandsupplies it andChatComposer.send()intercepts it — only on this transport, since a terminal tab's real TUI already has a working/rc./btwis aside_questioncontrol request, and it writes nothing to the transcript. It reads main context and answers mid-turn without disturbing the running turn, but leaves no trace on disk and does not chain — so there is nothing to read one back from.HeadlessSessionholds side questions in memory; never persist them.- Rollback is
rewind_conversation; fork-to-a-tab is--fork-session+--resume-session-at. A rewind cuts in place, deletes nothing on disk, and returns the message's text asprefillText— but it cannot move onto an abandoned branch (target_not_found), because the file is append-only while the CLI's live chain is not. A fork writes a whole new session file and leaves both conversations live, at the cost of an undocumented flag. Both buttons, and the marker where a conversation forked, are#if DEBUGuntil they are ready to ship. A refusal to rewind arrives as{"rewound": false, "reason": …}, not as an error response. Both sit on a turn's last reply but always cut at the user message after it (MessageRedoContext.rollbackTargets). Never target the reply itself: an assistant row is frequently mid-tool-call, which the CLI refuses withtarget_splits_tool_call, and a rendered reply carries only its first row's uuid. - Headless launches carry
CLAUDE_CODE_ARTIFACT=1(HeadlessCommand.environment). The CLI withholds its Artifact tools from SDK entrypoints, and-pis one, so without it no headless agent can publish an artifact. - Status, quota and cost arrive as events, so headless tabs start no hook watch.
total_cost_usdis per turn and accumulates; streamutilizationis 0–1 where the retired statusline capture used 0–100.
Codex. codex app-server speaking JSON-RPC over pipes, owned by CodexAppServerClient. docs/codex-protocol.md is the wire reference. Read it before touching Plume/Agent/Codex/. The envelope carries no jsonrpc field, every server request must be answered or the turn stalls, and history comes from thread/items/list rather than the rollout files on disk.
Terminal (.terminal). The Claude Code TUI hosted in a real PTY, kept as an escape hatch. Input reaches it as a paste plus a synthetic Enter, so it cannot answer a running AskUserQuestion — that ceiling is why the headless transport exists. docs/agent-transport.md records it.
Both transports launch through AgentLauncher and report through StatusEngine, which is transport-agnostic.
Hook instrumentation (terminal transport)
claude launches with --settings <generated> plus PLUME_TASK_ID / PLUME_TAB_ID / PLUME_EVENTS_DIR. Each hook appends its stdin to ~/Library/Application Support/Plume/events/<taskID>/<tabID>.jsonl; AgentEventMonitor tails those files and feeds StatusEngine.
- Never put a
matcheronStoporUserPromptSubmit— Claude Code rejects it. Omittingmatcheralready means "all", so the generated file omits it everywhere. --settingsmerges, and hook lists union, so the user's own hooks keep firing. Don't expect replacement semantics.- Instrumentation is best-effort: a missing settings file degrades to a plain
claude, never a failed launch. - A hook only fires when Claude Code actually reaches that point. Testing in an untrusted directory (like
/tmp) stalls on the folder-trust prompt and produces no events — use a directory already trusted.
Verifying the app from an agent
Use the drive-plume skill. A debug build serves a control socket that reads the window as text, drives controls, hovers, and screenshots without an Accessibility grant and without activating the app; docs/control-server.md is the reference and scripts/debug/plume-control.py the client. Controls reach it through plumeID(_:) and hover through plumeHover, so a control without one is invisible to it. The whole layer is #if DEBUG and compiles to nothing in Release.
Verifying terminal behavior
Verify from outside the app rather than by screenshot — the surfaces are real processes, so the process tree is better evidence than a picture:
PLUME_SEED_TASKS=1 PLUME_SEED_TABS=3 PLUME_CYCLE_SELECTION=3 \
<DerivedData>/Plume.app/Contents/MacOS/Plume &
PID=$!Then watch real processes — one login → -zsh per surface, each on its own tty:
for l in $(pgrep -P $PID); do pgrep -P $l; done # shell pids, stable across switches
/usr/bin/log show --predicate 'subsystem == "com.ryanmoelter.Plume"' --last 2m --infoStable PIDs across many switches is the real proof that hide/show doesn't kill processes. Compare the PID sets and ttys, not the counts: a teardown and respawn keeps the count identical. Take the PID from $! or from pgrep -f on the bundle path, never pgrep -x Plume, which has come back empty with Plume running. SmokeHarness (DEBUG only) drives this from env vars.
Always walk down from Plume's own PID. A global pgrep/grep for claude matches the Claude desktop app's helper processes and will convince you an agent launched when none did.
Android device tabs
A device tab (TabKind.device) shows a live Android emulator over its gRPC control service, or a physical device over scrcpy-server, either over USB or reserved from Google's lab through Device Streaming. docs/android-emulator.md, docs/android-physical-device.md and docs/android-remote-device.md are the references — discovery, auth, launch flags, the frame pipelines and the measured input quirks. Read them before touching Plume/Android/, Shared/Android/, Plume/UI/Device/ or the EmulatorControl and ScrcpyControl packages.
- All gRPC and protobuf code lives in
Packages/EmulatorControl, because the app target's default MainActor isolation breaks generated protobuf code. Never edit itsGenerated/folder; runscripts/android/generate-emulator-grpc.sh. - A remote device's reservation is billed, so nothing Plume reserves outlives its tab or the app. Closing the tab, Change Device and End Reservation run
disconnectthenremove. Quitting ends every reservation inRemoteReservationLedger, shown or not, and the next launch releases any entry a crash left behind, so a remote tab never reconnects after a relaunch. Never runandroid device remote createwhile testing without removing the reservation afterwards. DeviceSessionManager.sharedowns oneDeviceSessionper tab ID.TaskStore.forgetTabcloses it, which stops streaming and never stops the emulator — an agent may still be using it over adb. Only the tab's "Shut Down Emulator" does that; a physical device is never powered off.- Device leases are owned by tabs, in one registry every build shares.
plume deviceswrites~/Library/Application Support/Plume/devices/registry.jsonwhichever build runs it, and a tab owner names its app's socket. The app only ever sweeps, reaps or releases leases whoseowner.appis its own socket, so a debug build never touches the installed app's leases. A remote lease follows its reservation:claim --kind=remotereserves through a device tab's session, and releasing the lease ends the reservation. It bills like any other reservation, so release it after testing.docs/device-leases.mdis the reference. scrcpy-serverships without a.jarextension and lives in Git LFS. Xcode drops a.jarfrom Resources, and a checkout without LFS has a pointer file there.- Frames never pass through
@Observablestate.DeviceSurfaceViewsets its layer's contents directly, and the session streams only while a surface in a visible window is attached. A hidden tab unmounts its surface, so it costs nothing — except that a physical device stays connected for 5 seconds, so a quick tab switch doesn't pay a reconnect, and then closes its connection until the tab is shown again.
Ghostty
Terminals come from the GhosttyTerminal product of Lakr233/libghostty-spm, pinned .exact("1.5.2"). docs/ghostty-pin.md is the reference — pin details, why the wrapper was adopted, config search order, upgrade steps, and the self-vendoring fallback. Read it before touching anything Ghostty-related or upgrading the package.
The wrapper does not call ghostty_config_load_default_files, so GhosttyConfigLoader finds the user's config itself. Its ordering (Application Support before XDG) is deliberate and test-locked — don't "fix" it to match ghostty's docs page, which is wrong.
The discovered file is only the entry point. expandConfig follows config-file includes recursively, and that is load-bearing: a config that only redirects (config-file = "~/.config/ghostty/ghostty-config") is a supported setup that otherwise loads nothing. An include applies after the file that named it, and a relative path is relative to that file. Resolve themes against winningThemeSourcePath(in:), not the root config — the theme is often declared in an included file whose directory is where themes/ lives.
The config reaches libghostty as generated contents with every theme directive stripped, never as a file path. GhosttyThemeResolver applies the theme in Swift instead. Passing theme through breaks terminal launching outright — surfaces silently spawn a login shell instead of their command, with no diagnostic. GhosttyConfigLoader.configContentsForGhostty documents the mechanism.
Updates
Sparkle checks for updates in the background, and an available update never interrupts — no launch dialog, no system notification, nothing modal. It shows only as an "Update Available" row in the sidebar footer. Clicking it opens Sparkle's window on a DMG install, or Plume's own update window on a Homebrew install (detected via a Caskroom/plume directory; Settings can override). UpdateController (Plume/App/Updates/) owns this. Plume schedules the background checks itself, with checkForUpdateInformation(); Sparkle's own automatic checks stay off. A Sparkle-scheduled check holds the first update it found until relaunch, so the row would never show a newer release. A Debug build doesn't start the updater unless the PlumeUpdateFeedURLOverride user default is set.
- Sparkle's Info.plist keys (
SUFeedURL,SUPublicEDKey,SUEnableAutomaticChecks,SUAllowsAutomaticUpdates) live inConfiguration/Info.plist.SUAllowsAutomaticUpdatesisfalseso Sparkle never offers or performs a silent install-on-quit, which would bypass both the Homebrew path and the no-interruption rule. - Never run
brew upgradeinside Plume. The cask'suninstall quit:quits Plume mid-upgrade, and itslaunchctlstep for the sleep helper can prompt for a sudo password.HomebrewUpgradeopens a temporary.commandscript in Terminal.app instead, which upgrades and then reopens Plume. - Never add a manual "Embed Frameworks" phase for Sparkle. Xcode already auto-embeds and signs its XCFramework into
Contents/Frameworks; a hand-written phase breaks the build with "Sparkle-product couldn't be opened". - Test both update paths without reinstalling via Settings ▸ Debug (
Plume/UI/Settings/UpdatesDebugSection.swift,#if DEBUGonly) plusscripts/debug/serve-test-appcast.sh, which serves a version-bumped, re-signed copy of the Debug build over a local appcast.docs/releasing.md's "Testing an update end-to-end" is the reference.
Commits
A one-line subject, no prose body. Imperative mood, capitalized, no trailing period, 50 characters at most — Add image paste to the composer, Fix stale title after fork. Name the behavior, not the files, and write identifiers in their source casing. No feat:-style prefixes, no gitmoji, no bracketed tags. Join two related changes with a comma; if they don't fit, lead with the main one. A body, when there is one, is only the issue identifier (PLUME-123).
Every commit an agent creates ends with a Co-Authored-By: trailer naming the model. This includes subagent commits, merges and version bumps. Older history lacks the trailer, so don't copy that, and never strip the trailer to fit the one-line style.
Debugging
Gather evidence before proposing a cause: the exact error text, recent changes to the affected area, the versions involved. Hold at least two hypotheses, and run the cheapest experiment that tells them apart before changing product code. After two failed fixes, stop guessing and measure.
For performance, measure first. scripts/profile-chat-scroll.sh <seconds> records a trace; read its swiftui-causes table with xcrun xctrace export --xpath '/trace-toc/run[@number="1"]/data/table[@schema="swiftui-causes"]'. Node names are interned, so count <metadata (id|ref)="N" occurrences, not fmt= definitions. When something used to be fast, git bisect it against a fixed fixture. docs/handoff-idle-cpu.md is a worked example.
Conventions
- SwiftData models are the persisted skeleton only. Live process/terminal state belongs in in-memory
@Observableobjects keyed by model UUID. Never persist anything about a running PTY. - Every SwiftData stored property needs a default value, so lightweight migration keeps working as the schema grows.
- Enums persist as
...Rawstrings with a computed accessor (workspaceKindRaw/workspaceKind). Keeps migrations trivial. - Ordering is a dense
orderIndexper section, rewritten on move. All of it lives inTaskStore— keep it out of views. - Model names avoid colliding with Swift's
Taskand SwiftUI'sGroup:WorkTask,TaskGroup,TaskTab. - All
ghostty_*calls stay inPlume/Ghostty/. The C API is unstable between versions; upgrading should touch one folder. - Status flows through
StatusEnginein memory; SwiftData gets only a debounced snapshot inlastStatusRaw, never per-event writes. - Keep the main thread for UI. Work that reads a file, scans a transcript, or waits on a process runs off the main actor and publishes only its result, the way
TranscriptStore.readandAgentTitleMonitor.readdo. Its tests await the store'sdidRead, not a clock. Never wait on a child process from the main actor:AgentProcess.terminate()escalates on a background queue, and only app termination blocks, inawaitTerminations. Switching tasks unmounts the chat, so work a view would redo on every mount belongs in a per-tab cache that outlives it (ChatPieceCache.forTab). Measure a hang before fixing it:samplethe pid during the action, or time a control-socket request sent right after it. - Work that outlives a turn comes from the running CLI where it can, and from the transcript where it can't — never from hooks. A
Monitor, a backgroundedBashand aWorkflowall keep running after the tab drops toawaitingReply, andBackgroundTaskTrackerholds them per tab soKeepAwakeCoordinatorcan keep the Mac awake. A headless tab takes membership from the CLI'sbackground_tasks_changedlist (HeadlessSession→replaceLive): a task runs exactly as long as the list names it, with no time limit, and the tab empties when its process exits. Terminal tabs fall back toBackgroundTaskScanner, which reads the transcript in the same parseTranscriptStorealready runs; Codex tabs read app-server's inventory. Those inferred entries are capped at 30 minutes, because a transcript that stops being written can never clear them. A headless tab still runs the scan, but only to name what the live list cannot: aMonitorarrives aslocal_bash, and the list carries no start time. In the scan, an entry retires when the transcript says its task ended: a terminal<task-notification>, or for a Monitor the call passedpersistent: false, that monitor's first event. Only an explicitfalsecounts — reading an absentpersistentas one-shot would retire a monitor still watching and let the Mac sleep mid-work. A backgroundAgentis deliberately not tracked here:setSubagentActivityalready covers it.
Gotchas
- Name controls with
plumeID(_:), never a bare.accessibilityIdentifier, and hover withplumeHover, never a bare.onHover. SwiftUI puts identifiers on no NSView and builds its accessibility tree only for a trusted external client, so the debug control server finds controls through the registryplumeIDfeeds — a bare identifier is invisible to it. Apply it inside.disabled(_:)so the registered enabled state is real, and passlabel:on repeated rows so a driver can tell them apart. SwiftUI's hover tracking likewise answers only the real pointer, soplumeHoverregisters the region and the server calls its closure itself. - SwiftUI's
.globalframes hang from the top of the window frame, title bar included. They equal content-view coordinates only when the content view fills the frame.WindowGeometry.contentRect(fromGlobal:in:)subtracts the difference; a hosted view in a plain titled window is off by the title bar height without it. SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YESmeans transitive imports don't count. UsingArray.move(fromOffsets:toOffset:)needs an explicitimport SwiftUI;IndexSetneedsimport Foundation. The error names the missing module.SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor: everything is MainActor-isolated unless marked otherwise. Test suites touching models need@MainActor.- The app is unsandboxed (
ENABLE_APP_SANDBOX = NO) — it spawns PTYs, reads~/.claude/**, and runsgit worktree. Distribution is Developer ID + notarization, not the App Store. Don't re-enable the sandbox. - The store lives at
~/Library/Application Support/Plume/Plume.store, alongside thehooksandeventsdirectories. Delete it to test first-run behavior. Debug writes toPlume.debug/instead — its bundle ID carries a.debugsuffix andAppPaths.directoryNamekeys off that — so a debug run and the installed app no longer share data and can run side by side. A debug build from a worktree writes toPlume.debug-<worktreeName>/, keyed offBuildInfo.sourceRoot; only the primary checkout (whose.gitis a directory) keepsPlume.debug/. UserDefaults stay shared. The test host has no bundle ID and falls back toPlume/. If the store fails to open,PlumeAppmoves it aside asPlume.store.<timestamp>.bakand starts empty rather than refusing to launch. - SourceKit in-editor diagnostics go stale on new files and report phantom "cannot find type in scope" errors (often resolving
TaskGroupto Swift's generic one). Trustxcodebuild, not the editor squiggles. - In Debug,
Plume.app/Contents/MacOS/Plumeis a ~57K launcher stub. The real code — and every linked libghostty symbol — is inPlume.debug.dylibbeside it. Inspecting the stub withnmmakes it look like nothing is linked. - Tests that run
git commitmust setcommit.gpgsign falseon the scratch repo. A signing config that prompts an external agent is unreachable from a test host: the commit hangs ~60s, then fails with exit 128.WorkspaceProvisionerTests.makeRepositorydoes this. -only-testingwith a name that matches no Swift Testing test prints** TEST SUCCEEDED **having run nothing, and-parallel-testing-enabled NOsilently skips Swift Testing suites entirely. Both read as a pass. Confirm the test name appears in the output before believing a green run.TerminalViewStateis a CombineObservableObject, not@Observable. Itstitle/workingDirectoryare@Published, which@Observabletracking cannot see, andTerminalSessionholds it@ObservationIgnored. Reading it straight from a view renders once and never updates — a stale value that looks like it works.TerminalSessionmirrors those two fields into its own observable storage; add any further ones the same way rather than observing the state object from a view.- The chat list places one item per block, not per message.
ChatPieceSplitterbuilds them andChatPieceViewdraws them; a message's wash is painted per piece on the edges that piece owns. This dates from a SwiftUILazyVStackengine that estimated unrealized items from realized ones and hung when a realized set's heights differed by a large factor; that engine is gone, replaced by Plume's ownNSScrollView-based list, but the split itself stays because it also keeps one measured row from towering over its neighbors.docs/chat-list.mdhas the history, the evidence, and the measured ratios. EnterWorktreemoves a live transcript to another project directory. The session id is unchanged, but Claude Code relocates the.jsonlinto a directory keyed by the worktree's path — so a path derived from the task's original folder goes stale mid-session. A transcript watcher never creates its file (createsFile: false), so the old path simply stops existing.TranscriptStorere-points on a read that finds no file, or an empty one, viaSessionJSONLReader.locateTranscript(sessionID:); never trust a derived transcript path to stay valid.- A transcript is a tree, and the surviving branch is the one the file's last
userorassistantrow hangs from — not the last child in file order. WalkingparentUuidup from that row is the only correct rule; last-child-wins is wrong on 81% of real forks and biggest-subtree on 27%. Three more rules are mandatory before calling a parent a fork: dedupe rows byuuid(one transcript has 1,339 byte-identical duplicate lines and reports 543 phantom forks otherwise), excludetype == "attachment"children (95% of multi-child parents involve one, and attachments chain) andprogresschildren, and excludeassistant/systemapi_errorretry pairs. A compaction's boundary row has a nullparentUuidand names its predecessor only aslogicalParentUuid, so the walk up from the last row must follow that too — without it, a fork before the compaction abandons every branch. Walk the tree iteratively — real transcripts are deep enough to blow the stack. - The chat list (
Plume/UI/Chat/List/) is Plume's ownNSScrollView-based container, not SwiftUI'sLazyVStack— it owns layout itself, in AppKit, and every visible row is measured for real before it is placed.docs/chat-list.mdis the reference. A freshNSHostingViewroot has none of the SwiftUI environment a view mounted normally would inherit, so any environment key a chat row reads has to be forwarded by hand inChatListItemRootor the row silently renders with defaults instead of failing loudly. - A reference chip's title is rewritten into the block text, never substituted at render.
ChatPieceCachewrites[\u{202F}title](dest)into a piece, because the reveal, selection and copy count characters and must count what the chip shows. A render-time swap leaves them all out of step with what is drawn. The Reference chips section ofdocs/chat-list.mdis the reference. - Never put
.textSelection(.enabled)on chat markdown. The transcript owns prose selection across rows (Plume/UI/Chat/Selection/, the Selection section ofdocs/chat-list.md). ATextthat SwiftUI makes selectable draws without itsTextRenderer, so the selection highlight and the reveal both vanish from it. It also runs its own mouse-tracking loop, which hides the mouse-up from the transcript's monitor. A new selectableTextin a markdown piece needs onlyrevealFade, which attaches the renderer and records the layout for hit-testing. .symbolEffectwith a repeating option is not free in-process. RenderBox re-rasterizes the symbol every frame on the main thread and blocks on the render-server commit, about a tenth of a core per instance, and the stall starves wheel-event handling.WorkingEllipsisfades masked copies of the glyph off aTimelineViewfor this reason; measure any repeating symbol effect in a bare window before shipping one.docs/handoff-idle-cpu.mdhas the numbers.- The composer is WYSIWYM: its text is always plain markdown.
MarkdownLineStructureparses the lines,MarkdownComposerStylersets attributes,ComposerDecorationsdraws bullets, quote bars, checkboxes, rules and code boxes, andComposerMarkdownEditingowns Return, Tab, Delete and caret snapping as pure text-in, text-out transforms. A hidden marker (-,>,- [ ]) keeps its characters with a clear foreground, shrunk to a 1 pt font so it fits under the list column (TextKit 2 floors a negative kern, so kern can't take the width back), so the binding and the sent message never change shape. Draw decorations fromdrawBackground(in:). Overridingdraw(_:)or readinglayoutManagerdrops the view to TextKit 1;ComposerMarkdownPresentationTests.staysOnTextKit2guards this. Don't overrideinit(frame:)on theNSTextViewsubclass either.ChatComposerclips at the text container's padding, so anything drawn left of x = 5 is invisible. - Never bind the composer's focus through
FocusState. SwiftUI answers a programmaticfalseon a.focusedbinding by resigning whatever is first responder at that moment, from inside a layout pass. During a click on selectable text that is the field editor whose mouse-tracking loop is still on the stack, and detaching it spins the main thread at 100% forever.MarkdownComposerTextViewtakes a plainBinding<Bool>and mirrors first responder itself;docs/selectable-text-link-hang.mdhas the trace and a repro that needs neither the pointer nor the frontmost window. - The composer has two autocompletes, and
ComposerAutocompleteRouteris the only handler its text view holds. It forwards each key to the slash list or the mention list, whichever has rows, with the slash list first. The arrows, Tab and Return go to a list only whileisShowing— it has rows. A mention panel that shows nothing but "Searching…" still claims Esc (claimsEscape) and plain Return (claimsReturn): Esc closes it without interrupting the turn, and Return closes it and types a space instead of sending, whichever key sends. ⌘↩ always reaches the send path. Esc keeps a list closed for that token until the caret leaves it. The text view reports the caret only with the text it belongs to: mid-edit,textViewDidChangeSelectionsees the old binding, and pairing the two would read the wrong token and reopen a dismissed menu. A mention never opens inside code, inside an existing link, or in command mode. - An accepted mention leaves a plain reference in the draft, and sending turns it into a link. The draft reads
@DROID-123,#123,@path/fileor@"My Notes.md";MentionReferenceparses these with the same boundary, code and link rules asMentionQuery. Accepting a row also records what it names inMentionResolver, per tab.ChatComposer.send()rewrites each reference the resolver already knows into a Markdown link (MentionExpansion), and sends the rest as typed — it never waits on a lookup. Command mode sends the text untouched. Sources always list Linear, then pull requests, then files, and when a query spans more than one source each non-empty section opens with its filter row (@linear:,@pr:,@file:) as a header, and a typed prefix of a filter name (@li) leads the list with that filter row in place of its header. The selection starts on the first result, never a header. A folder row and a filter row insert their prefix and keep the menu open on what follows. - Never write to
@Observablestate during a SwiftUIbody. SwiftUI records the write as a dependency of the view being rendered, so the render invalidates itself and spins forever — one core at 100%, no crash, and the surfaces never spawn their PTYs. Asampleblames whatever is most expensive inside the loop (ProcessInfo.environment, say), not the write. Create sessions and register them from.onAppear/.taskand hold them in@State;TerminalTabHostinTabContentViewis the pattern. - The wrapper's text path is a paste, not typing.
TerminalViewState.paste(text:)frames its argument as a bracketed paste, so a trailing\rlands in the program's edit line instead of submitting —send(_:)is deprecated for exactly this reason. Sending a line meanspaste(text:)thensendKey(.enter);TerminalSession.submit(text:)does both. - Claude Code inherits directory trust from an ancestor, and records nothing for the child. A folder under an already-trusted repository — every worktree under
<repo>/.plume/worktrees, say — gets noprojects.<path>.hasTrustDialogAcceptedentry in~/.claude.json, because it was never prompted for. An exact-path lookup therefore reads every one of them as untrusted;ClaudeTrustStorewalks the ancestors and takes the nearest explicit answer. Separately,claude -pskips the trust dialog outright (claude --helpsays so, and a probe confirms it), so the refusal inAgentLauncheris Plume's own stance rather than something the CLI enforces. SurfaceCommandTestsneeds a real GUI session. It spawns realNSWindows and PTYs, so it fails from a headless shell. That is environmental; check it againstmainbefore believing a regression.SessionJSONLReaderTests.encodingResolvesADirectoryClaudeCodeHasUsedonly asserts where Claude Code has run in this checkout. It derives the repo path from#filePathand locates the transcript directory by thecwdClaude Code records, which a worktree or a freshly cloned machine has none of. There its#requirefails with "Claude Code has not run in …" — an environmental failure, not a regression. More than one directory can legitimately record the samecwd, because an agent working in a worktree still stamps the parent repository's path, so the test asserts the encoded name is among the owning directories rather than the only one.- Measure a SwiftUI view's height from AppKit with
NSHostingController.sizeThatFits(in:), with an unbounded height.NSHostingViewhas nosizeThatFits, and itsfittingSizecollapses under a pinned frame, so every row measures the same. A hosting view withsizingOptions = []reports afittingSizeof exactly zero. That setting is right for an on-screen row and useless for measuring, so measure with a separate offscreen controller. A height test must assert that a long row measures taller than a short one;> 0passes the broken version. - Every tab spawns its PTY at launch, hidden or not, so a debug store with many tabs stalls launch long enough to look like a crash. Start from a fresh store instead. Moving a store aside means moving
Plume.store,-shmand-waltogether, or the fresh store inherits a stale WAL. To render a chat with noclaudeprocess at all, seed it withPLUME_SEED_TRANSCRIPT_PATH;docs/chat-list.mdhas the recipe. - Swift Testing runs suites in parallel in one process, so tests sharing libghostty state can contaminate each other's results.
- A test that waits on a file watcher must await the store's own read signal, never a wall clock.
RealTranscriptCorpusTestsparses every transcript on the machine — hundreds of files, tens of seconds — and Swift Testing runs it in parallel with everything else, so a deadline generous enough to look safe still expires under that load.TranscriptStore.didReadandMarkdownFileStore.didReadexist for this; thewaitUntilhelpers in their suites resume on the signal and finish instantly. Lengthening a timeout only moves the threshold. - Swift Testing's
#expectcannot wrap a throwing call.allSatisfy(\.isHexDigit)counts as throwing (the closure isrethrows), so writeallSatisfy { $0.isHexDigit }. The failure names a generated macro file, but…MX45…in that name is the line number in the real source. - Never embed anything named
plumeinContents/MacOS. The volume is case-insensitive, so it is the same file as the app'sPlumeexecutable, and the copy phase silently replaces the app; the test host then runs the tool and exits 2 before connecting. The tool lives inContents/Helpers. For the same reason no terminal may getContents/MacOSon its PATH: libghostty appends it to every child's PATH, where a shell finds the app asplumeandeval "$(plume init zsh)"launches a second copy that never exits.TerminalSession.pinningPathreplaces that PATH with the app's own. A tool target namedplumealso needsPRODUCT_MODULE_NAMEset to something else, or itsplume.swiftmodulecollides withPlume.swiftmoduleand PlumeTests fails with "Unable to resolve module dependency: 'Plume'". - Writable
/dev/ttydoesn't mean a controlling terminal exists.[ -w /dev/tty ]is true without one, so code that must reach the real terminal has to attempt the open and treat failure as "no terminal".plume notifydoes that withopen(2)and stays silent when it fails. - Adding a source file needs no project edit, but adding a SwiftPM package means hand-editing
project.pbxproj(build file, package reference, product dependency, and the Frameworks phase). - A GUI-launched app does not inherit your shell PATH. Launched from Xcode or a terminal it does, so a PATH bug hides completely until the app is opened from Finder or the Dock —
claudeat~/.local/binthen fails withNo such file or directory. Both transports run throughLoginShellCommand.wrapfor this reason. Test PATH-sensitive changes by opening the installed bundle, not from a terminal. - SwiftUI never re-evaluates a
Commandsbody for a changed@Observable. The menu bar is built once, so a rebindable chord read fromAppSettingsstays on whatever it was at launch — a recorded shortcut does nothing and the one it replaced keeps working.ShortcutMenuApplierwrites the new chord onto the builtNSMenuIteminstead.docs/keyboard-shortcuts.mdis the reference, and it also covers why a test must never readPlumeShortcuts.all(it reads the developer's own bindings) or assign toAppSettings.shared.shortcutBindings. - A hidden chat tab is unmounted, so every reading from it is meaningless.
TabContentViewbuildsChatTabViewonly for the visible tab andColor.clearotherwise, so a hidden tab's controls all readdisabledand its chat list is "not found: chat list" — which looks exactly like a broken tab. Forking selects the new tab on its own, so the tab being read changes without being asked to. Confirm which chip reportsvalue: "selected"before believing any chat-list or composer reading. - A scratch instance cannot prove a menu command ran. SwiftUI populates a
focusedSceneValueonly while the app's scene is active, which a hiddenopen -g -jinstance never is, so the whole Tab menu and most of the File menu read disabled and their chords do nothing. The control server'skeycommand still proves which item owns a chord (handledBy) and reports the item's enabled state; confirming the action itself needs a frontmost app. - Deployment target is macOS 26.2, but building needs the Xcode 27 SDK. An older Xcode fails on
ModelMenuButton.swift, which calls anNSMenuItemAPI only the 27 SDK has.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

