agentleFS
ryanmoelterryanmoelter/plume/CLAUDE.md

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

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
  1. Very specific:

    Every change ends with a clean build and a manual run.

    line 20
  2. Very specific:

    Keep every tab's TerminalTabView mounted and toggle visibility (opacity).

    line 68
  3. Very specific:

    Call SurfaceManager.closeSession(for:) when a tab or task is deleted.

    line 72
  4. Distinctive:

    Verify from outside the app rather than by screenshot — the surfaces are real processes, so the process tree is better evidence than a …

    line 114
  5. Very specific:

    Always walk down from Plume's own PID.

    line 131
  6. Very specific:

    Every commit an agent creates ends with a Co-Authored-By: trailer naming the model.

    line 167

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

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:PlumeTests

Every 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 messages

Shared/ 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 TerminalTabView mounted and toggle visibility (opacity). Never unmount to hide.
  • TerminalSession holds 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's attachedView is 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's makePlatformView hook. 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), so TabVisibility answers both: rendering and bell-seen follow on-screen, and only the focused pane (selectedTabID) claims first responder, through claimsPaneFocus. Every tab of the task stays in the one ForEach, placed by frame, so a tab moving between panes, or a split opening or closing, never changes container. TaskStore owns pane membership, keeps orderIndex dense 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.
  • TerminalSurfaceOptions set 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. DetachedSurfaceTicker ticks 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 stdio plus an initialize control 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_id is stable and gets persisted to the tab so a restart can --resume.
  • A control response is correlated by request_id, through pendingControlRequests. Sniffing replies for a field you recognize works only while one request matters; add a request by recording its kind when you send it.
  • /rc is served by Plume, not the CLI. remote-control is an interactive TUI command with no non-interactive variant, so it never reaches the headless commands list. PlumeSlashCommand supplies it and ChatComposer.send() intercepts it — only on this transport, since a terminal tab's real TUI already has a working /rc.
  • /btw is a side_question control 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. HeadlessSession holds 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 as prefillText — 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 DEBUG until 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 with target_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 -p is 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_usd is per turn and accumulates; stream utilization is 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 matcher on Stop or UserPromptSubmit — Claude Code rejects it. Omitting matcher already means "all", so the generated file omits it everywhere.
  • --settings merges, 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 --info

