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

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-bookmarkshooks/session-bookmarks.mjs 876 lines1// 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}
876types/index.d.ts 29 lines1export 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