Files
web-page-backend/docs/superpowers/plans/2026-04-11-agent-office.md
gahusb c3b8794621 docs: Agent Office 구현 계획서 작성
17개 태스크: 백엔드 scaffold → FSM → 에이전트 → 텔레그램 → 인프라 → 프론트엔드 Canvas → UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:22:49 +09:00

87 KiB

Agent Office Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 2D 픽셀아트 가상 사무실에서 AI 에이전트(주식, 음악)가 실제 작업을 수행하고, 텔레그램 양방향 연동으로 알림/승인을 처리하는 MVP 구현.

Architecture: agent-office 백엔드 서비스(포트 18900)가 기존 stock-lab/music-lab API를 프록시 호출하고 에이전트 FSM을 관리. 프론트엔드는 Canvas 2D로 사무실을 렌더링하고 WebSocket으로 실시간 상태를 수신. 텔레그램 봇이 양방향 알림/승인을 처리.

Tech Stack: FastAPI, SQLite, APScheduler, python-telegram-bot, WebSocket, React 18, HTML5 Canvas 2D, Vite

Design Spec: docs/superpowers/specs/2026-04-11-agent-office-design.md


File Structure

Backend (web-backend/agent-office/)

File Responsibility
agent-office/app/__init__.py Package init
agent-office/app/main.py FastAPI app, WebSocket endpoint, REST routes, lifespan (scheduler start)
agent-office/app/config.py Environment variables, service URLs
agent-office/app/db.py SQLite init, CRUD for agent_config, agent_tasks, agent_logs, telegram_state
agent-office/app/models.py Pydantic request/response models
agent-office/app/websocket_manager.py WebSocket connection pool, broadcast
agent-office/app/service_proxy.py HTTP client for stock-lab, music-lab APIs
agent-office/app/telegram_bot.py Telegram Bot API: send messages, handle webhook callbacks
agent-office/app/scheduler.py APScheduler setup, job registration
agent-office/app/agents/__init__.py Package init, agent registry
agent-office/app/agents/base.py BaseAgent FSM (state transitions, idle timer, break logic)
agent-office/app/agents/stock.py StockAgent (news summary, price alerts)
agent-office/app/agents/music.py MusicAgent (compose pipeline with approval)
agent-office/Dockerfile Python 3.12-alpine, uvicorn
agent-office/requirements.txt Dependencies

Frontend (web-ui/src/pages/agent-office/)

File Responsibility
agent-office/AgentOffice.jsx Main page: Canvas container + React overlay panels
agent-office/AgentOffice.css All styles for office page
agent-office/canvas/OfficeRenderer.js Game loop, layer rendering, click detection
agent-office/canvas/SpriteSheet.js Sprite sheet loader, frame animation
agent-office/canvas/TileMap.js Tile map data + rendering (floor, furniture)
agent-office/canvas/AgentSprite.js Agent character: position, state, movement, animation
agent-office/components/ChatPanel.jsx Agent chat/command panel (click to open)
agent-office/components/TaskHistory.jsx Task history side panel
agent-office/hooks/useAgentManager.js WebSocket connection + agent state management
agent-office/hooks/useOfficeCanvas.js Canvas init, resize, click event binding
agent-office/assets/office-map.json Tile map layout data

Infrastructure

File Change
docker-compose.yml Add agent-office service
nginx/default.conf Add /api/agent-office/ location with WebSocket upgrade
web-ui/src/routes.jsx Add agent-office route
web-ui/src/pages/effect-lab/EffectLab.jsx Add Agent Office to LAB_ITEMS
web-ui/src/api.js Add agent-office API helpers

Task 1: Backend Scaffold — config, db, models

Files:

  • Create: agent-office/app/__init__.py

  • Create: agent-office/app/config.py

  • Create: agent-office/app/db.py

  • Create: agent-office/app/models.py

  • Create: agent-office/requirements.txt

  • Create: agent-office/Dockerfile

  • Test: agent-office/app/test_db.py

  • Step 1: Create requirements.txt

fastapi==0.115.6
uvicorn[standard]==0.30.6
requests==2.32.3
apscheduler==3.10.4
python-telegram-bot==21.5
websockets>=12.0
httpx>=0.27
  • Step 2: Create Dockerfile
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • Step 3: Create __init__.py
# agent-office/app/__init__.py

Empty file for package init.

  • Step 4: Create config.py
import os

# Service URLs (Docker internal network)
STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500")
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")

# Telegram
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "")

# Database
DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db")

# CORS
CORS_ALLOW_ORIGINS = os.getenv(
    "CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
)

# Idle break threshold (seconds)
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300"))  # 5 min
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60"))       # 1 min
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180"))      # 3 min
  • Step 5: Create models.py
from pydantic import BaseModel
from typing import Optional

class CommandRequest(BaseModel):
    agent: str
    action: str
    params: Optional[dict] = None

class ApprovalRequest(BaseModel):
    agent: str
    task_id: str
    approved: bool
    feedback: Optional[str] = None

class AgentConfigUpdate(BaseModel):
    enabled: Optional[bool] = None
    schedule_config: Optional[dict] = None
    custom_config: Optional[dict] = None

class PriceAlertConfig(BaseModel):
    symbol: str
    name: str
    target_price: float
    direction: str  # "above" or "below"

class ComposeCommand(BaseModel):
    prompt: str
    style: Optional[str] = None
    model: Optional[str] = "V4"
    instrumental: Optional[bool] = False
  • Step 6: Create db.py
import os
import json
import sqlite3
import uuid
from typing import Any, Dict, List, Optional

from .config import DB_PATH

def _conn() -> sqlite3.Connection:
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    return conn

def init_db() -> None:
    with _conn() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS agent_config (
                agent_id       TEXT PRIMARY KEY,
                display_name   TEXT NOT NULL,
                enabled        INTEGER NOT NULL DEFAULT 1,
                schedule_config TEXT NOT NULL DEFAULT '{}',
                custom_config   TEXT NOT NULL DEFAULT '{}',
                created_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                updated_at     TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS agent_tasks (
                id              TEXT PRIMARY KEY,
                agent_id        TEXT NOT NULL,
                task_type       TEXT NOT NULL,
                status          TEXT NOT NULL DEFAULT 'pending',
                input_data      TEXT NOT NULL DEFAULT '{}',
                result_data     TEXT,
                requires_approval INTEGER NOT NULL DEFAULT 0,
                approved_at     TEXT,
                approved_via    TEXT,
                created_at      TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
                completed_at    TEXT
            )
        """)
        conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_tasks_agent
            ON agent_tasks(agent_id, created_at DESC)
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS agent_logs (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                agent_id    TEXT NOT NULL,
                task_id     TEXT,
                level       TEXT NOT NULL DEFAULT 'info',
                message     TEXT NOT NULL,
                created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS telegram_state (
                callback_id TEXT PRIMARY KEY,
                task_id     TEXT NOT NULL,
                agent_id    TEXT NOT NULL,
                action      TEXT,
                responded   INTEGER NOT NULL DEFAULT 0,
                created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
            )
        """)
        # Seed default agent configs
        for agent_id, name in [("stock", "주식 트레이더"), ("music", "음악 프로듀서")]:
            conn.execute(
                "INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
                (agent_id, name),
            )

# --- agent_config CRUD ---

def get_all_agents() -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall()
    return [_config_to_dict(r) for r in rows]

def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone()
    return _config_to_dict(r) if r else None

def update_agent_config(agent_id: str, **kwargs) -> None:
    sets, vals = [], []
    for k in ("enabled", "schedule_config", "custom_config"):
        if k in kwargs and kwargs[k] is not None:
            if k in ("schedule_config", "custom_config"):
                sets.append(f"{k}=?")
                vals.append(json.dumps(kwargs[k]))
            else:
                sets.append(f"{k}=?")
                vals.append(kwargs[k])
    if not sets:
        return
    sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
    vals.append(agent_id)
    with _conn() as conn:
        conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals)

def _config_to_dict(r) -> Dict[str, Any]:
    return {
        "agent_id": r["agent_id"],
        "display_name": r["display_name"],
        "enabled": bool(r["enabled"]),
        "schedule_config": json.loads(r["schedule_config"]),
        "custom_config": json.loads(r["custom_config"]),
        "created_at": r["created_at"],
        "updated_at": r["updated_at"],
    }

# --- agent_tasks CRUD ---

def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str:
    task_id = str(uuid.uuid4())
    status = "pending" if requires_approval else "working"
    with _conn() as conn:
        conn.execute(
            "INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)",
            (task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)),
        )
    return task_id

def update_task_status(task_id: str, status: str, result_data: dict = None) -> None:
    with _conn() as conn:
        if result_data is not None:
            conn.execute(
                "UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
                (status, json.dumps(result_data), task_id),
            )
        else:
            conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id))

def approve_task(task_id: str, via: str = "web") -> None:
    with _conn() as conn:
        conn.execute(
            "UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?",
            (via, task_id),
        )

def reject_task(task_id: str) -> None:
    with _conn() as conn:
        conn.execute("UPDATE agent_tasks SET status='failed', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (task_id,))

def get_task(task_id: str) -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
    return _task_to_dict(r) if r else None

def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
            (agent_id, limit),
        ).fetchall()
    return [_task_to_dict(r) for r in rows]

def get_pending_approvals() -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC"
        ).fetchall()
    return [_task_to_dict(r) for r in rows]

