Stock and crypto quotes above the Claude Code prompt, with a /pulse panel to manage your watchlist.

Market quotes above your Claude Code prompt. Open /pulse to manage the watchlist of stocks, indices and crypto pairs while you work.

Requires Claude Code 2.1.287 or later, with Mods enabled. The ticker and panel support the terminal and Claude Desktop's Code tab in local sessions. Headless (claude -p / Agent SDK) sessions do not poll or draw a ticker; Claude Desktop starts polling when its window attaches to the session.
claude plugin marketplace add fatwang2/Pulse
claude plugin install pulse-cc@pulse
Restart Claude Code or run /reload-plugins, then add tickers:
/pulse add AAPL
/pulse add NVDA
/pulse add MSFT
/pulse add BTC/USDT
/pulse
The initial watchlist is empty. Add ticker symbols, not company names. Examples: AAPL, BRK-B, 0700.HK, 600519.SS, 7203.T, ^GSPC. Hong Kong leading zeros are normalized; Shanghai .SH is accepted as an alias for .SS. The quote source's coverage determines which tickers return a quote. Unknown tickers stay visible with an error so you can correct or remove them.
Add crypto as a pair written BASE/QUOTE: BTC/USDT, ETH/USDT, SOL/USDC. Stocks and crypto share one list and one band. A symbol with a dash, such as BTC-USD, is read as a ticker rather than a pair.
To update an installed plugin:
claude plugin marketplace update pulse
claude plugin update pulse-cc@pulse
Restart Claude Code or run /reload-plugins to apply the update.
| Command | Action |
|---|---|
/pulse | Open or close the panel |
/pulse add <ticker> | Add a ticker |
/pulse remove <ticker> | Remove a ticker |
/pulse refresh | Request one refresh round, including while paused |
/pulse off | Pause automatic refresh; keep the watchlist and cached quotes |
/pulse on | Resume automatic refresh |
/pulse help | Show command usage |
The panel header shows the watchlist size with Refresh and Pause beside it and a colored status line below. An add field sits above the watchlist. Each row shows the ticker and its name, the price and change on the right, and a Remove button. Rows carry no per-row clock; a quote from an earlier day, such as the last close over a weekend, is labelled with its date so it never reads as today's. Narrow panes stack the price below the ticker. Add with Enter or the field's submit button. Press r or p while the panel has focus; input fields keep ordinary typing. Press Escape to return to the prompt. The panel uses native controls on Claude Desktop and text controls in the terminal; both draw the same layout. The footer links to pulseticker.app.
In the terminal, a fullscreen session docks the panel beside the conversation:

In Claude Desktop's Code tab, the band sits above the prompt and the panel opens beside the chat with native buttons:

