/stock AAPL: a slim live row above the prompt with the price, the day's change and the last two hours' line, streamed from Yahoo Finance.

A Claude Code mod that keeps one stock above your prompt: a slim row with the live price, the day's change, and a line of the last two hours.

In a terminal session of Claude Code:
/plugin install stock-ticker --marketplace migmorales22/claude-stock-ticker
Answer y to add the marketplace, then pick a scope (user scope loads it in every session, the desktop app's Code tab included). Needs a Claude Code version with mods enabled. The live price needs Node.js 22 or newer on the machine; without it the row still works, updating every 15 seconds.
| Command | What it does |
|---|---|
/stock AAPL | Watch a stock. Any Yahoo Finance symbol: TSLA, BRK-B, ^GSPC, BTC-USD, EURUSD=X. |
/stock | Print the current quote in the chat. |
/stock off | Stop watching and hide the row (the ✕ on the row does the same). |
/stock debug | What the row last drew and whether the live stream is on. |
The symbol is remembered, so the row comes back in your next session.
stream/yahoo-stream.mjs, a small Node script that holds Yahoo Finance's price stream open and passes each new price on, about one a second for a busy stock. Each one moves the price and the end of the line. The script stops when the market closes, on /stock off, and with the session.offline if that fails too. While the market is closed it checks every 10 minutes./stock command you type.Prices come from Yahoo Finance's public chart endpoint and price stream. Both are unofficial and undocumented: no account or key, but they can change or stop without notice. US stocks are real time; some other exchanges are delayed. The only thing sent is the ticker symbol.
For information only, not financial advice.
claude plugin validate .
claude plugin test . # 6 tests, network and stream mocked
A note for anyone drawing SVG in a mod: the desktop app drew nothing at all, and logged nothing, for an early version whose SVG held a ▼ character, inline style= attributes and a <polyline>. Keeping the SVG plain ASCII (anything else as &#N;) and drawing glyphs as paths fixed it.
MIT
hooks/register.tsx 442 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MarketSession, Quote } from '../types'
5
6const quote = atom({ plugin: 'stock-ticker', key: 'quote' } as const, null)
7
8/** The watched symbol lives in the store, so the row comes back in the next session. */
9const SYMBOL_KEY = 'symbol'
10/** The chart's refresh while the stream is down, or not yet up. */
11const POLL_MS = 15_000
12/** While the stream carries the price, the chart is read only for the minute line. */
13const STREAMING_POLL_MS = 60_000
14/** A stream whose last price is older than this is not carrying the price. */
15const STREAM_FRESH_MS = 30_000
16/** After the stream could not start, the wait before trying again. */
17const STREAM_RETRY_MS = 5 * 60_000
18/** Outside pre-market to after-hours the price stands still: read it far less often. */
19const CLOSED_POLL_MS = 10 * 60_000
20/** Yahoo's symbols: AAPL, BRK-B, ^GSPC, BTC-USD, EURUSD=X, 7203.T. */
21const SYMBOL = /^\^?[A-Z0-9][A-Z0-9.=-]{0,14}$/
22const OFF = new Set(['OFF', 'STOP', 'HIDE', 'CLEAR'])
23/** As tall as savvy-progress's row, so the two sit together above the prompt. */
24const BAR_H = 22
25/** The line shows the last two hours, a point a minute: new minutes scroll it left, the live price moves its end. */
26const WINDOW = 120
27const UP = '#1f9d55'
28const DOWN = '#e5484d'
29const FLAT = '#8a8a86'
30const SPARK = '▁▂▃▄▅▆▇█'
31
32// The module's own: a reload drops it, and nothing is lost but a fetch in flight.
33let isFetching = false
34/** The last time the row above the prompt was drawn, for /stock debug. */
35const lastDraw = { count: 0, surface: '', hasSvg: false, hadQuote: false, error: '' }
36/** The live stream: its symbol, when its last price came, and whether it should keep running. */
37type Live = { symbol: string; isOn: boolean; lastAt: number }
38let live: Live | undefined
39/** When the stream last failed to start or ended by itself; 0 when it has not. */
40let liveFailedAt = 0
41
42const chartUrl = (symbol: string) =>
43 `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1m&range=1d`
44
45const clean = (s: unknown, max: number) =>
46 typeof s === 'string' ? s.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
47
48const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
49
50/** Escapes markup, and writes anything outside printable ASCII as a character reference: the SVG stays plain ASCII. */
51const xml = (s: string) => s.replace(/[&<>"']|[^\x20-\x7e]/gu, c => `&#${c.codePointAt(0)};`)
52
53/** Where the exchange is now, from the chart's trading periods (epoch seconds). */
54export function sessionAt(period: unknown, nowSec: number): MarketSession {
55 const p = period as Record<string, { start?: unknown; end?: unknown } | undefined> | undefined
56 const within = (k: string) => {
57 const start = num(p?.[k]?.start)
58 const end = num(p?.[k]?.end)
59 return start !== undefined && end !== undefined && nowSec >= start && nowSec < end
60 }
61 if (within('regular')) return 'open'
62 if (within('pre')) return 'pre'
63 if (within('post')) return 'post'
64 return 'closed'
65}
66
67/** A quote from Yahoo's chart answer, or why there is none. */
68export function parseChart(text: string, symbol: string, nowMs: number): Quote | string {
69 let data: unknown
70 try {
71 data = JSON.parse(text)
72 } catch {
73 return 'Yahoo Finance sent something that is not a quote'
74 }
75 const chart = (data as { chart?: { result?: unknown[] | null; error?: { description?: unknown } | null } }).chart
76 const r = chart?.result?.[0] as
77 | { meta?: Record<string, unknown>; indicators?: { quote?: { close?: unknown[] }[] } }
78 | undefined
79 if (r?.meta === undefined) {
80 return `no quote for ${symbol} on Yahoo Finance`
81 }
82 const m = r.meta
83 const price = num(m.regularMarketPrice)
84 const prevClose = num(m.chartPreviousClose) ?? num(m.previousClose)
85 if (price === undefined || prevClose === undefined || prevClose === 0) return `no price for ${symbol} right now`
86
87 // A minute with no trade comes back null: skip it.
88 const points = (r.indicators?.quote?.[0]?.close ?? []).map(num).filter((c): c is number => c !== undefined)
89 return {
90 symbol,
91 name: clean(m.shortName, 40) || clean(m.longName, 40) || symbol,
92 currency: clean(m.currency, 8) || 'USD',
93 price,
94 prevClose,
95 points: points.slice(-400),
96 session: sessionAt(m.currentTradingPeriod, Math.floor(nowMs / 1000)),
97 fetchedAt: nowMs,
98 isStale: false,
99 }
100}
101
102/** 1234.5 → "1,234.50"; under 1 keeps four decimals. */
103export function amount(n: number) {
104 const digits = Math.abs(n) < 1 ? 4 : 2
105 const [whole = '0', part = ''] = Math.abs(n).toFixed(digits).split('.')
106 return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${part}`
107}
108
109const PREFIX: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', INR: '₹', KRW: '₩' }
110
111export function price(q: Quote) {
112 if (q.currency === 'GBp') return `${amount(q.price)}p`
113 const prefix = PREFIX[q.currency]
114 return prefix === undefined ? `${amount(q.price)} ${q.currency}` : `${prefix}${amount(q.price)}`
115}
116
117export function change(q: Quote) {
118 const diff = q.price - q.prevClose
119 const pct = (diff / q.prevClose) * 100
120 const dir: 'up' | 'down' | 'flat' = Math.abs(pct) < 0.005 ? 'flat' : diff > 0 ? 'up' : 'down'
121 const sign = dir === 'up' ? '+' : dir === 'down' ? '-' : ''
122 return {
123 dir,
124 color: dir === 'up' ? UP : dir === 'down' ? DOWN : FLAT,
125 arrow: dir === 'up' ? '▲' : dir === 'down' ? '▼' : '■',
126 text: `${sign}${amount(Math.abs(diff))} (${sign}${Math.abs(pct).toFixed(2)}%)`,
127 }
128}
129
130const SESSION_LABEL: Record<MarketSession, string> = { open: 'open', pre: 'pre-market', post: 'after hours', closed: 'closed' }
131
132/** One line saying it all: the row's alt text and the command's answer. */
133export function describe(q: Quote) {
134 const c = change(q)
135 return `${q.symbol} ${price(q)} ${c.arrow} ${c.text} today, market ${SESSION_LABEL[q.session]}${q.isStale ? ', not updating' : ''}`
136}
137
138/** The line in block characters, for the terminal. */
139export function sparkText(points: number[], width: number) {
140 if (points.length < 2 || width < 2) return ''
141 const step = points.length / width
142 const picked = points.length <= width ? points : Array.from({ length: width }, (_, i) => points[Math.floor(i * step)] ?? 0)
143 const lo = Math.min(...picked)
144 const hi = Math.max(...picked)
145 return picked.map(p => SPARK[hi === lo ? 3 : Math.round(((p - lo) / (hi - lo)) * (SPARK.length - 1))]).join('')
146}
147
148/** About how wide `s` draws at `size` px in the system font. */
149const textWidth = (s: string, size: number) => s.length * size * 0.62
150
151/** The last two hours and the live price: what the line draws. */
152export const recent = (q: Quote) => [...q.points.slice(-WINDOW), q.price]
153
154/** One slim row: a live dot, symbol, price, the day's change, the last two hours' line, and the market state. */
155export function barSvg(q: Quote, W: number) {
156 const c = change(q)
157 const isLive = !q.isStale && q.session !== 'closed'
158 const font = `font-family="-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"`
159 const mid = BAR_H / 2
160 const baseline = 15.5
161 const priceText = price(q)
162 const symbolX = 14
163 const priceX = symbolX + textWidth(q.symbol, 13) + 8
164 const arrowX = priceX + textWidth(priceText, 13) + 10
165 const changeX = arrowX + 10
166 const badge = q.isStale ? 'offline' : SESSION_LABEL[q.session]
167 const sparkLeft = changeX + textWidth(c.text, 12) + 14
168 const sparkRight = W - textWidth(badge, 11) - 14
169 // The day's direction as a small triangle beside the change.
170 const arrow =
171 c.dir === 'up'
172 ? `M${arrowX} ${mid + 3.5}L${arrowX + 7} ${mid + 3.5}L${arrowX + 3.5} ${mid - 3}Z`
173 : c.dir === 'down'
174 ? `M${arrowX} ${mid - 3}L${arrowX + 7} ${mid - 3}L${arrowX + 3.5} ${mid + 3.5}Z`
175 : `M${arrowX} ${mid - 1.25}L${arrowX + 7} ${mid - 1.25}L${arrowX + 7} ${mid + 1.25}L${arrowX} ${mid + 1.25}Z`
176
177 let spark = ''
178 const pts = recent(q)
179 if (pts.length > 1 && sparkRight - sparkLeft >= 40) {
180 const lo = Math.min(...pts)
181 const hi = Math.max(...pts)
182 const top = 3
183 const bottom = BAR_H - 3
184 const y = (v: number) => (hi === lo ? mid : bottom - ((v - lo) / (hi - lo)) * (bottom - top))
185 const x = (i: number) => sparkLeft + (i / (pts.length - 1)) * (sparkRight - sparkLeft)
186 const line = pts.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' ')
187 // Yesterday's close, dashed, when the window reaches it.
188 const base =
189 q.prevClose >= lo && q.prevClose <= hi
190 ? `<line class="base" x1="${sparkLeft}" x2="${sparkRight.toFixed(1)}" y1="${y(q.prevClose).toFixed(1)}" y2="${y(q.prevClose).toFixed(1)}" stroke-width="1" stroke-dasharray="2 2"/>`
191 : ''
192 spark =
193 base +
194 `<path d="${line}" fill="none" stroke="${c.color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>` +
195 `<circle cx="${sparkRight.toFixed(1)}" cy="${y(q.price).toFixed(1)}" r="2.2" fill="${c.color}"/>`
196 }
197
198 return [
199 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BAR_H}" viewBox="0 0 ${W} ${BAR_H}">`,
200 '<style>.t{fill:#1f1f1f}.s{fill:#6b6b68}.base{stroke:#b9b9b4}.n{font-variant-numeric:tabular-nums}',
201 '@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.base{stroke:#5a5a57}}',
202 '.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}',
203 '@media (prefers-reduced-motion: reduce){.live{animation:none}}</style>',
204 `<circle${isLive ? ' class="live"' : ''} cx="5" cy="${mid}" r="3.5" fill="${isLive ? c.color : FLAT}"/>`,
205 `<text class="t" x="${symbolX}" y="${baseline}" ${font} font-size="13" font-weight="700">${xml(q.symbol)}</text>`,
206 `<text class="t n" x="${priceX.toFixed(1)}" y="${baseline}" ${font} font-size="13" font-weight="600">${xml(priceText)}</text>`,
207 `<path d="${arrow}" fill="${c.color}"/>`,
208 `<text class="n" x="${changeX.toFixed(1)}" y="${baseline}" fill="${c.color}" ${font} font-size="12" font-weight="600">${xml(c.text)}</text>`,
209 spark,
210 `<text class="s" x="${W - 2}" y="${baseline}" text-anchor="end" ${font} font-size="11">${badge}</text>`,
211 '</svg>',
212 ].join('')
213}
214
215async function watched($: EngineInterface): Promise<string | undefined> {
216 const got = await $.store.get(SYMBOL_KEY)
217 return typeof got === 'string' && SYMBOL.test(got) ? got : undefined
218}
219
220async function fetchQuote($: EngineInterface, symbol: string): Promise<Quote | string> {
221 try {
222 const r = await $.http.fetch(chartUrl(symbol), {
223 // Without a browser's User-Agent Yahoo answers 429 to every request.
224 headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' },
225 })
226 if (r.status === 429) return 'Yahoo Finance is busy; trying again shortly'
227 return parseChart(r.text, symbol, await $.clock.now())
228 } catch {
229 return 'could not reach Yahoo Finance'
230 }
231}
232
233/** One refresh: the watched symbol, read again unless the market is closed and the last read is recent. */
234async function tick($: EngineInterface) {
235 if (isFetching) return
236 isFetching = true
237 try {
238 const symbol = await watched($)
239 const shown = await read($, quote)
240 if (symbol === undefined) {
241 stopStream()
242 if (shown !== null) await update($, quote, () => null)
243 return
244 }
245 const now = await $.clock.now()
246 const isStreaming = live?.symbol === symbol && now - live.lastAt < STREAM_FRESH_MS
247 const every = shown?.session === 'closed' ? CLOSED_POLL_MS : isStreaming ? STREAMING_POLL_MS : POLL_MS - 1000
248 const isFresh = shown?.symbol === symbol && !shown.isStale && now - shown.fetchedAt < every
249 if (!isFresh) {
250 const got = await fetchQuote($, symbol)
251 // A failed refresh keeps the last good read on the row, marked as not updating; while the
252 // stream runs, its price is newer than the chart's.
253 if (typeof got === 'string') {
254 if (!isStreaming) await update($, quote, q => (q?.symbol === symbol ? { ...q, isStale: true } : q))
255 } else {
256 await update($, quote, q => (isStreaming && q?.symbol === symbol ? { ...got, price: q.price } : got))
257 }
258 }
259 // The stream runs while the market does: a closed one sends nothing.
260 const q = await read($, quote)
261 if (q?.symbol === symbol && q.session !== 'closed') ensureStream($, symbol, now)
262 else stopStream()
263 } finally {
264 isFetching = false
265 }
266}
267
268/** The node binary: on PATH, or where Homebrew and the installers put it. */
269async function findNode($: EngineInterface) {
270 const path = (await $.env.get('PATH')) ?? ''
271 for (const dir of [...path.split(':'), '/opt/homebrew/bin', '/usr/local/bin']) {
272 if (dir.startsWith('/') && (await $.fs.exists(`${dir}/node`))) return `${dir}/node`
273 }
274 return undefined
275}
276
277/** One line from the stream: a price for the watched symbol moves the row; anything else is ignored. */
278async function onPrice($: EngineInterface, mine: Live, line: string) {
279 let got: unknown
280 try {
281 got = JSON.parse(line)
282 } catch {
283 return
284 }
285 const { id, price } = (got ?? {}) as { id?: unknown; price?: unknown }
286 const value = num(price)
287 if (id !== mine.symbol || value === undefined || value <= 0) return
288 mine.lastAt = await $.clock.now()
289 await update($, quote, q => (q?.symbol === mine.symbol ? { ...q, price: value, isStale: false } : q))
290}
291
292/**
293 * Runs stream/yahoo-stream.mjs until `mine` is switched off or the child ends. Its pings
294 * (every 5 s) wake the loop, so switching off ends the child within seconds even when nothing trades.
295 */
296async function runStream($: EngineInterface, mine: Live) {
297 try {
298 const node = await findNode($)
299 if (node !== undefined) {
300 let pending = ''
301 const child = $.process.spawn({ argv: [node, `${$.plugin.root}/stream/yahoo-stream.mjs`, mine.symbol] })
302 for await (const piece of child) {
303 if (!mine.isOn) break
304 if (piece.stream !== 'stdout') continue
305 const lines = (pending + piece.text).split('\n')
306 pending = lines.pop() ?? ''
307 for (const line of lines) await onPrice($, mine, line)
308 }
309 }
310 } catch {
311 // It could not start: the 15 s refresh carries the price.
312 }
313 // Ended without being switched off: no node, or the child stopped. Wait before trying again.
314 if (mine.isOn) liveFailedAt = await $.clock.now()
315 if (live === mine) live = undefined
316}
317
318function stopStream() {
319 if (live !== undefined) live.isOn = false
320 live = undefined
321}
322
323/** Starts the stream for `symbol` unless it runs already, or failed a moment ago. */
324function ensureStream($: EngineInterface, symbol: string, now: number) {
325 if (live?.symbol === symbol) return
326 stopStream()
327 if (now - liveFailedAt < STREAM_RETRY_MS) return
328 const mine: Live = { symbol, isOn: true, lastAt: 0 }
329 live = mine
330 void runStream($, mine).catch(() => undefined)
331}
332
333async function stop($: EngineInterface) {
334 stopStream()
335 await $.store.delete(SYMBOL_KEY)
336 await update($, quote, () => null)
337}
338
339export const register: Register = on => {
340 on('session.start', async ($, e, next) => {
341 const started = await next(e)
342 await $.command.register({
343 name: 'stock',
344 description: 'Watch a stock in a slim row above the prompt: /stock AAPL. /stock off hides it',
345 })
346 $.clock.after(0, () => void tick($).catch(() => undefined))
347 $.clock.every(POLL_MS, () => void tick($).catch(() => undefined))
348 return started
349 })
350
351 on('command.run', { command: 'stock' }, async ($, e) => {
352 const typed = e.args.trim()
353 const arg = typed.toUpperCase()
354 if (arg === '') {
355 const q = await read($, quote)
356 return { text: q === null ? 'Not watching a stock. Try /stock AAPL; /stock off hides the row.' : describe(q) }
357 }
358 if (arg === 'DEBUG') {
359 const q = await read($, quote)
360 const d = lastDraw
361 const now = await $.clock.now()
362 return {
363 text: [
364 `quote: ${q === null ? 'none' : describe(q)}`,
365 `drawn ${d.count} times; last on ${d.surface || '-'}, Svg ${d.hasSvg ? 'available' : 'not available'}, ${d.hadQuote ? 'with' : 'without'} a quote`,
366 `last error: ${d.error || 'none'}`,
367 `stream: ${live === undefined ? 'off' : `on for ${live.symbol}, last price ${live.lastAt === 0 ? 'not yet' : `${Math.round((now - live.lastAt) / 1000)} s ago`}`}${liveFailedAt === 0 ? '' : `; last failed ${Math.round((now - liveFailedAt) / 1000)} s ago`}`,
368 ].join('\n'),
369 }
370 }
371 if (OFF.has(arg)) {
372 await stop($)
373 return { text: 'Stock row off.' }
374 }
375 if (!SYMBOL.test(arg)) return { text: `"${clean(typed, 20)}" is not a ticker symbol. Try /stock AAPL` }
376 const got = await fetchQuote($, arg)
377 if (typeof got === 'string') {
378 // Yahoo writes share classes with a dash.
379 const hint = /^[A-Z]+\.[A-Z]$/.test(arg) ? ` (try ${arg.replace('.', '-')})` : ''
380 return { text: `${got.charAt(0).toUpperCase()}${got.slice(1)}${hint}.` }
381 }
382 await $.store.set(SYMBOL_KEY, arg)
383 await update($, quote, () => got)
384 if (got.session !== 'closed') ensureStream($, arg, await $.clock.now())
385 else stopStream()
386 return {
387 text: `Watching ${got.name}: ${describe(got)}. Prices stream live from Yahoo Finance while the market trades (every 15 s if the stream is down) and can be delayed. /stock off hides the row.`,
388 }
389 })
390
391 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
392 if (e.props.hasSurvey) return next(e)
393 const q = await read($, quote)
394 const ui = $.ui.resolve(e)
395 lastDraw.count += 1
396 lastDraw.surface = e.surface
397 lastDraw.hasSvg = 'Svg' in ui
398 lastDraw.hadQuote = q !== null
399 if (q === null) return next(e)
400 const { Box, Button, Text } = ui
401 const below = await next(e)
402 try {
403 const close = <Button key="stock-off" label="✕" plain role="dismiss" onPress={() => stop($)} />
404
405 if (e.surface === 'desktop' && 'Svg' in ui) {
406 const { Svg } = ui
407 // About 8 CSS px per reported column, less the dismiss and its gap; as savvy-progress sizes its row.
408 const width = Math.max(260, Math.min(720, (e.props.bodyColumns || 100) * 8 - 64))
409 return (
410 <Box flexDirection="column">
411 <Box flexDirection="row" alignItems="center" gap={1}>
412 <Svg source={barSvg(q, width)} alt={describe(q)} width={width} height={BAR_H} />
413 {close}
414 </Box>
415 {below}
416 </Box>
417 )
418 }
419
420 const c = change(q)
421 const spark = sparkText(recent(q), Math.max(0, Math.min(24, e.props.bodyColumns - 48)))
422 return (
423 <Box flexDirection="column">
424 <Box flexDirection="row" gap={1}>
425 <Text bold>{q.symbol}</Text>
426 <Text>{price(q)}</Text>
427 <Text color={c.color}>{`${c.arrow} ${c.text}`}</Text>
428 {spark !== '' && <Text color={c.color}>{spark}</Text>}
429 <Text dimColor>{q.isStale ? 'offline' : SESSION_LABEL[q.session]}</Text>
430 {close}
431 </Box>
432 {below}
433 </Box>
434 )
435 } catch (err) {
436 // A drawing that failed leaves the row to the plugins beneath; /stock debug says why.
437 lastDraw.error = String(err).slice(0, 300)
438 return below
439 }
440 })
441}
442types/index.d.ts 31 lines1/** Where the exchange is in its day when the quote was read. */
2export type MarketSession = 'pre' | 'open' | 'post' | 'closed'
3
4/** One read of a stock, as the tile draws it. */
5export type Quote = {
6 /** The symbol as watched, upper case (`AAPL`, `BRK-B`, `^GSPC`). */
7 symbol: string
8 /** The exchange's short name for it (`Apple Inc.`). */
9 name: string
10 currency: string
11 price: number
12 /** Yesterday's close: what the day's change is measured from. */
13 prevClose: number
14 /** Today's closes, a minute apart, oldest first. */
15 points: number[]
16 session: MarketSession
17 /** When the quote was read (epoch ms). */
18 fetchedAt: number
19 /** True once a refresh failed: the tile still shows the last good read. */
20 isStale: boolean
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'stock-ticker': {
26 /** What the tile shows; null when nothing is watched. */
27 quote: Quote | null
28 }
29 }
30}
31