cmux Architecture
Package architecture
We are migrating cmux from a single app target into Swift Packages under
. Every new package must satisfy three rules:
- Ergonomic. Public API surface matches what callers naturally want to write. Default to internal access; expose only for types and functions that downstream consumers actually use. Avoid friction such as forcing every call site through a builder or wrapper when a direct API is fine.
- No dependency cycles. Packages form a strict DAG. A package may only depend on packages strictly lower in the graph. When two packages need to share a type, lift it to a common lower-level package or define a protocol seam in the consumer. Every new dependency edge requires re-checking that the graph stays acyclic.
- Clear but not overly narrow responsibilities. A package owns one full domain (e.g. settings, appearance, workspace, terminal, browser, command palette), not a slice of one. A package called "appearance math" or "workspace model" is too narrow — it forces every consumer that touches the surrounding domain to also depend on the sibling slices. Prefer a single that owns settings, theming, colors, glass, and snapshots together, over + + . Don't fragment a domain into + + — that's folder structure inside a single package, not module structure. A package boundary exists because more than one consumer needs the contents, or a build/test seam needs to exist.
When in doubt, extract leaf-first: pull out the package that has no internal dependencies. Consumers in the app target stay put and only update imports. Each leaf shrinks the app target without requiring downstream packages to exist yet.
The existing packages under
predate this policy and should not be used as design references.
Wiring a new local package into the project. lists package dependencies explicitly (it is not a synchronized-folder project). Adding
means mirroring an existing package's
entries — one
XCLocalSwiftPackageReference
(in the project's
), one
XCSwiftPackageProductDependency
, and a
linked in the Frameworks phase of
every target that imports it. The app-target packages link into
both and
(so tests can
and inject them); copy a recent leaf like
for the exact shape, then run
scripts/normalize-pbxproj.py
and
. A package the app builds against but
does not link will compile the app yet fail the test target.
Refactor architecture: layers, Coordinator/Service/Repository, dependency inversion
These higher-level patterns are binding on every new or moved/meaningfully-rewritten file. (The full blueprint, with worked examples and the per-god decomposition, lives in the cmuxterm-hq control repo under
docs/cmux-refactor-audit/blueprint/
; the enforceable core is below.)
Layered, downward-only DAG. Packages form a strict acyclic graph in five layers; dependencies point only downward:
- Core (e.g. ) — pure values, IDs, DTOs, errors, and the protocol seams shared across domains. No AppKit/SwiftUI/I/O. The lift target when two domains need the same type.
- Services / infrastructure — s implementing core protocols against the outside world (process/PTY, filesystem, sockets, web API, notifications, auth). One package per cohesive capability.
- Domain / state — models + Coordinators, one package per feature domain; owns that domain's mutable state. is the exemplar.
- UI — SwiftUI/AppKit views, one UI package per domain package, depending only on its domain package + Core, never on a Service directly. is the exemplar.
- Executable ( / ) — a thin composition shim, no business logic.
Classify every extracted entity by intent:
- Coordinator — a orchestrator that sequences a user flow and owns navigation/selection/lifecycle state, calling Services and child models. Does no I/O itself.
- Service — an (or only when an AppKit main-thread API forces it) performing one outside-world capability; exposes / + , holds only its own resource handles, holds no UI state.
- Repository — an mediating one persistence source of truth (file, defaults, web API) behind CRUD-shaped async methods returning value types. Precedents: ,
UserDefaultsSettingsStore
.
Dependency inversion. Lower packages publish protocols; concrete Services/Repositories conform; higher layers depend on
, never the concrete type. Share a type by lifting it to Core or defining a protocol seam in the consumer — never a stored property reaching across modules. Injection is constructor (
) injection only: no global container, no singleton, no
. The
executable app target is the single composition root — the one place concretes are named and the object graph is assembled. SwiftUI
may carry already-constructed
models down a view tree (as
does), but is never the source of truth for service wiring.
State + SwiftUI wiring. Domain state lives in
models (never
/
). A god model decomposes into cohesive child
sub-models owned by their domain packages and composed by the home object via held references; cross-domain reads go behind read-only protocols. In views use
(owned),
/ plain
(passed-in), or
+
(injected) — never
/
/
/
.
Executable-target boundary (three hard constraints — invert, never work around):
- and stay in the executable target as the thin composition shim; that residual is the intended end state, not debt.
- A type is declared in exactly one module and a lower package cannot extend a higher-owned type, so / / extensions do not move down: extract the behavior into a Coordinator/Service/Repository, have the god object own an instance, and reduce the extension to a one-line forward.
- Stored properties cannot cross module boundaries: decompose god-model state into child sub-models owned by domain packages, composed by held reference, with cross-cutting reads behind read-only protocols.
File organization
One major type per file. Each
,
,
,
, or
that is part of a public API (or has any meaningful body) lives in its own file named after the type (
,
,
— not one shared
). This rule applies to all new code in
and to any new files added to the app target.
- Small, closely-bound helpers (, nested types, single-line extensions used only inside the file) can stay with the parent type. Anything bigger or independently meaningful gets its own file.
- Conformance-adding extensions for a type defined elsewhere go in
TypeName+Conformance.swift
or , not bundled into the consuming feature file.
- Type-erased wrappers () live next to the type they erase ( and ), each in its own file.
- Existing god files (, , , ) are the pattern this rule exists to stop. When migrating code out of them, split into one file per type even if it triples the file count. File count is cheap; "find this type" being unanswerable is expensive.
Documentation
Every
symbol in any new Swift package under
is documented with a Swift-DocC triple-slash comment at the time of writing. Treat docs as part of the API surface, not as follow-up work.
- Format. Use doc comments above the symbol. First line is a one-sentence summary that fits on a single line and ends with a period. If more context is needed, leave a blank line, then add a discussion paragraph. Use / / callouts on and symbols that take parameters or throw. Use Markdown freely (bold, fenced code blocks for examples, backticks for inline code).
- Cross-references. Refer to other symbols using double-backticks: CmuxSetting. Plain backticks are for non-symbol code (, ).
- What to document on each symbol. Types: what they represent and when to use them. Enums: meaning of each case. Init parameters: especially defaults and the reason for them. Properties: what value they hold and any invariants. Methods: what they do, plus parameters/returns/throws. Generic constraints: which / shapes the type accepts and why (e.g., ).
- Examples. Non-trivial APIs get at least one example in a fenced block, ideally a real declaration from this codebase. Keep examples short and idiomatic.
- Internal vs public. and symbols get a one-line when the intent is non-obvious; verbosity is not required at that scope. The public boundary is the one that needs full coverage.
- No stale docs. When you change a symbol's behavior or signature, update its doc comment in the same edit. Docs that describe last week's behavior are worse than no docs.
- Don't comment-narrate the body. Doc comments describe the contract from the outside. Inline comments inside method bodies are reserved for non-obvious why, not what (the existing rule from the top-level guidance still applies).
This rule applies to all packages under
. Code in the main app target is not retroactively required to be documented, but new
symbols added to packages must be.
Package design discipline
These are the recurring design mistakes that have to be caught at the design step, not at code review:
- No shared-singleton accessors. / / on a package type that holds runtime state is a singleton-by-another-name. Construct the package type at the app's startup site and inject it. is fine for declarations — identifiers, schema entries, enum cases — but not for behavior.
- No namespace-enums.
enum Foo { static func bar() }
(a no-case enum used as a namespace) is a fake namespace that fights the rest of the design (no instances, no DI, no test seam). Prefer a value-typed struct passed via constructor when the helper might gain configuration, or a file-scope for pure helpers internal to one file.
- No parallel hand-maintained registries. When a list mirrors a set of declared items (e.g. mirroring the catalog's stored properties), derive the list via reflection or a macro. Two sources of truth drift silently; the IDE doesn't tell you.
- Prefer compile-time invariants to runtime traps. If the pattern is
guard ... else { assertionFailure(...); return default }
for a "programmer error" case, encode it in the type system (phantom types, separate concrete flavors). Runtime traps become silent fallbacks in release builds.
- No free functions. Functionality is always scoped to an entity that owns the responsibility: a method on a value type, an extension on the type the operation belongs to, or a member of the Coordinator/Service/Repository that uses it. Top-level declarations (any visibility, including file-scope ) are banned. The only sanctioned exception is a trampoline a C API forces on us, marked with a one-line justification.
- Nested types still count for the one-major-type-per-file rule. A
private final class WatcherAttachment
inside JSONConfigFileWatcher.swift
is a major type. Move it to its own file the moment it has a meaningful body.
Testability
Every public type added to
must be
testable from a test target without launching the app target, without booting AppKit, and without depending on the user's filesystem or
. Production-grade designs surface a test seam at every boundary:
- No global state in package code. Every public type that needs , , an on-disk path, an environment variable, or a clock takes it via initializer parameter. Tests pass a scoped to the test, a temp directory URL, a fixed , etc.
- No reliance on / . A public type that hardcodes or inside its implementation cannot be tested without polluting the developer's actual settings. Inject these at the seam.
- Test through injected seams, never a static test hook. A
nonisolated(unsafe) static var fooForTesting
(or any global mutable "override" a test swaps in) is global state by another name: it leaks across tests, forces , and usually needs a lock. Replace it with a protocol seam injected through (e.g. init(commandRunner: any CommandRunning = CommandRunner())
); the test passes a conforming fake. When you extract such a type into a package, deleting the static hook (and the lock it required) is part of the extraction, not a follow-up.
- Public APIs return values, not side effects, where possible. A function that mutates global UserDefaults and returns is harder to test than one that returns the changed value and lets the caller persist. Prefer pure transformations + thin imperative layers.
- Asynchronous APIs surface their observation as . Tests can iterate deterministically and assert the sequence of yielded values. Avoid -only patterns where the test has to spin a runloop.
- Document the test pattern alongside any non-trivial public surface. The package's and any DocC catalog should show how to instantiate the type with test-friendly dependencies.
If a design is hard to test, it is wrong. Reach for the constructor parameter list, not the test bench.
Modern Swift concurrency
All new code in
and any new files added to the app target use Swift 6 concurrency primitives:
,
/
,
/
,
,
. Old primitives — locks, manual KVO,
, completion handlers,
used as a serial lock — are not allowed.
If you find yourself reaching for a lock to protect ongoing mutable shared state, the type is almost always the wrong shape — promote it to an
. The exception is the narrow lock carve-out below.
Do not introduce a single-method purely as a mutex. An
actor Guard { func claim() -> Bool }
whose only job is to guard a flag is a lock with extra ceremony: it forces synchronous callers — a
termination handler, a
event handler, a
resume race — through
Task { await guard.claim() }
, which adds suspension points, ordering hops, and reentrancy surface to what is fundamentally a synchronous compare-and-set. That makes the code worse, not safer. A tiny synchronous guard like that belongs in the lock carve-out, not an actor.
When
extracting existing code that uses a forbidden primitive into a package, reconsider the shape at the seam rather than copying it blindly — usually it wants an
. But a one-shot single-resume guard (a
termination handler vs. a timeout vs. a spawn failure racing to resume one
) is exactly a case the lock carve-out covers: keep a synchronous primitive, hidden behind the type. Drain
pipes concurrently on detached tasks keyed by the raw fd (an
is
; a
is not).
Forbidden in new code (no exceptions without a written justification in the PR description):
- Locks. , , , , , , used as a lock. Use isolation. Mutable shared state belongs in an actor; reads and writes are . (Narrow carve-out below: a lock is allowed where the / alternative would genuinely worsen the code, with justification.)
- KVO via subclassing. Any whose purpose is to override
observeValue(forKeyPath:...)
or call addObserver(_:forKeyPath:...)
. Replace with NotificationCenter.default.notifications(named:)
, or the token API at the seam only.
- used as a synchronization primitive. A accessed via to serialize mutable state is a lock with different syntax. Use an . Queues are fine for event delivery (e.g. a handler), not for protecting state.
- Combine for change propagation. No , no , no /, no for change observation. Use (Observation framework, Swift 5.9+) for SwiftUI state, or / for cross-actor change propagation.
- Completion-handler APIs. Authoring a new public API with a
(Result<T, Error>) -> Void
or callback is forbidden. Use . When wrapping a legacy callback at the boundary, use /withCheckedThrowingContinuation
and keep it confined to that one seam.
DispatchQueue.main.async { ... }
. Annotate the destination with . Call sites either the main-isolated function or are themselves .
- Sleeping as a synchronization substitute. / (or any sleep) used to poll for a condition, to let state "settle" before reading it, or to race a callback/animation is forbidden — use a real signal (, , a completion, a state change). is banned outright (it is neither cancellable-by-structure nor testable). A bounded, cancellable, intended delay or deadline is allowed under the carve-out below.
Required shape:
- Mutable shared state → . Reads/writes/reset are . Observers receive returned by the actor.
- SwiftUI view-render-friendly state → view-model that subscribes to the actor's and projects snapshots. Don't read actor state synchronously from view code.
- Cross-process / cross-thread invariants → expressed via actor isolation, not via locks or queues.
- New public observable surfaces → or . Not callbacks, not , not raw subscription.
Acceptable with a one-line justification comment on the declaration:
These low-level primitives have no async-native replacement. They must be hidden behind an
or
surface; callers never see them.
DispatchSource.makeFileSystemObjectSource
for file watching (no Foundation async equivalent).
DispatchSource.makeReadSource
/ for low-level socket I/O.
- A bounded, cancellable (preferred) or for a genuine delay/deadline that is itself the intended behavior — a minimum display duration, an auto-dismiss, a check timeout. Drive it from an injected (or a duration) so tests advance virtual time with no real waiting, and wire the sleeping 's cancellation to the relevant lifecycle so a state transition cancels the pending delay (store the , cancel it on transition; or use
withTaskCancellationHandler
). For true delays/deadlines only, never to poll, settle, or race — those still require a real signal. One-line justification on the call site.
DispatchSource.makeTimerSource
(one-shot) only when a genuine deadline must fire outside any async context — a non- type with no to host the sleep. Prefer the carve-out above whenever the code is already on an actor or in async code (it is cancellation-integrated and testable; a raw timer is not, and has suspend/resume/cancel footguns). Hide the timer behind the type, cancel it on the non-timeout path, and never use it to poll or fake a sleep.
- A lock for a synchronous compare-and-set called from non-async callbacks, where promoting to an would only add / hops and reentrancy. The canonical case is a one-shot resume guard: several synchronous / callbacks race to resume one exactly once.
OSAllocatedUnfairLock(initialState:)
guarding a (claimed once, checked synchronously in each callback) is correct, deterministic, and lets the callback resume the continuation inline. This carve-out is for short, non-blocking critical sections over a tiny flag/counter — not for guarding ongoing domain state (that is still an ). Keep it private to the type, with a one-line justification.
- token (the closure-based API) when wrapping a Foundation/AppKit type that exposes change only via KVO.
Both require a comment on the declaration explaining the safety argument. Examples that pass review:
swift
// Wraps DispatchSourceFileSystemObject; every mutation happens on `queue`.
private final class WatcherAttachment: @unchecked Sendable { ... }
// UserDefaults is Apple-documented thread-safe; OK to read nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
Without a justification comment, the diff is rejected.
on an entire actor or struct is almost always wrong; prefer
on the single non-Sendable property.
Scope and enforcement:
- Applies to: every new file in , every new file in the app target, every meaningful rewrite of an existing Swift file.
- Existing app target code may continue to use the old primitives until rewritten. Do not retrofit blindly.
- Code review checklist (Codex, CodeRabbit, Greptile, and human reviewers): reject diffs that introduce ///
addObserver(_:forKeyPath:...)
/ in new code, or / used to poll, settle, or race rather than as a bounded, cancellable, injected-clock delay with justification. Reject a lock (//etc.) or / unless it falls under a documented carve-out and carries a one-line justification — and reject a single-method that exists only to guard a flag (use the lock carve-out instead).