def _task_to_dict(r) -> Dict[str, Any]:
    return {
        "id": r["id"],
        "agent_id": r["agent_id"],
        "task_type": r["task_type"],
        "status": r["status"],
        "input_data": json.loads(r["input_data"]) if r["input_data"] else {},
        "result_data": json.loads(r["result_data"]) if r["result_data"] else None,
        "requires_approval": bool(r["requires_approval"]),
        "approved_at": r["approved_at"],
        "approved_via": r["approved_via"],
        "created_at": r["created_at"],
        "completed_at": r["completed_at"],
    }

# --- agent_logs ---

def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None:
    with _conn() as conn:
        conn.execute(
            "INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)",
            (agent_id, task_id, level, message),
        )

def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
    with _conn() as conn:
        rows = conn.execute(
            "SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
            (agent_id, limit),
        ).fetchall()
    return [{"id": r["id"], "agent_id": r["agent_id"], "task_id": r["task_id"],
             "level": r["level"], "message": r["message"], "created_at": r["created_at"]} for r in rows]

# --- telegram_state ---

def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None:
    with _conn() as conn:
        conn.execute(
            "INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)",
            (callback_id, task_id, agent_id),
        )

def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]:
    with _conn() as conn:
        r = conn.execute("SELECT * FROM telegram_state WHERE callback_id=? AND responded=0", (callback_id,)).fetchone()
    if not r:
        return None
    return {"callback_id": r["callback_id"], "task_id": r["task_id"],
            "agent_id": r["agent_id"], "responded": bool(r["responded"])}

def mark_telegram_responded(callback_id: str, action: str) -> None:
    with _conn() as conn:
        conn.execute("UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?", (action, callback_id))
  • Step 7: Write DB test
# agent-office/app/test_db.py
import os
import sys
import tempfile

# Override DB_PATH before importing db
_tmp = tempfile.mktemp(suffix=".db")
os.environ["AGENT_OFFICE_DB_PATH"] = _tmp

sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.db import (
    init_db, get_all_agents, get_agent_config, update_agent_config,
    create_task, update_task_status, approve_task, get_task, get_agent_tasks,
    get_pending_approvals, add_log, get_logs,
    save_telegram_callback, get_telegram_callback, mark_telegram_responded,
)

def test_init_and_seed():
    init_db()
    agents = get_all_agents()
    assert len(agents) == 2
    ids = {a["agent_id"] for a in agents}
    assert ids == {"stock", "music"}

def test_agent_config_update():
    init_db()
    update_agent_config("stock", custom_config={"watch": ["AAPL"]})
    cfg = get_agent_config("stock")
    assert cfg["custom_config"] == {"watch": ["AAPL"]}

def test_task_lifecycle():
    init_db()
    # Create task with approval
    tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True)
    task = get_task(tid)
    assert task["status"] == "pending"
    assert task["requires_approval"] is True

    # Approve
    approve_task(tid, via="telegram")
    task = get_task(tid)
    assert task["status"] == "approved"
    assert task["approved_via"] == "telegram"

    # Complete
    update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"})
    task = get_task(tid)
    assert task["status"] == "succeeded"
    assert task["result_data"]["url"] == "/media/music/test.mp3"

def test_task_no_approval():
    init_db()
    tid = create_task("stock", "news_summary", {"limit": 10})
    task = get_task(tid)
    assert task["status"] == "working"

def test_pending_approvals():
    init_db()
    create_task("music", "compose", {"prompt": "a"}, requires_approval=True)
    create_task("music", "compose", {"prompt": "b"}, requires_approval=True)
    create_task("stock", "news_summary", {})
    pending = get_pending_approvals()
    assert len(pending) == 2

def test_logs():
    init_db()
    add_log("stock", "News fetched", "info", "task-1")
    add_log("stock", "API error", "error")
    logs = get_logs("stock")
    assert len(logs) == 2
    assert logs[0]["level"] == "error"  # DESC order

def test_telegram_state():
    init_db()
    save_telegram_callback("cb-1", "task-1", "music")
    cb = get_telegram_callback("cb-1")
    assert cb["task_id"] == "task-1"
    mark_telegram_responded("cb-1", "approve")
    cb = get_telegram_callback("cb-1")
    assert cb is None  # responded=1, filtered out

if __name__ == "__main__":
    test_init_and_seed()
    test_agent_config_update()
    test_task_lifecycle()
    test_task_no_approval()
    test_pending_approvals()
    test_logs()
    test_telegram_state()
    print("All DB tests passed!")
    os.unlink(_tmp)
  • Step 8: Run DB test

Run: cd agent-office && python -m app.test_db Expected: "All DB tests passed!"

  • Step 9: Commit
git add agent-office/
git commit -m "feat(agent-office): scaffold backend — config, db, models, Dockerfile"

Task 2: WebSocket Manager

Files:

  • Create: agent-office/app/websocket_manager.py

  • Step 1: Create websocket_manager.py

import asyncio
import json
from typing import Any, Dict, Set
from fastapi import WebSocket

class WebSocketManager:
    def __init__(self):
        self._connections: Set[WebSocket] = set()
        self._lock = asyncio.Lock()

    async def connect(self, ws: WebSocket) -> None:
        await ws.accept()
        async with self._lock:
            self._connections.add(ws)

    async def disconnect(self, ws: WebSocket) -> None:
        async with self._lock:
            self._connections.discard(ws)

    async def broadcast(self, message: Dict[str, Any]) -> None:
        payload = json.dumps(message, ensure_ascii=False)
        async with self._lock:
            dead = set()
            for ws in self._connections:
                try:
                    await ws.send_text(payload)
                except Exception:
                    dead.add(ws)
            self._connections -= dead

    async def send_agent_state(self, agent_id: str, state: str, detail: str = "", task_id: str = None) -> None:
        msg = {"type": "agent_state", "agent": agent_id, "state": state, "detail": detail}
        if task_id:
            msg["task_id"] = task_id
        await self.broadcast(msg)

    async def send_task_complete(self, agent_id: str, task_id: str, result: dict) -> None:
        await self.broadcast({
            "type": "task_complete", "agent": agent_id,
            "task_id": task_id, "result": result,
        })

    async def send_agent_move(self, agent_id: str, target: str) -> None:
        await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target})

ws_manager = WebSocketManager()
  • Step 2: Commit
git add agent-office/app/websocket_manager.py
git commit -m "feat(agent-office): WebSocket connection manager with broadcast"

Task 3: Service Proxy

Files:

  • Create: agent-office/app/service_proxy.py

  • Step 1: Create service_proxy.py

import httpx
from typing import Any, Dict, List, Optional

from .config import STOCK_LAB_URL, MUSIC_LAB_URL

_client = httpx.AsyncClient(timeout=30.0)

# --- Stock Lab ---

async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
    params = {"limit": limit}
    if category:
        params["category"] = category
    resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params)
    resp.raise_for_status()
    return resp.json()

async def fetch_stock_indices() -> Dict[str, Any]:
    resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices")
    resp.raise_for_status()
    return resp.json()

# --- Music Lab ---

async def generate_music(payload: dict) -> Dict[str, Any]:
    resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
    resp.raise_for_status()
    return resp.json()

async def get_music_status(task_id: str) -> Dict[str, Any]:
    resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/status/{task_id}")
    resp.raise_for_status()
    return resp.json()

async def get_music_credits() -> Dict[str, Any]:
    resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits")
    resp.raise_for_status()
    return resp.json()
  • Step 2: Commit
git add agent-office/app/service_proxy.py
git commit -m "feat(agent-office): service proxy for stock-lab and music-lab APIs"

Task 4: BaseAgent FSM

Files:

  • Create: agent-office/app/agents/__init__.py

  • Create: agent-office/app/agents/base.py

  • Step 1: Create agents/init.py

from .stock import StockAgent
from .music import MusicAgent

AGENT_REGISTRY = {}

def init_agents():
    AGENT_REGISTRY["stock"] = StockAgent()
    AGENT_REGISTRY["music"] = MusicAgent()

def get_agent(agent_id: str):
    return AGENT_REGISTRY.get(agent_id)

def get_all_agent_states() -> list:
    return [
        {"agent_id": aid, "state": agent.state, "detail": agent.state_detail}
        for aid, agent in AGENT_REGISTRY.items()
    ]
  • Step 2: Create agents/base.py
import asyncio
import random
import time
from typing import Optional

from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
from ..db import add_log

VALID_STATES = ("idle", "working", "waiting", "reporting", "break")

class BaseAgent:
    agent_id: str = ""
    display_name: str = ""
    state: str = "idle"
    state_detail: str = ""
    _idle_since: float = 0.0
    _break_until: float = 0.0
    _ws_manager = None

    def __init__(self):
        self._idle_since = time.time()

    def set_ws_manager(self, manager):
        self._ws_manager = manager

    async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None:
        if new_state not in VALID_STATES:
            return
        old = self.state
        self.state = new_state
        self.state_detail = detail

        if new_state == "idle":
            self._idle_since = time.time()
        elif new_state == "break":
            duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
            self._break_until = time.time() + duration

        add_log(self.agent_id, f"State: {old}{new_state} ({detail})")

        if self._ws_manager:
            await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
            if new_state == "break":
                await self._ws_manager.send_agent_move(self.agent_id, "break_room")
            elif old == "break" and new_state == "idle":
                await self._ws_manager.send_agent_move(self.agent_id, "desk")

    async def check_idle_break(self) -> None:
        now = time.time()
        if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
            if random.random() < 0.5:
                break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
                await self.transition("break", break_type)
        elif self.state == "break" and now > self._break_until:
            await self.transition("idle", "휴식 완료")

    async def on_schedule(self) -> None:
        raise NotImplementedError

    async def on_command(self, command: str, params: dict) -> dict:
        raise NotImplementedError

    async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
        raise NotImplementedError

    async def get_status(self) -> dict:
        return {
            "agent_id": self.agent_id,
            "display_name": self.display_name,
            "state": self.state,
            "detail": self.state_detail,
        }
  • Step 3: Commit
