Bookmarks and vim-style marks inside Claude Code conversations: mark a line, jump back, keep a reading position, browse your prompts, and let Claude cite…

Vim-style marks, a reading position, a numbered prompt history and durable bookmarks inside a Claude Code conversation. Mark a line with a letter and jump back to it from anywhere; keep your place while you scroll; browse every prompt you have typed; and let Claude cite earlier places in the conversation as links you can click to jump there.
<img src="images/prompts-pane-marked-line-and-bookmark-link.png" alt="The prompts pane open on the right, numbered from the first prompt; a marked line in the conversation with its c tag; a bookmark link in a reply; the band above the prompt in prompt mode"> <sub>In use: the prompts pane on the right, a marked line with its tag, a bookmark link Claude wrote in a reply, and the band above the input box.</sub>
Pre-alpha. In daily use in Win11 (Windows Terminal, Claude Code 2.1.288 to 2.1.296, fullscreen renderer). macOS and Linux are expected to work but have not been tried. The key layout will still change. The state of the plugin, with every known issue and what is coming, is in docs/status.md.
Reviewers: everything the plugin sets, runs, reads, writes and sends is listed under What it runs, reads, writes and sends below.
m and a letter a-z. The line lights up where it sits. With nothing freshly selected, the letter marks the message at the top of your screen.', and the letter scrolls back to that mark from anywhere and lights the line again; the jump pane lists the marks you have.Space Space after the leader keeps your place: once to go there, again to return to where you were, even after new replies arrived below.p after the leader lists every prompt of the conversation, numbered from your first, including the ones from before the plugin was loaded. Type a number or browse with j/k, then Enter. Pin the ones you keep returning to.file: link to a readable export of the message, with the message's id, transcript line and byte range in the link. Promote any mark into one; the link lands on your clipboard.bookmark tool: a verbatim fragment of a message and a label in, a markdown link out. In its reply the link is drawn with an anchor marker: a click jumps there and highlights the words; a ctrl-click opens the bookmark's file in your markdown editor. Your bookmarks and Claude's are separate lists; Claude never writes on yours unless you ask.The leader is Ctrl+], Claude Code's own abovePrompt:focus action bound to one key; the one-line binding is in the setup section of the full README. Every key, grouped by where the keyboard is: docs/keys.md. Each feature in detail: docs/usage.md. New to vim-style keys: the tutorial. When something does not work: troubleshooting.
Install from one place only. Installed from the Anthropic plugin directory and from a marketplace at the same time, the plugin would load twice and draw two bands.
From the directory, use its install flow. From this repository, which is also a marketplace, on Claude Code 2.1.275 or later:
/plugin install bookmarks --marketplace DazzleML/claude-bookmarks
Then bind the leader (one line in ~/.claude/keybindings.json), switch to the fullscreen renderer with /tui fullscreen, restart Claude Code, and check with /bm-env. To work on the plugin, load this folder directly: claude --plugin-dir /path/to/claude-bookmarks/plugin.
The plugin is a Claude Code mod: TypeScript that runs inside Claude Code's own process through its plugin API. It installs nothing and needs no package manager. The same facts in plain language, with how to remove everything: PRIVACY.md.
Sets. One environment variable, CONVO_BOOKMARKS_DEBUG, in the plugin's own process when you type /bm-debug on or off: it turns the debug echo on or off for that conversation and dies with the process. No configuration file, settings file, start-up file or instructions file is written or edited.
Runs. To list the prompts of a conversation and to resolve a bookmark to its transcript line, it searches the conversation's transcript file, which is often larger than the 4 MiB the plugin API will read at once, with a search program already on your machine: sh running grep where they exist (macOS, Linux, Git Bash on Windows); on Windows without them, two PowerShell scripts shipped in this folder, hooks/scripts/user-rows.ps1 (14 lines) and hooks/scripts/grep-offsets.ps1 (17 lines), run as powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <script>. In both cases the file path and the search text are passed as separate arguments, never spliced into a command string, so the text you select or Claude asks for cannot become a command. The program's output is read back by the plugin and goes nowhere else; each run is capped at 60 seconds. Nothing else is executed.
Reads. The conversation's transcript, which Claude Code keeps on disk under its config folder (~/.claude/projects/<project>/<session id>.jsonl, or CLAUDE_CONFIG_DIR): it is read, never written. The environment variables CLAUDE_CONFIG_DIR, CLAUDE_USER_DIR, HOME or USERPROFILE (to find those folders), CONVO_BOOKMARKS_DEBUG (the debug echo), and DCC_PATCH_H, DCC_PATCHES, DCC_PATCHER (shown by /bm-env when the companion patched build of Claude Code is in use; absent otherwise). Your mouse selection, through the plugin API, to know which line to mark. The prompt box, when you submit or edit it, to catch the plugin's own /bm- commands and to keep your draft intact while a pane is open; anything else you type passes through untouched and is not stored.
Writes. Marks, pinned prompts, reading positions, prompt lists and the jumplist, per conversation, in Claude Code's own plugin store (~/.claude/plugins/store/bookmarks_inline-<id>.json). Bookmarks in your own folder, ~/claude/bookmarks/ (or CLAUDE_USER_DIR): one JSON register per conversation under sessions/ and one markdown file per bookmarked message, which you can read and edit. A debug log per conversation, ~/claude/bookmarks/debug/<session id>.log, kept to its last 400 lines, holding key names, pane and jump events and never the text you type. The clipboard, when you promote a mark (the bookmark's link) or when a jump is refused (a phrase of the message to search for). Every path is computed from your home folder and the conversation's id; none is a file another tool runs or obeys. Nothing is written into the session file, and nothing under ~/.claude other than the plugin store.
Sends. Nothing. The plugin makes no network connections and contacts no service. The one thing the model sees is its own bookmark tool and the links it returns.
GPL-3.0; see LICENSE. Source, issues and the roadmap: github.com/DazzleML/claude-bookmarks.
hooks/register.tsx 2782 lines1// claude-bookmarks POC -- a throwaway probe, not the product.
2//
3// It answers five questions before anything real is built (the DWP's B1-B5):
4// B1 is a UserMessage row's requestId the transcript uuid session.append reports?
5// B2 can $.ui.scroll reveal a row that has not been drawn since the mod loaded?
6// B3 does a typed command count as "the person's own input" for scroll?
7// B4 does a chord bound to a borrowed engine action press a mod Button?
8// B5 does a press in a mod pane count as the person's input for scroll?
9//
10// Every probe reports with $.ui.log (a dim transcript row Claude never reads) or a
11// toast. No command answers with `text`, because that text becomes a row Claude reads.
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, Timer } from 'claude-code'
15
16import type { PaneMode, Row } from '../types'
17import { describePathOrder } from './engine/select'
18import {
19 anchorHref, anchorMarkdown, anchorPath, markLinks, markdownLinks, ownerOf, parseAnchor,
20 type AnchorOwner, type AnchorRecord, type MarkerStyle,
21} from './core/anchor'
22import {
23 addOrRelabel, emptyRegister, exportMarkdown, exportNotesOf, findRecord, listOf, parseRegister, prune, remove, serializeRegister, share,
24 type Mint, type Register as BookmarkRegister, type Retention,
25} from './core/register-file'
26
27// How long a `temporary` bookmark is kept before it is pruned (to a tombstone). A
28// setting with the archive-retention picker (#7, #10); a constant until then.
29const TEMPORARY_RETENTION: Retention = '30d'
30import { headOf, jsonEscaped, resolveRows, rowsFromGrep, uuidPattern, type Resolution, type TranscriptRow } from './core/transcript-lines'
31
32const PANE = 'bm-poc'
33
34// Claude Code builds the borrowed-action chords (below) were verified on. The
35// start-up tripwire toasts on any other build, because a new build could give a
36// borrowed action a handler of its own. Add a build here after re-verifying.
37const VERIFIED_CLIENTS = ['2.1.288', '2.1.289', '2.1.290']
38
39// Engine keybinding actions borrowed for the chord test (B4). Neither should have a
40// handler mounted while the built-in diff mod is enabled; the version check in
41// session.start is how we notice when a new build might have changed that.
42const MARK_ACTION = 'app:toggleDiffNoiseFilter'
43const JUMP_ACTION = 'app:toggleDiffPreSession'
44// A third borrowed action (2026-10-04, user: "ctrl-x p" for the recent-prompts pane,
45// then 1-9): `ctrl+x p` in keybindings.json. The engine's own docs use it as their
46// example of a Button `action`; another diff-viewer toggle, idle in a conversation.
47const PROMPTS_ACTION = 'app:cycleDiffBase'
48// A fourth (2026-10-04, user: "Ctrl+x space" for the reading position). Scrolls the
49// diff panel's file list, so idle in a conversation like the other three. `ctrl+x x`,
50// djdarcy's first idea, is Claude Code's own chord for closing a pane.
51const READING_ACTION = 'app:diffFileListDown'
52// The reading position is kept as a mark under this key, so the highlight and the
53// band show it like any other; the panes list only a-z, so it never appears there.
54const READING = '`'
55
56const LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('')
57
58const rows = atom({ plugin: 'bookmarks', key: 'rows' } as const, [])
59const paneMode = atom({ plugin: 'bookmarks', key: 'paneMode' } as const, 'list')
60const shown = atom({ plugin: 'bookmarks', key: 'shown' } as const, null)
61const bandMode = atom({ plugin: 'bookmarks', key: 'bandMode' } as const, 'idle')
62const pinsRev = atom({ plugin: 'bookmarks', key: 'pinsRev' } as const, 0)
63// The band's command line (design 2026-10-07__02-58-22): bumped after each command so
64// the field is drawn under a new key and starts empty; and the prompt number's digits.
65const cmdRev = atom({ plugin: 'bookmarks', key: 'cmdRev' } as const, 0)
66const bandNum = atom({ plugin: 'bookmarks', key: 'bandNum' } as const, '')
67// The bookmarks pane shows one group at a time, cycled from the band (djdarcy, 2026-10-09:
68// start on "their" bookmarks, a key cycles to Claude's, and a third group such as team
69// members' can join later). An ordered list, so a group is one entry, not a code path.
70// The keys are vim's sideways pair; `o`/`i` already mean back/forward at the band's top
71// level. Both become settings (#7).
72const bandGroup = atom({ plugin: 'bookmarks', key: 'bandGroup' } as const, 0)
73const BOOKMARK_GROUPS: { owner: AnchorOwner | 'all'; title: string }[] = [
74 { owner: 'user', title: 'yours' },
75 { owner: 'claude', title: "Claude's" },
76 { owner: 'all', title: 'all' },
77]
78const GROUP_CYCLE = { prev: 'h', next: 'l' } as const
79
80// The highlight and the band text are temporary (djdarcy, 2026-10-03: "visible temporarily
81// for maybe a minute or two or until the next action like another prompt is sent").
82// Only the mark just set or jumped to is shown; it clears after HIGHLIGHT_MS or on the
83// next prompt. Display only: nothing here touches what session.append stores.
84const HIGHLIGHT_MS = 120_000
85let clearTimer: Timer | undefined
86
87async function showMark($: EngineInterface, letter: string, text: string) {
88 await update($, shown, () => ({ letter, text }))
89 clearTimer?.cancel()
90 clearTimer = $.clock.after(HIGHLIGHT_MS, () => {
91 void clearShown($)
92 })
93 note(`show mark ${letter} for ${HIGHLIGHT_MS / 1000}s`)
94}
95
96async function clearShown($: EngineInterface) {
97 clearTimer?.cancel()
98 clearTimer = undefined
99 if (await read($, shown)) {
100 await update($, shown, () => null)
101 note('highlight cleared')
102 }
103}
104
105// The session's marks, kept in memory so render hooks can read them without a store
106// call per row. Loaded at session.start and refreshed on every mark write.
107let markCache: Record<string, Mark> = {}
108
109// The first line of a selection, which is what a row redraw searches for: a match
110// across lines would have to rewrite markdown structure, which this POC won't do.
111const snippetOf = (text: string) => (text.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 120)
112
113// Round 4 colors (dark terminal; a light background would need a lighter LINE_BG).
114// LINE_BG: darker than the selection blue #264F78 so it reads as a soft band.
115const LINE_BG = '#1B3754'
116const WORDS_FG = 'yellow'
117// A letter already in use, in the mark pane's list: a muted grey-red, "this one is taken".
118const USED_FG = '#B07A7A'
119
120// Split a reply's markdown around the line holding `snippet`, for drawing that line
121// ourselves. Undefined (caller falls back to bold) when the split is risky: no match,
122// the line is inside a fenced code block, or the remainder is too long for Markdown.
123// A selection is copied from the RENDERED reply, so `**bold**`, `code` and [links](url)
124// have lost their markup; compare against each source line with the same markup removed.
125// (Matching the raw markdown missed any line with inline formatting, 2026-10-04.)
126const plainMarkdown = (s: string) =>
127 s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|`/g, '')
128
129function colorSplit(text: string, letter: string, snippet: string | undefined) {
130 if (!snippet) return undefined
131 const lines = text.split('\n')
132 const idx = lines.findIndex(l => plainMarkdown(l).includes(snippet))
133 if (idx < 0) return undefined
134 const fences = lines.slice(0, idx).filter(l => l.trimStart().startsWith('```')).length
135 if (fences % 2 === 1) return undefined
136 const before = lines.slice(0, idx).join('\n').trimEnd()
137 const after = lines.slice(idx + 1).join('\n').trim()
138 if (after.length > 9000) return undefined
139 // The blank line between paragraphs, which the trims above drop: kept so the marked
140 // line keeps its spacing (2026-10-04: marking a line joined it to the paragraph
141 // above). Each side's own markdown draws its inner spacing; only the seam is ours.
142 const gapAbove = idx > 0 && !lines[idx - 1]?.trim() && before.length > 0
143 const gapBelow = idx + 1 < lines.length && !lines[idx + 1]?.trim() && after.length > 0
144 // The line is drawn as plain Text, from its markup-free form.
145 const line = plainMarkdown(lines[idx] ?? '')
146 const at = line.indexOf(snippet)
147 return {
148 before,
149 pre: line.slice(0, at),
150 snippet,
151 post: line.slice(at + snippet.length),
152 after,
153 letter,
154 gapAbove,
155 gapBelow,
156 }
157}
158
159// The shown mark, if it is on this row: by real uuid, or a drawn id sharing its first
160// four groups. Only the one mark currently shown is ever highlighted.
161function marksOnRow(requestId: string, showing: string | undefined): [string, Mark][] {
162 if (!showing) return []
163 return Object.entries(markCache).filter(
164 ([letter, m]) =>
165 letter === showing &&
166 m.source === 'selection' &&
167 (m.uuid === requestId || firstFour(m.uuid) === firstFour(requestId)),
168 )
169}
170
171// Ids seen at UserMessage render time since this module loaded. A module variable on
172// purpose: a render hook may not write $.state, and a reload SHOULD forget these,
173// which is what makes the B2 test ("never drawn since load") possible.
174const rendered = new Map<string, string>()
175
176// Which messages are on screen now, as the render hooks last reported them (a
177// message's `onScreen`; null or absent drops it). Lets the reading position tell
178// "am I there already?" and note where the person was before a jump.
179// Each report is stamped: Claude Code reports a message when drawn and, on a scroll,
180// only the messages at the viewport's edges, so one that leaves the screen in a single
181// jump (Ctrl+End) is never reported gone and goes stale here (2026-10-04: "nowhere to
182// go" right after Ctrl+End). Only the latest burst of reports is trusted.
183const onScreenNow = new Map<string, { first: number; last: number; of: number; at: number }>()
184function noteOnScreen(requestId: string, onScreen: { first: number; last: number; of: number } | null | undefined) {
185 // A command run from a key (`command:bm-read`) is drawn for a moment under a
186 // temporary `placeholder…` id; as the newest report it became "where you were", and
187 // the way back was refused once it vanished (log, 2026-10-06 04:03:29). Only real
188 // message ids count.
189 if (!/^[0-9a-f]{8}-/.test(requestId)) return
190 if (!onScreen) return void onScreenNow.delete(requestId)
191 // A redraw that repeats the same lines is not news: clearing the reading position's
192 // highlight after a "back" jump redrew it, off screen, with its old lines, which made
193 // it look freshly on screen, so the next press "stayed put" at the bottom (djdarcy,
194 // 2026-10-07; log 08:15:48 -> 08:15:53). Only a change of lines refreshes the time.
195 const before = onScreenNow.get(requestId)
196 const same = before && before.first === onScreen.first && before.last === onScreen.last && before.of === onScreen.of
197 onScreenNow.set(requestId, { ...onScreen, at: same ? before.at : Date.now() })
198}
199const FRESH_MS = 1500
200function freshOnScreen(): string[] {
201 const newest = Math.max(0, ...[...onScreenNow.values()].map(v => v.at))
202 return [...onScreenNow.entries()].filter(([, v]) => v.at >= newest - FRESH_MS).map(([id]) => id)
203}
204
205// A timeline of what the mod saw and did, so a drawn-id mismatch can be lined up
206// against the pane, store and toast activity just before it. Module-level: a reload
207// starts it over, the same as `rendered`.
208const timeline: { t: number; what: string }[] = []
209function note(what: string) {
210 timeline.push({ t: Date.now(), what })
211 if (timeline.length > 400) timeline.shift()
212}
213// The [bm-poc] log lines, also written to <user dir>/bookmarks/debug/<session>.log:
214// $.ui.log rows are only drawn, so a session that reads files (Claude, while
215// developing this) cannot see them. Kept outside the plugin folder on purpose: a write
216// inside it trips the folder watch and reloads the mod on every line. $.fs has no
217// append, so the file is rewritten whole, last 400 lines, one write at a time.
218const LOG_LINES = 400
219const logLines: string[] = []
220let logPath: string | null | undefined // undefined: not looked up yet; null: no usable place
221let flushing: Promise<void> = Promise.resolve()
222
223// Whether log lines are also echoed into the transcript as dim rows. Off by default: the
224// file log is always written; the echo is for the person debugging one conversation, and
225// a single global switch was spamming every session the plugin loads in (djdarcy,
226// 2026-10-09). Two things are kept, one volatile and one persisted (the 14-17-44 DWP):
227// - this conversation's choice lives in its own process environment, CONVO_BOOKMARKS_DEBUG.
228// `/bm-debug on|off` sets it with $.env.set, so it survives a hot reload, reaches the
229// processes this session starts, and dies with the session: a resumed session forgets.
230// `CONVO_BOOKMARKS_DEBUG=1 claude` is the same signal given from the shell.
231// - the machine-wide policy, one store key: the default every conversation without a
232// choice follows, and whether a conversation may choose at all (`forced`), so "on
233// everywhere" and "off everywhere" can be guaranteed from any one session (djdarcy's
234// six states: forced on, forced off, and a session's on/off/unset over an overridable
235// default). Nothing is cached: a force typed in one session must reach the others on
236// their very next log line.
237const ECHO_POLICY_ENTRY = 'debug:echo'
238type EchoPolicy = { default: boolean; forced: boolean }
239async function echoPolicy($: EngineInterface): Promise<EchoPolicy> {
240 const v = (await $.store.get(ECHO_POLICY_ENTRY)) as Partial<EchoPolicy> | undefined
241 return { default: v?.default === true, forced: v?.forced === true }
242}
243async function setEchoPolicy($: EngineInterface, policy: EchoPolicy) {
244 await $.store.set(ECHO_POLICY_ENTRY, policy)
245}
246/** This conversation's own choice, from its environment; undefined when it made none. */
247async function sessionEchoChoice($: EngineInterface): Promise<boolean | undefined> {
248 const env = await $.env.get('CONVO_BOOKMARKS_DEBUG')
249 if (env === undefined || env === '') return undefined
250 return env !== '0' && env.toLowerCase() !== 'off' && env.toLowerCase() !== 'false'
251}
252/** Sets this conversation's choice; `undefined` drops it, so the default applies again. */
253async function setSessionEcho($: EngineInterface, enabled: boolean | undefined) {
254 // Not named `on`: the directory's reader treats that name as the hook registrar everywhere in the file.
255 await $.env.set('CONVO_BOOKMARKS_DEBUG', enabled === undefined ? undefined : enabled ? '1' : '0')
256}
257async function uiEchoEnabled($: EngineInterface): Promise<boolean> {
258 const policy = await echoPolicy($)
259 if (policy.forced) return policy.default
260 return (await sessionEchoChoice($)) ?? policy.default
261}
262/** The state in one line, for `/bm-debug status` and `/bm-env`. */
263async function echoStateLine($: EngineInterface): Promise<string> {
264 const policy = await echoPolicy($)
265 const mine = await sessionEchoChoice($)
266 const word = (b: boolean) => (b ? 'on' : 'off')
267 if (policy.forced) {
268 const asked = mine !== undefined && mine !== policy.default ? `; this conversation asked for ${word(mine)}, which applies once the force is lifted` : ''
269 return `${word(policy.default)} everywhere, forced (/bm-debug default ${word(policy.default)} lifts it${asked})`
270 }
271 if (mine !== undefined) return `${word(mine)} here only (this conversation's choice; the default is ${word(policy.default)})`
272 return `${word(policy.default)}, the default (every conversation without a choice of its own)`
273}
274
275function log($: EngineInterface, line: string, opts?: { always?: boolean }) {
276 if (opts?.always) $.ui.log(line)
277 else void uiEchoEnabled($).then(enabled => { if (enabled) $.ui.log(line) }).catch(() => {})
278 logLines.push(`${new Date().toISOString()} ${line}`)
279 if (logLines.length > LOG_LINES) logLines.splice(0, logLines.length - LOG_LINES)
280 flushing = flushing.then(() => flushLog($)).catch(() => {})
281}
282
283// The data root: where this plugin keeps what is the person's (bookmarks, exports, the
284// debug log). `CLAUDE_USER_DIR`, else `<home>/claude`; never under `~/.claude`, which is
285// Claude Code's to clean (#6). Forward slashes, no trailing slash.
286async function dataRoot($: EngineInterface): Promise<string | undefined> {
287 const fromEnv = await $.env.get('CLAUDE_USER_DIR')
288 if (fromEnv) return fromEnv.replace(/\\/g, '/').replace(/\/+$/, '')
289 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
290 return home ? `${home.replace(/\\/g, '/')}/claude` : undefined
291}
292
293async function flushLog($: EngineInterface) {
294 if (logPath === undefined) {
295 const root = await dataRoot($)
296 logPath = root ? `${root}/bookmarks/debug/${await $.session.id()}.log` : null
297 // A reload starts the module over: keep what the file already holds.
298 if (logPath && (await $.fs.exists(logPath))) {
299 const earlier = (await $.fs.read(logPath)).split('\n').filter(l => l.trim())
300 logLines.unshift(...earlier)
301 if (logLines.length > LOG_LINES) logLines.splice(0, logLines.length - LOG_LINES)
302 }
303 }
304 if (logPath) await $.fs.write(logPath, logLines.join('\n') + '\n')
305}
306
307const clock = (t: number) => new Date(t).toISOString().slice(11, 23)
308const firstFour = (uuid: string) => uuid.split('-').slice(0, 4).join('-')
309
310// `snippet`: the selection's first line exactly as selected, for highlighting.
311type Mark = { uuid: string; head: string; markedAt: number; source?: 'selection' | 'latest' | 'screen'; snippet?: string }
312type Marks = Record<string, Mark>
313
314const short = (uuid: string) => uuid.slice(0, 8)
315const head = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, 60)
316
317// This plugin's own version, from its manifest (the one Claude Code installs by).
318async function pluginVersion($: EngineInterface): Promise<string> {
319 try {
320 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
321 return typeof manifest.version === 'string' ? manifest.version : '?'
322 } catch {
323 return '?'
324 }
325}
326
327async function allRows($: EngineInterface): Promise<Row[]> {
328 return (await read($, rows)) as Row[]
329}
330
331// The session's prompts in the order they were sent, #1 first. `rows` ($.state) starts
332// empty when the session restarts, so every prompt is also kept in $.store under the
333// session id (djdarcy, 2026-10-04: number prompts from the first message). Prompts sent
334// before the mod was loaded are not known: reading them needs the transcript (#6).
335type PromptRef = { uuid: string; head: string }
336const PROMPTS_KEPT = 5000
337async function promptsKey($: EngineInterface): Promise<string> {
338 return `prompts:${await $.session.id()}`
339}
340async function keptPrompts($: EngineInterface): Promise<PromptRef[]> {
341 return ((await $.store.get(await promptsKey($))) as PromptRef[] | undefined) ?? []
342}
343async function keepPrompt($: EngineInterface, p: PromptRef) {
344 const kept = await keptPrompts($)
345 if (kept.some(k => k.uuid === p.uuid)) return
346 await $.store.set(await promptsKey($), [...kept, p].slice(-PROMPTS_KEPT))
347}
348
349// --- Back-fill: the prompts sent before the mod was loaded ------------------------
350// The transcript holds them but is too big for $.fs.read (4 MiB; 18 MB here). The
351// platform's own tool filters it to the user rows (no install: Windows PowerShell 5.1
352// on Windows, sh + grep elsewhere) and the mod parses the JSON lines. Settled by the
353// POC in tests/one-offs/thinking/prompt-history/ (2026-10-04: 152/152 prompts,
354// identical text; ~1 MB of output; 328 ms PowerShell, 81 ms sh + grep). Runs once per
355// conversation; after that the live capture keeps the list current.
356// A typed user prompt's text, or undefined for tool results, meta and sidechain rows.
357// The same rule as the POC's reference parse.
358function promptTextOf(o: any): string | undefined {
359 if (o?.type !== 'user' || o.isMeta || o.isSidechain) return undefined
360 const content = o.message?.content
361 if (typeof content === 'string') return content
362 if (!Array.isArray(content)) return undefined
363 if (content.some((b: any) => b?.type === 'tool_result')) return undefined
364 const texts = content.filter((b: any) => b?.type === 'text').map((b: any) => String(b.text ?? ''))
365 return texts.length > 0 ? texts.join(' ') : undefined
366}
367
368// The PowerShell fallbacks are scripts shipped with the plugin (hooks/scripts/*.ps1),
369// run with -File and named parameters. Until v0.3.0 they were inline scripts passed as
370// base64 UTF-16LE text, to keep their double quotes intact; a measurement on 2026-10-09
371// showed -File parameters keep `"type":"user"` verbatim from a non-shell spawn, and
372// base64-encoded PowerShell is what a security scan flags. Each $.process.run below
373// receives its argv as a literal array at the call, program name and flags spelled
374// out, so a static reader can see what runs; only the file path, the search text and
375// the plugin's own folder are variables.
376
377// The session's transcript: <config dir>/projects/<project>/<session id>.jsonl. The
378// project folder is the cwd with every non-alphanumeric character turned into `-`;
379// if that guess misses, every project folder is looked in.
380async function transcriptPath($: EngineInterface): Promise<string | undefined> {
381 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
382 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
383 if (!config) return undefined
384 const file = `${await $.session.id()}.jsonl`
385 const guess = `${config}/projects/${(await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')}/${file}`
386 if (await $.fs.exists(guess)) return guess
387 for (const entry of await $.fs.list(`${config}/projects`)) {
388 const candidate = `${config}/projects/${entry.name}/${file}`
389 if (entry.kind === 'dir' && (await $.fs.exists(candidate))) return candidate
390 }
391 return undefined
392}
393
394// --- For reviewers: the only two places this plugin runs a program ----------------
395// userRows() and grepTranscript() below are the plugin's only $.process.run calls.
396// Why a program at all: the transcript is often larger than the 4 MiB that $.fs.read
397// accepts (it rejects above that), so the file is searched by a tool already on the
398// machine. What runs: `sh -c 'grep ...'` where sh and grep exist, else `powershell
399// -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <one of the two .ps1 files
400// shipped in hooks/scripts/>`. The transcript path and the search text are always
401// separate argv elements ("$1"/"$2" to sh, named -Path/-Pattern parameters to
402// PowerShell), never spliced into the command string, so selected or model-chosen
403// text cannot become a command. Output is read back here and parsed as JSON lines;
404// it is not sent anywhere, and the plugin opens no network connection. Each run is
405// capped at 60 seconds and at 4 MiB of output by the engine. See README "What it
406// runs, reads, writes and sends" and PRIVACY.md.
407
408// The user rows of the transcript, one JSON line each, from the platform's own tool.
409async function userRows($: EngineInterface, path: string) {
410 const windows = /^[A-Za-z]:[\\/]/.test(path)
411 if (windows) {
412 // Windows PowerShell 5.1 first on a Windows path. The row markers ("type":"user",
413 // "type":"tool_result") are spelled out at each call so the command reads as fixed
414 // text; the sh form below uses the same two.
415 try {
416 const started = Date.now()
417 const r = await $.process.run(
418 ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/hooks/scripts/user-rows.ps1`,
419 '-Path', path, '-UserRow', '"type":"user"', '-ToolResultRow', '"type":"tool_result"'],
420 { timeoutMs: 60_000 },
421 )
422 if (r.exitCode === 0 || r.stdout) return { via: 'powershell' as const, ms: Date.now() - started, ...r }
423 } catch {
424 // PowerShell is not there: try sh.
425 }
426 }
427 try {
428 const started = Date.now()
429 const r = await $.process.run(
430 ['sh', '-c', 'grep -F \'"type":"user"\' "$1" | grep -vF \'"type":"tool_result"\'', 'sh', path],
431 { timeoutMs: 60_000 },
432 )
433 if (r.exitCode === 0 || r.stdout) return { via: 'sh' as const, ms: Date.now() - started, ...r }
434 } catch {
435 // sh is not there either.
436 }
437 return undefined
438}
439
440// `grep -bn` over the transcript: every line containing `pattern`, as `line:byteoffset:
441// text`. sh + grep first on every platform (29 ms on a 10 MB file here; Git Bash has
442// grep on Windows); PowerShell second, computing the byte offsets itself since
443// Select-String has none. The output cap (4 MiB) cuts the newest matches first; a cut
444// line fails to parse and is skipped.
445async function grepTranscript($: EngineInterface, path: string, pattern: string) {
446 try {
447 const started = Date.now()
448 const r = await $.process.run(
449 ['sh', '-c', 'grep -bnF -- "$1" "$2"', 'sh', pattern, path],
450 { timeoutMs: 60_000 },
451 )
452 // grep exits 1 for "no match" with empty output: that is an answer, not a failure.
453 if (r.exitCode === 0 || r.exitCode === 1 || r.stdout) return { via: 'sh' as const, ms: Date.now() - started, ...r }
454 } catch {
455 // sh is not there: try PowerShell.
456 }
457 try {
458 const started = Date.now()
459 const r = await $.process.run(
460 ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/hooks/scripts/grep-offsets.ps1`,
461 '-Path', path, '-Pattern', pattern],
462 { timeoutMs: 60_000 },
463 )
464 if (r.exitCode === 0 || r.exitCode === 1 || r.stdout) return { via: 'powershell' as const, ms: Date.now() - started, ...r }
465 } catch {
466 // PowerShell is not there either.
467 }
468 return undefined
469}
470
471// The message a request names, with its line and byte range: by uuid (full or 8+
472// prefix), or by a fragment of its text (earliest real message that contains it; several
473// are returned as candidates). The anchors DWP's resolver (2026-10-09).
474type ResolveOutcome =
475 | { kind: 'one'; row: TranscriptRow; via: string; ms: number }
476 | { kind: 'many'; rows: TranscriptRow[]; earliest: TranscriptRow; via: string; ms: number }
477 | { kind: 'none'; via?: string; ms?: number; reason: string }
478
479async function resolveTarget($: EngineInterface, want: { uuid?: string; fragment?: string }): Promise<ResolveOutcome> {
480 const path = await transcriptPath($)
481 if (!path) return { kind: 'none', reason: 'transcript not found' }
482 const pattern = want.uuid ? uuidPattern(want.uuid) : want.fragment ? jsonEscaped(want.fragment) : undefined
483 if (!pattern) return { kind: 'none', reason: 'nothing to look for' }
484 const g = await grepTranscript($, path, pattern)
485 if (!g) return { kind: 'none', reason: 'neither sh + grep nor PowerShell ran' }
486 const rows = rowsFromGrep(g.stdout)
487 const r: Resolution = resolveRows(rows, want)
488 if (r.kind === 'none') return { kind: 'none', via: g.via, ms: g.ms, reason: g.isStdoutTruncated ? 'no match (output was cut at 4 MiB)' : 'no match' }
489 return { ...r, via: g.via, ms: g.ms }
490}
491
492async function backfillPrompts($: EngineInterface) {
493 const doneKey = `backfilled:${await $.session.id()}`
494 if (await $.store.get(doneKey)) return
495 const path = await transcriptPath($)
496 if (!path) {
497 log($, '[bm-poc] backfill: transcript not found; prompts list starts when the mod loaded')
498 return
499 }
500 const rows = await userRows($, path)
501 if (!rows) {
502 log($, '[bm-poc] backfill: neither PowerShell nor sh + grep ran; prompts list starts when the mod loaded')
503 return
504 }
505 const found: PromptRef[] = []
506 for (const line of rows.stdout.split('\n')) {
507 if (!line.trim()) continue
508 let o: any
509 try {
510 o = JSON.parse(line)
511 } catch {
512 continue // a line cut off at the 4 MiB output limit
513 }
514 const text = promptTextOf(o)
515 if (text !== undefined && typeof o.uuid === 'string') found.push({ uuid: o.uuid, head: head(text) })
516 }
517 // Transcript order first, then anything the live capture holds that the file did not.
518 const have = new Set(found.map(p => p.uuid))
519 const merged = [...found, ...(await keptPrompts($)).filter(p => !have.has(p.uuid))]
520 await $.store.set(await promptsKey($), merged.slice(-PROMPTS_KEPT))
521 // A cut-off read is left unmarked, so a later session start tries again.
522 if (!rows.isStdoutTruncated) await $.store.set(doneKey, true)
523 log(
524 $,
525 `[bm-poc] backfill: ${found.length} prompts from the transcript via ${rows.via} in ${rows.ms} ms ` +
526 `(${rows.stdout.length} chars${rows.isStdoutTruncated ? ', CUT OFF at 4 MiB: newest may be missing' : ''}); ` +
527 `list now ${Math.min(merged.length, PROMPTS_KEPT)}`,
528 )
529}
530
531async function prompts($: EngineInterface): Promise<PromptRef[]> {
532 const kept = await keptPrompts($)
533 const seen = new Set(kept.map(k => k.uuid))
534 const fresh = (await allRows($)).filter(r => r.door === 'prompt' && !seen.has(r.uuid))
535 return [...kept, ...fresh.map(r => ({ uuid: r.uuid, head: r.head }))]
536}
537
538async function marksKey($: EngineInterface): Promise<string> {
539 return `marks:${await $.session.id()}`
540}
541
542async function loadMarks($: EngineInterface): Promise<Marks> {
543 return ((await $.store.get(await marksKey($))) as Marks | undefined) ?? {}
544}
545
546// A short, distinctive piece of a message to search for in the Ctrl+O transcript view:
547// the text after any generic opening (`RE:{`, a `<pasted_content …>` tag, braces), cut
548// at a word boundary to about 32 characters.
549function searchPhrase(text: string | undefined): string | undefined {
550 if (!text) return undefined
551 const cleaned = text
552 .replace(/<\/?pasted_content[^>]*>/g, ' ')
553 .replace(/^\s*(RE:\s*)?\{?\s*/i, '')
554 .replace(/\s+/g, ' ')
555 .trim()
556 if (!cleaned) return undefined
557 if (cleaned.length <= 32) return cleaned
558 const cut = cleaned.slice(0, 32)
559 const space = cut.lastIndexOf(' ')
560 return space > 16 ? cut.slice(0, space) : cut
561}
562
563// --- Bookmark anchors (anchors DWP 2026-10-09) -----------------------------------------
564// The URL format, its parser and the link finder live in hooks/core/anchor.ts (pure, with
565// tests under `node --test`). This file holds what needs the engine: the press, the jump,
566// the highlight, the tool and the register's I/O.
567
568// The transient mark an anchor press shows: the words the anchor names, highlighted on
569// the target row like a letter's selection, under a key no letter can take. Display
570// only; never written to the store (djdarcy, 2026-10-09: "highlighted yellow like how we
571// handle a mark when we do <leader>'<mark-key>").
572const ANCHOR_MARK = '@'
573
574// --- The register: <dataRoot>/bookmarks/sessions/<sessionId>.json, and one export per
575// bookmark beside it. The file is the source of truth (#6); this cache is a copy of what
576// was last read or written. `.bak` is written before the file, since `$.fs.write` is not
577// atomic; a main file that fails to parse falls back to it.
578let registerCache: BookmarkRegister | undefined
579
580async function registerPath($: EngineInterface): Promise<string | undefined> {
581 const root = await dataRoot($)
582 return root ? `${root}/bookmarks/sessions/${await $.session.id()}.json` : undefined
583}
584
585async function loadRegister($: EngineInterface): Promise<BookmarkRegister> {
586 if (registerCache) return registerCache
587 const path = await registerPath($)
588 let reg: BookmarkRegister | undefined
589 if (path) {
590 for (const p of [path, `${path}.bak`]) {
591 if (!(await $.fs.exists(p))) continue
592 reg = parseRegister(await $.fs.read(p))
593 if (reg) {
594 if (p !== path) log($, `[bm] register: ${path} unreadable; loaded its .bak (rev ${reg.rev})`)
595 break
596 }
597 log($, `[bm] register: ${p} is not a register file`)
598 }
599 }
600 if (!reg) {
601 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
602 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home.replace(/\\/g, '/')}/.claude` : undefined)
603 reg = emptyRegister(await $.session.id(), `${$.plugin.name} ${await pluginVersion($)}`, await $.clock.now(), {
604 ...(configDir ? { configDir } : {}),
605 cwd: (await $.session.cwd()).replace(/\\/g, '/'),
606 })
607 }
608 registerCache = reg
609 return reg
610}
611
612async function saveRegister($: EngineInterface, reg: BookmarkRegister) {
613 const path = await registerPath($)
614 if (!path) return
615 if (await $.fs.exists(path)) await $.fs.write(`${path}.bak`, await $.fs.read(path))
616 await $.fs.write(path, serializeRegister({ ...reg, writer: `${$.plugin.name} ${await pluginVersion($)}` }))
617 registerCache = reg
618}
619
620// Mint a bookmark: add or relabel the record, save the register, write the export, and
621// hand back the anchor URL and the markdown link a reply writes.
622async function mintBookmark($: EngineInterface, mint: Mint, messageText: string) {
623 const reg = await loadRegister($)
624 const now = await $.clock.now()
625 const { register, record, added } = addOrRelabel(reg, mint, now)
626 await saveRegister($, register)
627 const exportPath = await writeExport($, register, record, messageText, now)
628 const root = await dataRoot($)
629 const href = root ? anchorHref(record, root) : undefined
630 const markdown = root ? anchorMarkdown(record, root) : undefined
631 log($, `[bm] bookmark ${added ? 'added' : 'relabelled'}: ${short(record.uuid)} "${record.label}" (${ownerOf(record)}'s list; ${record.by}/${record.source}) rev ${register.rev}`)
632 return { record, added, href, markdown, exportPath, rev: register.rev }
633}
634
635// One export per message, shared by both lists. A rewrite keeps whatever was written under
636// the notes marker (a person's or Claude's notes, links, context: pillar 3, #11).
637async function writeExport($: EngineInterface, register: BookmarkRegister, record: Mint | { uuid: string; sessionId: string }, messageText: string, now: number) {
638 const root = await dataRoot($)
639 if (!root) return undefined
640 const sessionId = 'sessionId' in record ? record.sessionId : register.sessionId
641 const exportPath = anchorPath(root, sessionId, record.uuid)
642 const existing = (await $.fs.exists(exportPath)) ? await $.fs.read(exportPath) : undefined
643 // A relabel or share has no message text in hand: read it from the transcript again,
644 // and if that fails leave the file as it is rather than write an empty message.
645 if (!messageText) {
646 const found = await resolveTarget($, { uuid: record.uuid })
647 if (found.kind === 'one') messageText = found.row.text
648 else if (existing) return exportPath
649 }
650 const owners = (['user', 'claude'] as AnchorOwner[]).filter(o => findRecord(register, record.uuid, o))
651 // The record to describe: the person's copy first, since the file is theirs to read.
652 const main = findRecord(register, record.uuid, 'user') ?? findRecord(register, record.uuid)
653 if (!main) return exportPath
654 const minted = `${new Date(now).toISOString()} by ${main.by} (${main.source})`
655 await $.fs.write(exportPath, exportMarkdown(main, messageText, { minted, owners, notes: exportNotesOf(existing) }))
656 return exportPath
657}
658
659// How link kinds are told apart in a reply (djdarcy, 2026-10-09: "how can we denote that
660// a link has secondary metadata ... so I don't have to ctrl-click every link"). Settings
661// in a later version (#7); constants until then.
662const LINK_MARKERS: MarkerStyle = 'glyph'
663const LINK_LEGEND = true
664
665// Replies whose links were already logged and queued (a reply is drawn many times).
666const anchorRowsLogged = new Set<string>()
667
668// A bookmark link written by hand (or by an older session) is a bookmark too: once drawn,
669// any anchor the register does not know is resolved by uuid and recorded with
670// `by: 'prose'`. Off the render path (a clock tick), one attempt per uuid per load.
671const proseQueued = new Set<string>()
672function queueProseAnchors($: EngineInterface, anchors: ReturnType<typeof markdownLinks>) {
673 for (const l of anchors) {
674 const a = l.anchor
675 if (!a || proseQueued.has(a.uuid8)) continue
676 proseQueued.add(a.uuid8)
677 $.clock.after(0, () => {
678 void (async () => {
679 const reg = await loadRegister($)
680 if (reg.perma.some(r => r.uuid.toLowerCase().startsWith(a.uuid8))) return
681 if (a.sessionId && a.sessionId !== (await $.session.id())) return // another conversation's bookmark
682 const found = await resolveTarget($, { uuid: a.uuid })
683 if (found.kind !== 'one') {
684 log($, `[bm] prose bookmark ${a.uuid8} not recorded: ${found.kind === 'none' ? found.reason : 'ambiguous uuid'}`)
685 return
686 }
687 const row = found.row
688 await mintBookmark(
689 $,
690 {
691 uuid: row.uuid,
692 label: l.label.replace(/^(⚓|▤|\[bm\]|\[file\])\s*/, '') || `bookmark ${a.uuid8}`,
693 ...(a.words && row.text.includes(a.words) ? { words: a.words } : {}),
694 line: row.line,
695 bytes: [row.byteStart, row.byteEnd],
696 head: headOf(row.text),
697 owner: 'claude',
698 by: 'prose',
699 source: 'prose',
700 transcript: (await transcriptPath($))?.replace(/\\/g, '/'),
701 role: row.role,
702 ...(row.timestamp ? { timestamp: row.timestamp } : {}),
703 },
704 row.text,
705 )
706 })().catch(err => log($, `[bm] prose bookmark ${a.uuid8} failed: ${String(err)}`))
707 })
708 }
709}
710
711// The press on an anchor link: resolve a uuid prefix against the rows this mod knows,
712// then the same jump as a mark (with its refused-jump fallbacks).
713async function onAnchorPress($: EngineInterface, href: string) {
714 const a = parseAnchor(href)
715 log($, `[bm-poc] M1: anchor link pressed: ${href.slice(0, 120)} -> ${a ? `uuid ${a.uuid}` : 'NOT an anchor'}`)
716 if (!a) return
717 let uuid = a.uuid
718 if (uuid.length < 36) {
719 const row = (await allRows($)).find(r => r.uuid.startsWith(uuid))
720 if (row) uuid = row.uuid
721 else log($, `[bm-poc] M1: no known row starts with ${uuid}; trying it as written`)
722 }
723 // The jump records itself in the jumplist (jumpTo), so <leader> o comes back here. The
724 // reading position is left alone: it is the person's (djdarcy, 2026-10-09).
725 // The words to highlight (and, on a refused jump, the phrase the fallback copies): the
726 // link's `q`, else the register's record.
727 const words = a.words ?? (await loadRegister($)).perma.find(r => r.uuid === uuid)?.words
728 const deny = await jumpTo($, uuid, 'anchor press', `anchor ${short(uuid)}${a.line ? ` (line ${a.line})` : ''}`, words)
729 if (deny || !words) return
730 await highlightWords($, uuid, words)
731 log($, `[bm] anchor words highlighted on ${short(uuid)}: "${words.slice(0, 60)}"`)
732}
733
734// --- Jumplist: back and forward over every jump (vim's Ctrl+O / Ctrl+I) ------------------
735// djdarcy, 2026-10-09: a back button "like <leader><space><space>", but separate from the
736// reading position, which is a place the person set on purpose. Browser-style history of
737// view positions: `entries[index]` is where the view is now; a new jump drops anything
738// after `index`, records where we left from, and appends where we landed. Kept per
739// session in the store, capped at 100.
740type JumpEntry = { uuid: string; block: 'start' | 'end' }
741type Jumplist = { entries: JumpEntry[]; index: number }
742const JUMPLIST_MAX = 100
743let jumplistCache: Jumplist | undefined
744let jumplistMoving = false
745
746async function jumplistKey($: EngineInterface): Promise<string> {
747 return `jumplist:${await $.session.id()}`
748}
749async function loadJumplist($: EngineInterface): Promise<Jumplist> {
750 if (!jumplistCache) jumplistCache = ((await $.store.get(await jumplistKey($))) as Jumplist | undefined) ?? { entries: [], index: -1 }
751 return jumplistCache
752}
753async function saveJumplist($: EngineInterface, jl: Jumplist) {
754 jumplistCache = jl
755 await $.store.set(await jumplistKey($), jl)
756}
757const sameEntry = (a: JumpEntry | undefined, b: JumpEntry) => !!a && (a.uuid === b.uuid || firstFour(a.uuid) === firstFour(b.uuid))
758
759async function recordJump($: EngineInterface, from: JumpEntry | undefined, to: JumpEntry) {
760 if (jumplistMoving) return // a back or forward move is a walk, not a new jump
761 const jl = await loadJumplist($)
762 let entries = jl.entries.slice(0, jl.index + 1)
763 if (from && !sameEntry(entries.at(-1), from)) entries.push(from)
764 if (!sameEntry(entries.at(-1), to)) entries.push(to)
765 entries = entries.slice(-JUMPLIST_MAX)
766 await saveJumplist($, { entries, index: entries.length - 1 })
767}
768
769async function jumplistMove($: EngineInterface, step: -1 | 1) {
770 const jl = await loadJumplist($)
771 const next = jl.index + step
772 if (next < 0 || next >= jl.entries.length) {
773 $.ui.toast(step < 0 ? 'Nothing to go back to' : 'Nothing to go forward to')
774 return
775 }
776 const target = jl.entries[next]!
777 jumplistMoving = true
778 try {
779 const deny = await jumpTo($, target.uuid, step < 0 ? 'jumplist back' : 'jumplist forward', `${step < 0 ? 'back' : 'forward'} to ${short(target.uuid)}`, undefined, false, target.block)
780 if (!deny) {
781 await saveJumplist($, { ...jl, index: next })
782 const where = rendered.get(target.uuid) ?? Object.values(markCache).find(m => m.uuid === target.uuid)?.head
783 $.ui.toast(`${step < 0 ? 'back' : 'forward'}${where ? `: ${where.slice(0, 40)}` : ''} (${next + 1}/${jl.entries.length})`)
784 }
785 } finally {
786 jumplistMoving = false
787 }
788}
789
790// B2/B3/B5 share this: scroll, then report exactly what the engine answered. Returns the
791// refusal, if any. `probe`: a try whose "not person-initiated" refusal the caller
792// handles (the band command line's one-time check), so no toast for that one.
793async function jumpTo($: EngineInterface, uuid: string, via: string, label: string, words?: string, probe = false, block: 'start' | 'end' = 'start'): Promise<string | undefined> {
794 // Where the person is leaving from, for the jumplist: the view before the pane opened
795 // when a pane is up (the pane shifts the view), else the view now.
796 const from = paneView ?? (await viewAnchor($))
797 const result = await $.ui.scroll({ to: { requestId: uuid }, block })
798 // The jump is where the person wants to be: no going back to before the pane.
799 if (!result.deny) {
800 paneAnchor = undefined
801 await recordJump($, from, { uuid, block })
802 }
803 const verdict = result.deny ? `DENY: ${result.deny}` : 'ok'
804 if (probe && result.deny && /person-initiated/i.test(result.deny)) {
805 log($, `[bm-poc] ${via} scroll -> ${verdict} | target ${label} ${short(uuid)} (probe)`)
806 return result.deny
807 }
808 // A message Claude Code has not drawn cannot be scrolled to ("nothing drawn under
809 // that requestId": prompts from before this conversation's compaction, 2026-10-05).
810 // The view stays put, which looked like "it took me somewhere else"; say so, and
811 // offer the planned fallback (issue #4): search the transcript view.
812 // Any other refusal ("not person-initiated", ...) is not about the message: say what
813 // Claude Code said, not "older than a compaction" (log, 2026-10-07 06:26:12).
814 if (result.deny && !/nothing drawn/i.test(result.deny)) {
815 $.ui.toast(`Can't jump there: Claude Code refused the scroll (${result.deny}).`)
816 log($, `[bm-poc] jump refused for another reason: ${result.deny}`)
817 } else if (result.deny) {
818 const text = words ?? rendered.get(uuid) ?? Object.values(markCache).find(m => m.uuid === uuid)?.head
819 // The mod cannot open Ctrl+O or fill a search (nothing in $.ui drives the
820 // transcript view), but it can put a search phrase on the clipboard (djdarcy,
821 // 2026-10-05: "give the user the text ... so it autopopulates"). The phrase skips
822 // the generic openings many prompts share ("RE:{", a pasted-content tag).
823 const phrase = searchPhrase(text)
824 const copied = phrase ? (await $.ui.copy({ text: phrase })).isCopied : false
825 // Where to search: transcript mode's `/` only sees what the view holds, which after
826 // a compaction excludes earlier messages (2026-10-05: not found). `[` in transcript
827 // mode writes the FULL conversation to the terminal's scrollback, where the
828 // terminal's own find does reach them (user-verified, 2026-10-05).
829 const how = `press Ctrl+O, then [ (full conversation), then your terminal's Find (Ctrl+Shift+F or Cmd+F)`
830 $.ui.toast(
831 copied
832 ? `Can't jump there: that message isn't on screen (older than a compaction?). Copied "${phrase}": ${how} and paste.`
833 : `Can't jump there: that message isn't on screen (older than a compaction?). ${how} and search for ` +
834 `${phrase ? `"${phrase}"` : 'a few words of it'}.`,
835 { timeoutMs: 12000 },
836 )
837 log($, `[bm-poc] jump refused; search phrase ${copied ? 'copied' : 'shown'}: "${phrase ?? ''}"`)
838 }
839 note(`scroll ${uuid} via ${via} -> ${verdict}`)
840 log($,
841 `[bm-poc] ${via} scroll -> ${verdict} | target ${label} ${short(uuid)} | ` +
842 `drawn since load: ${rendered.has(uuid) ? 'yes' : 'NO'}`,
843 )
844 return result.deny
845}
846
847// The selection as it stood when the mark chord fired, read BEFORE the pane opens:
848// round 1 read it after, and the mark landed one row too high (the inline pane shifts
849// the transcript; hypothesis: the engine maps the selection to a row by screen position).
850let selectionAtChord: Awaited<ReturnType<EngineInterface['ui']['selection']>> | undefined
851
852// A drawn row id may be the zero-tailed form of the real uuid. Find the saved row it
853// stands for, and say whether the selected text is really in that row.
854async function resolveSelectionRow($: EngineInterface, id: string, text: string) {
855 const list = await allRows($)
856 const saved = list.find(r => r.uuid === id) ?? list.find(r => firstFour(r.uuid) === firstFour(id))
857 const needle = text.trim().slice(0, 40)
858 const check = !saved ? 'no saved row'
859 : saved.text === undefined ? 'row saved before text capture'
860 : saved.text.includes(needle) ? 'text verified' : 'TEXT NOT IN ROW'
861 return { uuid: saved?.uuid ?? id, check }
862}
863
864// Highlight a mark just set from a selection, then put the view back. Redrawing the
865// message under a visible selection drops the view to the bottom, even with following
866// the bottom turned off (djdarcy, 2026-10-06; a stale selection, no longer shown, did
867// not; seen only when the pane held the keyboard). So note the message at the top of
868// the screen first, and scroll back to it once the pane has closed: message-level, like
869// the reading position's return. The scroll must run within the keypress's own handler:
870// one from a timer is refused ("not person-initiated", log 2026-10-06 04:22:20).
871let restoreTo: string | undefined
872// The message at the top of the screen just before a pane opened (openFor): where the
873// view goes back to once the pane has done its job. A jump clears it (jumpTo).
874let paneAnchor: string | undefined
875// The same moment as a scroll can restore it: at the bottom, the last message's end.
876let paneView: { uuid: string; block: 'start' | 'end' } | undefined
877async function highlightInPlace($: EngineInterface, letter: string, text: string) {
878 restoreTo = paneAnchor ?? (await topOnScreen($))
879 await showMark($, letter, text)
880}
881// The shift is Claude Code's, on every route: the old Ctrl+X m chord moved the view by
882// about a screen as well (djdarcy, 2026-10-07, with this scroll-back switched off).
883async function restoreView($: EngineInterface) {
884 // A pane opened and closed: the view was shifted for certain, so go back to where it
885 // was before the pane, whether or not that message is still on screen somewhere.
886 const fromPane = !!paneAnchor
887 const block = (fromPane && paneView?.block) || 'start'
888 const here = (fromPane ? paneView?.uuid ?? paneAnchor : undefined) ?? restoreTo
889 restoreTo = undefined
890 paneAnchor = undefined
891 paneView = undefined
892 if (!here) return
893 // Otherwise only when the view actually moved: scrolling back regardless lined up the
894 // top message's first line and moved a view that had not dropped (djdarcy, 2026-10-07).
895 if (!fromPane && freshOnScreen().some(id => id === here || firstFour(id) === firstFour(here))) {
896 log($, `[bm-poc] view kept after highlight (${short(here)} still on screen)`)
897 return
898 }
899 const r = await $.ui.scroll({ to: { requestId: here }, block })
900 log($, `[bm-poc] view restored after highlight -> ${r.deny ? `DENY: ${r.deny}` : 'ok'} | ${short(here)}`)
901}
902
903// --- Fresh and stale selections ---------------------------------------------------------
904// $.ui.selection() answers what the person LAST selected, even once its highlight is gone
905// (the types' docs), so a mark with nothing selected used a quote selected minutes
906// earlier (2026-10-07 07:13:10, 07:20:21). djdarcy: "if a user selects text we should be
907// attempting to date-time it and if it's older than 1.25 minutes then we should mark the
908// current top-left portion of the screen". No event says when a selection changes, so it
909// is polled once a second and stamped when it does. Becomes a setting (#7).
910const SELECTION_FRESH_SECONDS = 75
911type Selection = Awaited<ReturnType<EngineInterface['ui']['selection']>>
912let selectionSeen: { key: string; at: number } | undefined
913let selectionPoll: Timer | undefined
914const selectionKey = (s: Selection) => (s?.requestId && s.text.trim() ? `${s.requestId}|${s.text}` : '')
915
916async function pollSelection($: EngineInterface) {
917 const key = selectionKey(await $.ui.selection())
918 // The first look has no history: whatever is selected then counts as old.
919 if (!selectionSeen) selectionSeen = { key, at: 0 }
920 else if (key !== selectionSeen.key) selectionSeen = { key, at: Date.now() }
921}
922
923// The selection if it was made within SELECTION_FRESH_SECONDS, else undefined.
924async function freshSelection($: EngineInterface, sel: Selection): Promise<Selection> {
925 const key = selectionKey(sel)
926 if (!key) return undefined
927 // Changed since the last poll: made within the last second.
928 if (selectionSeen && key !== selectionSeen.key) selectionSeen = { key, at: Date.now() }
929 const at = selectionSeen?.key === key ? selectionSeen.at : 0
930 const age = at ? Math.round((Date.now() - at) / 1000) : undefined
931 const fresh = age !== undefined && age <= SELECTION_FRESH_SECONDS
932 log($, `[bm-poc] selection "${sel!.text.trim().slice(0, 24)}" is ${age === undefined ? 'of unknown age' : `${age}s old`}: ${fresh ? 'used' : 'ignored (stale)'}`)
933 return fresh ? sel : undefined
934}
935
936// The message at the top of the screen, as a mark: the fallback when no fresh selection
937// says where (djdarcy: "the current 'screen' they are looking at from the top left").
938// Message-level, like the reading position; its first line is the one highlighted.
939async function screenTopMark($: EngineInterface, before?: string): Promise<Mark | undefined> {
940 const id = before ?? (await topOnScreen($))
941 if (!id) return undefined
942 const list = await allRows($)
943 const row = list.find(r => r.uuid === id) ?? list.find(r => firstFour(r.uuid) === firstFour(id))
944 const text = row?.text ?? rendered.get(id) ?? row?.head ?? ''
945 const first = snippetOf(plainMarkdown(text))
946 return {
947 uuid: row?.uuid ?? id,
948 head: head(text) || '(message at the top of the screen)',
949 markedAt: await $.clock.now(),
950 source: 'screen',
951 ...(first ? { snippet: first } : {}),
952 }
953}
954
955async function setMark($: EngineInterface, letter: string) {
956 // A fresh mouse selection says where; otherwise the top of the screen.
957 const sel = await freshSelection($, selectionAtChord ?? (await $.ui.selection()))
958 selectionAtChord = undefined
959 note(`selection at mark: ${sel ? `row ${sel.requestId ?? '(none)'} text ${JSON.stringify(sel.text.slice(0, 80))}` : 'none'}`)
960 if (sel?.requestId) {
961 const { uuid, check } = await resolveSelectionRow($, sel.requestId, sel.text)
962 const fresh = await loadMarks($)
963 const mark: Mark = {
964 uuid,
965 head: head(sel.text),
966 markedAt: await $.clock.now(),
967 source: 'selection',
968 snippet: snippetOf(sel.text),
969 }
970 note(`store.set mark ${letter} -> ${uuid} (selection, drawn ${sel.requestId}, ${check})`)
971 await $.store.set(await marksKey($), { ...fresh, [letter]: mark })
972 markCache = { ...fresh, [letter]: mark }
973 // Writing `shown` redraws the rows and the band that read it.
974 await highlightInPlace($, letter, snippetOf(sel.text))
975 log($, `[bm-poc] SEL: mark ${letter} -> ${uuid} (drawn as ${sel.requestId}; ${check}) | "${head(sel.text)}"`)
976 $.ui.toast(`mark ${letter} [selection] -> ${head(sel.text).slice(0, 40)}`)
977 return
978 }
979
980 // No fresh selection: the message at the top of the screen, as it was BEFORE the pane
981 // opened and shifted it (paneAnchor). (The "latest prompt" fallback is gone: prompts
982 // have the prompts pane and pins. djdarcy, 2026-10-07.)
983 const mark = await screenTopMark($, paneAnchor)
984 if (!mark) {
985 $.ui.toast('Nothing on screen to mark yet: scroll a little, or select some text')
986 return
987 }
988 // Read again right before the write: the store is shared and not atomic.
989 const fresh = await loadMarks($)
990 note(`store.set mark ${letter} -> ${mark.uuid} (top of screen)`)
991 await $.store.set(await marksKey($), { ...fresh, [letter]: mark })
992 markCache = { ...fresh, [letter]: mark }
993 if (mark.snippet) await highlightInPlace($, letter, mark.snippet)
994 log($, `[bm-poc] mark ${letter} -> ${short(mark.uuid)} (top of screen) | "${mark.head}"`)
995 $.ui.toast(`mark ${letter} [top of screen] -> ${mark.head.slice(0, 40)}`)
996}
997
998// --- Reading position: Ctrl+X Space ------------------------------------------------
999// One key, no letter (djdarcy, 2026-10-04): with text selected it sets the reading
1000// position there; with nothing selected (or the same selection still up) it jumps to
1001// it, and pressed again while it is on screen it swaps back to where the person was,
1002// like vim's ``. "On screen" comes from what the render hooks report, not from the
1003// last press, so scrolling by hand in between does not confuse it.
1004// The toggle's state, kept explicitly rather than read off the screen (whose reports go
1005// stale after a jump, see onScreenNow): `at` true right after going to the reading
1006// position, so the next press goes `back`; false otherwise, so the next press goes there.
1007// `backBlock`: which edge of the window `back` was anchored to (viewAnchor).
1008type ReadingState = { at: boolean; back: string | null; backBlock?: 'start' | 'end' }
1009async function readingStateKey($: EngineInterface): Promise<string> {
1010 return `readingState:${await $.session.id()}`
1011}
1012async function readingState($: EngineInterface): Promise<ReadingState> {
1013 return ((await $.store.get(await readingStateKey($))) as ReadingState | undefined) ?? { at: false, back: null }
1014}
1015async function setReadingState($: EngineInterface, state: ReadingState) {
1016 await $.store.set(await readingStateKey($), state)
1017}
1018
1019// The message at the top of the view: of those on screen, the earliest in the
1020// conversation. Order is known for every prompt (back-filled) and for the replies
1021// captured since load (each placed after the prompt before it).
1022async function topOnScreen($: EngineInterface): Promise<string | undefined> {
1023 const order = new Map<string, number>()
1024 const promptList = await prompts($)
1025 promptList.forEach((p, i) => order.set(p.uuid, i * 1000))
1026 let base = 0
1027 let k = 0
1028 for (const r of await allRows($)) {
1029 if (r.door === 'prompt') {
1030 base = order.get(r.uuid) ?? base
1031 k = 0
1032 } else order.set(r.uuid, base + ++k)
1033 }
1034 // Only the latest burst of reports: what is on screen now, not what was before a jump.
1035 const visible = freshOnScreen()
1036 const known = visible.filter(id => order.has(id)).sort((a, b) => order.get(a)! - order.get(b)!)
1037 // Not yet captured (a reply still arriving) sorts last: it is the newest.
1038 lastScreenOrder = [...known, ...visible.filter(id => !order.has(id))]
1039 return known[0] ?? visible[0]
1040}
1041// The messages on screen, top to bottom, as topOnScreen last ordered them.
1042let lastScreenOrder: string[] = []
1043
1044// Where "back" goes when you left from the bottom of the conversation (a setting, #7):
1045// 'where-it-was': the same text you were reading, even if new replies arrived since
1046// (djdarcy's choice: "I'd rather have it always go to the exact same
1047// location where the screen was so I'm reading exactly what I was
1048// reading before");
1049// 'newest': the bottom as it is now, Ctrl+End, new replies included.
1050const READING_BACK_FROM_BOTTOM: 'where-it-was' | 'newest' = 'where-it-was'
1051
1052// Where the view is, as a scroll can put it back: the bottom message's end when its last
1053// line shows (at the bottom of the conversation, that is exactly Ctrl+End), else the top
1054// message's start. "Back" from the reading position went to the top message's start
1055// even from the very bottom, a little above where the person was (djdarcy, 2026-10-07).
1056async function viewAnchor($: EngineInterface): Promise<{ uuid: string; block: 'start' | 'end' } | undefined> {
1057 const top = await topOnScreen($)
1058 if (!top) return undefined
1059 const bottom = lastScreenOrder.at(-1)
1060 const b = bottom ? onScreenNow.get(bottom) : undefined
1061 if (bottom && b && b.last >= b.of - 1) return { uuid: bottom, block: 'end' }
1062 return { uuid: top, block: 'start' }
1063}
1064
1065// Go to the reading position, noting where we were so the next Ctrl+X Space swaps back.
1066// Shared by the chord and the jump pane's `␣ reading` entry.
1067async function goToReading($: EngineInterface, reading: Mark, via: string) {
1068 const here = await topOnScreen($)
1069 const anchor = await viewAnchor($)
1070 // Already at the reading position (the jump pane's entry used while there, log
1071 // 2026-10-05 00:06:17): keep the earlier spot to return to, or "back" would go to
1072 // the reading position itself.
1073 const atItAlready = !!here && (here === reading.uuid || firstFour(here) === firstFour(reading.uuid))
1074 const previous = await readingState($)
1075 await setReadingState(
1076 $,
1077 atItAlready
1078 ? { at: true, back: previous.back, ...(previous.backBlock ? { backBlock: previous.backBlock } : {}) }
1079 : { at: true, back: anchor?.uuid ?? null, ...(anchor ? { backBlock: anchor.block } : {}) },
1080 )
1081 if (anchor) log($, `[bm-poc] reading position: back will be ${short(anchor.uuid)} at the window's ${anchor.block}`)
1082 await jumpTo($, reading.uuid, via, `from ${here ? short(here) : '(unknown)'}`)
1083 await showMark($, READING, reading.snippet ?? reading.head)
1084}
1085
1086async function readingToggle($: EngineInterface) {
1087 // Only a fresh selection moves the reading position (a stale one did, 2026-10-07 07:20:21).
1088 const sel = await freshSelection($, await $.ui.selection())
1089 const marks = await loadMarks($)
1090 const reading = marks[READING]
1091 const picked = sel?.requestId && sel.text.trim() ? snippetOf(sel.text) : undefined
1092
1093 // A selection in a different message: set the reading position there. One inside the
1094 // message that already holds it counts as "go there", not "move": terminal
1095 // selections appear and linger easily (a click, a drag), and twice a press meant as a
1096 // jump re-set it instead, to a stray mid-word selection in the same message (log,
1097 // 2026-10-04 23:53:58 and 23:55:09).
1098 const sameMessage =
1099 !!reading && !!sel?.requestId && (sel.requestId === reading.uuid || firstFour(sel.requestId) === firstFour(reading.uuid))
1100 if (sel?.requestId && picked && !sameMessage) {
1101 const { uuid, check } = await resolveSelectionRow($, sel.requestId, sel.text)
1102 const mark: Mark = { uuid, head: head(sel.text), markedAt: await $.clock.now(), source: 'selection', snippet: picked }
1103 await $.store.set(await marksKey($), { ...marks, [READING]: mark })
1104 markCache = { ...marks, [READING]: mark }
1105 // Next press goes there (from wherever the person has scrolled to by then).
1106 await setReadingState($, { at: false, back: null })
1107 await highlightInPlace($, READING, picked)
1108 await restoreView($)
1109 log($, `[bm-poc] reading position set -> ${short(uuid)} (${check}) | "${head(sel.text)}"`)
1110 $.ui.toast(`reading position set: ${head(sel.text).slice(0, 40)}`)
1111 return
1112 }
1113
1114 if (!reading) {
1115 $.ui.toast('Select some text, then Ctrl+X Space, to set a reading position')
1116 return
1117 }
1118
1119 const state = await readingState($)
1120 // Go back only while the reading position is actually on screen, judged by the
1121 // latest burst of on-screen reports. The there/back state alone was wrong once the
1122 // person scrolled elsewhere by hand: the next press "went back" to the old spot
1123 // instead of to the mark (djdarcy, 2026-10-04). Old reports alone were wrong after
1124 // Ctrl+End (the message left behind still looked visible); the freshness filter
1125 // handles that.
1126 const onScreenNowFresh = freshOnScreen().some(
1127 id => id === reading.uuid || firstFour(id) === firstFour(reading.uuid),
1128 )
1129 if (state.at && state.back && onScreenNowFresh) {
1130 await setReadingState($, { at: false, back: null })
1131 // Left from the bottom, and the setting says "the bottom as it is now": the newest
1132 // captured message's end instead of the one that was last then.
1133 const newest = state.backBlock === 'end' && READING_BACK_FROM_BOTTOM === 'newest' ? (await allRows($)).at(-1)?.uuid : undefined
1134 const target = newest ?? state.back
1135 await jumpTo($, target, 'reading position (back)', `to ${short(target)} (${state.backBlock ?? 'start'}${newest ? ', newest' : ''})`, undefined, false, state.backBlock ?? 'start')
1136 await clearShown($)
1137 return
1138 }
1139 // Already looking at it, with nowhere real to go back to: stay put. Jumping here
1140 // recorded the message just above the mark as "where you were", so the next press
1141 // swapped between two spots a few lines apart; and pressing at the mark re-jumped
1142 // to it over and over (log, 2026-10-05 00:39-00:42 UTC).
1143 if (onScreenNowFresh) {
1144 await showMark($, READING, reading.snippet ?? reading.head)
1145 $.ui.toast("You're at the reading position. Scroll away and press again to come back here.")
1146 const seen = [...onScreenNow.entries()].find(([id]) => id === reading.uuid || firstFour(id) === firstFour(reading.uuid))?.[1]
1147 log($, `[bm-poc] reading position: already on screen, stayed put` +
1148 (seen ? ` (reported lines ${seen.first}-${seen.last} of ${seen.of}, ${Date.now() - seen.at} ms ago)` : ''))
1149 return
1150 }
1151 // Otherwise: note where we are, then go there.
1152 await goToReading($, reading, 'reading position')
1153}
1154
1155// Returns the scroll's refusal, if any ('unset' when there is no such mark).
1156async function jumpToMark($: EngineInterface, letter: string, probe = false): Promise<string | undefined> {
1157 const mark = (await loadMarks($))[letter]
1158 if (!mark) {
1159 $.ui.toast(`mark ${letter} is not set`)
1160 return 'unset'
1161 }
1162 const deny = await jumpTo($, mark.uuid, 'B4+B5 chord jump', `mark ${letter}`, undefined, probe)
1163 if (!deny) await showMark($, letter, mark.snippet ?? mark.head)
1164 return deny
1165}
1166
1167// --- The band's command line -----------------------------------------------------------
1168// Design 2026-10-07__02-58-22 (and its POC addendum). The band leader (abovePrompt:focus,
1169// Ctrl+] for djdarcy) puts the keyboard on the band, whose first element is a field
1170// that takes every printable key, ' and Space included, over a draft and mid-turn.
1171// The field's typing and Enter DO scroll the conversation, as long as the handler is
1172// still running when the scroll is asked (the engine credits the work to the keystroke
1173// only until the handler returns; a fire-and-forget `void` lost it, 2026-10-09). So the
1174// keys below act directly; the once-per-version probe that used to decide this is gone.
1175
1176const CMD_HINT = "bm: ' or j + letter (jump) m + letter (mark) Space or r (reading) p + number (prompt) b (bookmarks) P + letter (promote) o / i (back / forward)"
1177
1178// The two list-shaped pane modes share the band's number entry (digits, j/k, Enter).
1179const listLike = (m: PaneMode | 'idle') => m === 'list' || m === 'bookmarks'
1180let cmdBusy = false
1181
1182// Draw the field anew, empty.
1183async function clearCommandLine($: EngineInterface) {
1184 await update($, cmdRev, v => v + 1)
1185}
1186
1187// The band's key mode for `mode`, with the ring on `focusKey` when given (Enter presses
1188// it). openFor opens the pane as the list; from the band its focus is refused, so the
1189// band takes the key (see openFor).
1190async function handOff($: EngineInterface, mode: PaneMode, focusKey?: string) {
1191 await openFor($, mode, 'band command line')
1192 if (focusKey) await focusBand($, focusKey)
1193}
1194
1195async function runCommandLine($: EngineInterface, typed: string, via: 'input' | 'submit') {
1196 if (cmdBusy || typed === '') return
1197 cmdBusy = true
1198 try {
1199 const first = typed[0]!
1200 log($, `[bm-poc] band command line ${via}: "${typed}"`)hooks/engine/select.ts 26 lines1// Capability paths (issue #18): for each engine API Claude Code is missing, the plugin
2// can hold up to three implementations and pick one at run time.
3//
4// ideal written against the API we'd propose upstream; runs as soon as Claude
5// Code (stock, or a patched build implementing the proposal) offers it
6// patched only for a patched build exposing something that isn't the proposal
7// workaround what works on stock Claude Code today
8//
9// Code that uses an API Claude Code already has is written directly and never comes
10// through here (djdarcy, 2026-10-05: "we shouldn't need #2 ... nor need #3 ... if #1
11// already exists"). Design: the capability-paths DWP, 2026-10-05.
12//
13// A mod can't load code on demand (a module holding `import()` doesn't load), so every
14// path is imported up front and the selector only chooses among them.
15
16export type PathName = 'ideal' | 'patched' | 'workaround'
17
18// The order the selector tries paths in when nothing overrides it: the proper API first,
19// then a patched build's own route, then the stock workaround.
20export const PATH_ORDER: readonly PathName[] = ['ideal', 'patched', 'workaround']
21
22// One line for /bm-env: proves this module loaded, and says which order is in force.
23export function describePathOrder(): string {
24 return `capability paths: ${PATH_ORDER.join(' > ')}`
25}
26hooks/core/anchor.ts 286 lines1// Bookmark anchors: the durable address of a place in a conversation.
2//
3// Pure: no engine, no I/O, so it runs under `node --test` and a real debugger. The mod
4// imports it; csb or a script can too. Design: the anchors DWP of 2026-10-09
5// (`2026-10-09__06-55-30__dev-workflow-process__claude-anchors-persistent-marks-and-click-to-jump.md`).
6//
7// Identity is (sessionId, uuid). The URL names the bookmark's own export file under the
8// data root and carries the position in a QUERY string: Windows opens a `file:` URL with a
9// query on ctrl-click and refuses one with a `#` fragment, raw or encoded (measured
10// 2026-10-09). The line, bytes and words are hints that make the place quick to find and
11// easy to verify by hand; the uuid is the truth.
12//
13// file:///<dataRoot>/bookmarks/sessions/<sessionId>/<uuid8>.md?u=<uuid>&l=<line>&b=<start>-<end>&q=<words>&v=1
14
15export const ANCHOR_VERSION = 1
16
17export type AnchorBy = 'claude' | 'user' | 'prose'
18export type AnchorSource = 'tool' | 'promote' | 'prose'
19/** Whose list a bookmark is on: the person's, or Claude's own (two separate sets). */
20export type AnchorOwner = 'user' | 'claude'
21
22export type AnchorRecord = {
23 sessionId: string
24 uuid: string
25 /** Whose list this is on. Absent on records from before 2026-10-09: read it as `by`. */
26 owner?: AnchorOwner
27 /** Set on a copy made from the other list (`share`). */
28 sharedFrom?: AnchorOwner
29 /** 1-based line of the message in the transcript, as `grep -n` counts it. */
30 line?: number
31 /** Byte offset of that line's first byte, and of the byte after its last (newline excluded). */
32 bytes?: [number, number]
33 /** The words to highlight on arrival; a verbatim piece of the message. */
34 words?: string
35 head: string
36 label: string
37 why?: string
38 temporary?: boolean
39 createdAt: number
40 updatedAt?: number
41 by: AnchorBy
42 source: AnchorSource
43 /** The transcript path as last seen. A hint: the file moves between project folders on /cd. */
44 transcript?: string
45 role?: 'user' | 'assistant'
46 timestamp?: string
47}
48
49export type ParsedAnchor = {
50 href: string
51 /** From the path (`.../sessions/<sessionId>/<uuid8>.md`); absent when the path is not ours. */
52 sessionId?: string
53 /** The full uuid when the link carried one, else the 8-character prefix. */
54 uuid: string
55 uuid8: string
56 line?: number
57 bytes?: [number, number]
58 words?: string
59 version?: number
60 /** True for links written before the query form (position after `#`). */
61 legacyFragment: boolean
62}
63
64export const uuid8 = (uuid: string): string => uuid.slice(0, 8).toLowerCase()
65
66/** The list a record is on; older records without `owner` belong to whoever minted them. */
67export const ownerOf = (r: Pick<AnchorRecord, 'owner' | 'by'>): AnchorOwner => r.owner ?? (r.by === 'user' ? 'user' : 'claude')
68
69const UUID_FULL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
70const UUID_PREFIX = /^[0-9a-f]{8}(-[0-9a-f-]{1,27})?$/i
71
72/** Forward slashes, no trailing slash. */
73export function normalizeRoot(dataRoot: string): string {
74 return dataRoot.replace(/\\/g, '/').replace(/\/+$/, '')
75}
76
77/** The export file's path under the data root (forward slashes, not URL-encoded). */
78export function anchorPath(dataRoot: string, sessionId: string, uuid: string): string {
79 return `${normalizeRoot(dataRoot)}/bookmarks/sessions/${sessionId}/${uuid8(uuid)}.md`
80}
81
82/** A `file:` URL for a path: `C:/x y` -> `file:///C:/x%20y`; `/home/x` -> `file:///home/x`. */
83export function fileUrl(path: string): string {
84 const p = path.replace(/\\/g, '/')
85 const abs = p.startsWith('/') ? p : `/${p}`
86 // `encodeURI` leaves `( ) ? #` raw (so does `encodeURIComponent` for the parens): a `)`
87 // ends the markdown link early (a data root under `Program Files (x86)`), and `?` or `#`
88 // would split the path from the query (tester sweep, 2026-10-09). They are plain path
89 // characters here, so they are percent-encoded by hand.
90 return `file://${encodeURI(abs).replace(/[()?#]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`
91}
92
93/** The anchor URL of a record. */
94export function anchorHref(record: Pick<AnchorRecord, 'sessionId' | 'uuid' | 'line' | 'bytes' | 'words'>, dataRoot: string): string {
95 const params = [`u=${record.uuid}`]
96 if (record.line !== undefined) params.push(`l=${record.line}`)
97 if (record.bytes) params.push(`b=${record.bytes[0]}-${record.bytes[1]}`)
98 if (record.words) params.push(`q=${encodeURIComponent(record.words)}`)
99 params.push(`v=${ANCHOR_VERSION}`)
100 return `${fileUrl(anchorPath(dataRoot, record.sessionId, record.uuid))}?${params.join('&')}`
101}
102
103/** The markdown link a reply writes for a record. */
104export function anchorMarkdown(record: AnchorRecord, dataRoot: string, label = record.label): string {
105 return `[${label.replace(/[[\]]/g, ' ')}](${anchorHref(record, dataRoot)})`
106}
107
108const PATH_RE = /\/bookmarks\/sessions\/([0-9a-f-]{36})\/([0-9a-f]{8})\.md$/i
109
110/**
111 * Reads an anchor URL. Undefined for anything that is not one: not `file:`, no `u`, a `u`
112 * that is not a uuid or a uuid prefix, or a `u` that disagrees with the filename.
113 */
114export function parseAnchor(href: string): ParsedAnchor | undefined {
115 if (!/^file:/i.test(href)) return undefined
116 const q = href.indexOf('?')
117 const hash = href.indexOf('#')
118 const cut = q >= 0 ? q : hash
119 if (cut < 0) return undefined
120 const pathPart = safeDecode(href.slice(0, cut))
121 // Parameters after `?`, and after a `#` too (the morning's legacy form, or both).
122 const tail = href.slice(cut + 1).replace(/#/g, '&')
123 let uuid: string | undefined
124 let line: number | undefined
125 let bytes: [number, number] | undefined
126 let words: string | undefined
127 let version: number | undefined
128 for (const part of tail.split('&')) {
129 if (!part) continue
130 const eq = part.indexOf('=')
131 const k = eq < 0 ? part : part.slice(0, eq)
132 const v = eq < 0 ? '' : part.slice(eq + 1)
133 switch (k) {
134 case 'u':
135 if (UUID_PREFIX.test(v)) uuid = v.toLowerCase()
136 break
137 case 'l':
138 if (/^\d+$/.test(v)) line = Number(v)
139 break
140 case 'b': {
141 const m = /^(\d+)-(\d+)$/.exec(v)
142 if (m) bytes = [Number(m[1]), Number(m[2])]
143 break
144 }
145 case 'q': {
146 const w = safeDecode(v.replace(/\+/g, ' ')).trim()
147 if (w) words = w
148 break
149 }
150 case 'v':
151 if (/^\d+$/.test(v)) version = Number(v)
152 break
153 }
154 }
155 if (!uuid) return undefined
156 const m = PATH_RE.exec(pathPart)
157 const sessionId = m?.[1]?.toLowerCase()
158 const fileUuid8 = m?.[2]?.toLowerCase()
159 if (fileUuid8 && fileUuid8 !== uuid8(uuid)) return undefined
160 return { href, sessionId, uuid, uuid8: uuid8(uuid), line, bytes, words, version, legacyFragment: q < 0 }
161}
162
163function safeDecode(s: string): string {
164 try {
165 return decodeURIComponent(s)
166 } catch {
167 return s
168 }
169}
170
171export type LinkKind = 'anchor' | 'file' | 'web' | 'other'
172
173export type MarkdownLink = {
174 kind: LinkKind
175 label: string
176 href: string
177 /** Index of `[` and of the character after `)` in the text. */
178 start: number
179 end: number
180 anchor?: ParsedAnchor
181}
182
183const LINK_RE = /\[([^\]\n]*)\]\(([^)\s]+)\)/g
184
185/**
186 * The markdown links in a text, by kind, skipping fenced and inline code. A link whose
187 * href is an anchor URL carries its parse.
188 */
189export function markdownLinks(text: string): MarkdownLink[] {
190 const out: MarkdownLink[] = []
191 let offset = 0
192 let inFence = false
193 for (const lineText of text.split('\n')) {
194 const lineStart = offset
195 offset += lineText.length + 1
196 if (/^\s*(```|~~~)/.test(lineText)) {
197 inFence = !inFence
198 continue
199 }
200 if (inFence) continue
201 for (const m of lineText.matchAll(LINK_RE)) {
202 if (insideInlineCode(lineText, m.index!)) continue
203 const label = m[1]!
204 const href = m[2]!
205 const anchor = parseAnchor(href)
206 const kind: LinkKind = anchor ? 'anchor' : /^file:/i.test(href) ? 'file' : /^https?:/i.test(href) ? 'web' : 'other'
207 out.push({ kind, label, href, start: lineStart + m.index!, end: lineStart + m.index! + m[0].length, ...(anchor ? { anchor } : {}) })
208 }
209 }
210 return out
211}
212
213function insideInlineCode(lineText: string, at: number): boolean {
214 let ticks = 0
215 for (let i = 0; i < at; i++) if (lineText[i] === '`') ticks++
216 return ticks % 2 === 1
217}
218
219export const anchorLinks = (text: string): MarkdownLink[] => markdownLinks(text).filter(l => l.kind === 'anchor')
220
221export type MarkerStyle = 'glyph' | 'text' | 'off'
222
223export const MARKERS: Record<Exclude<MarkerStyle, 'off'>, Partial<Record<LinkKind, string>>> = {
224 glyph: { anchor: '⚓ ', file: '▤ ' },
225 text: { anchor: '[bm] ', file: '[file] ' },
226}
227
228/** The label with its kind marker in front, unless the style is off or the kind unmarked. */
229export function markLabel(label: string, kind: LinkKind, style: MarkerStyle): string {
230 if (style === 'off') return label
231 const marker = MARKERS[style][kind]
232 if (!marker || label.startsWith(marker)) return label
233 return marker + label
234}
235
236/**
237 * Rewrites a text's links so each label carries its kind marker. Only the drawn text
238 * changes; hrefs are untouched (so `pressableLinks` still matches them).
239 */
240export function markLinks(text: string, style: MarkerStyle): string {
241 if (style === 'off') return text
242 let out = ''
243 let last = 0
244 for (const l of markdownLinks(text)) {
245 const marked = markLabel(l.label, l.kind, style)
246 if (marked === l.label) continue
247 out += text.slice(last, l.start) + `[${marked}](${l.href})`
248 last = l.end
249 }
250 return out + text.slice(last)
251}
252
253export type FilePosition = { path: string; line?: number; endLine?: number; column?: number }
254
255/**
256 * `path:801`, `path:801-822`, `path:801:5` (vim and VS Code spellings). A Windows drive
257 * letter (`C:\x`) is not a line.
258 */
259export function parsePosition(ref: string): FilePosition | undefined {
260 const s = ref.trim()
261 if (!s) return undefined
262 const m = /^(.+?):(\d+)(?:-(\d+)|:(\d+))?$/.exec(s)
263 if (!m) return { path: s }
264 const path = m[1]!
265 if (/^[A-Za-z]$/.test(path)) return { path: s } // `C:801` is not a position
266 const pos: FilePosition = { path, line: Number(m[2]) }
267 if (m[3]) pos.endLine = Number(m[3])
268 if (m[4]) pos.column = Number(m[4])
269 return pos
270}
271
272/** UTF-8 byte length without Node's Buffer (the mod runtime has neither Node nor DOM). */
273export function utf8ByteLength(s: string): number {
274 let n = 0
275 for (let i = 0; i < s.length; i++) {
276 const c = s.charCodeAt(i)
277 if (c < 0x80) n += 1
278 else if (c < 0x800) n += 2
279 else if (c >= 0xd800 && c <= 0xdbff) {
280 n += 4
281 i++ // the low surrogate
282 } else n += 3
283 }
284 return n
285}
286hooks/core/register-file.ts 366 lines1// The bookmark register: one readable JSON file per session under the data root, plus one
2// markdown export per bookmark. Pure: the mod does the reads and writes.
3//
4// <dataRoot>/bookmarks/sessions/<sessionId>.json the register (this file's shape)
5// <dataRoot>/bookmarks/sessions/<sessionId>/<uuid8>.md one export per bookmark
6//
7// Rules (issue #6 and the anchors DWP, 2026-10-09): the file is the source of truth and
8// is hand-editable, so keys are written in a fixed order; records are append-only (a
9// re-mark adds or relabels, never deletes); only `temporary` records are pruned, and a
10// pruned one leaves a tombstone so "never marked" and "marked, then pruned" stay distinct.
11
12import { type AnchorOwner, type AnchorRecord, ownerOf, uuid8 } from './anchor.ts'
13
14export const REGISTER_VERSION = 1
15
16/** What is left when a record goes: pruned (temporary, aged out) or removed (on request). */
17export type Tombstone = { uuid: string; owner?: AnchorOwner; label?: string; prunedAt?: number; removedAt?: number; removedBy?: string }
18
19export type Register = {
20 version: number
21 sessionId: string
22 /** Where Claude Code kept its config when the file was written (never used to find anything). */
23 configDir?: string
24 cwd?: string
25 /** Bumped on every write; the higher wins when two copies meet (#6). */
26 rev: number
27 updatedAt: number
28 writer: string
29 perma: AnchorRecord[]
30 tombstones: Tombstone[]
31}
32
33export function emptyRegister(sessionId: string, writer: string, now: number, env?: { configDir?: string; cwd?: string }): Register {
34 return { version: REGISTER_VERSION, sessionId, ...(env ?? {}), rev: 0, updatedAt: now, writer, perma: [], tombstones: [] }
35}
36
37/** What a mint supplies; the register fills the rest. */
38export type Mint = {
39 uuid: string
40 /** Whose list: the person's or Claude's. */
41 owner: AnchorOwner
42 sharedFrom?: AnchorOwner
43 label: string
44 why?: string
45 words?: string
46 temporary?: boolean
47 line?: number
48 bytes?: [number, number]
49 head: string
50 by: AnchorRecord['by']
51 source: AnchorRecord['source']
52 transcript?: string
53 role?: 'user' | 'assistant'
54 timestamp?: string
55}
56
57export type AddResult = { register: Register; record: AnchorRecord; added: boolean }
58
59/**
60 * Adds a bookmark for a message to one owner's list, or relabels the one that exists
61 * there. Never removes. A record is found by uuid (full match, or either side an 8+
62 * prefix of the other) within the owner's list: the two lists may hold the same message.
63 */
64export function addOrRelabel(reg: Register, mint: Mint, now: number): AddResult {
65 const existing = reg.perma.find(r => ownerOf(r) === mint.owner && sameUuid(r.uuid, mint.uuid))
66 if (existing) {
67 const record: AnchorRecord = {
68 ...existing,
69 // The full uuid wins over a prefix; a newer position fills a gap but never
70 // overwrites one (the transcript is append-only, so the first was right).
71 uuid: existing.uuid.length >= mint.uuid.length ? existing.uuid : mint.uuid,
72 line: existing.line ?? mint.line,
73 bytes: existing.bytes ?? mint.bytes,
74 words: mint.words ?? existing.words,
75 head: existing.head || mint.head,
76 label: mint.label || existing.label,
77 why: mint.why ?? existing.why,
78 // A permanent mark stays permanent; a temporary one can be made permanent.
79 temporary: existing.temporary && mint.temporary ? true : undefined,
80 updatedAt: now,
81 transcript: mint.transcript ?? existing.transcript,
82 }
83 if (record.temporary === undefined) delete record.temporary
84 return { register: bump({ ...reg, perma: reg.perma.map(r => (r === existing ? record : r)) }, now), record, added: false }
85 }
86 const record: AnchorRecord = {
87 sessionId: reg.sessionId,
88 uuid: mint.uuid,
89 owner: mint.owner,
90 ...(mint.sharedFrom ? { sharedFrom: mint.sharedFrom } : {}),
91 ...(mint.line !== undefined ? { line: mint.line } : {}),
92 ...(mint.bytes ? { bytes: mint.bytes } : {}),
93 ...(mint.words ? { words: mint.words } : {}),
94 head: mint.head,
95 label: mint.label,
96 ...(mint.why ? { why: mint.why } : {}),
97 ...(mint.temporary ? { temporary: true } : {}),
98 createdAt: now,
99 by: mint.by,
100 source: mint.source,
101 ...(mint.transcript ? { transcript: mint.transcript } : {}),
102 ...(mint.role ? { role: mint.role } : {}),
103 ...(mint.timestamp ? { timestamp: mint.timestamp } : {}),
104 }
105 return { register: bump({ ...reg, perma: [...reg.perma, record] }, now), record, added: true }
106}
107
108export function sameUuid(a: string, b: string): boolean {
109 const x = a.toLowerCase()
110 const y = b.toLowerCase()
111 if (x === y) return true
112 const [short, long] = x.length <= y.length ? [x, y] : [y, x]
113 return short.length >= 8 && long.startsWith(short)
114}
115
116/** The record for a message on one list, or on any list when `owner` is left out. */
117export function findRecord(reg: Register, uuid: string, owner?: AnchorOwner): AnchorRecord | undefined {
118 return reg.perma.find(r => (owner === undefined || ownerOf(r) === owner) && sameUuid(r.uuid, uuid))
119}
120
121/** One owner's list, in minted order. */
122export function listOf(reg: Register, owner: AnchorOwner): AnchorRecord[] {
123 return reg.perma.filter(r => ownerOf(r) === owner).sort((a, b) => a.createdAt - b.createdAt)
124}
125
126/**
127 * Copies one of Claude's bookmarks onto the person's list (or the other way): same
128 * message, label, why and words; `sharedFrom` names where it came from. The original
129 * stays. A copy that already exists is relabelled from the source.
130 */
131export function share(reg: Register, uuid: string, from: AnchorOwner, to: AnchorOwner, now: number): AddResult | { error: string } {
132 const src = findRecord(reg, uuid, from)
133 if (!src) return { error: `no bookmark ${uuid8(uuid)} on ${from === 'claude' ? "Claude's" : "the person's"} list` }
134 return addOrRelabel(
135 reg,
136 {
137 uuid: src.uuid,
138 owner: to,
139 sharedFrom: from,
140 label: src.label,
141 ...(src.why ? { why: src.why } : {}),
142 ...(src.words ? { words: src.words } : {}),
143 ...(src.line !== undefined ? { line: src.line } : {}),
144 ...(src.bytes ? { bytes: src.bytes } : {}),
145 head: src.head,
146 by: src.by,
147 source: src.source,
148 ...(src.transcript ? { transcript: src.transcript } : {}),
149 ...(src.role ? { role: src.role } : {}),
150 ...(src.timestamp ? { timestamp: src.timestamp } : {}),
151 },
152 now,
153 )
154}
155
156/**
157 * Removes a bookmark from one list, on request: the record leaves `perma` and a tombstone
158 * says who removed it and when. Nothing is deleted from the file's history of events.
159 */
160export function remove(reg: Register, uuid: string, owner: AnchorOwner, removedBy: string, now: number): { register: Register; removed: AnchorRecord } | { error: string } {
161 const rec = findRecord(reg, uuid, owner)
162 if (!rec) return { error: `no bookmark ${uuid8(uuid)} on ${owner === 'claude' ? "Claude's" : "the person's"} list` }
163 const tomb: Tombstone = { uuid: rec.uuid, owner, label: rec.label, removedAt: now, removedBy }
164 return { register: bump({ ...reg, perma: reg.perma.filter(r => r !== rec), tombstones: [...reg.tombstones, tomb] }, now), removed: rec }
165}
166
167export type Retention = '7d' | '30d' | '3mo' | '1y' | 'never'
168
169const DAY = 86_400_000
170const RETENTION_MS: Record<Exclude<Retention, 'never'>, number> = { '7d': 7 * DAY, '30d': 30 * DAY, '3mo': 91 * DAY, '1y': 365 * DAY }
171
172/** Drops temporary records older than the retention, each leaving a tombstone. */
173export function prune(reg: Register, retention: Retention, now: number): { register: Register; pruned: AnchorRecord[] } {
174 if (retention === 'never') return { register: reg, pruned: [] }
175 const cutoff = now - RETENTION_MS[retention]
176 const pruned = reg.perma.filter(r => r.temporary && (r.updatedAt ?? r.createdAt) < cutoff)
177 if (pruned.length === 0) return { register: reg, pruned }
178 const keep = reg.perma.filter(r => !pruned.includes(r))
179 const tombstones = [...reg.tombstones, ...pruned.map(r => ({ uuid: r.uuid, owner: ownerOf(r), label: r.label, prunedAt: now }))]
180 return { register: bump({ ...reg, perma: keep, tombstones }, now), pruned }
181}
182
183function bump(reg: Register, now: number): Register {
184 return { ...reg, rev: reg.rev + 1, updatedAt: now }
185}
186
187// Fixed key order, so a hand edit and a program write diff cleanly.
188const REGISTER_KEYS = ['version', 'sessionId', 'configDir', 'cwd', 'rev', 'updatedAt', 'writer', 'perma', 'tombstones'] as const
189const RECORD_KEYS = [
190 'sessionId', 'uuid', 'owner', 'label', 'why', 'words', 'head', 'line', 'bytes', 'role', 'timestamp',
191 'temporary', 'createdAt', 'updatedAt', 'by', 'source', 'sharedFrom', 'transcript',
192] as const
193
194export function serializeRegister(reg: Register): string {
195 const ordered: Record<string, unknown> = {}
196 for (const k of REGISTER_KEYS) {
197 if (k === 'perma') ordered.perma = reg.perma.map(orderRecord)
198 else if ((reg as Record<string, unknown>)[k] !== undefined) ordered[k] = (reg as Record<string, unknown>)[k]
199 }
200 return JSON.stringify(ordered, null, 2) + '\n'
201}
202
203function orderRecord(r: AnchorRecord): Record<string, unknown> {
204 const o: Record<string, unknown> = {}
205 for (const k of RECORD_KEYS) if ((r as Record<string, unknown>)[k] !== undefined) o[k] = (r as Record<string, unknown>)[k]
206 return o
207}
208
209/** Reads a register file's text; undefined when it is not one (the caller then tries `.bak`). */
210export function parseRegister(text: string): Register | undefined {
211 let o: any
212 try {
213 o = JSON.parse(text)
214 } catch {
215 return undefined
216 }
217 if (!o || typeof o !== 'object' || typeof o.sessionId !== 'string' || !Array.isArray(o.perma)) return undefined
218 return {
219 version: typeof o.version === 'number' ? o.version : REGISTER_VERSION,
220 sessionId: o.sessionId,
221 ...(typeof o.configDir === 'string' ? { configDir: o.configDir } : {}),
222 ...(typeof o.cwd === 'string' ? { cwd: o.cwd } : {}),
223 rev: typeof o.rev === 'number' ? o.rev : 0,
224 updatedAt: typeof o.updatedAt === 'number' ? o.updatedAt : 0,
225 writer: typeof o.writer === 'string' ? o.writer : '?',
226 perma: o.perma.filter((r: any) => r && typeof r.uuid === 'string' && typeof r.label === 'string'),
227 tombstones: Array.isArray(o.tombstones) ? o.tombstones : [],
228 }
229}
230
231/** Of two copies of one register, the one to keep: the higher `rev`, then the newer. */
232export function newer(a: Register, b: Register): Register {
233 if (a.rev !== b.rev) return a.rev > b.rev ? a : b
234 return a.updatedAt >= b.updatedAt ? a : b
235}
236
237// --- The export: one markdown file per bookmark ------------------------------------
238
239export function exportFileName(uuid: string): string {
240 return `${uuid8(uuid)}.md`
241}
242
243/** Everything after this line in an export is the reader's own and survives a rewrite. */
244export const EXPORT_NOTES_MARKER = '<!-- notes: everything below this line is kept when the bookmark is rewritten -->'
245
246/** The part of an existing export the person (or Claude) wrote: after the marker. */
247export function exportNotesOf(existing: string | undefined): string {
248 if (!existing) return ''
249 const i = existing.indexOf(EXPORT_NOTES_MARKER)
250 if (i < 0) return ''
251 // The notes go back under a fresh marker and a fresh `## Notes` heading, so a marker a
252 // note quotes and the heading itself are dropped; otherwise every rewrite would add
253 // one of each (tester sweep, 2026-10-09).
254 return existing
255 .slice(i + EXPORT_NOTES_MARKER.length)
256 .split(EXPORT_NOTES_MARKER)
257 .join('')
258 .replace(/^\s*## Notes[ \t]*\n/, '')
259 .replace(/^\n+/, '')
260 .trimEnd()
261}
262
263/**
264 * The export's text: front matter with every field, the message, a verify recipe, and a
265 * notes section that a rewrite keeps (`notes`, from `exportNotesOf` of the old file).
266 * One export per message; `owners` lists every list the message is on.
267 */
268export function exportMarkdown(
269 record: AnchorRecord,
270 messageText: string,
271 opts?: { minted?: string; owners?: AnchorOwner[]; notes?: string },
272): string {
273 const owners = opts?.owners && opts.owners.length > 0 ? opts.owners : [ownerOf(record)]
274 const fm: [string, unknown][] = [
275 ['kind', 'bookmark-anchor'],
276 ['version', REGISTER_VERSION],
277 ['session', record.sessionId],
278 ['uuid', record.uuid],
279 ['owners', owners.join(', ')],
280 ['role', record.role],
281 ['timestamp', record.timestamp],
282 ['transcript', record.transcript],
283 ['line', record.line],
284 ['bytes', record.bytes ? `${record.bytes[0]}-${record.bytes[1]}` : undefined],
285 ['words', record.words],
286 ['label', record.label],
287 ['why', record.why],
288 ['by', record.by],
289 ['source', record.source],
290 ['shared-from', record.sharedFrom],
291 ['temporary', record.temporary ? true : undefined],
292 ['created', new Date(record.createdAt).toISOString()],
293 ]
294 const lines = ['---']
295 for (const [k, v] of fm) if (v !== undefined && v !== '') lines.push(`${k}: ${yamlScalar(v)}`)
296 lines.push('---', '')
297 lines.push(`# ${record.label}`, '')
298 if (record.why) lines.push(record.why, '')
299 const who = record.role === 'assistant' ? 'Claude' : 'the person'
300 // No editor opens a markdown file at a position from the outside (Typora refuses a
301 // `#heading` on its command line, 2026-10-09), so the words are made findable instead:
302 // bold inside the quote, and for a long message a link to a heading just above them
303 // that Typora follows within the document.
304 const quoted = record.words && messageText.includes(record.words)
305 ? messageText.replace(record.words, () => `**${record.words}**`) // a function: `$&` in the words stays literal
306 : messageText
307 const long = messageText.length > 1500
308 if (long && record.words) lines.push(`[jump to the bookmarked words](#the-bookmarked-words)`, '')
309 lines.push(`## The message (${who}${record.timestamp ? `, ${record.timestamp}` : ''})`, '')
310 if (long && record.words) {
311 // Split the quote at the words' paragraph so a heading can sit right above it.
312 const at = quoted.indexOf(`**${record.words}**`)
313 const cut = at < 0 ? 0 : Math.max(0, quoted.lastIndexOf('\n', at) + 1)
314 for (const l of quoted.slice(0, cut).split('\n')) if (cut > 0) lines.push(l ? `> ${l}` : '>')
315 lines.push('', '### The bookmarked words', '')
316 for (const l of quoted.slice(cut).split('\n')) lines.push(l ? `> ${l}` : '>')
317 } else {
318 for (const l of quoted.split('\n')) lines.push(l ? `> ${l}` : '>')
319 }
320 lines.push('')
321 if (record.line !== undefined && record.bytes) {
322 lines.push('## Verify by hand', '', '```sh')
323 lines.push(`F=$(ls ~/.claude/projects/*/${record.sessionId}.jsonl)`)
324 lines.push(`sed -n '${record.line}p' "$F" | jq -r .uuid # ${record.uuid}`)
325 lines.push(`head -n ${record.line - 1} "$F" | wc -c # ${record.bytes[0]}`)
326 lines.push('```', '')
327 }
328 if (opts?.minted) lines.push(`*Minted ${opts.minted}.*`, '')
329 lines.push(EXPORT_NOTES_MARKER, '')
330 lines.push('## Notes', '')
331 // The marker is the writer's: one a note carries would make the next read-back
332 // start too early, so it never goes into the notes section.
333 const notes = opts?.notes?.split(EXPORT_NOTES_MARKER).join('').trim()
334 lines.push(notes ? notes : '_Add notes, links or context here; they stay when the bookmark is relabelled._', '')
335 return lines.join('\n')
336}
337
338function yamlScalar(v: unknown): string {
339 if (typeof v === 'number' || typeof v === 'boolean') return String(v)
340 const s = String(v)
341 // Quote anything YAML would misread: colons followed by space, leading symbols, quotes.
342 return /[:#'"\n]|^[\s\-?&*!|>%@`[\]{}]|^$/.test(s) ? JSON.stringify(s) : s
343}
344
345/** The front matter of an export, as flat strings (enough to recover `words` and the position). */
346export function parseFrontMatter(text: string): Record<string, string> {
347 const m = /^---\n([\s\S]*?)\n---/.exec(text)
348 const out: Record<string, string> = {}
349 if (!m) return out
350 for (const line of m[1]!.split('\n')) {
351 const i = line.indexOf(': ')
352 if (i < 0) continue
353 const k = line.slice(0, i).trim()
354 let v = line.slice(i + 2).trim()
355 if (v.startsWith('"')) {
356 try {
357 v = JSON.parse(v)
358 } catch {
359 // keep as written
360 }
361 }
362 out[k] = v
363 }
364 return out
365}
366hooks/core/transcript-lines.ts 131 lines1// Transcript lines: turning `grep -bn` output over a session's JSONL into message rows
2// with their line number and byte range, and picking the row a fragment or uuid names.
3//
4// Pure: the shell pass that produces the grep output lives in the mod (it needs
5// `$.process.run`); everything here runs under `node --test`.
6//
7// Each transcript line is one JSON object. Only `user` and `assistant` rows that carry
8// message text count as places; `last-prompt`, `system`, meta and sidechain rows, and
9// tool results, are bookkeeping. A fragment usually matches many lines (a later message
10// quoting it, the `last-prompt` rows); the earliest real row is where it was said.
11
12import { utf8ByteLength } from './anchor.ts'
13
14export type TranscriptRow = {
15 /** 1-based line in the file, as `grep -n` counts. */
16 line: number
17 /** Byte offset of the line's first byte, and of the byte after its last (the newline). */
18 byteStart: number
19 byteEnd: number
20 uuid: string
21 role: 'user' | 'assistant'
22 text: string
23 timestamp?: string
24}
25
26export type GrepLine = { line: number; byteStart: number; json: string }
27
28/** One `grep -bn` output line: `<line>:<byteoffset>:<text>`. */
29export function parseGrepLine(s: string): GrepLine | undefined {
30 // A trailing CR is the process runner's line ending on Windows, not the file's
31 // (the transcript has none): it must not count toward the line's bytes.
32 const m = /^(\d+):(\d+):(.*?)\r?$/s.exec(s)
33 if (!m) return undefined
34 return { line: Number(m[1]), byteStart: Number(m[2]), json: m[3]! }
35}
36
37/** The text a person or the model wrote in a row; undefined for everything else. */
38export function messageTextOf(o: any): { role: 'user' | 'assistant'; text: string } | undefined {
39 if (!o || o.isMeta || o.isSidechain) return undefined
40 // A message typed while Claude was working is not a `user` row: it is an `attachment`
41 // row of type `queued_command`, drawn as a user message, with the text in `prompt`
42 // (seen 2026-10-09; the `user` rows around it hold only that turn's tool results).
43 if (o.type === 'attachment') {
44 const a = o.attachment
45 if (a?.type === 'queued_command' && typeof a.prompt === 'string' && a.prompt.trim()) return { role: 'user', text: a.prompt }
46 return undefined
47 }
48 if (o.type !== 'user' && o.type !== 'assistant') return undefined
49 const content = o.message?.content
50 if (typeof content === 'string') return content ? { role: o.type, text: content } : undefined
51 if (!Array.isArray(content)) return undefined
52 // A message typed while Claude was working is stored beside that turn's tool result,
53 // in one user row: the text blocks are the message, the tool_result blocks are not.
54 const texts = content.filter((b: any) => b?.type === 'text').map((b: any) => String(b.text ?? ''))
55 const text = texts.join('\n').trim()
56 return text ? { role: o.type, text } : undefined
57}
58
59/** A grep line as a message row, or undefined when it is not a message. */
60export function rowFromGrepLine(g: GrepLine): TranscriptRow | undefined {
61 let o: any
62 try {
63 o = JSON.parse(g.json)
64 } catch {
65 return undefined // a line cut off at the output cap
66 }
67 const m = messageTextOf(o)
68 if (!m || typeof o.uuid !== 'string') return undefined
69 return {
70 line: g.line,
71 byteStart: g.byteStart,
72 byteEnd: g.byteStart + utf8ByteLength(g.json),
73 uuid: o.uuid,
74 role: m.role,
75 text: m.text,
76 ...(typeof o.timestamp === 'string' ? { timestamp: o.timestamp } : {}),
77 }
78}
79
80/** Every message row in a grep output, in file order. */
81export function rowsFromGrep(output: string): TranscriptRow[] {
82 const rows: TranscriptRow[] = []
83 for (const s of output.split('\n')) {
84 if (!s.trim()) continue
85 const g = parseGrepLine(s)
86 const r = g && rowFromGrepLine(g)
87 if (r) rows.push(r)
88 }
89 return rows.sort((a, b) => a.line - b.line)
90}
91
92export type Resolution =
93 | { kind: 'one'; row: TranscriptRow }
94 | { kind: 'many'; rows: TranscriptRow[]; earliest: TranscriptRow }
95 | { kind: 'none' }
96
97/**
98 * The row a request names, from the rows a grep returned.
99 * - by `uuid`: the row with that uuid (a prefix of 8+ characters is accepted);
100 * - by `fragment`: rows whose text contains it, earliest first. One match is the answer;
101 * several are returned together with the earliest, so the caller can ask rather than
102 * guess (#19: never jump somewhere wrong).
103 */
104export function resolveRows(rows: TranscriptRow[], want: { uuid?: string; fragment?: string }): Resolution {
105 let hits = rows
106 if (want.uuid) {
107 const u = want.uuid.toLowerCase()
108 hits = hits.filter(r => r.uuid.toLowerCase() === u || (u.length >= 8 && r.uuid.toLowerCase().startsWith(u)))
109 }
110 if (want.fragment) {
111 const f = want.fragment
112 hits = hits.filter(r => r.text.includes(f))
113 }
114 if (hits.length === 0) return { kind: 'none' }
115 if (hits.length === 1) return { kind: 'one', row: hits[0]! }
116 return { kind: 'many', rows: hits, earliest: hits[0]! }
117}
118
119/** The grep pattern for a fragment: as JSON escapes it inside the line. */
120export function jsonEscaped(fragment: string): string {
121 return JSON.stringify(fragment).slice(1, -1)
122}
123
124/** The grep pattern for a uuid. */
125export function uuidPattern(uuid: string): string {
126 return `"uuid":"${uuid}`
127}
128
129/** A short head of a message: one line, 60 characters. */
130export const headOf = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 60)
131types/index.d.ts 32 lines1// One transcript row the probe saw appended: its uuid, the door it came in by,
2// and the head of its text for display.
3export type Row = { uuid: string; door: 'prompt' | 'response'; head: string; text?: string }
4
5// What the pane is doing: listing prompts to jump to, listing bookmarks, or waiting for
6// the register letter after a chord.
7export type PaneMode = 'list' | 'mark' | 'jump' | 'bookmarks'
8
9declare module 'claude-code' {
10 interface PluginState {
11 'bookmarks': {
12 rows: Row[]
13 paneMode: PaneMode
14 // When a pane opened from the band can't take the keyboard, the band itself
15 // collects the next key; 'idle' shows the usual buttons.
16 bandMode: PaneMode | 'idle'
17 // Bumped when the pinned prompts change, so the prompts pane redraws.
18 pinsRev: number
19 // Bumped after the band's command line runs a command: the field is drawn
20 // under a new key, so it starts empty (POC 2026-10-07).
21 cmdRev: number
22 // The digits of a prompt number typed into the band, one Button press each.
23 bandNum: string
24 // Which group the bookmarks pane shows: the person's, Claude's, (later: a team
25 // member's). Cycled from the band; an index into BOOKMARK_GROUPS.
26 bandGroup: number
27 // What the band shows after a mark or jump: the letter and the marked text.
28 shown: { letter: string; text: string } | null
29 }
30 }
31}
32