The band shows up to five tickers in saved order, fitting fewer in a narrow terminal. A +N more indicator points to the rest in /pulse. Gains are green, losses red, and percentages always include their sign. USD is omitted in this compact band; other currencies remain visible. The panel includes currency. Neither surface displays charts or mini trends. Opening the panel does not trigger an extra quote request.
Pulse CC refreshes 60 seconds after each round. Stock requests are spaced at least one second apart, so a large watchlist takes longer to complete a round; crypto pairs are read together in one request.
Stock and index quotes come from Yahoo Finance.
—.Retry-After (at least 60 seconds) and repeated throttling backs off. Manual refresh respects this cooldown too.Yahoo's chart endpoint is unofficial, has no availability guarantee, and may change or be unavailable in some regions. Software's MIT license does not grant rights to Yahoo's market data. These quotes are for informational use.
data-api.binance.vision). No API key or account.The plugin saves Yahoo ticker symbols, Binance pairs and the paused setting in Claude Code's local plugin store (watchlist.v1). That store is shared by your local sessions. Edits in one session are picked up by another at its next refresh; concurrent writes across processes are last-writer-wins. Quote caches are in memory and are fetched again in a new session.
Watching a ticker sends it to Yahoo Finance and watching a pair sends it to Binance. The plugin has no analytics, account system, external backend, filesystem scanner, or model calls. It does not add quotes or your watchlist to model prompts. A command's short reply, such as Added AAPL., is visible in the conversation. See the Pulse privacy policy.
Versions before 0.4.0 could also display Pulse Mac watchlists through a bundled MCP connection. 0.4.0 removes that mode, the connection and its token option; a session that had Pulse Mac selected shows your own watchlist, which was kept separately all along. The old display setting (mac-view.v1) is left unread.
Pulse CC is a Claude Code mod: functions in hooks/register.tsx that Claude Code calls in its own process. Everything it does goes through the mods API, and every host call is written out in that one file.
session.start: registers the /pulse command and, in a terminal session, starts the quote refresh.session.attach and session.detach (Claude Desktop only): start the refresh when a Desktop window attaches and stop it when the last one detaches.session.end: stops the refresh.command.run, matched to /pulse only: the one call it sees is the person typing /pulse. It reads the arguments (add, remove, refresh, off, on, help or none), updates the watchlist in the plugin store or opens and closes the panel, and answers with one short line such as Added AAPL.. It never runs, rewrites or forwards the command, and never sees other commands.ui.render for AbovePrompt and for its own pulse-quotes pane: draws the quote band above the prompt and the /pulse panel.It has no hook on Claude's tool calls, prompts, permission checks or messages, so it never sees, changes or approves them.
HTTPS GET requests, at two calls, each with its https address and its options object written at the call:
https://query1.finance.yahoo.com/v8/finance/chart/<symbol>?interval=1d&range=1d&includePrePost=false for each watched stock or index, at least one second apart.https://data-api.binance.vision/api/v3/ticker/24hr?symbols=<pairs> for the watched crypto pairs, up to 100 pairs per request.They run once per refresh round, 60 seconds after the previous round ends, while the band or panel can show prices, plus rounds you request with Refresh. Responses are read only as quote data: nothing in them is run, opened or treated as a command.
query1.finance.yahoo.com (Yahoo Finance) and data-api.binance.vision (Binance). No other hosts.Pulse-CC/<version> user agent and Accept: application/json. No token, cookie, account, watchlist name or other data.https://www.pulseticker.app/ opens in your browser only when you click it; the mod sends nothing there itself.Nothing. It calls no tools (no shell, agent or MCP tool), runs no commands, starts no processes and spawns no agents.
Its plugin store holds the watchlist and paused setting described above. It has no configuration options and reads no environment variables, files or credentials.
From the Pulse repository root, load a local checkout for one session:
claude --plugin-dir ./plugins/claude-code
This works before the marketplace is published and does not install the plugin. To install from a local checkout instead:
claude plugin marketplace add .
claude plugin install pulse-cc@pulse
Keep that checkout in place for updates. GitHub installation uses the published marketplace and plugin files on the repository's default branch.
Run the native checks:
claude plugin validate plugins/claude-code
claude plugin validate .
claude plugin test plugins/claude-code
Run these commands from the repository root. Tests use Claude Code's native Mods harness: mocked host network, local store and clock, plus terminal and Desktop drawing/input checks. They make no model calls or live requests.
The plugin ID is pulse-cc; the display title and command are Pulse and /pulse.
For type checking, first load this plugin with --plugin-dir so Claude Code generates your installed version's declarations in .claude-plugin/types/, then:
npm exec --yes --package=typescript@5.9.3 -- tsc -p plugins/claude-code
Generated declarations stay local and are ignored by Git. All host API calls live in hooks/register.tsx; watchlist.ts manages lifecycle, yahoo.ts and binance.ts decode stock and crypto quotes. The implementation was written independently; it does not include code from meme-watch.
MIT. See LICENSE.
hooks/register.tsx 253 lines1import type { EngineInterface, Register } from 'claude-code'
2import { SETTINGS_KEY, Watchlist } from './watchlist'
3import { formatChange, formatPrice, formatBandPrice, formatStaleDate } from './yahoo'
4import { isCryptoPair } from './binance'
5
6export const PANE = 'pulse-quotes'
7const HELP = '/pulse · /pulse add AAPL · /pulse remove AAPL · /pulse refresh · /pulse off · /pulse on'
8
9export const register: Register = on => {
10 const intervalSeconds = 60
11 const watch = new Watchlist(intervalSeconds * 1000)
12 let input = ''
13 // Desktop keeps a field's typed text while the drawn value stays '', so a
14 // cleared field is redrawn under a new key to discard it.
15 let fieldRound = 0
16 let error = ''
17
18 const act = async (action: () => Promise<unknown>) => {
19 error = ''
20 try { await action() } catch (cause) {
21 error = cause instanceof Error ? cause.message : 'Could not save settings. Please try again.'
22 }
23 }
24
25 // The terminal REPL draws from session.start. Claude Desktop runs a headless
26 // session and attaches later; a plain -p run never draws and never polls.
27 let interactive = false
28 const desktops = new Set<string>()
29 on('session.start', async ($, e, next) => {
30 await $.command.register({
31 name: 'pulse', description: 'Watch stock and crypto quotes above the prompt',
32 argumentHint: '[add <ticker> | remove <ticker> | refresh | off | on]',
33 immediate: true,
34 })
35 interactive = e.isInteractive
36 if (interactive) await startWatch($, watch)
37 return next(e)
38 })
39
40 on('session.attach', { surface: 'desktop' }, async ($, e, next) => {
41 // A second desktop window shares the running refresh loop.
42 const first = !interactive && !desktops.size
43 desktops.add(e.clientId)
44 if (first) await startWatch($, watch)
45 return next(e)
46 })
47
48 on('session.detach', { surface: 'desktop' }, async ($, e, next) => {
49 if (desktops.delete(e.clientId) && !interactive && !desktops.size) watch.stop()
50 return next(e)
51 })
52
53 on('session.end', async ($, e, next) => {
54 // /clear and /resume end a conversation, not the loaded mod's session.
55 if (e.reason !== 'clear' && e.reason !== 'resume') {
56 watch.stop()
57 interactive = false
58 desktops.clear()
59 }
60 return next(e)
61 })
62
63 on('command.run', { command: 'pulse' }, async ($, e) => {
64 const args = e.args.trim().split(/\s+/).filter(Boolean)
65 try {
66 if (!args.length) {
67 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
68 await $.ui.close({ id: PANE })
69 return { text: 'Pulse panel closed.' }
70 }
71 await $.ui.open({ id: PANE, title: 'Pulse', focus: true, columns: 80,
72 rows: Math.min(30, Math.max(22, watch.preferences.symbols.length * 3 + 20)) })
73 return { text: 'Pulse panel opened.' }
74 }
75 if (args[0] === 'add' && args.length === 2) return { text: await watch.add(args[1]) }
76 if (args[0] === 'remove' && args.length === 2) return { text: await watch.remove(args[1]) }
77 if (args[0] === 'off' && args.length === 1) return { text: await watch.pause(true) }
78 if (args[0] === 'on' && args.length === 1) return { text: await watch.pause(false) }
79 if (args[0] === 'refresh' && args.length === 1) {
80 // Refresh on a background timer, so a long watchlist never blocks input.
81 $.clock.after(0, () => watch.refresh())
82 return { text: 'Refresh requested.' }
83 }
84 return { text: HELP }
85 } catch (cause) {
86 return { text: cause instanceof Error ? cause.message : 'Could not save settings. Please try again.' }
87 }
88 })
89
90 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91 if (e.props.hasSurvey) return next(e)
92 const { Box, Text } = $.ui.resolve(e)
93 const width = Math.max(20, e.props.bodyColumns || e.viewport?.columns || 80)
94 const state = watch.preferences.paused ? 'Paused' : ''
95 const symbols = watch.preferences.symbols
96 const label = (symbol: string) => {
97 const quote = watch.quotes[symbol]
98 const cached = watch.errors[symbol] && quote ? ' cached' : ''
99 return quote ? `${symbol} ${formatBandPrice(quote)} ${formatChange(quote.changePercent)}${cached}` : `${symbol} unavailable`
100 }
101 const more = (list: string[]) => list.length < symbols.length ? ` +${symbols.length - list.length} more`.length : 0
102 const fits = (list: string[]) => 8 + (state ? state.length + 3 : 0) + list.map(symbol => label(symbol).length).reduce((a, b) => a + b, 0)
103 + Math.max(0, list.length - 1) * 3 + more(list) <= width
104 let visible = symbols.slice(0, 5)
105 while (visible.length > 1 && !fits(visible)) visible = visible.slice(0, -1)
106 return <Box><Text wrap="truncate">
107 <Text dimColor>Pulse</Text>
108 {state && <Text color="warning">{` ${state}`}</Text>}{' '}
109 {!visible.length && <Text dimColor>Add a ticker with /pulse add AAPL</Text>}
110 {visible.map((symbol, index) => {
111 const quote = watch.quotes[symbol]
112 const failed = !!watch.errors[symbol]
113 return <Text key={symbol}>
114 {index ? <Text dimColor>{' · '}</Text> : ''}<Text bold>{symbol}</Text>{' '}
115 {quote ? <Text>
116 <Text dimColor={failed}>{formatBandPrice(quote)}</Text>{' '}
117 <Text color={changeColor(quote.changePercent)}>{formatChange(quote.changePercent)}</Text>
118 {failed && <Text dimColor>{' cached'}</Text>}
119 </Text> : <Text dimColor>{failed ? 'unavailable' : '…'}</Text>}
120 </Text>
121 })}
122 {visible.length < symbols.length && <Text dimColor>{` +${symbols.length - visible.length} more`}</Text>}
123 </Text></Box>
124 })
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
127 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
128 const { Box, Text, Button, Input, Link } = $.ui.resolve(e)
129 // Desktop measures cells in its code font but draws proportional text, so
130 // its rows fit side by side at fewer reported columns than the terminal's.
131 const compact = e.props.bodyColumns < (e.surface === 'desktop' ? 44 : 64)
132 // Desktop draws native controls (hotkey badges, the field's own submit
133 // button, a close button); the terminal spells those out in text.
134 const desktop = e.surface === 'desktop'
135 const redraw = () => $.ui.invalidate('ui.render')
136 const action = (task: () => Promise<unknown>) => { void act(task).finally(redraw) }
137 const addTicker = () => action(async () => { await watch.add(input); input = ''; fieldRound++ })
138 const paused = watch.preferences.paused
139 const statusColor = paused ? 'warning' : watch.loading ? 'cyan' : 'success'
140 const status = paused ? '● Auto-refresh paused' : watch.loading ? '● Refreshing quotes…' : `● Auto-refresh every ${intervalSeconds}s`
141 const ccCount = watch.preferences.symbols.length
142 const now = await $.clock.now()
143
144 const section = (title: string) => <Text dimColor bold>{title.toUpperCase()}</Text>
145 // Spacing is in cells. Desktop converts a cell to a code-font character, far
146 // smaller than a terminal row, so it needs more of them for the same air. A
147 // docked terminal pane has the full height; an inline one shares the prompt's.
148 // Desktop draws a horizontal cell only a few pixels wide, so its horizontal
149 // padding takes several.
150 const space = desktop ? 3 : e.props.placement === 'dock' ? 2 : 1
151 const gap = desktop ? 1 : 0
152 const rowGap = desktop ? 2 : 1
153 // Desktop text fields keep a fixed width (their submit button included), so
154 // the insets stay small enough for the add field to fit a docked pane.
155 const padX = desktop ? 4 : 1
156
157 return <Box flexDirection="column" paddingX={padX} paddingY={desktop ? 2 : 0}>
158 <Box key="status-section" flexDirection="column">
159 <Box justifyContent="space-between" alignItems="center" gap={2} flexWrap="wrap">
160 {section(`Watchlist · ${ccCount} ${ccCount === 1 ? 'ticker' : 'tickers'}`)}
161 {!desktop && <Box key="watchlist-actions" gap={1}>
162 <Button key="refresh" label="Refresh (r)" hotkey="r" dimColor onPress={() => action(() => watch.refresh())} />
163 <Button key="pause" label={paused ? 'Resume (p)' : 'Pause (p)'} hotkey="p" dimColor
164 onPress={() => action(() => watch.pause(!paused))} />
165 </Box>}
166 </Box>
167 <Box marginTop={gap}><Text key="refresh-status" color={statusColor}>{status}</Text></Box>
168 {desktop && <Box key="watchlist-actions" gap={1} marginTop={rowGap}>
169 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => action(() => watch.refresh())} />
170 <Button key="pause" label={paused ? 'Resume' : 'Pause'} hotkey="p" onPress={() => action(() => watch.pause(!paused))} />
171 </Box>}
172 </Box>
173
174 <Box key={`add-section-${fieldRound}`} gap={1} alignItems="center" marginTop={rowGap}>
175 <Box flexGrow={1} flexShrink={1}>
176 <Input key="add-ticker" placeholder={compact ? 'AAPL, BTC/USDT' : 'Add a ticker: AAPL, 0700.HK, BTC/USDT'} value={input}
177 submitLabel={desktop ? 'Add' : 'add'} autoFocus onInput={value => { input = value }}
178 onSubmit={value => { input = value; addTicker() }} />
179 </Box>
180 {!desktop && <Button key="add" label="Add" onPress={addTicker} />}
181 </Box>
182 {error && <Box marginTop={rowGap}><Text color="error" wrap="wrap">{error}</Text></Box>}
183 {watch.message && <Box marginTop={1}>
184 <Text color={watch.message.includes('rate limited') ? 'warning' : undefined} dimColor={!watch.message.includes('rate limited')} wrap="wrap">{watch.message}</Text>
185 </Box>}
186
187 <Box key="watchlist" flexDirection="column" marginTop={rowGap}>
188 {!ccCount && <Text dimColor wrap="wrap">Your watchlist is empty. Add a ticker above.</Text>}
189 {watch.preferences.symbols.map((symbol, index) => {
190 const quote = watch.quotes[symbol]
191 const failed = watch.errors[symbol]
192 const price = <Text dimColor={!quote || !!failed} wrap="truncate">
193 {quote ? formatPrice(quote) : failed ? 'Unavailable' : paused ? 'Not fetched' : 'Waiting…'}
194 </Text>
195 const change = <Text color={quote ? changeColor(quote.changePercent) : undefined} dimColor={!quote}>
196 {quote ? formatChange(quote.changePercent) : '—'}
197 </Text>
198 const stale = quote ? formatStaleDate(quote.timestamp, now) : ''
199 const meta = quote ? [quote.name, stale && `As of ${stale}`,
200 !!quote.delaySeconds && `Delay ${Math.ceil(quote.delaySeconds / 60)}m`].filter(Boolean).join(' · ') : ''
201 // minWidth 0 lets a long name truncate instead of pushing the row's
202 // button out of a narrow Desktop pane (flex items default to content width).
203 return <Box key={`ticker-${symbol}`} flexDirection="column" width="100%" marginTop={index ? rowGap : 0}>
204 <Box gap={2} alignItems="center" width="100%">
205 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
206 <Text bold wrap="truncate">{symbol}</Text>
207 {!compact && meta && <Text dimColor wrap="truncate">{meta}</Text>}
208 </Box>
209 {!compact && <Box flexDirection="column" alignItems="flex-end" flexShrink={0}>{price}{change}</Box>}
210 <Button key={`remove-${symbol}`} label="Remove" dimColor={!desktop} onPress={() => action(() => watch.remove(symbol))} />
211 </Box>
212 {compact && <Box gap={2}>{price}{change}</Box>}
213 {compact && meta && <Text dimColor wrap="truncate">{meta}</Text>}
214 {failed && <Text color="warning" wrap="wrap">{quote ? 'Cached quote · ' : ''}{failed}</Text>}
215 </Box>
216 })}
217 </Box>
218
219 <Box key="footer" flexDirection="column" marginTop={space}>
220 <Text dimColor wrap="wrap">
221 {watch.preferences.symbols.some(isCryptoPair)
222 ? 'Yahoo Finance · Binance Spot · Stocks: regular session, exchange delays vary · Crypto: 24h change · Times are local · '
223 : 'Yahoo Finance · Regular-session quotes · Exchange delays vary · Times are local · '}
224 <Link href="https://www.pulseticker.app/">pulseticker.app</Link>
225 </Text>
226 {!desktop && <Text dimColor wrap="wrap">Tab: move between controls · Enter: activate · Esc: return to prompt</Text>}
227 </Box>
228 </Box>
229 })
230}
231
232function changeColor(change: number | null): string | undefined {
233 return change === null ? undefined : change >= 0 ? 'green' : 'red'
234}
235
236function startWatch($: EngineInterface, watch: Watchlist): Promise<void> {
237 return watch.start({
238 load: () => $.store.get(SETTINGS_KEY),
239 save: preferences => $.store.set(SETTINGS_KEY, preferences),
240 now: () => $.clock.now(),
241 sleep: ms => $.clock.sleep(ms),
242 after: (ms, callback) => $.clock.after(ms, callback),
243 // The only two hosts the mod contacts, each written whole at its call.
244 fetchYahoo: path => $.http.fetch(`https://query1.finance.yahoo.com/v8/finance/chart/${path}`, {
245 headers: { 'User-Agent': 'Pulse-CC/0.4.1', Accept: 'application/json' },
246 }),
247 fetchBinance: query => $.http.fetch(`https://data-api.binance.vision/api/v3/ticker/24hr?${query}`, {
248 headers: { 'User-Agent': 'Pulse-CC/0.4.1', Accept: 'application/json' },
249 }),
250 redraw: () => $.ui.invalidate('ui.render'),
251 })
252}
253hooks/watchlist.ts 281 lines1import type { HttpResponse, Timer } from 'claude-code'
2import { decodeQuote, normalizeSymbol, quotePath, YahooError } from './yahoo'
3import type { Quote } from './yahoo'
4import { BINANCE_BATCH, BinanceError, decodeTickers, isCryptoPair, tickersQuery } from './binance'
5
6export const SETTINGS_KEY = 'watchlist.v1'
7export type Preferences = { version: 1; symbols: string[]; paused: boolean }
8export type Host = {
9 load: () => Promise<unknown>
10 save: (preferences: Preferences) => Promise<void>
11 now: () => Promise<number>
12 sleep: (ms: number) => Promise<void>
13 after: (ms: number, callback: () => Promise<void>) => Timer
14 fetchYahoo: (path: string) => Promise<HttpResponse>
15 fetchBinance: (query: string) => Promise<HttpResponse>
16 redraw: () => void
17}
18
19export function readPreferences(value: unknown): Preferences {
20 const saved = value as Partial<Preferences> | null
21 const symbols: string[] = []
22 if (saved?.version === 1 && Array.isArray(saved.symbols)) {
23 for (const raw of saved.symbols) {
24 if (typeof raw !== 'string') continue
25 try {
26 const symbol = normalizeSymbol(raw)
27 if (!symbols.includes(symbol)) symbols.push(symbol)
28 } catch { /* Ignore invalid saved entries, not the whole watchlist. */ }
29 }
30 }
31 return { version: 1, symbols, paused: saved?.version === 1 && saved.paused === true }
32}
33
34/** One controller per loaded mod; host APIs provide network, timers and storage. */
35export class Watchlist {
36 preferences: Preferences = { version: 1, symbols: [], paused: false }
37 quotes: Record<string, Quote> = {}
38 errors: Record<string, string> = {}
39 message = ''
40 loading = false
41 retryAt = 0
42 private api?: Host
43 private timer?: Timer
44 private active = false
45 private revision = 0
46 private job?: Promise<void>
47 private pending = false
48 private pendingManual = false
49 private lastRequestAt = -Infinity
50 private rateFailures = 0
51 private writes: Promise<unknown> = Promise.resolve()
52
53 constructor(private readonly intervalMs: number) {}
54
55 async start(api: Host): Promise<void> {
56 this.stop()
57 this.api = api
58 this.preferences = readPreferences(await api.load())
59 this.reconcile()
60 this.active = true
61 this.changed()
62 this.schedule(0)
63 }
64
65 stop(): void {
66 this.active = false
67 this.revision++
68 this.pending = false
69 this.pendingManual = false
70 this.timer?.cancel()
71 this.timer = undefined
72 this.loading = false
73 }
74
75 private changed(): void { this.api?.redraw() }
76
77 private schedule(delay: number, manual = false): void {
78 this.timer?.cancel()
79 this.timer = undefined
80 if (!this.active || (!manual && (this.preferences.paused || !this.preferences.symbols.length))) return
81 if (this.job) { this.pending = true; this.pendingManual ||= manual; return }
82 this.timer = this.api!.after(delay, async () => {
83 this.timer = undefined
84 await this.refresh(manual)
85 })
86 }
87
88 private edit(change: (preferences: Preferences) => string): Promise<string> {
89 const previous = this.writes
90 const action = (async () => {
91 try { await previous } catch { /* A failed write does not block the next. */ }
92 if (!this.api) throw new Error('Pulse has not started yet.')
93 // Read before writing so a second session's latest settings are retained.
94 const preferences = readPreferences(await this.api.load())
95 const message = change(preferences)
96 await this.api.save(preferences)
97 this.preferences = preferences
98 this.revision++
99 this.message = message
100 this.reconcile()
101 this.changed()
102 this.schedule(0)
103 return message
104 })()
105 this.writes = action
106 return action
107 }
108
109 add(input: string): Promise<string> {
110 const symbol = normalizeSymbol(input)
111 return this.edit(preferences => {
112 if (preferences.symbols.includes(symbol)) return `${symbol} is already on your watchlist.`
113 preferences.symbols.push(symbol)
114 return `Added ${symbol}.`
115 })
116 }
117
118 remove(input: string): Promise<string> {
119 const symbol = normalizeSymbol(input)
120 return this.edit(preferences => {
121 if (!preferences.symbols.includes(symbol)) return `${symbol} is not on your watchlist.`
122 preferences.symbols = preferences.symbols.filter(value => value !== symbol)
123 return `Removed ${symbol}.`
124 })
125 }
126
127 pause(paused: boolean): Promise<string> {
128 return this.edit(preferences => {
129 preferences.paused = paused
130 return paused ? 'Automatic refresh paused.' : 'Automatic refresh resumed.'
131 })
132 }
133
134 private reconcile(): void {
135 const symbols = new Set(this.preferences.symbols)
136 for (const record of [this.quotes, this.errors]) for (const key of Object.keys(record)) if (!symbols.has(key)) delete record[key]
137 }
138
139 async refresh(manual = true): Promise<void> {
140 if (!this.active || !this.api) return
141 if (this.job) return this.job
142 this.timer?.cancel()
143 this.timer = undefined
144 this.pending = false
145 this.pendingManual = false
146 // Claim the job before the first await; simultaneous refreshes share it.
147 this.job = this.runRefresh(manual).catch(() => {
148 this.message = 'Refresh failed. Try again with /pulse refresh.'
149 this.changed()
150 })
151 try { await this.job } finally {
152 this.job = undefined
153 this.loading = false
154 this.changed()
155 const now = await this.api.now()
156 const cooldown = Math.max(0, this.retryAt - now)
157 // Binance pairs keep their cadence through a Yahoo cooldown; Yahoo still waits.
158 const crypto = this.preferences.symbols.some(isCryptoPair)
159 if (this.pending) this.schedule(crypto ? 0 : cooldown, this.pendingManual)
160 else this.schedule(Math.max(crypto ? 0 : cooldown, this.intervalMs))
161 }
162 }
163
164 private async runRefresh(manual: boolean): Promise<void> {
165 const now = await this.api!.now()
166 if (now < this.retryAt) {
167 // Binance pairs still refresh; Yahoo tickers wait out the cooldown.
168 if (this.preferences.symbols.some(isCryptoPair)) await this.poll(manual, false)
169 this.message = `Yahoo rate limited requests. Retry after ${Math.ceil((this.retryAt - now) / 1000)}s.`
170 this.changed()
171 return
172 }
173 await this.poll(manual)
174 }
175
176 private async request(path: string, current: () => boolean) {
177 const api = this.api!
178 const wait = 1000 - ((await api.now()) - this.lastRequestAt)
179 if (wait > 0) await api.sleep(wait)
180 if (!current()) return undefined
181 this.lastRequestAt = await api.now()
182 if (!current()) return undefined
183 return api.fetchYahoo(path)
184 }
185
186 private async fetchQuote(symbol: string, current: () => boolean): Promise<void> {
187 const response = await this.request(quotePath(symbol), current)
188 if (!response || !current()) return
189 const now = await this.api!.now()
190 if (!current()) return
191 this.quotes[symbol] = decodeQuote(symbol, response, now)
192 delete this.errors[symbol]
193 }
194
195 private async throttle(cause: unknown): Promise<boolean> {
196 if (!(cause instanceof YahooError) || cause.retryAfterMs === undefined) return false
197 this.rateFailures++
198 const backoff = Math.min(900_000, 60_000 * 2 ** Math.min(this.rateFailures - 1, 4))
199 this.retryAt = (await this.api!.now()) + Math.max(backoff, cause.retryAfterMs)
200 this.message = 'Yahoo rate limited requests. Automatic retries are delayed.'
201 return true
202 }
203
204 private async poll(manual: boolean, yahoo = true): Promise<void> {
205 const api = this.api!
206 const revision = this.revision
207 const saved = readPreferences(await api.load())
208 if (!this.active || revision !== this.revision) return
209 // Pick up changes made in another local Claude Code session.
210 this.preferences = saved
211 this.reconcile()
212 if (saved.paused && !manual) { this.changed(); return }
213 this.loading = true
214 this.message = ''
215 this.changed()
216 const current = () => this.active && revision === this.revision
217 const pairs = saved.symbols.filter(isCryptoPair)
218 const tickers = saved.symbols.filter(symbol => !isCryptoPair(symbol))
219 for (let offset = 0; offset < pairs.length; offset += BINANCE_BATCH) {
220 await this.pollCrypto(pairs.slice(offset, offset + BINANCE_BATCH), current)
221 if (!current()) return
222 }
223 if (!yahoo) return
224 for (const symbol of tickers) {
225 if (!this.active || revision !== this.revision) return
226 try {
227 await this.fetchQuote(symbol, () => this.active && revision === this.revision)
228 } catch (error) {
229 if (!this.active || revision !== this.revision) return
230 // Do not echo request URLs or raw server bodies into the conversation.
231 this.errors[symbol] = error instanceof YahooError ? error.message : 'Could not reach Yahoo Finance.'
232 if (await this.throttle(error)) {
233 const deferred = tickers.slice(tickers.indexOf(symbol) + 1)
234 for (const remaining of deferred) this.errors[remaining] = 'Refresh deferred: Yahoo rate limited requests.'
235 this.changed()
236 return
237 }
238 }
239 this.changed()
240 }
241 if (!this.active || revision !== this.revision) return
242 this.rateFailures = 0
243 this.retryAt = 0
244 }
245
246 /** One request per batch; an unknown or delisted pair fails the batch, so retry pair by pair. */
247 private async pollCrypto(pairs: string[], current: () => boolean): Promise<void> {
248 try {
249 await this.fetchCrypto(pairs, current)
250 } catch (error) {
251 if (!current()) return
252 if (error instanceof BinanceError && error.invalidSymbol && pairs.length > 1) {
253 for (const pair of pairs) {
254 if (!current()) return
255 try { await this.fetchCrypto([pair], current) } catch (cause) {
256 if (!current()) return
257 this.errors[pair] = cause instanceof BinanceError ? cause.message : 'Could not reach Binance.'
258 }
259 }
260 } else {
261 // Do not echo request URLs or raw server bodies into the conversation.
262 for (const pair of pairs) this.errors[pair] = error instanceof BinanceError ? error.message : 'Could not reach Binance.'
263 }
264 }
265 if (current()) this.changed()
266 }
267
268 private async fetchCrypto(pairs: string[], current: () => boolean): Promise<void> {
269 const api = this.api!
270 const response = await api.fetchBinance(tickersQuery(pairs))
271 if (!current()) return
272 const quotes = decodeTickers(pairs, response, await api.now())
273 if (!current()) return
274 for (const pair of pairs) {
275 const quote = quotes[pair]
276 if (quote) { this.quotes[pair] = quote; delete this.errors[pair] }
277 else this.errors[pair] = 'Binance returned no quote for this pair.'
278 }
279 }
280}
281hooks/yahoo.ts 121 lines1import type { HttpResponse } from 'claude-code'
2
3export type Quote = {
4 symbol: string
5 name: string
6 price: number
7 changePercent: number | null
8 currency: string | null
9 timestamp: number
10 fetchedAt: number
11 delaySeconds: number | null
12}
13
14export class YahooError extends Error {
15 constructor(message: string, readonly retryAfterMs?: number) {
16 super(message)
17 }
18}
19
20/** Yahoo wire symbols or Binance pairs, not company names. Never interpolate arbitrary URLs. */
21export function normalizeSymbol(input: string): string {
22 let symbol = input.trim().toUpperCase()
23 if (/^[A-Z0-9]{1,20}\/[A-Z0-9]{1,20}$/.test(symbol)) return symbol
24 if (!/^[A-Z0-9^][A-Z0-9.^=\-]{0,31}$/.test(symbol)) {
25 throw new Error('Enter a Yahoo ticker, such as AAPL, 0700.HK or ^GSPC, or a Binance pair, such as BTC/USDT.')
26 }
27 if (/^\d{6}\.SH$/.test(symbol)) symbol = symbol.replace(/\.SH$/, '.SS')
28 const hk = /^(\d{1,5})\.HK$/.exec(symbol)
29 if (hk) symbol = String(Number(hk[1])).padStart(4, '0') + '.HK'
30 return symbol
31}
32
33/**
34 * The chart path after `https://query1.finance.yahoo.com/v8/finance/chart/`.
35 * Only quote metadata is used; daily bars avoid unnecessary minute-history payloads.
36 */
37export function quotePath(symbol: string): string {
38 return encodeURIComponent(normalizeSymbol(symbol)) + '?interval=1d&range=1d&includePrePost=false'
39}
40
41const positive = (value: unknown): value is number =>
42 typeof value === 'number' && Number.isFinite(value) && value > 0
43
44const cleanText = (value: unknown): string | null => typeof value === 'string'
45 ? value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 100) || null
46 : null
47
48function readResult(symbol: string, response: HttpResponse, now: number): any {
49 if (response.status === 429) {
50 const retry = response.headers['retry-after']
51 const seconds = retry === undefined || retry.trim() === '' ? NaN : Number(retry)
52 const wait = Number.isFinite(seconds) && seconds >= 0
53 ? seconds * 1000 : Math.max(0, Date.parse(retry ?? '') - now)
54 throw new YahooError('Yahoo rate limited requests.', Math.max(60_000, Number.isFinite(wait) ? wait : 60_000))
55 }
56 if (response.status === 404) throw new YahooError('Ticker not found on Yahoo Finance.')
57 if (!response.ok) throw new YahooError(`Yahoo returned HTTP ${response.status}.`)
58 let body: any
59 try { body = JSON.parse(response.text) } catch { throw new YahooError('Yahoo returned an unreadable response.') }
60 const result = body?.chart?.result?.[0]
61 const meta = result?.meta
62 if (body?.chart?.error || !meta) throw new YahooError('No quote available for this ticker.')
63 // Never display the response for another instrument under the requested ticker.
64 if (typeof meta.symbol !== 'string' || normalizeSymbol(meta.symbol) !== symbol) {
65 throw new YahooError('Yahoo returned a different ticker.')
66 }
67 return result
68}
69
70export function decodeQuote(symbol: string, response: HttpResponse, now: number): Quote {
71 const meta = readResult(symbol, response, now).meta
72 if (!positive(meta.regularMarketPrice) || !positive(meta.regularMarketTime)) {
73 throw new YahooError('Yahoo returned an incomplete quote.')
74 }
75 const close = positive(meta.previousClose) ? meta.previousClose : meta.chartPreviousClose
76 const delay = meta.exchangeDataDelayedBy
77 return {
78 symbol,
79 name: cleanText(meta.longName) ?? cleanText(meta.shortName) ?? symbol,
80 price: meta.regularMarketPrice,
81 changePercent: positive(close) ? (meta.regularMarketPrice - close) / close * 100 : null,
82 currency: typeof meta.currency === 'string' && /^[A-Z]{3}$/.test(meta.currency) ? meta.currency : null,
83 timestamp: meta.regularMarketTime * 1000,
84 fetchedAt: now,
85 delaySeconds: typeof delay === 'number' && Number.isFinite(delay) && delay >= 0 ? delay : null,
86 }
87}
88
89export function formatPrice(quote: Quote): string {
90 // Below 1, keep significant digits: a 0.00000441 token must not round to 0.000004.
91 const price = quote.price < 1
92 ? quote.price.toLocaleString('en-US', { minimumSignificantDigits: 2, maximumSignificantDigits: 4 })
93 : quote.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
94 return `${price}${quote.currency ? ' ' + quote.currency : ''}`
95}
96
97/** Keep the compact prompt ticker concise while disambiguating other currencies. */
98export function formatBandPrice(quote: Quote): string {
99 // A pair (BTC/USDT) already names its quote currency.
100 const named = quote.currency === 'USD' || quote.symbol.endsWith(`/${quote.currency}`)
101 return formatPrice(named ? { ...quote, currency: null } : quote)
102}
103
104export function formatChange(value: number | null): string {
105 return value === null ? '—' : `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`
106}
107
108export function formatTime(timestamp: number): string {
109 return new Date(timestamp).toLocaleString('en-US', {
110 month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
111 })
112}
113
114// A quote from an earlier day is labelled with its date so a cached or closed-market
115// price never reads as today's; today's quotes need no per-row time.
116export function formatStaleDate(timestamp: number, now: number): string {
117 const quoted = new Date(timestamp)
118 if (quoted.toDateString() === new Date(now).toDateString()) return ''
119 return quoted.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
120}
121hooks/binance.ts 64 lines1import type { HttpResponse } from 'claude-code'
2import type { Quote } from './yahoo'
3
4// Binance's public Spot market-data host (`data-api.binance.vision`). No key.
5export const BINANCE_BATCH = 100
6
7export class BinanceError extends Error {
8 constructor(message: string, readonly invalidSymbol = false) {
9 super(message)
10 }
11}
12
13/** A Binance Spot pair is written BASE/QUOTE (BTC/USDT); a dash stays a Yahoo symbol (BTC-USD). */
14export function isCryptoPair(symbol: string): boolean {
15 return /^[A-Z0-9]{1,20}\/[A-Z0-9]{1,20}$/.test(symbol)
16}
17
18export function binanceSymbol(pair: string): string {
19 return pair.replace('/', '')
20}
21
22/** The query of one request for up to BINANCE_BATCH pairs: `symbols=["BTCUSDT","ETHUSDT"]`. */
23export function tickersQuery(pairs: string[]): string {
24 return `symbols=${encodeURIComponent(JSON.stringify(pairs.map(binanceSymbol)))}`
25}
26
27const positive = (value: number): boolean => Number.isFinite(value) && value > 0
28
29/** Quotes by requested pair; a pair Binance did not return is absent. */
30export function decodeTickers(pairs: string[], response: HttpResponse, now: number): Record<string, Quote> {
31 if (response.status === 400) {
32 // -1121 Invalid symbol: one unknown or delisted pair fails the whole batch.
33 throw new BinanceError('Binance has no Spot pair with this name.', true)
34 }
35 if (response.status === 429 || response.status === 418) throw new BinanceError('Binance rate limited requests.')
36 if (!response.ok) throw new BinanceError(`Binance returned HTTP ${response.status}.`)
37 let body: unknown
38 try { body = JSON.parse(response.text) } catch { throw new BinanceError('Binance returned an unreadable response.') }
39 const tickers = Array.isArray(body) ? body : [body]
40 const wanted = new Map(pairs.map(pair => [binanceSymbol(pair), pair]))
41 const quotes: Record<string, Quote> = {}
42 for (const ticker of tickers as Record<string, unknown>[]) {
43 const pair = typeof ticker?.symbol === 'string' ? wanted.get(ticker.symbol) : undefined
44 if (!pair) continue
45 const price = Number(ticker.lastPrice)
46 const close = Number(ticker.prevClosePrice)
47 const time = Number(ticker.closeTime)
48 if (!positive(price) || !positive(time)) continue
49 quotes[pair] = {
50 symbol: pair,
51 name: 'Binance Spot',
52 price,
53 // The change against the close 24 hours before the last trade.
54 changePercent: positive(close) ? (price - close) / close * 100 : null,
55 currency: pair.split('/')[1],
56 timestamp: time,
57 fetchedAt: now,
58 // Binance Spot data is not delayed.
59 delaySeconds: 0,
60 }
61 }
62 return quotes
63}
64