git add agent-office/app/agents/
git commit -m "feat(agent-office): BaseAgent FSM with idle/break behavior"

Task 5: StockAgent

Files:

  • Create: agent-office/app/agents/stock.py

  • Step 1: Create stock.py

import asyncio
from typing import Optional

from .base import BaseAgent
from ..db import create_task, update_task_status, get_agent_config, add_log
from .. import service_proxy

class StockAgent(BaseAgent):
    agent_id = "stock"
    display_name = "주식 트레이더"

    async def on_schedule(self) -> None:
        """매일 08:00 실행 — 뉴스 수집 + 요약 + 텔레그램 전송."""
        if self.state not in ("idle", "break"):
            return

        task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
        await self.transition("working", "뉴스 수집 중...", task_id)

        try:
            news = await service_proxy.fetch_stock_news(limit=15)
            indices = await service_proxy.fetch_stock_indices()

            summary = self._format_news_summary(news, indices)

            update_task_status(task_id, "succeeded", {
                "summary": summary,
                "news_count": len(news) if isinstance(news, list) else 0,
            })

            await self.transition("reporting", "뉴스 요약 전송 중...")

            # Telegram send will be wired in Task 6
            from ..telegram_bot import send_stock_summary
            await send_stock_summary(summary)

            await self.transition("idle", "뉴스 요약 완료")

        except Exception as e:
            add_log(self.agent_id, f"News summary failed: {e}", "error", task_id)
            update_task_status(task_id, "failed", {"error": str(e)})
            await self.transition("idle", f"오류: {e}")

    async def on_command(self, command: str, params: dict) -> dict:
        if command == "fetch_news":
            await self.on_schedule()
            return {"ok": True, "message": "뉴스 수집 시작"}

        if command == "add_alert":
            config = get_agent_config(self.agent_id)
            alerts = config["custom_config"].get("alerts", [])
            alerts.append({
                "symbol": params["symbol"],
                "name": params.get("name", params["symbol"]),
                "target_price": params["target_price"],
                "direction": params.get("direction", "above"),
            })
            from ..db import update_agent_config
            update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts})
            return {"ok": True, "message": f"알람 추가: {params['symbol']}"}

        if command == "list_alerts":
            config = get_agent_config(self.agent_id)
            alerts = config["custom_config"].get("alerts", [])
            return {"ok": True, "alerts": alerts}

        return {"ok": False, "message": f"Unknown command: {command}"}

    async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
        pass  # Stock agent has no approval-required tasks

    def _format_news_summary(self, news, indices) -> str:
        lines = ["📈 [주식 에이전트] 아침 뉴스 요약", "━" * 20]

        if isinstance(news, list):
            for item in news[:10]:
                title = item.get("title", "")
                if title:
                    lines.append(f"• {title}")
        elif isinstance(news, dict) and "articles" in news:
            for item in news["articles"][:10]:
                title = item.get("title", "")
                if title:
                    lines.append(f"• {title}")

        if indices:
            lines.append("")
            lines.append("📊 주요 지수")
            if isinstance(indices, dict):
                for key, val in indices.items():
                    if isinstance(val, dict):
                        name = val.get("name", key)
                        price = val.get("price", "")
                        change = val.get("change", "")
                        lines.append(f"{name}: {price} ({change})")

        return "\n".join(lines)
  • Step 2: Commit
git add agent-office/app/agents/stock.py
git commit -m "feat(agent-office): StockAgent — news summary, price alerts"

Task 6: Telegram Bot

Files:

  • Create: agent-office/app/telegram_bot.py

  • Step 1: Create telegram_bot.py

import json
import uuid
import httpx
from typing import Optional

from .config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
from .db import save_telegram_callback, get_telegram_callback, mark_telegram_responded

_BASE = "https://api.telegram.org/bot"

def _enabled() -> bool:
    return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)

async def _api(method: str, payload: dict) -> dict:
    if not _enabled():
        return {"ok": False, "description": "Telegram not configured"}
    async with httpx.AsyncClient(timeout=10.0) as client:
        resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
        return resp.json()

async def send_message(text: str, reply_markup: dict = None) -> dict:
    payload = {
        "chat_id": TELEGRAM_CHAT_ID,
        "text": text,
        "parse_mode": "HTML",
    }
    if reply_markup:
        payload["reply_markup"] = reply_markup
    return await _api("sendMessage", payload)

async def send_stock_summary(summary: str) -> dict:
    return await send_message(summary)

async def send_approval_request(agent_id: str, task_id: str, title: str, detail: str) -> dict:
    approve_id = f"approve_{uuid.uuid4().hex[:8]}"
    reject_id = f"reject_{uuid.uuid4().hex[:8]}"

    save_telegram_callback(approve_id, task_id, agent_id)
    save_telegram_callback(reject_id, task_id, agent_id)

    text = f"{title}\n{'━' * 20}\n{detail}"
    reply_markup = {
        "inline_keyboard": [[
            {"text": "✅ 승인", "callback_data": approve_id},
            {"text": "❌ 거절", "callback_data": reject_id},
        ]]
    }
    return await send_message(text, reply_markup)

async def send_task_result(agent_id: str, title: str, result: str) -> dict:
    text = f"{title}\n{'━' * 20}\n{result}"
    return await send_message(text)

async def handle_webhook(data: dict) -> Optional[dict]:
    """Process incoming Telegram webhook update. Returns action info or None."""
    callback_query = data.get("callback_query")
    if not callback_query:
        return None

    callback_id = callback_query.get("data", "")
    cb = get_telegram_callback(callback_id)
    if not cb:
        return None

    action = "approve" if callback_id.startswith("approve_") else "reject"
    mark_telegram_responded(callback_id, action)

    # Answer callback query to remove loading state
    await _api("answerCallbackQuery", {
        "callback_query_id": callback_query["id"],
        "text": "승인됨 ✅" if action == "approve" else "거절됨 ❌",
    })

    return {
        "task_id": cb["task_id"],
        "agent_id": cb["agent_id"],
        "action": action,
        "approved": action == "approve",
    }

async def setup_webhook() -> dict:
    if not _enabled() or not TELEGRAM_WEBHOOK_URL:
        return {"ok": False, "description": "Webhook URL not configured"}
    return await _api("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
  • Step 2: Commit
git add agent-office/app/telegram_bot.py
git commit -m "feat(agent-office): Telegram bot — send messages, approval requests, webhook handler"

Task 7: MusicAgent

Files:

  • Create: agent-office/app/agents/music.py

  • Step 1: Create music.py

import asyncio
from .base import BaseAgent
from ..db import create_task, update_task_status, approve_task, reject_task, add_log
from .. import service_proxy
from .. import telegram_bot

class MusicAgent(BaseAgent):
    agent_id = "music"
    display_name = "음악 프로듀서"

    async def on_schedule(self) -> None:
        pass  # Music agent is command-driven, not scheduled

    async def on_command(self, command: str, params: dict) -> dict:
        if command == "compose":
            prompt = params.get("prompt", "")
            style = params.get("style", "")
            model = params.get("model", "V4")
            instrumental = params.get("instrumental", False)

            if not prompt:
                return {"ok": False, "message": "프롬프트를 입력해주세요"}

            task_id = create_task(self.agent_id, "compose", {
                "prompt": prompt, "style": style,
                "model": model, "instrumental": instrumental,
            }, requires_approval=True)

            await self.transition("waiting", "프롬프트 승인 대기", task_id)

            detail = f"프롬프트: {prompt}"
            if style:
                detail += f"\n스타일: {style}"
            detail += f"\n모델: {model}"

            await telegram_bot.send_approval_request(
                self.agent_id, task_id,
                "🎵 [음악 에이전트] 작곡 요청", detail,
            )

            return {"ok": True, "task_id": task_id, "message": "승인 대기 중"}

        if command == "credits":
            credits = await service_proxy.get_music_credits()
            return {"ok": True, "credits": credits}

        return {"ok": False, "message": f"Unknown command: {command}"}

    async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
        if not approved:
            reject_task(task_id)
            await self.transition("idle", "작곡 거절됨")
            await telegram_bot.send_task_result(
                self.agent_id, "🎵 [음악 에이전트] 작곡 취소",
                "사용자가 거절했습니다.",
            )
            return

        from ..db import get_task
        task = get_task(task_id)
        if not task:
            return

        approve_task(task_id, via="telegram")
        await self.transition("working", "작곡 중...", task_id)

        try:
            input_data = task["input_data"]
            payload = {
                "provider": "suno",
                "model": input_data.get("model", "V4"),
                "prompt": input_data.get("prompt", ""),
                "style": input_data.get("style", ""),
                "instrumental": input_data.get("instrumental", False),
                "custom_mode": True,
            }

            result = await service_proxy.generate_music(payload)
            music_task_id = result.get("task_id")

            if not music_task_id:
                raise Exception("music-lab did not return task_id")

            # Poll for completion
            for _ in range(60):  # max 5 min (60 * 5s)
                await asyncio.sleep(5)
                status = await service_proxy.get_music_status(music_task_id)
                progress = status.get("progress", 0)
                state = status.get("status", "")

                if state == "succeeded":
                    tracks = status.get("tracks", [])
                    update_task_status(task_id, "succeeded", {
                        "music_task_id": music_task_id,
                        "tracks": tracks,
                    })
                    await self.transition("reporting", "작곡 완료!")

                    track_info = ""
                    for t in tracks:
                        title = t.get("title", "Untitled")
                        url = t.get("audio_url", "")
                        track_info += f"🎶 {title}\n{url}\n"

                    await telegram_bot.send_task_result(
                        self.agent_id, "🎵 [음악 에이전트] 작곡 완료",
                        track_info or "트랙 생성 완료",
                    )
                    await self.transition("idle", "작곡 완료")
                    return

                if state == "failed":
                    raise Exception(status.get("message", "Generation failed"))

            raise Exception("Timeout: 5분 초과")

        except Exception as e:
            add_log(self.agent_id, f"Compose failed: {e}", "error", task_id)
            update_task_status(task_id, "failed", {"error": str(e)})
            await self.transition("idle", f"오류: {e}")
            await telegram_bot.send_task_result(
                self.agent_id, "🎵 [음악 에이전트] 작곡 실패",
                f"오류: {e}",
            )
  • Step 2: Commit
git add agent-office/app/agents/music.py
git commit -m "feat(agent-office): MusicAgent — compose with approval, polling, telegram notifications"

Task 8: Scheduler

Files:

  • Create: agent-office/app/scheduler.py

  • Step 1: Create scheduler.py

import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler

from .agents import AGENT_REGISTRY

scheduler = AsyncIOScheduler(timezone="Asia/Seoul")

def _run_async(coro_func):
    """Wrap async agent method for APScheduler."""
    def wrapper():
        loop = asyncio.get_event_loop()
        for agent in AGENT_REGISTRY.values():
            if hasattr(agent, coro_func):
                loop.create_task(getattr(agent, coro_func)())
    return wrapper

async def _check_idle_breaks():
    for agent in AGENT_REGISTRY.values():
        await agent.check_idle_break()

async def _run_stock_schedule():
    agent = AGENT_REGISTRY.get("stock")
    if agent:
        await agent.on_schedule()

def init_scheduler():
    # Stock agent: daily news at 08:00
    scheduler.add_job(_run_stock_schedule, "cron", hour=8, minute=0, id="stock_news")

    # Idle break check: every 60 seconds
    scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check")

    scheduler.start()
  • Step 2: Commit
git add agent-office/app/scheduler.py
git commit -m "feat(agent-office): APScheduler — stock news cron, idle break checker"

Task 9: FastAPI Main — REST + WebSocket + Lifespan

Files:

  • Create: agent-office/app/main.py

  • Step 1: Create main.py

import os
import json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware

from .config import CORS_ALLOW_ORIGINS
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs
from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
from .websocket_manager import ws_manager
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
from .scheduler import init_scheduler
from . import telegram_bot

app = FastAPI()

_cors_origins = CORS_ALLOW_ORIGINS.split(",")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[o.strip() for o in _cors_origins],
    allow_credentials=False,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type"],
)

