diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index cfee99e..0000000 --- a/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:22-slim - -# System deps for Claude Code tools -RUN apt-get update && apt-get install -y \ - git curl jq ripgrep \ - python3 python3-pip \ - && rm -rf /var/lib/apt/lists/* - -# Working directory -WORKDIR /app - -# Install agent dependencies -COPY agent/package.json agent/package-lock.json* ./ -RUN npm install - -# Copy and build agent code -COPY agent/tsconfig.json ./ -COPY agent/src/ ./src/ -RUN npx tsc - -# Workspace for agent data (mounted from host) -RUN mkdir -p /workspace /workspace/ipc/input /workspace/ipc/messages /workspace/ipc/tasks /workspace/conversations - -WORKDIR /workspace - -ENTRYPOINT ["node", "/app/dist/index.js"] diff --git a/README.md b/README.md index d5d5df0..bbeca89 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,79 @@ # Team Board Runner -Универсальный runner для AI-агентов Team Board. Запускает агентов в Docker-контейнерах с изоляцией. +AI-агент для Team Board. Один процесс, прямой доступ к файлам, WebSocket к Tracker. ## Архитектура ``` -Host (runner.py) Docker Container (agent) -┌──────────────┐ stdin/stdout ┌──────────────────────┐ -│ Runner │◄──────────────────►│ Claude Agent SDK │ -│ │ │ + MCP Tools │ -│ - Container │ IPC files │ + Persistent │ -│ manager │◄──────────────────►│ Sessions │ -│ - IPC → │ │ + SQLite (internal) │ -│ Tracker │ │ │ -└──────────────┘ └──────────────────────┘ +┌────────────────────────────────────────┐ +│ Agent Runner (один процесс) │ +│ │ +│ ┌──────────────┐ ┌────────────────┐ │ +│ │ Claude Agent │ │ WebSocket │ │ +│ │ SDK │ │ Client │ │ +│ │ (query loop, │ │ (Tracker :8100)│ │ +│ │ sessions, │ │ │ │ +│ │ tools) │ │ │ │ +│ └──────────────┘ └────────────────┘ │ +│ │ +│ Прямой доступ к файлам, git, bash │ +└────────────────────────────────────────┘ ``` -## Компоненты - -### agent/ — код внутри контейнера -- `index.ts` — Claude Agent SDK query loop с persistent sessions -- `mcp.ts` — MCP tools для работы с Tracker (chat, tasks) - -### runner.py — код на хосте -- Запускает Docker контейнеры -- Маршрутизирует IPC файлы → Tracker REST API -- Будущее: WebSocket к Tracker для real-time событий - -## Использование +## Быстрый старт ```bash -# Собрать Docker образ -docker build -t team-board-agent . +# Установка +npm install -# Запустить интерактивную сессию -CLAUDE_CODE_OAUTH_TOKEN=xxx python3 runner.py --slug coder --name "Кодер" +# Тест (без Tracker, интерактивный режим) +CLAUDE_CODE_OAUTH_TOKEN=xxx npm run test + +# Продакшн (с Tracker) +CLAUDE_CODE_OAUTH_TOKEN=xxx \ +AGENT_TOKEN=agent-xxx \ +AGENT_SLUG=coder \ +AGENT_NAME="Кодер" \ +npm start ``` -## IPC Protocol +## Переменные окружения -Агент внутри контейнера общается с runner'ом через файлы: +| Переменная | Описание | По умолчанию | +|-----------|----------|-------------| +| `CLAUDE_CODE_OAUTH_TOKEN` | OAuth токен Claude (подписка) | — | +| `ANTHROPIC_API_KEY` | API ключ Anthropic (альтернатива) | — | +| `TRACKER_URL` | WebSocket URL Tracker | `ws://localhost:8100/ws` | +| `AGENT_TOKEN` | Токен агента в Tracker | — | +| `AGENT_SLUG` | Slug агента | `coder` | +| `AGENT_NAME` | Имя агента | `Кодер` | +| `AGENT_WORKSPACE` | Рабочая директория | `/opt/team-board-agents/{slug}` | +| `LOBBY_CHAT_ID` | ID лобби-чата для авто-подписки | — | -- `/workspace/ipc/input/*.json` — сообщения от runner'а к агенту -- `/workspace/ipc/messages/*.json` — чат-сообщения от агента -- `/workspace/ipc/tasks/*.json` — операции с задачами от агента -- `/workspace/ipc/input/_close` — сигнал завершения +## Режимы + +### Test mode (`--test`) +Интерактивный режим без Tracker. Пишешь в консоли — агент отвечает. Для проверки что SDK работает. + +### Tracker mode (по умолчанию) +Подключается к Tracker по WebSocket, слушает чат-сообщения и задачи, отвечает через AI. + +## Как работает + +1. Подключается к Tracker по WebSocket +2. Аутентифицируется токеном агента +3. Подписывается на чат-комнаты +4. При упоминании (`@slug`) → запускает Claude Agent SDK `query()` +5. Результат → отправляет обратно в чат через WebSocket +6. При назначении задачи → берёт, выполняет, постит результат + +## Persistent Sessions + +Claude Agent SDK сохраняет сессии автоматически. При следующем вызове `query()` с тем же `sessionId` — агент помнит контекст предыдущих разговоров. ## Multi-model (будущее) -Архитектура позволяет подключить другие модели: -- Gemini (через MCP или прямой API) -- ChatGPT (через API) +Текущая архитектура позволяет заменить Claude Agent SDK на другие: +- Gemini SDK +- OpenAI SDK - Локальные модели (ollama) - -Для этого нужно заменить Claude Agent SDK на соответствующий SDK внутри контейнера. diff --git a/agent/package.json b/agent/package.json deleted file mode 100644 index e4cda7d..0000000 --- a/agent/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "team-board-agent", - "version": "0.1.0", - "type": "module", - "description": "Team Board Agent — runs inside container with Claude Agent SDK", - "main": "dist/index.js", - "scripts": { - "build": "tsc", - "start": "node dist/index.js" - }, - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", - "@modelcontextprotocol/sdk": "^1.12.1", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/node": "^22.10.7", - "typescript": "^5.7.3" - } -} diff --git a/agent/src/index.ts b/agent/src/index.ts deleted file mode 100644 index 5aca824..0000000 --- a/agent/src/index.ts +++ /dev/null @@ -1,364 +0,0 @@ -/** - * Team Board Agent Runner - * Runs inside a container. Receives prompts via stdin, outputs results to stdout. - * Supports persistent sessions via Claude Agent SDK. - * - * Input: JSON on stdin (ContainerInput) - * Follow-up messages: JSON files in /workspace/ipc/input/ - * Output: JSON wrapped in markers on stdout - * Close signal: /workspace/ipc/input/_close - */ - -import fs from 'fs'; -import path from 'path'; -import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; - -// --- Types --- - -interface ContainerInput { - prompt: string; - sessionId?: string; - agentSlug: string; - agentName: string; - secrets?: Record; -} - -interface ContainerOutput { - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface SDKUserMessage { - type: 'user'; - message: { role: 'user'; content: string }; - parent_tool_use_id: null; - session_id: string; -} - -// --- Constants --- - -const IPC_INPUT_DIR = '/workspace/ipc/input'; -const IPC_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); -const IPC_POLL_MS = 500; -const OUTPUT_START = '---AGENT_OUTPUT_START---'; -const OUTPUT_END = '---AGENT_OUTPUT_END---'; - -// Secrets to strip from Bash subprocesses -const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; - -// --- MessageStream: push-based async iterable for streaming prompts --- - -class MessageStream { - private queue: SDKUserMessage[] = []; - private waiting: (() => void) | null = null; - private done = false; - - push(text: string): void { - this.queue.push({ - type: 'user', - message: { role: 'user', content: text }, - parent_tool_use_id: null, - session_id: '', - }); - this.waiting?.(); - } - - end(): void { - this.done = true; - this.waiting?.(); - } - - async *[Symbol.asyncIterator](): AsyncGenerator { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise(r => { this.waiting = r; }); - this.waiting = null; - } - } -} - -// --- Helpers --- - -function log(msg: string): void { - console.error(`[agent] ${msg}`); -} - -function writeOutput(output: ContainerOutput): void { - console.log(OUTPUT_START); - console.log(JSON.stringify(output)); - console.log(OUTPUT_END); -} - -async function readStdin(): Promise { - return new Promise((resolve, reject) => { - let data = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('data', chunk => { data += chunk; }); - process.stdin.on('end', () => resolve(data)); - process.stdin.on('error', reject); - }); -} - -function shouldClose(): boolean { - if (fs.existsSync(IPC_CLOSE_SENTINEL)) { - try { fs.unlinkSync(IPC_CLOSE_SENTINEL); } catch {} - return true; - } - return false; -} - -function drainIpcInput(): string[] { - try { - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - const files = fs.readdirSync(IPC_INPUT_DIR) - .filter(f => f.endsWith('.json')) - .sort(); - - const messages: string[] = []; - for (const file of files) { - const filePath = path.join(IPC_INPUT_DIR, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - fs.unlinkSync(filePath); - if (data.type === 'message' && data.text) { - messages.push(data.text); - } - } catch (err) { - log(`Failed to process IPC file ${file}: ${err}`); - try { fs.unlinkSync(filePath); } catch {} - } - } - return messages; - } catch { - return []; - } -} - -function waitForIpcMessage(): Promise { - return new Promise((resolve) => { - const poll = () => { - if (shouldClose()) { resolve(null); return; } - const msgs = drainIpcInput(); - if (msgs.length > 0) { resolve(msgs.join('\n')); return; } - setTimeout(poll, IPC_POLL_MS); - }; - poll(); - }); -} - -// --- Hooks --- - -/** Strip API keys from Bash subprocess environments */ -function createSanitizeBashHook(): HookCallback { - return async (input) => { - const preInput = input as PreToolUseHookInput; - const command = (preInput.tool_input as { command?: string })?.command; - if (!command) return {}; - const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; - return { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - updatedInput: { - ...(preInput.tool_input as Record), - command: unsetPrefix + command, - }, - }, - }; - }; -} - -/** Archive transcript before compaction */ -function createPreCompactHook(): HookCallback { - return async (input) => { - const preCompact = input as PreCompactHookInput; - const transcriptPath = preCompact.transcript_path; - if (!transcriptPath || !fs.existsSync(transcriptPath)) return {}; - - try { - const content = fs.readFileSync(transcriptPath, 'utf-8'); - const archiveDir = '/workspace/conversations'; - fs.mkdirSync(archiveDir, { recursive: true }); - const date = new Date().toISOString().split('T')[0]; - const time = new Date().toISOString().replace(/[:.]/g, '-'); - fs.writeFileSync( - path.join(archiveDir, `${date}-${time}.jsonl`), - content - ); - log(`Archived transcript before compaction`); - } catch (err) { - log(`Failed to archive: ${err}`); - } - return {}; - }; -} - -// --- Main query loop --- - -async function runQuery( - prompt: string, - sessionId: string | undefined, - input: ContainerInput, - sdkEnv: Record, - mcpServerPath: string, - resumeAt?: string, -): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { - const stream = new MessageStream(); - stream.push(prompt); - - // Poll IPC during query - let ipcPolling = true; - let closedDuringQuery = false; - const pollIpc = () => { - if (!ipcPolling) return; - if (shouldClose()) { - closedDuringQuery = true; - stream.end(); - ipcPolling = false; - return; - } - const msgs = drainIpcInput(); - for (const text of msgs) { - log(`IPC message piped (${text.length} chars)`); - stream.push(text); - } - setTimeout(pollIpc, IPC_POLL_MS); - }; - setTimeout(pollIpc, IPC_POLL_MS); - - let newSessionId: string | undefined; - let lastAssistantUuid: string | undefined; - - // Load system prompt from CLAUDE.md if exists - const claudeMdPath = '/workspace/CLAUDE.md'; - let systemPromptAppend: string | undefined; - if (fs.existsSync(claudeMdPath)) { - systemPromptAppend = fs.readFileSync(claudeMdPath, 'utf-8'); - } - - for await (const message of query({ - prompt: stream, - options: { - cwd: '/workspace', - resume: sessionId, - resumeSessionAt: resumeAt, - systemPrompt: systemPromptAppend - ? { type: 'preset' as const, preset: 'claude_code' as const, append: systemPromptAppend } - : undefined, - allowedTools: [ - 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', - 'WebSearch', 'WebFetch', - 'Task', 'TaskOutput', 'TaskStop', - 'TeamCreate', 'TeamDelete', 'SendMessage', - 'TodoWrite', 'NotebookEdit', - 'mcp__tracker__*', - ], - env: sdkEnv, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, - mcpServers: { - tracker: { - command: 'node', - args: [mcpServerPath], - env: { - AGENT_SLUG: input.agentSlug, - AGENT_NAME: input.agentName, - }, - }, - }, - hooks: { - PreCompact: [{ hooks: [createPreCompactHook()] }], - PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], - }, - }, - })) { - if (message.type === 'system' && message.subtype === 'init') { - newSessionId = message.session_id; - log(`Session: ${newSessionId}`); - } - if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - } - if (message.type === 'result') { - const text = 'result' in message ? (message as { result?: string }).result : null; - if (text) { - const clean = text.replace(/[\s\S]*?<\/internal>/g, '').trim(); - if (clean) { - writeOutput({ status: 'success', result: clean, newSessionId }); - } - } - } - } - - ipcPolling = false; - return { newSessionId, lastAssistantUuid, closedDuringQuery }; -} - -// --- Entry point --- - -async function main(): Promise { - let input: ContainerInput; - try { - const raw = await readStdin(); - input = JSON.parse(raw); - try { fs.unlinkSync('/tmp/input.json'); } catch {} - log(`Agent '${input.agentName}' (${input.agentSlug}) starting`); - } catch (err) { - writeOutput({ status: 'error', result: null, error: `Bad input: ${err}` }); - process.exit(1); - } - - // Build SDK env with secrets (never exposed to Bash) - const sdkEnv: Record = { ...process.env }; - for (const [key, value] of Object.entries(input.secrets || {})) { - sdkEnv[key] = value; - } - - const mcpServerPath = path.join( - path.dirname(new URL(import.meta.url).pathname), - 'mcp.js' - ); - - let sessionId = input.sessionId; - fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); - try { fs.unlinkSync(IPC_CLOSE_SENTINEL); } catch {} - - // Drain pending IPC - let prompt = input.prompt; - const pending = drainIpcInput(); - if (pending.length > 0) { - prompt += '\n' + pending.join('\n'); - } - - // Query loop: run → wait for IPC → run again (persistent session) - let resumeAt: string | undefined; - try { - while (true) { - log(`Query start (session: ${sessionId || 'new'})`); - const result = await runQuery(prompt, sessionId, input, sdkEnv, mcpServerPath, resumeAt); - - if (result.newSessionId) sessionId = result.newSessionId; - if (result.lastAssistantUuid) resumeAt = result.lastAssistantUuid; - if (result.closedDuringQuery) { log('Closed during query'); break; } - - // Session update marker - writeOutput({ status: 'success', result: null, newSessionId: sessionId }); - - log('Waiting for next message...'); - const next = await waitForIpcMessage(); - if (next === null) { log('Close signal received'); break; } - - log(`New message (${next.length} chars)`); - prompt = next; - } - } catch (err) { - writeOutput({ status: 'error', result: null, newSessionId: sessionId, error: String(err) }); - process.exit(1); - } -} - -main(); diff --git a/agent/src/mcp.ts b/agent/src/mcp.ts deleted file mode 100644 index c520346..0000000 --- a/agent/src/mcp.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * MCP Server for Team Board Agent - * Provides tools for agent to interact with Tracker: - * - send_message: send chat message via IPC - * - update_task: update task status - * - create_comment: comment on a task - * - * Runs as a child process of the agent, communicates via stdio. - * Uses file-based IPC to communicate with the host runner. - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; -import fs from 'fs'; -import path from 'path'; - -const IPC_DIR = '/workspace/ipc'; -const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); -const TASKS_DIR = path.join(IPC_DIR, 'tasks'); - -const agentSlug = process.env.AGENT_SLUG || 'unknown'; -const agentName = process.env.AGENT_NAME || 'Agent'; - -function writeIpcFile(dir: string, data: object): string { - fs.mkdirSync(dir, { recursive: true }); - const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; - const filepath = path.join(dir, filename); - const tmpPath = `${filepath}.tmp`; - fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); - fs.renameSync(tmpPath, filepath); - return filename; -} - -const server = new McpServer({ - name: 'tracker', - version: '1.0.0', -}); - -// --- Chat --- - -server.tool( - 'send_message', - 'Send a message to a chat room (lobby, project, or task chat). Use for progress updates or responses.', - { - chat_id: z.string().describe('Chat ID to send the message to'), - text: z.string().describe('Message text'), - }, - async (args) => { - writeIpcFile(MESSAGES_DIR, { - type: 'chat_message', - chat_id: args.chat_id, - content: args.text, - sender_type: 'agent', - sender_name: agentName, - sender_slug: agentSlug, - timestamp: new Date().toISOString(), - }); - return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; - }, -); - -// --- Tasks --- - -server.tool( - 'update_task_status', - 'Move a task to a different status (backlog, todo, in_progress, in_review, done)', - { - task_id: z.string().describe('Task ID'), - status: z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done']).describe('New status'), - comment: z.string().optional().describe('Optional comment about the status change'), - }, - async (args) => { - writeIpcFile(TASKS_DIR, { - type: 'task_status', - task_id: args.task_id, - status: args.status, - comment: args.comment, - agent_slug: agentSlug, - timestamp: new Date().toISOString(), - }); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} → ${args.status}` }] }; - }, -); - -server.tool( - 'add_task_comment', - 'Add a comment to a task (for progress updates, questions, or results)', - { - task_id: z.string().describe('Task ID'), - comment: z.string().describe('Comment text'), - }, - async (args) => { - writeIpcFile(TASKS_DIR, { - type: 'task_comment', - task_id: args.task_id, - content: args.comment, - sender_type: 'agent', - sender_name: agentName, - agent_slug: agentSlug, - timestamp: new Date().toISOString(), - }); - return { content: [{ type: 'text' as const, text: 'Comment added.' }] }; - }, -); - -server.tool( - 'take_task', - 'Take a task and start working on it. Changes status to in_progress.', - { - task_id: z.string().describe('Task ID to take'), - }, - async (args) => { - writeIpcFile(TASKS_DIR, { - type: 'task_take', - task_id: args.task_id, - agent_slug: agentSlug, - timestamp: new Date().toISOString(), - }); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} taken.` }] }; - }, -); - -server.tool( - 'complete_task', - 'Mark a task as done.', - { - task_id: z.string().describe('Task ID'), - summary: z.string().optional().describe('Completion summary'), - }, - async (args) => { - writeIpcFile(TASKS_DIR, { - type: 'task_complete', - task_id: args.task_id, - summary: args.summary, - agent_slug: agentSlug, - timestamp: new Date().toISOString(), - }); - return { content: [{ type: 'text' as const, text: `Task ${args.task_id} completed.` }] }; - }, -); - -// --- Start --- - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ddb4705 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "team-board-runner", + "version": "0.1.0", + "type": "module", + "description": "AI Agent Runner for Team Board — connects agents to Tracker via WebSocket", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "test": "tsx src/index.ts --test" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "@types/ws": "^8.5.14", + "tsx": "^4.19.0", + "typescript": "^5.7.3" + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6ecf620..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -httpx>=0.27 diff --git a/runner.py b/runner.py deleted file mode 100644 index 7a451fd..0000000 --- a/runner.py +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env python3 -""" -Team Board Agent Runner (host-side) - -Manages agent containers: -- Starts Docker container with Claude Agent SDK -- Sends prompts via stdin -- Reads results from stdout (marker-based protocol) -- Handles IPC files for chat/task operations -- Forwards IPC to Tracker API - -Future: WebSocket connection to Tracker for real-time events. -Currently: CLI-based for testing. -""" - -import asyncio -import json -import logging -import os -import signal -import subprocess -import sys -import time -from pathlib import Path - -import httpx - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", -) -logger = logging.getLogger("runner") - -# --- Config --- - -TRACKER_URL = os.getenv("TRACKER_URL", "http://localhost:8100") -TRACKER_TOKEN = os.getenv("TRACKER_TOKEN", "tb-tracker-dev-token") -DATA_DIR = Path(os.getenv("RUNNER_DATA_DIR", "/opt/team-board-runner/data")) -DOCKER_IMAGE = os.getenv("RUNNER_IMAGE", "team-board-agent:latest") -IDLE_TIMEOUT = int(os.getenv("RUNNER_IDLE_TIMEOUT", "300")) # 5 min - -OUTPUT_START = "---AGENT_OUTPUT_START---" -OUTPUT_END = "---AGENT_OUTPUT_END---" - - -class AgentContainer: - """Manages a single agent container.""" - - def __init__(self, slug: str, name: str, secrets: dict): - self.slug = slug - self.name = name - self.secrets = secrets - self.session_id: str | None = None - self.process: subprocess.Popen | None = None - self.workspace = DATA_DIR / "agents" / slug - self.ipc_dir = self.workspace / "ipc" - - def ensure_dirs(self): - """Create workspace directories.""" - for d in ["ipc/input", "ipc/messages", "ipc/tasks", "conversations"]: - (self.workspace / d).mkdir(parents=True, exist_ok=True) - - def start(self, prompt: str) -> None: - """Start container with initial prompt.""" - self.ensure_dirs() - - container_name = f"tb-agent-{self.slug}-{int(time.time())}" - - cmd = [ - "docker", "run", "-i", "--rm", - "--name", container_name, - "-v", f"{self.workspace}:/workspace", - DOCKER_IMAGE, - ] - - logger.info("Starting container %s for agent '%s'", container_name, self.name) - - self.process = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - # Send initial input - input_data = json.dumps({ - "prompt": prompt, - "sessionId": self.session_id, - "agentSlug": self.slug, - "agentName": self.name, - "secrets": self.secrets, - }) - - self.process.stdin.write(input_data) - self.process.stdin.close() - - def send_message(self, text: str) -> None: - """Send follow-up message via IPC file.""" - ipc_input = self.ipc_dir / "input" - ipc_input.mkdir(parents=True, exist_ok=True) - - filename = f"{int(time.time() * 1000)}-{os.urandom(4).hex()}.json" - tmp_path = ipc_input / f"{filename}.tmp" - final_path = ipc_input / filename - - tmp_path.write_text(json.dumps({"type": "message", "text": text})) - tmp_path.rename(final_path) - logger.info("IPC message sent to %s (%d chars)", self.slug, len(text)) - - def close(self) -> None: - """Send close signal.""" - sentinel = self.ipc_dir / "input" / "_close" - sentinel.touch() - logger.info("Close signal sent to %s", self.slug) - - def read_outputs(self) -> list[dict]: - """Read all available outputs from stdout (non-blocking-ish).""" - if not self.process or not self.process.stdout: - return [] - - outputs = [] - buffer = "" - - while True: - line = self.process.stdout.readline() - if not line: - break - - buffer += line - - if OUTPUT_START in buffer: - start_idx = buffer.index(OUTPUT_START) - if OUTPUT_END in buffer[start_idx:]: - end_idx = buffer.index(OUTPUT_END, start_idx) - json_str = buffer[start_idx + len(OUTPUT_START):end_idx].strip() - try: - output = json.loads(json_str) - outputs.append(output) - if output.get("newSessionId"): - self.session_id = output["newSessionId"] - except json.JSONDecodeError as e: - logger.error("Failed to parse output: %s", e) - buffer = buffer[end_idx + len(OUTPUT_END):] - - return outputs - - def is_running(self) -> bool: - if not self.process: - return False - return self.process.poll() is None - - -class IpcProcessor: - """Processes IPC files from agent containers and forwards to Tracker.""" - - def __init__(self, tracker_url: str, tracker_token: str): - self.tracker_url = tracker_url - self.tracker_token = tracker_token - - async def process_agent_ipc(self, agent: AgentContainer): - """Process all pending IPC files for an agent.""" - await self._process_messages(agent) - await self._process_tasks(agent) - - async def _process_messages(self, agent: AgentContainer): - msg_dir = agent.ipc_dir / "messages" - if not msg_dir.exists(): - return - - for f in sorted(msg_dir.glob("*.json")): - try: - data = json.loads(f.read_text()) - f.unlink() - - if data.get("type") == "chat_message": - await self._send_chat_message(data) - except Exception as e: - logger.error("IPC message error: %s", e) - f.unlink(missing_ok=True) - - async def _process_tasks(self, agent: AgentContainer): - task_dir = agent.ipc_dir / "tasks" - if not task_dir.exists(): - return - - for f in sorted(task_dir.glob("*.json")): - try: - data = json.loads(f.read_text()) - f.unlink() - - ipc_type = data.get("type") - if ipc_type == "task_status": - await self._update_task_status(data) - elif ipc_type == "task_comment": - await self._add_task_comment(data) - elif ipc_type == "task_take": - await self._take_task(data) - elif ipc_type == "task_complete": - await self._complete_task(data) - except Exception as e: - logger.error("IPC task error: %s", e) - f.unlink(missing_ok=True) - - async def _send_chat_message(self, data: dict): - """Forward chat message to Tracker REST API.""" - async with httpx.AsyncClient() as client: - resp = await client.post( - f"{self.tracker_url}/api/v1/chats/{data['chat_id']}/messages", - headers={"Authorization": f"Bearer {self.tracker_token}"}, - json={ - "sender_type": data.get("sender_type", "agent"), - "sender_name": data.get("sender_name", "Agent"), - "content": data["content"], - }, - ) - if resp.status_code < 300: - logger.info("Chat message forwarded to %s", data["chat_id"]) - else: - logger.error("Chat forward failed: %s %s", resp.status_code, resp.text) - - async def _update_task_status(self, data: dict): - async with httpx.AsyncClient() as client: - await client.patch( - f"{self.tracker_url}/api/v1/tasks/{data['task_id']}", - headers={"Authorization": f"Bearer {self.tracker_token}"}, - json={"status": data["status"]}, - ) - logger.info("Task %s → %s", data["task_id"], data["status"]) - - async def _add_task_comment(self, data: dict): - logger.info("Task comment: %s (TODO: implement endpoint)", data["task_id"]) - - async def _take_task(self, data: dict): - async with httpx.AsyncClient() as client: - await client.patch( - f"{self.tracker_url}/api/v1/tasks/{data['task_id']}", - headers={"Authorization": f"Bearer {self.tracker_token}"}, - json={"status": "in_progress", "assigned_to": data["agent_slug"]}, - ) - logger.info("Task %s taken by %s", data["task_id"], data["agent_slug"]) - - async def _complete_task(self, data: dict): - async with httpx.AsyncClient() as client: - await client.patch( - f"{self.tracker_url}/api/v1/tasks/{data['task_id']}", - headers={"Authorization": f"Bearer {self.tracker_token}"}, - json={"status": "done"}, - ) - logger.info("Task %s completed", data["task_id"]) - - -# --- CLI Interface (for testing) --- - -async def interactive_session(agent_slug: str, agent_name: str): - """Run an interactive chat session with an agent.""" - secrets = {} - oauth_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "") - api_key = os.getenv("ANTHROPIC_API_KEY", "") - if oauth_token: - secrets["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token - if api_key: - secrets["ANTHROPIC_API_KEY"] = api_key - - if not secrets: - logger.error("Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY") - sys.exit(1) - - agent = AgentContainer(agent_slug, agent_name, secrets) - ipc = IpcProcessor(TRACKER_URL, TRACKER_TOKEN) - - print(f"\n🤖 Agent '{agent_name}' ({agent_slug})") - print("Type your messages. Ctrl+C to exit.\n") - - prompt = input("You: ").strip() - if not prompt: - return - - agent.start(prompt) - - # Read loop - try: - while agent.is_running(): - outputs = agent.read_outputs() - for out in outputs: - if out.get("result"): - print(f"\n🤖 {agent_name}: {out['result']}\n") - - # Process IPC - await ipc.process_agent_ipc(agent) - - if not agent.is_running(): - break - - await asyncio.sleep(0.5) - except KeyboardInterrupt: - agent.close() - print("\nSession ended.") - - -def main(): - import argparse - parser = argparse.ArgumentParser(description="Team Board Agent Runner") - parser.add_argument("--slug", default="coder", help="Agent slug") - parser.add_argument("--name", default="Кодер", help="Agent name") - args = parser.parse_args() - - asyncio.run(interactive_session(args.slug, args.name)) - - -if __name__ == "__main__": - main() diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..28f4018 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,416 @@ +/** + * Team Board Agent Runner + * + * Single process that: + * 1. Connects to Tracker via WebSocket + * 2. Runs Claude Agent SDK for AI processing + * 3. Has direct access to filesystem (code, git, etc.) + * 4. Persistent sessions via Agent SDK + * + * No Docker, no IPC files — everything in one process. + */ + +import { query, HookCallback, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; +import WebSocket from 'ws'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// --- Config --- + +const TRACKER_URL = process.env.TRACKER_URL || 'ws://localhost:8100/ws'; +const AGENT_TOKEN = process.env.AGENT_TOKEN || ''; +const AGENT_SLUG = process.env.AGENT_SLUG || 'coder'; +const AGENT_NAME = process.env.AGENT_NAME || 'Кодер'; +const WORKSPACE = process.env.AGENT_WORKSPACE || `/opt/team-board-agents/${AGENT_SLUG}`; +const HEARTBEAT_INTERVAL = 30_000; +const RECONNECT_DELAY = 5_000; + +const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; + +// --- State --- + +let ws: WebSocket | null = null; +let sessionId: string | undefined; +let running = true; +let busy = false; + +// --- Logging --- + +function log(level: string, msg: string, data?: Record): void { + const ts = new Date().toISOString(); + const extra = data ? ' ' + JSON.stringify(data) : ''; + console.error(`${ts} ${level} [${AGENT_SLUG}] ${msg}${extra}`); +} + +// --- WebSocket to Tracker --- + +function connectTracker(): void { + const url = `${TRACKER_URL}?client_type=agent&client_id=${AGENT_SLUG}`; + log('INFO', `Connecting to Tracker: ${url}`); + + ws = new WebSocket(url); + + ws.on('open', () => { + log('INFO', 'Connected to Tracker ✅'); + sendWs({ event: 'auth', token: AGENT_TOKEN }); + startHeartbeat(); + }); + + ws.on('message', (raw) => { + try { + const msg = JSON.parse(raw.toString()); + handleTrackerEvent(msg); + } catch (e) { + log('ERROR', `Bad message: ${e}`); + } + }); + + ws.on('close', (code, reason) => { + log('WARN', `Disconnected: ${code} ${reason}`); + stopHeartbeat(); + if (running) { + setTimeout(connectTracker, RECONNECT_DELAY); + } + }); + + ws.on('error', (err) => { + log('ERROR', `WS error: ${err.message}`); + }); +} + +function sendWs(data: Record): void { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } +} + +// --- Heartbeat --- + +let heartbeatTimer: ReturnType | null = null; + +function startHeartbeat(): void { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + sendWs({ + event: 'agent.heartbeat', + status: busy ? 'busy' : 'idle', + }); + }, HEARTBEAT_INTERVAL); +} + +function stopHeartbeat(): void { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +} + +// --- Chat subscriptions --- + +const subscribedChats = new Set(); + +function subscribeChat(chatId: string): void { + if (!subscribedChats.has(chatId)) { + sendWs({ event: 'chat.subscribe', chat_id: chatId }); + subscribedChats.add(chatId); + log('INFO', `Subscribed to chat ${chatId}`); + } +} + +// --- Event handling --- + +function handleTrackerEvent(msg: Record): void { + const event = msg.event as string; + + switch (event) { + case 'auth.ok': + log('INFO', 'Authenticated'); + // Subscribe to lobby chat if configured + const lobbyId = process.env.LOBBY_CHAT_ID; + if (lobbyId) subscribeChat(lobbyId); + break; + + case 'chat.subscribed': + log('INFO', `Chat subscribed: ${msg.chat_id}`); + break; + + case 'chat.message': + handleChatMessage(msg); + break; + + case 'task.assigned': + case 'task.offer': + handleTaskAssigned(msg); + break; + + case 'agent.heartbeat.ack': + break; + + case 'error': + log('ERROR', `Tracker error: ${msg.message}`); + break; + + default: + log('DEBUG', `Unhandled: ${event}`); + } +} + +async function handleChatMessage(msg: Record): Promise { + const content = msg.content as string || ''; + const senderName = msg.sender_name as string || ''; + const chatId = msg.chat_id as string; + const senderType = msg.sender_type as string || ''; + + // Don't respond to own messages + if (senderType === 'agent' && msg.sender_slug === AGENT_SLUG) return; + + // Only respond if mentioned + if (!content.includes(`@${AGENT_SLUG}`) && !content.toLowerCase().includes(`@${AGENT_NAME.toLowerCase()}`)) { + return; + } + + log('INFO', `Mentioned by ${senderName}: ${content.slice(0, 100)}`); + + const prompt = `Ты — агент "${AGENT_NAME}" в Team Board. +Тебе написали в чате: + +[${senderName}]: ${content} + +Ответь кратко и по делу. У тебя есть доступ к файлам и инструментам.`; + + const result = await runAgent(prompt); + + if (result) { + sendWs({ + event: 'chat.send', + chat_id: chatId, + content: result, + sender_type: 'agent', + sender_name: AGENT_NAME, + }); + } +} + +async function handleTaskAssigned(msg: Record): Promise { + const taskId = msg.task_id as string; + const title = msg.title as string || ''; + const description = msg.description as string || ''; + + log('INFO', `Task assigned: ${taskId} — ${title}`); + + // Take the task + sendWs({ event: 'task.take', task_id: taskId }); + + const prompt = `Ты — агент "${AGENT_NAME}" в Team Board. + +Задача: ${title} +${description ? `\nОписание:\n${description}` : ''} + +Рабочая директория: ${WORKSPACE} +Выполни задачу. Если нужно написать код — пиши. Если нужно изменить файлы — изменяй. +По завершении кратко опиши что сделано.`; + + const result = await runAgent(prompt); + + // Post result as comment + if (result) { + sendWs({ + event: 'task.comment', + task_id: taskId, + content: result, + sender_type: 'agent', + sender_name: AGENT_NAME, + }); + } + + // Complete task + sendWs({ event: 'task.complete', task_id: taskId }); +} + +// --- Claude Agent SDK --- + +function createSanitizeBashHook(): HookCallback { + return async (input) => { + const preInput = input as PreToolUseHookInput; + const command = (preInput.tool_input as { command?: string })?.command; + if (!command) return {}; + const prefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + updatedInput: { + ...(preInput.tool_input as Record), + command: prefix + command, + }, + }, + }; + }; +} + +async function runAgent(prompt: string): Promise { + if (busy) { + log('WARN', 'Already busy, skipping'); + return null; + } + + busy = true; + log('INFO', 'Agent query starting...'); + + // Ensure workspace exists + fs.mkdirSync(WORKSPACE, { recursive: true }); + + // Load CLAUDE.md if exists + const claudeMdPath = path.join(WORKSPACE, 'CLAUDE.md'); + let systemAppend: string | undefined; + if (fs.existsSync(claudeMdPath)) { + systemAppend = fs.readFileSync(claudeMdPath, 'utf-8'); + } + + try { + let result: string | null = null; + + for await (const message of query({ + prompt, + options: { + cwd: WORKSPACE, + resume: sessionId, + systemPrompt: systemAppend + ? { type: 'preset' as const, preset: 'claude_code' as const, append: systemAppend } + : undefined, + allowedTools: [ + 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', + 'WebSearch', 'WebFetch', + 'TodoWrite', 'NotebookEdit', + ], + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + hooks: { + PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], + }, + }, + })) { + if (message.type === 'system' && message.subtype === 'init') { + sessionId = message.session_id; + log('INFO', `Session: ${sessionId}`); + } + + if (message.type === 'result') { + const text = 'result' in message ? (message as { result?: string }).result : null; + if (text) { + const clean = text.replace(/[\s\S]*?<\/internal>/g, '').trim(); + if (clean) result = clean; + } + } + } + + log('INFO', `Agent done. Result: ${result ? result.length + ' chars' : 'none'}`); + return result; + } catch (err) { + log('ERROR', `Agent error: ${err}`); + return `Ошибка: ${err}`; + } finally { + busy = false; + } +} + +// --- CLI test mode --- + +async function testMode(): Promise { + log('INFO', '🧪 Test mode — no Tracker connection'); + log('INFO', `Workspace: ${WORKSPACE}`); + fs.mkdirSync(WORKSPACE, { recursive: true }); + + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const ask = (q: string): Promise => + new Promise(resolve => rl.question(q, resolve)); + + console.log(`\n🤖 Agent "${AGENT_NAME}" (${AGENT_SLUG})`); + console.log('Type messages. Ctrl+C to exit.\n'); + + while (true) { + const input = await ask('You: '); + if (!input.trim()) continue; + + console.log('\n⏳ Thinking...\n'); + const result = await runAgent(input); + if (result) { + console.log(`\n🤖 ${AGENT_NAME}:\n${result}\n`); + } else { + console.log('\n(no response)\n'); + } + } +} + +// --- Init & main --- + +function init(): void { + // Create workspace + fs.mkdirSync(WORKSPACE, { recursive: true }); + + // Create CLAUDE.md if not exists + const claudeMd = path.join(WORKSPACE, 'CLAUDE.md'); + if (!fs.existsSync(claudeMd)) { + fs.writeFileSync(claudeMd, `# ${AGENT_NAME} + +Agent slug: ${AGENT_SLUG} +Workspace: ${WORKSPACE} + +## Роль +Ты — AI-агент "${AGENT_NAME}" в системе Team Board. +Отвечай кратко и по делу. Пиши на русском. + +## Инструменты +- Bash: выполнение команд +- Read/Write/Edit: работа с файлами +- WebSearch/WebFetch: поиск в интернете +`); + log('INFO', `Created ${claudeMd}`); + } + + log('INFO', `Agent "${AGENT_NAME}" (${AGENT_SLUG}) initialized`); + log('INFO', `Workspace: ${WORKSPACE}`); +} + +async function main(): Promise { + // Check auth + if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) { + console.error('❌ Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY'); + process.exit(1); + } + + init(); + + // Graceful shutdown + const shutdown = () => { + running = false; + stopHeartbeat(); + ws?.close(); + log('INFO', 'Shutting down'); + process.exit(0); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + // Test mode or Tracker mode + const isTest = process.argv.includes('--test'); + if (isTest) { + await testMode(); + } else { + if (!AGENT_TOKEN) { + console.error('❌ Set AGENT_TOKEN for Tracker auth'); + process.exit(1); + } + connectTracker(); + } +} + +main().catch(err => { + log('ERROR', `Fatal: ${err}`); + process.exit(1); +}); diff --git a/agent/tsconfig.json b/tsconfig.json similarity index 100% rename from agent/tsconfig.json rename to tsconfig.json