Stable 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 its Generated/ folder; run scripts/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 disconnect then remove. Quitting ends every reservation in RemoteReservationLedger, shown or not, and the next launch releases any entry a crash left behind, so a remote tab never reconnects after a relaunch. Never run android device remote create while testing without removing the reservation afterwards.
  • DeviceSessionManager.shared owns one DeviceSession per tab ID. TaskStore.forgetTab closes 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 devices writes ~/Library/Application Support/Plume/devices/registry.json whichever build runs it, and a tab owner names its app's socket. The app only ever sweeps, reaps or releases leases whose owner.app is its own socket, so a debug build never touches the installed app's leases. A remote lease follows its reservation: claim --kind=remote reserves 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.md is the reference.
  • scrcpy-server ships without a .jar extension and lives in Git LFS. Xcode drops a .jar from Resources, and a checkout without LFS has a pointer file there.
  • Frames never pass through @Observable state. DeviceSurfaceView sets 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 in Configuration/Info.plist. SUAllowsAutomaticUpdates is false so 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 upgrade inside Plume. The cask's uninstall quit: quits Plume mid-upgrade, and its launchctl step for the sleep helper can prompt for a sudo password. HomebrewUpgrade opens a temporary .command script 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 DEBUG only) plus scripts/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 @Observable objects 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 ...Raw strings with a computed accessor (workspaceKindRaw / workspaceKind). Keeps migrations trivial.
  • Ordering is a dense orderIndex per section, rewritten on move. All of it lives in TaskStore — keep it out of views.
  • Model names avoid colliding with Swift's Task and SwiftUI's Group: WorkTask, TaskGroup, TaskTab.
  • All ghostty_* calls stay in Plume/Ghostty/. The C API is unstable between versions; upgrading should touch one folder.
  • Status flows through StatusEngine in memory; SwiftData gets only a debounced snapshot in lastStatusRaw, 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.read and AgentTitleMonitor.read do. Its tests await the store's didRead, 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, in awaitTerminations. 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: sample the 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 backgrounded Bash and a Workflow all keep running after the tab drops to awaitingReply, and BackgroundTaskTracker holds them per tab so KeepAwakeCoordinator can keep the Mac awake. A headless tab takes membership from the CLI's background_tasks_changed list (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 to BackgroundTaskScanner, which reads the transcript in the same parse TranscriptStore already 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: a Monitor arrives as local_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 passed persistent: false, that monitor's first event. Only an explicit false counts — reading an absent persistent as one-shot would retire a monitor still watching and let the Mac sleep mid-work. A background Agent is deliberately not tracked here: setSubagentActivity already covers it.

Gotchas

  • Name controls with plumeID(_:), never a bare .accessibilityIdentifier, and hover with plumeHover, 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 registry plumeID feeds — a bare identifier is invisible to it. Apply it inside .disabled(_:) so the registered enabled state is real, and pass label: on repeated rows so a driver can tell them apart. SwiftUI's hover tracking likewise answers only the real pointer, so plumeHover registers the region and the server calls its closure itself.
  • SwiftUI's .global frames 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 = YES means transitive imports don't count. Using Array.move(fromOffsets:toOffset:) needs an explicit import SwiftUI; IndexSet needs import 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 runs git 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 the hooks and events directories. Delete it to test first-run behavior. Debug writes to Plume.debug/ instead — its bundle ID carries a .debug suffix and AppPaths.directoryName keys 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 to Plume.debug-<worktreeName>/, keyed off BuildInfo.sourceRoot; only the primary checkout (whose .git is a directory) keeps Plume.debug/. UserDefaults stay shared. The test host has no bundle ID and falls back to Plume/. If the store fails to open, PlumeApp moves it aside as Plume.store.<timestamp>.bak and 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 TaskGroup to Swift's generic one). Trust xcodebuild, not the editor squiggles.
  • In Debug, Plume.app/Contents/MacOS/Plume is a ~57K launcher stub. The real code — and every linked libghostty symbol — is in Plume.debug.dylib beside it. Inspecting the stub with nm makes it look like nothing is linked.
  • Tests that run git commit must set commit.gpgsign false on 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.makeRepository does this.
  • -only-testing with a name that matches no Swift Testing test prints ** TEST SUCCEEDED ** having run nothing, and -parallel-testing-enabled NO silently skips Swift Testing suites entirely. Both read as a pass. Confirm the test name appears in the output before believing a green run.
  • TerminalViewState is a Combine ObservableObject, not @Observable. Its title/workingDirectory are @Published, which @Observable tracking cannot see, and TerminalSession holds it @ObservationIgnored. Reading it straight from a view renders once and never updates — a stale value that looks like it works. TerminalSession mirrors 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. ChatPieceSplitter builds them and ChatPieceView draws them; a message's wash is painted per piece on the edges that piece owns. This dates from a SwiftUI LazyVStack engine 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 own NSScrollView-based list, but the split itself stays because it also keeps one measured row from towering over its neighbors. docs/chat-list.md has the history, the evidence, and the measured ratios.
  • EnterWorktree moves a live transcript to another project directory. The session id is unchanged, but Claude Code relocates the .jsonl into 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. TranscriptStore re-points on a read that finds no file, or an empty one, via SessionJSONLReader.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 user or assistant row hangs from — not the last child in file order. Walking parentUuid up 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 by uuid (one transcript has 1,339 byte-identical duplicate lines and reports 543 phantom forks otherwise), exclude type == "attachment" children (95% of multi-child parents involve one, and attachments chain) and progress children, and exclude assistant/system api_error retry pairs. A compaction's boundary row has a null parentUuid and names its predecessor only as logicalParentUuid, 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 own NSScrollView-based container, not SwiftUI's LazyVStack — it owns layout itself, in AppKit, and every visible row is measured for real before it is placed. docs/chat-list.md is the reference. A fresh NSHostingView root 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 in ChatListItemRoot or 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. ChatPieceCache writes [\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 of docs/chat-list.md is the reference.
  • Never put .textSelection(.enabled) on chat markdown. The transcript owns prose selection across rows (Plume/UI/Chat/Selection/, the Selection section of docs/chat-list.md). A Text that SwiftUI makes selectable draws without its TextRenderer, 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 selectable Text in a markdown piece needs only revealFade, which attaches the renderer and records the layout for hit-testing.
  • .symbolEffect with 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. WorkingEllipsis fades masked copies of the glyph off a TimelineView for this reason; measure any repeating symbol effect in a bare window before shipping one. docs/handoff-idle-cpu.md has the numbers.
  • The composer is WYSIWYM: its text is always plain markdown. MarkdownLineStructure parses the lines, MarkdownComposerStyler sets attributes, ComposerDecorations draws bullets, quote bars, checkboxes, rules and code boxes, and ComposerMarkdownEditing owns 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 from drawBackground(in:). Overriding draw(_:) or reading layoutManager drops the view to TextKit 1; ComposerMarkdownPresentationTests.staysOnTextKit2 guards this. Don't override init(frame:) on the NSTextView subclass either. ChatComposer clips 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 programmatic false on a .focused binding 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. MarkdownComposerTextView takes a plain Binding<Bool> and mirrors first responder itself; docs/selectable-text-link-hang.md has the trace and a repro that needs neither the pointer nor the frontmost window.
  • The composer has two autocompletes, and ComposerAutocompleteRouter is 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 while isShowing — 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, textViewDidChangeSelection sees 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/file or @"My Notes.md"; MentionReference parses these with the same boundary, code and link rules as MentionQuery. Accepting a row also records what it names in MentionResolver, 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 @Observable state during a SwiftUI body. 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. A sample blames whatever is most expensive inside the loop (ProcessInfo.environment, say), not the write. Create sessions and register them from .onAppear/.task and hold them in @State; TerminalTabHost in TabContentView is 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 \r lands in the program's edit line instead of submitting — send(_:) is deprecated for exactly this reason. Sending a line means paste(text:) then sendKey(.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 no projects.<path>.hasTrustDialogAccepted entry in ~/.claude.json, because it was never prompted for. An exact-path lookup therefore reads every one of them as untrusted; ClaudeTrustStore walks the ancestors and takes the nearest explicit answer. Separately, claude -p skips the trust dialog outright (claude --help says so, and a probe confirms it), so the refusal in AgentLauncher is Plume's own stance rather than something the CLI enforces.
  • SurfaceCommandTests needs a real GUI session. It spawns real NSWindows and PTYs, so it fails from a headless shell. That is environmental; check it against main before believing a regression.
  • SessionJSONLReaderTests.encodingResolvesADirectoryClaudeCodeHasUsed only asserts where Claude Code has run in this checkout. It derives the repo path from #filePath and locates the transcript directory by the cwd Claude Code records, which a worktree or a freshly cloned machine has none of. There its #require fails with "Claude Code has not run in …" — an environmental failure, not a regression. More than one directory can legitimately record the same cwd, 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. NSHostingView has no sizeThatFits, and its fittingSize collapses under a pinned frame, so every row measures the same. A hosting view with sizingOptions = [] reports a fittingSize of 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; > 0 passes 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, -shm and -wal together, or the fresh store inherits a stale WAL. To render a chat with no claude process at all, seed it with PLUME_SEED_TRANSCRIPT_PATH; docs/chat-list.md has 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. RealTranscriptCorpusTests parses 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.didRead and MarkdownFileStore.didRead exist for this; the waitUntil helpers in their suites resume on the signal and finish instantly. Lengthening a timeout only moves the threshold.
  • Swift Testing's #expect cannot wrap a throwing call. allSatisfy(\.isHexDigit) counts as throwing (the closure is rethrows), so write allSatisfy { $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 plume in Contents/MacOS. The volume is case-insensitive, so it is the same file as the app's Plume executable, and the copy phase silently replaces the app; the test host then runs the tool and exits 2 before connecting. The tool lives in Contents/Helpers. For the same reason no terminal may get Contents/MacOS on its PATH: libghostty appends it to every child's PATH, where a shell finds the app as plume and eval "$(plume init zsh)" launches a second copy that never exits. TerminalSession.pinningPath replaces that PATH with the app's own. A tool target named plume also needs PRODUCT_MODULE_NAME set to something else, or its plume.swiftmodule collides with Plume.swiftmodule and PlumeTests fails with "Unable to resolve module dependency: 'Plume'".
  • Writable /dev/tty doesn'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 notify does that with open(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 — claude at ~/.local/bin then fails with No such file or directory. Both transports run through LoginShellCommand.wrap for this reason. Test PATH-sensitive changes by opening the installed bundle, not from a terminal.
  • SwiftUI never re-evaluates a Commands body for a changed @Observable. The menu bar is built once, so a rebindable chord read from AppSettings stays on whatever it was at launch — a recorded shortcut does nothing and the one it replaced keeps working. ShortcutMenuApplier writes the new chord onto the built NSMenuItem instead. docs/keyboard-shortcuts.md is the reference, and it also covers why a test must never read PlumeShortcuts.all (it reads the developer's own bindings) or assign to AppSettings.shared.shortcutBindings.
  • A hidden chat tab is unmounted, so every reading from it is meaningless. TabContentView builds ChatTabView only for the visible tab and Color.clear otherwise, so a hidden tab's controls all read disabled and 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 reports value: "selected" before believing any chat-list or composer reading.
  • A scratch instance cannot prove a menu command ran. SwiftUI populates a focusedSceneValue only while the app's scene is active, which a hidden open -g -j instance never is, so the whole Tab menu and most of the File menu read disabled and their chords do nothing. The control server's key command 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 an NSMenuItem API only the 27 SDK has.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.