@app.on_event("startup")
async def on_startup():
    init_db()
    os.makedirs("/app/data", exist_ok=True)
    init_agents()
    for agent in AGENT_REGISTRY.values():
        agent.set_ws_manager(ws_manager)
    init_scheduler()

@app.get("/health")
def health():
    return {"ok": True}

# --- WebSocket ---

@app.websocket("/api/agent-office/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws_manager.connect(ws)
    # Send initial state
    try:
        await ws.send_text(json.dumps({
            "type": "init",
            "agents": get_all_agent_states(),
            "pending": [t["id"] for t in get_pending_approvals()],
        }, ensure_ascii=False))
        while True:
            data = await ws.receive_text()
            msg = json.loads(data)
            await _handle_ws_message(msg)
    except WebSocketDisconnect:
        pass
    finally:
        await ws_manager.disconnect(ws)

async def _handle_ws_message(msg: dict):
    msg_type = msg.get("type")
    agent_id = msg.get("agent")
    agent = get_agent(agent_id) if agent_id else None

    if msg_type == "command" and agent:
        action = msg.get("action", "")
        params = msg.get("params", {})
        result = await agent.on_command(action, params)
        await ws_manager.broadcast({"type": "command_result", "agent": agent_id, "result": result})

    elif msg_type == "approval" and agent:
        task_id = msg.get("task_id")
        approved = msg.get("approved", False)
        if task_id:
            await agent.on_approval(task_id, approved)

    elif msg_type == "query" and agent:
        status = await agent.get_status()
        await ws_manager.broadcast({"type": "agent_status", "agent": agent_id, "status": status})

# --- REST Endpoints ---

@app.get("/api/agent-office/agents")
def list_agents():
    return {"agents": get_all_agents()}

@app.get("/api/agent-office/agents/{agent_id}")
def agent_detail(agent_id: str):
    config = get_agent_config(agent_id)
    if not config:
        return {"error": "Agent not found"}, 404
    agent = get_agent(agent_id)
    state_info = {"state": agent.state, "detail": agent.state_detail} if agent else {}
    return {**config, **state_info}

@app.put("/api/agent-office/agents/{agent_id}")
def update_agent(agent_id: str, body: AgentConfigUpdate):
    update_agent_config(agent_id, enabled=body.enabled,
                        schedule_config=body.schedule_config,
                        custom_config=body.custom_config)
    return {"ok": True}

@app.get("/api/agent-office/agents/{agent_id}/tasks")
def agent_tasks(agent_id: str, limit: int = 20):
    return {"tasks": get_agent_tasks(agent_id, limit)}

@app.get("/api/agent-office/agents/{agent_id}/logs")
def agent_logs(agent_id: str, limit: int = 50):
    return {"logs": get_logs(agent_id, limit)}

@app.get("/api/agent-office/tasks/pending")
def pending_tasks():
    return {"tasks": get_pending_approvals()}

@app.get("/api/agent-office/tasks/{task_id}")
def task_detail(task_id: str):
    task = get_task(task_id)
    if not task:
        return {"error": "Task not found"}, 404
    return task

@app.post("/api/agent-office/command")
async def send_command(body: CommandRequest):
    agent = get_agent(body.agent)
    if not agent:
        return {"error": f"Agent '{body.agent}' not found"}
    result = await agent.on_command(body.action, body.params or {})
    return result

@app.post("/api/agent-office/approve")
async def approve(body: ApprovalRequest):
    agent = get_agent(body.agent)
    if not agent:
        return {"error": f"Agent '{body.agent}' not found"}
    await agent.on_approval(body.task_id, body.approved, body.feedback or "")
    return {"ok": True}

# --- Telegram Webhook ---

@app.post("/api/agent-office/telegram/webhook")
async def telegram_webhook(data: dict):
    result = await telegram_bot.handle_webhook(data)
    if result:
        agent = get_agent(result["agent_id"])
        if agent:
            await agent.on_approval(result["task_id"], result["approved"])
    return {"ok": True}

@app.get("/api/agent-office/states")
def all_states():
    return {"agents": get_all_agent_states()}
  • Step 2: Commit
git add agent-office/app/main.py
git commit -m "feat(agent-office): FastAPI main — REST routes, WebSocket, telegram webhook, lifespan"

Task 10: Infrastructure — Docker Compose + Nginx

Files:

  • Modify: docker-compose.yml

  • Modify: nginx/default.conf

  • Step 1: Add agent-office to docker-compose.yml

Add the following service block after the existing services (e.g., after realestate-lab):

  agent-office:
    build:
      context: ./agent-office
    container_name: agent-office
    restart: unless-stopped
    ports:
      - "18900:8000"
    environment:
      - TZ=${TZ:-Asia/Seoul}
      - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
      - STOCK_LAB_URL=http://stock-lab:8000
      - MUSIC_LAB_URL=http://music-lab:8000
      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
      - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
      - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
    volumes:
      - ${RUNTIME_PATH:-.}/data/agent-office:/app/data
    depends_on:
      - stock-lab
      - music-lab
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 30s
      timeout: 5s
      retries: 3
  • Step 2: Add Nginx location block for agent-office

Add before the catch-all /api/ block in nginx/default.conf:

    # agent-office API + WebSocket
    location /api/agent-office/ {
        resolver 127.0.0.11 valid=10s;
        set $agent_office_backend agent-office:8000;

        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400s;
        proxy_pass http://$agent_office_backend$request_uri;
    }
  • Step 3: Commit
git add docker-compose.yml nginx/default.conf
git commit -m "infra(agent-office): Docker Compose service + Nginx WebSocket proxy"

Task 11: Frontend — API Helpers + Route + Lab Entry

Files:

  • Modify: web-ui/src/api.js — add agent-office helpers

  • Modify: web-ui/src/routes.jsx — add route

  • Modify: web-ui/src/pages/effect-lab/EffectLab.jsx — add LAB_ITEMS entry

  • Step 1: Add API helpers to api.js

Append to the end of web-ui/src/api.js:

// ── Agent Office ──────────────────────────────────
export const getAgents         = ()           => apiGet('/api/agent-office/agents');
export const getAgentDetail    = (id)         => apiGet(`/api/agent-office/agents/${id}`);
export const updateAgentConfig = (id, body)   => apiPut(`/api/agent-office/agents/${id}`, body);
export const getAgentTasks     = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`);
export const getAgentLogs      = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`);
export const getPendingTasks   = ()           => apiGet('/api/agent-office/tasks/pending');
export const sendAgentCommand  = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params });
export const approveAgentTask  = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
export const getAgentStates    = ()           => apiGet('/api/agent-office/states');
  • Step 2: Add route to routes.jsx

Add to the appRoutes array:

