SLOPSHOPPER

Session Notes for Claude Code

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

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-notes
│ ┃ Notes ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Notes app ⏺ Read(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ No notes yet. Servers and artifacts show ⎿ Added 2 lines, removed 1 line │ ┃ up here by themselves; ask the agent to ⏺ Bash(bun test) │ ┃ pin anything else. ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /notes │ ⎿ session-notes: │ ⎿ session-notes: No notes yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Notes
Notes app ────────────────────────────────────────────────────── No notes yet. Servers and artifacts show up here by themselves; ask the agent to pin anything else.
README

Session Notes for Claude Code

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.

Features

  • Servers a command prints (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.
  • Each server's port is checked: 🌐 / ● answers on the LAN, 🏠 / ◐ on localhost only, 💤 / ○ not running. A server that stays down for 10 minutes leaves the list.
  • Recent: the last 5 published artifacts and Claude Docs documents, newest first.
  • Pinned: only what you chose, with the 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.
  • The agent gets a notes tool: it pins and removes entries, and looks there first when you ask for a server link or an earlier artifact.
  • The file is plain Markdown you can read and edit. The first time it is created, .claude/notes.md is added to the project's .gitignore.

Requirements

  • Claude Code with mods (function hooks): CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
  • Node.js on PATH (the port check runs scripts/probe.mjs).
  • The panel docks beside the transcript in the fullscreen layout (/tui fullscreen); /notes prints the list in any layout.

Installation

  1. Add the flag to ~/.claude/settings.json and restart Claude Code:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
  1. Install the plugin from this repository, which is also a plugin marketplace:
   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>

Usage

WhatHow
Open the panel and print the list/notes
Close the panel/notes close, or the panel's own close mark
Keep somethingpin on a Recent entry, or ask the agent to remember it
Drop an entry✕ in the panel, or ask the agent to remove it

The file

## 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.

Development

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.

License

MIT

Source 2 files
hooks/register.ts 512 lines
1import 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}
512
hooks/notes.ts 236 lines
1/**
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