SLOPSHOPPER

Bookmarks

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…

newpanebandspinnerrowsguard
v0.3.5GPL-3.0updated 2026-10-10DazzleML/claude-bookmarks/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bookmarks
│ ┃ bm-poc ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No bookmarks yet. Claude mints them with its │ bookmarks │ │ ┃ bookmark tool; P + a letter promotes one of ● bookmarks: [bm] tool│ /bm-promote <letter>: promote that mark │ │ ┃ your marks. ● bookmarks: [bm-poc] │ into a bookmark │ │ ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Read 6 lines ╭─────────────────────────────────╮ │ ⏺ Update(src/auth.ts) │ bookmarks │ │ ⎿ Added 2 lines, removed 1 lin│ bookmarks: keystroke logging on │ │ ⏺ Bash(bun test) ╰─────────────────────────────────╯ │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bm-ids │ ● bookmarks: [bm-poc] dcc-patcher: none seen; DCC_PATCH_H=(unset); DC │ ● bookmarks: [bm] log echo off, the default (every conversation witho │ │ ⟨Claude Code's own drawing⟩ bm:: ' j m p ␣ ⏎ run m: mark j: jump p: prompts r: read ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ bm:: ' j m p ␣ ⏎ run m: mark j: jump p: prompts r: read
Pane · bm-poc
No bookmarks yet. Claude mints them with its bookmark tool; P + a letter promotes one of your marks.
README

Bookmarks

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.

What it does

  • Marks. Select a line in any reply or prompt, press the leader and 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.
  • Jumps. The leader, ', and the letter scrolls back to that mark from anywhere and lights the line again; the jump pane lists the marks you have.
  • The reading position. 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.
  • Prompt history. 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.
  • Bookmarks. A permanent, addressable place: a 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.
  • Claude cites with bookmarks. Claude has a 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.
  • Back and forward. Every jump is remembered, like vim's jumplist.
  • Works while you type. The leader works with a draft in the input box and while Claude is working; the draft is untouched. Highlights change only what is drawn on your screen, never what Claude reads or what the session file holds.

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

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.

What it runs, reads, writes and sends

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.

Licence

GPL-3.0; see LICENSE. Source, issues and the roadmap: github.com/DazzleML/claude-bookmarks.

Source 6 files
hooks/register.tsx 2782 lines
1// 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 lines
1// 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}
26
hooks/core/anchor.ts 286 lines
1// 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}
286
hooks/core/register-file.ts 366 lines
1// 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}
366
hooks/core/transcript-lines.ts 131 lines
1// 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)
131
types/index.d.ts 32 lines
1// 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