{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') },

And add to navLinks array:

{
    id: 'agent-office',
    label: 'Agent Office',
    path: '/agent-office',
    subtitle: 'AI LAB',
    description: 'AI 에이전트 사무실',
    icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
    accent: '#8b5cf6',
},
  • Step 3: Add to LAB_ITEMS in EffectLab.jsx

Add to the LAB_ITEMS array:

{
    id: 'agent-office',
    path: '/agent-office',
    title: 'Agent Office',
    category: 'AI · 자동화',
    desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스',
    tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'],
    accent: '#8b5cf6',
    icon: '🏢',
    status: 'wip',
},
  • Step 4: Commit
cd ../web-ui
git add src/api.js src/routes.jsx src/pages/effect-lab/EffectLab.jsx
git commit -m "feat(agent-office): API helpers, route, Lab entry"

Task 12: Frontend Canvas — SpriteSheet + TileMap

Files:

  • Create: web-ui/src/pages/agent-office/canvas/SpriteSheet.js

  • Create: web-ui/src/pages/agent-office/canvas/TileMap.js

  • Create: web-ui/src/pages/agent-office/assets/office-map.json

  • Step 1: Create office-map.json

{
  "tileSize": 32,
  "cols": 20,
  "rows": 14,
  "layers": {
    "floor": [
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
    ]
  },
  "furniture": [
    {"type": "desk", "x": 2, "y": 1, "label": "Stock"},
    {"type": "desk", "x": 7, "y": 1, "label": "Music"},
    {"type": "desk", "x": 12, "y": 1, "label": "Claude"},
    {"type": "desk", "x": 17, "y": 1, "label": "(빈)"},
    {"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"},
    {"type": "sofa", "x": 1, "y": 10, "label": "휴게실"},
    {"type": "coffee", "x": 3, "y": 10, "label": "☕"},
    {"type": "desk", "x": 14, "y": 10, "w": 5, "h": 2, "label": "CEO"}
  ],
  "waypoints": {
    "stock_desk": {"x": 2, "y": 2},
    "music_desk": {"x": 7, "y": 2},
    "claude_desk": {"x": 12, "y": 2},
    "meeting_table": {"x": 9, "y": 7},
    "break_room": {"x": 2, "y": 11},
    "ceo_desk": {"x": 16, "y": 11}
  },
  "colors": {
    "1": "#3a3a50",
    "2": "#4a3a2a"
  }
}
  • Step 2: Create SpriteSheet.js
// web-ui/src/pages/agent-office/canvas/SpriteSheet.js

const PIXEL_CHARS = {
  stock: {
    body: '#4488cc',
    accent: '#cc4444',  // necktie
    label: '주식',
    hair: '#332222',
  },
  music: {
    body: '#44aa88',
    accent: '#ffaa00',  // headphones
    label: '음악',
    hair: '#443322',
  },
  claude: {
    body: '#8855cc',
    accent: '#cc88ff',
    label: 'Claude',
    hair: '#554466',
  },
};

const ANIM_FRAMES = {
  idle:    { frames: 2, speed: 800 },  // ms per frame
  working: { frames: 4, speed: 200 },
  waiting: { frames: 2, speed: 400 },
  break:   { frames: 2, speed: 1000 },
  walk:    { frames: 4, speed: 150 },
};

export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) {
  const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude;
  const s = scale;
  const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle;
  const frame = frameIndex % anim.frames;

  ctx.save();
  ctx.translate(x, y);

  // Shadow
  ctx.fillStyle = 'rgba(0,0,0,0.2)';
  ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s);

  // Body
  ctx.fillStyle = char.body;
  ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s);

  // Head
  ctx.fillStyle = '#ffcc99';
  ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s);

  // Hair
  ctx.fillStyle = char.hair;
  ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s);

  // Eyes
  ctx.fillStyle = '#222';
  const eyeOffset = state === 'break' && frame === 1 ? 0 : 1;
  ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s);
  ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s);

  // Legs
  ctx.fillStyle = '#335';
  const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0;
  ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s);
  ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s);

  // Accent (agent-specific)
  ctx.fillStyle = char.accent;
  if (agentId === 'stock') {
    // Tie
    ctx.fillRect(0, 2 * s, 1 * s, 5 * s);
  } else if (agentId === 'music') {
    // Headphones
    ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s);
    ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s);
    ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s);
  } else if (agentId === 'claude') {
    // AI glow
    ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500);
    ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s);
    ctx.globalAlpha = 1;
  }

  // Working animation: typing hands
  if (state === 'working') {
    ctx.fillStyle = '#ffcc99';
    const handY = 6 * s + (frame % 2) * s;
    ctx.fillRect(-4 * s, handY, 1 * s, 2 * s);
    ctx.fillRect(3 * s, handY, 1 * s, 2 * s);
  }

  // Waiting wobble
  if (state === 'waiting') {
    const wobble = Math.sin(Date.now() / 200) * s;
    ctx.translate(wobble, 0);
  }

  ctx.restore();
}

export function getAnimSpeed(state) {
  return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed;
}

export function getCharLabel(agentId) {
  return (PIXEL_CHARS[agentId] || {}).label || agentId;
}
  • Step 3: Create TileMap.js
// web-ui/src/pages/agent-office/canvas/TileMap.js

const WALL_COLOR = '#2a2a3a';
const DESK_COLOR = '#6b5b3a';
const DESK_TOP = '#8b7b5a';
const TABLE_COLOR = '#5a4a2a';
const SOFA_COLOR = '#884444';
const MONITOR_COLOR = '#224466';
const MONITOR_SCREEN = '#44aacc';
const PLANT_POT = '#664422';
const PLANT_LEAF = '#44aa44';

export function drawTileMap(ctx, mapData, width, height) {
  const { tileSize, cols, rows, layers, furniture, colors } = mapData;
  const scaleX = width / (cols * tileSize);
  const scaleY = height / (rows * tileSize);
  const scale = Math.min(scaleX, scaleY);

  const offsetX = (width - cols * tileSize * scale) / 2;
  const offsetY = (height - rows * tileSize * scale) / 2;

  ctx.save();
  ctx.translate(offsetX, offsetY);
  ctx.scale(scale, scale);

  // Floor tiles
  const floor = layers.floor;
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const tile = floor[r][c];
      ctx.fillStyle = colors[String(tile)] || '#3a3a50';
      ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
      // Grid line
      ctx.strokeStyle = 'rgba(255,255,255,0.03)';
      ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize);
    }
  }

  // Walls (top edge)
  ctx.fillStyle = WALL_COLOR;
  ctx.fillRect(0, 0, cols * tileSize, 4);

  // Furniture
  for (const f of furniture) {
    const fx = f.x * tileSize;
    const fy = f.y * tileSize;
    const fw = (f.w || 2) * tileSize;
    const fh = (f.h || 2) * tileSize;

    if (f.type === 'desk') {
      _drawDesk(ctx, fx, fy, fw, fh, f.label);
    } else if (f.type === 'table') {
      _drawTable(ctx, fx, fy, fw, fh);
    } else if (f.type === 'sofa') {
      _drawSofa(ctx, fx, fy);
    } else if (f.type === 'coffee') {
      _drawCoffee(ctx, fx, fy);
    }
  }

  ctx.restore();

  return { scale, offsetX, offsetY, tileSize };
}

function _drawDesk(ctx, x, y, w, h, label) {
  // Desk surface
  ctx.fillStyle = DESK_COLOR;
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = DESK_TOP;
  ctx.fillRect(x + 2, y + 2, w - 4, 6);

  // Monitor
  const mx = x + w / 2 - 8;
  ctx.fillStyle = MONITOR_COLOR;
  ctx.fillRect(mx, y + 4, 16, 12);
  ctx.fillStyle = MONITOR_SCREEN;
  ctx.fillRect(mx + 2, y + 6, 12, 8);

  // Label
  if (label) {
    ctx.fillStyle = 'rgba(255,255,255,0.6)';
    ctx.font = '8px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(label, x + w / 2, y + h + 12);
  }
}

function _drawTable(ctx, x, y, w, h) {
  ctx.fillStyle = TABLE_COLOR;
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = '#7a6a4a';
  ctx.fillRect(x + 4, y + 4, w - 8, h - 8);
}

function _drawSofa(ctx, x, y) {
  ctx.fillStyle = SOFA_COLOR;
  ctx.fillRect(x, y, 48, 32);
  ctx.fillStyle = '#aa5555';
  ctx.fillRect(x + 4, y + 4, 40, 24);
}

function _drawCoffee(ctx, x, y) {
  ctx.fillStyle = PLANT_POT;
  ctx.fillRect(x + 8, y + 8, 16, 20);
  ctx.fillStyle = '#886644';
  ctx.fillRect(x + 6, y + 6, 20, 4);
}

export function worldToTile(mapData, renderInfo, canvasX, canvasY) {
  const { scale, offsetX, offsetY, tileSize } = renderInfo;
  const wx = (canvasX - offsetX) / scale;
  const wy = (canvasY - offsetY) / scale;
  return {
    col: Math.floor(wx / tileSize),
    row: Math.floor(wy / tileSize),
    worldX: wx,
    worldY: wy,
  };
}

export function tileToCanvas(mapData, renderInfo, col, row) {
  const { scale, offsetX, offsetY, tileSize } = renderInfo;
  return {
    x: offsetX + col * tileSize * scale + (tileSize * scale) / 2,
    y: offsetY + row * tileSize * scale + (tileSize * scale) / 2,
  };
}
  • Step 4: Commit
git add src/pages/agent-office/canvas/ src/pages/agent-office/assets/
git commit -m "feat(agent-office): Canvas engine — SpriteSheet, TileMap, office-map data"

Task 13: Frontend Canvas — AgentSprite + OfficeRenderer

