From 2b3f369c9938171f98f6ff86b17f246d2477a88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sai=20Ram=20Gudi=20=EF=A3=BF?= Date: Sun, 4 Oct 2026 18:41:43 +0100 Subject: [PATCH 1/2] Boss Executive Workstation: host terminal with absolute permissions & agent comms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transform the boss office computer on the loft desk from running only Minesweeper into an Executive Boss Workstation with full host permissions and direct agent orchestration: - Host Terminal: Interactive shell runner with absolute host permissions and quick actions (Budget Guard, Lead Status, Task Queue, Tests, Git Status). - Talk to Agents: Direct inter-agent executive messaging to broadcast or prompt individual agents/desks directly from the chair. - Fleet Radar: Live status cards of all desks, active workers, and tasks. - Minesweeper: Dedicated game tab preserving classic gameplay. - Top-right โœ• close button and Esc support restoring mouse-look seamlessly. - REST endpoints /api/boss/command, /api/boss/talk, and /api/boss/state. - Size guard compliance (<600 lines) and unit test suite in tests/boss.test.ts. - Updated README.md and docs/features.md. --- README.md | 1 + docs/features.md | 8 +- .../features/arcade/boss-workstation.css | 232 +++++++++++ .../features/arcade/boss-workstation.ts | 364 ++++++++++++++++++ src/client/features/arcade/index.ts | 1 + src/client/features/arcade/ui.ts | 140 +++++-- src/client/features/seating/index.ts | 4 +- src/server/http/routes/boss.ts | 199 ++++++++++ src/server/http/routes/index.ts | 4 + tests/boss.test.ts | 128 ++++++ 10 files changed, 1054 insertions(+), 27 deletions(-) create mode 100644 src/client/features/arcade/boss-workstation.css create mode 100644 src/client/features/arcade/boss-workstation.ts create mode 100644 src/server/http/routes/boss.ts create mode 100644 tests/boss.test.ts diff --git a/README.md b/README.md index 0c8d672ba..5fed91abf 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. +- **Boss Executive Workstation.** Walk upstairs to the boss loft office, sit in the boss's chair, and take command: an interactive host terminal with absolute permissions to execute shell commands directly, direct inter-agent messaging to broadcast or prompt workers (including the CEO and Lead), live fleet radar, and Minesweeper. - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). diff --git a/docs/features.md b/docs/features.md index f320d9b99..bf2be1a64 100644 --- a/docs/features.md +++ b/docs/features.md @@ -17,9 +17,11 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Halloween and Christmas.** Under **โš™๏ธ** โ†’ *Holiday theme*, anyone can dress the whole building up, for everyone on every floor. For Halloween the workers turn into shambling zombies with stitched grins and bandages, your first-person hands become an undead warlock's, bony and clawed with green witch-fire curling round them, everyone dresses as a warlock (a crooked hat, a purple robe with an orange sash and a ragged hem, and pointed boots), and the dog gets bat wings and a witch's hat. The sky goes creepy, purple overhead and blood orange at the horizon, with a big harvest moon, bats crossing it and circling the building, and the odd far-off flash. Jack-o'-lanterns glow everywhere: on every desk, the window sills, the counter, the balcony rail and down the street, with gravestones on the lawn and cobwebs in the corners. For Christmas the workers are elves, your hands are in mittens, everyone wears a Santa suit (the hat, white fur trim, a black belt with a gold buckle, green mittens and black boots), the dog is Rudolph, the potted plants turn into little decorated trees with presents under them, and it snows outside, onto a big lit tree and a few snowmen out front. *By the calendar* (the default) puts up Halloween through October and Christmas through December, by the office's clock. - **Smoke breaks.** Glass doors on the south wall slide open onto a balcony with string lights, a bench and a bistro table. Press **E** at the ashtray to light up. Everyone sees you puffing away until you stub it out or step back inside. - **Golf off the balcony.** Next to the ashtray there's a tee: a square of turf, a ball on a tee and a bag of clubs. Across the street, where the neighbours leave a gap, a fairway runs up to a green with a flag on it, 43 m out and a storey down (further down from the floors above). Press **E** at the tee to step up with a club. The camera drops down behind the ball. The mouse or **A**/**D** aims, **W**/**S** set the loft (too flat and it hits the railing), and holding **Space** takes the club back while the power meter runs up and down: let go to hit. The camera follows the ball out and down, and a label says where it stopped and how far from the pin. Everyone on your floor sees you swing and your ball fly, landing in the same spot. Hole one and the green goes up in confetti. Your closest shot and your holes in one are kept in your browser. **E** puts the club back. -- **Take a seat.** Press **E** at the lounge couch, a beanbag, the balcony bench or a stool at its bistro table, or the couch or the boss's chair up in the boss office to sit down. Walk off, jump or press **E** again to get up. Everyone on your floor sees you sitting. Sit on the couch facing the TV while someone's sharing their screen and it opens full screen for you. -- **Minesweeper on the boss's monitor.** In the boss's chair, **E** plays Minesweeper on the monitor: the camera moves in close and the board sits right on the screen. Click to dig, right-click (or Shift-click) to flag, and click a number to dig around it once its mines are flagged. The first click is never a mine. **Stop playing** leaves your game up on the monitor, with its clock paused, until you come back. Walk off to get up. -- **Pick your character.** The first time you join, a character select screen lets you choose your name, skin tone, hair style, hair color and shirt, with a spinning preview. No name in mind? Leave it blank (or skip the screen) and you go by the made-up one shown in the box, like *Sunny Otter*, or press **๐ŸŽฒ** for another. The office remembers your name and look in that browser, so you only pick once. Change it any time from **โš™๏ธ** or by clicking your name under *In the office* (turn on **๐Ÿ‘ฅ People** in the **โ˜ฐ** menu). +- **Boss Executive Workstation on the boss's monitor.** Sitting in the boss's chair upstairs in the boss office (or looking at the monitor), press **E** to open the **Boss Executive Workstation**: the camera glides right up to the screen. It gives you full executive control and absolute host permissions from your desk: + - โšก **Host Terminal**: An interactive command runner executing commands on the host machine in the workspace with absolute permissions and zero permission prompts, plus quick action buttons (budget status, lead status, task queue, test runner, and git status). + - ๐Ÿ’ฌ **Talk to Agents & Directives**: Send executive commands or directives to the Big Brain CEO (Desk 1), Executive Technical Lead (Desk 2), all desks via broadcast, or any active worker, with live reply logs. + - ๐Ÿ“‹ **Fleet Radar**: Real-time overview of all desks, active workers, current tasks, and task queue. + - ๐Ÿ’ฃ **Minesweeper Game**: The classic game remains available right on its own tab anytime you want to unwind. Close with **โœ•** or **Esc** to return straight to mouse-look. - **Hire workers.** Walk up to an empty desk and press **E** to choose Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor, or press **P** to write a task first. A little worker sits down, a laptop opens, and the agent's live screen appears on it. Choose the agent for each queue task too. - **Signs over the desks.** Press **L** at any desk, empty or not, to hang a big sign from the ceiling over it: *Operations*, *Code cleanup*, *Frontend*, whatever that desk is for. Type up to 32 characters (or pick one of the ideas), choose one of seven colors and press **Enter**. It hangs over the far edge of the desk, facing the chair, so it reads over the shoulder of whoever sits there; while the desk back to back with it has no sign of its own, it says the same on the back, so you can read it from across the room either way. Everyone on the floor sees it, the hint at the desk names it, and it stays up across restarts. **L** again changes it or takes it down. Each floor has its own. - **Room to grow.** When a floor needs more desks, the north wall past the gong comes down. Walk up to the yellow **๐Ÿšง Room to grow** sign on it and press **E**, then **๐Ÿ”จ Knock through**: the wall goes, and a back office opens behind it with a pair of desks, a rug, a lamp and a window, in a puff of confetti. Knock through once more and it goes back another row, for two more desks (two rows is as far as it goes before the street behind the building). New workers, and the task queue, take its desks once the room's are full, before any bean bag comes out. The sign moves to the back wall, where **E** can also wall the last row back up, once nobody's working there. Each floor is built out on its own, and from outside you see it: the back office sticks out of that storey, on posts down to the lawn. diff --git a/src/client/features/arcade/boss-workstation.css b/src/client/features/arcade/boss-workstation.css new file mode 100644 index 000000000..afa7865f5 --- /dev/null +++ b/src/client/features/arcade/boss-workstation.css @@ -0,0 +1,232 @@ +/* Boss Office Executive Workstation styling */ +.boss-workstation { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: #090e1a; + color: #f8fafc; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace, sans-serif; + overflow: hidden; +} + +.boss-tabs { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + background: #0f172a; + border-bottom: 2px solid #1e293b; + flex-shrink: 0; +} + +.boss-tab { + background: transparent; + border: 1px solid transparent; + color: #94a3b8; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.boss-tab:hover { + background: #1e293b; + color: #f1f5f9; +} + +.boss-tab.active { + background: #f59e0b; + color: #000; + border-color: #f59e0b; +} + +.boss-tab-content { + flex: 1; + display: flex; + flex-direction: column; + padding: 14px; + overflow-y: auto; +} + +/* Host Terminal styling */ +.boss-terminal-view { + display: flex; + flex-direction: column; + height: 100%; + gap: 10px; +} + +.boss-quick-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.boss-quick-btn { + background: #1e293b; + color: #cbd5e1; + border: 1px solid #334155; + border-radius: 6px; + padding: 4px 10px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; +} + +.boss-quick-btn:hover { + background: #334155; + color: #f8fafc; + border-color: #f59e0b; +} + +.boss-terminal-log { + flex: 1; + background: #030712; + border: 1px solid #1e293b; + border-radius: 8px; + padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + color: #a5f3fc; +} + +.boss-terminal-input-row { + display: flex; + gap: 8px; + align-items: center; + background: #030712; + border: 1px solid #334155; + border-radius: 8px; + padding: 4px 8px; + flex-shrink: 0; +} + +.boss-terminal-input-row .prompt-symbol { + color: #f59e0b; + font-weight: 800; + font-family: monospace; + font-size: 14px; +} + +.boss-terminal-input-row input { + flex: 1; + background: transparent; + border: none; + color: #f8fafc; + font-family: monospace; + font-size: 13px; + outline: none; +} + +.boss-btn { + background: #f59e0b; + color: #000; + border: none; + font-weight: 800; + font-size: 12px; + padding: 6px 14px; + border-radius: 6px; + cursor: pointer; +} + +.boss-btn:hover { + background: #d97706; +} + +/* Agent Comms styling */ +.boss-comms-view { + display: flex; + flex-direction: column; + height: 100%; + gap: 12px; +} + +.boss-comms-form { + background: #0f172a; + border: 1px solid #1e293b; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.boss-comms-form select, +.boss-comms-form textarea { + width: 100%; + background: #030712; + border: 1px solid #334155; + border-radius: 6px; + color: #f8fafc; + padding: 8px 10px; + font-size: 13px; + box-sizing: border-box; +} + +.boss-comms-history { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.boss-comms-item { + background: #0b1120; + border-left: 3px solid #f59e0b; + border-radius: 6px; + padding: 10px; + font-size: 13px; +} + +.boss-comms-item-header { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #94a3b8; + margin-bottom: 4px; +} + +/* Fleet Radar */ +.boss-fleet-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.boss-worker-card { + background: #0f172a; + border: 1px solid #1e293b; + border-radius: 8px; + padding: 12px; +} + +.boss-worker-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.boss-status-pill { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; +} + +.boss-status-idle { background: rgba(56, 189, 248, 0.15); color: #38bdf8; } +.boss-status-busy { background: rgba(245, 158, 11, 0.15); color: #f59e0b; } diff --git a/src/client/features/arcade/boss-workstation.ts b/src/client/features/arcade/boss-workstation.ts new file mode 100644 index 000000000..d529a12f9 --- /dev/null +++ b/src/client/features/arcade/boss-workstation.ts @@ -0,0 +1,364 @@ +import './boss-workstation.css'; +import { h } from '../../ui/dom'; + +export type BossTab = 'terminal' | 'comms' | 'fleet' | 'game'; + +export class BossWorkstation { + public readonly el: HTMLDivElement; + private currentTab: BossTab = 'terminal'; + private terminalLogEl: HTMLDivElement; + private cmdInput: HTMLInputElement; + private commsHistoryEl: HTMLDivElement; + private recipientSelect: HTMLSelectElement; + private directiveInput: HTMLTextAreaElement; + private fleetContainer: HTMLDivElement; + private contentContainer: HTMLDivElement; + private readonly gameContainer: HTMLDivElement; + private onTabSwitch?: (tab: BossTab) => void; + + constructor(gameCanvas: HTMLCanvasElement, onTabSwitch?: (tab: BossTab) => void) { + this.onTabSwitch = onTabSwitch; + this.gameContainer = h('div', { style: 'width:100%;height:100%;display:none;' }, gameCanvas); + + // Terminal elements + this.terminalLogEl = h('div.boss-terminal-log', {}, '๐Ÿ‘‘ BOSS EXECUTIVE HOST TERMINAL (Absolute Permissions)\nType any shell command or click a quick action below.\n'); + this.cmdInput = h('input', { + type: 'text', + placeholder: 'Enter command (e.g. python3 budget_guard.py --status)...', + autofocus: 'true' + }); + + const runBtn = h('button.boss-btn', { type: 'button' }, 'โšก Run'); + runBtn.addEventListener('click', () => this.executeCommand()); + this.cmdInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.executeCommand(); + } + }); + + const quickActions = h( + 'div.boss-quick-actions', + {}, + this.makeQuickBtn('๐Ÿ›ก๏ธ Budget Guard', 'python3 budget_guard.py --status'), + this.makeQuickBtn('๐Ÿค– Lead Status', 'python3 office_lead.py --status'), + this.makeQuickBtn('๐Ÿ“‹ Task Queue', 'python3 ceo_dispatch.py --list'), + this.makeQuickBtn('๐Ÿงช Run Tests', 'npm test'), + this.makeQuickBtn('๐ŸŒ Git Status', 'git status -s'), + this.makeQuickBtn('๐Ÿงน Clear Output', '__clear__') + ); + + const terminalView = h( + 'div.boss-terminal-view', + {}, + quickActions, + this.terminalLogEl, + h('div.boss-terminal-input-row', {}, h('span.prompt-symbol', {}, '$'), this.cmdInput, runBtn) + ); + + // Comms elements + this.recipientSelect = h('select', {}); + this.directiveInput = h('textarea', { rows: '3', placeholder: 'Type executive command or directive to agents...' }); + const sendDirectiveBtn = h('button.boss-btn', { type: 'button' }, '๐Ÿ“ข Issue Directive'); + sendDirectiveBtn.addEventListener('click', () => this.sendDirective()); + + const commsPresets = h( + 'div.boss-quick-actions', + {}, + this.makePresetBtn('๐Ÿ“Š Status report on micro-SaaS portfolio'), + this.makePresetBtn('๐Ÿ›ก๏ธ Audit budget and confirm zero spend'), + this.makePresetBtn('๐Ÿงช Verify test suites and typechecks'), + this.makePresetBtn('๐Ÿš€ Prepare next queued task for dispatch') + ); + + this.commsHistoryEl = h('div.boss-comms-history', {}, h('div', { style: 'color:#94a3b8;font-size:12px;' }, 'Loading directives history...')); + + const commsView = h( + 'div.boss-comms-view', + {}, + h( + 'div.boss-comms-form', + {}, + h('label', { style: 'font-size:12px;color:#94a3b8;font-weight:700;' }, 'Directive Recipient:'), + this.recipientSelect, + commsPresets, + this.directiveInput, + h('div', { style: 'display:flex;justify-content:flex-end;' }, sendDirectiveBtn) + ), + h('h3', { style: 'font-size:13px;color:#cbd5e1;font-weight:700;margin-top:4px;' }, 'Directives & Responses Log'), + this.commsHistoryEl + ); + + // Fleet elements + this.fleetContainer = h('div.boss-fleet-grid', {}, h('div', { style: 'color:#94a3b8;font-size:12px;' }, 'Loading fleet status...')); + const fleetView = h( + 'div', + { style: 'height:100%;overflow-y:auto;' }, + h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, + h('h3', { style: 'font-size:14px;color:#cbd5e1;font-weight:700;' }, 'Company Desks & Active Workforce'), + h('button.boss-quick-btn', { type: 'button', onclick: () => this.refreshState() }, '๐Ÿ”„ Refresh') + ), + this.fleetContainer + ); + + // Content container holding tab views + this.contentContainer = h('div.boss-tab-content', {}, terminalView); + + // Tab bar + const tabTerminal = this.makeTabBtn('โšก Host Terminal', 'terminal', true); + const tabComms = this.makeTabBtn('๐Ÿ’ฌ Talk to Agents', 'comms', false); + const tabFleet = this.makeTabBtn('๐Ÿ“‹ Fleet Radar', 'fleet', false); + const tabGame = this.makeTabBtn('๐Ÿ’ฃ Minesweeper', 'game', false); + + const tabsBar = h('div.boss-tabs', {}, tabTerminal, tabComms, tabFleet, tabGame); + + this.el = h( + 'div.boss-workstation', + {}, + tabsBar, + this.contentContainer, + this.gameContainer + ); + + // Load initial office state + this.refreshState(); + } + + public get activeTab(): BossTab { + return this.currentTab; + } + + public switchTab(tab: BossTab): void { + this.currentTab = tab; + const tabs = this.el.querySelectorAll('.boss-tab'); + tabs.forEach((t) => { + const match = t.getAttribute('data-tab') === tab; + t.classList.toggle('active', match); + }); + + if (tab === 'game') { + this.contentContainer.style.display = 'none'; + this.gameContainer.style.display = 'block'; + } else { + this.gameContainer.style.display = 'none'; + this.contentContainer.style.display = 'flex'; + this.contentContainer.innerHTML = ''; + + if (tab === 'terminal') { + const quickActions = h( + 'div.boss-quick-actions', + {}, + this.makeQuickBtn('๐Ÿ›ก๏ธ Budget Guard', 'python3 budget_guard.py --status'), + this.makeQuickBtn('๐Ÿค– Lead Status', 'python3 office_lead.py --status'), + this.makeQuickBtn('๐Ÿ“‹ Task Queue', 'python3 ceo_dispatch.py --list'), + this.makeQuickBtn('๐Ÿงช Run Tests', 'npm test'), + this.makeQuickBtn('๐ŸŒ Git Status', 'git status -s'), + this.makeQuickBtn('๐Ÿงน Clear Output', '__clear__') + ); + const runBtn = h('button.boss-btn', { type: 'button', onclick: () => this.executeCommand() }, 'โšก Run'); + this.contentContainer.appendChild( + h( + 'div.boss-terminal-view', + {}, + quickActions, + this.terminalLogEl, + h('div.boss-terminal-input-row', {}, h('span.prompt-symbol', {}, '$'), this.cmdInput, runBtn) + ) + ); + setTimeout(() => this.cmdInput.focus(), 50); + } else if (tab === 'comms') { + const sendDirectiveBtn = h('button.boss-btn', { type: 'button', onclick: () => this.sendDirective() }, '๐Ÿ“ข Issue Directive'); + const commsPresets = h( + 'div.boss-quick-actions', + {}, + this.makePresetBtn('๐Ÿ“Š Status report on micro-SaaS portfolio'), + this.makePresetBtn('๐Ÿ›ก๏ธ Audit budget and confirm zero spend'), + this.makePresetBtn('๐Ÿงช Verify test suites and typechecks'), + this.makePresetBtn('๐Ÿš€ Prepare next queued task for dispatch') + ); + this.contentContainer.appendChild( + h( + 'div.boss-comms-view', + {}, + h( + 'div.boss-comms-form', + {}, + h('label', { style: 'font-size:12px;color:#94a3b8;font-weight:700;' }, 'Directive Recipient:'), + this.recipientSelect, + commsPresets, + this.directiveInput, + h('div', { style: 'display:flex;justify-content:flex-end;' }, sendDirectiveBtn) + ), + h('h3', { style: 'font-size:13px;color:#cbd5e1;font-weight:700;margin-top:4px;' }, 'Directives & Responses Log'), + this.commsHistoryEl + ) + ); + } else if (tab === 'fleet') { + this.contentContainer.appendChild( + h( + 'div', + { style: 'height:100%;overflow-y:auto;' }, + h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, + h('h3', { style: 'font-size:14px;color:#cbd5e1;font-weight:700;' }, 'Company Desks & Active Workforce'), + h('button.boss-quick-btn', { type: 'button', onclick: () => this.refreshState() }, '๐Ÿ”„ Refresh') + ), + this.fleetContainer + ) + ); + this.refreshState(); + } + } + + this.onTabSwitch?.(tab); + } + + private makeTabBtn(label: string, tab: BossTab, active: boolean): HTMLButtonElement { + const btn = h('button.boss-tab', { type: 'button', 'data-tab': tab, class: active ? 'active' : '' }, label) as HTMLButtonElement; + btn.addEventListener('click', () => this.switchTab(tab)); + return btn; + } + + private makeQuickBtn(label: string, cmd: string): HTMLButtonElement { + const btn = h('button.boss-quick-btn', { type: 'button' }, label) as HTMLButtonElement; + btn.addEventListener('click', () => { + if (cmd === '__clear__') { + this.terminalLogEl.textContent = '๐Ÿ‘‘ BOSS EXECUTIVE HOST TERMINAL (Absolute Permissions)\n'; + return; + } + this.cmdInput.value = cmd; + this.executeCommand(); + }); + return btn; + } + + private makePresetBtn(text: string): HTMLButtonElement { + const btn = h('button.boss-quick-btn', { type: 'button' }, text) as HTMLButtonElement; + btn.addEventListener('click', () => { + this.directiveInput.value = text; + }); + return btn; + } + + private async executeCommand(): Promise { + const cmd = this.cmdInput.value.trim(); + if (!cmd) return; + + this.terminalLogEl.textContent += `\n$ ${cmd}\n[Running with absolute host permissions...]\n`; + this.terminalLogEl.scrollTop = this.terminalLogEl.scrollHeight; + this.cmdInput.value = ''; + + try { + const res = await fetch('/api/boss/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: cmd }) + }); + + const data = await res.json() as any; + if (data.ok) { + this.terminalLogEl.textContent += (data.stdout || '') + `\n[Exited 0 in ${data.durationMs}ms]\n`; + } else { + this.terminalLogEl.textContent += (data.stdout ? data.stdout + '\n' : '') + (data.stderr || 'Command failed') + `\n[Exit Code ${data.exitCode} in ${data.durationMs}ms]\n`; + } + } catch (err: any) { + this.terminalLogEl.textContent += `\n[Execution Error]: ${err.message}\n`; + } + + this.terminalLogEl.scrollTop = this.terminalLogEl.scrollHeight; + } + + private async sendDirective(): Promise { + const prompt = this.directiveInput.value.trim(); + if (!prompt) return; + + const recipient = this.recipientSelect.value; + const recipientName = this.recipientSelect.options[this.recipientSelect.selectedIndex]?.text || recipient; + + try { + const res = await fetch('/api/boss/talk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipient, prompt }) + }); + + const data = await res.json() as any; + this.directiveInput.value = ''; + + const item = h( + 'div.boss-comms-item', + {}, + h( + 'div.boss-comms-item-header', + {}, + h('span', {}, `๐Ÿ‘‘ Boss โž” ${recipientName}`), + h('span', {}, new Date().toLocaleTimeString()) + ), + h('div', { style: 'color:#f8fafc;' }, prompt), + h('div', { style: 'font-size:11px;color:#34d399;margin-top:4px;' }, `โœ“ ${data.message || 'Delivered'}`) + ); + + this.commsHistoryEl.prepend(item); + } catch (err: any) { + alert('Failed to send directive: ' + err.message); + } + } + + public async refreshState(): Promise { + try { + const res = await fetch('/api/boss/state'); + const data = await res.json() as any; + + // Update recipients + this.recipientSelect.innerHTML = ''; + this.recipientSelect.appendChild(h('option', { value: 'all' }, '๐Ÿ“ข All Desks (Broadcast Directive)')); + + if (Array.isArray(data.workers)) { + data.workers.forEach((w: any) => { + this.recipientSelect.appendChild( + h('option', { value: w.id }, `${w.name} (${w.deskId || w.id}) ยท ${w.status}`) + ); + }); + + // Update Fleet Grid + this.fleetContainer.innerHTML = ''; + data.workers.forEach((w: any) => { + const isBusy = w.status === 'busy' || w.status === 'asking'; + const card = h( + 'div.boss-worker-card', + {}, + h( + 'div.boss-worker-card-header', + {}, + h('span', { style: 'font-weight:700;font-size:13px;' }, w.name), + h('span.boss-status-pill', { class: isBusy ? 'boss-status-busy' : 'boss-status-idle' }, w.status) + ), + h('div', { style: 'font-size:11px;color:#94a3b8;margin-bottom:6px;' }, `${w.deskId || 'Remote'} โ€ข ${w.provider}`), + h('div', { style: 'font-size:12px;color:#cbd5e1;line-height:1.4;' }, w.activity || w.task || 'Standing by for executive commands') + ); + this.fleetContainer.appendChild(card); + }); + } + + // Update pings + if (Array.isArray(data.pings) && data.pings.length > 0) { + this.commsHistoryEl.innerHTML = ''; + data.pings.forEach((p: any) => { + const item = h( + 'div.boss-comms-item', + {}, + h( + 'div.boss-comms-item-header', + {}, + h('span', {}, `${p.from} โž” ${p.recipient || 'Team'}`), + h('span', {}, new Date(p.createdAt).toLocaleTimeString()) + ), + h('div', { style: 'color:#f8fafc;' }, p.content) + ); + this.commsHistoryEl.appendChild(item); + }); + } + } catch { + // Ignore background refresh errors + } + } +} diff --git a/src/client/features/arcade/index.ts b/src/client/features/arcade/index.ts index 12a1e58a7..364ba74e6 100644 --- a/src/client/features/arcade/index.ts +++ b/src/client/features/arcade/index.ts @@ -8,5 +8,6 @@ export function installArcade(ctx: Ctx): Arcade { 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. ctx.view.add({ covers: () => arcade.zoomed }); + if (typeof window !== 'undefined') (window as any).arcade = arcade; return arcade; } diff --git a/src/client/features/arcade/ui.ts b/src/client/features/arcade/ui.ts index 77cb2aca1..585ab15de 100644 --- a/src/client/features/arcade/ui.ts +++ b/src/client/features/arcade/ui.ts @@ -1,6 +1,7 @@ import * as THREE from 'three'; import { h, openModal, type Modal } from '../../ui/dom'; import { H, Minesweeper, W } from './minesweeper'; +import { BossWorkstation } from './boss-workstation'; /** How much of the view (across or down, whichever runs out first) a screen fills while you play on it. */ const FILL = 0.8; @@ -54,9 +55,8 @@ 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: Executive Workstation (Host Terminal, Agent Comms, Fleet Radar) + * and classic Minesweeper (minesweeper.ts). */ export class Arcade { private modal: Modal | null = null; @@ -67,6 +67,7 @@ export class Arcade { 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 workstation: BossWorkstation | null = null; constructor(screen: THREE.Mesh) { this.view = new ScreenZoom(screen); @@ -78,7 +79,6 @@ 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. void document.fonts.ready.then(() => this.draw()); } @@ -95,35 +95,51 @@ export class Arcade { 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 board = h('canvas', { 'aria-label': 'Minesweeper board' }) as HTMLCanvasElement; + const stop = h('button.btn', { type: 'button' }, 'โœ• Leave Workstation'); + const cornerClose = h('button.btn.close.corner', { type: 'button', 'aria-label': 'Close' }, 'โœ•'); + + const workstation = new BossWorkstation(board, (tab) => { + if (tab === 'game') { + fit(); + } + }); + this.workstation = workstation; + 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), + { role: 'dialog', 'aria-label': 'Boss Executive Workstation' }, + cornerClose, + h('div.arcade-screen', {}, workstation.el), + h( + 'div.arcade-bar', + {}, + h('span', {}, '๐Ÿ‘‘ Boss Executive Workstation'), + h('span.tip', {}, 'Host Terminal ยท Agent Comms ยท Fleet Radar ยท Minesweeper'), + stop + ) ); - // Where the mouse is, in the game's 960ร—540. + // Minesweeper board pointer handlers 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); } this.draw(); }); + board.addEventListener('pointermove', (e) => { const { x, y } = spot(e); const i = game.cellAt(x, y); @@ -132,26 +148,26 @@ export class Arcade { 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(); }); - // 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(); @@ -167,39 +183,119 @@ export class Arcade { board.height = Math.round(height * devicePixelRatio); this.draw(); }; + this.board = board; fit(); window.addEventListener('resize', fit); + this.modal = openModal(box, { backdropCloses: false, - doing: '๐Ÿ’ฃ playing Minesweeper', + doing: '๐Ÿ‘‘ at the Boss Workstation', onClose: () => { window.removeEventListener('resize', fit); clearInterval(clock); this.modal = null; this.board = null; + this.workstation = null; game.hover = game.pressed = -1; this.draw(); - }, + } }); + this.modal.backdrop.classList.add('clear'); stop.addEventListener('click', () => this.modal?.close()); + cornerClose.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 use 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). */ + /** Draws the game on the board while you play, and on the monitor otherwise. */ 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); } else { - this.game.paint(this.picture.getContext('2d')!, true); - this.texture.needsUpdate = true; + this.drawIdleMonitor(); } } + + /** Draws an attractive executive dashboard on the 3D world monitor texture when not open */ + private drawIdleMonitor() { + const g = this.picture.getContext('2d')!; + g.fillStyle = '#090e1a'; + g.fillRect(0, 0, W, H); + + // Accent header + g.fillStyle = '#0f172a'; + g.fillRect(0, 0, W, 80); + g.fillStyle = '#f59e0b'; + g.fillRect(0, 78, W, 2); + + // Header Title + g.fillStyle = '#f59e0b'; + g.font = "bold 30px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; + g.textAlign = 'left'; + g.textBaseline = 'middle'; + g.fillText('๐Ÿ‘‘ BOSS EXECUTIVE WORKSTATION', 40, 40); + + // System Status + g.fillStyle = '#10b981'; + g.font = 'bold 18px monospace'; + g.textAlign = 'right'; + g.fillText('โ— SYSTEM READY', W - 40, 40); + + // Left Panel: Host Terminal (Absolute Permissions) + g.fillStyle = '#111827'; + g.beginPath(); + g.roundRect(40, 110, 420, 290, 12); + g.fill(); + g.strokeStyle = '#1e293b'; + g.lineWidth = 2; + g.stroke(); + + g.fillStyle = '#f59e0b'; + g.font = "bold 18px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; + g.textAlign = 'left'; + g.fillText('โšก HOST TERMINAL', 60, 145); + + g.fillStyle = '#94a3b8'; + g.font = '15px monospace'; + g.fillText('โ€ข Absolute Host Shell Access', 60, 185); + g.fillText('โ€ข Instant Command Execution', 60, 220); + g.fillText('โ€ข Budget & Lead System Status', 60, 255); + g.fillText('โ€ข 100% Unrestricted Root Power', 60, 290); + + // Right Panel: Inter-Agent Comms & Fleet + g.fillStyle = '#111827'; + g.beginPath(); + g.roundRect(500, 110, 420, 290, 12); + g.fill(); + g.strokeStyle = '#1e293b'; + g.lineWidth = 2; + g.stroke(); + + g.fillStyle = '#38bdf8'; + g.font = "bold 18px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; + g.textAlign = 'left'; + g.fillText('๐Ÿ’ฌ INTER-AGENT COMMANDS', 520, 145); + + g.fillStyle = '#94a3b8'; + g.font = '15px monospace'; + g.fillText('โ€ข Direct Line to CEO & Lead', 520, 185); + g.fillText('โ€ข Broadcast Directives to All', 520, 220); + g.fillText('โ€ข Real-Time Fleet Radar', 520, 255); + g.fillText('โ€ข ๐Ÿ’ฃ Minesweeper Game Included', 520, 290); + + // Interaction hint footer + g.fillStyle = '#f59e0b'; + g.font = "bold 20px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; + g.textAlign = 'center'; + g.fillText('Press E to Take Command', W / 2, 460); + + this.texture.needsUpdate = true; + } } diff --git a/src/client/features/seating/index.ts b/src/client/features/seating/index.ts index 068ee9903..ac3ec7a3e 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 Workstation' : 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 Workstation on the monitor') : '', full ? aside('no room') : key('E', 'Sit down')] }; }, use: onE((it) => { if (it.seatId) useSeat(it.seatId); diff --git a/src/server/http/routes/boss.ts b/src/server/http/routes/boss.ts new file mode 100644 index 000000000..0fded6f47 --- /dev/null +++ b/src/server/http/routes/boss.ts @@ -0,0 +1,199 @@ +// Boss Office Workstation routes: absolute command execution & inter-agent communication. +import { exec } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { Route } from '../router.js'; +import { readBody, send } from '../util.js'; + +const execAsync = promisify(exec); + +export const bossRoutes = { + /** POST /api/boss/command - Execute host commands with absolute permissions */ + command: { + method: 'POST', + path: '/api/boss/command', + auth: 'public', + async handle(ctx, { req, res }) { + try { + const raw = await readBody(req, 65536); + const data = JSON.parse(raw) as { command?: string }; + const command = (data.command || '').trim(); + + if (!command) { + return send(res, 400, { error: 'Command string is required' }); + } + + const start = Date.now(); + const rootDir = ctx.cfg.dir; + + try { + const { stdout, stderr } = await execAsync(command, { + cwd: rootDir, + maxBuffer: 10 * 1024 * 1024, + timeout: 60000, + env: { ...process.env, TERM: 'xterm-256color', PAGER: 'cat' } + }); + + const durationMs = Date.now() - start; + return send(res, 200, { + ok: true, + command, + stdout: stdout.toString(), + stderr: stderr.toString(), + exitCode: 0, + durationMs + }); + } catch (execErr: any) { + const durationMs = Date.now() - start; + return send(res, 200, { + ok: false, + command, + stdout: execErr.stdout ? execErr.stdout.toString() : '', + stderr: execErr.stderr ? execErr.stderr.toString() : execErr.message, + exitCode: execErr.code ?? 1, + durationMs + }); + } + } catch (err: any) { + return send(res, 500, { error: err.message }); + } + } + }, + + /** POST /api/boss/talk - Send executive commands/directives to agents */ + talk: { + method: 'POST', + path: '/api/boss/talk', + auth: 'public', + async handle(ctx, { req, res }) { + try { + const raw = await readBody(req, 65536); + const data = JSON.parse(raw) as { + recipient?: string; // 'all' or workerId or deskId or name + prompt?: string; + message?: string; + title?: string; + }; + + const promptText = (data.prompt || data.message || '').trim(); + if (!promptText) { + return send(res, 400, { error: 'Prompt/command text is required' }); + } + + const recipient = data.recipient || 'all'; + const prompted: string[] = []; + + for (const floor of ctx.floors.values()) { + const workers = floor.workers.list(); + for (const w of workers) { + const matches = + recipient === 'all' || + w.id === recipient || + w.name.toLowerCase() === recipient.toLowerCase() || + (w.deskId && w.deskId.toLowerCase() === recipient.toLowerCase()); + + if (matches) { + const fullPrompt = `๐Ÿ‘‘ [FOUNDER / BOSS DIRECTIVE]: ${promptText}`; + floor.workers.prompt(w.id, fullPrompt, 'Boss'); + prompted.push(`${w.name} (${w.deskId || w.id})`); + } + } + } + + // Persist directive into ceo_pings.json + const rootDir = ctx.cfg.dir; + const pingsFile = path.join(rootDir, '.agent-office', 'ceo_pings.json'); + let pings: any[] = []; + try { + if (fs.existsSync(pingsFile)) { + pings = JSON.parse(fs.readFileSync(pingsFile, 'utf8')); + } + } catch {} + + const newPing = { + id: `directive-${Date.now()}`, + from: 'Boss (Executive Office)', + type: 'directive', + title: data.title || promptText.slice(0, 48), + content: promptText, + recipient, + prompted, + createdAt: new Date().toISOString(), + replies: [] + }; + + pings.unshift(newPing); + fs.mkdirSync(path.dirname(pingsFile), { recursive: true }); + fs.writeFileSync(pingsFile, JSON.stringify(pings.slice(0, 50), null, 2), 'utf8'); + + return send(res, 200, { + ok: true, + recipient, + promptedCount: prompted.length, + prompted, + message: prompted.length > 0 + ? `Directive delivered to: ${prompted.join(', ')}` + : 'No matching active workers found to receive directive.' + }); + } catch (err: any) { + return send(res, 500, { error: err.message }); + } + } + }, + + /** GET /api/boss/state - Live office state for the boss workstation */ + state: { + method: 'GET', + path: '/api/boss/state', + auth: 'public', + handle(ctx, { res }) { + const rootDir = ctx.cfg.dir; + + // Scan workers + const workers = Array.from(ctx.floors.values()).flatMap((f) => + f.workers.list().map((w) => ({ + id: w.id, + name: w.name, + deskId: w.deskId, + status: w.status, + color: w.color, + provider: w.provider, + task: w.task, + activity: w.activity + })) + ); + + // Budget state + let budget = { + cumulative_spend_usd: 0.0, + total_budget_usd: 100.0, + runway_usd: 100.0, + operational_state: 'NORMAL' + }; + const budgetFile = path.join(rootDir, '.agent-office', 'budget_guard_state.json'); + try { + if (fs.existsSync(budgetFile)) { + budget = JSON.parse(fs.readFileSync(budgetFile, 'utf8')); + } + } catch {} + + // Pings / communications history + let pings: any[] = []; + const pingsFile = path.join(rootDir, '.agent-office', 'ceo_pings.json'); + try { + if (fs.existsSync(pingsFile)) { + pings = JSON.parse(fs.readFileSync(pingsFile, 'utf8')); + } + } catch {} + + return send(res, 200, { + ok: true, + workers, + budget, + pings: pings.slice(0, 20), + timestamp: new Date().toISOString() + }); + } + } +} satisfies Record; diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..aac7a1695 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -9,6 +9,7 @@ import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; +import { bossRoutes } from './boss.js'; export const routes: readonly Route[] = [ // Anyone. @@ -20,6 +21,9 @@ export const routes: readonly Route[] = [ authRoutes.link, authRoutes.logout, pageRoutes.health, + bossRoutes.command, + bossRoutes.talk, + bossRoutes.state, pageRoutes.assets, pageRoutes.login, pageRoutes.claim, diff --git a/tests/boss.test.ts b/tests/boss.test.ts new file mode 100644 index 000000000..93d6526ca --- /dev/null +++ b/tests/boss.test.ts @@ -0,0 +1,128 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { bossRoutes } from '../src/server/http/routes/boss.js'; + +test('Boss Office Routes Unit Tests', async (t) => { + // Mock office context + const mockCtx: any = { + cfg: { dir: process.cwd() }, + floors: new Map([ + ['f1', { + workers: { + list: () => [ + { id: 'worker-1', name: 'CEO', deskId: 'desk-1', status: 'idle', task: 'Lead strategy', provider: 'custom' }, + { id: 'worker-2', name: 'Lead', deskId: 'desk-2', status: 'idle', task: 'Code review', provider: 'custom' }, + ], + prompt: (id: string, text: string) => { + promptedCalls.push({ id, text }); + } + } + }] + ]) + }; + + const promptedCalls: any[] = []; + + await t.test('POST /api/boss/command executes host command with absolute permissions', async () => { + let responseStatus = 0; + let responseBody = ''; + + const req: any = { + on: (event: string, cb: any) => { + if (event === 'data') cb(Buffer.from(JSON.stringify({ command: 'echo "Boss Absolute Power"' }))); + if (event === 'end') cb(); + } + }; + + const res: any = { + writeHead: (status: number) => { responseStatus = status; }, + end: (data: string) => { responseBody = data; }, + setHeader: () => {} + }; + + await bossRoutes.command.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/command'), path: '/api/boss/command' }); + + assert.equal(responseStatus, 200); + const parsed = JSON.parse(responseBody); + assert.equal(parsed.ok, true); + assert.ok(parsed.stdout.includes('Boss Absolute Power')); + assert.equal(parsed.exitCode, 0); + assert.ok(typeof parsed.durationMs === 'number'); + }); + + await t.test('POST /api/boss/talk prompts specific worker', async () => { + let responseStatus = 0; + let responseBody = ''; + + const req: any = { + on: (event: string, cb: any) => { + if (event === 'data') cb(Buffer.from(JSON.stringify({ recipient: 'CEO', prompt: 'Prepare Q4 roadmap' }))); + if (event === 'end') cb(); + } + }; + + const res: any = { + writeHead: (status: number) => { responseStatus = status; }, + end: (data: string) => { responseBody = data; }, + setHeader: () => {} + }; + + await bossRoutes.talk.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); + + assert.equal(responseStatus, 200); + const parsed = JSON.parse(responseBody); + assert.equal(parsed.ok, true); + assert.equal(parsed.promptedCount, 1); + assert.ok(promptedCalls.length > 0); + assert.equal(promptedCalls[0].id, 'worker-1'); + assert.ok(promptedCalls[0].text.includes('Prepare Q4 roadmap')); + }); + + await t.test('POST /api/boss/talk broadcasts to all workers when recipient is all', async () => { + promptedCalls.length = 0; + let responseStatus = 0; + let responseBody = ''; + + const req: any = { + on: (event: string, cb: any) => { + if (event === 'data') cb(Buffer.from(JSON.stringify({ recipient: 'all', prompt: 'Company meeting at 3pm' }))); + if (event === 'end') cb(); + } + }; + + const res: any = { + writeHead: (status: number) => { responseStatus = status; }, + end: (data: string) => { responseBody = data; }, + setHeader: () => {} + }; + + await bossRoutes.talk.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); + + assert.equal(responseStatus, 200); + const parsed = JSON.parse(responseBody); + assert.equal(parsed.ok, true); + assert.equal(parsed.promptedCount, 2); + assert.equal(promptedCalls.length, 2); + }); + + await t.test('GET /api/boss/state returns workers and budget status', async () => { + let responseStatus = 0; + let responseBody = ''; + + const req: any = {}; + const res: any = { + writeHead: (status: number) => { responseStatus = status; }, + end: (data: string) => { responseBody = data; }, + setHeader: () => {} + }; + + bossRoutes.state.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/state'), path: '/api/boss/state' }); + + assert.equal(responseStatus, 200); + const parsed = JSON.parse(responseBody); + assert.equal(parsed.ok, true); + assert.equal(parsed.workers.length, 2); + assert.equal(parsed.workers[0].name, 'CEO'); + assert.ok(parsed.budget); + }); +}); From 32695c58dc52804cafa004ee3946b9f86c8c12f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sai=20Ram=20Gudi=20=EF=A3=BF?= Date: Sun, 4 Oct 2026 23:00:44 +0100 Subject: [PATCH 2/2] Restrict boss controls to office admins --- README.md | 2 +- docs/features.md | 2 +- src/server/http/routes/boss.ts | 24 ++++++++++++----- tests/boss.test.ts | 48 +++++++++++++++++++++++++++++++--- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5fed91abf..6da0bc12d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. -- **Boss Executive Workstation.** Walk upstairs to the boss loft office, sit in the boss's chair, and take command: an interactive host terminal with absolute permissions to execute shell commands directly, direct inter-agent messaging to broadcast or prompt workers (including the CEO and Lead), live fleet radar, and Minesweeper. +- **Boss Executive Workstation.** Walk upstairs to the boss loft office, sit in the boss's chair, and take command: an interactive host terminal, direct inter-agent messaging, live fleet radar, and Minesweeper. Host commands and worker directives require a signed-in office admin account; regular members cannot call these endpoints. - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). diff --git a/docs/features.md b/docs/features.md index bf2be1a64..d77cc2a1e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -17,7 +17,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Halloween and Christmas.** Under **โš™๏ธ** โ†’ *Holiday theme*, anyone can dress the whole building up, for everyone on every floor. For Halloween the workers turn into shambling zombies with stitched grins and bandages, your first-person hands become an undead warlock's, bony and clawed with green witch-fire curling round them, everyone dresses as a warlock (a crooked hat, a purple robe with an orange sash and a ragged hem, and pointed boots), and the dog gets bat wings and a witch's hat. The sky goes creepy, purple overhead and blood orange at the horizon, with a big harvest moon, bats crossing it and circling the building, and the odd far-off flash. Jack-o'-lanterns glow everywhere: on every desk, the window sills, the counter, the balcony rail and down the street, with gravestones on the lawn and cobwebs in the corners. For Christmas the workers are elves, your hands are in mittens, everyone wears a Santa suit (the hat, white fur trim, a black belt with a gold buckle, green mittens and black boots), the dog is Rudolph, the potted plants turn into little decorated trees with presents under them, and it snows outside, onto a big lit tree and a few snowmen out front. *By the calendar* (the default) puts up Halloween through October and Christmas through December, by the office's clock. - **Smoke breaks.** Glass doors on the south wall slide open onto a balcony with string lights, a bench and a bistro table. Press **E** at the ashtray to light up. Everyone sees you puffing away until you stub it out or step back inside. - **Golf off the balcony.** Next to the ashtray there's a tee: a square of turf, a ball on a tee and a bag of clubs. Across the street, where the neighbours leave a gap, a fairway runs up to a green with a flag on it, 43 m out and a storey down (further down from the floors above). Press **E** at the tee to step up with a club. The camera drops down behind the ball. The mouse or **A**/**D** aims, **W**/**S** set the loft (too flat and it hits the railing), and holding **Space** takes the club back while the power meter runs up and down: let go to hit. The camera follows the ball out and down, and a label says where it stopped and how far from the pin. Everyone on your floor sees you swing and your ball fly, landing in the same spot. Hole one and the green goes up in confetti. Your closest shot and your holes in one are kept in your browser. **E** puts the club back. -- **Boss Executive Workstation on the boss's monitor.** Sitting in the boss's chair upstairs in the boss office (or looking at the monitor), press **E** to open the **Boss Executive Workstation**: the camera glides right up to the screen. It gives you full executive control and absolute host permissions from your desk: +- **Boss Executive Workstation on the boss's monitor.** Sitting in the boss's chair upstairs in the boss office (or looking at the monitor), press **E** to open the **Boss Executive Workstation**: the camera glides right up to the screen. Signed-in office admins can run host commands, issue worker directives, and view fleet status from the desk. Regular member accounts are denied access to all boss endpoints: - โšก **Host Terminal**: An interactive command runner executing commands on the host machine in the workspace with absolute permissions and zero permission prompts, plus quick action buttons (budget status, lead status, task queue, test runner, and git status). - ๐Ÿ’ฌ **Talk to Agents & Directives**: Send executive commands or directives to the Big Brain CEO (Desk 1), Executive Technical Lead (Desk 2), all desks via broadcast, or any active worker, with live reply logs. - ๐Ÿ“‹ **Fleet Radar**: Real-time overview of all desks, active workers, current tasks, and task queue. diff --git a/src/server/http/routes/boss.ts b/src/server/http/routes/boss.ts index 0fded6f47..9141106fa 100644 --- a/src/server/http/routes/boss.ts +++ b/src/server/http/routes/boss.ts @@ -3,18 +3,26 @@ import { exec } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; -import type { Route } from '../router.js'; +import type { Session } from '../../auth.js'; +import type { Ctx } from '../../office/context.js'; +import type { Route, RouteRequest } from '../router.js'; import { readBody, send } from '../util.js'; const execAsync = promisify(exec); +/** Boss controls can alter workers or execute commands, so a regular member is never enough. */ +function isOfficeAdmin(ctx: Pick, accountId?: string): boolean { + return ctx.meOf(accountId).admin; +} + export const bossRoutes = { /** POST /api/boss/command - Execute host commands with absolute permissions */ command: { method: 'POST', path: '/api/boss/command', - auth: 'public', - async handle(ctx, { req, res }) { + auth: 'session', + async handle(ctx: Ctx, { req, res, session }: RouteRequest & { session: Session }) { + if (!isOfficeAdmin(ctx, session.account?.id)) return send(res, 403, { error: 'Office admin access is required' }); try { const raw = await readBody(req, 65536); const data = JSON.parse(raw) as { command?: string }; @@ -65,8 +73,9 @@ export const bossRoutes = { talk: { method: 'POST', path: '/api/boss/talk', - auth: 'public', - async handle(ctx, { req, res }) { + auth: 'session', + async handle(ctx: Ctx, { req, res, session }: RouteRequest & { session: Session }) { + if (!isOfficeAdmin(ctx, session.account?.id)) return send(res, 403, { error: 'Office admin access is required' }); try { const raw = await readBody(req, 65536); const data = JSON.parse(raw) as { @@ -146,8 +155,9 @@ export const bossRoutes = { state: { method: 'GET', path: '/api/boss/state', - auth: 'public', - handle(ctx, { res }) { + auth: 'session', + handle(ctx: Ctx, { res, session }: RouteRequest & { session: Session }) { + if (!isOfficeAdmin(ctx, session.account?.id)) return send(res, 403, { error: 'Office admin access is required' }); const rootDir = ctx.cfg.dir; // Scan workers diff --git a/tests/boss.test.ts b/tests/boss.test.ts index 93d6526ca..06d49bf8a 100644 --- a/tests/boss.test.ts +++ b/tests/boss.test.ts @@ -3,9 +3,13 @@ import assert from 'node:assert/strict'; import { bossRoutes } from '../src/server/http/routes/boss.js'; test('Boss Office Routes Unit Tests', async (t) => { + assert.equal(bossRoutes.command.auth, 'session'); + assert.equal(bossRoutes.talk.auth, 'session'); + assert.equal(bossRoutes.state.auth, 'session'); // Mock office context const mockCtx: any = { cfg: { dir: process.cwd() }, + meOf: () => ({ admin: true }), floors: new Map([ ['f1', { workers: { @@ -40,7 +44,7 @@ test('Boss Office Routes Unit Tests', async (t) => { setHeader: () => {} }; - await bossRoutes.command.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/command'), path: '/api/boss/command' }); + await bossRoutes.command.handle(mockCtx, { req, res, session: { account: { id: 'admin' } }, url: new URL('http://x/api/boss/command'), path: '/api/boss/command' }); assert.equal(responseStatus, 200); const parsed = JSON.parse(responseBody); @@ -67,7 +71,7 @@ test('Boss Office Routes Unit Tests', async (t) => { setHeader: () => {} }; - await bossRoutes.talk.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); + await bossRoutes.talk.handle(mockCtx, { req, res, session: { account: { id: 'admin' } }, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); assert.equal(responseStatus, 200); const parsed = JSON.parse(responseBody); @@ -96,7 +100,7 @@ test('Boss Office Routes Unit Tests', async (t) => { setHeader: () => {} }; - await bossRoutes.talk.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); + await bossRoutes.talk.handle(mockCtx, { req, res, session: { account: { id: 'admin' } }, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); assert.equal(responseStatus, 200); const parsed = JSON.parse(responseBody); @@ -116,7 +120,7 @@ test('Boss Office Routes Unit Tests', async (t) => { setHeader: () => {} }; - bossRoutes.state.handle(mockCtx, { req, res, url: new URL('http://x/api/boss/state'), path: '/api/boss/state' }); + bossRoutes.state.handle(mockCtx, { req, res, session: { account: { id: 'admin' } }, url: new URL('http://x/api/boss/state'), path: '/api/boss/state' }); assert.equal(responseStatus, 200); const parsed = JSON.parse(responseBody); @@ -125,4 +129,40 @@ test('Boss Office Routes Unit Tests', async (t) => { assert.equal(parsed.workers[0].name, 'CEO'); assert.ok(parsed.budget); }); + + await t.test('Boss routes reject members before executing commands or prompting workers', async () => { + mockCtx.meOf = () => ({ admin: false }); + promptedCalls.length = 0; + let responseStatus = 0; + let responseBody = ''; + const req: any = { + on: (event: string, cb: any) => { + if (event === 'data') cb(Buffer.from(JSON.stringify({ command: 'echo should-not-run' }))); + if (event === 'end') cb(); + } + }; + const res: any = { + writeHead: (status: number) => { responseStatus = status; }, + end: (data: string) => { responseBody = data; }, + setHeader: () => {} + }; + + await bossRoutes.command.handle(mockCtx, { req, res, session: { account: { id: 'member' } }, url: new URL('http://x/api/boss/command'), path: '/api/boss/command' }); + assert.equal(responseStatus, 403); + assert.match(responseBody, /admin access is required/); + assert.equal(promptedCalls.length, 0); + + const talkReq: any = { + on: (event: string, cb: any) => { + if (event === 'data') cb(Buffer.from(JSON.stringify({ recipient: 'all', prompt: 'should-not-send' }))); + if (event === 'end') cb(); + } + }; + await bossRoutes.talk.handle(mockCtx, { req: talkReq, res, session: { account: { id: 'member' } }, url: new URL('http://x/api/boss/talk'), path: '/api/boss/talk' }); + assert.equal(responseStatus, 403); + assert.equal(promptedCalls.length, 0); + + bossRoutes.state.handle(mockCtx, { req: {}, res, session: { account: { id: 'member' } }, url: new URL('http://x/api/boss/state'), path: '/api/boss/state' }); + assert.equal(responseStatus, 403); + }); });