diff --git a/src/client/features/arcade/index.ts b/src/client/features/arcade/index.ts
index 12a1e58a7..90e86e5a5 100644
--- a/src/client/features/arcade/index.ts
+++ b/src/client/features/arcade/index.ts
@@ -1,12 +1,12 @@
import type { Ctx } from '../../core/context';
-import { Arcade } from './ui';
+import { Arcade, type ArcadeDeps } from './ui';
-/** The boss's monitor upstairs: Minesweeper, from the boss's chair (the chair's E plays it, see features/seating). */
-export function installArcade(ctx: Ctx): Arcade {
- // The boss's monitor upstairs: Minesweeper, from the boss's chair.
- const arcade = new Arcade(ctx.office.bossScreen);
+/** The boss's monitor upstairs: Boss Worker Control System, from the boss's chair (the chair's E opens it, see features/seating). */
+export function installArcade(ctx: Ctx, deps?: ArcadeDeps): Arcade {
+ // The boss's monitor upstairs: Boss Worker Control System, from the boss's chair.
+ const arcade = new Arcade(ctx.office.bossScreen, ctx.net, deps);
ctx.ticks.add('play', ({ dt }) => arcade.update(ctx.camera, dt));
- // With the camera up at the monitor, the game has the screen: no hands drawn over it.
+ // With the camera up at the monitor, the system has the screen: no hands drawn over it.
ctx.view.add({ covers: () => arcade.zoomed });
return arcade;
}
diff --git a/src/client/features/arcade/ui.ts b/src/client/features/arcade/ui.ts
index 77cb2aca1..32265f981 100644
--- a/src/client/features/arcade/ui.ts
+++ b/src/client/features/arcade/ui.ts
@@ -1,10 +1,25 @@
import * as THREE from 'three';
-import { h, openModal, type Modal } from '../../ui/dom';
-import { H, Minesweeper, W } from './minesweeper';
+import { store } from '../../state';
+import { needyFirst } from '../../nextup';
+import { h, openModal, STATUS_LABEL, toast, type Modal } from '../../ui/dom';
+import type { Net } from '../../net';
+import { fmtCost, fmtTokens, tokensOf } from '../../../shared/protocol';
+import { exportFloorReport, findMostIdleWorker } from '../../ui/boss-helpers';
+import { playIntercomSound } from '../../sound/alerts';
+import type { OfficeSound } from '../../sound';
-/** How much of the view (across or down, whichever runs out first) a screen fills while you play on it. */
+/** Screen width and height units for the monitor texture. */
+export const W = 960;
+export const H = 540;
+
+/** How much of the view a screen fills while you look at it. */
const FILL = 0.8;
+export interface ArcadeDeps {
+ openWorkerTerminal?: (id: string) => void;
+ sound?: OfficeSound;
+}
+
/**
* Glides the camera up to a screen in the office while you use it, and back after. The camera looks
* straight at the screen, so whatever is laid over it on the page is a plain centered box (see `box`).
@@ -54,21 +69,23 @@ export class ScreenZoom {
}
/**
- * The boss's monitor, which plays Minesweeper (minesweeper.ts). The monitor shows the board as it
- * was left. Sit down and play, and the camera glides up to the screen while a board you can click is
- * laid exactly over it. The camera looks straight at the screen, so that board is a plain centered box.
+ * The boss's monitor, which runs the Boss Worker Control System. The monitor shows a glowing, live
+ * command center terminal dashboard with Executive RGB Monitor Glow. Sitting down and pressing E
+ * opens the Boss Control Center modal allowing full management of all workers on the floor.
*/
export class Arcade {
private modal: Modal | null = null;
private readonly view: ScreenZoom;
- private readonly game = new Minesweeper();
- /** What the monitor shows. */
+ /** What the 3D monitor screen shows. */
private readonly picture = document.createElement('canvas');
private readonly texture = new THREE.CanvasTexture(this.picture);
- /** The board you click while playing, drawn at the size it shows on screen so it stays crisp. */
- private board: HTMLCanvasElement | null = null;
+ private storeOff: (() => void) | null = null;
- constructor(screen: THREE.Mesh) {
+ constructor(
+ private readonly screen: THREE.Mesh,
+ private readonly net: Net,
+ private readonly deps?: ArcadeDeps,
+ ) {
this.view = new ScreenZoom(screen);
this.picture.width = W;
this.picture.height = H;
@@ -78,7 +95,9 @@ export class Arcade {
mat.color.set('#ffffff');
mat.toneMapped = false;
this.draw();
- // Canvas text only picks up the office's font once it has loaded.
+
+ // Re-draw the 3D monitor screen canvas whenever worker states change in store.
+ store.on('workers', () => this.draw());
void document.fonts.ready.then(() => this.draw());
}
@@ -87,119 +106,511 @@ export class Arcade {
return this.view.zoomed;
}
- /** Puts it down, if you're at it (the building changed maps under you). */
+ /** Puts it down, if you're at it (e.g. building changed maps). */
stop() {
this.modal?.close();
}
+ /** Opens the Boss Worker Control System Modal Window. */
play() {
if (this.modal) return;
- const game = this.game;
- // A finished game stays up on the monitor until the next player sits down to a fresh one.
- if (game.state === 'won' || game.state === 'lost') game.reset();
- const board = h('canvas', { 'aria-label': 'Minesweeper board' });
- const stop = h('button.btn', { type: 'button' }, 'β Stop playing');
- const box = h(
- 'div.arcade',
- { role: 'dialog', 'aria-label': 'Minesweeper' },
- h('div.arcade-screen', {}, board),
- h('div.arcade-bar', {}, h('span', {}, 'π£ Minesweeper'), h('span.tip', {}, 'Click to dig Β· right-click to flag'), stop),
+
+ // --- 1. Broadcast Bar components (with Intercom Sound & Auto-Assign) ---
+ const broadcastInput = h('input.boss-broadcast-input', {
+ type: 'text',
+ placeholder: 'Broadcast prompt to all workers...',
+ }) as HTMLInputElement;
+
+ const broadcastBtn = h('button.boss-broadcast-btn', { type: 'button' }, 'Broadcast');
+
+ const autoAssignBtn = h(
+ 'button',
+ {
+ type: 'button',
+ title: 'Auto-assign prompt to the most idle worker',
+ style: 'background:rgba(56, 189, 248, 0.15); border:1px solid #38bdf8; color:#38bdf8; font-weight:700; font-size:11px; padding:4px 10px; border-radius:8px; cursor:pointer; margin-left:4px;',
+ onclick: () => {
+ let text = broadcastInput.value.trim();
+ if (!text) {
+ const userPrompt = window.prompt('Enter prompt to auto-assign to the most idle worker:');
+ if (!userPrompt || !userPrompt.trim()) return;
+ text = userPrompt.trim();
+ }
+
+ const worker = findMostIdleWorker(store.workers.values());
+ if (!worker) {
+ toast('No idle workers available to assign prompt', 'warn');
+ return;
+ }
+
+ playIntercomSound(this.deps?.sound);
+ this.net.send({ t: 'worker.prompt', workerId: worker.id, prompt: text });
+ broadcastInput.value = '';
+ toast(`Auto-assigned prompt to ${worker.name}`);
+ },
+ },
+ 'β‘ Auto-Assign',
);
- // Where the mouse is, in the game's 960Γ540.
- const spot = (e: MouseEvent) => ({ x: (e.offsetX * W) / board.clientWidth, y: (e.offsetY * H) / board.clientHeight });
- let holding = false;
- board.addEventListener('pointerdown', (e) => {
- const { x, y } = spot(e);
- const i = game.cellAt(x, y);
- // Right-click flags, and so do Ctrl- and Shift-click for a trackpad. The middle button chords.
- if (e.button === 2 || (e.button === 0 && (e.ctrlKey || e.shiftKey))) game.flag(i);
- else if (e.button === 1) game.chord(i);
- else if (e.button === 0 && game.onFace(x, y)) game.reset();
- else if (e.button === 0) {
- // It digs when you let go, wherever you let go, like the original.
- holding = true;
- game.pressed = i;
- board.setPointerCapture(e.pointerId);
+ const doBroadcast = () => {
+ const text = broadcastInput.value.trim();
+ if (!text) return;
+ const workers = [...store.workers.values()];
+ if (!workers.length) {
+ toast('No active workers to broadcast to', 'warn');
+ return;
}
- this.draw();
- });
- board.addEventListener('pointermove', (e) => {
- const { x, y } = spot(e);
- const i = game.cellAt(x, y);
- if (i === game.hover) return;
- game.hover = i;
- if (holding) game.pressed = i;
- this.draw();
- });
- board.addEventListener('pointerup', (e) => {
- if (e.button !== 0 || !holding) return;
- holding = false;
- const i = game.pressed;
- game.pressed = -1;
- // A click on a number digs around it, once its mines are all flagged.
- if (game.isOpen(i)) game.chord(i);
- else game.open(i);
- this.draw();
- });
- board.addEventListener('pointerleave', () => {
- if (holding) return;
- game.hover = -1;
- this.draw();
+
+ playIntercomSound(this.deps?.sound);
+
+ for (const w of workers) {
+ this.net.send({ t: 'worker.prompt', workerId: w.id, prompt: text });
+ }
+ broadcastInput.value = '';
+ toast(`Broadcast sent to ${workers.length} worker(s)`);
+ };
+
+ broadcastBtn.addEventListener('click', doBroadcast);
+ broadcastInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') doBroadcast();
});
- // No menu on right-click, and no scrolling or selecting on a click.
- board.addEventListener('contextmenu', (e) => e.preventDefault());
- board.addEventListener('mousedown', (e) => e.preventDefault());
-
- // The clock only runs while someone's at the monitor.
- let last = performance.now();
- const clock = setInterval(() => {
- const now = performance.now();
- if (game.tick(now - last)) this.draw();
- last = now;
- }, 250);
+
+ const broadcastBar = h('div.boss-broadcast', {}, broadcastInput, broadcastBtn, autoAssignBtn);
+
+ // --- 2. Action Header (Export Report, Send All Home) ---
+ const exportBtn = h(
+ 'button',
+ {
+ type: 'button',
+ title: 'Export Floor Report as Markdown',
+ style: 'background:rgba(34, 197, 94, 0.15); border:1px solid #22c55e; color:#4ade80; font-weight:700; font-size:11px; padding:4px 10px; border-radius:8px; cursor:pointer; margin-right:6px;',
+ onclick: () => {
+ exportFloorReport();
+ },
+ },
+ 'π₯ Export Report',
+ );
+
+ const sendAllBtn = h(
+ 'button',
+ {
+ type: 'button',
+ class: 'danger',
+ onclick: () => {
+ const workers = [...store.workers.values()];
+ if (!workers.length) {
+ toast('No workers to send home', 'warn');
+ return;
+ }
+ if (confirm(`Send all ${workers.length} worker(s) home?`)) {
+ for (const w of workers) {
+ this.net.send({ t: 'worker.kill', workerId: w.id });
+ }
+ toast(`Sent all ${workers.length} worker(s) home`);
+ }
+ },
+ },
+ 'Send All Home',
+ );
+
+ const modalHeader = h(
+ 'div.boss-header',
+ {},
+ h('h3', {}, 'π Boss Control Center'),
+ h('div.boss-actions', {}, exportBtn, sendAllBtn),
+ );
+
+ // --- 3. Live Analytics Summary Bar ---
+ const summaryBar = h('div.boss-summary-bar', {}) as HTMLDivElement;
+
+ const refreshSummaryBar = () => {
+ summaryBar.replaceChildren();
+ const workersList = [...store.workers.values()];
+ const totalCount = workersList.length;
+ const workingCount = workersList.filter((w) => w.status === 'working').length;
+ const needsInputCount = workersList.filter((w) => w.status === 'needs_input').length;
+
+ let totalTokens = 0;
+ let totalCost = 0;
+ for (const w of workersList) {
+ if (w.usage) {
+ totalTokens += tokensOf(w.usage);
+ totalCost += w.usage.cost || 0;
+ }
+ }
+
+ const content = h(
+ 'div.boss-summary-bar-content',
+ {},
+ h('div.boss-stat-item', { title: 'Total Workers' }, h('span.label', {}, 'Workers'), h('span.val', {}, String(totalCount))),
+ h('div.boss-stat-item', { title: 'Working / Active' }, h('span.label', {}, 'Working'), h('span.val.working', {}, String(workingCount))),
+ h(
+ 'div.boss-stat-item',
+ { title: 'Needs Your Input' },
+ h('span.label', {}, 'Needs Input'),
+ h('span', { class: 'val' + (needsInputCount > 0 ? ' needs' : '') }, String(needsInputCount)),
+ ),
+ h(
+ 'div.boss-stat-item',
+ { title: 'Total Token / Dollar Spend' },
+ h('span.label', {}, 'Total Spend'),
+ h('span.val.spend', {}, `${fmtCost(totalCost)} Β· ${fmtTokens(totalTokens)}`),
+ ),
+ );
+ summaryBar.append(content);
+ };
+
+ refreshSummaryBar();
+
+ // --- 4. Worker List container ---
+ const workerListEl = h('ul.boss-workers', {}) as HTMLUListElement;
+
+ const refreshList = () => {
+ refreshSummaryBar();
+ workerListEl.replaceChildren();
+ const workers = needyFirst(store.workers.values());
+ if (!workers.length) {
+ workerListEl.append(h('li.boss-empty', {}, 'No workers hired on this floor yet.'));
+ return;
+ }
+
+ for (const w of workers) {
+ const statusClass =
+ w.status === 'needs_input'
+ ? 'needs_input'
+ : w.status === 'working'
+ ? 'working'
+ : w.status === 'done'
+ ? 'done'
+ : 'idle';
+
+ const statusText = w.status === 'needs_input' ? 'NEEDS YOU' : (STATUS_LABEL[w.status] ?? w.status);
+
+ const terminalBtn = h(
+ 'button.boss-worker-terminal',
+ {
+ type: 'button',
+ title: `Open ${w.name}'s terminal`,
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ this.deps?.openWorkerTerminal?.(w.id);
+ },
+ },
+ 'Terminal',
+ );
+
+ const sendHomeBtn = h(
+ 'button.boss-worker-send-home',
+ {
+ type: 'button',
+ title: `Send ${w.name} home`,
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ if (confirm(`Send ${w.name} home?`)) {
+ this.net.send({ t: 'worker.kill', workerId: w.id });
+ }
+ },
+ },
+ 'Send home',
+ );
+
+ const card = h(
+ 'li.boss-worker-card',
+ {
+ title: `Open ${w.name}'s terminal`,
+ onclick: () => this.deps?.openWorkerTerminal?.(w.id),
+ },
+ h('span.boss-worker-dot', { style: `background:${w.color}` }),
+ h(
+ 'span.boss-worker-info',
+ {},
+ h('div.boss-worker-name', {}, w.name),
+ h('div.boss-worker-activity', {}, w.activity ?? w.title ?? w.prompt ?? 'β'),
+ ),
+ h('span.boss-worker-status', { class: statusClass }, statusText),
+ terminalBtn,
+ sendHomeBtn,
+ );
+
+ workerListEl.append(card);
+ }
+ };
+
+ refreshList();
+
+ // --- 5. Assemble Modal Window Structure ---
+ const stopBtn = h('button.btn', { type: 'button' }, 'β Exit Boss System');
+ const screenContent = h('div.arcade-screen', { style: 'display:flex; flex-direction:column;' }, modalHeader, summaryBar, broadcastBar, workerListEl);
+ const box = h(
+ 'div.arcade',
+ { role: 'dialog', 'aria-label': 'Boss Worker Control System' },
+ screenContent,
+ h('div.arcade-bar', {}, h('span', {}, 'π Boss Control Center'), h('span.tip', {}, 'Control all workers from one place'), stopBtn),
+ );
const fit = () => {
const { width, height } = this.view.box();
box.style.width = `${width}px`;
box.style.height = `${height}px`;
- board.width = Math.round(width * devicePixelRatio);
- board.height = Math.round(height * devicePixelRatio);
this.draw();
};
- this.board = board;
fit();
window.addEventListener('resize', fit);
+
+ const onWorkersStoreChange = () => {
+ refreshList();
+ this.draw();
+ };
+ this.storeOff = store.on('workers', onWorkersStoreChange);
+
this.modal = openModal(box, {
backdropCloses: false,
- doing: 'π£ playing Minesweeper',
+ doing: 'π Boss Control Center',
onClose: () => {
window.removeEventListener('resize', fit);
- clearInterval(clock);
+ if (this.storeOff) {
+ this.storeOff();
+ this.storeOff = null;
+ }
this.modal = null;
- this.board = null;
- game.hover = game.pressed = -1;
this.draw();
},
});
+
this.modal.backdrop.classList.add('clear');
- stop.addEventListener('click', () => this.modal?.close());
+ stopBtn.addEventListener('click', () => this.modal?.close());
}
- /** Moves the camera toward the monitor while you play, and back after. Call it once the player has placed the camera. */
+ /** Moves the camera toward the monitor while you look at it, and back after. */
update(camera: THREE.PerspectiveCamera, dt: number) {
this.view.update(camera, dt, !!this.modal);
}
- /** Draws the game on the board while you play, and on the monitor otherwise (the board covers it while you play). */
- private draw() {
- if (this.board) {
- const g = this.board.getContext('2d')!;
- g.setTransform(this.board.width / W, 0, 0, this.board.height / H, 0, 0);
- this.game.paint(g, false);
+ /**
+ * Draws the glowing Boss Command Center terminal dashboard onto the 3D monitor canvas (`this.picture`)
+ * and updates Executive RGB Monitor Glow tint on the 3D monitor screen mesh.
+ */
+ public draw() {
+ const g = this.picture.getContext('2d');
+ if (!g) return;
+
+ const FONT = "Nunito, ui-rounded, 'SF Pro Rounded', system-ui, sans-serif";
+
+ // --- Calculate Worker Stats & Executive RGB Status ---
+ const workers = [...store.workers.values()];
+ const totalCount = workers.length;
+ const workingCount = workers.filter((w) => w.status === 'working').length;
+ const needsInputCount = workers.filter((w) => w.status === 'needs_input').length;
+ const doneCount = workers.filter((w) => w.status === 'done').length;
+
+ let totalTokens = 0;
+ let totalCost = 0;
+ for (const w of workers) {
+ if (w.usage) {
+ totalTokens += tokensOf(w.usage);
+ totalCost += w.usage.cost || 0;
+ }
+ }
+
+ // --- Executive RGB Monitor Glow status determination ---
+ let rgbHex = '#f5a623';
+ let rgbLabel = 'SYSTEM ONLINE';
+ let rgbBadgeBg = '#166534';
+ let rgbBadgeFg = '#86efac';
+
+ if (needsInputCount > 0) {
+ rgbHex = '#ef4444';
+ rgbLabel = 'RGB: RED ALERT (NEEDS YOU)';
+ rgbBadgeBg = '#7f1d1d';
+ rgbBadgeFg = '#fca5a5';
+ } else if (totalCount > 0 && workers.every((w) => w.status === 'done' || w.status === 'exited')) {
+ rgbHex = '#22c55e';
+ rgbLabel = 'RGB: GREEN (ALL FINISHED)';
+ rgbBadgeBg = '#166534';
+ rgbBadgeFg = '#86efac';
+ } else if (workingCount > 0) {
+ rgbHex = '#00f0ff';
+ rgbLabel = 'RGB: BLUE (WORKING ACTIVE)';
+ rgbBadgeBg = '#075985';
+ rgbBadgeFg = '#a5f3fc';
+ }
+
+ // Update 3D monitor mesh screen material color tint dynamically
+ const mat = this.screen.material as THREE.MeshBasicMaterial;
+ if (mat) {
+ mat.color.set(rgbHex);
+ }
+
+ // --- Background ---
+ g.fillStyle = '#080c14';
+ g.fillRect(0, 0, W, H);
+
+ // --- Cybernetic Grid Lines ---
+ g.strokeStyle = 'rgba(0, 240, 255, 0.04)';
+ g.lineWidth = 1;
+ for (let x = 0; x < W; x += 30) {
+ g.beginPath();
+ g.moveTo(x, 0);
+ g.lineTo(x, H);
+ g.stroke();
+ }
+ for (let y = 0; y < H; y += 30) {
+ g.beginPath();
+ g.moveTo(0, y);
+ g.lineTo(W, y);
+ g.stroke();
+ }
+
+ // --- Outer Frame with Dynamic RGB Accent ---
+ g.strokeStyle = rgbHex;
+ g.lineWidth = 2;
+ g.strokeRect(15, 15, W - 30, H - 30);
+
+ // Corner accents
+ g.fillStyle = rgbHex;
+ g.fillRect(12, 12, 12, 4);
+ g.fillRect(12, 12, 4, 12);
+ g.fillRect(W - 24, 12, 12, 4);
+ g.fillRect(W - 16, 12, 4, 12);
+ g.fillRect(12, H - 16, 12, 4);
+ g.fillRect(12, H - 24, 4, 12);
+ g.fillRect(W - 24, H - 16, 12, 4);
+ g.fillRect(W - 16, H - 24, 4, 12);
+
+ // --- Header Banner ---
+ g.fillStyle = '#101827';
+ g.beginPath();
+ g.roundRect(25, 25, W - 50, 55, 8);
+ g.fill();
+ g.strokeStyle = '#1f293d';
+ g.stroke();
+
+ g.font = `800 24px ${FONT}`;
+ g.textAlign = 'left';
+ g.textBaseline = 'middle';
+ g.fillStyle = '#f5a623';
+ g.fillText('π BOSS WORKER CONTROL SYSTEM', 45, 52);
+
+ // Live status badge with RGB indicator
+ g.fillStyle = rgbBadgeBg;
+ g.beginPath();
+ g.roundRect(W - 275, 37, 240, 30, 15);
+ g.fill();
+ g.fillStyle = rgbBadgeFg;
+ g.beginPath();
+ g.arc(W - 257, 52, 5, 0, Math.PI * 2);
+ g.fill();
+ g.font = `700 12px ${FONT}`;
+ g.fillStyle = rgbBadgeFg;
+ g.fillText(rgbLabel, W - 243, 52);
+
+ // --- Stat Summary Cards (5 Cards) ---
+ const cardWidth = 172;
+ const cardGap = 13;
+ const startX = 25;
+
+ const drawCard = (idx: number, title: string, val: string, color: string) => {
+ const x = startX + idx * (cardWidth + cardGap);
+ g.fillStyle = '#0d131f';
+ g.beginPath();
+ g.roundRect(x, 95, cardWidth, 65, 8);
+ g.fill();
+ g.strokeStyle = '#1f293d';
+ g.stroke();
+
+ g.font = `700 11px ${FONT}`;
+ g.fillStyle = '#64748b';
+ g.fillText(title, x + 12, 115);
+
+ g.font = `900 22px ${FONT}`;
+ g.fillStyle = color;
+ g.fillText(val, x + 12, 142);
+ };
+
+ drawCard(0, 'TOTAL WORKERS', String(totalCount), '#38bdf8');
+ drawCard(1, 'ACTIVE WORKING', String(workingCount), '#4ade80');
+ drawCard(2, 'NEEDS INPUT', String(needsInputCount), needsInputCount > 0 ? '#ef4444' : '#9ca3af');
+ drawCard(3, 'TOTAL TOKENS', fmtTokens(totalTokens), '#a855f7');
+ drawCard(4, 'TOTAL SPEND', fmtCost(totalCost), '#f5a623');
+
+ // --- Main Monitor Feed Panel ---
+ g.fillStyle = '#0d131f';
+ g.beginPath();
+ g.roundRect(25, 175, W - 50, 290, 8);
+ g.fill();
+ g.strokeStyle = '#1f293d';
+ g.stroke();
+
+ g.font = `700 13px ${FONT}`;
+ g.fillStyle = '#00f0ff';
+ g.fillText('LIVE WORKER MONITOR FEED', 45, 198);
+
+ if (!totalCount) {
+ g.font = `800 22px ${FONT}`;
+ g.fillStyle = '#475569';
+ g.textAlign = 'center';
+ g.fillText('NO WORKERS HIRED ON THIS FLOOR', W / 2, 300);
+ g.font = `600 15px ${FONT}`;
+ g.fillStyle = '#334155';
+ g.fillText('Sit in the boss chair and press E to open the control system', W / 2, 335);
} else {
- this.game.paint(this.picture.getContext('2d')!, true);
- this.texture.needsUpdate = true;
+ g.textAlign = 'left';
+ const maxShow = 5;
+ const displayWorkers = needyFirst(workers).slice(0, maxShow);
+
+ displayWorkers.forEach((w, idx) => {
+ const rowY = 220 + idx * 46;
+
+ // Row background
+ g.fillStyle = idx % 2 === 0 ? '#131c2e' : '#0f172a';
+ g.beginPath();
+ g.roundRect(40, rowY, W - 80, 40, 6);
+ g.fill();
+
+ // Worker color dot
+ g.fillStyle = w.color || '#38bdf8';
+ g.beginPath();
+ g.arc(58, rowY + 20, 6, 0, Math.PI * 2);
+ g.fill();
+
+ // Worker name
+ g.font = `800 15px ${FONT}`;
+ g.fillStyle = '#f8fafc';
+ g.fillText(w.name, 76, rowY + 20);
+
+ // Status Tag
+ const st = w.status;
+ const stLabel = st === 'needs_input' ? 'NEEDS YOU' : (STATUS_LABEL[st] ?? st).toUpperCase();
+ const stBg = st === 'needs_input' ? '#7f1d1d' : st === 'working' ? '#166534' : st === 'done' ? '#1e3a5f' : '#374151';
+ const stFg = st === 'needs_input' ? '#fca5a5' : st === 'working' ? '#86efac' : st === 'done' ? '#93c5fd' : '#9ca3af';
+
+ g.fillStyle = stBg;
+ g.beginPath();
+ g.roundRect(220, rowY + 9, 100, 22, 4);
+ g.fill();
+
+ g.font = `800 11px ${FONT}`;
+ g.fillStyle = stFg;
+ g.textAlign = 'center';
+ g.fillText(stLabel, 270, rowY + 20);
+ g.textAlign = 'left';
+
+ // Activity / Prompt snippet
+ const act = w.activity ?? w.title ?? w.prompt ?? 'β';
+ const truncated = act.length > 55 ? act.slice(0, 52) + '...' : act;
+ g.font = `600 13px ${FONT}`;
+ g.fillStyle = '#94a3b8';
+ g.fillText(truncated, 335, rowY + 20);
+ });
}
+
+ // --- Footer Status line ---
+ g.font = `700 12px ${FONT}`;
+ g.fillStyle = '#475569';
+ g.textAlign = 'center';
+ g.fillText('[ MONITORED BY BOSS COMMAND CENTER ] Β· SIT IN CHAIR & PRESS E TO OPEN CONTROL MODAL', W / 2, 492);
+
+ this.texture.needsUpdate = true;
}
}
+
diff --git a/src/client/features/seating/index.ts b/src/client/features/seating/index.ts
index 068ee9903..a6142fbd1 100644
--- a/src/client/features/seating/index.ts
+++ b/src/client/features/seating/index.ts
@@ -112,11 +112,11 @@ export function installSeating(ctx: Ctx, deps: SeatingDeps) {
if (!seat) return { k: '', parts: [] };
if (ctx.player.seat?.seatId === seat.id) {
const tv = !!seat.tv && tvShowing();
- const use = tv ? 'Watch the TV' : seat.game ? 'Play Minesweeper' : seat.bar ? 'Order a drink' : '';
+ const use = tv ? 'Watch the TV' : seat.game ? 'Open Boss System' : seat.bar ? 'Order a drink' : '';
return { k: `${seat.id}|sitting|${tv}`, parts: [hintTitle(seat.label), aside('sitting'), ...(use ? [key('E', use), key('W A S D', 'Get up')] : [key('E', 'Get up')])] };
}
const full = !freePlace(seat);
- return { k: `${seat.id}|${full}`, parts: [hintTitle(seat.label), seat.game ? aside('π£ Minesweeper on the monitor') : '', full ? aside('no room') : key('E', 'Sit down')] };
+ return { k: `${seat.id}|${full}`, parts: [hintTitle(seat.label), seat.game ? aside('π Boss Control Center on the monitor') : '', full ? aside('no room') : key('E', 'Sit down')] };
},
use: onE((it) => {
if (it.seatId) useSeat(it.seatId);
diff --git a/src/client/index.html b/src/client/index.html
index a4b8fb849..b9f073c78 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -52,6 +52,23 @@
Workers
+
+
Spend
diff --git a/src/client/main.ts b/src/client/main.ts
index 7cc7da7d8..13eea2f5c 100644
--- a/src/client/main.ts
+++ b/src/client/main.ts
@@ -1,5 +1,6 @@
import './style.css';
import { Net } from './net';
+import { initBossMode, refreshBossPanel } from './ui/boss-panel';
import { DesktopNotifier } from './notify';
import { store, loadProfile, loadSettings } from './state';
import { PlayerController, groundAt } from './player';
@@ -101,7 +102,7 @@ installWhiteboard(ctx);
parts.confetti = new Confetti((x, z, y) => groundAt(ctx.player.colliders, x, z, y, false));
ctx.scene.add(parts.confetti.mesh);
parts.tv = installTv(ctx, { shares: () => parts.talk.currentShares(), watch: () => parts.talk.watchShare() });
-parts.arcade = installArcade(ctx);
+parts.arcade = installArcade(ctx, { openWorkerTerminal: (id) => parts.waiting.openWorkerTerminal(id), get sound() { return parts.sound; } });
parts.rooftop = installRooftop(ctx, { ambient: parts.stage.ambient, hemi: parts.stage.hemi });
// You, and how you talk to the office.
@@ -180,6 +181,16 @@ parts.talk = installVoice(ctx, { tv: parts.tv });
installDictation(ctx);
parts.hud = installHud(ctx, core, parts);
+// ---- Boss Mode -------------------------------------------------------------------------------
+// Wire the boss panel up once the HUD and net are ready.
+// `openWorkerTerminal` opens the terminal for a clicked worker, reusing the same mechanism
+// that "Walk up to desk β E β hire" uses for viewing terminals.
+initBossMode(parts.net, (id) => parts.waiting.openWorkerTerminal(id), parts.sound);
+
+// Keep the boss panel in sync whenever the worker list changes.
+store.on('workers', () => refreshBossPanel(parts.net, (id) => parts.waiting.openWorkerTerminal(id)));
+
+
// ---- Main loop ---------------------------------------------------------------------------------------
fitWindow(ctx);
const frame = frameLoop(ctx, loading);
diff --git a/src/client/sound/alerts.ts b/src/client/sound/alerts.ts
index 68fde53a9..ef2f62a76 100644
--- a/src/client/sound/alerts.ts
+++ b/src/client/sound/alerts.ts
@@ -24,3 +24,57 @@ export function ding(a: AudioCore, kind: 'done' | 'needs_input') {
o.stop(t0 + 0.3);
});
}
+
+/** Intercom alert sound: subtle two-tone PA broadcast chime (C5 then E5) when broadcasting prompts. */
+export function intercom(a: AudioCore) {
+ a.unlock();
+ const ctx = a.ctx;
+ if (!ctx) return;
+ if (ctx.state === 'suspended') void ctx.resume();
+ a.count('intercom');
+ const notes = [523.25, 659.25]; // C5 -> E5
+ notes.forEach((f, i) => {
+ const o = ctx.createOscillator();
+ const g = ctx.createGain();
+ o.type = 'sine';
+ o.frequency.value = f;
+ const t0 = ctx.currentTime + i * 0.15;
+ g.gain.setValueAtTime(0.0001, t0);
+ g.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.3);
+ o.connect(g).connect(a.alerts);
+ o.start(t0);
+ o.stop(t0 + 0.32);
+ });
+}
+
+/** Fallback intercom chime player when full OfficeSound context is not directly attached. */
+export function playIntercomSound(sound?: { intercom?: () => void } | (() => { intercom?: () => void } | undefined)) {
+ const s = typeof sound === 'function' ? sound() : sound;
+ if (s && typeof s.intercom === 'function') {
+ s.intercom();
+ return;
+ }
+ try {
+ const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
+ if (!AudioCtx) return;
+ const ctx = new AudioCtx();
+ const notes = [523.25, 659.25];
+ notes.forEach((f, i) => {
+ const o = ctx.createOscillator();
+ const g = ctx.createGain();
+ o.type = 'sine';
+ o.frequency.value = f;
+ const t0 = ctx.currentTime + i * 0.15;
+ g.gain.setValueAtTime(0.0001, t0);
+ g.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.3);
+ o.connect(g).connect(ctx.destination);
+ o.start(t0);
+ o.stop(t0 + 0.32);
+ });
+ } catch {
+ // AudioContext blocked or unavailable
+ }
+}
+
diff --git a/src/client/sound/index.ts b/src/client/sound/index.ts
index 321c2ea74..05560c18e 100644
--- a/src/client/sound/index.ts
+++ b/src/client/sound/index.ts
@@ -15,7 +15,7 @@
*/
import type { GongWhy } from '../../shared/protocol';
import { birdsong, Fridge, nightCrickets, startRoomTone, startWind } from './ambience';
-import { ding } from './alerts';
+import { ding, intercom } from './alerts';
import { arcade } from '../features/cabinet/sound';
import { ball, type BallSound } from '../features/basketball/sound';
import { Dj, hiccup, pour } from '../features/bar/sound';
@@ -268,6 +268,11 @@ export class OfficeSound {
ding(this.a, kind);
}
+ /** Subtle intercom PA broadcast chime played on prompt broadcast. */
+ intercom() {
+ intercom(this.a);
+ }
+
/** The alarm for a worker that needs you, or (`again`) the soft reminder while it still does. */
needsYou(again = false) {
needsYou(this.a, again);
diff --git a/src/client/ui/boss-helpers.ts b/src/client/ui/boss-helpers.ts
new file mode 100644
index 000000000..78401a327
--- /dev/null
+++ b/src/client/ui/boss-helpers.ts
@@ -0,0 +1,125 @@
+// Boss Mode Helpers β report generation, smart auto-assign algorithm, and analytics calculation.
+
+import { store } from '../state';
+import { fmtCost, fmtTokens, tokensOf, type WorkerInfo } from '../../shared/protocol';
+import { toast } from './dom';
+
+/**
+ * Finds the most idle worker on the floor for Smart Auto-Assign.
+ * 1. Returns an idle worker (sorted by least recent input / earliest creation).
+ * 2. If no idle workers, returns a completed/done worker (sorted by longest waiting time).
+ * 3. Fallback: returns any non-working and non-needs_input worker.
+ */
+export function findMostIdleWorker(workers: Iterable): WorkerInfo | null {
+ const all = [...workers];
+ if (!all.length) return null;
+
+ // 1. Idle workers first (sorted by least recent input or earliest creation)
+ const idles = all
+ .filter((w) => w.status === 'idle')
+ .sort((a, b) => (a.lastInput?.at ?? a.createdAt) - (b.lastInput?.at ?? b.createdAt));
+ if (idles.length > 0) return idles[0];
+
+ // 2. Done workers next
+ const dones = all
+ .filter((w) => w.status === 'done')
+ .sort((a, b) => (a.waitingSince ?? a.createdAt) - (b.waitingSince ?? b.createdAt));
+ if (dones.length > 0) return dones[0];
+
+ // 3. Fallback: non-working, non-needs_input workers
+ const fallback = all.filter((w) => w.status !== 'working' && w.status !== 'needs_input');
+ if (fallback.length > 0) return fallback[0];
+
+ return null;
+}
+
+/**
+ * Exports a detailed Markdown summary of the current floor's active workers, open PRs, tasks, and spend.
+ */
+export function exportFloorReport() {
+ const workers = [...store.workers.values()];
+ const floorName = store.project?.name || 'Agent Office';
+ const totalCount = workers.length;
+ const workingCount = workers.filter((w) => w.status === 'working').length;
+ const needsInputCount = workers.filter((w) => w.status === 'needs_input').length;
+ const doneCount = workers.filter((w) => w.status === 'done').length;
+ const idleCount = totalCount - workingCount - needsInputCount - doneCount;
+
+ let totalTokens = 0;
+ let totalCost = 0;
+ for (const w of workers) {
+ if (w.usage) {
+ totalTokens += tokensOf(w.usage);
+ totalCost += w.usage.cost || 0;
+ }
+ }
+
+ const timestamp = new Date();
+ const lines: string[] = [
+ `# π Boss Control Center β Floor Executive Report`,
+ ``,
+ `**Generated At:** ${timestamp.toLocaleString()}`,
+ `**Floor / Project:** ${floorName}`,
+ ``,
+ `---`,
+ ``,
+ `## π Live Analytics Summary`,
+ `- **Total Workers Hired:** ${totalCount}`,
+ `- **Active (Working):** ${workingCount}`,
+ `- **Needing Input:** ${needsInputCount}`,
+ `- **Completed (Done):** ${doneCount}`,
+ `- **Idle / Standby:** ${idleCount}`,
+ `- **Total Tokens Used:** ${fmtTokens(totalTokens)} (${totalTokens.toLocaleString()} tokens)`,
+ `- **Total Dollar Spend:** ${fmtCost(totalCost)}`,
+ ``,
+ `---`,
+ ``,
+ `## π· Worker Breakdown`,
+ ];
+
+ if (!workers.length) {
+ lines.push(`*No workers currently hired on this floor.*`);
+ } else {
+ lines.push(
+ `| Name | ID | Status | Provider / Model | Task / Current Activity | Tokens | Spend | PR |`,
+ `| --- | --- | --- | --- | --- | --- | --- | --- |`,
+ );
+
+ for (const w of workers) {
+ const statusText = w.status === 'needs_input' ? 'NEEDS INPUT' : w.status.toUpperCase();
+ const providerModel = [w.provider, w.model].filter(Boolean).join('/') || 'default';
+ const rawActivity = w.task?.name || w.activity || w.title || w.prompt || 'β';
+ const activity = rawActivity.replace(/\|/g, '\\|');
+ const tokens = w.usage ? fmtTokens(tokensOf(w.usage)) : '0';
+ const spend = w.usage?.cost != null ? fmtCost(w.usage.cost) : '$0.00';
+ const prText = w.pr ? `[#${w.pr.number}](${w.pr.url})` : 'β';
+
+ lines.push(`| **${w.name}** | \`${w.id}\` | ${statusText} | ${providerModel} | ${activity} | ${tokens} | ${spend} | ${prText} |`);
+ }
+ }
+
+ const openPrs = workers.filter((w) => w.pr);
+ if (openPrs.length > 0) {
+ lines.push(``, `---`, ``, `## π Active Pull Requests`);
+ for (const w of openPrs) {
+ if (w.pr) {
+ lines.push(`- **${w.name}**: [#${w.pr.number}](${w.pr.url}) β Branch: \`${w.worktree?.branch || 'main'}\``);
+ }
+ }
+ }
+
+ lines.push(``, `---`, `*End of Report β Generated by Agent Office Boss Control System*`);
+
+ const mdContent = lines.join('\n');
+ const blob = new Blob([mdContent], { type: 'text/markdown;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `floor-report-${Date.now()}.md`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ toast('Floor report downloaded successfully!');
+}
diff --git a/src/client/ui/boss-panel.css b/src/client/ui/boss-panel.css
new file mode 100644
index 000000000..a740f2578
--- /dev/null
+++ b/src/client/ui/boss-panel.css
@@ -0,0 +1,315 @@
+/* Boss Mode β a central control panel to manage all workers from one place. */
+
+/* ββ Boss mode toggle button in the workers panel header βββ */
+#boss-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 8px;
+ border-radius: 12px;
+ border: 1.5px solid var(--color-border, #444);
+ background: transparent;
+ color: inherit;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ margin-left: 6px;
+ opacity: 0.7;
+ transition: opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s;
+ white-space: nowrap;
+}
+#boss-toggle:hover {
+ opacity: 1;
+ border-color: #f5a623;
+}
+#boss-toggle.boss-on {
+ background: #f5a623;
+ border-color: #f5a623;
+ color: #1a1a1a;
+ opacity: 1;
+}
+
+/* ββ Boss panel section βββ */
+#boss-panel {
+ display: none;
+ flex-direction: column;
+ gap: 0;
+ overflow: hidden;
+}
+#boss-panel.visible {
+ display: flex;
+}
+
+.boss-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px 6px;
+ border-bottom: 1px solid var(--color-border, #333);
+}
+.boss-header h3 {
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #f5a623;
+ margin: 0;
+}
+.boss-actions {
+ display: flex;
+ gap: 6px;
+}
+.boss-actions button {
+ font-size: 11px;
+ padding: 3px 9px;
+ border-radius: 8px;
+ border: 1px solid var(--color-border, #444);
+ background: var(--color-bg-2, #2a2a2a);
+ color: inherit;
+ cursor: pointer;
+ font-weight: 600;
+ transition: background 0.12s, border-color 0.12s;
+}
+.boss-actions button:hover {
+ background: var(--color-bg-3, #383838);
+ border-color: #f5a623;
+ color: #f5a623;
+}
+.boss-actions button.danger:hover {
+ border-color: #ef4444;
+ color: #ef4444;
+}
+
+/* Broadcast bar */
+.boss-broadcast {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border-bottom: 1px solid var(--color-border, #333);
+}
+.boss-broadcast input {
+ flex: 1;
+ padding: 5px 9px;
+ border-radius: 8px;
+ border: 1px solid var(--color-border, #444);
+ background: var(--color-bg-2, #222);
+ color: inherit;
+ font-size: 12px;
+ outline: none;
+ transition: border-color 0.12s;
+}
+.boss-broadcast input:focus {
+ border-color: #f5a623;
+}
+.boss-broadcast button {
+ padding: 5px 12px;
+ border-radius: 8px;
+ border: 1px solid #f5a623;
+ background: #f5a623;
+ color: #1a1a1a;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.12s;
+ white-space: nowrap;
+}
+.boss-broadcast button:hover {
+ background: #e09600;
+ border-color: #e09600;
+}
+.boss-broadcast button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Worker cards in boss panel */
+.boss-workers {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ overflow-y: auto;
+ max-height: 280px;
+}
+.arcade-screen .boss-workers {
+ flex: 1;
+ min-height: 0;
+ max-height: none;
+}
+.boss-worker-card {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-bottom: 1px solid var(--color-border, #2a2a2a);
+ cursor: pointer;
+ transition: background 0.1s;
+}
+.boss-worker-card:hover {
+ background: var(--color-bg-2, #222);
+}
+.boss-worker-card:last-child {
+ border-bottom: none;
+}
+.boss-worker-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.boss-worker-info {
+ flex: 1;
+ min-width: 0;
+}
+.boss-worker-name {
+ font-weight: 600;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.boss-worker-activity {
+ font-size: 11px;
+ opacity: 0.55;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.boss-worker-status {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 6px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ flex-shrink: 0;
+}
+.boss-worker-status.working {
+ background: #166534;
+ color: #86efac;
+}
+.boss-worker-status.needs_input {
+ background: #7f1d1d;
+ color: #fca5a5;
+}
+.boss-worker-status.idle {
+ background: #374151;
+ color: #9ca3af;
+}
+.boss-worker-status.done {
+ background: #1e3a5f;
+ color: #93c5fd;
+}
+.boss-worker-send-home {
+ padding: 2px 7px;
+ border-radius: 6px;
+ border: 1px solid var(--color-border, #444);
+ background: transparent;
+ color: inherit;
+ font-size: 10px;
+ cursor: pointer;
+ opacity: 0.6;
+ flex-shrink: 0;
+ transition: opacity 0.1s, border-color 0.1s, color 0.1s;
+}
+.boss-worker-send-home:hover {
+ opacity: 1;
+ border-color: #ef4444;
+ color: #ef4444;
+}
+
+.boss-worker-terminal {
+ padding: 2px 7px;
+ border-radius: 6px;
+ border: 1px solid var(--color-border, #444);
+ background: var(--color-bg-2, #2a2a2a);
+ color: #38bdf8;
+ font-size: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background 0.1s, border-color 0.1s, color 0.1s;
+}
+.boss-worker-terminal:hover {
+ background: #0284c7;
+ border-color: #38bdf8;
+ color: #ffffff;
+}
+
+/* Empty state */
+.boss-empty {
+ padding: 18px 12px;
+ font-size: 12px;
+ opacity: 0.5;
+ text-align: center;
+}
+
+/* ββ Live Analytics Summary Bar βββ */
+.boss-summary-bar {
+ background: var(--color-bg-2, #181d28);
+ border-bottom: 1px solid var(--color-border, #2a3447);
+ padding: 6px 12px;
+}
+.boss-summary-bar-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.boss-stat-item {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.boss-stat-item .label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-muted, #718096);
+ font-weight: 700;
+}
+.boss-stat-item .val {
+ font-size: 12px;
+ font-weight: 800;
+ color: #f8fafc;
+ font-variant-numeric: tabular-nums;
+}
+.boss-stat-item .val.working {
+ color: #4ade80;
+}
+.boss-stat-item .val.needs {
+ color: #fca5a5;
+ animation: pulse-needs 1.5s infinite;
+}
+.boss-stat-item .val.spend {
+ color: #38bdf8;
+}
+
+@keyframes pulse-needs {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+/* Button variants in Boss Header */
+#boss-auto-assign-btn {
+ background: rgba(56, 189, 248, 0.12);
+ border-color: #38bdf8;
+ color: #38bdf8;
+}
+#boss-auto-assign-btn:hover {
+ background: #0284c7;
+ color: #ffffff;
+ border-color: #38bdf8;
+}
+
+#boss-export-report-btn {
+ background: rgba(34, 197, 94, 0.12);
+ border-color: #22c55e;
+ color: #4ade80;
+}
+#boss-export-report-btn:hover {
+ background: #166534;
+ color: #ffffff;
+ border-color: #4ade80;
+}
+
diff --git a/src/client/ui/boss-panel.ts b/src/client/ui/boss-panel.ts
new file mode 100644
index 000000000..a7d2d629e
--- /dev/null
+++ b/src/client/ui/boss-panel.ts
@@ -0,0 +1,236 @@
+// Boss Mode β a central control panel that lets any user toggle "boss mode" and control
+// all workers from one place: see their status, broadcast a prompt to all, or send them home.
+
+import './boss-panel.css';
+import { store } from '../state';
+import { needyFirst } from '../nextup';
+import { STATUS_LABEL, h, toast } from './dom';
+import type { Net } from '../net';
+import { fmtCost, fmtTokens, tokensOf } from '../../shared/protocol';
+import { exportFloorReport, findMostIdleWorker } from './boss-helpers';
+import { playIntercomSound } from '../sound/alerts';
+import type { OfficeSound } from '../sound';
+
+/** Whether boss mode is currently active (stored in memory; not persisted across reloads). */
+let bossMode = false;
+
+/** Re-renders the boss panel list and analytics summary bar whenever the store changes. */
+export function renderBossPanel(net: Net, onOpenWorker: (id: string) => void) {
+ const panel = document.getElementById('boss-panel');
+ if (!panel) return;
+
+ // --- Update Analytics Summary Bar ---
+ const summaryBar = panel.querySelector('#boss-summary-bar');
+ if (summaryBar) {
+ summaryBar.replaceChildren();
+ const workersList = [...store.workers.values()];
+ const totalCount = workersList.length;
+ const workingCount = workersList.filter((w) => w.status === 'working').length;
+ const needsInputCount = workersList.filter((w) => w.status === 'needs_input').length;
+
+ let totalTokens = 0;
+ let totalCost = 0;
+ for (const w of workersList) {
+ if (w.usage) {
+ totalTokens += tokensOf(w.usage);
+ totalCost += w.usage.cost || 0;
+ }
+ }
+
+ const summaryContent = h(
+ 'div.boss-summary-bar-content',
+ {},
+ h('div.boss-stat-item', { title: 'Total Workers' }, h('span.label', {}, 'Workers'), h('span.val', {}, String(totalCount))),
+ h('div.boss-stat-item', { title: 'Working / Active' }, h('span.label', {}, 'Working'), h('span.val.working', {}, String(workingCount))),
+ h(
+ 'div.boss-stat-item',
+ { title: 'Needs Your Input' },
+ h('span.label', {}, 'Needs Input'),
+ h('span', { class: 'val' + (needsInputCount > 0 ? ' needs' : '') }, String(needsInputCount)),
+ ),
+ h(
+ 'div.boss-stat-item',
+ { title: 'Total Token / Dollar Spend' },
+ h('span.label', {}, 'Spend'),
+ h('span.val.spend', {}, `${fmtCost(totalCost)} Β· ${fmtTokens(totalTokens)}`),
+ ),
+ );
+ summaryBar.append(summaryContent);
+ }
+
+ // --- Update Worker List ---
+ const list = panel.querySelector('.boss-workers');
+ if (!list) return;
+
+ list.replaceChildren();
+
+ const workers = needyFirst(store.workers.values());
+
+ if (!workers.length) {
+ list.append(h('li.boss-empty', {}, 'No workers hired on this floor yet.'));
+ return;
+ }
+
+ for (const w of workers) {
+ const statusClass =
+ w.status === 'needs_input'
+ ? 'needs_input'
+ : w.status === 'working'
+ ? 'working'
+ : w.status === 'done'
+ ? 'done'
+ : 'idle';
+
+ const statusText = w.status === 'needs_input' ? 'NEEDS YOU' : (STATUS_LABEL[w.status] ?? w.status);
+
+ const sendHomeBtn = h(
+ 'button.boss-worker-send-home',
+ {
+ type: 'button',
+ title: `Send ${w.name} home`,
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ if (confirm(`Send ${w.name} home?`)) {
+ net.send({ t: 'worker.kill', workerId: w.id });
+ }
+ },
+ },
+ 'Send home',
+ );
+
+ const card = h(
+ 'li.boss-worker-card',
+ {
+ title: `Open ${w.name}'s terminal`,
+ onclick: () => onOpenWorker(w.id),
+ },
+ h('span.boss-worker-dot', { style: `background:${w.color}` }),
+ h(
+ 'span.boss-worker-info',
+ {},
+ h('div.boss-worker-name', {}, w.name),
+ h('div.boss-worker-activity', {}, w.activity ?? w.title ?? w.prompt ?? 'β'),
+ ),
+ h('span.boss-worker-status', { class: statusClass }, statusText),
+ sendHomeBtn,
+ );
+
+ list.append(card);
+ }
+}
+
+/**
+ * Mounts the Boss Mode toggle button inside the workers panel header and wires up the
+ * boss panel section that sits just below the workers panel in the sidebar.
+ *
+ * Call once during app init, after the DOM is ready.
+ */
+export function initBossMode(net: Net, onOpenWorker: (id: string) => void, sound?: OfficeSound) {
+ // --- 1. Create the toggle button and inject it next to the workers count ----
+ const workerCount = document.getElementById('worker-count');
+ if (workerCount) {
+ const toggle = h(
+ 'button',
+ {
+ id: 'boss-toggle',
+ type: 'button',
+ title: 'Toggle Boss Mode β control all workers from one panel',
+ },
+ 'π Boss',
+ );
+
+ toggle.addEventListener('click', () => {
+ bossMode = !bossMode;
+ toggle.classList.toggle('boss-on', bossMode);
+ const panel = document.getElementById('boss-panel');
+ if (panel) panel.classList.toggle('visible', bossMode);
+ if (bossMode) renderBossPanel(net, onOpenWorker);
+ });
+
+ // Insert after the count span
+ workerCount.insertAdjacentElement('afterend', toggle);
+ }
+
+ // --- 2. Wire up the Broadcast button (with Intercom Sound) --------------------
+ const broadcastInput = document.getElementById('boss-broadcast-input') as HTMLInputElement | null;
+ const broadcastBtn = document.getElementById('boss-broadcast-btn') as HTMLButtonElement | null;
+
+ if (broadcastInput && broadcastBtn) {
+ const doSend = () => {
+ const text = broadcastInput.value.trim();
+ if (!text) return;
+ const workers = [...store.workers.values()];
+ if (!workers.length) {
+ toast('No active workers to broadcast to', 'warn');
+ return;
+ }
+
+ // Play Intercom audio chime alert
+ playIntercomSound(sound);
+
+ // Send the prompt to every worker on this floor
+ for (const w of workers) {
+ net.send({ t: 'worker.prompt', workerId: w.id, prompt: text });
+ }
+ broadcastInput.value = '';
+ toast(`Broadcast sent to ${workers.length} worker(s)`);
+ };
+
+ broadcastBtn.addEventListener('click', doSend);
+ broadcastInput.addEventListener('keydown', (e) => {
+ if ((e as KeyboardEvent).key === 'Enter') doSend();
+ });
+ }
+
+ // --- 3. Wire up Smart Auto-Assign button -------------------------------------
+ const autoAssignBtn = document.getElementById('boss-auto-assign-btn') as HTMLButtonElement | null;
+ if (autoAssignBtn) {
+ autoAssignBtn.addEventListener('click', () => {
+ let text = broadcastInput?.value.trim() ?? '';
+ if (!text) {
+ const userPrompt = window.prompt('Enter prompt to auto-assign to the most idle worker:');
+ if (!userPrompt || !userPrompt.trim()) return;
+ text = userPrompt.trim();
+ }
+
+ const worker = findMostIdleWorker(store.workers.values());
+ if (!worker) {
+ toast('No idle workers available to assign prompt', 'warn');
+ return;
+ }
+
+ playIntercomSound(sound);
+ net.send({ t: 'worker.prompt', workerId: worker.id, prompt: text });
+ if (broadcastInput) broadcastInput.value = '';
+ toast(`Auto-assigned prompt to ${worker.name}`);
+ });
+ }
+
+ // --- 4. Wire up Export Floor Report button -----------------------------------
+ const exportBtn = document.getElementById('boss-export-report-btn') as HTMLButtonElement | null;
+ if (exportBtn) {
+ exportBtn.addEventListener('click', () => {
+ exportFloorReport();
+ });
+ }
+
+ // --- 5. Wire up the Send All Home button ------------------------------------
+ const sendAllBtn = document.getElementById('boss-send-all-btn') as HTMLButtonElement | null;
+ if (sendAllBtn) {
+ sendAllBtn.addEventListener('click', () => {
+ const workers = [...store.workers.values()];
+ if (!workers.length) return;
+ if (!confirm(`Send all ${workers.length} worker(s) home?`)) return;
+ for (const w of workers) {
+ net.send({ t: 'worker.kill', workerId: w.id });
+ }
+ });
+ }
+}
+
+/** Call this whenever the workers store changes so the boss panel stays in sync. */
+export function refreshBossPanel(net: Net, onOpenWorker: (id: string) => void) {
+ if (!bossMode) return;
+ renderBossPanel(net, onOpenWorker);
+}
+