I Fed 653 Agent Sessions to Spotlight
There are 653 Codex CLI and Claude Code sessions lying on my machine — 440 Codex, 213 Claude. They're scattered across two hidden directories, ~/.codex/sessions and ~/.claude/projects, layered by date and escaped paths, filenames a string of UUIDs. To get back to a session from three days ago, I'd first have to remember which project it belonged to, then dig through the directories layer by layer — and after finding it, still resume it from the right working directory or it wouldn't come up. More often I couldn't be bothered and just opened a new one, feeding the context all over again.
On the same machine, I hit ⌘ + Space dozens of times a day. Launching apps, checking exchange rates, finding files — all through it. It doesn't know my sessions exist.
Spotion is the tool that connects those two ends. 2,400 lines of Swift, no window of its own. The interface is Spotlight itself; the menu-bar icon only watches the directories to keep the index fresh. Now I press ⌘ + Space, type soldy auth, hit return, and the terminal restores that session in the correct directory. New sessions start from here too: select New Codex Session, type the prompt inline, return, and it's running.
How four ideas got killed
Before writing code I had a round of conversation with Gemini. The trigger was a project called Notomo whose approach I liked a lot. Don't migrate the user's data, don't change the user's habits — just use the system's assistive capabilities to lay a thin layer over native apps. The day users get annoyed, they turn it off and are back to the native experience. Tools like this are fast to build, cheap to try, and cost nothing to quit.
I asked it to generate ideas along that line. It gave four; I killed them one by one.
- An AI sidebar for Apple Mail. If you really want an agent handling mail, hooking up a mail CLI is more direct.
- Quick capture for Calendar and Reminders. The command line already does this.
- Safari reading enhancements. I don't use Safari.
- AI Q&A inside QuickLook. Mildly interesting direction, far too broad a scenario. Shelved.
Only after killing the fourth did I see where they all lost, and it was the same place. All four were adding an interface to something that already had a shorter path. For someone who lives in the terminal all day, one more interface is one more cost, no matter how pretty.
My session problem was the inverse. The data was lying on disk; what was missing was the entry point. And the entry point I already had — Spotlight has lived in my muscle memory for years. There was exactly one thing to do: feed it the data.
Written down, the requirements were three lines. Historical sessions searchable, return goes straight there. New sessions start from Spotlight. The index stays fresh on its own. Those three lines later became the README's feature list almost verbatim.
Looked around — nothing existing
The Raycast and Alfred plugin ecosystems have session managers, provided you swap them in for ⌘ + Space. OpenAI's own Codex desktop app has a full session list, but that data is locked inside the app where the system can't search it. Community menu-bar tools build their own search boxes, which means yet another shortcut to remember. Everyone was building their own interface; nobody was handing the data to the one the system already has.
Meanwhile Core Spotlight and App Intents are both public APIs — no hacks needed anywhere. The gap was sitting right there, technically unobstructed; nobody had connected the two ends.
The name fell out of that same conversation. Spot plus Session, pronounced close to Potion — so, Spotion.
The officially recommended API: the call goes out and never comes back
I expected a weekend project. Parse the JSONL, donate into the index, done. Quite a few things exceeded that estimate — Apple's side first. Per current docs, the standard way to put App Entities into Spotlight is indexAppEntities. On my machine (macOS 26.5) this call doesn't error and doesn't return. The XPC request goes out, the completion never fires, the await hangs in place. I have a sample size of exactly one machine; no idea what other environments do.
The fallback is CSSearchableItem, around since 2015, plus one line of associateAppEntity to attach the entity. Spotlight gets every bit of the semantics, and the call actually returns.
Having been burned by the hang, I wrapped every cross-process call in a twenty-second timeout.
try await withTimeout(20, operation: "index \(batch.items.count) items") { done in
self.index.indexSearchableItems(batch.items) { done($0) }
}
A timeout doesn't mean it didn't run
A timeout only says I stopped waiting. The call on the other side is still running.
The most typical case looks like this. deleteAll times out; I treat it as failure, keep my ledger of already-indexed ids, and degrade to a normal refresh. Tens of seconds later, that call's callback suddenly comes back reporting success. The index really was wiped. My ledger still claims hundreds of entries exist, so every subsequent incremental refresh decides there's nothing to top up. What lands on the user: sessions in Spotlight vanish without warning, and a restart doesn't bring them back.
You can't enumerate this class of timing problem in your head. On the foundation PR, Codex's review bot left 28 findings; I went through ten rounds of fixes and the tests grew from 30 to 45. Around round five or six I finally saw it: those findings were all saying one thing. I was operating state that lives in another process with the instincts of a local dictionary. That index can't be queried for its current state, writes have no transactions, and a timeout may still succeed after the fact. With those three together, the whole style of writing has to change.
What concretely changed — three examples.
First, donation confirmation was split from the file cache. Before, an unchanged mtime skipped everything; after one upsert timeout, the stale metadata in the index would never be touched again. Now there's a separate dirty set — an entry is cleared only when its donation is confirmed, and a cache hit no longer implies skippable.
The wipe order got inverted too. Before: deleteAll first, then clear the ledger, then re-ingest. If the delete timed out, the ledger was already gone and the old entries genuinely present in the index became strangers forever. Now the ledger moves only after deletion confirms success; on failure, keep the ledger, report, degrade. The rebuild obligation also lands in UserDefaults — if the process gets killed, the next launch picks up the retry.
Timed-out calls keep one hook. When the callback comes back after the fact, the hook tells success from failure; on success it triggers compensation, re-ingesting in bulk whatever the zombie silently wiped. There was a scheme that looked cleaner which I didn't use: blocking the pipeline until the zombie finishes. This call's original failure mode is never finishing — waiting on it upgrades an occasional incident into a permanent deadlock.
One machine, 2,400 lines of code, never touching the network, and by the end it was all distributed-systems exercises.
The file is still growing while you read it
The second thing beyond estimate was the data source. A Codex rollout file can grow to 13MB, and it's being appended to while I read it. A file like that can't be read whole; the scanner reads only a bounded window from each end and drops the half-line at the window boundary.
Titles have a priority order: custom ones highest, then AI-generated, then the last prompt, with the first real prompt as the final fallback. And that fallback can get pushed out of the first read window by a giant blob of injected content. My first version wrote the window-growing condition as "keep growing if meta not found" — but meta happens to be on line one, so some sessions were forever stuck with the project name as their title. I fixed this bug twice — once on the Codex side, once identically on the Claude side; in review they were two independent findings.
Before shipping I ran the full real dataset once: 1,121 files deduplicated into 653 sessions, zero parse failures.
Can't find it — my problem or the system's?
There's one small design I'm rather happy with. When a session doesn't show up in Spotlight, either my donation never landed, or the system side isn't displaying it — the toggle in Settings turned off, or the index service acting up. The two failures look identical to the user.
So the settings page has a self-check button that uses CSUserQuery to bypass Spotlight's UI and query the index itself. If it's found, my side of the work is done — go check the toggle in System Settings. If it isn't, rebuild on the spot. It's the first item in the README's troubleshooting: self-check first, then split paths.
It indexed the sessions that wrote it
Spotion was written with Codex and Claude Code. Half the repo's branches are named claude/xxx, half codex/xxx, and review was done by Codex's bot. The sessions produced while writing it all became searchable once it was installed.
Later iterations follow the same line. Besides opening a terminal, return can hand the session to the Claude or Codex desktop app. Icon, Sparkle auto-update, Homebrew distribution — routine moves. To this day it has no window of its own.
Out of the whole project, there's one thing I want to remember. To judge whether a piece of code is local code, don't look at which machine it runs on. If a call can time out, has no transactions, and its state can't be queried back, it is a remote request — and it must be served with remote-request patterns.
Source at github.com/Iris-Ares/Spotion, requires macOS 26.