Project notes in .claude/notes.md: live dev servers, recent artifacts and pinned links, in a side panel and with /notes.

A Claude Code mod that keeps one notes file per project, .claude/notes.md, and shows it in a side panel. Dev servers and published artifacts land there by themselves; anything else stays only if you pin it. The point is to stop asking the agent "give me the server link again" in long sessions.
Local: http://localhost:5173, listening on port 3000, Serving HTTP on … port 8000) are added by themselves, with the machine's LAN address so the link opens on a phone. Background commands are covered too: their output file is read a few times after the start.● answers on the LAN, 🏠 / ◐ on localhost only, 💤 / ○ not running. A server that stays down for 10 minutes leaves the list.pin button in the panel or by asking the agent ("запомни", "закрепи", "remember this"). ✕ removes an entry./notes opens the panel and prints a compact list into the conversation. That is how the notes reach the phone over Remote Control.notes tool: it pins and removes entries, and looks there first when you ask for a server link or an earlier artifact..claude/notes.md is added to the project's .gitignore.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.PATH (the port check runs scripts/probe.mjs)./tui fullscreen); /notes prints the list in any layout.~/.claude/settings.json and restart Claude Code: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add udaaff/claude-code-session-notes
claude plugin install session-notes@session-notes
Or, to work on the code, clone the repository and link it into ~/.claude/skills/. Claude Code loads plugins from that folder in every session, the desktop app's included. Use one way or the other, not both.
Windows:
New-Item -ItemType Junction -Path $env:USERPROFILE\.claude\skills\session-notes -Target <path-to-repo>
macOS and Linux:
ln -s <path-to-repo> ~/.claude/skills/session-notes
To try it in a single session without linking:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir <path-to-repo>
| What | How |
|---|---|
| Open the panel and print the list | /notes |
| Close the panel | /notes close, or the panel's own close mark |
| Keep something | pin on a Recent entry, or ask the agent to remember it |
| Drop an entry | ✕ in the panel, or ask the agent to remove it |
## Servers
- [npm run dev](http://192.168.1.5:5173/) <!-- port=5173 session=1a2b3c4d at=2026-10-01T15:28 -->
## Pinned
- [Design doc](https://claude.ai/code/artifact/…) — what the plugin is for
- Test user: test@local
## Recent
- [Test page](https://claude.ai/artifact/…) <!-- kind=artifact session=1a2b3c4d at=2026-10-01T15:29 -->
One entry per line: a link or plain text, an optional note after — , and the plugin's own fields in an HTML comment that a Markdown preview hides. Sections the plugin doesn't know are kept as they are.
npm test
claude plugin validate .
The pure logic (parsing, the server and artifact detection, the section rules) is in hooks/notes.ts and covered by tests/notes.test.ts; hooks/register.ts wires it to the engine: the hooks, the panel, /notes and the notes tool.
MIT
hooks/register.ts 512 lines1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2import {
3 RECENT_LIMIT,
4 SECTIONS,
5 backgroundOutputFile,
6 findArtifactUrl,
7 findEntry,
8 findServers,
9 htmlTitle,
10 parseNotes,
11 pin,
12 pushRecent,
13 readsOnly,
14 remove,
15 serializeNotes,
16 serverTitle,
17 upsertServer,
18} from './notes'
19import type { Entry, FoundServer, Notes, SectionId } from './notes'
20
21/**
22 * Session Notes: one notes file per project, `.claude/notes.md`.
23 *
24 * - Servers a command prints (`Local: http://localhost:5173`) land in Servers
25 * by themselves, with the LAN address so a phone can open them. A probe
26 * checks their ports; one dead for DEAD_DROP_MS goes away.
27 * - Published artifacts and docs land in Recent, the last RECENT_LIMIT of them.
28 * - Pinned holds only what the person chose: the pin button in the pane, or
29 * the agent's `notes` tool when asked to remember something.
30 *
31 * `/notes` opens the pane and prints the notes into the conversation, which
32 * is how they reach a phone.
33 */
34
35const PANE_ID = 'session-notes'
36const NOTES_FILE = '.claude/notes.md'
37
38/** How wide the docked pane opens, in columns; a width the person dragged wins. */
39const PANE_COLUMNS = 64
40
41/** How often the open pane re-checks the ports; without a pane, every PROBE_IDLE_TICKS-th time. */
42const PROBE_MS = 5_000
43const PROBE_IDLE_TICKS = 12
44
45/** A server whose port stayed closed this long leaves Servers. */
46const DEAD_DROP_MS = 10 * 60_000
47
48/** When a background command's output is read again for server addresses. */
49const BACKGROUND_CHECKS_MS = [2_000, 5_000, 10_000, 20_000, 40_000, 80_000]
50
51type Engine = EngineInterface
52type PortState = 'lan' | 'local' | 'dead'
53
54let lan: string | null = null
55const portState = new Map<string, PortState>()
56const deadSince = new Map<string, number>()
57let probeTick = 0
58let probing: Promise<void> | null = null
59
60/** Changes to the file go one after another, so two hooks don't overwrite each other. */
61let queue: Promise<unknown> = Promise.resolve()
62
63const notesPath = async ($: Engine): Promise<string> => `${await $.session.cwd()}/${NOTES_FILE}`
64
65const readNotes = async ($: Engine): Promise<Notes> =>
66 parseNotes(await $.fs.read(await notesPath($)).catch(() => ''))
67
68/** Keeps the notes out of git: they hold local addresses and maybe test logins. */
69const ensureIgnored = async ($: Engine): Promise<void> => {
70 const cwd = await $.session.cwd()
71 const isRepo = (await $.fs.stat(`${cwd}/.git`).catch(() => null)) !== null
72 const ignore = await $.fs.read(`${cwd}/.gitignore`).catch(() => null)
73 if (!isRepo && ignore === null) return
74 const lines = (ignore ?? '').split(/\r?\n/).map((line) => line.trim())
75 if (lines.some((line) => line === NOTES_FILE || line === `/${NOTES_FILE}` || line === '.claude/' || line === '.claude')) return
76 const head = ignore === null || ignore === '' || ignore.endsWith('\n') ? ignore ?? '' : `${ignore}\n`
77 await $.fs.write(`${cwd}/.gitignore`, `${head}${NOTES_FILE}\n`)
78}
79
80const changeNotes = ($: Engine, change: (notes: Notes) => Notes): Promise<Notes> => {
81 const run = queue.then(async () => {
82 const path = await notesPath($)
83 const before = await $.fs.read(path).catch(() => null)
84 const notes = change(parseNotes(before ?? ''))
85 const text = serializeNotes(notes)
86 if (text !== before) {
87 await $.fs.write(path, text)
88 if (before === null) await ensureIgnored($).catch(() => {})
89 $.ui.invalidate('ui.render')
90 }
91 return notes
92 })
93 queue = run.catch(() => {})
94 return run
95}
96
97const minuteStamp = (): string => new Date().toISOString().slice(0, 16)
98
99const shortSession = async ($: Engine): Promise<string> => (await $.session.id().catch(() => '')).slice(0, 8)
100
101/** Runs the probe for `ports`; refreshes the LAN address on the way. */
102const runProbe = async ($: Engine, ports: string[]): Promise<Record<string, PortState>> => {
103 const result = await $.process.run(['node', `${$.plugin.root}/scripts/probe.mjs`, ...ports], { timeoutMs: 8_000 })
104 if (result.exitCode !== 0) return {}
105 const parsed = JSON.parse(result.stdout) as { lan: string | null; ports: Record<string, PortState> }
106 lan = parsed.lan
107 return parsed.ports
108}
109
110const portsOf = (notes: Notes): string[] => [
111 ...new Set([...notes.servers, ...notes.pinned].map((entry) => entry.meta.port).filter((port) => port !== undefined)),
112]
113
114/** Checks every known port, drops servers dead for too long, redraws on a change. */
115const probe = ($: Engine): Promise<void> => {
116 // A check already running answers for this one too.
117 probing ??= checkPorts($).finally(() => {
118 probing = null
119 })
120 return probing
121}
122
123const checkPorts = async ($: Engine): Promise<void> => {
124 try {
125 const notes = await readNotes($)
126 const ports = portsOf(notes)
127 if (ports.length === 0) return
128 const states = await runProbe($, ports)
129 const nowMs = Date.now()
130 let isChanged = false
131 for (const port of ports) {
132 const state = states[port] ?? 'dead'
133 if (portState.get(port) !== state) isChanged = true
134 portState.set(port, state)
135 if (state !== 'dead') deadSince.delete(port)
136 else if (!deadSince.has(port)) deadSince.set(port, nowMs)
137 }
138 const expired = notes.servers.filter((server) => {
139 const since = deadSince.get(server.meta.port ?? '')
140 return since !== undefined && nowMs - since > DEAD_DROP_MS
141 })
142 if (expired.length > 0) {
143 await changeNotes($, (current) => ({
144 ...current,
145 servers: current.servers.filter((server) => !expired.some((gone) => gone.meta.port === server.meta.port)),
146 }))
147 }
148 if (isChanged) $.ui.invalidate('ui.render')
149 } catch {
150 // A failed probe leaves the old states; the next tick tries again.
151 }
152}
153
154/** The address to open a server at: the LAN one when it answers there, else localhost. */
155const serverUrl = (entry: Entry): string | undefined => {
156 if (entry.url === undefined || entry.meta.port === undefined) return entry.url
157 if (portState.get(entry.meta.port) !== 'local') return entry.url
158 return entry.url.replace(/^(https?:\/\/)[^/:]+/, '$1localhost')
159}
160
161const addServers = async ($: Engine, found: FoundServer[], command: string): Promise<void> => {
162 if (found.length === 0) return
163 if (lan === null) await runProbe($, []).catch(() => {})
164 const session = await shortSession($)
165 const title = serverTitle(command)
166 await changeNotes($, (notes) =>
167 found.reduce((current, server) => {
168 const existing = current.servers.find((one) => one.meta.port === server.port)
169 // A port already listed keeps its name: a restart or a log shouldn't rename it.
170 if (existing !== undefined) return current
171 return upsertServer(current, {
172 title: title === '' ? `Server :${server.port}` : title,
173 url: `http://${lan ?? 'localhost'}:${server.port}${server.path}`,
174 meta: { port: server.port, session, at: minuteStamp() },
175 })
176 }, notes),
177 )
178 void probe($)
179}
180
181const addRecent = async ($: Engine, title: string, url: string, kind: string): Promise<void> => {
182 const session = await shortSession($)
183 await changeNotes($, (notes) => pushRecent(notes, { title, url, meta: { kind, session, at: minuteStamp() } }))
184}
185
186/** A background command's output arrives later: read its file a few times. */
187const watchBackground = ($: Engine, file: string, command: string): void => {
188 for (const delay of BACKGROUND_CHECKS_MS) {
189 $.clock.after(delay, () => {
190 void $.fs
191 .read(file)
192 .then((output) => addServers($, findServers(output), command))
193 .catch(() => {})
194 })
195 }
196}
197
198const basename = (path: string): string => path.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') ?? path
199
200/** What a finished tool call left behind: servers, artifacts, docs. */
201const capture = async ($: Engine, tool: string, input: Record<string, unknown>, text: string): Promise<void> => {
202 if (tool === 'Bash' || tool === 'PowerShell') {
203 const command = typeof input.command === 'string' ? input.command : ''
204 // Reading a log or a file prints the addresses in it, but starts nothing.
205 if (readsOnly(command)) return
206 await addServers($, findServers(text), command)
207 const file = backgroundOutputFile(text)
208 if (file !== null) watchBackground($, file, command)
209 return
210 }
211
212 if (tool === 'Artifact') {
213 const action = input.action ?? 'publish'
214 if (action !== 'publish' || input.asset === true) return
215 const url = findArtifactUrl(text)
216 if (url === null) return
217 const path = typeof input.file_path === 'string' ? input.file_path : null
218 const fromFile = path === null ? null : htmlTitle(await $.fs.read(path).catch(() => ''))
219 const title = (typeof input.title === 'string' ? input.title : null) ?? fromFile ?? (path === null ? url : basename(path))
220 await addRecent($, title, url, 'artifact')
221 return
222 }
223
224 // A doc created through the Claude Docs connector.
225 if (/Claude_Docs__batch$/.test(tool)) {
226 const create = (input.container as { create?: { name?: unknown } } | undefined)?.create
227 if (typeof create?.name !== 'string') return
228 const url = findArtifactUrl(text)
229 if (url !== null) await addRecent($, create.name, url, 'doc')
230 }
231}
232
233const STATE_GLYPH: Record<PortState, { glyph: string; color: string }> = {
234 lan: { glyph: '●', color: 'green' },
235 local: { glyph: '◐', color: 'yellow' },
236 dead: { glyph: '○', color: 'gray' },
237}
238
239const glyphOf = (entry: Entry): { glyph: string; color: string } => {
240 const port = entry.meta.port
241 const state = port === undefined ? undefined : portState.get(port)
242 // Not checked yet: no claim either way.
243 return state === undefined ? { glyph: '·', color: 'gray' } : STATE_GLYPH[state]
244}
245
246const hostOf = (url: string | undefined): string => {
247 if (url === undefined) return ''
248 const match = /^https?:\/\/([^/]+)/.exec(url)
249 return match?.[1] ?? ''
250}
251
252/** Colored marks for the conversation: a plain dot next to a list bullet reads as a second bullet. */
253const STATE_EMOJI: Record<PortState, { mark: string; words: string }> = {
254 lan: { mark: '🌐', words: '' },
255 local: { mark: '🏠', words: ' — localhost only' },
256 dead: { mark: '💤', words: ' — not running' },
257}
258
259/** The notes with their sections, for the agent: what the `notes` tool's "list" returns. */
260const notesMarkdown = (notes: Notes, project: string): string => {
261 // The engine puts the plugin's name in front of the first line: give it a line of its own.
262 const parts: string[] = [`Notes · ${project}`, '']
263 for (const { id, heading } of SECTIONS) {
264 if (notes[id].length === 0) continue
265 parts.push(`**${heading}**`, '')
266 for (const entry of notes[id]) {
267 const isServer = entry.meta.port !== undefined
268 const url = isServer ? serverUrl(entry) : entry.url
269 const known = isServer ? portState.get(entry.meta.port ?? '') : undefined
270 const state = known === undefined ? null : STATE_EMOJI[known]
271 const head = url === undefined ? entry.title : `[${entry.title}](${url})`
272 const address = isServer && url !== undefined ? ` · \`${hostOf(url)}\`` : ''
273 const note = entry.note === undefined ? '' : ` — ${entry.note}`
274 parts.push(`- ${state === null ? '' : `${state.mark} `}${head}${address}${note}${state?.words ?? ''}`)
275 }
276 parts.push('')
277 }
278 return parts.length === 2 ? `Notes · ${project}\n\nNo notes yet.` : parts.join('\n').trim()
279}
280
281/**
282 * What `/notes` prints: one flat list, for a phone with no buttons to press.
283 * Servers with their state first, then the pinned entries, then the recent
284 * ones.
285 */
286const notesCompact = (notes: Notes): string => {
287 const link = (entry: Entry, url = entry.url): string => (url === undefined ? entry.title : `[${entry.title}](${url})`)
288 const lines: string[] = []
289 for (const entry of notes.servers) {
290 const known = portState.get(entry.meta.port ?? '')
291 lines.push(`- ${known === undefined ? '' : `${STATE_EMOJI[known].mark} `}${link(entry, serverUrl(entry))}`)
292 }
293 for (const entry of [...notes.pinned, ...notes.recent]) lines.push(`- ${link(entry)}`)
294 // The engine puts the plugin's name in front of the text: the leading line
295 // break gives it a line of its own instead of the first entry's.
296 return `\n${lines.length === 0 ? 'No notes yet.' : lines.join('\n')}`
297}
298
299/** Opens the pane; null once it's drawn, else why it isn't. */
300const openPane = async ($: Engine): Promise<string | null> => {
301 const opened = await $.ui
302 .open({ id: PANE_ID, title: 'Notes', columns: PANE_COLUMNS })
303 .catch((error: unknown) => ({ isPlaced: false as const, reason: String(error) }))
304 return opened.isPlaced ? null : opened.reason
305}
306
307const projectName = async ($: Engine): Promise<string> =>
308 (await $.session.cwd().catch(() => '')).split(/[\\/]/).filter(Boolean).pop() ?? ''
309
310const TOOL_DESCRIPTION = `The project's notes file (${NOTES_FILE}), shown to the user in a pane and with /notes.
311
312Sections: Servers (filled automatically from command output), Recent (the last ${RECENT_LIMIT} artifacts, automatic), Pinned (only what the user asked to keep).
313
314Use it when the user asks to remember, note down, pin or keep something ("запомни", "сделай заметку", "закрепи", "добавь в заметки"): action "pin" with a title and, when there is one, the url and a short note. "pin" also moves an existing Servers/Recent entry to Pinned when the title or url matches. "remove" deletes an entry by title or url. "list" returns the notes. "show" opens the notes pane in the user's terminal (when they ask to show or open the notes).
315
316When the user asks for a server link or an artifact made earlier, call "list" first instead of searching the conversation. Prefer LAN addresses over localhost so links open on the user's phone, and start dev servers on 0.0.0.0 (e.g. --host). Keep only working, short-lived things here; lasting project knowledge belongs in the repository's docs.`
317
318export const register: Register = (on) => {
319 on('session.start', async ($, e, next) => {
320 await $.command
321 .register({ name: 'notes', description: 'Project notes: servers, pinned links, recent artifacts', argumentHint: '[close]' })
322 .catch((error: unknown) => $.ui.log(`session-notes: /notes not registered: ${String(error)}`))
323
324 await $.tool
325 .register({
326 name: 'notes',
327 description: TOOL_DESCRIPTION,
328 inputSchema: {
329 type: 'object',
330 properties: {
331 action: { type: 'string', enum: ['pin', 'remove', 'list', 'show'] },
332 title: { type: 'string', description: 'What the entry is called, or the entry to find' },
333 url: { type: 'string', description: 'The link, when there is one' },
334 note: { type: 'string', description: 'A few words on what it is' },
335 },
336 required: ['action'],
337 },
338 })
339 .catch((error: unknown) => $.ui.log(`session-notes: notes tool not registered: ${String(error)}`))
340
341 $.clock.after(0, () => {
342 void runProbe($, []).catch(() => {})
343 void probe($)
344 })
345
346 // Ports are checked often while the pane is open, rarely otherwise; the
347 // pane also picks up changes other sessions made to the file.
348 $.clock.every(PROBE_MS, () => {
349 void $.ui
350 .panes()
351 .then((panes) => {
352 const isOpen = panes.some((pane) => pane.id === PANE_ID)
353 probeTick += 1
354 if (isOpen) $.ui.invalidate('ui.render')
355 if (isOpen || probeTick % PROBE_IDLE_TICKS === 0) void probe($)
356 })
357 .catch(() => {})
358 })
359
360 return next(e)
361 })
362
363 on('command.run', { command: 'notes' }, async ($, e) => {
364 if (e.args.trim() === 'close') {
365 await $.ui.close({ id: PANE_ID })
366 return { text: 'Notes pane closed.' }
367 }
368 await probe($)
369 // The list always goes into the conversation; a pane that can't be placed
370 // here (a surface that draws no panes) says why in a toast.
371 const opened = await openPane($)
372 if (opened !== null) $.ui.toast(`Notes pane: ${opened}`)
373 return { text: notesCompact(await readNotes($)) }
374 })
375
376 on('tool.call', async ($, e, next) => {
377 const input = e as unknown as Record<string, unknown>
378
379 if (/^mcp__session-notes[^_]*__notes$/.test(e.tool)) {
380 const action = input.action
381 const title = typeof input.title === 'string' ? input.title.trim() : ''
382 const url = typeof input.url === 'string' && input.url.trim() !== '' ? input.url.trim() : undefined
383 const note = typeof input.note === 'string' && input.note.trim() !== '' ? input.note.trim() : undefined
384
385 if (action === 'list') {
386 await probe($)
387 return { result: notesMarkdown(await readNotes($), await projectName($)) }
388 }
389 if (action === 'show') {
390 await probe($)
391 const opened = await openPane($)
392 return { result: opened === null ? 'Notes pane opened.' : `Notes pane not shown: ${opened}` }
393 }
394 if (action === 'remove') {
395 const notes = await readNotes($)
396 const found = findEntry(notes, url ?? title)
397 if (found === null) return { result: `Nothing in the notes matches "${url ?? title}".` }
398 await changeNotes($, (current) => remove(current, found.entry))
399 return { result: `Removed "${found.entry.title}".` }
400 }
401 if (action === 'pin') {
402 if (title === '' && url === undefined) return { result: 'Give a title or a url to pin.' }
403 const notes = await readNotes($)
404 const found = url !== undefined || title !== '' ? findEntry(notes, url ?? title) : null
405 // An entry already listed keeps its link and meta; a title or note given now wins.
406 const entry: Entry =
407 found !== null
408 ? {
409 ...found.entry,
410 ...(title === '' ? {} : { title }),
411 ...(note === undefined ? {} : { note }),
412 meta: { ...found.entry.meta, at: minuteStamp() },
413 }
414 : { title: title === '' ? (url ?? '') : title, ...(url === undefined ? {} : { url }), ...(note === undefined ? {} : { note }), meta: { at: minuteStamp() } }
415 await changeNotes($, (current) => pin(found === null ? current : remove(current, found.entry, 'pinned'), entry))
416 return { result: `Pinned "${entry.title}".` }
417 }
418 return { result: 'Unknown action: use pin, remove, list or show.' }
419 }
420
421 const ran = await next(e)
422 if (ran.deny === undefined && ran.isError !== true) {
423 await capture($, e.tool, input, ran.text ?? '').catch((error: unknown) => {
424 $.ui.log(`session-notes: ${String(error)}`)
425 })
426 }
427 return ran
428 })
429
430 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
431 if (e.requestId !== PANE_ID) return next(e)
432 const { Box, Text, Button, Markdown } = await $.ui.resolve(e)
433 const notes = await readNotes($)
434 const cwd = await $.session.cwd().catch(() => '')
435 const project = cwd.split(/[\\/]/).filter(Boolean).pop() ?? ''
436 const columns = Math.max(20, e.props.bodyColumns - 2)
437
438 const button = (key: string, label: string, onPress: () => void): RenderElement =>
439 Button({ key, label, plain: true, dimColor: true, onPress })
440
441 const row = (entry: Entry, section: SectionId, index: number): RenderElement => {
442 const key = `${section}-${index}`
443 const isServer = entry.meta.port !== undefined
444 const url = isServer ? serverUrl(entry) : entry.url
445 const mark = glyphOf(entry)
446 const isDead = isServer && portState.get(entry.meta.port ?? '') === 'dead'
447 const detail = [isServer ? hostOf(url) : '', entry.note ?? ''].filter(Boolean).join(' · ')
448 const actions: RenderElement[] = []
449 // Servers come and go by themselves; only Recent has something to keep.
450 if (section === 'recent') {
451 actions.push(button(`pin-${key}`, 'pin', () => void changeNotes($, (current) => pin(current, entry))))
452 }
453 actions.push(button(`del-${key}`, '✕', () => void changeNotes($, (current) => remove(current, entry, section))))
454
455 return Box({
456 key,
457 flexDirection: 'row',
458 columnGap: 1,
459 children: [
460 Text({ color: mark.color, children: isServer ? mark.glyph : ' ' }),
461 Box({
462 flexDirection: 'column',
463 flexGrow: 1,
464 flexShrink: 1,
465 children: [
466 url === undefined
467 ? Text({ dimColor: isDead, wrap: 'truncate-end', children: entry.title })
468 : Markdown({ text: `[${entry.title.replace(/[[\]]/g, '')}](${url})`, dimColor: isDead }),
469 ...(detail === '' ? [] : [Text({ dimColor: true, wrap: 'truncate-end', children: detail })]),
470 ],
471 }),
472 ...actions,
473 ],
474 })
475 }
476
477 const sections: RenderElement[] = []
478 for (const { id, heading } of SECTIONS) {
479 if (notes[id].length === 0) continue
480 sections.push(
481 Box({
482 key: `section-${id}`,
483 flexDirection: 'column',
484 marginBottom: 1,
485 children: [Text({ bold: true, dimColor: true, children: heading.toUpperCase() }), ...notes[id].map((entry, index) => row(entry, id, index))],
486 }),
487 )
488 }
489
490 return Box({
491 flexDirection: 'column',
492 paddingX: 1,
493 paddingY: 1,
494 children: [
495 Box({
496 flexDirection: 'row',
497 columnGap: 1,
498 children: [
499 Text({ bold: true, children: 'Notes' }),
500 Box({ flexGrow: 1, children: [Text({ dimColor: true, wrap: 'truncate-end', children: project })] }),
501 Text({ dimColor: true, children: lan ?? '' }),
502 ],
503 }),
504 Text({ dimColor: true, children: '─'.repeat(columns) }),
505 ...(sections.length === 0
506 ? [Text({ dimColor: true, children: 'No notes yet. Servers and artifacts show up here by themselves; ask the agent to pin anything else.' })]
507 : sections),
508 ],
509 })
510 })
511}
512hooks/notes.ts 236 lines1/**
2 * The notes file, `.claude/notes.md`: plain Markdown a person can read and
3 * edit, with three sections the plugin knows.
4 *
5 * ## Servers filled by the plugin from command output; dead ones go away
6 * ## Pinned only what the person (or the agent, when asked) put there
7 * ## Recent the last few artifacts, newest first; older ones drop out
8 *
9 * One entry is one line: `- [title](url) — note <!-- key=value ... -->`. The
10 * comment holds what the plugin needs (port, session, time) and stays hidden
11 * in a Markdown preview. A line without a link is a plain text note.
12 *
13 * Everything here is pure: parse, change, serialize.
14 */
15
16export type SectionId = 'servers' | 'pinned' | 'recent'
17
18export type Entry = {
19 title: string
20 url?: string
21 note?: string
22 meta: Record<string, string>
23}
24
25export type Notes = Record<SectionId, Entry[]> & {
26 /** Sections the plugin doesn't know, kept verbatim at the end. */
27 extra: string
28}
29
30export const SECTIONS: { id: SectionId; heading: string }[] = [
31 { id: 'servers', heading: 'Servers' },
32 { id: 'pinned', heading: 'Pinned' },
33 { id: 'recent', heading: 'Recent' },
34]
35
36/** How many entries Recent keeps. */
37export const RECENT_LIMIT = 5
38
39export const emptyNotes = (): Notes => ({ servers: [], pinned: [], recent: [], extra: '' })
40
41const ENTRY = /^\s*[-*]\s+(.*)$/
42const META = /\s*<!--(.*?)-->\s*$/
43const LINK = /^\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(.*)$/
44
45const parseEntry = (line: string): Entry | null => {
46 const match = ENTRY.exec(line)
47 if (match === null) return null
48 let body = match[1]
49
50 const meta: Record<string, string> = {}
51 const comment = META.exec(body)
52 if (comment !== null) {
53 body = body.slice(0, comment.index)
54 for (const pair of comment[1].trim().split(/\s+/)) {
55 const at = pair.indexOf('=')
56 if (at > 0) meta[pair.slice(0, at)] = pair.slice(at + 1)
57 }
58 }
59
60 const link = LINK.exec(body.trim())
61 if (link !== null) {
62 const note = link[3].replace(/^\s*[—–-]\s*/, '').trim()
63 return {
64 title: link[1].replace(/\\(.)/g, '$1'),
65 url: link[2],
66 ...(note === '' ? {} : { note }),
67 meta,
68 }
69 }
70 const title = body.trim()
71 return title === '' ? null : { title, meta }
72}
73
74export const parseNotes = (source: string): Notes => {
75 const notes = emptyNotes()
76 const extra: string[] = []
77 let section: SectionId | 'other' | null = null
78
79 for (const line of source.split(/\r?\n/)) {
80 const heading = /^##\s+(.+?)\s*$/.exec(line)
81 if (heading !== null) {
82 const known = SECTIONS.find((one) => one.heading.toLowerCase() === heading[1].toLowerCase())
83 section = known?.id ?? 'other'
84 if (section === 'other') extra.push(line)
85 continue
86 }
87 if (section === 'other') {
88 extra.push(line)
89 continue
90 }
91 if (section === null) continue
92 const entry = parseEntry(line)
93 if (entry !== null) notes[section].push(entry)
94 }
95
96 notes.extra = extra.join('\n').trim()
97 return notes
98}
99
100const escapeTitle = (title: string): string => title.replace(/[[\]\\]/g, (char) => `\\${char}`)
101
102export const entryLine = (entry: Entry): string => {
103 const head = entry.url === undefined ? entry.title : `[${escapeTitle(entry.title)}](${entry.url})`
104 const note = entry.note === undefined ? '' : ` — ${entry.note}`
105 const pairs = Object.entries(entry.meta)
106 .filter(([, value]) => value !== '')
107 .map(([key, value]) => `${key}=${value.replace(/\s+/g, '_')}`)
108 const meta = pairs.length === 0 ? '' : ` <!-- ${pairs.join(' ')} -->`
109 return `- ${head}${note}${meta}`
110}
111
112export const serializeNotes = (notes: Notes): string => {
113 const parts = [
114 '# Notes',
115 '',
116 '<!-- Kept by the session-notes plugin. Servers and Recent fill themselves; Pinned is yours. -->',
117 ]
118 for (const { id, heading } of SECTIONS) {
119 parts.push('', `## ${heading}`)
120 for (const entry of notes[id]) parts.push(entryLine(entry))
121 }
122 if (notes.extra !== '') parts.push('', notes.extra)
123 return `${parts.join('\n')}\n`
124}
125
126/** Two entries are the same when their links are, or, without links, their titles. */
127export const sameEntry = (a: Entry, b: Entry): boolean =>
128 a.url !== undefined || b.url !== undefined ? a.url === b.url : a.title === b.title
129
130/** Adds or refreshes a server, matched by port. */
131export const upsertServer = (notes: Notes, server: Entry): Notes => {
132 const port = server.meta.port
133 const servers = notes.servers.filter((one) => one.meta.port !== port)
134 return { ...notes, servers: [server, ...servers] }
135}
136
137/** Puts an entry on top of Recent, unless it's already pinned. */
138export const pushRecent = (notes: Notes, entry: Entry, limit = RECENT_LIMIT): Notes => {
139 if (notes.pinned.some((one) => sameEntry(one, entry))) return notes
140 const recent = [entry, ...notes.recent.filter((one) => !sameEntry(one, entry))].slice(0, limit)
141 return { ...notes, recent }
142}
143
144/** Moves an entry from wherever it is to Pinned (servers keep their row too). */
145export const pin = (notes: Notes, entry: Entry): Notes => {
146 const pinned = notes.pinned.some((one) => sameEntry(one, entry)) ? notes.pinned : [...notes.pinned, entry]
147 return {
148 ...notes,
149 pinned,
150 recent: notes.recent.filter((one) => !sameEntry(one, entry)),
151 }
152}
153
154/** Removes an entry from one section, or from all of them. */
155export const remove = (notes: Notes, entry: Entry, section?: SectionId): Notes => {
156 const next = { ...notes }
157 for (const { id } of SECTIONS) {
158 if (section === undefined || section === id) next[id] = notes[id].filter((one) => !sameEntry(one, entry))
159 }
160 return next
161}
162
163/** Finds an entry by link, exact title, or a piece of the title. */
164export const findEntry = (notes: Notes, query: string): { entry: Entry; section: SectionId } | null => {
165 const wanted = query.trim().toLowerCase()
166 if (wanted === '') return null
167 const all = SECTIONS.flatMap(({ id }) => notes[id].map((entry) => ({ entry, section: id })))
168 return (
169 all.find(({ entry }) => entry.url?.toLowerCase() === wanted) ??
170 all.find(({ entry }) => entry.title.toLowerCase() === wanted) ??
171 all.find(({ entry }) => entry.title.toLowerCase().includes(wanted)) ??
172 null
173 )
174}
175
176const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g
177const URL_ON_PORT =
178 /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|\d{1,3}(?:\.\d{1,3}){3}):(\d{2,5})(\/[^\s'"<>)\]]*)?/gi
179const PORT_PHRASE = /\b(?:listening|running|started|serving|available)\b[^\n]{0,40}?\bport\s*:?\s*(\d{2,5})\b/gi
180
181export type FoundServer = { port: string; path: string }
182
183/** Servers a command printed: URLs on a local port, or "listening on port N". */
184export const findServers = (output: string): FoundServer[] => {
185 const text = output.replace(ANSI, '')
186 const found = new Map<string, FoundServer>()
187 for (const match of text.matchAll(URL_ON_PORT)) {
188 const path = (match[2] ?? '/').replace(/[.,;:]+$/, '')
189 if (!found.has(match[1])) found.set(match[1], { port: match[1], path })
190 }
191 for (const match of text.matchAll(PORT_PHRASE)) {
192 if (!found.has(match[1])) found.set(match[1], { port: match[1], path: '/' })
193 }
194 return [...found.values()]
195}
196
197/** Commands that show what is already there: their output is never a new server. */
198const READS_ONLY =
199 /^\s*(?:cat|type|Get-Content|gc|tail|head|less|more|grep|rg|sed|awk|echo|Write-Output|curl|wget|Invoke-WebRequest|iwr|git|ls|dir|Get-ChildItem|find|jq|Select-String)\b/i
200
201/**
202 * Whether a command only reads: some command in it starts with a reader. What
203 * a pipeline prints comes from its first command, so `npm run dev | grep
204 * Local` still starts a server and `cat log | grep url` doesn't.
205 */
206export const readsOnly = (command: string): boolean =>
207 command
208 .split(/&&|\|\||[;\n]/)
209 .map((part) => part.split('|')[0])
210 .some((head) => READS_ONLY.test(head))
211
212/** A short title for a server from the command that started it. */
213export const serverTitle = (command: string): string => {
214 const line = command.split(/\r?\n/).find((one) => one.trim() !== '') ?? command
215 const words = line
216 .trim()
217 .replace(/^(?:cd\s+\S+\s*(?:&&|;)\s*)+/, '')
218 .replace(/\s*(?:&|2>&1|>\s*\S+)\s*$/g, '')
219 return words.length > 48 ? `${words.slice(0, 47)}…` : words
220}
221
222const ARTIFACT_URL = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+/
223
224export const findArtifactUrl = (text: string): string | null => ARTIFACT_URL.exec(text)?.[0] ?? null
225
226/** The page title of an HTML file, or null. */
227export const htmlTitle = (html: string): string | null => {
228 const match = /<title[^>]*>([^<]*)<\/title>/i.exec(html)
229 const title = match?.[1].replace(/\s+/g, ' ').trim()
230 return title === undefined || title === '' ? null : title
231}
232
233/** The output file a background command writes to, from the tool's answer. */
234export const backgroundOutputFile = (text: string): string | null =>
235 /Output is being written to:?\s*(\S+)/i.exec(text)?.[1].replace(/[.,;]+$/, '') ?? null
236