Files:

  • Create: web-ui/src/pages/agent-office/canvas/AgentSprite.js

  • Create: web-ui/src/pages/agent-office/canvas/OfficeRenderer.js

  • Step 1: Create AgentSprite.js

// web-ui/src/pages/agent-office/canvas/AgentSprite.js

import { drawAgent, getAnimSpeed } from './SpriteSheet';

export class AgentSprite {
  constructor(agentId, waypoints) {
    this.agentId = agentId;
    this.waypoints = waypoints;
    this.state = 'idle';
    this.detail = '';

    const deskKey = `${agentId}_desk`;
    const desk = waypoints[deskKey] || { x: 5, y: 3 };
    this.x = desk.x;
    this.y = desk.y;
    this.targetX = desk.x;
    this.targetY = desk.y;
    this.deskPos = { x: desk.x, y: desk.y };

    this.frameIndex = 0;
    this._lastFrameTime = 0;
    this._moveSpeed = 0.05;  // tiles per frame
  }

  setState(newState, detail = '') {
    this.state = newState;
    this.detail = detail;
    this.frameIndex = 0;
  }

  moveTo(target) {
    const wp = this.waypoints[target];
    if (wp) {
      this.targetX = wp.x;
      this.targetY = wp.y;
    }
  }

  moveToDesk() {
    this.targetX = this.deskPos.x;
    this.targetY = this.deskPos.y;
  }

  update(now) {
    // Frame animation
    const speed = getAnimSpeed(this.state);
    if (now - this._lastFrameTime > speed) {
      this.frameIndex++;
      this._lastFrameTime = now;
    }

    // Movement
    const dx = this.targetX - this.x;
    const dy = this.targetY - this.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist > 0.1) {
      const step = Math.min(this._moveSpeed, dist);
      this.x += (dx / dist) * step;
      this.y += (dy / dist) * step;
    } else {
      this.x = this.targetX;
      this.y = this.targetY;
    }
  }

  draw(ctx, renderInfo) {
    const { scale, offsetX, offsetY, tileSize } = renderInfo;
    const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
    const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;

    const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1;
    const drawState = isMoving ? 'walk' : this.state;

    drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5);
  }

  hitTest(canvasX, canvasY, renderInfo) {
    const { scale, offsetX, offsetY, tileSize } = renderInfo;
    const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2;
    const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2;
    const hitW = 20 * scale;
    const hitH = 30 * scale;

    return canvasX >= cx - hitW && canvasX <= cx + hitW &&
           canvasY >= cy - hitH && canvasY <= cy + hitH;
  }
}
  • Step 2: Create OfficeRenderer.js
// web-ui/src/pages/agent-office/canvas/OfficeRenderer.js

import { drawTileMap } from './TileMap';
import { AgentSprite } from './AgentSprite';
import { getCharLabel } from './SpriteSheet';

const STATUS_ICONS = {
  idle: null,
  working: null,
  waiting: '❗',
  reporting: '📋',
  break: '☕',
};

export class OfficeRenderer {
  constructor(canvas, mapData) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.mapData = mapData;
    this.renderInfo = null;
    this.agents = {};
    this._animId = null;
    this._onClick = null;

    // Initialize agents from map waypoints
    const agentIds = ['stock', 'music', 'claude'];
    for (const id of agentIds) {
      this.agents[id] = new AgentSprite(id, mapData.waypoints);
    }
  }

  start() {
    this._loop = this._loop.bind(this);
    this._animId = requestAnimationFrame(this._loop);
  }

  stop() {
    if (this._animId) {
      cancelAnimationFrame(this._animId);
      this._animId = null;
    }
  }

  resize(width, height) {
    this.canvas.width = width;
    this.canvas.height = height;
  }

  setOnClick(handler) {
    this._onClick = handler;
  }

  handleClick(canvasX, canvasY) {
    if (!this.renderInfo) return null;

    for (const [id, sprite] of Object.entries(this.agents)) {
      if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) {
        if (this._onClick) this._onClick(id);
        return id;
      }
    }
    return null;
  }

  updateAgentState(agentId, state, detail) {
    const sprite = this.agents[agentId];
    if (sprite) {
      sprite.setState(state, detail);
      if (state === 'idle' || state === 'working' || state === 'waiting') {
        sprite.moveToDesk();
      }
    }
  }

  moveAgent(agentId, target) {
    const sprite = this.agents[agentId];
    if (sprite) {
      sprite.moveTo(target);
    }
  }

  _loop(timestamp) {
    const { ctx, canvas, mapData } = this;

    // Clear
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // Background
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // Draw tilemap
    this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);

    // Update and draw agents
    const now = Date.now();
    for (const sprite of Object.values(this.agents)) {
      sprite.update(now);
      sprite.draw(ctx, this.renderInfo);
    }

    // Draw overlays (bubbles, icons, labels)
    for (const [id, sprite] of Object.entries(this.agents)) {
      this._drawOverlay(ctx, sprite, id);
    }

    this._animId = requestAnimationFrame(this._loop);
  }

  _drawOverlay(ctx, sprite, agentId) {
    if (!this.renderInfo) return;
    const { scale, offsetX, offsetY, tileSize } = this.renderInfo;
    const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2;
    const cy = offsetY + sprite.y * tileSize * scale - 10 * scale;

    // Status icon
    const icon = STATUS_ICONS[sprite.state];
    if (icon) {
      ctx.font = `${14 * scale}px serif`;
      ctx.textAlign = 'center';
      ctx.fillText(icon, cx, cy - 15 * scale);
    }

    // Name label
    ctx.fillStyle = 'rgba(255,255,255,0.7)';
    ctx.font = `${8 * scale}px monospace`;
    ctx.textAlign = 'center';
    ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30);

    // Detail bubble (working/waiting)
    if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) {
      const bubbleY = cy - 25 * scale;
      ctx.fillStyle = 'rgba(0,0,0,0.7)';
      const textW = ctx.measureText(sprite.detail).width;
      ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16);
      ctx.fillStyle = '#fff';
      ctx.font = `${7 * scale}px monospace`;
      ctx.fillText(sprite.detail, cx, bubbleY);
    }
  }
}
  • Step 3: Commit
git add src/pages/agent-office/canvas/
git commit -m "feat(agent-office): AgentSprite movement + OfficeRenderer game loop"

Task 14: Frontend Hooks — useAgentManager + useOfficeCanvas

Files:

  • Create: web-ui/src/pages/agent-office/hooks/useAgentManager.js

  • Create: web-ui/src/pages/agent-office/hooks/useOfficeCanvas.js

  • Step 1: Create useAgentManager.js

// web-ui/src/pages/agent-office/hooks/useAgentManager.js

import { useState, useEffect, useRef, useCallback } from 'react';

