From 86a37c09a6c9e64a79eed8e018650dbf5bd6b056 Mon Sep 17 00:00:00 2001 From: johnf Date: Fri, 23 Oct 2026 16:46:55 -0400 Subject: [PATCH 1/2] A market terminal: live stock and crypto prices and the news J (or "Market terminal" in Ctrl+K) opens an amber-on-black window with a ticking watchlist, the picked symbol up close, and top headlines. Crypto comes from Coinbase's public prices with no key; stocks and news from Finnhub with a free AGENT_OFFICE_FINNHUB_KEY that never leaves the server. The office proxies both behind the sign-in, caps its Finnhub calls at 50 a minute for everyone, caps its cache and answer sizes, and only follows http(s) news links. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/configuration.md | 4 + docs/controls.md | 1 + docs/features.md | 1 + src/client/features/market/index.ts | 9 ++ src/client/features/market/ui.css | 46 ++++++ src/client/features/market/ui.ts | 230 +++++++++++++++++++++++++++ src/client/features/palette/index.ts | 2 + src/client/main.ts | 2 + src/client/ui/help.ts | 1 + src/server/http/routes/index.ts | 3 + src/server/http/routes/market.ts | 30 ++++ src/server/market.ts | 133 ++++++++++++++++ src/shared/market.ts | 69 ++++++++ tests/market.test.ts | 88 ++++++++++ 15 files changed, 620 insertions(+) create mode 100644 src/client/features/market/index.ts create mode 100644 src/client/features/market/ui.css create mode 100644 src/client/features/market/ui.ts create mode 100644 src/server/http/routes/market.ts create mode 100644 src/server/market.ts create mode 100644 src/shared/market.ts create mode 100644 tests/market.test.ts diff --git a/README.md b/README.md index fac9441a5..5c58ac785 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,7 @@ deploy/coolify.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit | X | Send a worker home | | L | Hang a sign over a desk ("Operations", "Code cleanup") | | T / Enter | Chat | +| J | The 📈 market terminal: live stock and crypto prices and headlines (stocks need a free `AGENT_OFFICE_FINNHUB_KEY`, see [Configuration](docs/configuration.md#market-terminal)) | | V | Join voice; then hold V to talk | | M | Mute / unmute in voice | | Ctrl + Space | Dictate into a terminal or a prompt box: hold it and talk (or hold the **🎤**) | diff --git a/docs/configuration.md b/docs/configuration.md index 611e23f0d..7337ce8c7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,3 +62,7 @@ agent-office tunnel [office@address | url] [--port ] [--office-port ] [--n the worker stops it. Given an SSH address it opens the tunnel to the office too. See docs/tunnel.md. ``` + +## Market terminal + +The 📈 market terminal (**J**, or *Market terminal* in the Ctrl+K palette) prices crypto pairs like `BTC-USD` from Coinbase's public prices, which need no key. Stocks and the market's headlines come from [Finnhub](https://finnhub.io/register): get a free key there and start the office with it in `AGENT_OFFICE_FINNHUB_KEY`, for example `AGENT_OFFICE_FINNHUB_KEY=... agent-office`. The key stays on the office's server. Browsers ask the office (`/api/market/quotes` and `/api/market/news`, signed-in only), and the office asks Finnhub with the key in a header, so it's never in a page or a URL. The office keeps each price for 10 seconds and the headlines for a minute, so many open terminals stay well inside the free tier's 60 calls a minute. Finnhub's free tier covers US stocks, and its prices can trail the full market by a little. It's no replacement for a broker's feed, and none of it is investment advice. diff --git a/docs/controls.md b/docs/controls.md index 4a863358d..48474cbc4 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -23,6 +23,7 @@ Back to the [README](../README.md). | T / Enter | Chat | | G / 1–6 | Emote: hold G for the wheel (point and let go) or press 1–6 to wave, give a thumbs up, clap, dance, point or facepalm; everyone on your floor sees it | | / | Search the chat and every terminal on your floor | +| J | The 📈 market terminal: live stock and crypto prices and headlines | | Ctrl + K (⌘K on a Mac) | Command palette: find a worker, issue, PR, service, board, teammate or action; Enter opens it, Shift+Enter walks you there first | | V | Join voice; in voice, hold to talk (you're muted when you let go) | | M | Mute / unmute in voice | diff --git a/docs/features.md b/docs/features.md index f320d9b99..45f3ac2c7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -42,6 +42,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Task cards.** A card over each worker's head names what it's on ("Fix Login Redirect") and says in one line what it's doing right now. For Claude workers, Claude Haiku writes it from prompts and recent tools through the `claude` CLI. OpenCode, Codex, Grok, Muse, DeepSeek Harness and Cursor use local task summaries and never invoke Claude to name their tasks. - **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said. - **Search.** Press **/** (or **🔎 Search** in the **☰** menu) to search the chat and every worker's terminal at once, including what they showed before the office restarted. Click a terminal line to open that terminal scrolled to it, with the line lit up. +- **Market terminal.** Press **J** (or pick *Market terminal* in the Ctrl+K palette) for an amber-on-black market screen: a watchlist of stocks and crypto that refreshes every 10 seconds, with each price flashing green or red as it moves, the symbol you click up close, and the market's latest headlines. Type a ticker (`AAPL`) or a coin pair (`BTC-USD`) and press Enter to add it. Your watchlist is kept in your browser. Crypto works out of the box. Stocks and news need a free Finnhub key on the office (see [Configuration](configuration.md#market-terminal)). - **Command palette.** Press **Ctrl+K** (**⌘K** on a Mac) anywhere in the office to find anything on your floor by typing a few letters: workers (by name, task card or desk), issues, pull requests, services, the boards, teammates, and actions (hire a worker, open the task queue, settings, invite, hang a picture). Words, word starts and scattered letters all match, best first, so "login" finds the worker whose card says *Fix Login Redirect*. **↑ ↓** choose and **Enter** opens it the way clicking it in the office does: a worker's terminal, a PR's window, a service in a new tab. **Shift+Enter** walks your character over to it first and opens it when you get there (take a step yourself to call it off). **Esc** or **Ctrl+K** again puts it away. It doesn't open while you're typing in a terminal or a text box, where Ctrl+K is the terminal's own (cut to the end of the line). - **Cost per worker.** Claude Code workers show tracked session cost and tokens. OpenCode workers show tokens and model/provider-reported cost estimates, including child-session usage, with a cache/reasoning breakdown in tooltips. These estimates are not billing; missing cost is shown as unavailable. Codex workers show the root session’s recorded tokens, including cache and reasoning breakdowns; cost and API-call count are unavailable. DeepSeek Harness workers show what is in each session's context window (and the window's size) from ACP's usage updates, plus a cost only when the harness reports one. Today's and all-time totals, `--budget` warnings and `--budget-pause` use tracked Claude Code spend only; they cannot cap OpenCode, Codex or DeepSeek Harness spending. Once the tracked budget is spent, the hiring pause applies to all new workers. OpenCode and Codex session snapshots and Claude totals survive restarts. Current-desk totals disappear when their workers are sent home. - **Machine monitor and worker limit.** A monitor on the west wall, between the windows, shows how busy the office's machine is: CPU and memory right now, the last five minutes of each, and how many workers the office runs. Start with `--max-workers `, or have an admin set a limit under **⚙️** → *Worker limit*, and the office runs at most that many workers at once across every floor (shells and board agents count too). Hiring past it is refused with a message saying why, and the task queue waits for room instead of failing its tasks. In the office, the limit can be lowered but never raised past `--max-workers`. When memory is 90% used, or the CPU has been 90% busy for half a minute, the monitor says the machine is under pressure, the hint at an empty desk says so, and the hire dialog warns you before you hire. diff --git a/src/client/features/market/index.ts b/src/client/features/market/index.ts new file mode 100644 index 000000000..f29a9fd5a --- /dev/null +++ b/src/client/features/market/index.ts @@ -0,0 +1,9 @@ +/** The 📈 market terminal: J opens it (as does "Market terminal" in the Ctrl+K palette). */ +import type { Ctx } from '../../core/context'; +import { openMarket } from './ui'; + +export { openMarket }; + +export function installMarket(ctx: Ctx) { + ctx.keys.bind({ code: 'KeyJ', preventDefault: true, run: openMarket }); +} diff --git a/src/client/features/market/ui.css b/src/client/features/market/ui.css new file mode 100644 index 000000000..57553a7a5 --- /dev/null +++ b/src/client/features/market/ui.css @@ -0,0 +1,46 @@ +/* The 📈 market terminal: amber on black, in the office's window frame. */ +.modal.market { width: min(1100px, 100%); height: min(720px, calc(100vh - 32px)); background: #0b0b0b; color: #ffb000; font-family: ui-monospace, Menlo, Consolas, monospace; } +.modal.market header { background: #1a1200; color: #ffb000; border-bottom-color: var(--ink); } +.mkt-status { font-size: 12px; font-weight: 700; color: #c98a00; white-space: nowrap; } +.mkt-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #3a2a00; } +.mkt-prompt { font-weight: 900; } +.modal.market input.mkt-cmd { flex: 1; background: #000; color: #ffd27a; border: 1px solid #5a4100; border-radius: 4px; padding: 6px 10px; font: 700 15px ui-monospace, Menlo, Consolas, monospace; text-transform: uppercase; } +.modal.market input.mkt-cmd:focus { border-color: #ffb000; } +.modal.market input.mkt-cmd.bad { border-color: #ff4d4d; } +.mkt-key:not(:empty) { padding: 6px 12px; font-size: 12px; color: #ffd27a; background: #241800; border-bottom: 1px solid #3a2a00; } +.mkt-key a, .mkt-news a { color: #ffd27a; } +.mkt-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); } +.mkt-panel { overflow: auto; padding: 8px 12px; border-right: 1px solid #3a2a00; } +.mkt-panel:last-child { border-right: 0; } +.mkt-panel h3 { margin: 6px 0; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: #fff; background: #3a2a00; padding: 2px 6px; } +.mkt-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.mkt-table th { text-align: right; color: #c98a00; font-weight: 700; padding: 3px 6px; border-bottom: 1px solid #3a2a00; } +.mkt-table th:first-child, .mkt-table td.sym { text-align: left; } +.mkt-table td { padding: 3px 6px; white-space: nowrap; } +.mkt-table tbody tr { cursor: pointer; transition: background-color .6s; } +.mkt-table tbody tr:hover { background: #1a1200; } +.mkt-table tbody tr.picked { background: #2a1d00; } +.mkt-table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.mkt-table td.sym { color: #fff; font-weight: 800; } +.modal.market .up { color: #3ddc84; } +.modal.market .down { color: #ff5c5c; } +.modal.market .dim { color: #8a6a20; } +.mkt-table tr.tick-up { background: #0f3d22; transition: none; } +.mkt-table tr.tick-down { background: #4a1414; transition: none; } +.mkt-x { background: none; border: 0; color: #8a6a20; font: inherit; cursor: pointer; padding: 0 4px; } +.mkt-x:hover { color: #ff5c5c; } +.mkt-detail { padding: 4px 0 10px; } +.mkt-big { font-size: 28px; font-weight: 900; font-variant-numeric: tabular-nums; } +.mkt-big .sym { color: #fff; } +.mkt-move { font-size: 15px; font-weight: 800; margin-top: 2px; white-space: pre; } +.mkt-range { font-size: 12px; margin-top: 4px; } +.mkt-news { margin: 0; padding-left: 20px; font-size: 13px; } +.mkt-news li { margin-bottom: 8px; } +.mkt-news a { text-decoration: none; font-weight: 700; } +.mkt-news a:hover { text-decoration: underline; } +.modal.market footer { background: #1a1200; border-top-color: var(--ink); } +.modal.market footer .grow { color: #8a6a20; } +@media (max-width: 760px) { + .mkt-grid { grid-template-columns: 1fr; } + .mkt-panel { border-right: 0; border-bottom: 1px solid #3a2a00; } +} diff --git a/src/client/features/market/ui.ts b/src/client/features/market/ui.ts new file mode 100644 index 000000000..d8b584e8d --- /dev/null +++ b/src/client/features/market/ui.ts @@ -0,0 +1,230 @@ +// The 📈 market terminal's window: a watchlist that ticks, the picked symbol up close, and the +// market's headlines, amber on black. Prices come through the office (/api/market/*), never +// straight from the sources, so the office's Finnhub key stays on the server. +import './ui.css'; +import { cleanSymbol, DEFAULT_WATCHLIST, MAX_SYMBOLS, type Headline, type NewsAnswer, type Quote, type QuotesAnswer } from '../../../shared/market'; +import { h, openModal, timeAgo } from '../../ui/dom'; + +const LIST_KEY = 'agent-office.market.watchlist'; +const QUOTES_EVERY = 10_000; +const NEWS_EVERY = 60_000; + +function loadList(): string[] { + try { + const v = JSON.parse(localStorage.getItem(LIST_KEY) ?? 'null'); + if (Array.isArray(v)) return v.map((s) => cleanSymbol(String(s))).filter((s): s is string => !!s).slice(0, MAX_SYMBOLS); + } catch { + // storage blocked + } + return [...DEFAULT_WATCHLIST]; +} + +function saveList(list: readonly string[]) { + try { + localStorage.setItem(LIST_KEY, JSON.stringify(list)); + } catch { + // storage blocked + } +} + +async function getJson(url: string): Promise { + const res = await fetch(url, { credentials: 'same-origin' }); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error((j as { error?: string }).error ?? `HTTP ${res.status}`); + return j as T; +} + +const price = (n: number) => (!Number.isFinite(n) ? '—' : n >= 1000 ? n.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }) : n >= 1 ? n.toFixed(2) : n.toPrecision(4)); +const signed = (n: number, digits = 2) => `${n > 0 ? '+' : ''}${n.toFixed(digits)}`; +const dir = (n: number) => (n > 0 ? 'up' : n < 0 ? 'down' : 'flat'); +const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + +let open = false; + +export function openMarket() { + if (open) return; + open = true; + let list = loadList(); + let picked = list[0] ?? ''; + const last = new Map(); + let missing = new Map(); + let stocks = true; + let headlines: Headline[] = []; + let newsError = ''; + let updated = 0; + + const cmd = h('input.mkt-cmd', { type: 'text', placeholder: 'Symbol e.g. AAPL or BTC-USD', spellcheck: 'false', autocomplete: 'off', 'aria-label': 'Add a symbol' }); + const status = h('span.mkt-status'); + const rows = h('tbody'); + const detail = h('div.mkt-detail'); + const newsList = h('ol.mkt-news'); + const keyNote = h('div.mkt-key'); + const el = h( + 'div.modal.market', + { role: 'dialog', 'aria-label': 'Market terminal' }, + h('header', {}, h('h2', {}, '📈 Market terminal'), status), + h('div.mkt-bar', {}, h('span.mkt-prompt', {}, '>'), cmd), + keyNote, + h( + 'div.mkt-grid', + {}, + h('section.mkt-panel', {}, h('h3', {}, 'Watchlist'), h('table.mkt-table', {}, h('thead', {}, h('tr', {}, ...['Ticker', 'Last', 'Chg', '%Chg', 'High', 'Low', 'Time', ''].map((t) => h('th', {}, t)))), rows)), + h('section.mkt-panel', {}, detail, h('h3', {}, 'Top news'), newsList), + ), + h('footer', {}, h('span.grow', {}, 'Stocks and news: Finnhub (free tier, US). Crypto: Coinbase. Prices can lag the market by a few seconds; not investment advice.')), + ); + + const renderKeyNote = () => + keyNote.replaceChildren( + ...(stocks + ? [] + : [ + 'Crypto is live. For stocks and news, get a free key at ', + h('a', { href: 'https://finnhub.io/register', target: '_blank', rel: 'noopener noreferrer' }, 'finnhub.io'), + ' and start the office with ', + h('code', {}, 'AGENT_OFFICE_FINNHUB_KEY'), + ' set.', + ]), + ); + + const renderRows = () => { + rows.replaceChildren( + ...list.map((s) => { + const q = last.get(s); + const why = missing.get(s); + const remove = h('button.mkt-x', { type: 'button', title: `Remove ${s}`, 'aria-label': `Remove ${s}`, onclick: (e: Event) => (e.stopPropagation(), removeSymbol(s)) }, '×'); + const cells = q + ? [h('td.num', {}, price(q.price)), h(`td.num.${dir(q.change)}`, {}, signed(q.change)), h(`td.num.${dir(q.change)}`, {}, `${signed(q.changePct)}%`), h('td.num', {}, price(q.high)), h('td.num', {}, price(q.low)), h('td.num.dim', {}, clock(q.at))] + : [h('td.dim', { colspan: 6 }, why ?? 'Loading…')]; + return h('tr', { class: s === picked ? 'picked' : '', 'data-sym': s, onclick: () => ((picked = s), renderRows(), renderDetail()) }, h('td.sym', {}, s), ...cells, h('td', {}, remove)); + }), + ); + }; + + const renderDetail = () => { + const q = last.get(picked); + if (!q) return detail.replaceChildren(h('div.mkt-big.dim', {}, picked || 'Add a symbol'), h('div.dim', {}, missing.get(picked) ?? '')); + detail.replaceChildren( + h('div.mkt-big', {}, h('span.sym', {}, q.symbol), ' ', price(q.price)), + h(`div.mkt-move.${dir(q.change)}`, {}, `${signed(q.change)} (${signed(q.changePct)}%) ${q.source === 'coinbase' ? 'vs 24h ago' : 'vs prev close'}`), + h('div.mkt-range.dim', {}, `Open ${price(q.open)} · High ${price(q.high)} · Low ${price(q.low)} · ${q.source === 'coinbase' ? 'Coinbase' : 'Finnhub'} ${clock(q.at)}`), + ); + }; + + const renderNews = () => { + if (newsError) return newsList.replaceChildren(h('li.dim', {}, newsError)); + if (!stocks) return newsList.replaceChildren(h('li.dim', {}, 'Headlines need a Finnhub key (see above).')); + if (!headlines.length) return newsList.replaceChildren(h('li.dim', {}, 'Loading…')); + newsList.replaceChildren( + ...headlines.map((n) => + h( + 'li', + {}, + h('a', { href: n.url, target: '_blank', rel: 'noopener noreferrer', title: n.summary }, n.headline), + h('span.dim', {}, ` ${n.source} · ${timeAgo(n.at)}`), + ), + ), + ); + }; + + const renderStatus = () => { + status.textContent = updated ? `Updated ${clock(updated)}` : 'Connecting…'; + }; + + /** Flashes the rows whose price moved since the last update. */ + const flash = (before: Map) => { + for (const q of last.values()) { + const was = before.get(q.symbol); + if (!was || was.price === q.price) continue; + const row = rows.querySelector(`tr[data-sym="${q.symbol}"]`); + row?.classList.add(q.price > was.price ? 'tick-up' : 'tick-down'); + setTimeout(() => row?.classList.remove('tick-up', 'tick-down'), 700); + } + }; + + let quoting = false; + async function refreshQuotes() { + if (quoting || !list.length) return; + quoting = true; + try { + const a = await getJson(`/api/market/quotes?s=${encodeURIComponent(list.join(','))}`); + const before = new Map(last); + for (const q of a.quotes) last.set(q.symbol, q); + missing = new Map(a.missing.map((m) => [m.symbol, m.why])); + for (const m of a.missing) last.delete(m.symbol); + stocks = a.stocks; + updated = Date.now(); + renderKeyNote(); + renderRows(); + renderDetail(); + flash(before); + } catch (err) { + status.textContent = `Offline: ${err instanceof Error ? err.message : 'no answer'}`; + return; + } finally { + quoting = false; + } + renderStatus(); + } + + async function refreshNews() { + try { + const a = await getJson('/api/market/news'); + headlines = a.items; + stocks = a.stocks; + newsError = ''; + } catch (err) { + newsError = `News is unavailable: ${err instanceof Error ? err.message : 'no answer'}`; + } + renderKeyNote(); + renderNews(); + } + + function removeSymbol(s: string) { + list = list.filter((x) => x !== s); + last.delete(s); + if (picked === s) picked = list[0] ?? ''; + saveList(list); + renderRows(); + renderDetail(); + } + + cmd.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const s = cleanSymbol(cmd.value); + if (!s) { + cmd.classList.add('bad'); + setTimeout(() => cmd.classList.remove('bad'), 500); + return; + } + cmd.value = ''; + if (!list.includes(s)) { + if (list.length >= MAX_SYMBOLS) list.pop(); + list.unshift(s); + saveList(list); + } + picked = s; + renderRows(); + renderDetail(); + void refreshQuotes(); + }); + + const quoteTimer = setInterval(() => void refreshQuotes(), QUOTES_EVERY); + const newsTimer = setInterval(() => void refreshNews(), NEWS_EVERY); + openModal(el, { + doing: 'checking the markets', + onClose: () => { + open = false; + clearInterval(quoteTimer); + clearInterval(newsTimer); + }, + }); + renderRows(); + renderDetail(); + renderNews(); + renderStatus(); + void refreshQuotes(); + void refreshNews(); + cmd.focus(); +} diff --git a/src/client/features/palette/index.ts b/src/client/features/palette/index.ts index 42673406c..2bea1e98a 100644 --- a/src/client/features/palette/index.ts +++ b/src/client/features/palette/index.ts @@ -19,6 +19,7 @@ import { openIssue, openPull } from '../../ui/pull'; import { openServices, serviceUrl } from '../../ui/services'; import { openTeam } from '../../ui/team'; import { IS_MAC } from '../../ui/termkeys'; +import { openMarket } from '../market'; import { openWhiteboard } from '../whiteboard/ui'; import type { InteractKind, Interactable } from '../../world/types'; @@ -99,6 +100,7 @@ export function installPalette(ctx: Ctx, parts: PaletteParts) { else if (store.me.admin) out.push({ icon: '👥', kind: 'Action', title: 'Invite people', detail: 'Accounts', keywords: ['invite teammates', 'accounts', 'team'], open: () => openAccounts(net) }); out.push({ icon: '🖼️', kind: 'Action', title: 'Hang a picture', detail: 'On a wall of this floor', keywords: ['decorate', 'frame', 'art'], open: hanging.startHanging }); out.push({ icon: '🔎', kind: 'Action', title: 'Search the chat and every terminal', keywords: ['find'], open: showSearch }); + out.push({ icon: '📈', kind: 'Action', title: 'Market terminal', detail: 'Live stock and crypto prices, and the news', keywords: ['stocks', 'quotes', 'crypto', 'news', 'ticker'], open: openMarket }); out.push(at('issues', 'the Issues board', { icon: '📌', kind: 'Board', title: 'Issues board', open: () => openBoard('issues', net, actions.boardActions()) })); out.push(at('pulls', 'the PR board', { icon: '🔀', kind: 'Board', title: 'PR board', keywords: ['pull requests'], open: () => openBoard('pulls', net, actions.boardActions()) })); diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..ff5218a0d 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -59,6 +59,7 @@ import { installLamplight } from './features/lamplight'; import { installTelescope } from './features/telescope'; import { installTv } from './features/tv'; import { installVoice } from './features/voice'; +import { installMarket } from './features/market'; import { installWaiting } from './features/waiting'; import { installWalking } from './features/walking'; import { installWhiteboard } from './features/whiteboard'; @@ -178,6 +179,7 @@ parts.pointer = installPointer(ctx, core, parts); installChat(ctx); parts.talk = installVoice(ctx, { tv: parts.tv }); installDictation(ctx); +installMarket(ctx); parts.hud = installHud(ctx, core, parts); // ---- Main loop --------------------------------------------------------------------------------------- diff --git a/src/client/ui/help.ts b/src/client/ui/help.ts index d1d9fc809..7437bd1d8 100644 --- a/src/client/ui/help.ts +++ b/src/client/ui/help.ts @@ -35,6 +35,7 @@ export const HELP_ROWS: readonly (readonly [string, string])[] = [ ['T', 'Chat'], ['G / 1–6', 'Emote: hold G, point at one and let go (or tap G and click one), or press 1–6: wave, thumbs up, clap, dance, point, facepalm. Everyone on your floor sees it'], ['/', 'Search the chat and every terminal on your floor, back to before the office last restarted'], + ['J', 'The 📈 market terminal: live stock and crypto prices and the market’s headlines, amber on black. Type a ticker (AAPL) or a coin pair (BTC-USD) and Enter adds it to your watchlist; click a row for a closer look. It’s also “Market terminal” in the Ctrl+K palette'], [IS_MAC ? '⌘K' : 'Ctrl+K', 'Command palette: type a few letters to find a worker, issue, PR, service, board, teammate or action. Enter opens it, Shift+Enter walks you over to it first'], ['V', 'Join voice. In voice, hold V to talk (push to talk): you’re muted once you let go. Leave voice from the ☰ menu'], ['M', 'Mute or unmute your mic in voice. ⚙️ Settings can have you join muted, for push to talk'], diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..d69df3760 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -6,6 +6,7 @@ import { agentRoutes } from './agents.js'; import { authRoutes } from './auth.js'; import { fileRoutes } from './files.js'; import { githubRoutes } from './github.js'; +import { marketRoutes } from './market.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; @@ -36,6 +37,8 @@ export const routes: readonly Route[] = [ searchRoutes.search, serviceRoutes.forwards, githubRoutes.github, + marketRoutes.quotes, + marketRoutes.news, pageRoutes.office, pageRoutes.lite, pageRoutes.bundle, diff --git a/src/server/http/routes/market.ts b/src/server/http/routes/market.ts new file mode 100644 index 000000000..912271de7 --- /dev/null +++ b/src/server/http/routes/market.ts @@ -0,0 +1,30 @@ +// The 📈 market terminal: prices and headlines, through the office so the Finnhub key stays here. +import { MAX_SYMBOLS, parseSymbols } from '../../../shared/market.js'; +import { news, quotes } from '../../market.js'; +import { send } from '../util.js'; +import type { Route } from '../router.js'; + +export const marketRoutes = { + quotes: { + method: 'GET', + path: '/api/market/quotes', + auth: 'session', + handle: async (_ctx, { res, url }) => { + const symbols = parseSymbols(url.searchParams.get('s') ?? ''); + if (!symbols.length) return send(res, 400, { error: `Name 1 to ${MAX_SYMBOLS} symbols, like AAPL,BTC-USD` }); + send(res, 200, await quotes(symbols)); + }, + }, + news: { + method: 'GET', + path: '/api/market/news', + auth: 'session', + handle: async (_ctx, { res }) => { + try { + send(res, 200, await news()); + } catch (err) { + send(res, 502, { error: err instanceof Error ? err.message : 'News is unavailable' }); + } + }, + }, +} satisfies Record; diff --git a/src/server/market.ts b/src/server/market.ts new file mode 100644 index 000000000..ef46215d0 --- /dev/null +++ b/src/server/market.ts @@ -0,0 +1,133 @@ +// The 📈 market terminal's data: stocks and news from Finnhub (with the office's own free key, which +// never leaves the server), coins from Coinbase's public prices (no key). Only those two hosts are +// ever asked, and each answer is kept a few seconds so a room full of open terminals asks once. +import type { Headline, NewsAnswer, Quote, QuotesAnswer } from '../shared/market.js'; +import { isCoin } from '../shared/market.js'; + +const FINNHUB = 'https://finnhub.io/api/v1'; +const COINBASE = 'https://api.exchange.coinbase.com'; +/** How long a price is kept before it's asked for again. Finnhub's free tier takes 60 calls a minute. */ +const QUOTE_TTL = 10_000; +const NEWS_TTL = 60_000; +const TIMEOUT = 6_000; +const MAX_HEADLINES = 40; + +/** The Finnhub key, read each time so a restart isn't needed to drop it (finnhub.io, free). */ +const finnhubKey = () => process.env.AGENT_OFFICE_FINNHUB_KEY?.trim() || ''; + +type Got = { ok: true; value: T } | { ok: false; why: string }; +const cache = new Map> }>(); + +/** `load`'s answer for `key`, kept for `ttl`; a failure is kept too, for a shorter while, so a down source isn't hammered. */ +function cached(key: string, ttl: number, load: () => Promise): Promise> { + const now = Date.now(); + const hit = cache.get(key); + if (hit && now - hit.at < ttl) return hit.got as Promise>; + const got = load().then( + (value): Got => ({ ok: true, value }), + (err: unknown): Got => { + // A failure is tried again after a few seconds, not a whole TTL. + cache.set(key, { at: Date.now() - ttl + 3_000, got }); + return { ok: false, why: err instanceof Error ? err.message : 'Unavailable' }; + }, + ); + cache.delete(key); + cache.set(key, { at: now, got }); + // Oldest first (a Map keeps the order things went in), so a flood of symbols can't grow it. + while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value!); + return got; +} + +const MAX_CACHED = 500; +/** The most Finnhub calls the office makes a minute, for everyone together: under the free tier's 60. */ +const FINNHUB_PER_MINUTE = 50; +const finnhubCalls: number[] = []; + +/** Whether the office may ask Finnhub once more this minute (and counts it if so). */ +function finnhubBudget(): boolean { + const now = Date.now(); + while (finnhubCalls.length && now - finnhubCalls[0] > 60_000) finnhubCalls.shift(); + if (finnhubCalls.length >= FINNHUB_PER_MINUTE) return false; + finnhubCalls.push(now); + return true; +} + +/** The most an answer may be: a quote is a few hundred bytes, the headlines a few dozen kB. */ +const MAX_BODY = 2 * 1024 * 1024; + +async function getJson(source: 'Finnhub' | 'Coinbase', url: string, headers: Record = {}): Promise { + if (source === 'Finnhub' && !finnhubBudget()) throw new Error('Busy, try again in a minute'); + const res = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'agent-office', ...headers }, signal: AbortSignal.timeout(TIMEOUT), redirect: 'error' }); + if (res.status === 429) throw new Error(`${source} is rate limiting, try again in a minute`); + if (res.status === 401 || res.status === 403) throw new Error(source === 'Finnhub' ? 'The Finnhub key was refused' : `${source} refused the request`); + if (res.status === 404) throw new Error('Unknown symbol'); + if (!res.ok) throw new Error(`${source} answered ${res.status}`); + if (Number(res.headers.get('content-length')) > MAX_BODY) throw new Error(`${source} answered too much`); + const body = await res.arrayBuffer(); + if (body.byteLength > MAX_BODY) throw new Error(`${source} answered too much`); + return JSON.parse(new TextDecoder().decode(body)); +} + +const num = (v: unknown) => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN); +const round = (n: number) => Math.round(n * 1e4) / 1e4; + +async function stockQuote(symbol: string): Promise { + const j = (await getJson('Finnhub', `${FINNHUB}/quote?symbol=${encodeURIComponent(symbol)}`, { 'x-finnhub-token': finnhubKey() })) as Record; + const price = num(j.c); + // Finnhub answers a symbol it doesn't know with zeros. + if (!Number.isFinite(price) || price <= 0) throw new Error('Unknown symbol'); + return { symbol, price, change: round(num(j.d) || 0), changePct: round(num(j.dp) || 0), high: num(j.h), low: num(j.l), open: num(j.o), at: num(j.t) * 1000 || Date.now(), source: 'finnhub' }; +} + +async function coinQuote(symbol: string): Promise { + const j = (await getJson('Coinbase', `${COINBASE}/products/${encodeURIComponent(symbol)}/stats`)) as Record; + const price = num(j.last); + const open = num(j.open); + if (!Number.isFinite(price) || price <= 0) throw new Error('Unknown symbol'); + const change = Number.isFinite(open) && open > 0 ? price - open : 0; + return { symbol, price, change: round(change), changePct: open > 0 ? round((change / open) * 100) : 0, high: num(j.high), low: num(j.low), open, at: Date.now(), source: 'coinbase' }; +} + +/** The latest prices of `symbols` (already cleaned by parseSymbols). */ +export async function quotes(symbols: readonly string[]): Promise { + const stocks = !!finnhubKey(); + const out: QuotesAnswer = { quotes: [], missing: [], stocks }; + const got = await Promise.all( + symbols.map((s): Promise> => { + if (isCoin(s)) return cached(`coin:${s}`, QUOTE_TTL, () => coinQuote(s)); + if (!stocks) return Promise.resolve({ ok: false, why: 'Stocks need a Finnhub key' }); + return cached(`stock:${s}`, QUOTE_TTL, () => stockQuote(s)); + }), + ); + got.forEach((g, i) => (g.ok ? out.quotes.push(g.value) : out.missing.push({ symbol: symbols[i], why: g.why }))); + return out; +} + +/** Only a link a browser can follow safely: http(s), nothing else. */ +function safeUrl(raw: unknown): string { + if (typeof raw !== 'string') return ''; + try { + const u = new URL(raw); + return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : ''; + } catch { + return ''; + } +} + +const text = (v: unknown, max: number) => (typeof v === 'string' ? v.slice(0, max) : ''); + +/** The market's latest headlines (Finnhub's general news), newest first. */ +export async function news(): Promise { + if (!finnhubKey()) return { items: [], stocks: false }; + const got = await cached('news', NEWS_TTL, async () => { + const j = await getJson('Finnhub', `${FINNHUB}/news?category=general`, { 'x-finnhub-token': finnhubKey() }); + if (!Array.isArray(j)) throw new Error('Unexpected answer'); + return j + .map((n: Record): Headline => ({ id: num(n.id) || 0, headline: text(n.headline, 300), source: text(n.source, 60), summary: text(n.summary, 600), url: safeUrl(n.url), at: num(n.datetime) * 1000 || 0 })) + .filter((n) => n.headline && n.url) + .sort((a, b) => b.at - a.at) + .slice(0, MAX_HEADLINES); + }); + if (!got.ok) throw new Error(got.why); + return { items: got.value, stocks: true }; +} diff --git a/src/shared/market.ts b/src/shared/market.ts new file mode 100644 index 000000000..6512b0eae --- /dev/null +++ b/src/shared/market.ts @@ -0,0 +1,69 @@ +// The 📈 market terminal: what the office's proxy answers with, and which symbols it takes. + +/** One symbol's latest price: a stock from Finnhub, or a coin from Coinbase. */ +export interface Quote { + symbol: string; + /** The last trade. */ + price: number; + /** Against the previous close (a stock) or 24 hours ago (a coin). */ + change: number; + changePct: number; + high: number; + low: number; + open: number; + /** When the price is from, in ms; a coin's is when the office asked. */ + at: number; + source: 'finnhub' | 'coinbase'; +} + +export interface Headline { + id: number; + headline: string; + source: string; + summary: string; + url: string; + /** When it was published, in ms. */ + at: number; +} + +export interface QuotesAnswer { + quotes: Quote[]; + /** Symbols nobody could price, with why: a stock with no key, a symbol no one knows, a source that's down. */ + missing: { symbol: string; why: string }[]; + /** Whether the office has a Finnhub key, so stocks and news work. */ + stocks: boolean; +} + +export interface NewsAnswer { + items: Headline[]; + stocks: boolean; +} + +/** The most symbols one request can ask for, so a page can't run the free tier's limit down at once. */ +export const MAX_SYMBOLS = 20; + +/** The watchlist a new terminal starts with. */ +export const DEFAULT_WATCHLIST = ['SPY', 'QQQ', 'AAPL', 'MSFT', 'NVDA', 'AMZN', 'TSLA', 'BTC-USD', 'ETH-USD', 'SOL-USD']; + +const STOCK = /^[A-Z][A-Z0-9.]{0,9}$/; +const COIN = /^[A-Z0-9]{2,10}-(USD|USDC|USDT|EUR|GBP|BTC|ETH)$/; + +/** Whether a symbol is a coin pair (BTC-USD), which Coinbase prices. */ +export const isCoin = (s: string) => COIN.test(s); + +/** A symbol as typed, upper-cased, or null when it's neither a stock ticker nor a coin pair. */ +export function cleanSymbol(raw: string): string | null { + const s = raw.trim().toUpperCase(); + return STOCK.test(s) || COIN.test(s) ? s : null; +} + +/** The symbols in a comma-separated list, cleaned, without repeats, at most MAX_SYMBOLS. */ +export function parseSymbols(list: string): string[] { + const out: string[] = []; + for (const raw of list.split(',')) { + const s = cleanSymbol(raw); + if (s && !out.includes(s)) out.push(s); + if (out.length >= MAX_SYMBOLS) break; + } + return out; +} diff --git a/tests/market.test.ts b/tests/market.test.ts new file mode 100644 index 000000000..dcc9a9c86 --- /dev/null +++ b/tests/market.test.ts @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cleanSymbol, isCoin, MAX_SYMBOLS, parseSymbols } from '../src/shared/market.js'; +import { news, quotes } from '../src/server/market.js'; + +test('symbols are cleaned, and anything that could reach past the sources is refused', () => { + assert.equal(cleanSymbol(' aapl '), 'AAPL'); + assert.equal(cleanSymbol('brk.b'), 'BRK.B'); + assert.equal(cleanSymbol('btc-usd'), 'BTC-USD'); + for (const bad of ['', '../quote', 'AAPL?token=x', 'A B', 'http://x', 'BTC-USD/../x', '%2F', 'TOOLONGSYMBOL1']) assert.equal(cleanSymbol(bad), null, bad); + assert.ok(isCoin('ETH-USD')); + assert.ok(!isCoin('AAPL')); + assert.deepEqual(parseSymbols('aapl, AAPL,,nope!,btc-usd'), ['AAPL', 'BTC-USD']); + assert.equal(parseSymbols(Array.from({ length: 50 }, (_, i) => `S${i}`).join(',')).length, MAX_SYMBOLS); +}); + +/** Swaps in a fetch that answers from `answer`, and records which URLs and headers were asked. */ +function fakeFetch(answer: (url: string) => unknown) { + const asked: { url: string; headers: Record }[] = []; + const real = globalThis.fetch; + globalThis.fetch = (async (url: string, init?: { headers?: Record }) => { + asked.push({ url, headers: init?.headers ?? {} }); + const body = answer(url); + return new Response(JSON.stringify(body), { status: body === undefined ? 404 : 200 }); + }) as typeof fetch; + return { asked, restore: () => (globalThis.fetch = real) }; +} + +test('without a key, coins are priced from Coinbase and stocks say what they need', async () => { + delete process.env.AGENT_OFFICE_FINNHUB_KEY; + const f = fakeFetch(() => ({ open: '100', high: '120', low: '90', last: '110' })); + try { + const a = await quotes(['NOKEY', 'DOGE-USD']); + assert.equal(a.stocks, false); + assert.deepEqual(a.missing, [{ symbol: 'NOKEY', why: 'Stocks need a Finnhub key' }]); + assert.equal(a.quotes[0].price, 110); + assert.equal(a.quotes[0].changePct, 10); + assert.deepEqual(f.asked.map((x) => new URL(x.url).host), ['api.exchange.coinbase.com']); + assert.deepEqual(await news(), { items: [], stocks: false }); + } finally { + f.restore(); + } +}); + +test('with a key, it goes in a header to Finnhub only, and headlines keep only web links', async () => { + process.env.AGENT_OFFICE_FINNHUB_KEY = 'test-key'; + const f = fakeFetch((url) => { + if (url.includes('/quote?symbol=ZZZ')) return { c: 0, d: null, dp: null, h: 0, l: 0, o: 0, pc: 0, t: 0 }; + if (url.includes('/quote')) return { c: 210.5, d: 2.5, dp: 1.2019, h: 211, l: 207, o: 208, pc: 208, t: 1_700_000_000 }; + return [ + { id: 1, headline: 'Stocks rise', source: 'Wire', summary: 's', url: 'https://example.com/a', datetime: 1_700_000_100 }, + { id: 2, headline: 'Sneaky', source: 'Wire', summary: 's', url: 'javascript:alert(1)', datetime: 1_700_000_200 }, + ]; + }); + try { + const a = await quotes(['MSFT', 'ZZZ']); + assert.equal(a.quotes[0].price, 210.5); + assert.equal(a.quotes[0].at, 1_700_000_000_000); + assert.deepEqual(a.missing, [{ symbol: 'ZZZ', why: 'Unknown symbol' }]); + const n = await news(); + assert.deepEqual(n.items.map((x) => x.url), ['https://example.com/a']); + for (const x of f.asked) { + assert.equal(new URL(x.url).host, 'finnhub.io'); + assert.ok(!x.url.includes('test-key'), 'the key is never in a URL'); + assert.equal(x.headers['x-finnhub-token'], 'test-key'); + } + } finally { + f.restore(); + delete process.env.AGENT_OFFICE_FINNHUB_KEY; + } +}); + +test('the office asks Finnhub at most 50 times a minute, however many symbols are asked for', async () => { + process.env.AGENT_OFFICE_FINNHUB_KEY = 'test-key'; + const f = fakeFetch(() => ({ c: 1, d: 0, dp: 0, h: 1, l: 1, o: 1, pc: 1, t: 1 })); + try { + const why: string[] = []; + for (let round = 0; round < 4; round++) { + const a = await quotes(Array.from({ length: 20 }, (_, i) => `B${round}X${i}`)); + why.push(...a.missing.map((m) => m.why)); + } + assert.ok(f.asked.length <= 50, `asked ${f.asked.length} times`); + assert.ok(why.length >= 30 && why.every((w) => w === 'Busy, try again in a minute')); + } finally { + f.restore(); + delete process.env.AGENT_OFFICE_FINNHUB_KEY; + } +}); From 207e377a3cdc691985bb3e710daf9b55d5acf3f8 Mon Sep 17 00:00:00 2001 From: johnf Date: Fri, 23 Oct 2026 16:55:11 -0400 Subject: [PATCH 2/2] A read-only portfolio in the market terminal, kept in the browser Type AAPL 10 182.50 or import a positions CSV (Symbol, Quantity and an optional Average Cost column) and the terminal values each holding live, with today's move and profit or loss. Holdings stay in localStorage: the office is only asked for prices by symbol, and nothing signs in to a broker. Co-Authored-By: Claude Opus 5.5 --- docs/configuration.md | 2 + docs/features.md | 2 +- src/client/features/market/portfolio.ts | 159 ++++++++++++++++++++++++ src/client/features/market/ui.css | 6 + src/client/features/market/ui.ts | 100 +++++++++++++-- tests/market.test.ts | 23 ++++ 6 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 src/client/features/market/portfolio.ts diff --git a/docs/configuration.md b/docs/configuration.md index 7337ce8c7..b4c11b468 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,3 +66,5 @@ agent-office tunnel [office@address | url] [--port ] [--office-port ] [--n ## Market terminal The 📈 market terminal (**J**, or *Market terminal* in the Ctrl+K palette) prices crypto pairs like `BTC-USD` from Coinbase's public prices, which need no key. Stocks and the market's headlines come from [Finnhub](https://finnhub.io/register): get a free key there and start the office with it in `AGENT_OFFICE_FINNHUB_KEY`, for example `AGENT_OFFICE_FINNHUB_KEY=... agent-office`. The key stays on the office's server. Browsers ask the office (`/api/market/quotes` and `/api/market/news`, signed-in only), and the office asks Finnhub with the key in a header, so it's never in a page or a URL. The office keeps each price for 10 seconds and the headlines for a minute, so many open terminals stay well inside the free tier's 60 calls a minute. Finnhub's free tier covers US stocks, and its prices can trail the full market by a little. It's no replacement for a broker's feed, and none of it is investment advice. + +The terminal's **Portfolio** needs nothing on the server. What you hold, typed in or imported from a CSV, is kept in that browser's storage and is never sent to the office. The office only sees the symbols it's asked to price. There's no broker connection to configure, on purpose: don't put a brokerage login or a trading API key in the office's environment, where every worker on the machine can read it. diff --git a/docs/features.md b/docs/features.md index 45f3ac2c7..95c472888 100644 --- a/docs/features.md +++ b/docs/features.md @@ -42,7 +42,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Task cards.** A card over each worker's head names what it's on ("Fix Login Redirect") and says in one line what it's doing right now. For Claude workers, Claude Haiku writes it from prompts and recent tools through the `claude` CLI. OpenCode, Codex, Grok, Muse, DeepSeek Harness and Cursor use local task summaries and never invoke Claude to name their tasks. - **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said. - **Search.** Press **/** (or **🔎 Search** in the **☰** menu) to search the chat and every worker's terminal at once, including what they showed before the office restarted. Click a terminal line to open that terminal scrolled to it, with the line lit up. -- **Market terminal.** Press **J** (or pick *Market terminal* in the Ctrl+K palette) for an amber-on-black market screen: a watchlist of stocks and crypto that refreshes every 10 seconds, with each price flashing green or red as it moves, the symbol you click up close, and the market's latest headlines. Type a ticker (`AAPL`) or a coin pair (`BTC-USD`) and press Enter to add it. Your watchlist is kept in your browser. Crypto works out of the box. Stocks and news need a free Finnhub key on the office (see [Configuration](configuration.md#market-terminal)). +- **Market terminal.** Press **J** (or pick *Market terminal* in the Ctrl+K palette) for an amber-on-black market screen: a watchlist of stocks and crypto that refreshes every 10 seconds, with each price flashing green or red as it moves, the symbol you click up close, and the market's latest headlines. Type a ticker (`AAPL`) or a coin pair (`BTC-USD`) and press Enter to add it. Your watchlist is kept in your browser. Under it, **Portfolio** values what you hold, live: type `AAPL 10 182.50` (10 shares at $182.50 each; `AAPL 0` forgets it), or **Import CSV** with a positions export from your broker or a sheet of your own with *Symbol* and *Quantity* columns (and *Average Cost*, for profit and loss). It shows each holding's value, today's move and profit or loss, with the totals. What you hold stays in that browser: the office is only asked for prices by symbol, and nothing signs in to a broker. Crypto works out of the box. Stocks and news need a free Finnhub key on the office (see [Configuration](configuration.md#market-terminal)). - **Command palette.** Press **Ctrl+K** (**⌘K** on a Mac) anywhere in the office to find anything on your floor by typing a few letters: workers (by name, task card or desk), issues, pull requests, services, the boards, teammates, and actions (hire a worker, open the task queue, settings, invite, hang a picture). Words, word starts and scattered letters all match, best first, so "login" finds the worker whose card says *Fix Login Redirect*. **↑ ↓** choose and **Enter** opens it the way clicking it in the office does: a worker's terminal, a PR's window, a service in a new tab. **Shift+Enter** walks your character over to it first and opens it when you get there (take a step yourself to call it off). **Esc** or **Ctrl+K** again puts it away. It doesn't open while you're typing in a terminal or a text box, where Ctrl+K is the terminal's own (cut to the end of the line). - **Cost per worker.** Claude Code workers show tracked session cost and tokens. OpenCode workers show tokens and model/provider-reported cost estimates, including child-session usage, with a cache/reasoning breakdown in tooltips. These estimates are not billing; missing cost is shown as unavailable. Codex workers show the root session’s recorded tokens, including cache and reasoning breakdowns; cost and API-call count are unavailable. DeepSeek Harness workers show what is in each session's context window (and the window's size) from ACP's usage updates, plus a cost only when the harness reports one. Today's and all-time totals, `--budget` warnings and `--budget-pause` use tracked Claude Code spend only; they cannot cap OpenCode, Codex or DeepSeek Harness spending. Once the tracked budget is spent, the hiring pause applies to all new workers. OpenCode and Codex session snapshots and Claude totals survive restarts. Current-desk totals disappear when their workers are sent home. - **Machine monitor and worker limit.** A monitor on the west wall, between the windows, shows how busy the office's machine is: CPU and memory right now, the last five minutes of each, and how many workers the office runs. Start with `--max-workers `, or have an admin set a limit under **⚙️** → *Worker limit*, and the office runs at most that many workers at once across every floor (shells and board agents count too). Hiring past it is refused with a message saying why, and the task queue waits for room instead of failing its tasks. In the office, the limit can be lowered but never raised past `--max-workers`. When memory is 90% used, or the CPU has been 90% busy for half a minute, the monitor says the machine is under pressure, the hint at an empty desk says so, and the hire dialog warns you before you hire. diff --git a/src/client/features/market/portfolio.ts b/src/client/features/market/portfolio.ts new file mode 100644 index 000000000..211ec1d3c --- /dev/null +++ b/src/client/features/market/portfolio.ts @@ -0,0 +1,159 @@ +// The 📈 market terminal's portfolio: what you hold, typed in or read from a CSV you export from your +// broker. It never leaves this browser: the office is only ever asked for the symbols' prices, never +// how many you hold, and there's no sign-in to any broker. +import { cleanSymbol, type Quote } from '../../../shared/market'; + +export interface Holding { + symbol: string; + qty: number; + /** What you paid a share on average, when you know it. */ + cost?: number; +} + +export interface HoldingRow extends Holding { + quote?: Quote; + value?: number; + /** Today's move on what you hold (against the previous close, or 24 hours for a coin). */ + day?: number; + /** What it's up or down against what you paid. */ + gain?: number; +} + +const KEY = 'agent-office.market.portfolio'; +/** The most holdings kept, so a stray file can't fill the page. */ +export const MAX_HOLDINGS = 100; + +export function loadHoldings(): Holding[] { + try { + const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); + if (Array.isArray(v)) return merge(v.map(toHolding).filter((x): x is Holding => !!x)); + } catch { + // storage blocked + } + return []; +} + +export function saveHoldings(list: readonly Holding[]) { + try { + if (list.length) localStorage.setItem(KEY, JSON.stringify(list)); + else localStorage.removeItem(KEY); + } catch { + // storage blocked + } +} + +function toHolding(v: unknown): Holding | null { + if (!v || typeof v !== 'object') return null; + const o = v as Record; + const symbol = cleanSymbol(String(o.symbol ?? '')); + const qty = Number(o.qty); + const cost = o.cost === undefined ? undefined : Number(o.cost); + if (!symbol || !Number.isFinite(qty) || qty <= 0) return null; + return { symbol, qty, cost: cost !== undefined && Number.isFinite(cost) && cost >= 0 ? cost : undefined }; +} + +/** The same symbol twice becomes one holding: the shares added up, the cost averaged over them. */ +export function merge(list: readonly Holding[]): Holding[] { + const by = new Map(); + for (const h of list) { + const was = by.get(h.symbol); + if (!was) by.set(h.symbol, { ...h }); + else { + const qty = was.qty + h.qty; + const cost = was.cost !== undefined && h.cost !== undefined ? (was.cost * was.qty + h.cost * h.qty) / qty : (was.cost ?? h.cost); + by.set(h.symbol, { symbol: h.symbol, qty, cost }); + } + } + return [...by.values()].slice(0, MAX_HOLDINGS); +} + +/** A number as a broker's CSV writes it: "$1,234.50", "(12.00)", "10 shares". */ +function money(raw: string | undefined): number { + if (raw === undefined) return NaN; + const s = raw.trim(); + const neg = /^\(.*\)$/.test(s) || s.startsWith('-'); + const n = Number(s.replace(/[^0-9.]/g, '')); + return s.replace(/[^0-9]/g, '') === '' ? NaN : neg ? -n : n; +} + +/** One line of a CSV, with "quoted, fields" and "" for a quote inside one. */ +function cells(line: string): string[] { + const out: string[] = []; + let cur = ''; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quoted) { + if (c === '"' && line[i + 1] === '"') (cur += '"'), i++; + else if (c === '"') quoted = false; + else cur += c; + } else if (c === '"') quoted = true; + else if (c === ',') out.push(cur), (cur = ''); + else cur += c; + } + out.push(cur); + return out.map((s) => s.trim()); +} + +const SYMBOL_COL = /^(symbol|ticker|instrument|ticker symbol)$/i; +const QTY_COL = /^(quantity|qty|shares|units|amount held)$/i; +const COST_COL = /^(average cost|avg cost|average price|avg price|cost per share|average buy price|cost basis per share)$/i; + +/** + * Holdings from a CSV with a header row naming the symbol and quantity columns (and, if it has + * one, the average cost a share): a positions export from a broker, or one you make yourself. + * Lines it can't read are skipped and counted. + */ +export function parseHoldingsCsv(text: string): { holdings: Holding[]; skipped: number; error?: string } { + const lines = text.replace(/^/, '').split(/\r?\n/).filter((l) => l.trim()); + const at = lines.findIndex((l) => { + const c = cells(l); + return c.some((x) => SYMBOL_COL.test(x)) && c.some((x) => QTY_COL.test(x)); + }); + if (at < 0) return { holdings: [], skipped: 0, error: 'No header row with a Symbol and a Quantity column' }; + const head = cells(lines[at]); + const sym = head.findIndex((x) => SYMBOL_COL.test(x)); + const qty = head.findIndex((x) => QTY_COL.test(x)); + const cost = head.findIndex((x) => COST_COL.test(x)); + const out: Holding[] = []; + let skipped = 0; + for (const line of lines.slice(at + 1)) { + const c = cells(line); + const h = toHolding({ symbol: c[sym], qty: money(c[qty]), cost: cost >= 0 ? money(c[cost]) : undefined }); + if (h) out.push(h); + else skipped++; + } + return { holdings: merge(out), skipped }; +} + +/** "AAPL 10" or "AAPL 10 182.50" (shares, then what you paid a share) typed in the terminal's box. */ +export function parseHoldingCommand(raw: string): Holding | { symbol: string; qty: 0 } | null { + const m = raw.trim().match(/^(\S+)\s+([\d.,]+)(?:\s+@?\s*\$?([\d.,]+))?$/); + if (!m) return null; + const symbol = cleanSymbol(m[1]); + const qty = money(m[2]); + const cost = m[3] === undefined ? undefined : money(m[3]); + if (!symbol || !Number.isFinite(qty) || qty < 0) return null; + if (qty === 0) return { symbol, qty: 0 }; + return { symbol, qty, cost: cost !== undefined && Number.isFinite(cost) ? cost : undefined }; +} + +/** Each holding priced with the latest quotes, and the totals over the ones that have a price. */ +export function valueHoldings(list: readonly Holding[], quotes: ReadonlyMap) { + let value = 0; + let day = 0; + let gain = 0; + let paid = 0; + const rows: HoldingRow[] = list.map((h) => { + const quote = quotes.get(h.symbol); + if (!quote) return { ...h }; + const v = quote.price * h.qty; + const d = quote.change * h.qty; + const g = h.cost !== undefined ? v - h.cost * h.qty : undefined; + value += v; + day += d; + if (g !== undefined && h.cost !== undefined) (gain += g), (paid += h.cost * h.qty); + return { ...h, quote, value: v, day: d, gain: g }; + }); + return { rows, value, day, gain, gainPct: paid > 0 ? (gain / paid) * 100 : undefined, priced: rows.filter((r) => r.quote).length }; +} diff --git a/src/client/features/market/ui.css b/src/client/features/market/ui.css index 57553a7a5..b0af496de 100644 --- a/src/client/features/market/ui.css +++ b/src/client/features/market/ui.css @@ -44,3 +44,9 @@ .mkt-grid { grid-template-columns: 1fr; } .mkt-panel { border-right: 0; border-bottom: 1px solid #3a2a00; } } +.mkt-portfolio { margin-top: 14px; } +.mkt-private { float: right; font-weight: 700; letter-spacing: 0; text-transform: none; color: #ffd27a; } +.mkt-totals { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 13px; padding: 4px 0 6px; } +.mkt-actions { display: flex; gap: 8px; margin-top: 8px; } +.mkt-btn { background: #000; color: #ffb000; border: 1px solid #5a4100; border-radius: 4px; padding: 3px 10px; font: 700 12px ui-monospace, Menlo, Consolas, monospace; cursor: pointer; } +.mkt-btn:hover { border-color: #ffb000; } diff --git a/src/client/features/market/ui.ts b/src/client/features/market/ui.ts index d8b584e8d..9a6cbdd57 100644 --- a/src/client/features/market/ui.ts +++ b/src/client/features/market/ui.ts @@ -3,7 +3,8 @@ // straight from the sources, so the office's Finnhub key stays on the server. import './ui.css'; import { cleanSymbol, DEFAULT_WATCHLIST, MAX_SYMBOLS, type Headline, type NewsAnswer, type Quote, type QuotesAnswer } from '../../../shared/market'; -import { h, openModal, timeAgo } from '../../ui/dom'; +import { h, openModal, timeAgo, toast } from '../../ui/dom'; +import { loadHoldings, merge, parseHoldingCommand, parseHoldingsCsv, saveHoldings, valueHoldings, type Holding } from './portfolio'; const LIST_KEY = 'agent-office.market.watchlist'; const QUOTES_EVERY = 10_000; @@ -52,8 +53,26 @@ export function openMarket() { let headlines: Headline[] = []; let newsError = ''; let updated = 0; + let holdings: Holding[] = loadHoldings(); - const cmd = h('input.mkt-cmd', { type: 'text', placeholder: 'Symbol e.g. AAPL or BTC-USD', spellcheck: 'false', autocomplete: 'off', 'aria-label': 'Add a symbol' }); + const cmd = h('input.mkt-cmd', { type: 'text', placeholder: 'Symbol e.g. AAPL or BTC-USD · AAPL 10 182.50 to hold 10 at $182.50', spellcheck: 'false', autocomplete: 'off', 'aria-label': 'Add a symbol or a holding' }); + const file = h('input', { type: 'file', accept: '.csv,text/csv', hidden: true, 'aria-label': 'Import holdings from a CSV' }); + const totals = h('div.mkt-totals'); + const holdRows = h('tbody'); + const portfolio = h( + 'div.mkt-portfolio', + {}, + h('h3', {}, 'Portfolio ', h('span.mkt-private', { title: 'Kept in this browser only. The office is asked for prices by symbol, never what you hold.' }, '🔒 this browser only')), + totals, + h('table.mkt-table', {}, h('thead', {}, h('tr', {}, ...['Ticker', 'Qty', 'Last', 'Value', 'Day', 'P/L', ''].map((t) => h('th', {}, t)))), holdRows), + h( + 'div.mkt-actions', + {}, + h('button.mkt-btn', { type: 'button', onclick: () => file.click() }, 'Import CSV'), + h('button.mkt-btn', { type: 'button', onclick: () => holdings.length && confirm('Forget every holding in this browser?') && setHoldings([]) }, 'Clear'), + file, + ), + ); const status = h('span.mkt-status'); const rows = h('tbody'); const detail = h('div.mkt-detail'); @@ -68,7 +87,7 @@ export function openMarket() { h( 'div.mkt-grid', {}, - h('section.mkt-panel', {}, h('h3', {}, 'Watchlist'), h('table.mkt-table', {}, h('thead', {}, h('tr', {}, ...['Ticker', 'Last', 'Chg', '%Chg', 'High', 'Low', 'Time', ''].map((t) => h('th', {}, t)))), rows)), + h('section.mkt-panel', {}, h('h3', {}, 'Watchlist'), h('table.mkt-table', {}, h('thead', {}, h('tr', {}, ...['Ticker', 'Last', 'Chg', '%Chg', 'High', 'Low', 'Time', ''].map((t) => h('th', {}, t)))), rows), portfolio), h('section.mkt-panel', {}, detail, h('h3', {}, 'Top news'), newsList), ), h('footer', {}, h('span.grow', {}, 'Stocks and news: Finnhub (free tier, US). Crypto: Coinbase. Prices can lag the market by a few seconds; not investment advice.')), @@ -127,6 +146,55 @@ export function openMarket() { ); }; + const money = (n: number | undefined, sign = false) => (n === undefined ? '—' : `${sign && n > 0 ? '+' : n < 0 ? '-' : ''}$${Math.abs(n).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`); + + const renderPortfolio = () => { + const v = valueHoldings(holdings, last); + if (!holdings.length) { + totals.replaceChildren(h('span.dim', {}, 'Type AAPL 10 182.50 above (10 shares at $182.50), or import a positions CSV with Symbol and Quantity columns.')); + return holdRows.replaceChildren(); + } + totals.replaceChildren( + h('span', {}, 'Value ', h('b', {}, money(v.value))), + h(`span.${dir(v.day)}`, {}, 'Day ', h('b', {}, money(v.day, true))), + h(`span.${dir(v.gain)}`, {}, 'P/L ', h('b', {}, money(v.gain, true)), v.gainPct === undefined ? '' : ` (${signed(v.gainPct)}%)`), + v.priced < holdings.length ? h('span.dim', {}, `${holdings.length - v.priced} unpriced`) : '', + ); + holdRows.replaceChildren( + ...v.rows.map((r) => + h( + 'tr', + { onclick: () => ((picked = r.symbol), renderRows(), renderDetail()) }, + h('td.sym', {}, r.symbol), + h('td.num', {}, String(r.qty)), + h('td.num', {}, r.quote ? price(r.quote.price) : '—'), + h('td.num', {}, money(r.value)), + h(`td.num.${dir(r.day ?? 0)}`, {}, money(r.day, true)), + h(`td.num.${dir(r.gain ?? 0)}`, { title: r.cost === undefined ? 'No cost given' : `Paid ${money(r.cost)} a share` }, money(r.gain, true)), + h('td', {}, h('button.mkt-x', { type: 'button', title: `Forget ${r.symbol}`, 'aria-label': `Forget ${r.symbol}`, onclick: (e: Event) => (e.stopPropagation(), setHoldings(holdings.filter((x) => x.symbol !== r.symbol))) }, '×')), + ), + ), + ); + }; + + function setHoldings(next: Holding[]) { + holdings = next; + saveHoldings(holdings); + renderPortfolio(); + void refreshQuotes(); + } + + file.addEventListener('change', async () => { + const f = file.files?.[0]; + file.value = ''; + if (!f) return; + if (f.size > 1024 * 1024) return void toast('That file is over 1 MB: export just your positions', 'warn'); + const got = parseHoldingsCsv(await f.text()); + if (got.error) return void toast(got.error, 'warn'); + setHoldings(merge([...got.holdings])); + toast(`Imported ${got.holdings.length} holdings${got.skipped ? `, skipped ${got.skipped} lines` : ''}`); + }); + const renderStatus = () => { status.textContent = updated ? `Updated ${clock(updated)}` : 'Connecting…'; }; @@ -144,19 +212,25 @@ export function openMarket() { let quoting = false; async function refreshQuotes() { - if (quoting || !list.length) return; + const symbols = [...new Set([...list, ...holdings.map((x) => x.symbol)])]; + if (quoting || !symbols.length) return; quoting = true; try { - const a = await getJson(`/api/market/quotes?s=${encodeURIComponent(list.join(','))}`); + const chunks: string[][] = []; + for (let i = 0; i < symbols.length; i += MAX_SYMBOLS) chunks.push(symbols.slice(i, i + MAX_SYMBOLS)); + const answers = await Promise.all(chunks.map((c) => getJson(`/api/market/quotes?s=${encodeURIComponent(c.join(','))}`))); const before = new Map(last); - for (const q of a.quotes) last.set(q.symbol, q); - missing = new Map(a.missing.map((m) => [m.symbol, m.why])); - for (const m of a.missing) last.delete(m.symbol); - stocks = a.stocks; + missing = new Map(); + for (const a of answers) { + for (const q of a.quotes) last.set(q.symbol, q); + for (const m of a.missing) missing.set(m.symbol, m.why), last.delete(m.symbol); + stocks = a.stocks; + } updated = Date.now(); renderKeyNote(); renderRows(); renderDetail(); + renderPortfolio(); flash(before); } catch (err) { status.textContent = `Offline: ${err instanceof Error ? err.message : 'no answer'}`; @@ -192,6 +266,13 @@ export function openMarket() { cmd.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); + const held = parseHoldingCommand(cmd.value); + if (held) { + cmd.value = ''; + const rest = holdings.filter((x) => x.symbol !== held.symbol); + setHoldings(held.qty ? [{ ...held }, ...rest] : rest); + return; + } const s = cleanSymbol(cmd.value); if (!s) { cmd.classList.add('bad'); @@ -223,6 +304,7 @@ export function openMarket() { renderRows(); renderDetail(); renderNews(); + renderPortfolio(); renderStatus(); void refreshQuotes(); void refreshNews(); diff --git a/tests/market.test.ts b/tests/market.test.ts index dcc9a9c86..db4059cf0 100644 --- a/tests/market.test.ts +++ b/tests/market.test.ts @@ -86,3 +86,26 @@ test('the office asks Finnhub at most 50 times a minute, however many symbols ar delete process.env.AGENT_OFFICE_FINNHUB_KEY; } }); + +test('holdings come from a broker-style CSV or a typed line, and are priced with the quotes', async () => { + const { parseHoldingsCsv, parseHoldingCommand, valueHoldings, merge } = await import('../src/client/features/market/portfolio.js'); + const csv = 'Account summary\r\n"Symbol","Name","Quantity","Average Cost"\r\nAAPL,"Apple, Inc.",10,"$150.00"\r\nBTC-USD,Bitcoin,0.5,"$60,000.00"\r\naapl,Apple,5,$180\r\nTOTAL,,,\r\n../x,Bad,3,1\r\n'; + const got = parseHoldingsCsv(csv); + assert.deepEqual(got.holdings, [ + { symbol: 'AAPL', qty: 15, cost: 160 }, + { symbol: 'BTC-USD', qty: 0.5, cost: 60000 }, + ]); + assert.equal(got.skipped, 2); + assert.match(parseHoldingsCsv('a,b\n1,2').error ?? '', /Symbol/); + assert.deepEqual(parseHoldingCommand('msft 3 @ $400'), { symbol: 'MSFT', qty: 3, cost: 400 }); + assert.deepEqual(parseHoldingCommand('MSFT 0'), { symbol: 'MSFT', qty: 0 }); + assert.equal(parseHoldingCommand('AAPL'), null); + assert.deepEqual(merge([{ symbol: 'X', qty: 1 }, { symbol: 'X', qty: 2, cost: 5 }]), [{ symbol: 'X', qty: 3, cost: 5 }]); + const q = (symbol: string, price: number, change: number) => ({ symbol, price, change, changePct: 0, high: 0, low: 0, open: 0, at: 0, source: 'finnhub' as const }); + const v = valueHoldings(got.holdings, new Map([['AAPL', q('AAPL', 200, 2)]])); + assert.equal(v.value, 3000); + assert.equal(v.day, 30); + assert.equal(v.gain, 600); + assert.equal(v.gainPct, 25); + assert.equal(v.priced, 1); +});