A paged, filterable, taggable switcher over your recent Claude Code sessions, with a per-row activity strip showing when each one was busy. Resumes in the…

<img src="docs/assets/mascot.svg" width="150" align="right" alt="Phae, a pixel-art hermit hummingbird">
/switcher opens a paged, filterable list of your recent Claude Code sessions, 10 per page, newest first.
Claude Code already has --resume with a searchable picker. What this adds is tags, a sense of shape (when a session was actually busy), and switching across projects from inside a running session.
Two lines a row:
▇█▁▁···········▁ 1: Token refresh keeps 401ing on retry now
api-gateway on feat/pane · 38 prompts, 12 files
▄█▄█▄█▄█▄█▄█▄█▄█ 2: Port the switcher to the mods surface 1h
session-switcher on main · 112 prompts, 9 files
█··█···█··█··█·· 3: Trace the 4 MiB stdout ceiling 2h
trailant on feat/pane · 640 prompts, 41 files · sampled
···············█ 4: Bump the deploy workflow 3h
infra on main · 1 prompt
claude -n, or the picker's rename) → the newest ai-title record → your first real prompt#tagsThere is no raw key hook on the mod surface, so the interaction is built from what the element table offers — and it fits: the page holds ten rows, and a Button hotkey is exactly one digit.
| Key | Action |
|---|---|
| type | filter across title, prompt, project, branch, #tag |
| 1–9, 0 | resume that row — in place if it's from this project, otherwise print the command with its directory (why) |
| t | tag mode — a digit then opens that row's tag field |
| n / p | next / previous page |
| Esc | close the pane |
A surface whose element table has no Input still draws its rows; the filter degrades to a label rather than the pane refusing to draw.
Two ways in. From inside the session you're working in — no pane, no picking your own row out of a list:
/switcher #wip #mods add two tags to this session
/switcher -#mods drop one
/switcher Token refresh bug retitle it
/switcher #blocked waiting on review both at once
/switcher help the above, in the terminal
+#wip is accepted as sugar for #wip. Removal has to be marked, so -#tag exists; once it does, the symmetric +#tag is what people reach for. Requiring it would be worse — #wip is what you type without thinking — so both work.
The form is drawn dim beside the command as you type it (argumentHint), which is the only place it's discoverable at the moment you'd want it. The command is registered immediate, so you can tag a session while a turn is still streaming — which is exactly when you notice the session is worth marking.
That form folds in rather than replacing: tags add, -#tag removes, and the title is only overwritten when you type one. Tagging a session mid-flight should never silently drop the title you gave it this morning.
Or from the pane: t, then the row's digit, then type. That form replaces, because you can see exactly what you're editing.
Tags are stored per session and keyed by a project + opening-prompt fingerprint, so they survive the new session id that a resume mints.
A name like Sep 10 | someone | some team is three facets crammed into one string, because a title was the only field there was. The switcher reads those names — they outrank the generated ai-title — so nothing you've already done is lost. But split that way it does more:
/switcher #someone #some-team Refund reconciliation mismatch
Sep 10 costs you the widest column in the list.#someone #some-team is the intersection. A pipe-string can only be substring-matched, and Some Team versus Some Tm splits into two things that never meet again.A tag is lowercased and takes letters, digits, _ and - only, so Some Team becomes #some-team. #some team would read as the tag some and a title team.
Not the project — that's already a facet. Typing switcher in the filter matches the project column for free, and the same goes for the branch and anything in the title. Tagging what the transcript already knows just duplicates it by hand.
Tags earn their keep on what the transcript can't know:
#wip, #blocked, #parked. The switcher can see when a session was last active, not whether you were finished. This is the one that turns a long list into a queue.#work vs personal, #demo for sessions worth referencing later.One state tag plus at most one thread tag is usually enough. The filter ANDs its words, so #wip #mods narrows to the intersection. Tag vocabularies die from ambition, not from disuse.
The repo is its own marketplace, so it installs in two lines:
claude plugin marketplace add semanticintent/claude-session-switcher
claude plugin install session-switcher@semanticintent
Mods are early access, so the CLI needs the flag until they ship:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude
Then /switcher. To try it without installing anything, clone it and point the CLI at the directory for one session:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./claude-session-switcher
Requirements: Claude Code 2.1.259 or newer (Mods do not exist before that). On a managed or enterprise install, plugins and marketplaces can be disabled by policy, and the CLI version may be pinned — both are worth checking before you start.
What it touches, as claude plugin validate . reports it, which is the whole answer to "what does this thing do on my machine":
env reads: HOME, OS, USERPROFILE
env writes: nothing
calls: $.command.register, $.command.run, $.env.get, $.fs.list, $.fs.read,
$.fs.stat, $.process.run, $.session.id, $.store.get, $.store.set,
$.ui.close, $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve
No $.http, so it makes no network calls of any kind. It reads your session transcripts and writes its index and your tags to the engine's own per-plugin store. Nothing leaves the machine, and nothing is written to the file system. The subprocesses it runs are sed, head and tail (or Get-Content on Windows), against transcript files only, and only for files too large for $.fs.read.
An upstream-shaped plugin: .claude-plugin/plugin.json, hooks/hooks.json naming hooks/register.ts, and types/claude-code.d.ts — Anthropic's declarations, fetched by npm run types rather than committed (see Development). register(on) hooks session.start (register the command), command.run (open the pane), ui.render (draw it) and ui.close.
Transcripts are big. Measured on one real, heavily used store: 372 MB across 13 sessions, the largest single file 226 MB. Reading them the obvious way (readFile + split("\n") + JSON.parse per line) takes ~1 GB of heap on that one file alone and blocks the overlay for the whole time.
Instead:
stated on open (cheap) and only re-read if its mtime or size moved. INDEX_VERSION forces a re-parse when the extractor changes, so the cache can't serve stale titles forever. This is lifted from trailant's indexer. The index lives in $.store, the engine's own key-value store.cached.lines + 1, not line 1. A file that shrank is re-read whole. test/scanner.test.ts pins the invariant: incremental re-index === full re-index.JSON.parse in the hot loop. Every field a row needs is pulled with a regex off the raw line. The only line ever parsed is the single opening user prompt. Attachment and tool-output lines — most of the bytes — are matched and discarded without being decoded.loadSessions() touches no transcript; syncSessions() streams updated rows into the open overlay.Measured on the real store: 1 ms to open, 0.4 s to index all 372 MB cold, 0 ms on reopen when nothing changed. First row on screen after 4 ms.
Verified against real files here, not assumed:
type: "ai-title" records as aiTitle, and they regenerate mid-session — the last one wins, not the first. There is no ai_title field.type: "summary" records are a legacy fallback that matched zero real sessions; kept only because it costs nothing.type: "user" record is usually not your prompt — it's a <system-reminder>, a slash-command banner or an IDE selection. Taking it at face value titles half your sessions with a wrapper blob.cwd on the record is authoritative; decoding the directory name is lossy (/, ., : and \ all map to -).projects/<slug>/<id>.jsonl, exactly one level deep. Sub-agent transcripts live in <id>/subagents/ and are somebody else's session — here that's 254 files and 51 MB correctly excluded.attachment, queue-operation, last-prompt, atis-latch, file-history-snapshot) must not be counted as messages, and "type":"user" lines carrying a tool_result are a tool's reply, not a turn.Nowhere on disk. The index, your tags and the config all live in $.store, the engine's own per-plugin key-value store (JSON, 4 MiB cap — the index was 53 KB for 13 sessions). Transcripts are only ever read.
Claude Code does have session names (~/.claude/sessions/*.json, a user-set name where nameSource isn't "derived"); it does not have tags. Tags, and filtering by them, are what this adds. Because resuming a session mints a new session id, each record also stores a fingerprint (project + opening prompt) and lookup falls back to it — otherwise your tags orphan the first time you resume.
/sessions is exactly the kind of generic name a future release could claim — --resume already ships a searchable picker, so a built-in /sessions is a short walk away. The mod therefore never hard-codes one name. hooks/config.ts lists candidates and keeps the first the engine accepts:
session-switcher:sessions → qualified; can't collide switcher → short alias sessions → generic; only if nothing owns it
If a name is taken, that registration fails and the next is tried. There is no fallback beneath the last candidate: the surface gives a mod no way to bind a chord of its own, so a command name is the only way in. The command name and the refresh count are declared as userConfig in the manifest, so they're editable from /config rather than a hidden file. A name chosen there is tried first and still falls back if a built-in owns it.
This is upstream's own idiom, not an invention: $.command.register throws when a name is taken, and Anthropic's diff mod catches exactly that to cede /diff — "/diff once the built-in stands down."
npm install # typescript + node types only; the surface supplies the elements
npm run types # fetch Anthropic's claude-code.d.ts (not vendored — see below)
npm run check # tsc against those declarations, then the tests
types/claude-code.d.ts is Anthropic's file and is deliberately not committed here: this repo is MIT, and shipping their declarations inside it would imply a licence over them that isn't mine to give. npm run types fetches the copy from anthropics/claude-code. Once /plugin-types ships in the CLI, prefer that — it writes the declarations for the build you're actually on.
It runs. On Claude Code 2.1.278 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, the engine loads the module, admits it, raises session.start, registers the command and opens the pane:
hooks module session-switcher@inline loaded (worker, environment 1, tier user);
events: session.start,command.run,ui.render,ui.close
plugin.register: session-switcher — admitted
$.command.register (session-switcher): /switcher listed
ui.open session-switcher (unasked, unmeasured columns): placed
$.store.set (session-switcher@inline): session-index
Note which name it took. session-switcher:sessions was refused and /switcher was accepted — the ordered fallback firing for real, unprompted.
Measured over the whole 372 MB store, in the engine, not a benchmark harness:
| subprocess reads | bytes read | |
|---|---|---|
| first open (cold index) | 52 | 15.2 MB |
| next open (nothing new) | 4 | — |
claude plugin validate . passes, and prints the mod's entire reach before any session loads it:
hooks: session.start, command.run, ui.render{component=Pane}, ui.close
calls: $.command.register, $.env.get, $.fs.list, $.fs.stat, $.process.run,
$.store.get, $.store.set, $.ui.close, $.ui.invalidate, $.ui.log,
$.ui.open, $.ui.resolve
env writes: nothing
env reads: HOME
npm run types && npm run check
claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
The pane itself has been drawn in an interactive session on the same version: rows, digit hotkeys, per-project colour, paging and live filtering all work as described. (A print-mode session has no surface, so ui.render never fires there — that part needed a real terminal to confirm.)
Resume switches in place — within the current project. There is no $.session.resume on the surface. $.process.run captures a child's output and never hands it the terminal, so shelling out to claude --resume starts a headless second session and blocks until the timeout. $.command.run is the right shape — it runs a slash command as if you typed it. On 2.1.277 a probe of all 70 commands found no /resume; on 2.1.281 it is there, and picking a row switches you to that session immediately.
/resume only lists the current project's sessions, though. One from another project comes back "Session … was not found." — as the command's output, not an error — so for those the mod skips the attempt and prints the command instead, with the directory:
cd /path/to/project && claude --resume <id> # macOS / Linux
cd 'C:\path\to\project'; if ($?) { claude --resume <id> } # Windows PowerShell
Older builds without a runnable /resume get the same printed command for every row. Switching across projects in place still needs the surface to offer it; the Mods issue is the place to say so.
The Windows read fallback has been run on a real Windows machine (2.1.281) and works as written.
/plugin-types isn't installed in this build, so the declarations used here are the 2.1.277 copy from upstream (npm run types). Regenerate with /plugin-types once it exists rather than trusting a snapshot — the header says the surface changes between releases.
Phae, a hermit hummingbird. Hermits trap-line — a repeatable circuit of scattered flowers, each returned to on its own schedule — and they track not just which flowers they visited but how long ago, timing each return to that flower's refill rate. Many sites, held in parallel, none of them home. Same diagram as this tool, arrived at about forty million years earlier.
MIT
hooks/register.ts 348 lines1// Entry point: the module hooks.json names. Registers the command, draws the
2// pane, and keeps the transcript index behind it fresh.
3//
4// Written against mods/types/claude-code.d.ts (Claude Code 2.1.277). The
5// surface is early access and may change between releases; regenerate the
6// declarations with /plugin-types rather than trusting this file's vintage.
7import type { EngineInterface, On, PluginOptions } from "claude-code";
8import { wholeLines, rangeArgv, sampleArgv, resumeCommand, sameDir, PROJECTS, SESSIONS, type FileInfo, type Host, type Store } from "./host.ts";
9import { cachedSessions, sync, type Session } from "./scan.ts";
10import { loadMeta, saveMeta, setMeta, mergeMeta, pruneMeta, metaFor, statusText, type Meta } from "./tags.ts";
11import { loadConfig, registerFirst, DEFAULTS } from "./config.ts";
12import { view, PAGE, type Model, type Actions } from "./view.ts";
13
14const PANE_ID = "session-switcher";
15const PANE_TITLE = "Sessions";
16const DESCRIPTION = "Switch between recent sessions — filter, tag, and resume in the right directory";
17// Drawn dim after the name as you type it, which is the only place the argument
18// form is discoverable at the moment you'd want it.
19const ARGUMENT_HINT = "[#tag | -#tag | new title | help]";
20
21const HELP = [
22 "/switcher open the switcher",
23 "/switcher #wip #mods tag this session (+#wip works too)",
24 "/switcher -#mods untag this session",
25 "/switcher Token refresh bug retitle this session",
26 "/switcher #blocked on review tag and retitle at once",
27 "",
28 "Arguments fold in: tags add, -#tag removes, and the title only changes when",
29 "you type one. In the pane, t then a row's digit edits it there instead, and",
30 "that form replaces what's on the row, because you can see it.",
31 "",
32 "This session's tags stay pinned under the prompt, with its switcher title",
33 "when that differs from the session name.",
34 "",
35 "In the pane: type to filter · 1-9,0 resume · t tag · n/p page · Esc close",
36].join("\n");
37
38/**
39 * The mod's whole state. It lives at module scope rather than inside
40 * `register`'s closure because `claude plugin validate` only follows `$` into
41 * functions declared at the top of this file — so `refresh` and `resume` have
42 * to be top-level, and the state they work on has to reach them.
43 */
44const state = {
45 host: null as Host | null,
46 store: null as Store | null,
47 commandName: null as string | null,
48 isWindows: false,
49 isOpen: false,
50 syncing: false,
51 sessions: [] as Session[],
52 meta: {} as Record<string, Meta>,
53 query: "",
54 page: 0,
55 mode: "resume" as "resume" | "tag",
56 editing: null as { id: string; text: string } | null,
57 refresh: 40,
58 // The row an in-place resume switched to, standing in for this session
59 // until its own transcript reaches the index.
60 selfHint: null as Session | null,
61};
62
63const model = (): Model => ({
64 sessions: state.sessions, meta: state.meta, query: state.query, page: state.page,
65 mode: state.mode, editing: state.editing, syncing: state.syncing,
66});
67
68/** Folds in whatever changed since the last open, a row at a time. */
69async function refresh($: EngineInterface): Promise<void> {
70 const { host, store } = state;
71 if (!host || !store || state.syncing) return;
72 state.syncing = true;
73 const byId = new Map(state.sessions.map((s) => [s.id, s]));
74 try {
75 await sync(store, host, (s) => {
76 byId.set(s.id, s);
77 state.sessions = [...byId.values()].sort((a, b) => b.lastActive - a.lastActive);
78 $.ui.invalidate("ui.render");
79 }, { refresh: state.refresh });
80 pruneMeta(state.meta, state.sessions);
81 await saveMeta(store, state.meta);
82 } catch {
83 /* a stale row beats a broken pane */
84 } finally {
85 state.syncing = false;
86 $.ui.invalidate("ui.render");
87 }
88 // This session may only now have reached the index.
89 await pinStatus($);
90}
91
92/**
93 * Pins this session's tags under the prompt, so you can see what you're in
94 * without opening the pane — plus its switcher title, when that isn't the name
95 * the prompt border already draws. `$.ui.status` holds one line per plugin.
96 *
97 * An in-place resume doesn't fire `session.start` again, so `resume()` passes
98 * the row it switched to as `hint`.
99 */
100async function pinStatus($: EngineInterface, hint?: Session): Promise<void> {
101 const { host, store } = state;
102 if (!host || !store) return;
103 if (hint) state.selfHint = hint;
104 try {
105 const id = await $.session.id();
106 const [sessions, names] = await Promise.all([cachedSessions(store, host), host.names().catch(() => ({}))]);
107 const self = sessions.find((x) => x.id === id) ?? state.selfHint;
108 const m = self ? metaFor(self, state.meta) : state.meta[id];
109 $.ui.status(statusText(m, (names as Record<string, string>)[self?.id ?? id]));
110 } catch { /* no line beats a broken session */ }
111}
112
113/**
114 * Resuming works in place for a session from this project, and nowhere else.
115 *
116 * `$.process.run` captures a child's output and never hands it the terminal, so
117 * shelling out to `claude --resume` does not switch you to anything — it starts
118 * a headless second session and blocks until the timeout. `$.command.run` is the
119 * right shape (it runs a slash command as if you typed it). A probe on 2.1.277
120 * found no `/resume` among its commands; on 2.1.281 it is there and switches
121 * immediately — but `/resume` only lists the current project's sessions.
122 *
123 * One from another project comes back "Session … was not found." as the
124 * command's *output*, not a rejection, so a catch alone never saw it and the
125 * click did nothing. Those get the command to paste instead — with the cwd,
126 * because resuming one from the wrong directory drops you in the wrong repo.
127 */
128async function resume($: EngineInterface, s: Session): Promise<void> {
129 await $.ui.close({ id: PANE_ID }).catch(() => undefined);
130 state.isOpen = false;
131 const here = await $.session.root().catch(() => "");
132 if (sameDir(state.isWindows, here, s.projectPath)) {
133 try {
134 const out = (await $.command.run({ command: "resume", args: s.id })) as { text?: string } | undefined;
135 if (!/not found/i.test(out?.text ?? "")) { await pinStatus($, s); return; }
136 } catch { /* older builds: no /resume to run — fall through */ }
137 }
138 $.ui.log(resumeCommand(state.isWindows, s.projectPath, s.id));
139}
140
141export function register(on: On, options: PluginOptions) {
142 // What the manifest's `userConfig` declares, as the person set it in
143 // /config. The ordered fallback below still applies: a name they chose that
144 // a built-in already owns costs them that candidate, not the mod.
145 const chosen = typeof options.commandName === "string" ? options.commandName : null;
146 const refreshCount = typeof options.refresh === "number" ? options.refresh : undefined;
147
148
149 on("session.start", async ($, e, next) => {
150 // The plugin doesn't know where home is; the engine does. Windows sets
151 // USERPROFILE and often leaves HOME unset.
152 const home = String((await $.env.get("HOME").catch(() => undefined))
153 ?? (await $.env.get("USERPROFILE").catch(() => undefined)) ?? "");
154 const isWindows = (await $.env.get("OS").catch(() => undefined)) === "Windows_NT";
155 state.isWindows = isWindows;
156 if (home) {
157 // Built here, not imported: `$` is only ever followed into a function
158 // declared in the same file, so the engine-side Host is spelled inline.
159 const root = `${home}/${PROJECTS}`;
160 const namesDir = `${home}/${SESSIONS}`;
161 const run = async (argv: string[]): Promise<string> => {
162 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 30_000 });
163 return exitCode === 0 ? stdout : "";
164 };
165
166 // One-entry memo: a scan calls linesFrom repeatedly on the same file, and
167 // the $.fs.read path would otherwise re-read it whole each time.
168 let memoPath = "";
169 let memoLines: string[] | null = null;
170
171 /** The whole file as lines, or null when it is too big for $.fs.read. */
172 const wholeFile = async (path: string): Promise<string[] | null> => {
173 if (memoPath === path) return memoLines;
174 memoPath = path;
175 memoLines = null;
176 try {
177 const text = await $.fs.read(path);
178 memoLines = typeof text === "string" ? text.split("\n").filter(Boolean) : null;
179 } catch {
180 memoLines = null; // over 4 MiB, or unreadable — the tools take it
181 }
182 return memoLines;
183 };
184
185 state.host = {
186 async list(): Promise<FileInfo[]> {
187 const out: FileInfo[] = [];
188 for (const dir of await $.fs.list(root).catch(() => [])) {
189 if (dir.kind !== "dir") continue;
190 // One level deep only: sub-agent transcripts sit in <id>/subagents/
191 // and are somebody else's session.
192 for (const f of await $.fs.list(`${root}/${dir.name}`).catch(() => [])) {
193 if (f.kind !== "file" || !f.name.endsWith(".jsonl")) continue;
194 const path = `${root}/${dir.name}/${f.name}`;
195 const st = await $.fs.stat(path).catch(() => null);
196 if (st) out.push({ path, size: st.size, mtimeMs: st.mtimeMs });
197 }
198 }
199 return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
200 },
201
202 async names() {
203 const out: Record<string, string> = {};
204 const files = await $.fs.list(namesDir).catch(() => []);
205 // Vendor-internal and undocumented: any failure here must degrade to
206 // "no names found", never break the listing.
207 for (const f of files) {
208 if (f.kind !== "file" || !f.name.endsWith(".json")) continue;
209 try {
210 const raw = await $.fs.read(`${namesDir}/${f.name}`);
211 const d = JSON.parse(typeof raw === "string" ? raw : "{}") as Record<string, unknown>;
212 const id = d["sessionId"], name = d["name"];
213 if (typeof id === "string" && typeof name === "string" && name
214 && d["nameSource"] !== "derived") out[id] = name;
215 } catch { /* not ours to understand */ }
216 }
217 return out;
218 },
219
220 async linesFrom(path, from, max) {
221 const all = await wholeFile(path);
222 if (all) {
223 // The common case: no subprocess, nothing platform-specific.
224 return { lines: all.slice(from - 1, from - 1 + max), sawBytes: from - 1 < all.length };
225 }
226 const stdout = await run(rangeArgv(isWindows, path, from, max));
227 // Measured: the engine cuts stdout at 4 MiB. wholeLines drops the
228 // partial tail, and `sawBytes` lets the scanner recognise the one
229 // case that cut hides — a single line bigger than the whole limit.
230 return { lines: wholeLines(stdout + "\n"), sawBytes: stdout.length > 0 };
231 },
232
233 async sample(path, lines, end) {
234 const all = await wholeFile(path);
235 if (all) return end === "head" ? all.slice(0, lines) : all.slice(-lines);
236 return wholeLines(await run(sampleArgv(isWindows, path, lines, end)) + "\n");
237 },
238 };
239
240 state.store = {
241 get: (key) => $.store.get(key),
242 set: (key, value) => $.store.set(key, value),
243 };
244 const config = await loadConfig(state.store);
245 state.refresh = refreshCount ?? config.refresh;
246 state.commandName = await registerFirst(
247 (spec) => $.command.register(spec),
248 chosen ? [chosen, ...config.commands] : config.commands,
249 // immediate: tagging is worth doing the moment you think of it, not
250 // after the turn you're watching finishes.
251 { description: DESCRIPTION, argumentHint: ARGUMENT_HINT, immediate: true },
252 );
253 // Every candidate taken is survivable: the pane's own hotkeys still work
254 // once it is open, and a later release may free one up.
255 if (!state.commandName) $.ui.log("session-switcher: no command name was free; open it from /help.");
256 // The tag line is worth having, not worth delaying start for: not awaited.
257 state.meta = await loadMeta(state.store);
258 void pinStatus($);
259 }
260 return next(e);
261 });
262
263 on("command.run", { command: DEFAULTS.commands }, async ($, e, next) => {
264 const { host, store } = state;
265 if (!host || !store || e.command !== state.commandName) return next(e);
266
267 // `/switcher #wip #mods` tags the session you're in and stays out of the
268 // way — no pane, no picking your own row out of a list.
269 const args = e.args.trim();
270 if (args === "help" || args === "?" || args === "--help") return { text: HELP };
271 if (args) {
272 const id = await $.session.id();
273 const [sessions, meta] = await Promise.all([cachedSessions(store, host), loadMeta(store)]);
274 const self = sessions.find((x) => x.id === id) ?? null;
275 const saved = mergeMeta(id, self, meta, args);
276 await saveMeta(store, meta);
277 state.meta = meta;
278 await pinStatus($);
279 const tags = saved.tags.length ? saved.tags.map((t) => "#" + t).join(" ") : "no tags";
280 return { text: saved.title ? `${tags} · “${saved.title}”` : tags };
281 }
282
283 if (state.isOpen) {
284 await $.ui.close({ id: PANE_ID }).catch(() => undefined);
285 return { text: "Sessions closed." };
286 }
287
288 // Opens on the cached index — no transcript is read — then fills in.
289 [state.sessions, state.meta] = await Promise.all([cachedSessions(store, host), loadMeta(store)]);
290 state.query = "";
291 state.page = 0;
292 state.mode = "resume";
293 state.editing = null;
294
295 // Two lines a row, plus the header, the filter and the controls — asked
296 // for explicitly so the list doesn't overflow and scroll its own chrome
297 // out of view.
298 await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true, closeOnEscape: true, rows: PAGE * 2 + 4 });
299 state.isOpen = true;
300 void refresh($);
301 return {};
302 });
303
304 on("ui.render", { component: "Pane" }, async ($, e, next) => {
305 if (e.requestId !== PANE_ID) return next(e);
306 const table = await $.ui.resolve(e);
307 // Element tables differ by surface — one without `Input` still gets rows.
308 const { Box, Text, Button } = table;
309 const Input = "Input" in table ? table.Input : undefined;
310
311 const actions: Actions = {
312 filter: (value) => { state.query = value; state.page = 0; $.ui.invalidate("ui.render"); },
313 turnPage: (by) => { state.page = Math.max(0, state.page + by); $.ui.invalidate("ui.render"); },
314 toggleMode: () => { state.mode = state.mode === "tag" ? "resume" : "tag"; $.ui.invalidate("ui.render"); },
315 pick: (id) => {
316 const s = state.sessions.find((x) => x.id === id);
317 if (!s) return;
318 if (state.mode === "tag") {
319 const m = metaFor(s, state.meta);
320 state.editing = { id, text: [m?.title, ...(m?.tags ?? []).map((t) => "#" + t)].filter(Boolean).join(" ") };
321 return $.ui.invalidate("ui.render");
322 }
323 void resume($, s);
324 },
325 editTags: (value) => {
326 const s = state.editing && state.sessions.find((x) => x.id === state.editing!.id);
327 if (s && state.store) { setMeta(s, state.meta, value); void saveMeta(state.store, state.meta); }
328 // Cheap when the edited row isn't this session: the same line re-pins.
329 void pinStatus($);
330 state.editing = null;
331 state.mode = "resume";
332 $.ui.invalidate("ui.render");
333 },
334 };
335
336 return view({ Box, Text, Button, Input }, model(), actions);
337 });
338
339 on("ui.close", { id: PANE_ID }, async ($, e, next) => {
340 state.isOpen = false;
341 state.editing = null;
342 return next(e);
343 });
344
345}
346
347export { PAGE };
348hooks/host.ts 119 lines1// The port between the scanner's logic and whatever can actually touch files.
2//
3// A hooks module runs in an environment of its own — no Node, no DOM — so the
4// scanner cannot import `node:fs`. Worse, `$.fs.read` rejects any file over
5// 4 MiB and offers no ranged read, and a 226 MB transcript is ordinary here.
6//
7// So a read tries `$.fs.read` first — no subprocess, every platform — and only
8// a transcript too big for that falls back to `$.process.run`, which takes an
9// argv (no shell) and hands back the child's stdout. That fallback is the one
10// platform-specific corner in the mod: `sed`/`head`/`tail` on a POSIX host,
11// `Get-Content` on Windows. Most sessions never reach it.
12//
13// This file holds only the port's shape and one pure helper. The engine-side
14// implementation lives in register.ts, because `claude plugin validate`
15// refuses a mod that passes `$` across an import: every `$.noun.verb(...)`
16// must be spelled in the file that hooks. `test/node-host.ts` implements the
17// same three calls on `node:fs`, which is what keeps the scanner testable.
18export type FileInfo = { path: string; size: number; mtimeMs: number };
19
20export type Host = {
21 /** Session transcripts, newest first. Exactly one level deep. */
22 list(): Promise<FileInfo[]>;
23 /**
24 * Lines `from` (1-based) onward, at most `max` of them. `sawBytes` says
25 * whether the read returned anything at all, which is how the scanner tells
26 * "end of file" from "one line too big to come back whole".
27 */
28 linesFrom(path: string, from: number, max: number): Promise<{ lines: string[]; sawBytes: boolean }>;
29 /**
30 * Session ids to the names you gave them, from Claude Code's own store.
31 * `nameSource: "derived"` marks the CLI's auto-slug, not your choice, and is
32 * left out — letting a weak slug outrank a real title inverts the order.
33 */
34 names(): Promise<Record<string, string>>;
35
36 /** The first or last `lines` lines — a line budget, not a byte one, because
37 * that is the one shape both `head`/`tail` and PowerShell's `Get-Content`
38 * express directly. */
39 sample(path: string, lines: number, end: "head" | "tail"): Promise<string[]>;
40};
41
42export type Store = {
43 get(key: string): Promise<unknown>;
44 set(key: string, value: unknown): Promise<void>;
45};
46
47export const PROJECTS = ".claude/projects";
48export const SESSIONS = ".claude/sessions";
49
50// The argv the fallback runs, built as pure functions so the Windows branch —
51// the one corner of this mod that cannot be executed on a POSIX machine — is
52// still reviewable and covered by tests.
53
54/** PowerShell quoting: single quotes, with any inside doubled. */
55export const psPath = (p: string) => `'${p.replace(/'/g, "''")}'`;
56
57const ps = (script: string) => ["powershell", "-NoProfile", "-NonInteractive", "-Command", script];
58
59/** Lines `from`..`from + max - 1`, printed without reading past them. */
60export function rangeArgv(isWindows: boolean, path: string, from: number, max: number): string[] {
61 const last = from + max - 1;
62 return isWindows
63 // -TotalCount stops the read at `last`; Skip drops the ones before `from`.
64 ? ps(`Get-Content -LiteralPath ${psPath(path)} -Encoding UTF8 -TotalCount ${last} | Select-Object -Skip ${from - 1}`)
65 // sed's trailing q is what stops it reading on to EOF after the range.
66 : ["sed", "-n", `${from},${last}p;${last + 1}q`, path];
67}
68
69/** The first or last `lines` lines. */
70export function sampleArgv(isWindows: boolean, path: string, lines: number, end: "head" | "tail"): string[] {
71 return isWindows
72 ? ps(`Get-Content -LiteralPath ${psPath(path)} -Encoding UTF8 ${end === "head" ? "-TotalCount" : "-Tail"} ${lines}`)
73 : [end === "head" ? "head" : "tail", "-n", String(lines), path];
74}
75
76/**
77 * POSIX shell quoting, only when the path needs it: a plain path stays readable,
78 * one with a space or a quote in it is single-quoted, with any `'` closed,
79 * escaped and reopened.
80 */
81export const shPath = (p: string) =>
82 /^[\w@%+=:,./-]+$/.test(p) ? p : `'${p.replace(/'/g, `'\\''`)}'`;
83
84/**
85 * What to paste to resume a session in its own directory. Windows PowerShell
86 * 5.1 has no `&&` — it is a parse error there — so the Windows form spells the
87 * same "only if the cd worked" with `$?`.
88 *
89 * Both forms quote the directory. An unquoted POSIX path with a space in it
90 * made `cd` fail, and the `&&` then quietly skipped the resume.
91 */
92export function resumeCommand(isWindows: boolean, dir: string, id: string): string {
93 return isWindows
94 ? `cd ${psPath(dir)}; if ($?) { claude --resume ${id} }`
95 : `cd ${shPath(dir)} && claude --resume ${id}`;
96}
97
98/**
99 * Whether two directories are the same project. Windows paths compare without
100 * case and with either separator: `C:\Projects` and `c:/projects/` are one place.
101 */
102export function sameDir(isWindows: boolean, a: string, b: string): boolean {
103 const norm = (p: string) => {
104 const s = isWindows ? p.replace(/\//g, "\\").toLowerCase() : p;
105 return s.replace(/[\\/]+$/, "");
106 };
107 return !!a && !!b && norm(a) === norm(b);
108}
109
110/** Splits a captured stdout chunk into whole lines, dropping a truncated tail. */
111export function wholeLines(stdout: string, dropFirstPartial = false): string[] {
112 const lines = stdout.split("\n");
113 // `$.process.run` cuts stdout at the output limit, so the last line may be a
114 // fragment; a tail read's *first* line is a fragment for the same reason.
115 lines.pop();
116 if (dropFirstPartial) lines.shift();
117 return lines.filter(Boolean);
118}
119hooks/scan.ts 293 lines1// Turns ~/.claude/projects/<slug>/<session>.jsonl into one row per session,
2// cheaply enough to run on a 400 MB transcript store.
3//
4// Three things keep it light:
5// 1. stat-first. Files are ranked by mtime and only the newest are ever
6// opened; everything else is served from the cached index.
7// 2. Append-only reads. Transcripts only grow, so a file that gained 40
8// lines is read from line `cached.lines + 1`, not line 1.
9// 3. No JSON.parse in the hot loop. Every field the row needs is pulled
10// with a regex over the raw line; the only line ever parsed is the
11// single first user prompt. Attachment and tool-output lines — the
12// bulk of the bytes — are matched and discarded without being decoded.
13//
14// Nothing here touches the file system directly: see hooks/host.ts.
15import type { Host, Store, FileInfo } from "./host.ts";
16
17export const INDEX_VERSION = 3;
18export const INDEX_KEY = "session-index";
19
20const BIN_MS = 5 * 60_000; // activity histogram resolution
21const HEAD_LINES = 200; // enough for the opening prompt + cwd + branch
22const TAIL_LINES = 400; // enough for the latest ai-title + last activity
23const FULL_SCAN_MAX = 8 * 1024 * 1024; // above this, sample head+tail instead
24const LINES_PER_READ = 5_000; // bounded: stdout is cut at 4 MiB (measured)
25const READS_PER_FILE = 8; // …so a huge delta catches up over a few opens
26const FILES_CAP = 200; // stop collecting distinct edited paths here
27
28export type Entry = {
29 v: number;
30 path: string;
31 lines: number; // complete lines already folded into this record
32 size: number;
33 mtimeMs: number;
34 id: string;
35 aiTitle?: string | undefined; // newest wins — titles regenerate mid-session
36 summary?: string | undefined; // legacy fallback; real transcripts have none
37 firstPrompt?: string | undefined;
38 wrapperPrompt?: string | undefined; // first user turn even when it was a wrapper
39 cwd?: string | undefined;
40 branch?: string | undefined;
41 prompts: number; // human turns, not every timestamped line
42 files: string[]; // distinct file_path values seen in Edit/Write tool calls
43 bins: Record<string, number>; // wall-clock activity histogram, sparse
44 firstTs?: number | undefined;
45 lastTs?: number | undefined;
46 partial: boolean; // a huge file we sampled rather than read whole
47};
48
49export type Session = {
50 id: string;
51 title: string;
52 firstPrompt: string;
53 project: string; // display name
54 projectPath: string; // full cwd — needed to resume in the right directory
55 branch?: string | undefined;
56 lastActive: number;
57 prompts: number;
58 filesTouched: number;
59 activity: number[]; // 24 cells over the session's lifetime
60 partial: boolean;
61};
62
63export async function loadIndex(store: Store): Promise<Record<string, Entry>> {
64 const all = ((await store.get(INDEX_KEY).catch(() => null)) ?? {}) as Record<string, Entry>;
65 // Drop anything written by an older extractor rather than trusting it.
66 for (const [k, e] of Object.entries(all)) if (e?.v !== INDEX_VERSION) delete all[k];
67 return all;
68}
69
70/** Instant: the cached index only, no transcript is opened. */
71export async function cachedSessions(store: Store, host: Host, limit = 200): Promise<Session[]> {
72 const [files, index, names] = await Promise.all([host.list(), loadIndex(store), host.names().catch(() => ({}))]);
73 return files.slice(0, limit)
74 .map((f) => toSession(index[f.path], f, names))
75 .filter((s): s is Session => s !== null);
76}
77
78/**
79 * Brings the index up to date, newest file first, invoking `onRow` as each one
80 * lands so the open pane fills in progressively instead of blocking.
81 */
82export async function sync(
83 store: Store,
84 host: Host,
85 onRow: (s: Session) => void,
86 { refresh = 40 }: { refresh?: number } = {},
87): Promise<void> {
88 const [files, index, names] = await Promise.all([host.list(), loadIndex(store), host.names().catch(() => ({}))]);
89 let touched = 0;
90 let opened = 0;
91
92 for (const f of files) {
93 const cached = index[f.path];
94 if (cached && cached.mtimeMs === f.mtimeMs && cached.size === f.size) continue;
95 if (opened++ >= refresh) break;
96
97 const entry = await scan(host, f, cached).catch(() => null);
98 if (!entry) continue;
99 index[f.path] = entry;
100 touched++;
101 const row = toSession(entry, f, names);
102 if (row) onRow(row);
103 }
104
105 // Forget files that have been deleted, so the index can't grow forever.
106 const live = new Set(files.map((f) => f.path));
107 for (const k of Object.keys(index)) if (!live.has(k)) { delete index[k]; touched++; }
108
109 if (touched) await store.set(INDEX_KEY, index).catch(() => undefined);
110}
111
112async function scan(host: Host, f: FileInfo, cached?: Entry): Promise<Entry> {
113 // A file that shrank was rewritten, not appended to — start over.
114 const resumable = cached && cached.v === INDEX_VERSION && cached.size <= f.size && !cached.partial;
115 const e: Entry = resumable
116 ? { ...cached, mtimeMs: f.mtimeMs, size: f.size, files: [...cached.files], bins: { ...cached.bins } }
117 : { v: INDEX_VERSION, path: f.path, lines: 0, size: f.size, mtimeMs: f.mtimeMs,
118 id: idOf(f.path), prompts: 0, files: [], bins: {}, partial: false };
119
120 if (!resumable && f.size > FULL_SCAN_MAX) {
121 // Too big to read whole on first sight: take the opening prompt from the
122 // head and recent activity from the tail, and say so in the row.
123 for (const line of await host.sample(f.path, HEAD_LINES, "head")) fold(line, e);
124 for (const line of await host.sample(f.path, TAIL_LINES, "tail")) fold(line, e);
125 e.partial = true;
126 return e;
127 }
128
129 // Bounded per read, because the engine cuts stdout at 4 MiB; a file that
130 // gained more than we take catches up over the next few opens.
131 //
132 // A read that returned bytes but no complete line means one line is larger
133 // than the whole limit, so it can never come back whole. Stepping over it
134 // costs that record; not stepping over it stalls the file for good.
135 for (let i = 0; i < READS_PER_FILE; i++) {
136 const { lines, sawBytes } = await host.linesFrom(f.path, e.lines + 1, LINES_PER_READ);
137 if (!sawBytes) break; // end of file
138 if (!lines.length) { e.lines += 1; continue; } // see below
139 for (const line of lines) fold(line, e);
140 e.lines += lines.length;
141 if (lines.length < LINES_PER_READ) break;
142 }
143 return e;
144}
145
146// Field extractors. Deliberately regex, not JSON.parse: the lines that hold
147// most of the bytes (attachments, tool output) are ones we want to skip, and
148// decoding them just to throw them away is where the naive version dies.
149const RE_TS = /"timestamp":"([^"]+)"/;
150const RE_CWD = /"cwd":"((?:[^"\\]|\\.)*)"/;
151const RE_BRANCH = /"gitBranch":"((?:[^"\\]|\\.)*)"/;
152const RE_AI_TITLE = /"aiTitle":"((?:[^"\\]|\\.)*)"/;
153const RE_SUMMARY = /"summary":"((?:[^"\\]|\\.)*)"/;
154const RE_EDIT = /"name":"(?:Edit|Write|MultiEdit|NotebookEdit)"[^}]*?"file_path":"((?:[^"\\]|\\.)*)"/g;
155
156export function fold(line: string, e: Entry) {
157 const ts = RE_TS.exec(line)?.[1];
158 const t = ts ? Date.parse(ts) : NaN;
159
160 if (e.cwd === undefined) { const m = RE_CWD.exec(line)?.[1]; if (m) e.cwd = unescape(m); }
161 if (e.branch === undefined) {
162 const m = RE_BRANCH.exec(line)?.[1];
163 // "HEAD" is what a detached checkout — or a directory that is no repo at
164 // all — reports. Drawing "on HEAD" tells you nothing.
165 if (m && m !== "HEAD") e.branch = unescape(m);
166 }
167
168 if (line.includes('"type":"ai-title"')) {
169 // Titles regenerate as a session goes on; the newest is the honest one.
170 const m = RE_AI_TITLE.exec(line)?.[1];
171 if (m) e.aiTitle = unescape(m);
172 return;
173 }
174 if (line.includes('"type":"summary"')) {
175 const m = RE_SUMMARY.exec(line)?.[1];
176 if (m) e.summary = unescape(m);
177 return;
178 }
179
180 const isUser = line.includes('"type":"user"');
181 const isAssistant = !isUser && line.includes('"type":"assistant"');
182 if (!isUser && !isAssistant) return; // attachment, queue-operation, last-prompt, …
183
184 // Only user/assistant turns count as activity, which is what the strip is
185 // meant to show. Sub-agent chatter is somebody else's session.
186 if (line.includes('"isSidechain":true')) return;
187
188 if (!Number.isNaN(t)) {
189 if (e.firstTs === undefined || t < e.firstTs) e.firstTs = t;
190 if (e.lastTs === undefined || t > e.lastTs) e.lastTs = t;
191 const bin = String(Math.floor(t / BIN_MS));
192 e.bins[bin] = (e.bins[bin] ?? 0) + 1;
193 }
194
195 if (isUser) {
196 if (line.includes('"tool_result"')) return; // a tool's reply, not a human turn
197 e.prompts++;
198 if (e.firstPrompt === undefined) {
199 const text = firstText(line);
200 if (text && !isSystemWrapper(text)) e.firstPrompt = clean(text).slice(0, 300);
201 // A session whose every turn is a wrapper still deserves better than
202 // "Untitled": `clean` strips the tags, and what's inside one is usually
203 // the command that ran.
204 else if (text && e.wrapperPrompt === undefined) {
205 const inner = clean(text).slice(0, 80);
206 if (inner) e.wrapperPrompt = inner;
207 }
208 }
209 return;
210 }
211
212 if (e.files.length < FILES_CAP && line.includes('"tool_use"')) {
213 RE_EDIT.lastIndex = 0;
214 for (let m: RegExpExecArray | null; (m = RE_EDIT.exec(line)); ) {
215 const p = unescape(m[1] ?? "");
216 if (p && !e.files.includes(p)) e.files.push(p);
217 if (e.files.length >= FILES_CAP) break;
218 }
219 }
220}
221
222// The one place we decode a line — a single user turn, once per session.
223function firstText(line: string): string | undefined {
224 if (line.length > 1_000_000) return undefined;
225 try {
226 const c = (JSON.parse(line) as any)?.message?.content;
227 if (typeof c === "string") return c;
228 if (Array.isArray(c)) return c.map((b: any) => (typeof b?.text === "string" ? b.text : "")).join(" ");
229 } catch { /* malformed line — no prompt from it */ }
230 return undefined;
231}
232
233// Tags the CLI injects as a literal "user" turn: a slash command's banner, a
234// hook's stdout, the IDE's selection, CLAUDE.md reminders. Taking the first
235// user record at face value titles half your sessions "<system-reminder>".
236// (List lifted from trailant, which learned it against real transcripts.)
237const WRAPPERS = ["local-command-caveat", "local-command-stdout", "command-name", "command-message",
238 "command-args", "system-reminder", "user-prompt-submit-hook", "ide_selection", "ide_diagnostics",
239 "ide_opened_file", "recommended_plugins", "environment_context"];
240function isSystemWrapper(text: string): boolean {
241 const m = /^<([a-zA-Z][\w-]*)>/.exec(text.trim())?.[1];
242 return !!m && WRAPPERS.includes(m);
243}
244
245// ---------------------------------------------------------------- rows
246
247export function toSession(e: Entry | undefined, f: FileInfo, names: Record<string, string> = {}): Session | null {
248 if (!e || (e.prompts === 0 && !e.aiTitle)) return null;
249 // cwd from the transcript is authoritative; the directory name is a lossy
250 // fallback (Claude Code maps "/", ".", ":" and "\" all onto "-").
251 const projectPath = e.cwd || decodeProjectDir(dirName(f.path));
252 return {
253 id: e.id,
254 // A name you gave the session outranks everything: it is the only one of
255 // these that is a decision rather than a guess.
256 title: clean(names[e.id] || e.aiTitle || e.summary || e.firstPrompt || e.wrapperPrompt || "Untitled session"),
257 firstPrompt: e.firstPrompt ? clean(e.firstPrompt) : "",
258 project: baseName(projectPath) || "?",
259 projectPath,
260 branch: e.branch,
261 lastActive: e.lastTs ?? f.mtimeMs,
262 prompts: e.prompts,
263 filesTouched: e.files.length,
264 activity: strip(e),
265 partial: e.partial,
266 };
267}
268
269/** Re-buckets the sparse wall-clock histogram into 24 cells at read time. */
270function strip(e: Entry, n = 24): number[] {
271 const out = new Array<number>(n).fill(0);
272 const keys = Object.keys(e.bins);
273 if (!keys.length) return out;
274 const lo = e.firstTs ?? Number(keys[0]!) * BIN_MS;
275 const span = Math.max(1, (e.lastTs ?? lo) - lo);
276 for (const k of keys) {
277 const i = Math.min(n - 1, Math.max(0, Math.floor(((Number(k) * BIN_MS - lo) / span) * n)));
278 out[i]! += e.bins[k]!;
279 }
280 return out;
281}
282
283// No node:path here either — these are the only two pieces of it we need.
284// Either separator: a Windows cwd is `C:\a\b` and has no "/" in it, so a
285// "/"-only split handed back the whole path as the project's display name.
286const baseName = (p: string) => p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1);
287const dirName = (p: string) => baseName(p.slice(0, p.lastIndexOf("/")));
288const idOf = (p: string) => baseName(p).replace(/\.jsonl$/, "");
289const decodeProjectDir = (name: string) =>
290 name.startsWith("-") ? "/" + name.slice(1).replace(/-/g, "/") : name.replace(/-/g, "/");
291const unescape = (s: string) => { try { return JSON.parse(`"${s}"`) as string; } catch { return s; } };
292const clean = (s: string) => s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
293hooks/tags.ts 110 lines1// Tags and custom titles — the part Claude Code genuinely doesn't have.
2// (It does have session *names*: `~/.claude/sessions/*.json` carries a
3// user-set `name` with a `nameSource`, and `nameSource: "derived"` marks
4// the CLI's own auto-slug rather than something you chose. Tags, and
5// searching by them, are ours.)
6//
7// The trap this file exists to avoid: resuming a session mints a NEW
8// session id, so metadata keyed only by id is orphaned the moment you use
9// the session you just tagged. Every record therefore also carries a
10// fingerprint — project + opening prompt, which survive a resume — and
11// lookup falls back to it.
12import type { Store } from "./host.ts";
13import type { Session } from "./scan.ts";
14
15export const META_KEY = "session-meta";
16
17export type Meta = { title?: string | undefined; tags: string[]; fp?: string | undefined };
18
19// No node:crypto in a hooks environment, and none needed: this only has to
20// tell two sessions apart, not resist anybody.
21export function fingerprint(s: Session): string {
22 const text = `${s.projectPath}\u0000${s.firstPrompt.slice(0, 200)}`;
23 let h = 2166136261;
24 for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
25 return (h >>> 0).toString(36) + ":" + text.length.toString(36);
26}
27
28export async function loadMeta(store: Store): Promise<Record<string, Meta>> {
29 return ((await store.get(META_KEY).catch(() => null)) ?? {}) as Record<string, Meta>;
30}
31
32export const saveMeta = (store: Store, all: Record<string, Meta>) =>
33 store.set(META_KEY, all).catch(() => undefined);
34
35/** By id, else by fingerprint — so a resumed session keeps its tags. */
36export function metaFor(s: Session, all: Record<string, Meta>): Meta | undefined {
37 const direct = all[s.id];
38 if (direct) return direct;
39 const fp = fingerprint(s);
40 for (const m of Object.values(all)) if (m.fp === fp) return m;
41 return undefined;
42}
43
44export function setMeta(s: Session, all: Record<string, Meta>, input: string) {
45 const parsed = parseEdit(input);
46 all[s.id] = { ...parsed, fp: fingerprint(s) };
47}
48
49/**
50 * Folds `input` into what a session already carries, rather than replacing it.
51 *
52 * This is what `/switcher #wip` does from inside a session, where replacing
53 * would be wrong: tagging a session you're in the middle of shouldn't silently
54 * drop the title you gave it this morning. Tags add, `-#tag` removes, and a
55 * title is only overwritten when you actually type one.
56 *
57 * `session` is null for a session too new to have reached the index yet — its
58 * tags are still keyed by id, just without a fingerprint to carry them across
59 * a resume until the next index pass.
60 */
61export function mergeMeta(
62 id: string,
63 session: Session | null,
64 all: Record<string, Meta>,
65 input: string,
66): Meta {
67 const existing = (session ? metaFor(session, all) : all[id]) ?? { tags: [] };
68 const removed = [...input.matchAll(/-#([\w-]+)/g)].map((m) => (m[1] ?? "").toLowerCase());
69 const parsed = parseEdit(input.replace(/-#[\w-]+/g, " "));
70 const tags = [...new Set([...existing.tags, ...parsed.tags])].filter((t) => !removed.includes(t));
71 const meta: Meta = { title: parsed.title ?? existing.title, tags };
72 if (session) meta.fp = fingerprint(session);
73 all[id] = meta;
74 return meta;
75}
76
77/**
78 * The line pinned under the prompt: the session's tags, and its switcher title
79 * only when that isn't the name the prompt border already draws. Nothing to
80 * say is `undefined`, which clears the line rather than pinning an empty one.
81 */
82export function statusText(m: Meta | undefined, shownName?: string): string | undefined {
83 if (!m) return undefined;
84 const tags = m.tags.map((t) => "#" + t).join(" ");
85 const same = (a: string, b?: string) => !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
86 const title = m.title && !same(m.title, shownName) ? `“${m.title}”` : "";
87 return [tags, title].filter(Boolean).join(" · ") || undefined;
88}
89
90/** Drops records for sessions that no longer exist, so this can't grow forever. */
91export function pruneMeta(all: Record<string, Meta>, sessions: Session[]) {
92 const live = new Set(sessions.map((s) => s.id));
93 const fps = new Set(sessions.map(fingerprint));
94 for (const [id, m] of Object.entries(all))
95 if (!live.has(id) && !(m.fp && fps.has(m.fp))) delete all[id];
96}
97
98// "#auth #urgent Rename to this" → tags + optional title.
99//
100// `+#auth` is accepted as sugar for `#auth`: removal has to be marked, so `-#x`
101// exists, and once it does the symmetric `+#x` is the form people reach for.
102// Requiring it would be worse — `#wip` is what you type without thinking — so
103// both work and the sign is simply dropped here.
104export function parseEdit(input: string): Meta {
105 const text = input.replace(/[-+]#/g, "#");
106 const tags = [...text.matchAll(/#([\w-]+)/g)].map((m) => (m[1] ?? "").toLowerCase()).filter(Boolean);
107 const title = text.replace(/#[\w-]+/g, "").trim() || undefined;
108 return { title, tags: [...new Set(tags)] };
109}
110hooks/config.ts 75 lines1// Everything a future Claude Code release could collide with lives here.
2//
3// `/sessions` is exactly the kind of generic name the CLI is likely to claim
4// for itself — it already ships `--resume` with a searchable picker, and a
5// `/sessions` command is a short walk from that. So the mod does not hard-code
6// one name: it asks for several, in order, and keeps the first that registers.
7// The bare `/sessions` is last, tried only if nothing owns it.
8//
9// Precedent worth following: plugin-provided skills are addressed as
10// `plugin:skill`. If Mods namespace commands the same way, the qualified name
11// below is the one that can never collide, and the short aliases are a
12// convenience the CLI is free to take back.
13import type { Store } from "./host.ts";
14
15export const CONFIG_KEY = "session-switcher-config";
16
17export type Config = {
18 /** Tried in order; the first that registers wins. */
19 commands: string[];
20 /** Files opened per refresh before the rest are left to the cache. */
21 refresh: number;
22};
23
24export const DEFAULTS: Config = {
25 // Qualified first, short aliases after, generic last.
26 commands: ["session-switcher:sessions", "switcher", "sessions"],
27 refresh: 40,
28};
29
30export async function loadConfig(store: Store): Promise<Config> {
31 try {
32 const raw = ((await store.get(CONFIG_KEY)) ?? {}) as Partial<Config>;
33 return {
34 commands: Array.isArray(raw.commands) && raw.commands.length ? raw.commands : DEFAULTS.commands,
35 refresh: Number.isFinite(raw.refresh) ? Number(raw.refresh) : DEFAULTS.refresh,
36 };
37 } catch {
38 return DEFAULTS;
39 }
40}
41
42/**
43 * Registers the first candidate the engine accepts.
44 *
45 * `registerCommand` throws when a name is already spoken for — this is the
46 * documented way a mod finds out, and Anthropic's own `diff` mod does exactly
47 * this to cede `/diff` to the built-in "once the built-in stands down". So a
48 * taken name costs us that candidate, not the mod.
49 *
50 * There is no fallback beneath the last candidate: the surface gives a mod no
51 * way to bind a chord of its own, so a command name is the only way in.
52 */
53export type Spec = {
54 name: string;
55 description: string;
56 argumentHint?: string;
57 immediate?: true;
58};
59
60export async function registerFirst(
61 register: (spec: Spec) => Promise<unknown>,
62 candidates: string[],
63 spec: Omit<Spec, "name">,
64): Promise<string | null> {
65 for (const name of candidates) {
66 try {
67 await register({ ...spec, name });
68 return name;
69 } catch {
70 /* taken, or refused — try the next one */
71 }
72 }
73 return null;
74}
75hooks/view.ts 222 lines1// The switcher's drawing. One memorable element: each row carries a 24-cell
2// activity strip showing *when* in its life the session was busy, so a
3// burst-then-idle debugging session looks different from a steady refactor.
4//
5// This is a pure function — element constructors in, tree out — so it can be
6// tested without an engine. The constructors come from the surface's table
7// (`$.ui.resolve(e)`), not from ink or react: the same view draws on the
8// terminal, the desktop, VS Code and mobile.
9//
10// Interaction is built from what the element table actually offers. There is
11// no raw key handler, so:
12// · the filter is an `Input` (every printable key reaches it while focused)
13// · each of the ten rows on a page is a `Button` with a digit hotkey — the
14// page size and the single-digit hotkeys fit each other exactly
15// · `t` flips to tag mode, where a row's digit opens its tag field instead
16// of resuming it; `n` / `p` page; Escape closes the pane
17import type {
18 BoxProps, ButtonProps, ElementConstructor, InputProps, RenderElement, TextProps,
19} from "claude-code";
20import type { Session } from "./scan.ts";
21import type { Meta } from "./tags.ts";
22import { metaFor } from "./tags.ts";
23
24export const PAGE = 10;
25// Sixteen cells, not twenty-four: the strip sits left of every title, so its
26// width is a left margin on the whole list. Empty cells are a dot rather than
27// a space — a session with one busy minute should read as a shape with one
28// mark in it, not as a lone block floating in blank space.
29const CELLS = 16;
30const BLOCKS = "·▁▂▃▄▅▆▇█";
31// Project colours are hashed, so the same repo is always the same hue.
32const HUES = ["#7aa2f7", "#9ece6a", "#e0af68", "#bb9af7", "#7dcfff", "#f7768e", "#73daca", "#ff9e64"];
33const HOTKEYS = "1234567890";
34// Where a row's second line starts: under the title, past the strip and the
35// "N: " a plain Button draws.
36const INDENT = CELLS + 4;
37
38export type Model = {
39 sessions: Session[];
40 meta: Record<string, Meta>;
41 query: string;
42 page: number;
43 mode: "resume" | "tag";
44 editing: { id: string; text: string } | null;
45 syncing: boolean;
46};
47
48export type Actions = {
49 filter: (value: string) => void;
50 pick: (id: string) => void;
51 toggleMode: () => void;
52 turnPage: (by: number) => void;
53 editTags: (value: string) => void;
54};
55
56/**
57 * Only the elements this view draws; the table has more. `Input` is optional
58 * on purpose: not every surface's table offers one, and a switcher that can't
59 * take typing should still draw its rows rather than not draw at all.
60 */
61export type Elements = {
62 Box: ElementConstructor<BoxProps>;
63 Text: ElementConstructor<TextProps>;
64 Button: ElementConstructor<ButtonProps>;
65 Input?: ElementConstructor<InputProps> | undefined;
66};
67
68export function matches(s: Session, meta: Record<string, Meta>, query: string): boolean {
69 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
70 if (!words.length) return true;
71 const m = metaFor(s, meta);
72 const hay = [m?.title, s.title, s.firstPrompt, s.project, s.branch, ...(m?.tags ?? []).map((t) => "#" + t)]
73 .join(" ").toLowerCase();
74 return words.every((w) => hay.includes(w));
75}
76
77export function view(ui: Elements, model: Model, actions: Actions): RenderElement {
78 const { Box, Text, Button, Input } = ui;
79 const rows = model.sessions.filter((s) => matches(s, model.meta, model.query));
80 const pages = Math.max(1, Math.ceil(rows.length / PAGE));
81 // Clamped: a filter that shrinks the list must not strand the view on a
82 // page that no longer exists.
83 const page = Math.min(Math.max(0, model.page), pages - 1);
84 const visible = rows.slice(page * PAGE, page * PAGE + PAGE);
85
86 const header = Box({
87 justifyContent: "space-between",
88 children: [
89 Text({ bold: true, children: model.mode === "tag" ? "Sessions — tag which?" : "Sessions" }),
90 Text({ dimColor: true, children: `${rows.length} found page ${page + 1}/${pages}${model.syncing ? " indexing…" : ""}` }),
91 ],
92 });
93
94 const filter = Input ? Input({
95 key: "filter",
96 // No label: the surface draws its own separator after one, so "› " came
97 // out as "› :".
98 placeholder: "filter by title, project, branch or #tag",
99 value: model.query,
100 submitLabel: "filter",
101 autoFocus: true,
102 onInput: (value: string) => actions.filter(value),
103 onSubmit: (value: string) => actions.filter(value),
104 }) : Text({ dimColor: true, children: model.query ? `filter: ${model.query}` : "filter unavailable on this surface" });
105
106 const body = visible.length === 0
107 ? [Text({ dimColor: true, children: `No sessions match “${model.query}”. Clear the filter to see them all.` })]
108 : visible.map((s, i) => row(ui, s, model, actions, HOTKEYS[i] ?? ""));
109
110 const editor = model.editing && Input
111 ? Input({
112 key: "tags",
113 label: "Tag or rename",
114 placeholder: "#wip #mods or a new title or both",
115 value: model.editing.text,
116 submitLabel: "save",
117 autoFocus: true,
118 onInput: () => {},
119 onSubmit: (value: string) => actions.editTags(value),
120 })
121 // Same squeeze as the meta line: in a narrow pane "Esc close" drew as
122 // "Esc" / "clos" / "e". Controls wrap onto another line as whole items;
123 // columnGap, not gap, or the wrapped line lands two blank lines down.
124 : Box({
125 marginTop: 1,
126 columnGap: 2,
127 flexWrap: "wrap",
128 children: [
129 Button({ key: "mode", hotkey: "t", plain: true, dimColor: true,
130 label: model.mode === "tag" ? "resume mode" : "tag a session",
131 onPress: () => actions.toggleMode() }),
132 Button({ key: "prev", hotkey: "p", plain: true, dimColor: true, label: "prev",
133 onPress: () => actions.turnPage(-1) }),
134 Button({ key: "next", hotkey: "n", plain: true, dimColor: true, label: "next",
135 onPress: () => actions.turnPage(1) }),
136 Box({ flexShrink: 0, children: [Text({ dimColor: true, children: "Esc close" })] }),
137 Box({ flexShrink: 0, children: [Text({ dimColor: true, children: "· /switcher #tag tags this session" })] }),
138 ],
139 });
140
141 return Box({
142 flexDirection: "column",
143 paddingX: 1,
144 children: [header, Box({ marginBottom: 1, children: [filter] }), ...body, editor],
145 });
146}
147
148function row(ui: Elements, s: Session, model: Model, actions: Actions, hotkey: string): RenderElement {
149 const { Box, Text, Button } = ui;
150 const m = metaFor(s, model.meta);
151 const colour = hue(s.project);
152 const isEditing = model.editing?.id === s.id;
153
154 return Box({
155 key: s.id,
156 flexDirection: "column",
157 marginBottom: isEditing ? 1 : 0,
158 children: [
159 Box({
160 children: [
161 Text({ color: colour, children: spark(s.activity) + " " }),
162 Box({
163 flexGrow: 1,
164 children: [
165 Button({
166 key: `row:${s.id}`,
167 hotkey,
168 plain: true,
169 label: m?.title ?? s.title,
170 onPress: () => actions.pick(s.id),
171 }),
172 ],
173 }),
174 Text({ dimColor: true, children: " " + ago(s.lastActive) }),
175 ],
176 }),
177 // One meta line, not three. Ten rows have to fit the pane alongside the
178 // filter and the controls, and the opening prompt is still searchable
179 // whether or not it is drawn.
180 // When the line is wider than the pane, every child gets squeezed, and a
181 // squeezed Text wraps inside its own narrow column — "Projects" came out
182 // as "Project" over "s", and a long branch interleaved with the project.
183 // So the project and tags never shrink, and the branch truncates instead.
184 Box({
185 marginLeft: INDENT,
186 children: [
187 Box({ flexShrink: 0, children: [Text({ color: colour, children: s.project })] }),
188 ...(s.branch ? [Text({ dimColor: true, wrap: "truncate-end", children: ` on ${s.branch}` })] : []),
189 Text({ dimColor: true, wrap: "truncate-end",
190 children: ` · ${s.prompts} ${s.prompts === 1 ? "prompt" : "prompts"}`
191 + (s.filesTouched ? `, ${s.filesTouched} files` : "")
192 + (s.partial ? " · sampled" : "") }),
193 ...(m?.tags ?? []).map((t) => Box({ flexShrink: 0, children: [Text({ color: "magenta", children: ` #${t}` })] })),
194 ],
195 }),
196 ],
197 });
198}
199
200export function spark(v: number[], cells = CELLS): string {
201 // The index keeps 24 buckets; fold them down to however many the strip draws.
202 const per = v.length / cells;
203 const folded = Array.from({ length: cells }, (_, i) =>
204 v.slice(Math.floor(i * per), Math.max(Math.floor((i + 1) * per), Math.floor(i * per) + 1))
205 .reduce((a, b) => a + b, 0));
206 const max = Math.max(...folded, 1);
207 return folded.map((x) => (x ? BLOCKS[Math.max(1, Math.round((x / max) * 8))] ?? "█" : BLOCKS[0]!)).join("");
208}
209
210export const hue = (s: string): string =>
211 HUES[[...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % HUES.length] ?? HUES[0]!;
212
213export function ago(t: number, now = Date.now()): string {
214 const m = Math.round((now - t) / 60000);
215 if (m < 1) return "now";
216 if (m < 60) return `${m}m`;
217 const h = Math.round(m / 60);
218 if (h < 24) return `${h}h`;
219 const d = Math.round(h / 24);
220 return d < 30 ? `${d}d` : new Date(t).toISOString().slice(0, 10);
221}
222