SLOPSHOPPER

session-bookmarks

Explicit bookmarks for Claude Code sessions you want to come back to. /bm saves one, /bm open lists them with a copyable resume command.

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-bookmarks
│ ┃ session-bookmarks ✕ › fix the failing auth test and add an audit log call │ ┃ 0 bookmarks, newest first · │ ┃ [ r Refresh ] [ c Close ] ⏺ Read(src/auth.ts) │ ┃ No bookmarks yet. /bm saves this session. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bm │ ⎿ session-bookmarks: Bookmarked "fix the failing auth test" (title │ ⎿ session-bookmarks: app, session preview-. 1 bookmark in total, / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-bookmarks
0 bookmarks, newest first · [ r Refresh ] [ c Close ] No bookmarks yet. /bm saves this session.
README

Session Bookmarks

Saves a title and a short note with the project and session reference, so you can find the conversation again.

See the complete guide and creation prompt.

Test from the kit root:

claude plugin validate plugins/session-bookmarks
claude plugin test plugins/session-bookmarks
Source 2 files
hooks/session-bookmarks.mjs 876 lines
1// Session Bookmarks: explicit bookmarks for sessions you want to come back to.
2//
3// Nothing is saved unless the person asks (/bm, or the model's bookmark_session
4// tool when they say "bookmark this"). A bookmark holds a title, a short note,
5// the project (cwd, folder name, git branch), the session id, the transcript's
6// path and a timestamp. Never the transcript itself: the one short model call
7// that writes a title reads a clipped digest in memory and only its answer is kept.
8//
9// Storage is a plain JSON file (default ~/.claude/bookmarks/bookmarks.json), not
10// $.store, because $.store is keyed per install and bookmarks must survive moving
11// from a --plugin-dir load to a marketplace install. Writes go to a temp file
12// that is then renamed over the old one; an unreadable file is backed up and a
13// fresh list started.
14
15const PANE = "session-bookmarks";
16const PANE_TITLE = "Bookmarks";
17const PANE_STATE = { plugin: "session-bookmarks", key: "pane" };
18const EMPTY_PANE = { rows: [], notice: "", file: "", loadedAt: 0 };
19
20const FILE_VERSION = 1;
21const TITLE_MAX = 60;
22const NOTE_MAX = 400;
23const DIGEST_MAX = 4000;
24
25const TOOL_SAVE = "bookmark_session";
26const TOOL_FIND = "find_bookmarks";
27
28// The only fields a bookmark ever holds.
29export const FIELDS = [
30  "id",
31  "title",
32  "titleSource",
33  "note",
34  "project",
35  "branch",
36  "cwd",
37  "sessionId",
38  "transcriptPath",
39  "createdAt",
40  "createdIso",
41];
42
43// A command word counts only in its own shape, so "/bm resume the auth work"
44// is a note, while "/bm resume 2" resumes. An empty shape mismatch shows usage.
45export function parseArgs(args) {
46  const text = String(args ?? "").trim();
47  const [first = ""] = text.split(/\s+/);
48  const word = first.toLowerCase();
49  const tail = text.slice(first.length).trim();
50  const verb = { ls: "list", search: "find", rm: "delete" }[word] ?? word;
51  const shapes = {
52    save: () => true,
53    help: () => tail === "",
54    list: () => tail === "",
55    open: () => tail === "",
56    close: () => tail === "",
57    find: () => tail !== "",
58    resume: () => /^\d+$/.test(tail),
59    delete: () => /^\d+$/.test(tail),
60    rename: () => /^\d+\s+\S/.test(tail),
61  };
62  if (shapes[verb]?.()) return { verb, tail };
63  if (shapes[verb] && tail === "") return { verb: "usage", tail: verb };
64  return { verb: "save", tail: text };
65}
66
67const USAGE = {
68  find: "Usage is /bm find <words>",
69  resume: "Usage is /bm resume <n>, the number from /bm list",
70  delete: "Usage is /bm delete <n>, the number from /bm list",
71  rename: "Usage is /bm rename <n> <new title>",
72};
73
74export const TITLE_SYSTEM =
75  "You label coding sessions so a person can find them again later. You answer in exactly the two-line format asked for, nothing else.";
76
77export function titlePrompt(digest) {
78  return [
79    "Below is a clipped digest of a Claude Code session. Write a bookmark for it.",
80    "",
81    "<digest>",
82    digest,
83    "</digest>",
84    "",
85    "Answer with exactly two lines:",
86    "TITLE: a specific title of at most 60 characters naming the task, not the tool",
87    "NOTE: one or two short sentences on where the session left off and the next step",
88    "",
89    "No quotes, no markdown, no em dashes.",
90  ].join("\n");
91}
92
93export function register(on, options) {
94  const configured = typeof options?.bookmarksFile === "string" ? options.bookmarksFile.trim() : "";
95  const titleModel = (typeof options?.titleModel === "string" && options.titleModel.trim()) || "haiku";
96
97  on("session.start", async ($, e, next) => {
98    const started = await next(e);
99    await $.command.register({
100      name: "bm",
101      description: "Bookmark this session, or list, find, open, resume, delete bookmarks",
102      argumentHint: "[note] | list | find <words> | open | resume <n> | delete <n> | help",
103    });
104    await $.tool.register({
105      name: TOOL_SAVE,
106      description:
107        "Bookmark the CURRENT Claude Code session so the user can find and resume it later. " +
108        "Call this only when the user explicitly asks to bookmark or save this session (for example \"bookmark this\"). " +
109        "Never call it on your own initiative. Pass a short specific title (at most 60 characters) and a one or two sentence note " +
110        "on where the work left off and the next step. Returns the saved bookmark.",
111      inputSchema: {
112        type: "object",
113        properties: {
114          title: { type: "string", description: "Short specific title, at most 60 characters" },
115          note: { type: "string", description: "One or two sentences on where the session left off and the next step" },
116        },
117      },
118    });
119    await $.tool.register({
120      name: TOOL_FIND,
121      description:
122        "Search the user's saved session bookmarks by words in the title, note or project name. " +
123        "Use it when the user asks to find, list or resume a bookmarked session. An empty query lists them all, newest first. " +
124        "Each match comes with the exact shell command that resumes it, which must be run in a new terminal.",
125      inputSchema: {
126        type: "object",
127        properties: { query: { type: "string", description: "Words to look for; empty lists every bookmark" } },
128      },
129    });
130    // The pane can outlive a hot reload; give it fresh rows.
131    const isUp = (await $.ui.panes()).some((p) => p.id === PANE);
132    if (isUp) {
133      const io = {
134        read: (p) => $.fs.read(p),
135        write: (p, t) => $.fs.write(p, t),
136        exists: (p) => $.fs.exists(p),
137        list: (p) => $.fs.list(p),
138        run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
139        home: () => $.env.get("HOME"),
140        configDirEnv: () => $.env.get("CLAUDE_CONFIG_DIR"),
141        fileEnv: () => $.env.get("SESSION_BOOKMARKS_FILE"),
142        now: () => $.clock.now(),
143        configured,
144      };
145      const view = await paneView(io, "");
146      await $.state.set(PANE_STATE, view);
147    }
148    return started;
149  });
150
151  on("command.run", { command: "bm" }, async ($, e) => {
152    const io = {
153      read: (p) => $.fs.read(p),
154      write: (p, t) => $.fs.write(p, t),
155      exists: (p) => $.fs.exists(p),
156      list: (p) => $.fs.list(p),
157      run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
158      home: () => $.env.get("HOME"),
159        configDirEnv: () => $.env.get("CLAUDE_CONFIG_DIR"),
160        fileEnv: () => $.env.get("SESSION_BOOKMARKS_FILE"),
161      now: () => $.clock.now(),
162      sessionId: () => $.session.id(),
163      cwd: () => $.session.cwd(),
164      messages: () => $.session.messages(),
165      complete: (req) => $.model.complete(req),
166      configured,
167      titleModel,
168    };
169    const { verb, tail } = parseArgs(e.args);
170
171    // Keep an open pane in step after anything that changes the list.
172    const syncPane = async (notice) => {
173      const isUp = (await $.ui.panes()).some((p) => p.id === PANE);
174      if (!isUp) return;
175      const view = await paneView(io, notice ?? "");
176      await $.state.set(PANE_STATE, view);
177    };
178
179    if (verb === "usage") return { text: USAGE[tail] ?? helpText(await bookmarksFile(io)) };
180
181    if (verb === "help") return { text: helpText(await bookmarksFile(io)) };
182
183    if (verb === "close") {
184      const isUp = (await $.ui.panes()).some((p) => p.id === PANE);
185      if (!isUp) return { text: "The Bookmarks pane is not open." };
186      await $.ui.close({ id: PANE });
187      return { text: "Bookmarks pane closed." };
188    }
189
190    if (verb === "open") {
191      const isUp = (await $.ui.panes()).some((p) => p.id === PANE);
192      if (isUp) {
193        await $.ui.close({ id: PANE });
194        return { text: "Bookmarks pane closed." };
195      }
196      const view = await paneView(io, "");
197      await $.state.set(PANE_STATE, view);
198      // Focus so the row hotkeys work at once; Esc hands the keys back.
199      const opened = await $.ui.open({
200        id: PANE,
201        title: PANE_TITLE,
202        focus: true,
203        rows: Math.min(22, 5 + 2 * Math.max(1, view.rows.length) + (view.notice ? 2 : 0)),
204      });
205      const count = `${view.rows.length} bookmark${view.rows.length === 1 ? "" : "s"}`;
206      if (!opened.isPlaced) {
207        const loaded = await loadBookmarks(io);
208        return { text: `The pane could not be placed (${opened.reason}).\n${formatList(await withFlags(io, sortNewest(loaded.bookmarks)), await io.now())}` };
209      }
210      return { text: `Bookmarks pane open, ${count}. Number keys copy a resume command, Esc returns to the prompt, /bm close hides it.` };
211    }
212
213    if (verb === "list") {
214      const loaded = await loadBookmarks(io);
215      const rows = await withFlags(io, sortNewest(loaded.bookmarks));
216      return { text: withWarning(loaded.warning, formatList(rows, await io.now())) };
217    }
218
219    if (verb === "find") {
220      const loaded = await loadBookmarks(io);
221      const rows = await withFlags(io, sortNewest(loaded.bookmarks));
222      const hits = search(rows, tail);
223      const body = hits.length === 0 ? `No bookmark matches "${tail}".` : formatList(hits, await io.now(), `Bookmarks matching "${tail}"`);
224      return { text: withWarning(loaded.warning, body) };
225    }
226
227    if (verb === "resume") {
228      const loaded = await loadBookmarks(io);
229      const picked = pick(sortNewest(loaded.bookmarks), tail);
230      if (picked.error) return { text: withWarning(loaded.warning, picked.error) };
231      const cmd = resumeCommand(picked.bookmark);
232      const copied = await $.ui.copy({ text: cmd });
233      const head = copied.isCopied ? "Copied to the clipboard. Paste it in a new terminal" : `Could not copy (${copied.reason}). Run it in a new terminal`;
234      await syncPane(copied.isCopied ? `Copied #${picked.n}: ${cmd}` : "");
235      return { text: withWarning(loaded.warning, `${head}\n${cmd}\nA mod cannot switch the session you are in, so resuming happens in a new terminal.`) };
236    }
237
238    if (verb === "delete") {
239      const loaded = await loadBookmarks(io);
240      const picked = pick(sortNewest(loaded.bookmarks), tail);
241      if (picked.error) return { text: withWarning(loaded.warning, picked.error) };
242      await deleteBookmark(io, picked.bookmark.id);
243      await syncPane(`Deleted "${picked.bookmark.title}".`);
244      return { text: withWarning(loaded.warning, `Deleted bookmark ${picked.n}, "${picked.bookmark.title}".`) };
245    }
246
247    if (verb === "rename") {
248      const [num = "", ...words] = tail.split(/\s+/);
249      const title = cleanTitle(words.join(" "));
250      if (!num || !title) return { text: "Usage is /bm rename <n> <new title>" };
251      const loaded = await loadBookmarks(io);
252      const picked = pick(sortNewest(loaded.bookmarks), num);
253      if (picked.error) return { text: withWarning(loaded.warning, picked.error) };
254      await renameBookmark(io, picked.bookmark.id, title);
255      await syncPane(`Renamed #${picked.n}.`);
256      return { text: withWarning(loaded.warning, `Bookmark ${picked.n} is now "${title}".`) };
257    }
258
259    // Anything else saves a bookmark; the words are the note.
260    const saved = await saveCurrent(io, { note: tail });
261    await syncPane(`Saved "${saved.bookmark.title}".`);
262    return { text: withWarning(saved.warning, savedText(saved)) };
263  });
264
265  on("tool.call", { tool: "mcp__session-bookmarks__bookmark_session" }, async ($, e) => {
266    const io = {
267      read: (p) => $.fs.read(p),
268      write: (p, t) => $.fs.write(p, t),
269      exists: (p) => $.fs.exists(p),
270      list: (p) => $.fs.list(p),
271      run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
272      home: () => $.env.get("HOME"),
273        configDirEnv: () => $.env.get("CLAUDE_CONFIG_DIR"),
274        fileEnv: () => $.env.get("SESSION_BOOKMARKS_FILE"),
275      now: () => $.clock.now(),
276      sessionId: () => $.session.id(),
277      cwd: () => $.session.cwd(),
278      messages: () => $.session.messages(),
279      complete: (req) => $.model.complete(req),
280      configured,
281      titleModel,
282    };
283    const saved = await saveCurrent(io, {
284      title: typeof e.title === "string" ? e.title : "",
285      note: typeof e.note === "string" ? e.note : "",
286    });
287    const isUp = (await $.ui.panes()).some((p) => p.id === PANE);
288    if (isUp) await $.state.set(PANE_STATE, await paneView(io, `Saved "${saved.bookmark.title}".`));
289    return { result: withWarning(saved.warning, savedText(saved)) };
290  });
291
292  on("tool.call", { tool: "mcp__session-bookmarks__find_bookmarks" }, async ($, e) => {
293    const io = {
294      read: (p) => $.fs.read(p),
295      write: (p, t) => $.fs.write(p, t),
296      exists: (p) => $.fs.exists(p),
297      list: (p) => $.fs.list(p),
298      run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
299      home: () => $.env.get("HOME"),
300        configDirEnv: () => $.env.get("CLAUDE_CONFIG_DIR"),
301        fileEnv: () => $.env.get("SESSION_BOOKMARKS_FILE"),
302      now: () => $.clock.now(),
303      configured,
304    };
305    const query = typeof e.query === "string" ? e.query.trim() : "";
306    const loaded = await loadBookmarks(io);
307    const rows = await withFlags(io, sortNewest(loaded.bookmarks));
308    const hits = query ? search(rows, query) : rows;
309    const now = await io.now();
310    const body =
311      hits.length === 0
312        ? query
313          ? `No bookmark matches "${query}".`
314          : "No bookmarks saved yet."
315        : formatList(hits, now, query ? `Bookmarks matching "${query}"` : "Bookmarks", { withCommand: true });
316    return { result: withWarning(loaded.warning, body) };
317  });
318
319  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
320    const { Box, Text, Button } = $.ui.resolve(e);
321    const { value: pane = EMPTY_PANE } = await $.state.get(PANE_STATE);
322    const width = Math.max(30, e.props.bodyColumns ?? 80);
323    const height = Math.max(8, e.viewport?.rows ?? 24);
324    const dim = (children) => Text({ dimColor: true, wrap: "truncate", children });
325
326    const refresh = async (notice) => {
327      const io = {
328        read: (p) => $.fs.read(p),
329        write: (p, t) => $.fs.write(p, t),
330        exists: (p) => $.fs.exists(p),
331        list: (p) => $.fs.list(p),
332        run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
333        home: () => $.env.get("HOME"),
334        configDirEnv: () => $.env.get("CLAUDE_CONFIG_DIR"),
335        fileEnv: () => $.env.get("SESSION_BOOKMARKS_FILE"),
336        now: () => $.clock.now(),
337        configured,
338      };
339      await $.state.set(PANE_STATE, await paneView(io, notice));
340    };
341
342    const children = [
343      dim(clip(`${pane.rows.length} bookmark${pane.rows.length === 1 ? "" : "s"}, newest first  ·  ${shortPath(pane.file, Math.max(10, width - 30))}`, width)),
344      Box({
345        flexDirection: "row",
346        gap: 1,
347        children: [
348          Button({ key: "refresh", label: "r Refresh", hotkey: "r", onPress: () => refresh("") }),
349          Button({ key: "close", label: "c Close", hotkey: "c", role: "dismiss", onPress: () => $.ui.close({ id: PANE }) }),
350        ],
351      }),
352    ];
353    if (pane.notice) children.push(Text({ color: "cyan", children: pane.notice }));
354    if (pane.rows.length === 0) children.push(dim("No bookmarks yet. /bm saves this session."));
355
356    const room = Math.max(1, Math.floor((height - 6 - (pane.notice ? 3 : 0)) / 2));
357    for (const row of pane.rows.slice(0, room)) {
358      const hotkey = row.n <= 9 ? String(row.n) : undefined;
359      const flags = [];
360      if (row.isCwdMissing) flags.push("folder gone");
361      if (row.isTranscriptMissing) flags.push("transcript missing");
362      const where = row.branch ? `${row.project} (${row.branch})` : row.project;
363      children.push(
364        Box({
365          flexDirection: "row",
366          gap: 1,
367          children: [
368            Button({
369              key: `resume:${row.id}`,
370              label: `${hotkey ? hotkey + " " : ""}Resume`,
371              hotkey,
372              variant: "primary",
373              onPress: async (press) => {
374                const cmd = resumeCommand(row);
375                const copied = await $.ui.copy({ text: cmd, surface: press.surface });
376                const notice = copied.isCopied
377                  ? `Copied. Paste in a new terminal: ${cmd}`
378                  : `Could not copy (${copied.reason}). Run in a new terminal: ${cmd}`;
379                const { value: now = EMPTY_PANE } = await $.state.get(PANE_STATE);
380                await $.state.set(PANE_STATE, { ...now, notice });
381                $.ui.toast(copied.isCopied ? `Resume command for #${row.n} copied` : "Copy failed, the command is in the pane");
382              },
383            }),
384            Button({
385              key: `delete:${row.id}`,
386              label: "Delete",
387              onPress: async () => {
388                let answer;
389                try {
390                  answer = await $.ui.ask(`Delete bookmark ${row.n}, "${clip(row.title, 60)}"?`, {
391                    options: ["Delete", "Keep"],
392                    header: "Bookmarks",
393                  });
394                } catch {
395                  answer = undefined;
396                }
397                if (answer !== "Delete") {
398                  await refresh("Kept, nothing deleted.");
399                  return;
400                }
401                const io = {
402                  read: (p) => $.fs.read(p),
403                  write: (p, t) => $.fs.write(p, t),
404                  exists: (p) => $.fs.exists(p),
405                  list: (p) => $.fs.list(p),
406                  run: (argv) => $.process.run(argv, { timeoutMs: 5000 }),
407                  home: () => $.env.get("HOME"),
408        configDirEnv: () => $.env.get("CLAUDE_CONFIG_DIR"),
409        fileEnv: () => $.env.get("SESSION_BOOKMARKS_FILE"),
410                  now: () => $.clock.now(),
411                  configured,
412                };
413                await deleteBookmark(io, row.id);
414                await refresh(`Deleted "${row.title}".`);
415              },
416            }),
417            Text({ bold: true, wrap: "truncate", children: `${row.n}. ${row.title}` }),
418          ],
419        }),
420      );
421      const meta = [where, ago(pane.loadedAt - row.createdAt)];
422      if (flags.length) meta.push(flags.join(", "));
423      if (row.note) meta.push(row.note);
424      children.push(
425        Text({
426          dimColor: !flags.length,
427          color: flags.length ? "yellow" : undefined,
428          wrap: "truncate",
429          children: clip(`    ${meta.join("  ·  ")}`, width),
430        }),
431      );
432    }
433    if (pane.rows.length > room) children.push(dim(`${pane.rows.length - room} more, /bm list shows all`));
434    return Box({ flexDirection: "column", children });
435  });
436}
437
438// ---- saving -----------------------------------------------------------------
439
440// Builds a bookmark of the current session and appends it to the file.
441export async function saveCurrent(io, { title = "", note = "" } = {}) {
442  const cwd = (await io.cwd()) || "";
443  const sessionId = (await io.sessionId()) || "";
444  const now = await io.now();
445  const project = baseName(cwd) || cwd || "unknown";
446  const branch = await gitBranch(io, cwd);
447  const transcriptPath = await findTranscript(io, sessionId, cwd);
448
449  let finalTitle = cleanTitle(title);
450  let finalNote = cleanNote(note);
451  let titleSource = finalTitle ? "given" : "";
452  if (!finalTitle && finalNote) {
453    finalTitle = cleanTitle(firstSentence(finalNote));
454    titleSource = "note";
455  }
456  if (!finalTitle) {
457    const made = await generateTitle(io, project);
458    finalTitle = made.title;
459    titleSource = made.source;
460    if (!finalNote) finalNote = made.note;
461  }
462
463  const bookmark = {
464    id: `bm-${now.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
465    title: finalTitle,
466    titleSource,
467    note: finalNote,
468    project,
469    branch,
470    cwd,
471    sessionId,
472    transcriptPath,
473    createdAt: now,
474    createdIso: new Date(now).toISOString(),
475  };
476
477  const loaded = await loadBookmarks(io);
478  const list = [...loaded.bookmarks, bookmark];
479  await saveBookmarks(io, loaded.file, list);
480  return { bookmark, count: list.length, file: loaded.file, warning: loaded.warning };
481}
482
483// One bounded model call over a clipped digest; the first prompt when it fails.
484export async function generateTitle(io, project) {
485  let messages = [];
486  try {
487    const got = await io.messages();
488    messages = Array.isArray(got) ? got : [];
489  } catch {
490    messages = [];
491  }
492  const firstPrompt = firstUserPrompt(messages);
493  const fallback = () => {
494    const title = cleanTitle(firstPrompt) || `Session in ${project}`;
495    return { title, note: "", source: "first-prompt" };
496  };
497  const digest = buildDigest(messages);
498  if (!digest) return fallback();
499  let reply;
500  try {
501    reply = await io.complete({
502      model: io.titleModel || "haiku",
503      system: TITLE_SYSTEM,
504      prompt: titlePrompt(digest),
505      maxTokens: 160,
506      timeoutMs: 20000,
507    });
508  } catch {
509    return fallback();
510  }
511  if (!reply?.isAnswered) return fallback();
512  const parsed = parseTitleReply(reply.text ?? "");
513  if (!parsed.title) return fallback();
514  return { title: parsed.title, note: parsed.note, source: "model" };
515}
516
517export function parseTitleReply(text) {
518  const title = /^\s*\**TITLE\**\s*:\s*(.+)$/im.exec(text)?.[1] ?? "";
519  const note = /^\s*\**NOTE\**\s*:\s*(.+)$/im.exec(text)?.[1] ?? "";
520  return { title: cleanTitle(title), note: cleanNote(note) };
521}
522
523// The person's real prompts and the replies, newest last, clipped. Kept in memory only.
524export function buildDigest(messages) {
525  const talk = messages
526    .map((m) => ({ role: m.role, text: m.role === "user" ? userText(m.text) : oneLine(m.text ?? "") }))
527    .filter((m) => m.text);
528  if (talk.length === 0) return "";
529  const parts = [];
530  const first = talk.find((m) => m.role === "user");
531  if (first) parts.push(`First request: ${clip(first.text, 400)}`);
532  for (const m of talk.slice(-8)) parts.push(`${m.role === "user" ? "User" : "Assistant"}: ${clip(m.text, 400)}`);
533  let digest = parts.join("\n");
534  if (digest.length > DIGEST_MAX) digest = digest.slice(digest.length - DIGEST_MAX);
535  return digest;
536}
537
538export function firstUserPrompt(messages) {
539  for (const m of messages) {
540    if (m.role !== "user") continue;
541    const text = userText(m.text);
542    if (text) return text;
543  }
544  return "";
545}
546
547// A user row's own words: no command records, no injected tags, no slash commands.
548function userText(text) {
549  const raw = String(text ?? "");
550  if (/<command-name>|<local-command-stdout>|<command-message>/.test(raw)) return "";
551  const stripped = oneLine(raw.replace(/<([a-z-]+)>[\s\S]*?<\/\1>/g, " "));
552  if (!stripped || stripped.startsWith("/")) return "";
553  return stripped;
554}
555
556async function gitBranch(io, cwd) {
557  if (!cwd) return "";
558  try {
559    const r = await io.run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"]);
560    if (r.exitCode !== 0) return "";
561    const branch = r.stdout.trim();
562    return branch === "HEAD" ? "detached" : branch;
563  } catch {
564    return "";
565  }
566}
567
568export function projectSlug(path) {
569  return String(path).replace(/[^a-zA-Z0-9]/g, "-");
570}
571
572// <config>/projects/<slug of cwd>/<id>.jsonl, or wherever that id's file is.
573export async function findTranscript(io, sessionId, cwd) {
574  if (!sessionId) return "";
575  const configDir = await configDirOf(io);
576  const direct = `${configDir}/projects/${projectSlug(cwd)}/${sessionId}.jsonl`;
577  try {
578    if (await io.exists(direct)) return direct;
579    const dirs = await io.list(`${configDir}/projects`);
580    for (const d of dirs.slice(0, 3000)) {
581      if (d.kind !== "dir") continue;
582      const path = `${configDir}/projects/${d.name}/${sessionId}.jsonl`;
583      if (await io.exists(path)) return path;
584    }
585  } catch {
586    // no projects folder; keep the expected path
587  }
588  return direct;
589}
590
591async function configDirOf(io) {
592  const home = (await io.home()) ?? "";
593  return (await io.configDirEnv()) || `${home}/.claude`;
594}
595
596// ---- the file -----------------------------------------------------------------
597
598export async function bookmarksFile(io) {
599  const home = (await io.home()) ?? "";
600  const fromEnv = ((await io.fileEnv()) ?? "").trim();
601  const chosen = fromEnv || io.configured || `${home}/.claude/bookmarks/bookmarks.json`;
602  return chosen.startsWith("~/") ? `${home}${chosen.slice(1)}` : chosen;
603}
604
605// Reads the list. A file that does not parse is copied aside and replaced by an
606// empty list, and `warning` says so.
607export async function loadBookmarks(io) {
608  const file = await bookmarksFile(io);
609  if (!(await io.exists(file))) return { file, bookmarks: [], warning: "" };
610  let raw = "";
611  try {
612    raw = await io.read(file);
613  } catch (err) {
614    return { file, bookmarks: [], warning: `Could not read ${file} (${err?.message ?? err}).` };
615  }
616  const parsed = parseFile(raw);
617  if (parsed) return { file, bookmarks: parsed, warning: "" };
618  const backup = `${file}.corrupt-${stamp(await io.now())}`;
619  await io.write(backup, raw);
620  await saveBookmarks(io, file, []);
621  return {
622    file,
623    bookmarks: [],
624    warning: `Your bookmarks file was unreadable, so I saved a copy to ${backup} and started a fresh list.`,
625  };
626}
627
628export function parseFile(raw) {
629  try {
630    const data = JSON.parse(raw);
631    const list = Array.isArray(data) ? data : data && Array.isArray(data.bookmarks) ? data.bookmarks : null;
632    if (!list) return null;
633    return list.filter((b) => b && typeof b === "object" && typeof b.id === "string").map(onlyFields);
634  } catch {
635    return null;
636  }
637}
638
639function onlyFields(b) {
640  const out = {};
641  for (const k of FIELDS) {
642    if (b[k] !== undefined) out[k] = b[k];
643  }
644  out.title = typeof out.title === "string" && out.title ? out.title : "(untitled)";
645  out.note = typeof out.note === "string" ? out.note : "";
646  out.project = typeof out.project === "string" ? out.project : "";
647  out.branch = typeof out.branch === "string" ? out.branch : "";
648  out.cwd = typeof out.cwd === "string" ? out.cwd : "";
649  out.sessionId = typeof out.sessionId === "string" ? out.sessionId : "";
650  out.transcriptPath = typeof out.transcriptPath === "string" ? out.transcriptPath : "";
651  out.createdAt = typeof out.createdAt === "number" ? out.createdAt : 0;
652  return out;
653}
654
655// Writes to a temp file beside it, then renames it over the old one.
656export async function saveBookmarks(io, file, list) {
657  const text = `${JSON.stringify({ version: FILE_VERSION, bookmarks: list.map(onlyFields) }, null, 2)}\n`;
658  const tmp = `${file}.tmp-${await io.now()}`;
659  await io.write(tmp, text);
660  let isMoved = false;
661  try {
662    await io.run(["chmod", "600", tmp]);
663    const r = await io.run(["mv", "-f", tmp, file]);
664    isMoved = r.exitCode === 0;
665  } catch {
666    isMoved = false;
667  }
668  if (!isMoved) {
669    await io.write(file, text);
670    try {
671      await io.run(["rm", "-f", tmp]);
672    } catch {
673      // the temp file stays; harmless
674    }
675  }
676  // Keep the default folder private; a folder the person chose is theirs.
677  const home = (await io.home()) ?? "";
678  if (file === `${home}/.claude/bookmarks/bookmarks.json`) {
679    try {
680      await io.run(["chmod", "700", `${home}/.claude/bookmarks`]);
681    } catch {
682      // best effort
683    }
684  }
685}
686
687export async function deleteBookmark(io, id) {
688  const loaded = await loadBookmarks(io);
689  const list = loaded.bookmarks.filter((b) => b.id !== id);
690  await saveBookmarks(io, loaded.file, list);
691  return list.length !== loaded.bookmarks.length;
692}
693
694export async function renameBookmark(io, id, title) {
695  const loaded = await loadBookmarks(io);
696  const list = loaded.bookmarks.map((b) => (b.id === id ? { ...b, title, titleSource: "given" } : b));
697  await saveBookmarks(io, loaded.file, list);
698}
699
700// ---- listing ------------------------------------------------------------------
701
702export function sortNewest(list) {
703  return [...list].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
704}
705
706// Numbers rows newest first and notes a folder or transcript that is gone.
707export async function withFlags(io, sorted) {
708  const rows = [];
709  for (let i = 0; i < sorted.length; i++) {
710    const b = sorted[i];
711    const isCwdMissing = b.cwd ? !(await io.exists(b.cwd)) : true;
712    const isTranscriptMissing = b.transcriptPath ? !(await io.exists(b.transcriptPath)) : true;
713    rows.push({ ...b, n: i + 1, isCwdMissing, isTranscriptMissing });
714  }
715  return rows;
716}
717
718async function paneView(io, notice) {
719  const loaded = await loadBookmarks(io);
720  const rows = (await withFlags(io, sortNewest(loaded.bookmarks))).map((r) => ({
721    n: r.n,
722    id: r.id,
723    title: r.title,
724    note: r.note,
725    project: r.project,
726    branch: r.branch,
727    cwd: r.cwd,
728    sessionId: r.sessionId,
729    createdAt: r.createdAt,
730    isCwdMissing: r.isCwdMissing,
731    isTranscriptMissing: r.isTranscriptMissing,
732  }));
733  return { rows, notice: loaded.warning || notice || "", file: loaded.file, loadedAt: await io.now() };
734}
735
736// Every word must appear in the title, the note or the project (name or branch).
737export function search(rows, query) {
738  const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
739  if (words.length === 0) return rows;
740  return rows.filter((r) => {
741    const hay = `${r.title} ${r.note} ${r.project} ${r.branch}`.toLowerCase();
742    return words.every((w) => hay.includes(w));
743  });
744}
745
746function pick(sorted, arg) {
747  const n = Number.parseInt(String(arg ?? "").trim(), 10);
748  if (sorted.length === 0) return { error: "No bookmarks saved yet. /bm saves this session." };
749  if (!Number.isInteger(n) || n < 1 || n > sorted.length) {
750    return { error: `Pick a bookmark number from 1 to ${sorted.length}. /bm list shows them.` };
751  }
752  return { n, bookmark: sorted[n - 1] };
753}
754
755export function formatList(rows, now, heading = "Bookmarks", { withCommand = false } = {}) {
756  if (rows.length === 0) return "No bookmarks saved yet. /bm saves this session.";
757  const lines = [`${heading} (${rows.length}), newest first`];
758  for (const r of rows) {
759    const where = r.branch ? `${r.project} (${r.branch})` : r.project;
760    lines.push(`${String(r.n).padStart(2)}. ${r.title}`);
761    lines.push(`    ${where}  ·  ${ago(now - r.createdAt)}  ·  session ${String(r.sessionId).slice(0, 8)}`);
762    if (r.note) lines.push(`    ${r.note}`);
763    const flags = [];
764    if (r.isCwdMissing) flags.push("folder no longer exists");
765    if (r.isTranscriptMissing) flags.push("transcript file not found");
766    if (flags.length) lines.push(`    [${flags.join(", ")}]`);
767    if (withCommand) lines.push(`    resume in a new terminal with: ${resumeCommand(r)}`);
768  }
769  if (!withCommand) lines.push("/bm resume <n> copies the command that reopens one.");
770  return lines.join("\n");
771}
772
773function savedText(saved) {
774  const b = saved.bookmark;
775  const how = { given: "", note: "", model: " (title written by the model)", "first-prompt": " (title from your first prompt)" }[b.titleSource] ?? "";
776  const lines = [`Bookmarked "${b.title}"${how}.`];
777  if (b.note) lines.push(`Note: ${b.note}`);
778  lines.push(`${b.project}${b.branch ? ` (${b.branch})` : ""}, session ${b.sessionId.slice(0, 8)}. ${saved.count} bookmark${saved.count === 1 ? "" : "s"} in total, /bm list shows them.`);
779  return lines.join("\n");
780}
781
782function withWarning(warning, text) {
783  return warning ? `${warning}\n\n${text}` : text;
784}
785
786export function helpText(file) {
787  return [
788    "Session bookmarks, saved only when you ask.",
789    "  /bm [note]             bookmark this session (no note means a short title is written for you)",
790    "  /bm save <note>        the same, for a note that starts with a command word",
791    "  /bm list               every bookmark, newest first",
792    "  /bm find <words>       search titles, notes and project names",
793    "  /bm open               toggle the Bookmarks pane (Resume copies the command, Delete asks first)",
794    "  /bm resume <n>         copy the command that reopens bookmark n",
795    "  /bm rename <n> <title> retitle bookmark n",
796    "  /bm delete <n>         remove bookmark n",
797    "  /bm close              close the pane",
798    "Resuming happens in a new terminal, since a mod cannot switch the session you are in.",
799    "Plain English works too, for example \"bookmark this session\" or \"find my bookmark about the parser\".",
800    `Bookmarks live in ${file}`,
801  ].join("\n");
802}
803
804// ---- the resume command ----------------------------------------------------------
805
806export function shellQuote(s) {
807  return `'${String(s).replace(/'/g, `'\\''`)}'`;
808}
809
810export function resumeCommand(b) {
811  const id = /^[A-Za-z0-9_-]+$/.test(b.sessionId) ? b.sessionId : shellQuote(b.sessionId);
812  return b.cwd ? `cd ${shellQuote(b.cwd)} && claude --resume ${id}` : `claude --resume ${id}`;
813}
814
815// ---- text helpers -----------------------------------------------------------------
816
817export function cleanTitle(text) {
818  let t = oneLine(String(text ?? ""))
819    .replace(/[—–]/g, ", ")
820    .replace(/^["'`*#\s]+|["'`*\s]+$/g, "")
821    .replace(/\s+,/g, ",")
822    .trim();
823  if (t.length > TITLE_MAX) {
824    const cut = t.slice(0, TITLE_MAX - 1);
825    const space = cut.lastIndexOf(" ");
826    t = `${(space > 30 ? cut.slice(0, space) : cut).replace(/[\s,.;:]+$/, "")}…`;
827  }
828  return t;
829}
830
831function cleanNote(text) {
832  const t = oneLine(String(text ?? "")).replace(/[—–]/g, ", ").replace(/\s+,/g, ",").trim();
833  return t.length > NOTE_MAX ? `${t.slice(0, NOTE_MAX - 1)}…` : t;
834}
835
836function firstSentence(text) {
837  const m = /^(.+?[.!?])(\s|$)/.exec(text);
838  return m ? m[1] : text;
839}
840
841function baseName(path) {
842  return String(path).split("/").filter(Boolean).pop() ?? "";
843}
844
845function oneLine(s) {
846  return String(s).replace(/\s+/g, " ").trim();
847}
848
849function clip(s, n) {
850  const t = String(s);
851  return t.length <= n ? t : `${t.slice(0, Math.max(0, n - 1))}…`;
852}
853
854function stamp(ms) {
855  const d = new Date(ms);
856  const p = (n) => String(n).padStart(2, "0");
857  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
858}
859
860export function ago(ms) {
861  const s = Math.max(0, Math.round(ms / 1000));
862  if (s < 60) return "just now";
863  const m = Math.round(s / 60);
864  if (m < 60) return `${m} min ago`;
865  const h = Math.round(m / 60);
866  if (h < 24) return `${h} h ago`;
867  const d = Math.round(h / 24);
868  return d === 1 ? "yesterday" : `${d} days ago`;
869}
870
871function shortPath(path, n) {
872  let p = String(path).replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
873  if (p.length <= n) return p;
874  return `…${p.slice(p.length - n + 1)}`;
875}
876
types/index.d.ts 29 lines
1export type SessionBookmarksRow = {
2  n: number;
3  id: string;
4  title: string;
5  note: string;
6  project: string;
7  branch: string;
8  cwd: string;
9  sessionId: string;
10  createdAt: number;
11  isCwdMissing: boolean;
12  isTranscriptMissing: boolean;
13};
14
15export type SessionBookmarksPane = {
16  rows: SessionBookmarksRow[];
17  notice: string;
18  file: string;
19  loadedAt: number;
20};
21
22declare module "claude-code" {
23  interface PluginState {
24    "session-bookmarks": {
25      pane: SessionBookmarksPane;
26    };
27  }
28}
29