This commit is contained in:
Eugene 2026-02-23 15:48:14 +03:00
parent d04d9e0d3a
commit 368b9abf69
6 changed files with 346 additions and 129 deletions

3
.env
View File

@ -1,2 +1,5 @@
ANTHROPIC_API_KEY=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
LOG_LEVEL=debug
; tb--HfzlIbf8h0Bhz9Dkzeb2at-y6Ag0mZh8_P0GeWqBBo

View File

@ -7,42 +7,45 @@
"slug": "coder",
"_slug_comment": "Уникальный идентификатор агента (латиница, без пробелов). Env: AGENT_SLUG",
"prompt": "Ты опытный Go-разработчик. Пишешь чистый, идиоматичный Go-код.",
"prompt": "Ты опытный разработчик. Пишешь чистый, идиоматичный код. Анализируешь задачу, пишешь код, отчитываешься.",
"_prompt_comment": "Системный промпт — описание роли и компетенций агента. Env: AGENT_PROMPT",
"tracker_url": "http://localhost:8100",
"_tracker_url_comment": "URL Team Board Tracker для регистрации и получения задач. Env: TRACKER_URL",
"_tracker_url_comment": "REST API URL. Локально: http://localhost:8100. Через nginx: https://dev.team.uix.su/agent-api. Env: TRACKER_URL",
"token": "tb-agent-abc123",
"_token_comment": "Токен авторизации для Tracker API (Bearer token). Env: AGENT_TOKEN",
"ws_url": "",
"_ws_url_comment": "WebSocket URL (только для transport=ws). Локально: ws://localhost:8100/ws. Через nginx: wss://dev.team.uix.su/agent-ws. Пустой = авто из tracker_url. Env: AGENT_WS_URL",
"transport": "http",
"_transport_comment": "Транспорт для связи с трекером: 'http' (agent слушает HTTP, трекер шлёт POST) или 'ws' (agent подключается к трекеру по WebSocket). Env: AGENT_TRANSPORT. Default: http",
"token": "tb-agent-xxxxxxxx",
"_token_comment": "Токен агента (генерируется в Tracker UI или через POST /api/v1/agents/register). Env: AGENT_TOKEN",
"transport": "ws",
"_transport_comment": "Транспорт: 'ws' (WebSocket, рекомендуется) или 'http' (callback). Env: AGENT_TRANSPORT",
"listen_port": 3200,
"_listen_port_comment": "Порт HTTP-сервера для приёма событий от трекера (только для transport=http). Env: AGENT_PORT. Default: 3200",
"_listen_port_comment": "Порт для transport=http (не нужен при ws). Env: AGENT_PORT",
"work_dir": "/projects/my-app",
"_work_dir_comment": "Рабочая директория агента (где он выполняет задачи). Env: PICOGENT_WORK_DIR. Default: agentHome или cwd",
"work_dir": ".",
"_work_dir_comment": "Рабочая директория (где агент выполняет код). Env: PICOGENT_WORK_DIR",
"model": "sonnet",
"_model_comment": "Модель LLM. Алиасы: sonnet, opus, haiku, sonnet-4, opus-4. Полные ID тоже работают. Env: PICOGENT_MODEL. Default: sonnet",
"_model_comment": "Модель: sonnet, opus, haiku, sonnet-4, opus-4, или полный ID. Env: PICOGENT_MODEL",
"provider": "anthropic",
"_provider_comment": "Провайдер LLM. Default: anthropic. Env: PICOGENT_PROVIDER",
"_provider_comment": "Провайдер LLM. Env: PICOGENT_PROVIDER",
"api_key": "",
"_api_key_comment": "API ключ провайдера. Лучше через .env файл. Env: PICOGENT_API_KEY или ANTHROPIC_API_KEY",
"_api_key_comment": "API ключ. Лучше через .env: ANTHROPIC_API_KEY=sk-ant-... Env: PICOGENT_API_KEY",
"capabilities": ["coding", "review"],
"_capabilities_comment": "Список возможностей агента — передаётся трекеру при регистрации. Default: [\"coding\"]",
"_capabilities_comment": "Возможности агента (передаются трекеру). Примеры: coding, review, testing, docs",
"max_concurrent_tasks": 2,
"_max_concurrent_tasks_comment": "Сколько задач агент может выполнять параллельно. Default: 2",
"_max_concurrent_tasks_comment": "Сколько задач агент может выполнять параллельно",
"heartbeat_interval_sec": 30,
"_heartbeat_interval_sec_comment": "Интервал heartbeat к трекеру (секунды). Default: 30",
"_heartbeat_interval_sec_comment": "Интервал heartbeat (сек). Timeout трекера: 90с",
"allowed_paths": [],
"_allowed_paths_comment": "Ограничение доступа к файлам. Пустой массив = без ограничений. Пример: [\"/projects/my-app/src\", \"/projects/my-app/tests\"]"
"_allowed_paths_comment": "Ограничение файлового доступа. [] = без ограничений. Пример: [\"/projects/my-app\"]"
}

