Shows the Lavish pages this session opened, whether each is streaming, idle, closed or dead, with a one-line description of each and open and end buttons

A Claude Code mod for Lavish Editor (lavish-axi).
When several Claude Code sessions run in one folder, each one opens its own Lavish review pages, and the list lavish-axi prints mixes them all together. lavish-pages shows each session only the pages it opened, in a band above the prompt:

Each row shows:
streaming (an agent poll is waiting on it), open, starting, closed, down (the Lavish server is not running) or gone,Pages with queued prompts come first, then streaming, then open, and closed pages are folded away. Above three pages the band collapses to one summary line.
lavish-axi on your PATH.open button, which uses the open command. Everything else works anywhere.Run these inside Claude Code:
/plugin marketplace add FoCDoT/lavish-pages
/plugin install lavish-pages@lavish-pages
The band appears on its own the first time the session opens a Lavish page. It is never shown for a session with no pages.
| Action | Effect |
|---|---|
collapse / expand | Show one summary line, or one row per page. |
hide | Hide the band. It comes back when the session opens a new page. |
show N closed | Unfold closed pages. |
/lavish-pages | Show the band again and open a side pane listing every page of this session, each description on its own line. |
A session owns a page only if it wrote the .html file (with Write or Edit, or a shell > or tee) or polled it (lavish-axi poll). Opening, ending or exporting a page alone does not claim it, so looking at another session's page never adds it to your band.
When the mod loads in a session that is already running, it reads that session's transcript (and its subagents' transcripts) once to find the pages opened before it loaded.
GET http://127.0.0.1:4387/health (a key listed under listeners means an agent poll is waiting), plus Lavish's own ~/.lavish-axi/state.json. Both are read only, and the mod never writes to Lavish's files.<meta name="description">. If the page has none, Claude Haiku writes a one-line summary from the page's title and visible text. That call goes through your Claude Code sign-in and counts toward your plan's usage. To avoid it, give your pages a meta description.Summaries are cached by file version, so Haiku runs once per version of a page. At most two run at once, and a page rewritten within 15 minutes keeps its previous summary. A failed summary is not retried until the file changes.
| Path | Purpose |
|---|---|
~/.lavish-axi/state.json | Lavish's session state. Read only. |
~/.claude/projects/<folder>/<id>.jsonl | This session's transcript, read once on load to find earlier pages. Read only. |
| Claude Code's per-mod store | This session's page list (pages:<session>, last 200 sessions) and cached descriptions (desc:<file>, last 300 pages). |
CLAUDE_CONFIG_DIR is respected, and so is a Lavish server on another port when the page URLs point there.
claude plugin test lavish-pages # mod tests
claude plugin validate ./lavish-pages # mod manifest and hooks
To work on a local copy, add this folder as a marketplace (/plugin marketplace add /path/to/lavish-pages), install from it, and run /reload-plugins after each edit.
/plugin uninstall lavish-pages@lavish-pages
/plugin marketplace remove lavish-pages
MIT. Not affiliated with Lavish Editor or its author.
hooks/register.tsx 532 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LavishPage, LavishStatus } from '../types'
5import {
6 GLYPH,
7 WORD,
8 fit,
9 isDone,
10 authoredFromGrep,
11 cleanDescription,
12 descFit,
13 describePrompt,
14 isLive,
15 isOwned,
16 lavishCalls,
17 merge,
18 metaDescription,
19 nameOf,
20 ordered,
21 pageTitle,
22 parseOpened,
23 sanitize,
24 sightingsFromTranscript,
25 summary,
26 visibleText,
27 writtenHtml,
28} from './lib'
29import type { Sighting } from './lib'
30
31const pages = atom({ plugin: 'lavish-pages', key: 'pages' } as const, [])
32const isHidden = atom({ plugin: 'lavish-pages', key: 'isHidden' } as const, false)
33const isExpanded = atom({ plugin: 'lavish-pages', key: 'isExpanded' } as const, null)
34const showClosed = atom({ plugin: 'lavish-pages', key: 'showClosed' } as const, false)
35const scanVersion = atom({ plugin: 'lavish-pages', key: 'scanVersion' } as const, 0)
36const authored = atom({ plugin: 'lavish-pages', key: 'authored' } as const, [])
37
38/** Bumped when the backfill's rules change, so every session rescans once. */
39const SCAN_VERSION = 2
40const AUTHORED_MAX = 500
41
42const PANE = 'lavish-pages'
43const DEFAULT_BASE = 'http://127.0.0.1:4387'
44const REFRESH_MS = 4000
45const PENDING_GRACE_MS = 60_000
46const AUTO_EXPAND_MAX = 3
47const BAND_ROWS_MAX = 6
48const STORE_SESSIONS_MAX = 200
49
50const HAIKU = 'claude-haiku-4-5-20251001'
51/** Haiku calls running at once, across all pages. */
52const DESC_IN_FLIGHT_MAX = 2
53/** A page rewritten within this long of its last summary keeps that summary. */
54const DESC_REDO_MS = 15 * 60_000
55const DESC_STORE_MAX = 300
56const READ_MAX = 4 * 1024 * 1024
57
58// ---------- store ----------
59
60const storeKey = async ($: EngineInterface) => `pages:${await $.session.id()}`
61
62const save = async ($: EngineInterface, fn: (list: LavishPage[]) => LavishPage[]) => {
63 await update($, pages, list => fn(list ?? []))
64 await $.store.set(await storeKey($), { v: 2, pages: await read($, pages) })
65}
66
67const prune = async ($: EngineInterface) => {
68 const all = await $.store.keys()
69 const drop = (prefix: string, max: number) => {
70 const keys = all.filter(k => k.startsWith(prefix))
71 return keys.slice(0, Math.max(0, keys.length - max))
72 }
73 for (const k of [...drop('pages:', STORE_SESSIONS_MAX), ...drop('desc:', DESC_STORE_MAX)]) await $.store.delete(k)
74}
75
76/** Adds what was found; a page new to this session brings the band back. */
77const remember = async ($: EngineInterface, found: readonly Sighting[]) => {
78 if (found.length === 0) return
79 const before = new Set((await read($, pages)).map(p => p.file))
80 await save($, list => merge(list, found))
81 if (found.some(f => !before.has(f.file))) await update($, isHidden, () => false)
82}
83
84// ---------- backfill: pages this session touched before the mod loaded ----------
85
86const addAuthored = async ($: EngineInterface, files: readonly string[]) => {
87 const html = files.filter(f => f.endsWith('.html'))
88 if (html.length === 0) return
89 await update($, authored, list => [...new Set([...(list ?? []), ...html])].slice(-AUTHORED_MAX))
90}
91
92/**
93 * Rebuilds the list from the session's transcript: the pages it owns.
94 * Resolves false when no transcript file was found.
95 */
96const scanTranscript = async ($: EngineInterface): Promise<boolean> => {
97 const home = (await $.env.get('HOME')) ?? ''
98 const cwd = await $.session.cwd()
99 const id = await $.session.id()
100 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
101
102 let main = `${configDir}/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
103 if (!(await $.fs.exists(main))) {
104 const found = await $.process.run(['find', `${configDir}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`])
105 main = found.stdout.split('\n')[0]?.trim() ?? ''
106 }
107
108 if (main && (await $.fs.exists(main))) {
109 const files = [main]
110 const subagents = main.replace(/\.jsonl$/, '') + '/subagents'
111 if (await $.fs.exists(subagents)) {
112 for (const entry of await $.fs.list(subagents)) {
113 if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) files.push(`${subagents}/${entry.name}`)
114 }
115 }
116 // grep, not $.fs.read: transcripts outgrow the 4 MiB read limit; only lavish lines matter.
117 const hits = await $.process.run(['grep', '-h', '-F', '-e', 'lavish-axi', '-e', '/session/', ...files], { timeoutMs: 30_000 })
118 const writes = await $.process.run(
119 ['grep', '-h', '-o', '-E', '"name":"(Write|Edit|MultiEdit)","input":\\{"file_path":"[^"]+\\.html"', ...files],
120 { timeoutMs: 30_000 },
121 )
122 await addAuthored($, authoredFromGrep(writes.stdout.split('\n')))
123 const found = sightingsFromTranscript(hits.stdout.split('\n'), home, await read($, authored))
124 await save($, () => merge([], found))
125 return true
126 }
127
128 // No transcript file found: fall back to what the session holds in memory.
129 const now = await $.clock.now()
130 const messages = await $.session.messages()
131 const wrote: string[] = []
132 for (const message of messages) {
133 for (const use of message.toolUses) {
134 const path = use.input.file_path
135 if (['Write', 'Edit', 'MultiEdit'].includes(use.tool) && typeof path === 'string') wrote.push(path)
136 if (use.tool === 'Bash' && typeof use.input.command === 'string') wrote.push(...writtenHtml(use.input.command, cwd, home))
137 }
138 }
139 await addAuthored($, wrote)
140 const mine = new Set(await read($, authored))
141 const found: Sighting[] = []
142 for (const message of messages) {
143 for (const use of message.toolUses) {
144 if (use.tool !== 'Bash' || typeof use.input.command !== 'string') continue
145 const owned = lavishCalls(use.input.command, cwd, home).filter(c => isOwned(c, mine)).map(c => c.file)
146 for (const file of owned) found.push({ file, seenAt: now })
147 for (const o of parseOpened(use.text ?? '')) if (owned.includes(o.file)) found.push({ ...o, seenAt: now })
148 }
149 }
150 await remember($, found)
151 return false
152}
153
154// ---------- status ----------
155
156type LavishSession = {
157 key: string
158 file: string
159 url?: string
160 status: string
161 ended_by?: string
162 pending_prompts?: number
163 artifact_revision?: number
164}
165
166const baseOf = (list: readonly LavishPage[]): string => {
167 const url = list.find(p => p.url)?.url
168 if (!url) return DEFAULT_BASE
169 try {
170 return new URL(url).origin
171 } catch {
172 return DEFAULT_BASE
173 }
174}
175
176const refresh = async ($: EngineInterface): Promise<void> => {
177 const list = await read($, pages)
178 if (list.length === 0) return
179
180 let listeners = new Set<string>()
181 let stateDir: string | undefined
182 let isAlive = false
183 try {
184 const res = await $.http.fetch(`${baseOf(list)}/health`)
185 if (res.ok) {
186 const health = JSON.parse(res.text)
187 isAlive = health.ok === true
188 stateDir = health.state_dir
189 listeners = new Set((health.listeners ?? []).map((l: { key: string }) => l.key))
190 }
191 } catch {
192 isAlive = false
193 }
194 stateDir ??= `${(await $.env.get('HOME')) ?? ''}/.lavish-axi`
195
196 let sessions: LavishSession[] = []
197 try {
198 const raw = JSON.parse(await $.fs.read(`${stateDir}/state.json`))
199 sessions = Object.values(raw.sessions ?? {}) as LavishSession[]
200 } catch {
201 sessions = []
202 }
203
204 const now = await $.clock.now()
205 const next = list.map((page): LavishPage => {
206 const s = sessions.find(x => x.file === page.file || (page.key !== undefined && x.key === page.key))
207 if (!s) {
208 const status: LavishStatus = !isAlive ? 'dead' : now - page.seenAt < PENDING_GRACE_MS ? 'pending' : 'missing'
209 return { ...page, status, prompts: 0 }
210 }
211 let status: LavishStatus
212 if (s.status === 'ended') status = 'closed'
213 else if (!isAlive) status = 'dead'
214 else status = listeners.has(s.key) ? 'streaming' : 'idle'
215 return {
216 ...page,
217 key: s.key,
218 url: s.url ?? page.url,
219 status,
220 endedBy: s.status === 'ended' ? s.ended_by : undefined,
221 prompts: s.pending_prompts ?? 0,
222 rev: s.artifact_revision,
223 }
224 })
225
226 if (JSON.stringify(next) !== JSON.stringify(list)) await save($, () => next)
227}
228
229// ---------- descriptions ----------
230
231/** Kept in `$.store` (across sessions) under `desc:<file>`, so Haiku runs once per page version. */
232type DescCache = { ver: string; desc?: string; at: number; source: 'meta' | 'haiku' | 'none' }
233
234const inFlight = new Set<string>()
235let isDescribing = false
236
237const setDesc = async ($: EngineInterface, file: string, ver: string, desc: string | undefined) => {
238 await save($, list => list.map(p => (p.file === file ? { ...p, desc: desc ?? p.desc, descVer: ver } : p)))
239}
240
241/** Fills in each page's one-line description in the background; never blocks a draw. */
242async function describePages($: EngineInterface): Promise<void> {
243 if (isDescribing) return
244 isDescribing = true
245 try {
246 for (const page of await read($, pages)) {
247 if (inFlight.has(page.file)) continue
248 let ver: string
249 let size: number
250 try {
251 const st = await $.fs.stat(page.file)
252 if (st.kind !== 'file') continue
253 ver = `${Math.round(st.mtimeMs)}:${st.size}`
254 size = st.size
255 } catch {
256 continue
257 }
258 if (page.descVer === ver) continue
259
260 const key = `desc:${page.file}`
261 const cached = (await $.store.get(key)) as DescCache | undefined
262 if (cached?.ver === ver) {
263 await setDesc($, page.file, ver, cached.desc)
264 continue
265 }
266 if (size > READ_MAX) {
267 await setDesc($, page.file, ver, cached?.desc)
268 continue
269 }
270 let html: string
271 try {
272 html = await $.fs.read(page.file)
273 } catch {
274 await setDesc($, page.file, ver, cached?.desc)
275 continue
276 }
277 const now = await $.clock.now()
278 const meta = metaDescription(html)
279 if (meta) {
280 await $.store.set(key, { ver, desc: meta, at: now, source: 'meta' } satisfies DescCache)
281 await setDesc($, page.file, ver, meta)
282 continue
283 }
284 // A page being rewritten again and again keeps its summary for a while.
285 if (cached?.source === 'haiku' && cached.desc && now - cached.at < DESC_REDO_MS) {
286 await setDesc($, page.file, ver, cached.desc)
287 continue
288 }
289 if (inFlight.size >= DESC_IN_FLIGHT_MAX) continue
290 inFlight.add(page.file)
291 void summarize($, page.file, ver, html, cached?.desc).finally(() => inFlight.delete(page.file))
292 }
293 } finally {
294 isDescribing = false
295 }
296}
297
298async function summarize($: EngineInterface, file: string, ver: string, html: string, previous: string | undefined) {
299 const text = visibleText(html)
300 let desc: string | undefined
301 if (text.length > 40) {
302 const res = await $.model.complete({
303 model: HAIKU,
304 prompt: describePrompt(pageTitle(html), text),
305 maxTokens: 80,
306 effort: 'low',
307 timeoutMs: 60_000,
308 })
309 desc = res.isAnswered ? cleanDescription(res.text) : undefined
310 }
311 desc ??= pageTitle(html) ?? previous
312 // Failures are cached too (`none`), so a page is not retried until it changes.
313 await $.store.set(`desc:${file}`, { ver, desc, at: await $.clock.now(), source: desc ? 'haiku' : 'none' } satisfies DescCache)
314 await setDesc($, file, ver, desc)
315}
316
317// ---------- drawing ----------
318
319/** One page's row, sized to `columns`. */
320function row(
321 $: EngineInterface,
322 e: Parameters<EngineInterface['ui']['resolve']>[0],
323 page: LavishPage,
324 columns: number,
325 descBelow = false,
326) {
327 const { Box, Text, Button } = $.ui.resolve(e)
328 const run = async (argv: string[], done: string) => {
329 const res = await $.process.run(argv, { timeoutMs: 20000 })
330 $.ui.toast(res.exitCode === 0 ? done : `lavish: ${(res.stderr || res.stdout).trim().slice(0, 120)}`)
331 await refresh($)
332 }
333 const name = nameOf(page.file)
334 const isNarrow = columns < 70
335 const label = isNarrow ? `${GLYPH[page.status]} ` : `${GLYPH[page.status]} ${WORD[page.status]}`.padEnd(12)
336 const meta = [
337 page.prompts > 0 && !isDone(page) ? `${page.prompts} queued` : '',
338 page.status === 'closed' && page.endedBy ? `by ${page.endedBy === 'user' ? 'you' : page.endedBy}` : '',
339 !isNarrow && page.rev !== undefined ? `r${page.rev}` : '',
340 ].filter(Boolean)
341 const metaText = meta.length > 0 ? ` ${meta.join(' · ')}` : ''
342 const buttons = isLive(page) ? 16 : page.status === 'pending' ? 8 : 20
343 const room = Math.max(8, columns - label.length - metaText.length - buttons - 2)
344 const nameWidth = Math.min(room, name.length)
345 const inline = descBelow ? undefined : descFit(page.desc, room - nameWidth - 3)
346 const canOpen = page.url !== undefined && !isDone(page) && page.status !== 'dead'
347
348 return (
349 <Box key={`row-${page.file}`} flexDirection="row">
350 <Text bold={page.status === 'streaming'} dimColor={!isLive(page)}>
351 {label}
352 </Text>
353 <Button
354 key={`name-${page.file}`}
355 label={fit(name, nameWidth)}
356 plain
357 dimColor={isDone(page)}
358 onPress={() => page.url && run(['open', page.url], `Opened ${name}`)}
359 />
360 {inline !== undefined && <Text dimColor>{` · ${inline}`}</Text>}
361 {metaText !== '' && <Text dimColor>{metaText}</Text>}
362 <Text> </Text>
363 {canOpen && <Button key={`open-${page.file}`} label="open" onPress={() => run(['open', page.url!], `Opened ${name}`)} />}
364 {isLive(page) && (
365 <Button key={`end-${page.file}`} label="end" onPress={() => run(['lavish-axi', 'end', page.file], `Ended ${name}`)} />
366 )}
367 {(isDone(page) || page.status === 'dead') && (
368 <Button
369 key={`reopen-${page.file}`}
370 label="reopen"
371 onPress={() => run(['lavish-axi', page.file, '--reopen'], `Reopened ${name}`)}
372 />
373 )}
374 {(isDone(page) || page.status === 'dead') && (
375 <Button key={`forget-${page.file}`} label="forget" dimColor onPress={() => save($, l => l.filter(p => p.file !== page.file))} />
376 )}
377 </Box>
378 )
379}
380
381/** The pane's row: the page, then its description on a line of its own. */
382function paneRow($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], page: LavishPage, columns: number) {
383 const { Box, Text } = $.ui.resolve(e)
384 const below = descFit(page.desc, columns - 4)
385 return (
386 <Box key={`pane-${page.file}`} flexDirection="column">
387 {row($, e, page, columns, true)}
388 {below !== undefined && <Text dimColor wrap="truncate-end">{` ${below}`}</Text>}
389 </Box>
390 )
391}
392
393
394export const register: Register = on => {
395 on('session.start', async ($, e, next) => {
396 await $.command.register({
397 name: 'lavish-pages',
398 description: 'Show the Lavish band again and open the full list of this session\'s Lavish pages',
399 })
400 if ((await read($, scanVersion)) < SCAN_VERSION) {
401 let hasTranscript = false
402 try {
403 hasTranscript = await scanTranscript($)
404 } finally {
405 await update($, scanVersion, () => SCAN_VERSION)
406 }
407 if (!hasTranscript && (await read($, pages)).length === 0) {
408 const saved = sanitize(await $.store.get(await storeKey($)))
409 if (saved.length > 0) await update($, pages, () => saved)
410 }
411 void prune($)
412 }
413 $.clock.every(REFRESH_MS, async () => {
414 await refresh($)
415 await describePages($)
416 })
417 await refresh($)
418 void describePages($)
419 return next(e)
420 })
421
422 on('command.run', { command: 'lavish-pages' }, async $ => {
423 await update($, isHidden, () => false)
424 const list = await read($, pages)
425 if (list.length === 0) return { text: 'This session has not opened any Lavish pages yet.' }
426 await $.ui.open({ id: PANE, title: 'Lavish pages' })
427 return { text: `Lavish: ${summary(list)}` }
428 })
429
430 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
431 await addAuthored($, [e.file_path])
432 return next(e)
433 })
434
435 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
436 await addAuthored($, [e.file_path])
437 return next(e)
438 })
439
440 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
441 const command = String(e.command ?? '')
442 if (!command.includes('lavish-axi')) return next(e)
443
444 const cwd = await $.session.cwd()
445 const home = (await $.env.get('HOME')) ?? ''
446 const now = await $.clock.now()
447 await addAuthored($, writtenHtml(command, cwd, home))
448 const mine = new Set(await read($, authored))
449 const owned = lavishCalls(command, cwd, home).filter(c => isOwned(c, mine)).map(c => c.file)
450 // Recorded before the command runs, so a long foreground poll shows at once.
451 await remember($, owned.map(file => ({ file, seenAt: now })))
452 void refresh($)
453
454 const ran = await next(e)
455 if (ran.deny === undefined && typeof ran.text === 'string') {
456 await remember($, parseOpened(ran.text).filter(o => owned.includes(o.file)).map(o => ({ ...o, seenAt: now })))
457 }
458 await refresh($)
459 return ran
460 })
461
462 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
463 const list = ordered(await read($, pages))
464 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return next(e)
465
466 const { Box, Text, Button } = $.ui.resolve(e)
467 const columns = e.props.bodyColumns
468 const expandedSetting = await read($, isExpanded)
469 const expanded = expandedSetting ?? list.length <= AUTO_EXPAND_MAX
470 const withClosed = await read($, showClosed)
471
472 const header = (
473 <Box flexDirection="row">
474 <Text bold>lavish </Text>
475 <Text dimColor wrap="truncate-end">
476 {summary(list)}
477 {' '}
478 </Text>
479 <Button
480 key="toggle"
481 label={expanded ? 'collapse' : 'expand'}
482 plain
483 dimColor
484 onPress={() => update($, isExpanded, () => !expanded)}
485 />
486 <Text> </Text>
487 <Button key="hide" label="hide" plain dimColor onPress={() => update($, isHidden, () => true)} />
488 </Box>
489 )
490 if (!expanded) return header
491
492 const active = list.filter(p => !isDone(p))
493 const done = list.filter(isDone)
494 const candidates = withClosed ? [...active, ...done] : active
495 // Header, the closed toggle and the "+N more" line take three rows at most.
496 const room = Math.max(1, Math.min(BAND_ROWS_MAX, e.props.maxRows - 3))
497 const shown = candidates.slice(0, room)
498 const more = candidates.length - shown.length
499
500 return (
501 <Box flexDirection="column">
502 {header}
503 {shown.map(page => row($, e, page, columns))}
504 {more > 0 && <Text dimColor>{` +${more} more · /lavish-pages lists them all`}</Text>}
505 {done.length > 0 && (
506 <Button
507 key="closed"
508 label={withClosed ? `hide ${done.length} closed` : `show ${done.length} closed`}
509 plain
510 dimColor
511 onPress={() => update($, showClosed, () => !withClosed)}
512 />
513 )}
514 </Box>
515 )
516 })
517
518 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
519 const { Box, Text } = $.ui.resolve(e)
520 const list = ordered(await read($, pages))
521 return (
522 <Box flexDirection="column">
523 <Text dimColor wrap="truncate-end">
524 {summary(list)}
525 </Text>
526 {list.length === 0 && <Text dimColor>No Lavish pages from this session.</Text>}
527 {list.map(page => paneRow($, e, page, e.props.bodyColumns))}
528 </Box>
529 )
530 })
531}
532hooks/lib.ts 300 lines1import type { LavishPage, LavishStatus } from '../types'
2
3const SUBCOMMANDS = new Set(['open', 'poll', 'end', 'export', 'share'])
4
5export type Sighting = { file: string; url?: string; seenAt: number }
6
7// ---------- paths and commands ----------
8
9const normalize = (path: string): string => {
10 const out: string[] = []
11 for (const part of path.split('/')) {
12 if (part === '' || part === '.') continue
13 if (part === '..') out.pop()
14 else out.push(part)
15 }
16 return '/' + out.join('/')
17}
18
19const resolvePath = (base: string, path: string, home: string): string => {
20 if (path.startsWith('~/')) return normalize(home + path.slice(1))
21 if (path.startsWith('/')) return normalize(path)
22 return normalize(base + '/' + path)
23}
24
25const tokens = (segment: string): string[] => {
26 const escaped = segment.replace(/\\ /g, '\u0000')
27 const found: string[] = []
28 for (const m of escaped.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
29 found.push((m[1] ?? m[2] ?? m[3] ?? '').replace(/\u0000/g, ' '))
30 }
31 return found
32}
33
34export type LavishCall = { file: string; sub: string }
35
36const stripHeredocs = (command: string) =>
37 command.replace(/<<-?\s*['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\s*\1\s*(?=\n|$)/g, '')
38
39/**
40 * Each lavish-axi call in a shell command: the .html file and the subcommand
41 * (`open` for a bare `lavish-axi <file>`).
42 */
43export const lavishCalls = (command: string, cwd: string, home: string): LavishCall[] => {
44 let dir = cwd
45 const calls: LavishCall[] = []
46 // Heredoc bodies are file contents, not commands run.
47 for (const segment of stripHeredocs(command).split(/&&|\|\||;|\n|\|/)) {
48 const words = tokens(segment)
49 const target = words[1]
50 if (words[0] === 'cd' && target) {
51 dir = resolvePath(dir, target, home)
52 continue
53 }
54 const at = words.findIndex(w => w === 'lavish-axi' || w.endsWith('/lavish-axi'))
55 if (at < 0) continue
56 const rest = words.slice(at + 1)
57 const first = rest[0]
58 if (first === undefined) continue
59 if (!first.endsWith('.html') && !SUBCOMMANDS.has(first)) continue
60 const html = rest.find(w => w.endsWith('.html') && !w.startsWith('-'))
61 if (html) calls.push({ file: resolvePath(dir, html, home), sub: first.endsWith('.html') ? 'open' : first })
62 }
63 return calls
64}
65
66/** The .html files a shell command opens, polls, ends, exports or shares with lavish-axi. */
67export const lavishFiles = (command: string, cwd: string, home: string): string[] =>
68 lavishCalls(command, cwd, home).map(c => c.file)
69
70/** .html files a shell command writes by redirection or tee (`cat > page.html <<EOF`). */
71export const writtenHtml = (command: string, cwd: string, home: string): string[] => {
72 const files: string[] = []
73 for (const m of stripHeredocs(command).matchAll(/(?:>>?|\btee(?:\s+-a)?)\s*("[^"]+\.html"|'[^']+\.html'|[^\s;&|<>]+\.html)/g)) {
74 const raw = (m[1] ?? '').replace(/^["']|["']$/g, '')
75 if (raw) files.push(resolvePath(cwd, raw, home))
76 }
77 return files
78}
79
80/**
81 * Whether a session owns a page: it wrote the file, or it polls it for
82 * feedback. Opening, ending or exporting someone else's page is no claim.
83 */
84export const isOwned = (call: LavishCall, authored: ReadonlySet<string>) =>
85 call.sub === 'poll' || authored.has(call.file)
86
87/** `file:` and `url:` from lavish-axi's open output. */
88export const parseOpened = (text: string): { file: string; url: string }[] => {
89 const found: { file: string; url: string }[] = []
90 const re = /file:\s*"?([^"\n]+?\.html)"?\s*\n\s*url:\s*"?(https?:\/\/[^"\s]+)"?/g
91 for (const m of text.matchAll(re)) if (m[1] && m[2]) found.push({ file: m[1], url: m[2] })
92 return found
93}
94
95// ---------- the transcript backfill ----------
96
97type Block = { type?: string; name?: string; input?: { command?: unknown }; content?: unknown; text?: unknown }
98
99const resultText = (content: unknown): string => {
100 if (typeof content === 'string') return content
101 if (Array.isArray(content)) return content.map(b => (typeof b?.text === 'string' ? b.text : '')).join('\n')
102 return ''
103}
104
105/** Absolute .html paths from `grep -o` hits on Write/Edit tool calls. */
106export const authoredFromGrep = (hits: readonly string[]): string[] =>
107 hits.flatMap(h => {
108 const m = h.match(/"file_path":"([^"]+\.html)"/)
109 return m?.[1] ? [m[1]] : []
110 })
111
112/**
113 * The Lavish pages a session's transcript lines (JSONL, main and subagents)
114 * show it owns: written by it (`authored`, or a shell redirection), or polled.
115 */
116export const sightingsFromTranscript = (lines: readonly string[], home: string, authored: readonly string[] = []): Sighting[] => {
117 const mine = new Set(authored)
118 const calls: (LavishCall & { seenAt: number })[] = []
119 const urls = new Map<string, string>()
120 for (const line of lines) {
121 if (!line.includes('lavish-axi') && !line.includes('/session/')) continue
122 let row: { cwd?: string; timestamp?: string; message?: { content?: unknown } }
123 try {
124 row = JSON.parse(line)
125 } catch {
126 continue // a line cut short by the read limit
127 }
128 const content = row.message?.content
129 if (!Array.isArray(content)) continue
130 const seenAt = Date.parse(row.timestamp ?? '') || 0
131 for (const block of content as Block[]) {
132 if (block.type === 'tool_use' && block.name === 'Bash' && typeof block.input?.command === 'string') {
133 const cwd = row.cwd ?? '/'
134 for (const file of writtenHtml(block.input.command, cwd, home)) mine.add(file)
135 for (const call of lavishCalls(block.input.command, cwd, home)) calls.push({ ...call, seenAt })
136 }
137 if (block.type === 'tool_result') {
138 for (const o of parseOpened(resultText(block.content))) urls.set(o.file, o.url)
139 }
140 }
141 }
142 const owned = new Set(calls.filter(c => isOwned(c, mine)).map(c => c.file))
143 return calls.filter(c => owned.has(c.file)).map(c => ({ file: c.file, url: urls.get(c.file), seenAt: c.seenAt }))
144}
145
146/** Folds sightings into the list: one row per file, newest url and time kept. */
147export const merge = (list: readonly LavishPage[], found: readonly Sighting[]): LavishPage[] => {
148 const out = [...list]
149 for (const f of found) {
150 const at = out.findIndex(p => p.file === f.file)
151 const known = out[at]
152 if (!known) {
153 out.push({ file: f.file, url: f.url, status: 'pending', prompts: 0, seenAt: f.seenAt })
154 continue
155 }
156 out[at] = { ...known, url: f.url ?? known.url, seenAt: Math.max(known.seenAt, f.seenAt) }
157 }
158 return out
159}
160
161/** Pages read back from the store, whatever version of the mod wrote them. */
162export const sanitize = (saved: unknown): LavishPage[] => {
163 const list = Array.isArray(saved) ? saved : (saved as { pages?: unknown })?.pages
164 if (!Array.isArray(list)) return []
165 return list
166 .filter((p): p is Record<string, unknown> => typeof p?.file === 'string')
167 .map(p => ({
168 file: p.file as string,
169 url: typeof p.url === 'string' ? p.url : undefined,
170 key: typeof p.key === 'string' ? p.key : undefined,
171 status: 'pending' as const,
172 prompts: 0,
173 seenAt: typeof p.seenAt === 'number' ? p.seenAt : 0,
174 }))
175}
176
177// ---------- ordering and summaries ----------
178
179const RANK: Record<LavishStatus, number> = { streaming: 0, idle: 1, pending: 2, dead: 3, missing: 4, closed: 5 }
180
181export const isLive = (p: LavishPage) => p.status === 'streaming' || p.status === 'idle'
182export const isDone = (p: LavishPage) => p.status === 'closed' || p.status === 'missing'
183
184/** Pages needing you first: queued prompts, streaming, open; closed last; newest first within each. */
185export const ordered = (list: readonly LavishPage[]): LavishPage[] =>
186 [...list].sort((a, b) => {
187 const ra = RANK[a.status] - (a.prompts > 0 && !isDone(a) ? 10 : 0)
188 const rb = RANK[b.status] - (b.prompts > 0 && !isDone(b) ? 10 : 0)
189 return ra - rb || b.seenAt - a.seenAt
190 })
191
192export const GLYPH: Record<LavishStatus, string> = {
193 streaming: '●',
194 idle: '○',
195 closed: '–',
196 dead: '✕',
197 pending: '…',
198 missing: '?',
199}
200
201export const WORD: Record<LavishStatus, string> = {
202 streaming: 'streaming',
203 idle: 'open',
204 closed: 'closed',
205 dead: 'down',
206 pending: 'starting',
207 missing: 'gone',
208}
209
210/** One line: `7 pages · ● 2 streaming · 3 queued · ○ 1 open · – 4 closed`. */
211export const summary = (list: readonly LavishPage[]): string => {
212 const count = (s: LavishStatus) => list.filter(p => p.status === s).length
213 const queued = list.filter(p => !isDone(p)).reduce((n, p) => n + p.prompts, 0)
214 const parts = [`${list.length} page${list.length === 1 ? '' : 's'}`]
215 const add = (s: LavishStatus) => {
216 const n = count(s)
217 if (n > 0) parts.push(`${GLYPH[s]} ${n} ${WORD[s]}`)
218 }
219 add('streaming')
220 if (queued > 0) parts.push(`${queued} queued`)
221 add('idle')
222 add('pending')
223 add('dead')
224 add('closed')
225 add('missing')
226 return parts.join(' · ')
227}
228
229export const nameOf = (file: string) => file.split('/').pop() ?? file
230
231/** Cuts a name to `width` cells, keeping its end (the extension) visible. */
232export const fit = (text: string, width: number): string => {
233 if (text.length <= width) return text
234 if (width <= 1) return '…'
235 const keep = width - 1
236 const tail = Math.min(8, Math.floor(keep / 2))
237 return text.slice(0, keep - tail) + '…' + text.slice(text.length - tail)
238}
239
240// ---------- descriptions ----------
241
242const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'", mdash: '—', ndash: '–', middot: '·', hellip: '…' }
243
244const decode = (text: string) => text.replace(/&(#?\w+);/g, (m, name: string) => ENTITIES[name] ?? m)
245
246const squash = (text: string) => decode(text).replace(/\s+/g, ' ').trim()
247
248/** `<meta name="description" content="…">`, in either attribute order. */
249export const metaDescription = (html: string): string | undefined => {
250 for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
251 if (!/\bname\s*=\s*["']description["']/i.test(tag)) continue
252 const content = tag.match(/\bcontent\s*=\s*"([^"]*)"|\bcontent\s*=\s*'([^']*)'/i)
253 const text = squash(content?.[1] ?? content?.[2] ?? '')
254 if (text) return text
255 }
256 return undefined
257}
258
259export const pageTitle = (html: string): string | undefined => {
260 const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
261 const text = squash(m?.[1] ?? '')
262 return text || undefined
263}
264
265/** What a reader sees: scripts, styles, svg and the head dropped, tags stripped. */
266export const visibleText = (html: string, max = 4000): string => {
267 const body = html
268 .replace(/<head\b[\s\S]*?<\/head>/gi, ' ')
269 .replace(/<(script|style|svg|noscript|template)\b[\s\S]*?<\/\1>/gi, ' ')
270 .replace(/<!--[\s\S]*?-->/g, ' ')
271 .replace(/<[^>]+>/g, ' ')
272 return squash(body).slice(0, max)
273}
274
275/** A model's reply cut to one clean line. */
276export const cleanDescription = (text: string): string | undefined => {
277 const line = text
278 .split('\n')
279 .map(l => l.trim())
280 .find(Boolean)
281 const out = (line ?? '')
282 .replace(/^(description|summary)\s*:\s*/i, '')
283 .replace(/^["'`*]+|["'`*]+$/g, '')
284 .replace(/\.$/, '')
285 .trim()
286 return out ? out.slice(0, 140) : undefined
287}
288
289export const describePrompt = (title: string | undefined, text: string) =>
290 'Below is the text of an HTML page someone made to review work with an AI assistant.\n' +
291 'Write ONE plain-English line, at most 12 words, saying what the page is for ' +
292 '(e.g. "Tracks launch tasks and who owns each"). No quotes, no trailing period, nothing else.\n\n' +
293 `Title: ${title ?? '(none)'}\n\nText:\n${text}`
294
295/** Where `desc` goes in a row: after the name, or nowhere when it would be a stub. */
296export const descFit = (desc: string | undefined, width: number): string | undefined => {
297 if (!desc || width < 14) return undefined
298 return desc.length <= width ? desc : desc.slice(0, width - 1) + '…'
299}
300types/index.d.ts 35 lines1export type LavishStatus = 'streaming' | 'idle' | 'closed' | 'dead' | 'pending' | 'missing'
2
3export type LavishPage = {
4 file: string
5 url?: string
6 key?: string
7 status: LavishStatus
8 endedBy?: string
9 prompts: number
10 /** Lavish's artifact_revision: how many times the page has been rewritten. */
11 rev?: number
12 /** When this session last touched the page (ms since epoch). */
13 seenAt: number
14 /** One line saying what the page is for: its meta description, else a Haiku summary. */
15 desc?: string
16 /** The file version (`mtimeMs:size`) `desc` was last checked against. */
17 descVer?: string
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'lavish-pages': {
23 pages: LavishPage[]
24 isHidden: boolean
25 /** null: automatic (expanded up to 3 pages, collapsed above). */
26 isExpanded: boolean | null
27 showClosed: boolean
28 /** Which version of the transcript backfill last ran for this session (0: none). */
29 scanVersion: number
30 /** .html files this session wrote (Write, Edit, shell redirection): pages it may own. */
31 authored: string[]
32 }
33 }
34}
35