我给 Spotlight 喂了 653 个 agent 会话
我本机躺着 653 个 Codex CLI 和 Claude Code 的会话,Codex 440 个,Claude 213 个。它们散在 ~/.codex/sessions 和 ~/.claude/projects 两个隐藏目录里,按日期和转义路径分层,文件名是一串 UUID。想回到三天前的某个会话,得先想起它属于哪个项目,再钻进目录一层层翻,翻到了还要用对工作目录才 resume 得起来。更多时候我懒得翻,直接开个新的,把上下文重新喂一遍。
同一台机器上,⌘ + Space 我每天要按几十次。开 App、查汇率、找文件都走它。它不认识我的会话。
Spotion 是把这两头接起来的工具。2400 行 Swift,没有自己的窗口。界面就是 Spotlight 本身,菜单栏那个图标只负责盯目录,让索引保持新鲜。现在我按 ⌘ + Space 输入 soldy auth,回车,终端在正确的目录里把那个会话恢复出来。新会话也从这里发起,选中 New Codex Session,行内敲完 prompt 回车就开。
四个点子是怎么被否掉的
动手前我和 Gemini 聊了一轮。起因是我看到一个叫 Notomo 的项目,很喜欢它的路数。不迁移用户数据,不改用户习惯,纯靠系统的辅助能力在原生 App 上面盖一层。用户哪天不爽,关掉就回到原生体验。这类工具开发快,试用门槛低,退出成本是零。
我让它顺着这个路数出主意。它给了四个,我一个个否掉。
- 给 Apple Mail 做 AI 侧栏。真要让 agent 处理邮件,挂个 mail CLI 更直接。
- 给日历和提醒事项做快速录入。命令行本来就能干这事。
- Safari 阅读增强。我不用 Safari。
- QuickLook 里加 AI 问答。方向有点意思,场景太泛,先放着。
否到第四个,我才看清它们输在同一个地方。四个点子都在给已经有更短路径的事情加界面。对整天泡在终端里的人,多一个界面就是多一笔成本,做得再漂亮也一样。
我的会话问题是反着的。数据躺在盘上,缺的是入口。而入口我手上已经有了,Spotlight 在肌肉记忆里待了很多年。要做的只有一件事,把数据喂给它。
需求写下来只有三行。历史会话可搜索,回车直达。新会话从 Spotlight 直接发起。索引自动保持新鲜。这三行后来几乎原样变成了 README 的功能列表。
查了一圈,没有现成的
Raycast 和 Alfred 的插件生态里有会话管理,前提是拿它们换掉 ⌘ + Space。OpenAI 自家的 Codex 桌面版有完整的会话列表,那些数据锁在 App 里,系统搜不到。社区的菜单栏工具自己做搜索框,等于又多一个要记的快捷键。各家都在造自己的界面,没有人把数据交给系统已有的那个。
而 Core Spotlight 和 App Intents 都是公开 API,从头到尾不需要 hack。缺口摆在那儿,技术上没有障碍,只是没人把两头接上。
名字也是那轮聊天里顺出来的。Spot 加 Session,读音撞上 Potion,就叫 Spotion。
官方推荐的 API,调用发出去就没回来
我预期这是个周末项目。解析 JSONL,donate 进索引,收工。结果超出预期的地方不少,先说 Apple 这侧的。按现在的文档,把 App Entity 送进 Spotlight 的标准做法是 indexAppEntities。这个调用在我机器上(macOS 26.5)不报错,也不返回。XPC 请求发出去,completion 永远不触发,await 原地挂死。我只有这一台机器的样本,别的环境什么样不知道。
退路是 2015 年就有的 CSSearchableItem,加一句 associateAppEntity 把实体关联上去。Spotlight 拿到的语义一点不少,调用还真的会返回。
被挂死吓过之后,我给每个跨进程调用都包了二十秒超时。
try await withTimeout(20, operation: "index \(batch.items.count) items") { done in
self.index.indexSearchableItems(batch.items) { done($0) }
}
超时不等于没执行
超时只说明我不再等了。对面那个调用还在跑。
最典型的一种情况长这样。deleteAll 超时,我按失败处理,保留自己那份已索引 id 的账本,降级成普通刷新。几十秒后,那个调用的回调突然回来,报告成功。索引真的被清空了。我的账本还声称几百条都在,于是之后每轮增量刷新都觉得没什么要补。落到用户身上就是,Spotlight 里的会话毫无征兆全部消失,重启也不回来。
这种时序问题靠脑子想不全。foundation PR 上 Codex 的 review bot 留了 28 条 finding,我改了十轮,测试从 30 条涨到 45 条。改到五六轮的时候我看明白了,这些 finding 说的是同一件事。我在拿本地字典的直觉,操作另一个进程里的状态。那个索引没法查询现状,写入没有事务,超时之后还可能事后成功。三条凑齐,写法全得换。
具体改了什么,挑三个说。
先是把 donate 的确认和文件缓存拆开。原来 mtime 没变就跳过一切,一次 upsert 超时之后,索引里陈旧的元数据就永远没人再碰。现在有个单独的 dirty 集合,donate 确认成功才销账,缓存命中不再意味着可以跳过。
清库的顺序也倒了过来。原来先 deleteAll 再清账本再重灌,删除一超时,账本已经没了,索引里真实存在的旧条目从此没人认识。现在删除确认成功才动账本,失败就保留账本、报错、降级。重建的义务还落进 UserDefaults,进程被杀了,下次启动接着重试。
超时的调用还留了一个钩子。回调事后回来,钩子分辨它是成功还是失败,成功就触发补偿,把僵尸偷偷清掉的东西整批重灌。有个方案看着更干净但我没用,阻塞流水线等僵尸结束。这个调用的原始故障模式是永远不结束,等它等于把偶发事故升级成永久死锁。
一台机器,2400 行代码,不碰网络,写到后面全是分布式系统的习题。
读的时候,文件还在涨
第二个超预期的地方在数据源。Codex 的 rollout 文件能涨到 13MB,我读它的时候它还在被追加。这种文件没法整个读,扫描器只从头尾各读一个有限窗口,窗口边界的半行直接丢掉。
标题有优先级,自定义的最大,其次 AI 起的,再往下是最后一条 prompt,兜底才轮到第一条真实 prompt。而这个兜底可能被一坨巨大的注入内容挤出首个读取窗口。我第一版的扩窗条件写成了「没找到 meta 就继续扩」,meta 偏偏第一行就有,于是有的会话永远顶着项目名当标题。这个 bug 我修了两遍,Codex 侧一遍,Claude 侧原样一遍,review 里是两条独立的 finding。
上线前用真实数据全量跑了一次,1121 个文件去重出 653 个会话,零解析失败。
搜不到,是我的问题还是系统的问题
还有一个小设计我比较满意。会话没出现在 Spotlight 里,可能是我的 donation 没落地,也可能是系统那侧没展示,设置里的开关被关了,或者索引服务抽风。两种故障用户看到的现象一模一样。
所以设置页里放了个自检按钮,用 CSUserQuery 绕过 Spotlight 的界面,直接查索引本体。查得到,说明我这侧的活干完了,去系统设置里检查开关。查不到,就地重建。README 排障第一条写的就是它,先自检,再分头处理。
它把写它的那些会话也索引了
Spotion 是用 Codex 和 Claude Code 写的。仓库的分支一半叫 claude/xxx,一半叫 codex/xxx,review 也是 Codex 的 bot 做的。写它的过程里产生的那些会话,装上它之后全都搜得到。
后面的迭代顺着同一条线走。回车除了开终端,也能把会话交给 Claude 或 Codex 的桌面 App。图标、Sparkle 自动更新、Homebrew 分发,这些是常规动作。到今天它也没有自己的窗口。
整个项目做下来,我只想记住一条。判断一段代码算不算本地代码,别看它跑在哪台机器上。一个调用只要可以超时、没有事务、状态查不回来,它就是远程请求,得按远程请求的写法伺候。
源码在 github.com/Iris-Ares/Spotion,要 macOS 26。