View File

@ -16,7 +16,10 @@ export interface AgentConfig {
name: string;
slug: string;
prompt: string;
/** REST API base URL (e.g. https://dev.team.uix.su/agent-api) */
trackerUrl: string;
/** WebSocket URL (e.g. wss://dev.team.uix.su/agent-ws). Falls back to trackerUrl with http→ws conversion. */
wsUrl: string;
token: string;
transport: 'http' | 'ws';
listenPort: number;
@ -136,11 +139,14 @@ export function loadAgentConfig(): AgentConfig {
|| resolvedHome
|| process.cwd();
const wsUrl = process.env.AGENT_WS_URL || (file.ws_url as string) || '';
return {
name: (file.name as string) || process.env.AGENT_NAME || 'Agent',
slug: (file.slug as string) || process.env.AGENT_SLUG || 'agent',
prompt: (file.prompt as string) || process.env.AGENT_PROMPT || '',
trackerUrl,
wsUrl,
token,
transport: (process.env.AGENT_TRANSPORT || (file.transport as string) || 'http') as 'http' | 'ws',
listenPort: parseInt(process.env.AGENT_PORT || String(file.listen_port || '3200'), 10),

View File

@ -21,14 +21,24 @@ export class EventRouter {
) {}
async handleEvent(event: TrackerEvent): Promise<void> {
this.log.info({ event: event.event, id: event.id }, 'Handling event');
this.log.info('┌── ROUTER: handling %s (id: %s)', event.event, event.id);
switch (event.event) {
case 'task.assigned':
await this.handleTaskAssigned(event.data);
break;
case 'message.new':
await this.handleMessageNew(event.data);
break;
case 'chat.message':
await this.handleChatMessage(event.data);
await this.handleMessageNew(event.data);
break;
case 'task.created':
case 'task.updated':
case 'agent.status':
case 'agent.online':
case 'agent.offline':
this.log.info({ event: event.event, data: event.data }, 'Informational event');
break;
default:
this.log.warn({ event: event.event }, 'Unknown event type, ignoring');
@ -36,7 +46,8 @@ export class EventRouter {
}
private async handleTaskAssigned(data: Record<string, unknown>): Promise<void> {
const task = data.task as TrackerTask;
// Protocol: data = { task: TaskOut } or data IS the task
const task = (data.task as TrackerTask) || (data as unknown as TrackerTask);
if (!task?.id) {
this.log.error({ data }, 'task.assigned event missing task data');
return;
@ -49,10 +60,13 @@ export class EventRouter {
this.activeTasks++;
this.taskTracker.addTask(task.id);
this.log.info({ taskId: task.id, key: task.key, title: task.title }, 'Processing task');
this.log.info('│ TASK ASSIGNED: %s — %s', task.key, task.title);
this.log.info('│ Priority: %s | Status: %s', task.priority || '-', task.status || '-');
if (task.description) this.log.info('│ Description: %s', task.description.slice(0, 200));
try {
// Update status → in_progress
this.log.info('│ → Updating task status to in_progress...');
await this.client.updateTask(task.id, { status: 'in_progress' }).catch((err) => {
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to in_progress');
});
@ -78,40 +92,58 @@ export class EventRouter {
}
}
// Post result as comment
// Post result as comment to task
if (collectedText.trim()) {
await this.client.addComment(task.id, collectedText.trim()).catch((err) => {
this.log.info('│ → Sending result comment (%d chars)...', collectedText.trim().length);
this.log.info('│ Result preview: %s', collectedText.trim().slice(0, 300));
await this.client.sendMessage({ task_id: task.id, content: collectedText.trim() }).catch((err) => {
this.log.error({ err, taskId: task.id }, 'Failed to add comment');
});
}
// Update status → review
await this.client.updateTask(task.id, { status: 'review' }).catch((err) => {
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to review');
// Update status → in_review
this.log.info('│ → Updating task status to in_review...');
await this.client.updateTask(task.id, { status: 'in_review' }).catch((err) => {
this.log.warn({ err, taskId: task.id }, 'Failed to update task status to in_review');
});
this.log.info({ taskId: task.id, resultLength: collectedText.length }, 'Task completed');
this.log.info('└── TASK DONE: %s (%d chars output)', task.key, collectedText.length);
} catch (err) {
this.log.error({ err, taskId: task.id }, 'Task processing failed');
await this.client.addComment(task.id, `Agent error: ${err instanceof Error ? err.message : String(err)}`).catch(() => {});
await this.client.updateTask(task.id, { status: 'error' }).catch(() => {});
await this.client.sendMessage({
task_id: task.id,
content: `Agent error: ${err instanceof Error ? err.message : String(err)}`,
}).catch(() => {});
} finally {
this.activeTasks--;
this.taskTracker.removeTask(task.id);
}
}
private async handleChatMessage(data: Record<string, unknown>): Promise<void> {
const content = (data.content as string) || (data.message as string) || '';
private async handleMessageNew(data: Record<string, unknown>): Promise<void> {
// Protocol: message.new → { id, chat_id, task_id, author_slug, content, mentions, ... }
const content = (data.content as string) || '';
const authorSlug = (data.author_slug as string) || (data.sender_slug as string) || '';
const taskId = data.task_id as string | undefined;
const chatId = data.chat_id as string | undefined;
const mentions = (data.mentions as string[]) || [];
if (!content) {
this.log.warn({ data }, 'chat.message event missing content');
// Don't respond to own messages
if (authorSlug === this.config.slug) {
this.log.debug('Ignoring own message');
return;
}
this.log.info({ taskId, contentLength: content.length }, 'Processing chat message');
if (!content) {
this.log.warn({ data }, 'message.new event missing content');
return;
}
// Check if agent is mentioned (for filtered modes)
const isMentioned = mentions.includes(this.config.slug);
this.log.info('│ MESSAGE from @%s: "%s"', authorSlug, content.slice(0, 200));
this.log.info('│ Context: %s | Mentioned: %s', taskId ? `task=${taskId}` : chatId ? `chat=${chatId}` : 'none', isMentioned);
let collectedText = '';
for await (const msg of runAgent(content, {
@ -128,10 +160,21 @@ export class EventRouter {
}
}
if (taskId && collectedText.trim()) {
await this.client.addComment(taskId, collectedText.trim()).catch((err) => {
this.log.error({ err, taskId }, 'Failed to add chat reply comment');
// Reply to the same context (task comment or chat message)
if (collectedText.trim()) {
this.log.info('│ → Sending reply (%d chars): %s', collectedText.trim().length, collectedText.trim().slice(0, 200));
if (taskId) {
await this.client.sendMessage({ task_id: taskId, content: collectedText.trim() }).catch((err) => {
this.log.error({ err, taskId }, 'Failed to send task comment reply');
});
} else if (chatId) {
await this.client.sendMessage({ chat_id: chatId, content: collectedText.trim() }).catch((err) => {
this.log.error({ err, chatId }, 'Failed to send chat reply');
});
}
this.log.info('└── MESSAGE REPLIED');
} else {
this.log.info('└── MESSAGE PROCESSED (no reply)');
}
}
}

View File

@ -1,6 +1,14 @@
import { logger } from '../logger.js';
import type { RegistrationPayload, HeartbeatPayload } from './types.js';
/**
* HTTP client to Tracker REST API.
*
* REST API base: {baseUrl}/api/v1
* Auth: Bearer token in header.
*
* Used in both WS and HTTP transports for mutations.
*/
export class TrackerClient {
private log = logger.child({ component: 'tracker-client' });
@ -11,6 +19,8 @@ export class TrackerClient {
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
this.log.info(' REST %s %s', method, path);
if (body) this.log.info(' body: %s', JSON.stringify(body).slice(0, 300));
const res = await fetch(url, {
method,
headers: {
@ -21,8 +31,10 @@ export class TrackerClient {
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.log.error(' REST FAIL %s %s → %d: %s', method, path, res.status, text.slice(0, 200));
throw new Error(`Tracker API ${method} ${path} failed: ${res.status} ${text}`);
}
this.log.info(' REST OK %s %s → %d', method, path, res.status);
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
@ -30,6 +42,8 @@ export class TrackerClient {
return undefined as T;
}
// --- Agent lifecycle ---
async register(payload: RegistrationPayload): Promise<void> {
this.log.info({ slug: payload.slug }, 'Registering agent');
await this.request('POST', '/api/v1/agents/register', payload);
@ -39,25 +53,90 @@ export class TrackerClient {
await this.request('POST', '/api/v1/agents/heartbeat', payload);
}
async updateTask(taskId: string, fields: Record<string, unknown>): Promise<void> {
this.log.info({ taskId, fields }, 'Updating task');
await this.request('PATCH', `/api/v1/tasks/${taskId}`, fields);
}
// --- Tasks ---
async addComment(taskId: string, content: string): Promise<void> {
this.log.info({ taskId, contentLength: content.length }, 'Adding comment');
await this.request('POST', `/api/v1/tasks/${taskId}/comments`, { content });
}
async uploadFile(taskId: string, filename: string, content: string): Promise<void> {
await this.request('POST', `/api/v1/tasks/${taskId}/files`, { filename, content });
async listTasks(params: Record<string, string> = {}): Promise<Record<string, unknown>[]> {
const qs = new URLSearchParams(params).toString();
const path = qs ? `/api/v1/tasks?${qs}` : '/api/v1/tasks';
return this.request('GET', path);
}
async getTask(taskId: string): Promise<Record<string, unknown>> {
return this.request('GET', `/api/v1/tasks/${taskId}`);
}
async createTask(projectSlug: string, task: Record<string, unknown>): Promise<Record<string, unknown>> {
return this.request('POST', `/api/v1/tasks?project_slug=${encodeURIComponent(projectSlug)}`, task);
}
async updateTask(taskId: string, fields: Record<string, unknown>): Promise<void> {
this.log.info({ taskId, fields }, 'Updating task');
await this.request('PATCH', `/api/v1/tasks/${taskId}`, fields);
}
async deleteTask(taskId: string): Promise<void> {
await this.request('DELETE', `/api/v1/tasks/${taskId}`);
}
async takeTask(taskId: string, slug: string): Promise<void> {
this.log.info({ taskId, slug }, 'Taking task');
await this.request('POST', `/api/v1/tasks/${taskId}/take?slug=${encodeURIComponent(slug)}`);
}
async rejectTask(taskId: string, slug: string, reason?: string): Promise<void> {
this.log.info({ taskId, slug, reason }, 'Rejecting task');
await this.request('POST', `/api/v1/tasks/${taskId}/reject`, { slug, reason });
}
async watchTask(taskId: string, slug: string): Promise<void> {
await this.request('POST', `/api/v1/tasks/${taskId}/watch?slug=${encodeURIComponent(slug)}`);
}
// --- Steps (checklist inside task) ---
async addStep(taskId: string, title: string): Promise<Record<string, unknown>> {
return this.request('POST', `/api/v1/tasks/${taskId}/steps`, { title });
}
async completeStep(taskId: string, stepId: string): Promise<void> {
await this.request('PATCH', `/api/v1/tasks/${taskId}/steps/${stepId}`, { done: true });
}
// --- Messages (unified: chat + task comments) ---
async sendMessage(payload: { chat_id?: string; task_id?: string; content: string; mentions?: string[] }): Promise<Record<string, unknown>> {
this.log.info({ chatId: payload.chat_id, taskId: payload.task_id, contentLength: payload.content.length }, 'Sending message');
return this.request('POST', '/api/v1/messages', payload);
}
async listMessages(params: Record<string, string>): Promise<Record<string, unknown>[]> {
const qs = new URLSearchParams(params).toString();
return this.request('GET', `/api/v1/messages?${qs}`);
}
// --- Files ---
async uploadFile(taskId: string, filename: string, content: string): Promise<void> {
await this.request('POST', `/api/v1/tasks/${taskId}/files`, { filename, content });
}
async listTaskFiles(taskId: string): Promise<Record<string, unknown>[]> {
return this.request('GET', `/api/v1/tasks/${taskId}/files`);
}
// --- Projects ---
async listProjects(): Promise<Record<string, unknown>[]> {
return this.request('GET', '/api/v1/projects');
}
async getProject(slug: string): Promise<Record<string, unknown>> {
return this.request('GET', `/api/v1/projects/${slug}`);
}
// --- Members ---
async listMembers(): Promise<Record<string, unknown>[]> {
return this.request('GET', '/api/v1/members');
}
}

View File

@ -9,12 +9,15 @@ export type EventHandler = (event: TrackerEvent) => Promise<void>;
/**
* WebSocket client transport for connecting to the tracker.
*
* Instead of running an HTTP server and receiving events via POST,
* the agent connects to the tracker over WebSocket. The bidirectional
* channel handles auth, events, heartbeat, and ack no open port needed.
* The tracker WS handler supports two field conventions:
* - WEBSOCKET-PROTOCOL.md uses "event" field
* - TRACKER-PROTOCOL.md uses "type" field
*
* We send BOTH fields in every message for compatibility.
* We read whichever is present on incoming messages.
*/
export class WsClientTransport implements TaskTracker {
private log = logger.child({ component: 'ws-client-transport' });
private log = logger.child({ component: 'ws' });
private ws: WebSocket | null = null;
private handler: EventHandler | null = null;
private currentTasks = new Set<string>();
@ -27,6 +30,13 @@ export class WsClientTransport implements TaskTracker {
private rejectStart: ((err: Error) => void) | null = null;
private authenticated = false;
/** Lobby chat ID returned by auth.ok */
lobbyChatId: string | null = null;
/** Projects returned by auth.ok */
projects: Array<{ id: string; slug: string; name: string; chat_id?: string }> = [];
/** Online members from auth.ok */
online: string[] = [];
constructor(private config: AgentConfig) {}
onEvent(handler: EventHandler): void {
@ -68,47 +78,51 @@ export class WsClientTransport implements TaskTracker {
}
private buildWsUrl(): string {
const base = this.config.trackerUrl
if (this.config.wsUrl) return this.config.wsUrl;
const url = this.config.trackerUrl;
if (url.startsWith('ws://') || url.startsWith('wss://')) return url;
const base = url
.replace(/^http:\/\//, 'ws://')
.replace(/^https:\/\//, 'wss://');
// Use the existing /ws endpoint with client_type=agent
return `${base.replace(/\/$/, '')}/ws?client_type=agent&client_id=${encodeURIComponent(this.config.slug)}`;
return `${base.replace(/\/$/, '')}/ws`;
}
private connect(): void {
const url = this.buildWsUrl();
this.log.info({ url }, 'Connecting to tracker via WebSocket');
this.log.info('━━━ CONNECTING to %s ━━━', url);
const ws = new WebSocket(url);
this.ws = ws;
ws.on('open', () => {
this.log.info('WebSocket connected, sending auth');
this.sendJson({
type: 'auth',
this.log.info('━━━ WS CONNECTED ━━━');
// Send auth — use BOTH "event" and "type" for compatibility
this.send('auth', {
token: this.config.token,
agent: {
name: this.config.name,
slug: this.config.slug,
capabilities: this.config.capabilities,
max_concurrent_tasks: this.config.maxConcurrentTasks,
},
});
});
ws.on('message', (data) => {
const raw = data.toString();
this.log.info('← RAW: %s', raw.slice(0, 500));
let msg: Record<string, unknown>;
try {
msg = JSON.parse(data.toString());
msg = JSON.parse(raw);
} catch {
this.log.warn('Received non-JSON message, ignoring');
this.log.warn('Non-JSON message, ignoring');
return;
}
this.handleMessage(msg);
});
ws.on('close', (code, reason) => {
this.log.info({ code, reason: reason.toString() }, 'WebSocket closed');
this.log.info('━━━ WS CLOSED (code=%d reason=%s) ━━━', code, reason.toString());
this.authenticated = false;
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
@ -120,115 +134,184 @@ export class WsClientTransport implements TaskTracker {
});
ws.on('error', (err) => {
this.log.warn({ err: err.message }, 'WebSocket error');
// 'close' event will follow, triggering reconnect
this.log.error('━━━ WS ERROR: %s ━━━', err.message);
});
}
private handleMessage(msg: Record<string, unknown>): void {
const type = msg.type as string;
switch (type) {
case 'auth_ok':
this.log.info('Authenticated with tracker');
this.authenticated = true;
this.reconnectDelay = 1000; // Reset backoff on successful auth
this.startHeartbeat();
// Resolve the start() promise on first successful auth
if (this.resolveStart) {
this.resolveStart();
this.resolveStart = null;
this.rejectStart = null;
/**
* Send a JSON message with both "event" and "type" fields set,
* so the tracker picks up whichever field it uses.
*/
private send(eventType: string, payload: Record<string, unknown> = {}): void {
const msg = { event: eventType, type: eventType, ...payload };
const json = JSON.stringify(msg);
this.log.info('→ SEND: %s', json.slice(0, 500));
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(json);
} else {
this.log.warn('WS not open, cannot send');
}
}
private handleMessage(msg: Record<string, unknown>): void {
// Read either "event" or "type" — tracker may use either
const msgType = (msg.event as string) || (msg.type as string) || '';
if (!msgType) {
this.log.warn('Message without event/type field: %s', JSON.stringify(msg).slice(0, 200));
return;
}
switch (msgType) {
case 'auth.ok':
case 'auth_ok':
this.onAuthOk(msg);
break;
case 'auth.error':
case 'auth_error':
this.log.error({ message: msg.message }, 'Authentication failed');
this.log.error('━━━ AUTH FAILED: %s ━━━', msg.message || msg.data);
if (this.rejectStart) {
this.rejectStart(new Error(`Auth failed: ${msg.message}`));
this.resolveStart = null;
this.rejectStart = null;
}
// Close — don't reconnect on auth error from initial start
if (this.ws) {
this.ws.close(1000, 'Auth failed');
this.ws = null;
}
break;
case 'event':
this.handleTrackerEvent(msg);
// Heartbeat ack (tracker may send as any of these)
case 'agent.heartbeat.ack':
case 'heartbeat.ack':
case 'heartbeat_ack':
this.log.info('← HEARTBEAT ACK');
break;
// Subscribe confirmations
case 'subscribe.ok':
case 'chat.subscribe.ok':
case 'project.subscribe.ok':
this.log.info('← SUBSCRIBE OK: %s', JSON.stringify(msg.data || {}).slice(0, 200));
break;
default:
this.log.debug({ type }, 'Unknown message type');
// Everything else is a tracker event — forward to handler
this.onTrackerEvent(msgType, msg);
break;
}
}
private handleTrackerEvent(msg: Record<string, unknown>): void {
const event: TrackerEvent = {
event: msg.event as string,
data: msg.data as Record<string, unknown>,
ts: msg.ts as number,
id: msg.id as string,
};
private onAuthOk(msg: Record<string, unknown>): void {
const data = (msg.data || msg.init || {}) as Record<string, unknown>;
this.lobbyChatId = (data.lobby_chat_id as string) || null;
this.projects = (data.projects as Array<{ id: string; slug: string; name: string; chat_id?: string }>) || [];
this.online = (data.online as string[]) || (data.agents_online as string[]) || [];
if (!event.event || !event.id) {
this.log.warn({ msg }, 'Invalid event: missing event or id');
this.log.info('━━━ AUTH OK ━━━');
this.log.info(' Lobby chat: %s', this.lobbyChatId || '(none)');
this.log.info(' Projects: %s', this.projects.map(p => `${p.slug}(${p.id})`).join(', ') || '(none)');
this.log.info(' Online: %s', this.online.join(', ') || '(nobody)');
this.log.info(' Full auth data: %s', JSON.stringify(data, null, 2));
this.authenticated = true;
this.reconnectDelay = 1000;
this.startHeartbeat();
// Subscribe to lobby chat
if (this.lobbyChatId) {
this.log.info('→ Subscribing to lobby chat: %s', this.lobbyChatId);
this.send('chat.subscribe', { chat_id: this.lobbyChatId });
}
// Subscribe to projects (try both protocols)
for (const project of this.projects) {
this.log.info('→ Subscribing to project: %s (%s)', project.slug, project.id);
// WEBSOCKET-PROTOCOL.md style: subscribe with channels
this.send('subscribe', { channels: [`project:${project.slug}`] });
// TRACKER-PROTOCOL.md style: project.subscribe
this.send('project.subscribe', { project_id: project.id });
// If project has a chat_id, subscribe to it
if (project.chat_id) {
this.log.info('→ Subscribing to project chat: %s', project.chat_id);
this.send('chat.subscribe', { chat_id: project.chat_id });
}
}
if (this.resolveStart) {
this.resolveStart();
this.resolveStart = null;
this.rejectStart = null;
}
}
private onTrackerEvent(eventType: string, msg: Record<string, unknown>): void {
const eventId = (msg.id as string) || `${eventType}-${Date.now()}`;
const data = (msg.data || msg) as Record<string, unknown>;
const ts = (msg.ts as number) || Date.now();
this.log.info('┌── EVENT: %s', eventType);
this.log.info('│ id: %s', eventId);
this.log.info('│ data: %s', JSON.stringify(data, null, 2));
const event: TrackerEvent = { event: eventType, data, ts, id: eventId };
// Dedup
if (msg.id) {
if (this.processedIds.has(eventId)) {
this.log.info('└── DUPLICATE, skipping');
return;
}
// Deduplication
if (this.processedIds.has(event.id)) {
this.log.debug({ eventId: event.id }, 'Duplicate event, skipping');
return;
}
this.processedIds.add(event.id);
// Cap dedup set size
this.processedIds.add(eventId);
if (this.processedIds.size > 10000) {
const entries = [...this.processedIds];
this.processedIds = new Set(entries.slice(entries.length - 5000));
}
}
// Send ack
this.sendJson({ type: 'ack', id: event.id });
// Ack
this.send('ack', {});
this.log.info({ eventType: event.event, eventId: event.id }, 'Received event');
this.log.info('└── → forwarding to router');
if (this.handler) {
this.handler(event).catch((err) => {
this.log.error({ err, eventId: event.id }, 'Event handler failed');
this.log.error({ err, eventId }, 'Event handler failed');
});
}
}
/** Send a chat message via WebSocket */
sendChatMessage(chatId: string, content: string, mentions: string[] = []): void {
this.send('chat.send', { chat_id: chatId, content, mentions });
}
/** Send a task comment via WebSocket */
sendTaskComment(taskId: string, content: string, mentions: string[] = []): void {
this.send('chat.send', { task_id: taskId, content, mentions });
}
private startHeartbeat(): void {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
const intervalMs = this.config.heartbeatIntervalSec * 1000;
this.heartbeatTimer = setInterval(() => {
this.sendJson({
type: 'heartbeat',
status: this.currentTasks.size > 0 ? 'busy' : 'idle',
current_tasks: [...this.currentTasks],
});
}, intervalMs);
this.log.info({ intervalSec: this.config.heartbeatIntervalSec }, 'Heartbeat started');
this.sendHeartbeat();
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), intervalMs);
this.log.info('Heartbeat started (every %ds)', this.config.heartbeatIntervalSec);
}
private sendHeartbeat(): void {
const status = this.currentTasks.size > 0 ? 'busy' : 'online';
this.send('agent.heartbeat', { status, current_tasks: [...this.currentTasks] });
}
private scheduleReconnect(): void {
this.log.info({ delayMs: this.reconnectDelay }, 'Scheduling reconnect');
this.log.info('━━━ RECONNECT in %dms ━━━', this.reconnectDelay);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.reconnectDelay);
// Exponential backoff: 1s → 2s → 4s → ... → 30s cap
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
}
private sendJson(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}