Rewrite: single process, no Docker, no IPC files

- One Node.js process with Claude Agent SDK + WebSocket to Tracker
- Direct filesystem access (code, git, bash)
- Test mode (--test): interactive CLI without Tracker
- Tracker mode: WS connection, chat mentions, task handling
- Persistent sessions via Agent SDK
- Removed: Docker, IPC files, host/container split, Python runner
This commit is contained in:
Markov 2026-02-17 07:00:16 +01:00
parent 77c745a088
commit d978544752
10 changed files with 499 additions and 906 deletions

View File

@ -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"]

View File

@ -1,56 +1,79 @@
# Team Board Runner # Team Board Runner
Универсальный runner для AI-агентов Team Board. Запускает агентов в Docker-контейнерах с изоляцией. AI-агент для Team Board. Один процесс, прямой доступ к файлам, WebSocket к Tracker.
## Архитектура ## Архитектура
``` ```
Host (runner.py) Docker Container (agent) ┌────────────────────────────────────────┐
┌──────────────┐ stdin/stdout ┌──────────────────────┐ │ Agent Runner (один процесс) │
│ Runner │◄──────────────────►│ Claude Agent SDK │ │ │
│ │ │ + MCP Tools │ │ ┌──────────────┐ ┌────────────────┐ │
│ - Container │ IPC files │ + Persistent │ │ │ Claude Agent │ │ WebSocket │ │
│ manager │◄──────────────────►│ Sessions │ │ │ SDK │ │ Client │ │
│ - IPC → │ │ + SQLite (internal) │ │ │ (query loop, │ │ (Tracker :8100)│ │
│ Tracker │ │ │ │ │ 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 ```bash
# Собрать Docker образ # Установка
docker build -t team-board-agent . npm install
# Запустить интерактивную сессию # Тест (без Tracker, интерактивный режим)
CLAUDE_CODE_OAUTH_TOKEN=xxx python3 runner.py --slug coder --name "Кодер" 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` — операции с задачами от агента ### Test mode (`--test`)
- `/workspace/ipc/input/_close` — сигнал завершения Интерактивный режим без 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 (будущее) ## Multi-model (будущее)
Архитектура позволяет подключить другие модели: Текущая архитектура позволяет заменить Claude Agent SDK на другие:
- Gemini (через MCP или прямой API) - Gemini SDK
- ChatGPT (через API) - OpenAI SDK
- Локальные модели (ollama) - Локальные модели (ollama)
Для этого нужно заменить Claude Agent SDK на соответствующий SDK внутри контейнера.

View File

@ -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"
}
}

View File

@ -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<string, string>;
}
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<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>(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<string> {
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<string | null> {
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<string, unknown>),
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<string, string | undefined>,
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(/<internal>[\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<void> {
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<string, string | undefined> = { ...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();

View File

@ -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);

23
package.json Normal file
View File

@ -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"
}
}

View File

@ -1 +0,0 @@
httpx>=0.27

312
runner.py
View File

@ -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()

416
src/index.ts Normal file
View File

@ -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<string, unknown>): 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<string, unknown>): void {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
// --- Heartbeat ---
let heartbeatTimer: ReturnType<typeof setInterval> | 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<string>();
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<string, unknown>): 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<string, unknown>): Promise<void> {
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<string, unknown>): Promise<void> {
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<string, unknown>),
command: prefix + command,
},
},
};
};
}
async function runAgent(prompt: string): Promise<string | null> {
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(/<internal>[\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<void> {
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<string> =>
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<void> {
// 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);
});