SLOPSHOPPER

Pulse CC

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

newpanebandcommandnetworktimer
★ 113v0.4.1MITupdated 2026-10-08fatwang2/Pulse/plugins/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulse-cc
│ ┃ Pulse ✕ › fix the failing auth test and add an audit log call │ ┃ WATCHLIST · 0 TICKERS [ Refresh (r) ] [ Pa │ ┃ ● Auto-refresh every 60s ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ AAPL, BTC/USDT ⏎ add [ Add ] ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Your watchlist is empty. Add a ticker │ ┃ above. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Yahoo Finance · Regular-session quotes · │ ┃ Exchange delays vary · Times are local · › /pulse │ ┃ https://www.pulseticker.app/ ⎿ pulse-cc: Pulse panel opened. │ ┃ Tab: move between controls · Enter: │ ┃ activate · Esc: return to prompt │ Pulse Add a ticker with /pulse add AAPL ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Pulse Add a ticker with /pulse add AAPL
Pane · Pulse
WATCHLIST · 0 TICKERS [ Refresh (r) ] [ Pause (p) ] ● Auto-refresh every 60s AAPL, BTC/USDT ⏎ add [ Add ] Your watchlist is empty. Add a ticker above. Yahoo Finance · Regular-session quotes · Exchange delays vary · Times are local · https://www.pulseticker.app/ Tab: move between controls · Enter: activate · Esc: return to prompt
README

Pulse for Claude Code

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

AAPL, NVDA, BTC/USDT and ETH/USDT prices above the Claude Code prompt

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.

Install

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.

Use

CommandAction
/pulseOpen or close the panel
/pulse add <ticker>Add a ticker
/pulse remove <ticker>Remove a ticker
/pulse refreshRequest one refresh round, including while paused
/pulse offPause automatic refresh; keep the watchlist and cached quotes
/pulse onResume automatic refresh
/pulse helpShow 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:

Pulse CC panel docked beside a Claude Code terminal session, with two stocks and two crypto pairs

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

Claude Desktop with Pulse CC prices above the prompt and the panel beside the chat

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 quote behavior

Stock and index quotes come from Yahoo Finance.

  • Prices are Yahoo's regular-session quotes, not pre-market, after-hours, overnight quotes, or a live trade stream. The percentage compares the price with the previous close; when that is missing, it shows —.
  • A quote from an earlier day is labelled with its date. A closed market may correctly show the last trading session's price.
  • Exchange delays vary. Delay is shown when Yahoo supplies it; missing delay metadata is never presented as proof of real-time data. See Yahoo's exchange data guide.
  • Network or provider failures keep the last successful price and label it Cached. An unavailable ticker with no cached quote shows an error.
  • HTTP 429 pauses the remaining round. Retries respect 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.

Crypto quote behavior

  • Pairs are read from Binance's public Spot market-data API (data-api.binance.vision). No API key or account.
  • One request reads every pair, in batches of 100, on the same 60-second refresh as stocks. Crypto trades around the clock, so the percentage is the 24-hour change: the last price against the price 24 hours earlier.
  • Binance Spot data is not delayed. The band omits the quote currency because the pair already names it; the panel shows it.
  • An unknown or delisted pair shows its own error without stopping the other pairs. A Yahoo rate limit never delays crypto refreshes.

Local data and privacy

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.

What the mod runs

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.

What each hook does

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

What it fetches

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.

What it sends and where

  • Hosts it contacts: query1.finance.yahoo.com (Yahoo Finance) and data-api.binance.vision (Binance). No other hosts.
  • What it sends: the watched symbols in the request address, a Pulse-CC/<version> user agent and Accept: application/json. No token, cookie, account, watchlist name or other data.
  • The panel's link to https://www.pulseticker.app/ opens in your browser only when you click it; the mod sends nothing there itself.

What it runs

Nothing. It calls no tools (no shell, agent or MCP tool), runs no commands, starts no processes and spawns no agents.

What it reads and stores

Its plugin store holds the watchlist and paused setting described above. It has no configuration options and reads no environment variables, files or credentials.

Development and verification

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.

License

MIT. See LICENSE.

Source 4 files
hooks/register.tsx 253 lines
1import 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}
253
hooks/watchlist.ts 281 lines
1import 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}
281
hooks/yahoo.ts 121 lines
1import 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}
121
hooks/binance.ts 64 lines
1import 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