diff --git a/.env b/.env index 3c4bb32..42ecd14 100644 --- a/.env +++ b/.env @@ -1,2 +1,5 @@ ANTHROPIC_API_KEY=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 LOG_LEVEL=debug + + +; tb--HfzlIbf8h0Bhz9Dkzeb2at-y6Ag0mZh8_P0GeWqBBo diff --git a/agent.example.json b/agent.example.json index b0af948..ed85ce8 100644 --- a/agent.example.json +++ b/agent.example.json @@ -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\"]" } diff --git a/src/config.ts b/src/config.ts index dd812d0..e337253 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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), diff --git a/src/router.ts b/src/router.ts index a9b14d8..cc91bfa 100644 --- a/src/router.ts +++ b/src/router.ts @@ -21,14 +21,24 @@ export class EventRouter { ) {} async handleEvent(event: TrackerEvent): Promise { - 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): Promise { - 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): Promise { - const content = (data.content as string) || (data.message as string) || ''; + private async handleMessageNew(data: Record): Promise { + // 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)'); } } } diff --git a/src/tracker/client.ts b/src/tracker/client.ts index 7f5c6d0..0ad17e7 100644 --- a/src/tracker/client.ts +++ b/src/tracker/client.ts @@ -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(method: string, path: string, body?: unknown): Promise { 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 { 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): Promise { - this.log.info({ taskId, fields }, 'Updating task'); - await this.request('PATCH', `/api/v1/tasks/${taskId}`, fields); - } + // --- Tasks --- - async addComment(taskId: string, content: string): Promise { - 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 { - await this.request('POST', `/api/v1/tasks/${taskId}/files`, { filename, content }); + async listTasks(params: Record = {}): Promise[]> { + 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> { return this.request('GET', `/api/v1/tasks/${taskId}`); } + async createTask(projectSlug: string, task: Record): Promise> { + return this.request('POST', `/api/v1/tasks?project_slug=${encodeURIComponent(projectSlug)}`, task); + } + + async updateTask(taskId: string, fields: Record): Promise { + this.log.info({ taskId, fields }, 'Updating task'); + await this.request('PATCH', `/api/v1/tasks/${taskId}`, fields); + } + + async deleteTask(taskId: string): Promise { + await this.request('DELETE', `/api/v1/tasks/${taskId}`); + } + + async takeTask(taskId: string, slug: string): Promise { + 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 { + 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 { + await this.request('POST', `/api/v1/tasks/${taskId}/watch?slug=${encodeURIComponent(slug)}`); + } + + // --- Steps (checklist inside task) --- + + async addStep(taskId: string, title: string): Promise> { + return this.request('POST', `/api/v1/tasks/${taskId}/steps`, { title }); + } + + async completeStep(taskId: string, stepId: string): Promise { + 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> { + 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): Promise[]> { + const qs = new URLSearchParams(params).toString(); + return this.request('GET', `/api/v1/messages?${qs}`); + } + + // --- Files --- + + async uploadFile(taskId: string, filename: string, content: string): Promise { + await this.request('POST', `/api/v1/tasks/${taskId}/files`, { filename, content }); + } + async listTaskFiles(taskId: string): Promise[]> { return this.request('GET', `/api/v1/tasks/${taskId}/files`); } + + // --- Projects --- + + async listProjects(): Promise[]> { + return this.request('GET', '/api/v1/projects'); + } + + async getProject(slug: string): Promise> { + return this.request('GET', `/api/v1/projects/${slug}`); + } + + // --- Members --- + + async listMembers(): Promise[]> { + return this.request('GET', '/api/v1/members'); + } } diff --git a/src/transport/ws-client.ts b/src/transport/ws-client.ts index 1586d38..23798c4 100644 --- a/src/transport/ws-client.ts +++ b/src/transport/ws-client.ts @@ -9,12 +9,15 @@ export type EventHandler = (event: TrackerEvent) => Promise; /** * 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(); @@ -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, - }, + name: this.config.name, + slug: this.config.slug, + capabilities: this.config.capabilities, }); }); ws.on('message', (data) => { + const raw = data.toString(); + this.log.info('← RAW: %s', raw.slice(0, 500)); + let msg: Record; 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): void { - const type = msg.type as string; + /** + * 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 = {}): 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'); + } + } - switch (type) { + private handleMessage(msg: Record): 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.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; - } + 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): void { - const event: TrackerEvent = { - event: msg.event as string, - data: msg.data as Record, - ts: msg.ts as number, - id: msg.id as string, - }; + private onAuthOk(msg: Record): void { + const data = (msg.data || msg.init || {}) as Record; + 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'); - return; + 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 }); } - // 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 - if (this.processedIds.size > 10000) { - const entries = [...this.processedIds]; - this.processedIds = new Set(entries.slice(entries.length - 5000)); + // 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 }); + } } - // Send ack - this.sendJson({ type: 'ack', id: event.id }); + if (this.resolveStart) { + this.resolveStart(); + this.resolveStart = null; + this.rejectStart = null; + } + } - this.log.info({ eventType: event.event, eventId: event.id }, 'Received event'); + private onTrackerEvent(eventType: string, msg: Record): void { + const eventId = (msg.id as string) || `${eventType}-${Date.now()}`; + const data = (msg.data || msg) as Record; + 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; + } + this.processedIds.add(eventId); + if (this.processedIds.size > 10000) { + const entries = [...this.processedIds]; + this.processedIds = new Set(entries.slice(entries.length - 5000)); + } + } + + // Ack + this.send('ack', {}); + + 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)); - } - } }