agterm is a native macOS SwiftUI terminal on libghostty, with a two-level workspace -> session vertical
sidebar.
Read README.md for the overview and ARCHITECTURE.md for the module split,
surface ownership, and the C-boundary concurrency contract before changing the bridge.
- The maintainer and most expected contributors are NOT SwiftUI / macOS UI-UX experts.
When a UI request is non-standard, risky, or trickier than it sounds (custom window chrome,
fighting
NavigationSplitView, reaching into private AppKit views, layout-direction hacks, etc.), push back gently FIRST: explain what it actually takes and the trade-offs, and offer the simpler/standard alternative. If the user still wants it after that, do it — the user is the boss. - Propose control-API/CLI coverage for every new feature (aim for completeness). When adding ANY
feature or capability, evaluate what of it makes sense to drive over the control channel and PROACTIVELY
propose adding it — a
Commandcase +ControlServerarm +agtermctlsubcommand + round-trip/e2e tests. The goal is the most complete control-API coverage possible (the API is a first-class surface, not an afterthought), not merely parity with the GUI. This generalizes the HARD keep-in-sync convention (which covers GUI actions inAppActions/AppStore) to features with NO GUI surface at all —notify/session.copy/session.typeare control-native. Only skip when exposure is genuinely meaningless (pure rendering/visual chrome with nothing to drive). - Propose a Settings ▸ Interface toggle for a new toggleable UI/chrome element — ask first, never
automatic. When adding a title-bar or sidebar affordance the user might want to hide (a button, a
hover glyph like the workspace-row add-session "+", a status/indicator element), evaluate whether it
should join the host-free
InterfaceElementset so it gets a live Settings ▸ Interface toggle (a new case auto-appears in the tab viaallCases, gated through theGhosttyApp.hiddenInterfaceElementsmirror — the detail is thehiddenInterfaceElementsbullet in.claude/rules/settings.md). Unlike the control-API norm above, do NOT add it proactively — PROPOSE it and let the user decide, since some chrome is intentionally always-on; skip only when a toggle is genuinely meaningless (transient/decorative chrome with nothing to hide). - Use the Swift skills for Swift/SwiftUI work — proactively, from the START,
not only when stuck. agterm is Swift 6 + SwiftUI + AppKit; activate the relevant skill before/while
working:
swiftui-expertfor any SwiftUI/AppKit view, layout,@Observable/state, focus, animation, or rendering work (this wholeContentView/window-chrome/overlay surface);swift-testing-expertfor writing or modernizing Swift tests;swift-concurrencyfor actor /@MainActor/Sendable/ async work (esp. across the C-callback boundary). Don't wait for a failure to reach for them. - "Show me" / "show it" for a UI feature = BUILD + RUN the app for the user to look at,
NOT a screenshot. When the user asks to see a visual change,
make buildthen launch an ISOLATED dev instance (open -n --env AGTERM_STATE_DIR=<tmp> --env AGTERM_CONTROL_SOCKET=<tmp>/agterm.sock build/DerivedData/.../Debug/agterm.app) so it coexists with the deployed daily-driver and never touches the realworkspaces.json— then leave it running and tell the user how to reach the feature. Do NOT take a screenshot (screencapture) or capture an image via XCUITest (XCUIScreen.screenshot/XCTAttachment) — the user wants to interact with the running app themselves. The XCUITest runner is sandboxed and can't write/tmpanyway. - Dev instance that must run the user's REAL custom commands / keymap = COPY the config into the
isolated state dir; don't rely on
AGTERM_STATE_DIRalone. An isolatedAGTERM_STATE_DIRALSO redirects the config dir to<stateDir>/config(ConfigPathsprecedence: explicitAppSettings.configDirectory→<stateDir>/configwhen the state dir is set →~/.config/agterm), so an isolated dev instance does NOT read~/.config/agterm/keymap.confand loses every custom command. Keep them withcp ~/.config/agterm/{keymap,ghostty,restore-denylist}.conf <stateDir>/config/before launch — there is NO env var that overrides the config dir independently of the state dir. Two more gotchas: (1) do NOT set anAGTERM_CONTROL_SOCKETwhose path differs from<stateDir>/agterm.sock— a custom command's spawnedagtermctlderives its socket from the inheritedAGTERM_STATE_DIR(<stateDir>/agterm.sock), so a server bound elsewhere is unreachable; set ONLYAGTERM_STATE_DIR(a SHORT/tmppath) and let the app + CLI derive the same socket. (2) to make custom commands use the freshly-BUILTagtermctl(not the deployed one on PATH), launch with--env PATH="<devApp>/Contents/MacOS:/opt/homebrew/bin:/usr/bin:/bin:…"—CustomCommandRunnerruns/bin/sh -coverProcessInfo.processInfo.environment, so the prepended PATH reaches the command; a session shell's login rc re-prioritizes PATH back to the deployedagtermctl, so a MANUALagtermctlin a dev session needs the full dev-binary path while keybinding commands resolve the dev binary automatically. - After launching a dev instance for the user to test, default to HANDS-OFF.
For the user's MANUAL test (the common case — "run it for me to test",
"I'll test manually"), do NOT touch the running instance after launch:
no
agtermctlcalls, nosession statuspokes, no state changes — poking it mid-test corrupts what the user is observing. For an ASSISTED experiment (you drive part of it — e.g. set a session's status viaagtermctlso the user can then act on it), acting is fine, but ANNOUNCE each action as you do it so the user can follow and help. When in doubt, treat it as manual and ask before touching. - Non-trivial work goes in an ISOLATED git worktree, cleaned up after merge. See the worktree rule under "Build and test commands" for the mandate, the artifact-symlink setup a fresh worktree needs, and the post-merge cleanup (which must never switch the main checkout's branch).
- The app target is generated with
xcodegenand built withxcodebuild(Xcode 26).miseis not used; callxcodegen,xcodebuild, andswiftdirectly through the scripts. - The
agtermCorepackage is built and tested withswift test(Swift 6, strict concurrencycomplete). It is independent of Xcode and libghostty. scripts/setup.shbuilds libghostty from upstream ghostty source, so it needsgit, Homebrew (for thezig@0.15keg = zig 0.15.2, what ghostty pins), and Xcode's Metal Toolchain (auto-downloaded on first run viaxcodebuild -downloadComponent MetalToolchain). The build is one-time — cached by the present-check — so day-to-day work pays nothing.
scripts/setup.sh— buildGhosttyKit.xcframeworkand the ghostty resources from upstream ghostty source (pinned SHA, zig 0.15.2). Idempotent; skips the build if both are already present. First run takes a few minutes plus a one-time Metal Toolchain download.scripts/run.sh— setup,xcodegen generate,xcodebuildDebug, then launch.scripts/build.sh— same but Release, no launch.cd agtermCore && swift test— run the host-free unit tests (scripts/test.shwraps this).scripts/test-app.sh— run the application-hosted AppKit unit tests with the isolatedagtermTestsscheme.Makefile— a thin front door over the scripts:make prep/build(Debug, no launch)/run/release/deploy(Release build + copy to~/Applications)/test/test-app/lint/dist VERSION=x.y.z [PUBLISH=1](therelease.shDMG)/clean; a baremakelists them. The scripts stay the source of truth — onlybuild,deploy, andlintcarry their own recipe.make lintrunsswiftlint lint --strictover the tree, configured by.swiftlint.ymlat the repo root. The config disables only rules that fight deliberate conventions (identifier_name,trailing_comma,force_try,optional_data_string_conversion— the last keeps the lossyString(decoding:as:)for terminal/process bytes), exempts the deliberately-namedagtermApp/Gotypes, tunesline_length(200) andcyclomatic_complexity(ignores_case_statements, so the flat 44-arm command dispatch isn't "complex"), allows 2-deep type nesting, and caps source files at 1000 lines / 800-line type bodies. Test files get a 2000-line budget via nested.swiftlint.ymlconfigs inagtermCore/Tests/,agtermTests/, andagtermUITests/. Those configs override onlyfile_length/type_body_lengthand inherit everything else from the root.--strictpromotes warnings to failures, so the tree must stay swiftlint-clean (zero findings).
The app must build, swift test and make test-app must stay green, and make lint must pass after every change.
-
Manage file sizes for real — source files stay under 1000 lines, tests under a hard 2000 (= 2×). In OUR OWN work: when you touch a long file, PROPOSE splitting/relocating it toward that rather than growing it further — but ALWAYS ask the user first, never restructure a file unprompted; and don't reflexively bump the swiftlint
file_length/type_body_lengthlimits to fit new code. For a CONTRIBUTOR's PR: do NOT force this — a contributor shouldn't have to refactor a pre-existing long file to land their change; NOTIFY that a file is getting long and SUGGEST keeping it under 1000, but never make them address the line count or block the PR on it. And when REVIEWING a contributor's PR, never suggest the contributor RAISE afile_length/type_body_length(or any lint) limit to fit their change — bumping a size limit is a maintainer decision, so at most note the file is getting long, never offer the limit bump as the fix. -
Feature development and any non-trivial fix/change SHOULD go through an ISOLATED git worktree, cleaned up after merge. A trivial one-file edit can stay on the main checkout; anything larger gets its own worktree so a build/test/dev-launch iteration never disturbs the deployed daily driver or the main checkout's branch. Create it with Claude Code's NATIVE support ("work in a worktree" runs
EnterWorktree, orclaude --worktree <name>), never a manualgit worktree add(global rule), then do the artifact SYMLINK setup in the next bullet BEFORE building (a fresh worktree lacks the gitignoredGhosttyKit.xcframework/agterm/Resources/{ghostty,terminfo}). Before creating the worktree,git fetch origin masterso it forks the CURRENT remote tip, not a stale localorigin/master.EnterWorktree'sfreshbase is the LOCALorigin/masterref, which goes stale when the remote advances during a long session; forking from a stale base means the PR conflicts at merge time against whatever landed meanwhile (this bit once — a worktree forked 5 commits behind and had to rebase-and-resolve against a merged PR that touched the samesession.newcommand). AFTER the PR merges, remove the worktree (git worktree remove --force <wt>, orExitWorktree), which drops the worktree + its symlinks without touching the main repo's artifacts. Cleanup must NEVER switch the main checkout's branch (the user may be working there in another window), andgh pr merge --delete-branchrun FROM the worktree switches it, so merge / branch-delete from the main checkout, not the worktree. A squash (or rebase) merge makesExitWorktreeremove(and a manualgit worktree remove) REFUSE with "N commits will be discarded permanently" — git can't recognize the worktree branch as merged, because the squash rewrote its commits into one new commit onmaster. This is the SAME snag every worktree cleanup after a squash merge; it is expected, not a failure. Once you have VERIFIED the squash landed onorigin/master(its tip is the PR's merge commit — check withgh pr view <n> --json state,mergeCommit+git fetch origin master), re-invokeExitWorktreewithdiscard_changes: true: the branch work is safe inside the squash, so discarding the loose commits loses nothing. Second gotcha:ExitWorktree's message and itsremovereference the ORIGINAL branch name (worktree-<name>) even after you renamed the branch to<name>, so the renamed local<name>branch SURVIVES the worktree removal and must be deleted separately withgit branch -D <name>from the main checkout (and GitHub's auto-delete-head-branch may already have removed the remote one — confirm withgit ls-remote --heads origin <name>before anygit push origin --delete). -
Working in a git WORKTREE: SYMLINK the prebuilt artifacts, don't re-run setup. A fresh
git worktreedoes NOT contain the gitignoredGhosttyKit.xcframework,agterm/Resources/ghostty, oragterm/Resources/terminfo(they're build outputs, never committed). Runningscripts/setup.shthere would REBUILD libghostty from upstream (a few minutes + the Metal Toolchain). Instead symlink all three from the main worktree, thensetup.sh's present-check skips the rebuild (printsGhosttyKit and resources already present):ln -s <main>/GhosttyKit.xcframework GhosttyKit.xcframeworkandln -s <main>/agterm/Resources/{ghostty,terminfo}into the worktree'sagterm/Resources/. Use ABSOLUTE targets for the two Resources symlinks — the relative depth from<wt>/agterm/Resources/is easy to miscount (../../lands inside the worktree, not the sibling). The symlinks show as untracked (??) in the worktree and never need committing;git worktree remove --force <wt>removes the worktree + its symlinks WITHOUT touching the symlink targets in the main repo. Build with the samexcodegen generate+xcodebuild … -derivedDataPath build/DerivedDataas usual. -
Debug build code lives in
agterm.debug.dylib, NOT the mainagtermexecutable. Xcode Debug builds emit the Swift code (and its string literals) into a sibling…/Contents/MacOS/agterm.debug.dylib; the mainagtermbinary is a thin stub. So to verify that an edit/instrumentation actually compiled into the running app,grep -athe dylib (or the per-file…/Objects-normal/arm64/<File>.o), not the main executable — greppingagtermfor a string you added will falsely come back empty. -
For launch-time value capture, write to a temp FILE, not
NSLog.NSLogfrom a dev build launched viaopen -ndoes NOT reliably reach the unified log (log showshowed only thelog showcommand's own echoes, never the app's lines, even with the string confirmed present in the dylib and a window on screen). A tiny file appender (FileHandleappend to/tmp/<tag>.log) is bulletproof and independent of unified-log capture — the reliable way to read what a value resolved to atinit/ first view render. (os.Loggerwith a subsystem is the production channel; this is just for throwaway investigation.) -
run.shre-activates a stale instance.scripts/run.shends inopen agterm.appwith no kill, so if an instance is already running macOS just brings it to front — the freshly built binary is NOT loaded. To actually test a rebuild, fully quit the running app first (thenopen, or launch…/Debug/agterm.appdirectly /open -n); otherwise visual verification runs against the old build and a real fix looks like it failed. -
A
make deploy'd copy in~/ApplicationsSHADOWS the dev build — for the CLI, the hooks, AND the app. This machine has agterm installed viamake deploy(Release →~/Applications/agterm.app), and the Help-menu installers run off that copy point/usr/local/bin/agtermctland the agent-status hooks' bakedAGTERMCTL(in~/.config/agterm/agent-status/agterm-agent-status.sh) at it. So once deployed, a bareagtermctl …on PATH, the agent-status hooks, AND a plain launch/activate (LaunchServices resolvescom.umputun.agtermto~/Applications) all hit the DEPLOYED build — NOT whatever you just rebuilt intobuild/DerivedData. When iterating onagtermctlor the hook scripts, the change is therefore NOT exercised by the PATH CLI / the hooks until you either (a) invoke the fresh binary by full path —build/DerivedData/Build/Products/Debug/agterm.app/Contents/MacOS/agtermctl …(orexport AGTERMCTL=to it for the hooks), or (b)make deployagain and re-run Help ▸ Install Command Line Tool… + Install Agent Status Hooks… to re-point PATH and the hooks at the new build. For APP-code changes, do NOT quit the deployed app (see the next note — it is the user's live daily driver); Debug builds carry a DISTINCT bundle id (com.umputun.agterm.debug, project.yml per-config) so they run as a SEPARATE instance alongside the deployed Release. The XCUITests no longer collide either: the.debugbundle id means XCUITest's launch-time terminate hits only the.debuginstance, not the deployedcom.umputun.agterm, and they still use an isolatedAGTERM_STATE_DIR/socket. -
NEVER run a MUTATING
agtermctlcommand against the DEFAULT socket — that socket is the user's LIVE daily driver. The kill/relaunch ban below is only part of the rule: a bareagtermctl window move|resize|minimize|new,session new|close|type,workspace …reaches the DEPLOYED app, becauseagtermctlon PATH is the deployed copy and its socket auto-resolves to~/Library/Application Support/agterm/agterm.sock. This has already cost real damage: a review subagent "checked" the bundledskills/agterm/examples.mdwindow-stacking recipe by RUNNING it and restacked all four of the user's live windows onto one frame. Read-only probes (tree,window list) are tolerable; anything that WRITES must target an isolated instance with an explicit--socket <tmp>/agterm.sock. Never "test" a bundled recipe or doc example by executing it — read it statically, or run it against an isolated instance. -
Every DISPATCHED SUBAGENT must carry that constraint VERBATIM in its prompt. A subagent inherits the same PATH and socket default, so it reaches the daily driver exactly as easily as you do, and it never reads this file unless told to. Any agent prompt that could plausibly lead to running the app or its CLI must state: never execute
agterm/agtermctlagainst the default socket, never launch or quit the app, static reading only. -
NEVER kill or relaunch the deployed
~/Applications/agterm.app— it is the user's REAL, in-use daily terminal with LIVE sessions. BANNED:pkill agterm/pkill -x agterm,osascript -e 'tell application "agterm" to quit', and ANY quit-then-relaunch of the deployed app (including the quit+openaftermake deploy). Aftermake deploythe Release build is COPIED into~/Applications, but the RUNNING instance keeps the old code until the USER relaunches it on their own schedule (so their live sessions survive) — just report it's installed; do NOT relaunch it. For dev-build UI acceptance / socket probes, open a SEPARATE ISOLATED instance that coexists with the deployed app:open -n --env AGTERM_STATE_DIR=<tmp> --env AGTERM_CONTROL_SOCKET=<tmp>/agterm.sock build/DerivedData/Build/Products/Debug/agterm.app(verified: launches a second instance with the deployed app untouched and its socket not stolen). Probe via the temp socket (agtermctl tree --socket <tmp>/agterm.sock—--socketis a per-subcommand option, so it goes AFTER the subcommand, never before it) and quit ONLY that instance BY PID (kill <pid>, neverpkill). Usekill <pid>(SIGTERM), NOT a clean quit (osascript … to quit/ ⌘Q), to tear down a dev instance mid-experiment — a clean quit pops the "Quit Agterm?" confirmation modal on the USER's screen and interrupts them.AppDelegate.applicationShouldTerminateshows that alert whenever a window is open (skipped only under XCUITest or with nothing open), andosascript … to quitroutes through it exactly like ⌘Q;kill <pid>bypassesapplicationShouldTerminate, so no dialog. For a RESTORE test this loses nothing: the session tree is persisted INCREMENTALLY (windows/<id>.jsonis written on session creation, BEFORE any quit), so a SIGTERM-killed instance still restores its sessions on relaunch — the clean-quit flush (applicationWillTerminate→library.saveAllOpen) only adds cwd-changes-since-the-last-structural-mutation and restore-running-command capture. Do a clean quit ONLY when the experiment specifically needs that final flush. The Debug bundle id (com.umputun.agterm.debug) makes the dev/test build a distinct LaunchServices identity from the deployedcom.umputun.agterm, which is what lets XCUITest (it terminates the app-under-test's bundle-id instance on launch) run WITHOUT killing the deployed app — verified, the e2e leaves it alive. But state + socket are PATH-based (NOT bundle-id-derived, both default to~/Library/Application Support/agterm/), so theAGTERM_STATE_DIR/AGTERM_CONTROL_SOCKETenv overrides are STILL required for a manual dev launch even with the distinct id — otherwise the dev instance reads/writes the user's realworkspaces.jsonAND steals the deployed app's socket (itsstart()unlinks-then-binds the default path). The socket override must be a SHORT path (unix sockets cap the path at ~104 bytes): a long temp dir (e.g. a Claude session scratchpad) fails withsocket path too longand the control server never binds, while the app itself launches fine — so keep the state dir wherever, but point the socket at something like/tmp/<name>.sock. -
Anchor path/existence checks at an ABSOLUTE repo-root path — the Bash cwd DRIFTS. The shell working directory persists across tool calls and silently drifts (e.g. a
cd agtermCoreforswift testleaves you there), so a later relativefind .github/ls/cdruns from the WRONG place and returns a FALSE negative. NEVER assert "file/dir X doesn't exist" from a relativefind/ls— confirm with an absolute path (/Users/umputun/dev.umputun/agterm/...) orgit -C <root> ls-files, especially before claiming infra facts (CI presence, config files). The repo root vs theagtermCoreSwiftPM subpackage makes root-vs-subdir confusion easy; verify the directory, don't trust a negative relative result. -
CI and release mechanics live in path-scoped rules —
.claude/rules/ci.md(scoped to.github/workflows/**) for theci.ymljob graph (test→coverageuploading to Coveralls on Linux, theSF:-path rewrite,lint,build), and.claude/rules/release.md(scoped toscripts/release.sh) for the LOCAL, maintainer-only sign/notarize/staple + Homebrew-cask flow (there is NOrelease.yml— release is NOT CI). Read the matching rule before touching CI or the release script. One guardrail stays in this root file because it binds during FEATURE work, when no CI/release file is open to trigger those rules:CHANGELOG.mdis RELEASE-ONLY — never touch it in a feature PR. It is written only at release time (thedocs: update changelog for vX.Y.Zcommit / the release flow). A feature's own doc updates go toREADME.md, the bundledplugins/agterm/skills/agterm/, and the relevant.claude/rules/*.mdnote — not the changelog.
- Source: built from upstream
ghostty-org/ghosttysource byscripts/setup.sh, pinned to theGHOSTTY_REVSHA (zig build -Demit-xcframework=true -Dxcframework-target=native …with zig 0.15.2). Self-owned: the only inputs are upstream ghostty at a pinned commit and the zig/Metal toolchains — no third-party fork, no daily-build release that can be pruned. BumpGHOSTTY_REVdeliberately when adopting a newer libghostty. - The pin is a pre-regression commit on purpose.
A libghostty
mainrenderer regression introduced after4dcb09ada(2026-04-30) blanks the scrollback on a font-size increase (decrease is fine); it is NOT an agterm bug and no app-side change fixes it. Every thdxg/ghostty daily build (which agterm used to download) has it. Re-test the font-increase case before bumping past it. setup.shstages the freshly-builtmacos/GhosttyKit.xcframeworkpluszig-out/share/{ghostty,terminfo}resources. The xcframework,agterm/Resources/ghostty, andagterm/Resources/terminfoare gitignored and never committed.- The xcframework is linked with
embed: falseinproject.yml. Never embed it; embedding breaks the signature on non-Developer-ID builds.
agtermCoremust not import GhosttyKit, AppKit, or Metal. Keeping it host-free is what letsswift testrun with no app host. Model, persistence, and naming logic go here; the surface contract is theTerminalSurfaceprotocol, which the app target'sGhosttySurfaceViewconforms to.- The app target owns all SwiftUI and libghostty code.
- Also keep
agtermCoreCoreGraphics-free — noCGSize/CGPoint/CGRect/CGFloat. They're Foundation-reachable on Darwin and compile +swift testfine, but a CoreGraphics member reference (e.g.CGSize.width) in a Foundation-only module serializes as an unresolvable cross-reference that crashes the app target's release whole-module-optimizer SIL deserializer (*** DESERIALIZATION FAILURE *** Cross-reference to module 'CoreFoundation', Xcode 26.5) — so it passes Debug + tests but breaksmake release/make deploy. Use plainDouble-backed structs inagtermCore(seeWindowGeometry.Size/Point/Rect) and convert to/from CG at the app-target call site. Treat CoreGraphics geometry types as if they were on the banned list above. - Hoist host-free logic DOWN into
agtermCore; keep the app target a thin side-effect adapter. The sustained refactor direction (therefactor/hoistPR series, #78 onward) moves command validation, argument parsing, dispatch routing, response shaping, and static catalogs OUT of the app target INTOagtermCore, soswift testexercises them with no app host. For the control channel this is theControlDispatcher+ControlActionsseam (agtermCore/Sources/agtermCore/ControlDispatcher.swift):dispatch(_:)owns parsing + validation + response shape, and the app-targetControlServerconforms toControlActionssupplying ONLY target resolution and AppKit/process side effects. Commands are migrated group-by-group; a command the dispatcher doesn't yet own returnsniland falls through toControlServer's existing switch. The same "logic host-free, side effects app-side" split already governs the installers (CLIInstall/AgentHooksInstall/SkillInstall), the status sound (AgentStatus.effectiveSound), and the watermark (WatermarkConfig). When adding a feature, ask which parts are host-free and put those inagtermCoreby default — see the dispatcher-first rule in.claude/rules/control-api.mdfor the control-command case.
GhosttyCallbacksis@unchecked Sendable, not@MainActor. C closures capture nothing and reach Swift viaGhosttyApp.shared.- Copy any
char*into a SwiftStringbefore hopping; every@MainActortouch goes throughDispatchQueue.main.async. - Rendering is demand-driven, no poll timer.
GhosttyCallbacks.wakeupcoalesces libghostty wakeups into oneDispatchQueue.main.asyncghostty_app_tick(anOSAllocatedUnfairLockflag dedupes the wakeup storm), andGHOSTTY_ACTION_RENDERdraws the surface viarenderNow()(ghostty_surface_draw). Mirrors Ghostty.app/conterm — an idle terminal does no work (the old 120Hz poll ticked continuously). The C callbacks never useassumeIsolated; every@MainActortouch hops throughDispatchQueue.main.async. close_surface_cbonly recovers the view and dispatches to the main actor; it never frees synchronously.
These cross-subsystem contracts apply when editing ANY feature, not just the files that own them. They are restated in detail in the relevant path-scoped rules, but the principle lives here so it is always in context:
- A new user action is not "done" until it is drivable from the control socket. Any action added
to
AppActions/AppStorerequires all four: (1) aCommandcase (+ args) inagtermCore'sControlProtocol, (2) a dispatch arm inControlServer, (3) anagtermctlsubcommand, (4) protocol round-trip + end-to-end tests. The toolbar/bottom-bar, the menu bar, and the control channel are three callers of the SAMEAppActions/AppStoreseam and must never drift. (The Working-norms bullet above generalizes this to control-native features with no GUI surface.) Genuinely meaningless exposure (pure visual chrome with nothing to drive — quit-confirm, CLI/skill installers, click-routingreveal) is the only exemption, and must be called out as such. - A command that WRITES or sets session state owes a matching READ-BACK on the
treenode. The four-point audit above covers only the WRITE path. A command that mutates or sets per-session state must ALSO surface that state onControlSessionNode(or the tree top-level), so a script can query what it just changed: record-then-restore, read-modify-write, and idempotency checks all need the read leg. Every state-mutating command pairs with a read field:session.background/background,notify+session.seen/unseen,session.status/status+statusPane(+statusBlink/statusColor/statusShapefor--blink/--color/--shape),session.flag/flagged,session.focus/splitFocused,session.resize/splitRatio,session.overlay.resize/overlaySizePercent,sidebar/sidebarVisible,sidebar.mode/sidebarMode,workspace.focus/focused,quick/quickVisible,window.move+window.resize/geometry,window.fullscreen+window.zoom/fullscreen+zoomed,window.minimize/minimized. When adding a state-mutating command, ask "how does a script read back what I just set?" and add that field in the SAME change.session.overlay.resizeshipped write-only and theoverlaySizePercentread-back was missed until a tmux-zoom script needed record-then-restore, so it went in as a separate follow-up. - An argument whose value rides a control EVENT owes the CLI's human line too, not just the payload
field.
When you audit the legs of a per-call argument, count
EventFormatter.human(agtermctlKit/EventCommands.swift) as one of them: a value added toControlEventPayloadwith no matching arm there is silently dropped fromagtermctl eventsin its default, non---jsonmode, so the human reader of the stream cannot explain a change the payload does describe.session.status --shapewas tracked as five legs and turned out to have six — the formatter was found only during the final acceptance sweep, after every other leg was already in. - The bundled agent skill is the fourth keep-in-sync surface.
Whenever you change the Control API (commands/args/returns), the keymap format,
or the window/workspace/session/pane model, update
plugins/agterm/skills/agterm/(SKILL.md + reference.md- examples.md + troubleshooting.md + scripts, incl. the command count) so the installed agent-driver
doc stays accurate.
The app-repo
plugins/agterm/skills/agterm/is the SINGLE source of truth — edit ONLY there. NEVER edit, copy into, or "mirror" the installed copies at~/.claude/skills/agterm/or~/.codex/skills/agterm/; they are install OUTPUTS that Help ▸ Install Agent Skill (SkillInstaller) regenerates from the bundle, so a manual edit there is wrong and must never even be offered (~/.claude/skills/agterm/is snapshotted in the dot-files repo, but that does not make it a source).
- examples.md + troubleshooting.md + scripts, incl. the command count) so the installed agent-driver
doc stays accurate.
The app-repo
- The website (
site/) is the fifth keep-in-sync surface.site/docs.htmlis a hand-authored mirror ofREADME.md— when you add features, flags, keybindings, or modes, update both.site/index.html(the features grid and install copy) and thesoftwareVersionin itsSoftwareApplicationJSON-LD must reflect major features and the latest release.site/commands.htmlis the per-commandagtermctlcontrol reference — it documents the FULL control-command catalog, each entry carrying the invocation, arguments, and thetreeread-back field. It MUST gain, lose, or update an entry for EVERY control-command add/change/remove, in lockstep with the agent skill and.claude/rules/control-api.md(a newCommandcase owes a new commands.html entry). See the## Websitesection below for the deploy model. - The cookbook (
cookbook/) is deliberately NOT a keep-in-sync surface — do not add it as a sixth. It is full ofagtermctlinvocations, so the reflex on a control-API change is to sweep it like the skill and the site; that reflex is wrong here, and the exemption is a decision, not an oversight. Recipes are pinned snapshots: each one's Requirements names the MINIMUM agterm version it needs, and that pin is the contract. A recipe is fixed reactively — when someone reports it broke — and dropped if it stays broken and nobody claims it. So a control-API change carries NO obligation to sweepcookbook/, and a PR is not incomplete for leaving it alone. The control API grows by addition, so real breakage is rare, and an honest reactive contract beats a per-feature tax that would quietly stop being paid. ThecookbookCI job (.claude/rules/ci.md) checks layout, index, headings and shell hygiene only; nothing checks a recipe against the current command surface.
agterm.com is a hand-authored static site in site/, deployed via Cloudflare Pages with no build step
(the revdiff pattern).
Cloudflare's Git integration auto-deploys site/ on every push to master; there is no wrangler config
and no deploy workflow in the repo.
All Cloudflare wiring — the Pages project, the agterm.com custom domain, and the output directory (site)
— lives in the Cloudflare dashboard, not in git, so it is not reproducible from the repo.
Cloudflare Pages strips .html and 308-redirects /docs.html to /docs, so every canonical link,
og:url, and sitemap.xml entry uses the extensionless https://agterm.com/docs.
The site is lean and self-contained — nothing is embedded.
site/style.css holds the reset, keyframes, @font-face, and the hover classes;
the two pages keep the design's inline styles.
Assets are self-hosted under site/assets/: latin woff2 fonts in site/assets/fonts/,
screenshots as webp, plus a generated 1200×630 agterm-og.png social card and favicons.
The pages were converted from a design-tool bundle export whose source zip lives on the maintainer's
Desktop, not in the repo, so a visual redesign means re-exporting the design and re-running that conversion.
Two recurring screenshot/layout gotchas when editing the pages.
The hero carousel (.hero-gallery in site/index.html) is a FIXED-aspect crop box:
aspect-ratio: 1187 / 696 (≈1.70:1) with object-fit: cover (site/style.css),
so a screenshot added to it must be captured at ~1.70:1 (existing shots are 2374×1392) or cover crops its
top and bottom.
A dense multi-pane shot (the dashboard's grid) is taller by nature — resize the agterm window to ~1.70:1
BEFORE capturing rather than letting the crop eat rows, and expect its webp to floor around ~200k
(vs the 85–172k of single-window shots), so tune cwebp -q/-m 6 for size, not the usual quality.
Adding a slide also means re-timing the crossfade in style.css:
the loop duration is slides × 5s, each image needs its own nth-child(N) animation-delay on the 5s
stagger, and the heroGallery/heroGalleryFirst keyframe plateau (the opacity: 1 hold) is ≈1/slides of
the cycle — leave the old percentages and adjacent slides double-expose.
Feature-card grids that use repeat(auto-fit, minmax(…)) strand an ORPHAN card on the last row when the
card count does not divide evenly into the resolved column count (five surface cards rendered 4 + 1, the
Dashboard card stranded alone).
For an even row, switch that block to a fixed grid-template-columns: repeat(N, minmax(0, 1fr)) and give the
wide/odd item a grid-column: span M, with @media fallbacks for narrow widths — see .surfaces-grid,
where row 1 holds the four surface cards and row 2 pairs the Dashboard card with the Windows callout spanning
three columns.
Detailed per-subsystem engineering notes live in .claude/rules/*.md, each scoped with paths: frontmatter
so it loads into context only when you read a matching source file — that is what keeps this root file
lean.
When starting work on a subsystem, read its rule first: the auto-trigger covers the subsystem-owned files,
but a cross-cutting edit that touches only a hub file (AppStore.swift,
ContentView.swift, agtermApp.swift) may not match a glob, so consult this index and open the rule
yourself.
When writing or editing these notes — this file and .claude/rules/*.md — use semantic line breaks: one sentence per line, never a giant single-line bullet.
Break after every sentence (split a long sentence further at clause boundaries, around 100 columns), keep inline-code spans intact, and render any long enumeration (e.g. a command catalog) as a real markdown list rather than one inline run.
This only changes raw-text line breaks — the rendered markdown is identical — but it keeps a diff scoped to the sentence that changed and stops two branches that edit the same note from conflicting on the whole paragraph.
sidebar.md—NSOutlineViewsidebar: drag-reorder (sessions + workspaces), flagged working-set view, focus filter, scoped session nav, reconcile signal, persistence. Plus the Linux GTK sidebar: the label sizing contract (user text ellipsizes, fixed instructional text wraps, hugging trailing glyphs get nothing), the one derivedAppController.sidebarWidthFloorthat replaced the two disagreeing width minimums, and the drag-and-drop/selection contract — no claiming click gesture on a row carrying aGtkDragSource, selection mode NONE withagterm-selectedas the single paint path, passive + non-focusable rows (hover CSS keyed on bare:hover), and the y-midpointdropInsertionSlotconvention feeding the sharedSidebarDropmath. Triggers onWorkspaceSidebar.swift,SidebarDrop/SidebarMode/Reorder, the sidebar/reorder/flagged/focus UI tests, and the LinuxAppControllerSidebar*.swift/AppControllerCallbacks.swift/LinuxSidebarPolicy.swift(+LinuxPolicyTests). Read it too when touching the sidebar scroller in the hubagterm-linux/…/AppController.swift(no secondsize_requestthere, ever), the sidebar scenarios inagterm-linux/tests/atspi_smoke.py, or the sidebar row-hover CSS installation ininstallAppCSS(agterm-linux/Sources/AgtermLinux/App.swift) — none of those files matches a rule glob (the hover RULE itself lives in the globbedLinuxSidebarPolicy.sidebarHoverCSS).menu-actions.md— theAppActionsseam: View vs Navigate menu split, split panes (one session two shells), session navigation, command palettes, Ctrl-Tab MRU switcher, inline rename, font/in-terminal-search. Triggers onAppActions.swift,agtermApp.swift,Palette/PaneShortcuts/SessionSwitcher,RecencyStack/Fuzzy, and the menu/palette/nav/switcher/split UI tests.windows.md— multi-window model:WindowLibrary, scene + claim-queue restoration, per-window quick terminal, frontmost-store resolution + quit-flush, quit confirmation,window.*control. Triggers onWindowLibrary/WindowGeometry/QuitPrompt,QuickTerminal.swift, and the multi-window/quick-terminal UI tests.control-api.md— the full control-command catalog, the three protocol layers, addressing, and the CLI/hooks/skill installers. Triggers onControlServer.swift,ControlProtocol/ControlResolve,agtermctlKit/agtermctl, the three installers + their host-free*Installlogic,plugins/agterm/skills/agterm/, and the control UI tests.settings.md—AppSettings/SettingsModel, the 6-tab Settings scene, ghostty-config emission, window translucency. Triggers onSettingsModel.swift,SettingsView/SettingsCatalog/WindowAppearance/NSColor+AgtermHex,AppSettings/SettingsStore, and the settings UI tests.theme-picker.md— the live-preview theme palette mode, preview/commit/cancel, the seeded default theme. Triggers onPalette.swift,SettingsModel/SettingsCatalog,AppActions.swift.keymap.md— the kitty-flavoredkeymap.confparser, built-in-override resolution, custom-command monitor,{AGT_X}tokens, reload + Edit Keymap. Triggers onKeybind/KeybindMatcher/Keymap/BuiltinAction/CustomCommand/ConfigPaths,CustomCommandRunner.swift, the LinuxKeymapDispatch/LinuxKeyboardPolicysources plus the reload seam (WindowManager) and its call sites (Palette/SettingsKeyMappingPage/LinuxSettingsController/ControlActions+AppController), plusAppControllerSurfacesfor the pending Edit-Keymap reload, and the keymap UI +LinuxKeymapTeststests.notifications.md— terminal OSC 9/777 + controlnotify, suppression, click-to-reveal identity, the unseen badge, the agent-status glyph. Triggers onNotificationManager.swift,Notifications/AgentStatus.ui-tests.md— XCUITest patterns:launchForUITest(FB11763863), Settings-tab retry helper, driving anNSOutlineViewdrag, the occlusion-timeout symptom, test cadence. Triggers on anyagtermUITests/file.libghostty.md— rendering / AppKit gotchas: the eager-deck surface lifecycle, the NSSplitView-titlebar-overrun rule, cursor focus, theme-tracking chrome colors, search-bar / overlay placement, reparent repaint. Triggers on theGhostty/surfaces,ContentView.swift,TerminalView/TerminalSearchBar.app-icon.md— the adaptive Icon Composer.iconbuild rules. Triggers onAppIcon.icon/,project.yml.main-loop.md— the GTK/Linux main-loop contract: deferred main-actor work goes throughagtermCore'sMainTimerseam (GLib drains neither the dispatch main queue nor the main-actor executor),runOnMainfor hops, and thewithFakeMainTimertest rule. It also owns the Linux KEYBOARD-FOCUS-OWNERSHIP contract, whose failure mode is a silently keyboard-dead window rather than an error: every chrome construction seam setsfocus-on-click = 0; a path that unmaps or destroys a focused widget owesrefocusIfStranded(); a popover dismissal owesdetachPopover; and a mode change hands the keyboard back throughfocusActiveSurface(), nevershowActive()'s deck-only focus leg. Triggers onMainTimer/Debouncer/AppStore*/WindowLibrary.swiftin core and anyagterm-linux/Sources/AgtermLinux/file.linux-surface-sizing.md— surface sizing at realize time on the GTK/Linux port: thepushSize()fallback precedence for a surface force-realized before its first layout pass, the per-pair credibility rule, the floating-overlaysizeFallbackand its measure-before-set_size_request/ measure-before-set_visible(0)orderings, and thesurfaces[].rows/colsread-back. Triggers onGhosttySurface/GhosttySurfaceGeometry/AppControllerSurfaces.swift, the geometry unit tests, and thebackground-overlay-gridAT-SPI scenario.ci.md— theci.ymljob graph: thetest/coverage/lint/buildsplit, coverage → Coveralls-on-Linux with theSF:-path rewrite, the paths-filter, and the badge scope. Triggers on.github/workflows/**.release.md— the LOCAL, maintainer-onlyscripts/release.shflow: sign/notarize/staple the app + DMG, tag + GitHub release, Homebrew-cask push, the release-time changelog draft-approval. Triggers onscripts/release.sh.