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:
parent
77c745a088
commit
d978544752
26
Dockerfile
26
Dockerfile
@ -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"]
|
||||
97
README.md
97
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 внутри контейнера.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
146
agent/src/mcp.ts
146
agent/src/mcp.ts
@ -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
23
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
httpx>=0.27
|
||||
312
runner.py
312
runner.py
@ -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
416
src/index.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user