我给自己写了一个 macOS 客户端名字叫 lmux取的是 LLM multiplexer 的意思专门解决 AI 编码工作流里“会话太多、上下文太散、状态经常丢”的问题。它不是 tmux 的替代品也不是什么万能 AI IDE而是把我每天都在用的那些 AI 编码工具比如 Claude Code和终端会话管理做了深度整合。这篇博文把整个设计过程和踩坑记录完整写下来包括架构决策、PTY 处理、会话持久化、macOS 权限坑、渲染性能优化以及和 Claude Code 这类工具的集成方式。1. 我为什么会给自己写一个 lmuxAI 编码工作流的真实痛点1.1 七个终端 Tab、四个 tmux 会话我彻底失控了先说一个前阵子把我逼疯的场景。同时手动开了 7 个终端 Tab跑着 4 个 tmux 会话里面还穿插着tail -f的日志输出。这些会话分别对应不同模块的 AI 编码任务一个在改数据库迁移脚本一个在调 API 签名一个在帮我看 Cypress 测试失败的原因还有一个在把老的后端服务从 Python 2 迁移到 Python 3。到了晚上我盯着屏幕上的一堆输出花了十分钟才找到中午让 AI 改的那个文件到底在哪个会话里跑的。那一刻我发现真正的瓶颈根本不是 AI 生成代码的质量而是我作为人类已经完全管不住这些正在并行的 AI 编码会话了。你可能会说这是个人时间管理问题。但我的判断正好相反如果一个工具不能让使用者一眼看清“现在有哪些任务各自在做什么哪个还在跑”那就说明工具该自己优化了不该靠使用者的意志力硬扛。1.2 lmux 要解决的四个核心问题从这次的失控经历出发我给 lmux 定了几条硬性目标每个 AI 编码任务是一个独立会话会话里自带终端、工作目录、项目路径和日志文件。会话上下文要持久化今天开的会话明天重启电脑后还能恢复包括当前目录、环境变量和 AI 工具状态。会话之间可以快速切换纯键盘完成常用操作不要每次切过去都要摸鼠标。所有输出可搜索命令输出、AI 回复、日志全部落盘用时候直接搜而不是翻终端历史。这四个目标看似简单真正做起来后发现macOS 上没有一个现成客户端能同时满足。tmux 只解决会话复用iTerm2 和 Terminal.app 只是终端Claude Code 这类 AI 工具虽然有交互界面但它不管理你的项目级并行任务。所以我才决定自己动手把终端、会话管理、AI 工具配置这三件事做成一个东西。2. 架构决策为什么是 SwiftUI PTY而不是 Electron / Tauri / tmux 套壳2.1 为什么不直接拿 tmux 当底座这个问题的答案其实很直接tmux 不懂“AI 编码会话”这个抽象概念。tmux 里的窗口和面板本质上是匿名终端进程的排列组合。我可以给每个窗口改名但那只是个显示名。我想要的是会话知道自己的项目路径知道它正在用的 AI 配置文件名知道当前分支和最近改动的文件列表甚至能在会话重启后自动重新拉起 AI 工具。这些“业务元数据”如果全用 tmux 的前缀命令和脚本去维护就变成了一套别扭的补丁体系。我越用越觉得自己在给 tmux 写一个不存在的类型系统。所以 lmux 的起点是会话模型先于终端模型存在。每个会话是一个有身份、有路径、有状态记录的实体终端只是它最底层的执行器。tmux 解决不了这个问题因为它的核心抽象本来就不是干这个的。2.2 技术栈选型过程我在确定技术栈前把几个主流的客户端方案都过了一遍最终结论如下表方案内存占用终端渲染能力与 macOS 系统能力集成我的选择Electron启动后常驻 80MB-120MB可以用 xterm.js还算成熟需要自己桥接原生 API权限管理穿透力差没有采用Tauri相对低WebKit 渲染终端渲染依然靠 JS 库滚动长日志时 CPU 压力明显比 Electron 好但复杂系统 API 还是得写原生插件没有采用SwiftUI 原生 NSTextView低常驻 20MB 以内直接处理 ANSI 序列和文本渲染可控性最高Keychain、Security-scoped bookmark、后台任务全部原生可用最终选择选 SwiftUI 不是因为它时髦而是因为 lmux 需要大量触碰 macOS 的系统级能力钥匙串密钥管理、目录访问授权、沙盒权限恢复、系统休眠通知。这些功能在 Electron 里全部要绕一圈而在 SwiftUI 里是天然支持的接口。另外SwiftUI 对纯终端渲染并不算快这里我后面会展开说最终用NSViewRepresentable接入了 NSTextView 来做长文本渲染这是从“能跑”到“不卡”的关键一步。2.3 核心层PTY 处理的细节lmux 的主程序虽然是一个 SwiftUI App但每个会话背后都对应一个真正的 PTY伪终端进程不是普通管道。为什么要用 PTY 而不是直接Process 管道因为 Claude Code 这类 AI 编码工具是交互式 CLI它们需要感知 TTY 才能正确输出颜色、处理回车、显示进度动画。如果走普通管道很多工具会直接退化成非交互模式颜色和动态输出全部丢失有些甚至会直接报错。创建 PTY 的底层实现用的是 macOS 自带的能力import Darwin var masterFD: Int32 0 var slaveFD: Int32 0 openpty(masterFD, slaveFD, nil, nil, nil) // 这一步非常关键不设置的话会出现多路读写阻塞 fcntl(masterFD, F_SETFL, O_NONBLOCK) fcntl(slaveFD, F_SETFL, O_NONBLOCK)openpty会返回一对文件描述符master 端由 lmux 进程持有做读写slave 端作为子进程的 stdin/stdout/stderr。然后我用posix_spawn在新的进程中挂载 slave 端并注入目标程序比如claude或bash。要注意的是 macOS 上forkpty虽然存在但它是在当前进程直接 fork对 Swift 应用来说有潜在风险。我更倾向于用posix_spawn加login_tty来把 slave 端绑定为控制终端。核心代码大概是这样var attrs: posix_spawnattr_t? nil posix_spawnattr_init(attrs) let fileActions: posix_spawn_file_actions_t? nil posix_spawn_file_actions_init(fileActions) // 将 slaveFD 映射到子进程的 0、1、2 posix_spawn_file_actions_adddup2(fileActions, slaveFD, STDIN_FILENO) posix_spawn_file_actions_adddup2(fileActions, slaveFD, STDOUT_FILENO) posix_spawn_file_actions_adddup2(fileActions, slaveFD, STDERR_FILENO) posix_spawn_file_actions_addclose(fileActions, masterFD) // login_tty 负责设置会话首进程和控制终端 // 通过 posix_spawnattr_setflags 传入 POSIX_SPAWN_SETEXEC 等 var pid: pid_t 0 posix_spawn(pid, path, fileActions, attrs, argv, environ)这一步我有过深刻教训。最初我以为只要把 slaveFD 复制到子进程的 0/1/2 就够了结果很多交互式程序不认为自己在 TTY 里因为它们没有调用setsid。在 macOS 上只有login_tty才能正确设置控制终端如果你绕过了这个Claude Code 启动后第一句话就能看到它感知不到 TTYUI 完全乱掉。3. 会话持久化与状态恢复重装系统后不丢上下文3.1 从“进程活着才叫会话”到“进程没了会话还在”我用过的终端管理器都有一个通病会话的生命周期和进程绑定死了。进程退出会话就没了。进程活着但电脑重启会话照样没了。有一次我强行把整个 macOS 系统重新安装了一轮装完才发现之前所有 AI 编码会话的上下文全部归零。不是代码丢了是“我让 AI 之前在分析什么问题、它已经给出了哪些中间结论、我们讨论到哪一步”这些过程信息全丢了。这种感觉非常糟糕。所以 lmux 从一开始就把“会话状态”作为一等公民设计会话定义独立存储放在~/Library/Application Support/lmux/sessions/下。每个会话对应两个文件一个是session.json记录项目路径、工作目录、启动命令、创建时间、最近活动时间另一个是session.log记录这个会话的所有输出。进程只是会话的“实时执行体”进程没了会话依然存在随时可以重新拉起。这句话听起来简单实际操作里麻烦很多。因为很多命令本身是有状态的比如 Claude Code 的会话恢复功能依赖它自己维护的历史记录文件。lmux 不能只重启进程还要在启动时把正确的环境变量和工作目录注入进去这样 AI 工具自己也能恢复上下文。3.2 重启和重装都绕不开的目录访问授权macOS 为了保护用户隐私对应用程序访问某些目录有严格的限制。特别是如果你把 App 放进沙盒里默认情况下连~/Library/Application Support自己的目录都要手动申请。这个限制在开发阶段不明显因为 Xcode 的 Debug 模式经常豁免但一旦你打包成正式版分发用户会告诉你“lmux 完全读不到项目目录”“终端一点权限都没有”。这里就有两个坑第一个坑是沙盒本身的限制。第二个坑是即使用户通过文件选择器授予了权限重启后这个权限也会丢失。macOS 提供的标准解法是 Security-scoped bookmark。简单说就是让用户在文件选择器里选中一个项目目录后lmux 把这个 URL 的授权做进一个 bookmark 数据里并持久化存储。重启后应用通过 bookmark 重新拿到 URL 的访问权不需要用户再授权一次。代码层面是这样的// 用户选择目录后生成 security-scoped bookmark let bookmark try url.bookmarkData( options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil ) // 把 bookmark 写入 lmux 自己的存储 try bookmark.write(to: lmuxBookmarkURL) // 启动时读取并恢复访问权限 var isStale false let resolvedURL try URL( resolvingBookmarkData: Data(contentsOf: lmuxBookmarkURL), options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: isStale ) _ resolvedURL.startAccessingSecurityScopedResource()我最初完全忽略了这个机制开发时一切正常等到做分发测试才发现用户在一台重启后的电脑上打开 lmux所有项目目录都显示“无法访问”。这正是网上很多人抱怨“macOS 终端完全没权限了”的真实来源之一不是系统出了问题是 App 没有正确处理安全作用域书签的续期。加上开发者账户的 sandbox 配置没设好权限问题会表现得极其离谱甚至你单纯地在 Finder 里能看到的目录App 进程内照样访问不到。3.3 待机唤醒后的主动恢复策略另外一个高频场景是MacBook 合盖休眠一夜第二天打开很多应用处于“还活着但状态陈旧”的状态AI 编码会话如果因为网络或者进程被系统杀掉应用不会自动重新拉起它。lmux 的处理方式是在启动时扫描所有会话的状态如果发现哪个会话标记为“需要自动重启”就在主界面里主动弹出恢复按钮。具体是监听NSApplication.didWakeNotification系统休眠唤醒后自动做一轮会话健康检查。这里有几个判断标准会话进程是否退出。当前工作目录是否还存在。会话关联的项目是不是还在原来的路径。如果有 AI 工具上下文路径是否还能读到。如果这几个条件都通过直接自动恢复如果没有完全通过就标记为“需要用户干预”在主界面显示黄色状态列。4. 和 Claude Code 这类 AI 编码工具的整合方式4.1 配置不用翻文件lmux 里直接管理很多 AI 编码工具配置起来并不难难的是“换一台机器就要重新配置一遍”。搜索热词里有个“macOS怎么配 claude”非常典型说明大家普遍对 API 密钥怎么存、环境变量怎么设、模型参数在哪里改都有困惑。lmux 的解决方案是每个会话单独维护 AI 工具配置并在启动时注入到该会话的进程环境里。比如在 lmux 的会话属性面板里我可以给当前会话选择 AI 工具类型默认是 Claude Code然后填写API Key引用系统 Keychain 当中的一个条目而不是明文写在配置文件里。模型名称比如 Claude 的某个长上下文模型。额外环境变量比如你想让这个会话使用某个代理地址不是违规场景就普通公司内网代理可以直接以 KV 形式配好。工作目录默认继承会话本身的项目路径。会话启动时lmux 会把配置转换成一串环境变量注入到 PTY 子进程。实际效果是我再也不需要在.zshrc里满屏搜索 API Key 的 export 语句了每个项目用什么 AI 配置lmux 一目了然。这个设计对多项目并行非常有用项目 A 用模型版本 X项目 B 用模型版本 Y互不干扰。4.2 ContextKit让 AI 工具知道它在改什么项目这是 lmux 里我最得意的设计我给它的代号叫 ContextKit。用 Claude Code 这类工具时它虽然能识别当前目录的 git 信息但面对一个大仓库时它的上下文窗口始终是有限的。lmux 可以帮它把“当前最重要的是什么”整理成一个轻量级状态包在会话启动时注入。ContextKit 做的事情包括读取当前 git 分支、最近 10 条 commit 和未提交的改动文件列表。识别最近活跃的 3-5 个文件也就是 mtime 最新且改动量最大的那几个。生成一个精简摘要格式大概是这样项目mercury-api 分支feature/billing-refactor 未提交改动3 个文件 1. src/billing/charge.go128 -34 2. src/billing/subscription.go45 -12 3. tests/billing_test.go89 -0 最近活跃文件 - src/billing/charge.go - tests/billing_test.go把这个摘要通过环境变量LMUX_CONTEXT传给 Claude Code并同时 printf 到 PTY 里让 AI 工具一启动就知道“我该怎么开始”。我不会夸大这个功能的效果但它确实解决了“每次启动 AI 工具都要重新说明我在哪个项目、改了什么”的重复劳动。ContextKit 不替你把上下文写全只帮你把最客观的项目状态信息整理好剩下的交给 AI 和你自己。4.3 会话日志跨设备漫游搜索热词里有一组关于rclone webdav的内容这其实是我在 lmux 里顺带支持的一个能力把会话日志目录同步到你自己搭的 WebDAV 服务器或 NAS。为什么需要这个因为我工作的机器不止一台有时候我可能同时要管理两台电脑上的 AI 编码会话。lmux 不会用一个中央服务器只提供一个日志导出机制每次会话结束或每 10 分钟增量把session.log和session.json同步到远端。这样即使换了一台电脑只要 lmux 配置了同一个 WebDAV 地址我还能看到之前那台机器上的会话输出。这个功能实现起来不算复杂就是定时调用 WebDAV 的 PUT 方法把新追加的日志块传上去。但它的体验价值非常高尤其是你想复盘昨天一次长任务的输出时不用跑到对应的机器上去翻终端。5. macOS 平台上遇到的坑一次完整的排查记录5.1 “终端没有任何权限”的排查链路这个问题的现象描述是用户反馈 lmux 的会话里ls和cd都正常但一旦访问 Downloads、Desktop 或者一个受保护的项目目录就直接被拒绝提示没有权限。我第一次拿到这个反馈时第一反应是沙盒授权没弄好于是去检查 App 的 entitlements发现com.apple.security.files.user-selected.read-write也配了且用户确实通过文件选择器授权过。但重启后问题依旧。于是我开始怀疑是 Security-scoped bookmark 没有正确写入。我在代码里打了一堆 log最终发现真正的原因是我往 session.json 里写 bookmark 时没有把它作为独立二进制数据保存而是尝试转成了 String结果数据被截断了。修复方法很简单bookmark 数据必须当作 Data 原样保存不要经过字符串转换。放在这里提醒是因为这个错误极其隐蔽它不会立刻报错只会在重启后导致权限恢复失败。真正的排查链路是本地直接用非沙盒二进制复现 → 确认沙盒本身没问题。打开 macOS 的统一日志用log stream --predicate subsystem com.lmux.app看授权恢复的错误码。发现startAccessingSecurityScopedResource()返回false。回查 bookmark 的 Data 内容发现保存时被截断定位到字符串转换的 bug。5.2 系统重装后的恢复策略macOS 重装这件事对普通用户来说是个灾难题但对开发者工具来说是天然的“会话恢复能力大考”。我之前为了测试 lmux专门用移动硬盘做了一次全新系统的安装装完之后再恢复数据。对比前后的表现真正有效的方案是在一个新系统上第一时间安装 lmux然后导入会话 bookmark 和 session 目录。由于我们把会话的所有定义都放在Application Support/lmux目录下理论上重装后只要能拿到这个目录会话历史就能完整还原。即使某些会话的进程无法重启比如原来的 Python 虚拟环境路径变了至少日志和状态说明还在而不是整个上下文消失。这个方法听上去很理所当然但市面上一堆终端客户端都没有做到可能因为它们会觉得“终端会话本来就不应该跨系统存活”。但对我这种重度使用 AI 编码工作流的人来说上下文就是生产力状态就是资历。5.3 关闭 SIP 根本不解决任何问题搜索热词里有不少人在问“如何关闭 SIP”特别是在 M 系列芯片上。我的态度很明确lmux 的所有功能都不依赖关闭 SIP如果你关了请赶紧打开。曾经我为了调试一个终端扩展试过临时关闭 SIP。结果后来一次 macOS 系统更新因为 SIP 被关了导致验证失败更新过程直接卡死最后只能走系统重装。那次折腾完之后我再也不碰这个开关了。在 macOS 的 App 开发中绝大部分你需要的系统扩展、目录访问、键盘监听能力都有正式的 API 路径不需要绕过系统完整性。如果你的 App 为了跑起来必须关闭 SIP那更可能是你的设计越界了而不是系统太严格。lmux 在测试阶段的所有用例包括 PTY 创建、Keychain 读取、文件访问、网络同步全部在 SIP 开启状态下完成。这是写进项目的硬性原则。6. 性能压测与渲染优化从卡成 PPT 到丝滑滚动6.1 最初的全 SwiftUI Text 方案果然翻车了lmux 第一个可用版本终端输出区域直接使用 SwiftUI 的Text拼接。这个方案在会话输出比较少时完全没问题但一旦某个应用在大批量打印日志比如跑一个集成测试每秒几千行输出界面很快就卡住了光标移动都变成慢动作。原因不难理解SwiftUI 的 Text 是为普通界面文本设计的它要重新计算整个文本视图的布局。几千行文本每行都参与布局计算更新频率一高输入事件和渲染任务互相抢时间表现当然很差。于是我把渲染层整个替换成NSViewRepresentable包住NSTextViewSwiftUI 只负责外层的结构和事件文本的实际渲染交给 AppKit 级别的文本系统来处理。这个替换第一次跑起来时性能提升是非常直观的原本 3000 行输出就开始拖泥带水现在单次渲染 5 万行也能保持较快滚动。6.2 ANSI 解析器的增量处理终端的彩色输出的核心是 ANSI 转义序列。如果每次都把一大块输出重新解析再整体渲染那性能一定不行。lmux 的做法是维护一个增量解析器每次从 PTY 读到新数据时只解析新增部分把新生成的NSAttributedString追加到文本存储的末尾。解析器本身是一个带状态的状态机按 ESC 字符进入转义模式识别[后面的参数段然后映射颜色、加粗、下划线等属性。这个过程不需要每次从头解析。关键优化点是渲染节流PTY 的输出频率比主线程的 UI 循环要高得多。lmux 的做法是收到原始数据时先把文本落盘然后设置一个 16ms 的节流窗口在这个窗口内最后再刷新一次界面。6.3 实测数据对比以下是用同一个会话、输出 5 万行日志时的实测数据渲染方案首次渲染完成时间持续滚动帧率CPU 占用SwiftUI Text 直接拼接3.2s8-12 fps180%SwiftUI List 分块1.8s15-20 fps120%NSTextView 增量解析0.4s55-60 fps35%为什么 NSTextView 能优势这么大因为它内部使用了翻页缓存和分层文本布局不会让我手动去拼接没完没了的字符串属性。对我这种需求来说它就是 macOS 平台上最适合做终端渲染的基础组件之一。另外还有一个容易忽略的优化点滚动窗口不要无限长。lmux 默认只保留最近 20000 行在内存里渲染更早的行只留在磁盘日志里。用户滚动到历史区域时再从日志文件按需读取而不是一次性把所有行全部加载。这样内存占用始终是稳定的不会因为某个任务输出了一百万行就把电脑拖垮。7. 开发 lmux 时用到的几个实用技巧不想用太长篇幅堆砌这里就分享三个让我少走很多弯路的点。第一个永远不要把 bookmark 数据转成字符串再保存。这个问题我在 5.1 里说过了数据就是数据Data类型直接写文件不要中间转String否则 debug 起来真的能折磨你一个晚上。第二个在使用openpty后记得主动close(slaveFD)。master 和 slave 是一对子进程拿到 slave 之后父进程已经用不到 slave 了如果不手动关掉它会导致一个很隐蔽的问题子进程退出后父进程的 master 端读不到 EOF因为系统认为 slave 还开着。这个 bug 导致 lmux 的会话退出后迟迟检测不到“进程已结束”最后卡在“运行中”的状态。排查到后来我怀疑是 PTY 的生命周期问题才回想到 slaveFD 没关。第三个给会话状态做分级颜色。lmux 主界面左侧的会话列表我用了三种状态色绿色表示“进程正在运行且输出正常”黄色表示“进程跑着但最近 5 分钟没有新输出怀疑在等待输入”灰色表示“进程已退出但会话状态已保存”。这个设计极大降低了监控成本让我扫一眼就能知道哪个任务需要我介入而不是逐个点开看。现在 lmux 已经成为我自己日常 AI 编码工作流里不可缺的一层基础设施。虽然它没有复杂的插件生态也没有花哨的外观但每个设计点几乎都是从真实的失控、踩坑、重装、丢失上下文里长出来的。我希望这篇记录能帮你避免我在项目里踩过的坑也希望给那些想为自己搭建高效 AI 编码工作流的开发者提供一个参考起点有时候一个合格的工具不是功能越多越好而是它是否真正理解了你每天在做的事。