export function useAgentManager() {
  const [agents, setAgents] = useState({});
  const [pendingTasks, setPendingTasks] = useState([]);
  const [connected, setConnected] = useState(false);
  const wsRef = useRef(null);
  const reconnectTimer = useRef(null);

  const connect = useCallback(() => {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`;

    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onopen = () => {
      setConnected(true);
      if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
    };

    ws.onclose = () => {
      setConnected(false);
      reconnectTimer.current = setTimeout(connect, 3000);
    };

    ws.onerror = () => {
      ws.close();
    };

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);

      switch (msg.type) {
        case 'init':
          const agentMap = {};
          for (const a of msg.agents) {
            agentMap[a.agent_id] = { state: a.state, detail: a.detail };
          }
          setAgents(agentMap);
          setPendingTasks(msg.pending || []);
          break;

        case 'agent_state':
          setAgents(prev => ({
            ...prev,
            [msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id },
          }));
          break;

        case 'task_complete':
          setAgents(prev => ({
            ...prev,
            [msg.agent]: { ...prev[msg.agent], lastResult: msg.result },
          }));
          setPendingTasks(prev => prev.filter(id => id !== msg.task_id));
          break;

        case 'command_result':
          setAgents(prev => ({
            ...prev,
            [msg.agent]: { ...prev[msg.agent], lastCommand: msg.result },
          }));
          break;

        default:
          break;
      }
    };
  }, []);

  useEffect(() => {
    connect();
    return () => {
      if (wsRef.current) wsRef.current.close();
      if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
    };
  }, [connect]);

  const sendCommand = useCallback((agent, action, params = {}) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params }));
    }
  }, []);

  const sendApproval = useCallback((agent, taskId, approved) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved }));
    }
  }, []);

  return { agents, pendingTasks, connected, sendCommand, sendApproval };
}
  • Step 2: Create useOfficeCanvas.js
// web-ui/src/pages/agent-office/hooks/useOfficeCanvas.js

import { useRef, useEffect, useCallback } from 'react';
import { OfficeRenderer } from '../canvas/OfficeRenderer';
import officeMap from '../assets/office-map.json';

export function useOfficeCanvas(containerRef, onAgentClick) {
  const rendererRef = useRef(null);
  const canvasRef = useRef(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const canvas = document.createElement('canvas');
    canvas.style.display = 'block';
    canvas.style.width = '100%';
    canvas.style.height = '100%';
    canvas.style.imageRendering = 'pixelated';
    containerRef.current.appendChild(canvas);
    canvasRef.current = canvas;

    const renderer = new OfficeRenderer(canvas, officeMap);
    rendererRef.current = renderer;

    const resize = () => {
      const rect = containerRef.current.getBoundingClientRect();
      renderer.resize(rect.width, rect.height);
    };

    resize();
    renderer.start();

    renderer.setOnClick((agentId) => {
      if (onAgentClick) onAgentClick(agentId);
    });

    const handleClick = (e) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      renderer.handleClick(x, y);
    };

    canvas.addEventListener('click', handleClick);
    window.addEventListener('resize', resize);

    return () => {
      renderer.stop();
      canvas.removeEventListener('click', handleClick);
      window.removeEventListener('resize', resize);
      if (containerRef.current && canvas.parentNode === containerRef.current) {
        containerRef.current.removeChild(canvas);
      }
    };
  }, [containerRef, onAgentClick]);

  const updateAgentState = useCallback((agentId, state, detail) => {
    rendererRef.current?.updateAgentState(agentId, state, detail);
  }, []);

  const moveAgent = useCallback((agentId, target) => {
    rendererRef.current?.moveAgent(agentId, target);
  }, []);

  return { updateAgentState, moveAgent };
}
  • Step 3: Commit
git add src/pages/agent-office/hooks/
git commit -m "feat(agent-office): useAgentManager WebSocket hook + useOfficeCanvas rendering hook"

Task 15: Frontend Components — ChatPanel + TaskHistory

Files:

  • Create: web-ui/src/pages/agent-office/components/ChatPanel.jsx

  • Create: web-ui/src/pages/agent-office/components/TaskHistory.jsx

  • Step 1: Create ChatPanel.jsx

// web-ui/src/pages/agent-office/components/ChatPanel.jsx
import React, { useState } from 'react';

const AGENT_COMMANDS = {
  stock: [
    { action: 'fetch_news', label: '뉴스 수집', icon: '📰' },
    { action: 'list_alerts', label: '알람 목록', icon: '🔔' },
  ],
  music: [
    { action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true },
    { action: 'credits', label: '크레딧 확인', icon: '💳' },
  ],
  claude: [
    { action: 'instruct', label: '지시하기', icon: '💬', needsInput: true },
  ],
};

const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => {
  const [input, setInput] = useState('');
  const [activeCommand, setActiveCommand] = useState(null);

  const commands = AGENT_COMMANDS[agentId] || [];
  const state = agentState || {};

  const handleSend = () => {
    if (!input.trim() || !activeCommand) return;
    const params = activeCommand === 'compose'
      ? { prompt: input }
      : { message: input };
    onCommand(agentId, activeCommand, params);
    setInput('');
    setActiveCommand(null);
  };

  const handleQuickAction = (cmd) => {
    if (cmd.needsInput) {
      setActiveCommand(cmd.action);
    } else {
      onCommand(agentId, cmd.action, {});
    }
  };

  return (
    <div className="ao-chat-panel">
      <div className="ao-chat-header">
        <span className="ao-chat-title">
          {agentId === 'stock' ? '주식 트레이더' :
           agentId === 'music' ? '음악 프로듀서' : 'Claude AI'}
        </span>
        <span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}>
          {state.state || 'idle'}
        </span>
        <button className="ao-chat-close" onClick={onClose}>&times;</button>
      </div>

      {state.detail && (
        <div className="ao-chat-detail">{state.detail}</div>
      )}

      {state.state === 'waiting' && state.taskId && (
        <div className="ao-chat-approval">
          <p>승인 대기 중인 작업이 있습니다</p>
          <div className="ao-chat-approval-btns">
            <button className="ao-btn ao-btn--approve"
              onClick={() => onApproval(agentId, state.taskId, true)}>
               승인
            </button>
            <button className="ao-btn ao-btn--reject"
              onClick={() => onApproval(agentId, state.taskId, false)}>
               거절
            </button>
          </div>
        </div>
      )}

      <div className="ao-chat-commands">
        {commands.map(cmd => (
          <button key={cmd.action} className="ao-cmd-btn"
            onClick={() => handleQuickAction(cmd)}>
            <span>{cmd.icon}</span> {cmd.label}
          </button>
        ))}
      </div>

      {activeCommand && (
        <div className="ao-chat-input-area">
          <input
            type="text"
            className="ao-chat-input"
            value={input}
            onChange={e => setInput(e.target.value)}
            onKeyDown={e => e.key === 'Enter' && handleSend()}
            placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'}
            autoFocus
          />
          <button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
        </div>
      )}

      {state.lastResult && (
        <div className="ao-chat-result">
          <h4>최근 결과</h4>
          <pre>{JSON.stringify(state.lastResult, null, 2)}</pre>
        </div>
      )}
    </div>
  );
};

export default ChatPanel;
  • Step 2: Create TaskHistory.jsx
// web-ui/src/pages/agent-office/components/TaskHistory.jsx
import React, { useState, useEffect } from 'react';
import { getAgentTasks } from '../../../api';

const STATUS_BADGE = {
  pending: { label: '대기', color: '#fbbf24' },
  approved: { label: '승인됨', color: '#60a5fa' },
  working: { label: '진행중', color: '#818cf8' },
  succeeded: { label: '완료', color: '#34d399' },
  failed: { label: '실패', color: '#f87171' },
};

const TaskHistory = ({ agentId, onClose }) => {
  const [tasks, setTasks] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!agentId) return;
    setLoading(true);
    getAgentTasks(agentId, 30)
      .then(data => setTasks(data.tasks || []))
      .catch(() => setTasks([]))
      .finally(() => setLoading(false));
  }, [agentId]);

  return (
    <div className="ao-history-panel">
      <div className="ao-history-header">
        <span>작업 이력  {agentId}</span>
        <button className="ao-chat-close" onClick={onClose}>&times;</button>
      </div>
      <div className="ao-history-list">
        {loading && <p className="ao-history-empty">로딩 ...</p>}
        {!loading && tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
        {tasks.map(task => {
          const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
          return (
            <div key={task.id} className="ao-history-item">
              <div className="ao-history-item-header">
                <span className="ao-history-type">{task.task_type}</span>
                <span className="ao-history-badge" style={{ background: badge.color }}>
                  {badge.label}
                </span>
              </div>
              <div className="ao-history-time">
                {task.created_at?.replace('T', ' ').slice(0, 19)}
              </div>
              {task.result_data && (
                <details className="ao-history-detail">
                  <summary>결과 보기</summary>
                  <pre>{JSON.stringify(task.result_data, null, 2)}</pre>
                </details>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default TaskHistory;
  • Step 3: Commit
git add src/pages/agent-office/components/
git commit -m "feat(agent-office): ChatPanel with commands/approval + TaskHistory panel"

Task 16: Frontend — AgentOffice Main Page + CSS

Files:

  • Create: web-ui/src/pages/agent-office/AgentOffice.jsx

  • Create: web-ui/src/pages/agent-office/AgentOffice.css

  • Step 1: Create AgentOffice.jsx

// web-ui/src/pages/agent-office/AgentOffice.jsx
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useAgentManager } from './hooks/useAgentManager';
import { useOfficeCanvas } from './hooks/useOfficeCanvas';
import ChatPanel from './components/ChatPanel';
import TaskHistory from './components/TaskHistory';
import './AgentOffice.css';

export function Component() {
  const canvasContainerRef = useRef(null);
  const [selectedAgent, setSelectedAgent] = useState(null);
  const [showHistory, setShowHistory] = useState(null);

  const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager();

  const handleAgentClick = useCallback((agentId) => {
    setSelectedAgent(prev => prev === agentId ? null : agentId);
  }, []);

  const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick);

  // Sync WebSocket state to canvas
  useEffect(() => {
    for (const [id, info] of Object.entries(agents)) {
      updateAgentState(id, info.state, info.detail);
    }
  }, [agents, updateAgentState]);

  return (
    <div className="ao-page">
      <div className="ao-header">
        <h1 className="ao-title">Agent Office</h1>
        <div className="ao-status">
          <span className={`ao-dot ${connected ? 'ao-dot--on' : 'ao-dot--off'}`} />
          {connected ? 'Connected' : 'Disconnected'}
        </div>
      </div>

      <div className="ao-workspace">
        <div className="ao-canvas-container" ref={canvasContainerRef} />

        {/* Agent indicator bar */}
        <div className="ao-agent-bar">
          {Object.entries(agents).map(([id, info]) => (
            <button
              key={id}
              className={`ao-agent-chip ${info.state === 'waiting' ? 'ao-agent-chip--alert' : ''} ${selectedAgent === id ? 'ao-agent-chip--selected' : ''}`}
              onClick={() => handleAgentClick(id)}
            >
              <span className={`ao-chip-dot ao-chip-dot--${info.state}`} />
              {id}
              {info.state === 'waiting' && <span className="ao-chip-badge">!</span>}
            </button>
          ))}
          {pendingTasks.length > 0 && (
            <span className="ao-pending-count">{pendingTasks.length} pending</span>
          )}
        </div>

        {/* Panels */}
        {selectedAgent && (
          <ChatPanel
            agentId={selectedAgent}
            agentState={agents[selectedAgent]}
            onCommand={sendCommand}
            onApproval={sendApproval}
            onClose={() => setSelectedAgent(null)}
          />
        )}

        {showHistory && (
          <TaskHistory
            agentId={showHistory}
            onClose={() => setShowHistory(null)}
          />
        )}
      </div>

      {/* Bottom toolbar */}
      <div className="ao-toolbar">
        {['stock', 'music', 'claude'].map(id => (
          <button key={id} className="ao-tool-btn"
            onClick={() => setShowHistory(prev => prev === id ? null : id)}>
            📋 {id} 이력
          </button>
        ))}
      </div>
    </div>
  );
}
  • Step 2: Create AgentOffice.css
/* web-ui/src/pages/agent-office/AgentOffice.css */

.ao-page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #0d0d1a;
  color: #e0e0e0;
  font-family: 'Courier New', monospace;
}

.ao-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 20px;
  background: #1a1a2e;
  border-bottom: 1px solid #2a2a4a;
}

.ao-title {
  font-size: 1.4rem;
  color: #8b5cf6;
  margin: 0;
  letter-spacing: 2px;
}

.ao-status {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.85rem;
  color: #888;
}

.ao-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.ao-dot--on { background: #34d399; }
.ao-dot--off { background: #f87171; }

.ao-workspace {
  flex: 1;
  position: relative;
  overflow: hidden;
}

.ao-canvas-container {
  width: 100%;
  height: 100%;
}

/* Agent bar */
.ao-agent-bar {
  position: absolute;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 8px;
  padding: 6px 12px;
  background: rgba(0, 0, 0, 0.6);
  border-radius: 20px;
  backdrop-filter: blur(8px);
}

.ao-agent-chip {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 12px;
  border: 1px solid #333;
  border-radius: 12px;
  background: transparent;
  color: #ccc;
  font-size: 0.8rem;
  cursor: pointer;
  font-family: inherit;
}
.ao-agent-chip:hover { border-color: #8b5cf6; }
.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); }
.ao-agent-chip--alert { animation: ao-pulse 1s infinite; }

@keyframes ao-pulse {
  0%, 100% { border-color: #fbbf24; }
  50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); }
}

.ao-chip-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
}
.ao-chip-dot--idle { background: #666; }
.ao-chip-dot--working { background: #818cf8; }
.ao-chip-dot--waiting { background: #fbbf24; }
.ao-chip-dot--reporting { background: #34d399; }
.ao-chip-dot--break { background: #a78bfa; }

.ao-chip-badge {
  background: #f87171;
  color: #fff;
  font-size: 0.65rem;
  padding: 0 4px;
  border-radius: 4px;
  font-weight: bold;
}

.ao-pending-count {
  color: #fbbf24;
  font-size: 0.75rem;
  align-self: center;
}

/* Chat Panel */
.ao-chat-panel {
  position: absolute;
  right: 16px;
  top: 60px;
  width: 340px;
  max-height: calc(100% - 80px);
  background: rgba(26, 26, 46, 0.95);
  border: 1px solid #333;
  border-radius: 12px;
  overflow-y: auto;
  backdrop-filter: blur(12px);
}

.ao-chat-header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  border-bottom: 1px solid #2a2a4a;
}

.ao-chat-title {
  flex: 1;
  font-weight: bold;
  color: #e0e0e0;
}

.ao-chat-state {
  font-size: 0.75rem;
  padding: 2px 8px;
  border-radius: 8px;
  text-transform: uppercase;
}
.ao-chat-state--idle { background: #333; }
.ao-chat-state--working { background: #3730a3; }
.ao-chat-state--waiting { background: #92400e; }
.ao-chat-state--break { background: #4c1d95; }

.ao-chat-close {
  background: none;
  border: none;
  color: #888;
  font-size: 1.2rem;
  cursor: pointer;
}
.ao-chat-close:hover { color: #fff; }

.ao-chat-detail {
  padding: 8px 16px;
  color: #aaa;
  font-size: 0.85rem;
}

.ao-chat-approval {
  padding: 12px 16px;
  background: rgba(251, 191, 36, 0.1);
  border-top: 1px solid #2a2a4a;
  border-bottom: 1px solid #2a2a4a;
}
.ao-chat-approval p {
  margin: 0 0 8px;
  color: #fbbf24;
  font-size: 0.85rem;
}
.ao-chat-approval-btns {
  display: flex;
  gap: 8px;
}

.ao-btn {
  padding: 6px 16px;
  border: none;
  border-radius: 6px;
  font-size: 0.85rem;
  cursor: pointer;
  font-family: inherit;
}
.ao-btn--approve { background: #065f46; color: #34d399; }
.ao-btn--approve:hover { background: #047857; }
.ao-btn--reject { background: #7f1d1d; color: #f87171; }
.ao-btn--reject:hover { background: #991b1b; }
.ao-btn--send { background: #4c1d95; color: #c4b5fd; }
.ao-btn--send:hover { background: #5b21b6; }

.ao-chat-commands {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 12px 16px;
}

.ao-cmd-btn {
  padding: 6px 12px;
  border: 1px solid #333;
  border-radius: 8px;
  background: transparent;
  color: #ccc;
  font-size: 0.8rem;
  cursor: pointer;
  font-family: inherit;
}
.ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); }

.ao-chat-input-area {
  display: flex;
  gap: 8px;
  padding: 8px 16px 12px;
}
.ao-chat-input {
  flex: 1;
  padding: 8px 12px;
  background: #111;
  border: 1px solid #333;
  border-radius: 6px;
  color: #e0e0e0;
  font-size: 0.85rem;
  font-family: inherit;
}
.ao-chat-input:focus { border-color: #8b5cf6; outline: none; }

.ao-chat-result {
  padding: 8px 16px;
  border-top: 1px solid #2a2a4a;
}
.ao-chat-result h4 {
  margin: 0 0 8px;
  font-size: 0.8rem;
  color: #888;
}
.ao-chat-result pre {
  font-size: 0.75rem;
  color: #aaa;
  overflow-x: auto;
  white-space: pre-wrap;
  margin: 0;
}

/* Task History */
.ao-history-panel {
  position: absolute;
  left: 16px;
  top: 60px;
  width: 340px;
  max-height: calc(100% - 80px);
  background: rgba(26, 26, 46, 0.95);
  border: 1px solid #333;
  border-radius: 12px;
  overflow-y: auto;
  backdrop-filter: blur(12px);
}

.ao-history-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid #2a2a4a;
  font-weight: bold;
}

.ao-history-list { padding: 8px; }
.ao-history-empty { text-align: center; color: #666; padding: 20px; }

.ao-history-item {
  padding: 10px 12px;
  border-bottom: 1px solid #1a1a2e;
}
.ao-history-item:last-child { border-bottom: none; }

.ao-history-item-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.ao-history-type { font-size: 0.85rem; color: #ccc; }
.ao-history-badge {
  font-size: 0.7rem;
  padding: 2px 8px;
  border-radius: 4px;
  color: #fff;
}
.ao-history-time {
  font-size: 0.75rem;
  color: #666;
  margin-top: 4px;
}
.ao-history-detail {
  margin-top: 6px;
  font-size: 0.75rem;
}
.ao-history-detail summary {
  cursor: pointer;
  color: #8b5cf6;
}
.ao-history-detail pre {
  color: #aaa;
  white-space: pre-wrap;
  margin: 4px 0 0;
}

/* Toolbar */
.ao-toolbar {
  display: flex;
  gap: 8px;
  padding: 8px 20px;
  background: #1a1a2e;
  border-top: 1px solid #2a2a4a;
}

.ao-tool-btn {
  padding: 6px 14px;
  border: 1px solid #333;
  border-radius: 6px;
  background: transparent;
  color: #aaa;
  font-size: 0.8rem;
  cursor: pointer;
  font-family: inherit;
}
.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; }
  • Step 3: Commit
git add src/pages/agent-office/
git commit -m "feat(agent-office): AgentOffice main page with canvas + overlay panels + CSS"

Task 17: CLAUDE.md Updates

Files:

  • Modify: web-backend/CLAUDE.md

  • Step 1: Add agent-office to CLAUDE.md

Add to the Docker services table (section 4):

| `agent-office` | 18900 | AI 에이전트 사무실 — FSM, 텔레그램, 스케줄러 |

Add to the Nginx routing rules (section 5):

| `/api/agent-office/` | `agent-office:8000` | Agent Office API + WebSocket |

Add a new subsection to section 9 (서비스별 핵심 정보):

### agent-office (agent-office/)
- AI 에이전트 가상 사무실 서비스 (에이전트 FSM, 텔레그램 양방향, 스케줄러)
- 기존 서비스 프록시 호출 (stock-lab, music-lab)
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state)
- 파일 구조: `main.py`, `config.py`, `db.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`

**환경변수**
- `TELEGRAM_BOT_TOKEN`: 텔레그램 Bot API 토큰
- `TELEGRAM_CHAT_ID`: 알림 수신 채팅 ID
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook 수신 URL
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)

**에이전트 목록 (MVP)**
- `stock`: 매일 08:00 뉴스 요약 + 주가 알람 (자동)
- `music`: 프롬프트 기반 작곡 (승인 필요)

**스케줄러 job**
- 08:00 매일 — StockAgent 뉴스 수집/요약/텔레그램 전송
- 60초 간격 — 에이전트 idle break 체크

**agent-office API 목록**

| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/agent-office/agents` | 에이전트 목록 |
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정+상태) |
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
| POST | `/api/agent-office/command` | 에이전트 직접 지시 |
| POST | `/api/agent-office/approve` | 작업 승인/거절 |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
| WS | `/api/agent-office/ws` | WebSocket 실시간 연결 |
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 |
  • Step 2: Commit
git add CLAUDE.md
git commit -m "docs: add agent-office service to CLAUDE.md"

Summary

Task Component Estimated Complexity
1 Backend scaffold (config, db, models) Standard
2 WebSocket manager Simple
3 Service proxy Simple
4 BaseAgent FSM Standard
5 StockAgent Standard
6 Telegram bot Standard
7 MusicAgent Standard
8 Scheduler Simple
9 FastAPI main (REST + WS) Complex
10 Infrastructure (Docker + Nginx) Standard
11 Frontend API + routing + Lab entry Simple
12 Canvas SpriteSheet + TileMap Complex
13 Canvas AgentSprite + OfficeRenderer Complex
14 Frontend hooks (WebSocket + Canvas) Standard
15 Frontend ChatPanel + TaskHistory Standard
16 Frontend AgentOffice page + CSS Standard
17 CLAUDE.md updates Simple