Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **🎤**) |
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,9 @@ agent-office tunnel [office@address | url] [--port <n>] [--office-port <n>] [--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.

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.
1 change: 1 addition & 0 deletions docs/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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 <n>`, 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.
Expand Down
9 changes: 9 additions & 0 deletions src/client/features/market/index.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
159 changes: 159 additions & 0 deletions src/client/features/market/portfolio.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, Holding>();
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<string, Quote>) {
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 };
}
52 changes: 52 additions & 0 deletions src/client/features/market/ui.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* 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; }
}
.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; }
Loading