SLOPSHOPPER

session-switcher

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…

newpanecommandstatusprocess
v0.3.1MITupdated 2026-09-24semanticintent/claude-session-switcher
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-switcher
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ Sessions 0 found page 1/1 indexing… │ ┃ filter by title, project, branch or #tag ⏎ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No sessions match “”. Clear the filter to ⏺ Update(src/auth.ts) │ ┃ see them all. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ t: tag a session p: prev n: next Esc clo ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /switcher │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
Sessions 0 found page 1/1 indexing… filter by title, project, branch or #tag ⏎ filter No sessions match “”. Clear the filter to see them all. t: tag a session p: prev n: next Esc close · /switcher
README

Session Switcher — a Claude Mod

<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
  • an activity strip: 16 cells tracing when in its life the session was busy, tinted by project — burst-then-idle, steady and bursty each read differently
  • the title, in order of how deliberate it is: a title you set here → the name you gave the session (claude -n, or the picker's rename) → the newest ai-title record → your first real prompt
  • its digit hotkey, relative last-active time, project, branch, counts and #tags

Keys

There 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.

KeyAction
typefilter across title, prompt, project, branch, #tag
1–9, 0resume that row — in place if it's from this project, otherwise print the command with its directory (why)
ttag mode — a digit then opens that row's tag field
n / pnext / previous page
Escclose 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.

Tagging

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.

If you already name sessions

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
  • The date is already there. Every row shows its own last-active time, and falls back to a calendar date past a month. Spending the title on Sep 10 costs you the widest column in the list.
  • The facets compose. The filter ANDs its words, so #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.
  • The title is then free to say what the session was about — the one thing none of the three facets tell you, and the thing you actually need when you come back.

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.

What's worth tagging

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:

  • State — #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.
  • Threads that cross repos — one line of thinking spanning three projects is exactly what a project filter can't express.
  • Context that isn't in the code — #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.

Install

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.

Layout

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.

How it stays fast

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:

  1. stat first, cache on disk. Every known file is 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.
  2. Append-only reads. The engine cuts a subprocess's stdout at 4 MiB — measured, not documented: the largest capture came back at 3.99 MiB. Reads are bounded so that cut is routine rather than exceptional, and a read that returns bytes but no whole line (one record larger than the entire limit) steps over that record instead of stalling the file forever. Transcripts only grow, so a session that gained 40 lines is read from line 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.
  3. No 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.
  4. Head+tail sampling above 64 MB, so a runaway session costs two bounded reads on first sight instead of a 226 MB one. Those rows say so in the detail line.
  5. The list opens on the cache and fills in. 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.

Transcript facts this depends on

Verified against real files here, not assumed:

  • The title lives in 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.
  • The first 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 -).
  • Sessions are 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.
  • Noise record types (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.

Where it writes

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.

Command naming, and not colliding with the CLI

/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."

Development

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.

Verified on 2.1.278

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 readsbytes read
first open (cold index)5215.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.

The mascot

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.

License

MIT

Source 6 files
hooks/register.ts 348 lines
1// 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 };
348
hooks/host.ts 119 lines
1// 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}
119
hooks/scan.ts 293 lines
1// 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();
293
hooks/tags.ts 110 lines
1// 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}
110
hooks/config.ts 75 lines
1// 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}
75
hooks/view.ts 222 lines
1// 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