@johpaz/hive-core 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,21 @@
1
- import type { Config } from "../config/loader.ts";
2
- import { logger } from "../utils/logger.ts";
3
- import { sessionManager } from "./session.ts";
4
- import { laneQueue } from "./lane-queue.ts";
1
+ import type { Config } from "../config/loader";
2
+ import { loadConfig, saveConfig } from "../config/loader";
3
+ import { logger } from "../utils/logger";
4
+ import { sessionManager, parseSessionId } from "./session";
5
+ import { laneQueue } from "./lane-queue";
5
6
  import {
6
7
  type InboundMessage,
7
8
  type OutboundMessage,
8
9
  isSlashCommand,
9
10
  executeSlashCommand,
10
- } from "./slash-commands.ts";
11
- import { ChannelManager } from "../channels/manager.ts";
12
- import { Agent } from "../agent/index.ts";
13
- import { AgentRunner } from "../agent/providers/index.ts";
14
- import type { IncomingMessage } from "../channels/base.ts";
11
+ } from "./slash-commands";
12
+ import { ChannelManager } from "../channels/manager";
13
+ import { Agent, watchEthics, watchSoul, watchUser } from "../agent/index";
14
+ import { AgentRunner } from "../agent/providers/index";
15
+ import type { IncomingMessage } from "../channels/base";
15
16
  import * as fs from "node:fs";
16
17
  import * as path from "node:path";
18
+ import { dbService, type ChatMessageRow } from "../storage/sqlite";
17
19
 
18
20
  function expandPath(p: string): string {
19
21
  if (p.startsWith("~")) {
@@ -22,169 +24,71 @@ function expandPath(p: string): string {
22
24
  return p;
23
25
  }
24
26
 
25
- const UI_HTML = `<!DOCTYPE html>
26
- <html lang="en">
27
- <head>
28
- <meta charset="UTF-8">
29
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
30
- <title>Hive Control UI</title>
31
- <style>
32
- * { box-sizing: border-box; margin: 0; padding: 0; }
33
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; height: 100vh; display: flex; flex-direction: column; }
34
- .header { background: #16213e; padding: 1rem; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
35
- .header h1 { font-size: 1.2rem; color: #e94560; }
36
- .status { font-size: 0.8rem; color: #4ecca3; }
37
- .container { display: flex; flex: 1; overflow: hidden; }
38
- .sidebar { width: 250px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; }
39
- .sidebar-header { padding: 1rem; border-bottom: 1px solid #0f3460; font-weight: bold; }
40
- .session-list { flex: 1; overflow-y: auto; padding: 0.5rem; }
41
- .session-item { padding: 0.5rem; cursor: pointer; border-radius: 4px; margin-bottom: 4px; }
42
- .session-item:hover { background: #0f3460; }
43
- .session-item.active { background: #e94560; }
44
- .main { flex: 1; display: flex; flex-direction: column; }
45
- .chat { flex: 1; overflow-y: auto; padding: 1rem; }
46
- .message { margin-bottom: 1rem; padding: 0.75rem; border-radius: 8px; max-width: 80%; }
47
- .message.user { background: #0f3460; margin-left: auto; }
48
- .message.assistant { background: #16213e; }
49
- .message .role { font-size: 0.7rem; color: #888; margin-bottom: 0.25rem; }
50
- .input-area { padding: 1rem; background: #16213e; border-top: 1px solid #0f3460; }
51
- .input-form { display: flex; gap: 0.5rem; }
52
- .input { flex: 1; padding: 0.75rem; border: 1px solid #0f3460; border-radius: 8px; background: #1a1a2e; color: #eee; }
53
- .input:focus { outline: none; border-color: #e94560; }
54
- .send-btn { padding: 0.75rem 1.5rem; background: #e94560; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
55
- .send-btn:hover { background: #c23a51; }
56
- .connecting { text-align: center; padding: 2rem; color: #888; }
57
- </style>
58
- </head>
59
- <body>
60
- <div class="header">
61
- <h1>🐝 Hive</h1>
62
- <span class="status" id="status">Connecting...</span>
63
- </div>
64
- <div class="container">
65
- <div class="sidebar">
66
- <div class="sidebar-header">Sessions</div>
67
- <div class="session-list" id="sessionList"></div>
68
- </div>
69
- <div class="main">
70
- <div class="chat" id="chat">
71
- <div class="connecting">Connecting to gateway...</div>
72
- </div>
73
- <div class="input-area">
74
- <form class="input-form" id="form">
75
- <input type="text" class="input" id="input" placeholder="Type a message or /help for commands" autocomplete="off">
76
- <button type="submit" class="send-btn">Send</button>
77
- </form>
78
- </div>
79
- </div>
80
- </div>
81
- <script>
82
- const ws = new WebSocket('ws://' + location.host + '/ws');
83
- const chat = document.getElementById('chat');
84
- const form = document.getElementById('form');
85
- const input = document.getElementById('input');
86
- const status = document.getElementById('status');
87
- const sessionList = document.getElementById('sessionList');
88
-
89
- let currentSession = 'agent:main:webchat:dm:default';
90
- let messages = [];
91
-
92
- ws.onopen = () => {
93
- status.textContent = 'Connected';
94
- status.style.color = '#4ecca3';
95
- chat.innerHTML = '';
96
- addMessage('system', 'Connected to Hive. Type a message to start.');
97
-
98
- ws.send(JSON.stringify({ type: 'command', sessionId: currentSession, command: 'join' }));
99
- };
100
-
101
- ws.onclose = () => {
102
- status.textContent = 'Disconnected';
103
- status.style.color = '#e94560';
104
- };
105
-
106
- ws.onerror = () => {
107
- status.textContent = 'Error';
108
- status.style.color = '#e94560';
109
- };
110
-
111
- ws.onmessage = (event) => {
112
- const msg = JSON.parse(event.data);
113
-
114
- if (msg.type === 'message' && msg.sessionId === currentSession) {
115
- addMessage('assistant', msg.content);
116
- } else if (msg.type === 'stream' && msg.sessionId === currentSession) {
117
- addStreamChunk(msg.chunk, msg.isLast);
118
- } else if (msg.type === 'status') {
119
- updateStatus(msg.status);
120
- } else if (msg.type === 'command_result') {
121
- addMessage('system', JSON.stringify(msg.result, null, 2));
122
- } else if (msg.type === 'error') {
123
- addMessage('error', msg.error);
124
- }
125
- };
126
-
127
- function addMessage(role, content) {
128
- const div = document.createElement('div');
129
- div.className = 'message ' + role;
130
- div.innerHTML = '<div class="role">' + role.toUpperCase() + '</div>' + escapeHtml(content);
131
- chat.appendChild(div);
132
- chat.scrollTop = chat.scrollHeight;
133
- }
27
+ // FIX 1 Redactar secrets antes de exponer config al dashboard
28
+ // Tokens y API keys nunca viajan completos al cliente
29
+ function redactValue(value: string): string {
30
+ if (!value || value.length < 8) return "••••••••";
31
+ return `${value.slice(0, 4)}••••••••`;
32
+ }
134
33
 
135
- function addStreamChunk(chunk, isLast) {
136
- let last = chat.lastElementChild;
137
- if (!last || !last.classList.contains('streaming')) {
138
- last = document.createElement('div');
139
- last.className = 'message assistant streaming';
140
- last.innerHTML = '<div class="role">ASSISTANT</div><div class="content"></div>';
141
- chat.appendChild(last);
142
- }
143
- const content = last.querySelector('.content');
144
- content.textContent += chunk || '';
145
- chat.scrollTop = chat.scrollHeight;
146
- if (isLast) last.classList.remove('streaming');
34
+ function redactConfig(cfg: Config): Record<string, unknown> {
35
+ const redacted = JSON.parse(JSON.stringify(cfg)) as any;
36
+
37
+ // Redactar authToken del gateway
38
+ if (redacted.gateway?.authToken) {
39
+ redacted.gateway.authToken = redactValue(redacted.gateway.authToken);
40
+ }
41
+
42
+ // Redactar API keys de providers
43
+ if (redacted.models?.providers) {
44
+ for (const provider of Object.values(redacted.models.providers) as any[]) {
45
+ if (provider?.apiKey) provider.apiKey = redactValue(provider.apiKey);
147
46
  }
47
+ }
148
48
 
149
- function updateStatus(s) {
150
- if (s.model) status.textContent = s.model + ' | ' + (s.tokens || 0) + ' tokens';
49
+ // Redactar tokens de canales
50
+ if (redacted.channels) {
51
+ for (const channel of Object.values(redacted.channels) as any[]) {
52
+ if (channel?.accounts) {
53
+ for (const acc of Object.values(channel.accounts) as any[]) {
54
+ if (acc?.botToken) acc.botToken = redactValue(acc.botToken);
55
+ if (acc?.appToken) acc.appToken = redactValue(acc.appToken);
56
+ if (acc?.signingSecret) acc.signingSecret = redactValue(acc.signingSecret);
57
+ }
58
+ }
151
59
  }
60
+ }
152
61
 
153
- function escapeHtml(text) {
154
- const div = document.createElement('div');
155
- div.textContent = text;
156
- return div.innerHTML;
62
+ // Redactar headers de MCP servers
63
+ if (redacted.mcp?.servers) {
64
+ for (const server of Object.values(redacted.mcp.servers) as any[]) {
65
+ if (server?.headers) {
66
+ for (const [k, v] of Object.entries(server.headers)) {
67
+ const lk = k.toLowerCase();
68
+ if (lk.includes("auth") || lk.includes("token") || lk.includes("key")) {
69
+ server.headers[k] = redactValue(v as string);
70
+ }
71
+ }
72
+ }
157
73
  }
74
+ }
158
75
 
159
- form.onsubmit = (e) => {
160
- e.preventDefault();
161
- const content = input.value.trim();
162
- if (!content) return;
163
-
164
- addMessage('user', content);
165
- ws.send(JSON.stringify({ type: 'message', sessionId: currentSession, content }));
166
- input.value = '';
167
- };
168
-
169
- input.focus();
170
- </script>
171
- </body>
172
- </html>`;
76
+ return redacted;
77
+ }
173
78
 
174
79
  interface WebSocketData {
175
80
  sessionId: string;
176
81
  authenticatedAt: number;
177
82
  }
178
83
 
179
- interface SessionMessages {
180
- messages: Array<{ role: string; content: string }>;
181
- }
182
-
183
84
  export async function startGateway(config: Config): Promise<void> {
184
85
  const host = config.gateway?.host ?? "127.0.0.1";
185
86
  const port = config.gateway?.port ?? 18790;
186
87
  const token = config.gateway?.authToken;
187
88
 
89
+ // FIX 2 — startTime para calcular uptime en /status y /api/agents
90
+ const startTime = Date.now();
91
+
188
92
  const log = logger.child("gateway");
189
93
 
190
94
  log.info(`Starting gateway on ${host}:${port}`);
@@ -205,16 +109,35 @@ export async function startGateway(config: Config): Promise<void> {
205
109
  const agentList = config.agents?.list ?? [];
206
110
  const defaultAgent = agentList.find((a) => a.default) ?? agentList[0];
207
111
  const workspacePath = expandPath(defaultAgent?.workspace ?? "~/.hive/workspace");
208
-
112
+
209
113
  const agent = new Agent({
210
114
  agentId: defaultAgent?.id ?? "main",
211
115
  config,
212
116
  workspacePath,
213
117
  });
214
-
118
+
215
119
  await agent.initialize();
216
120
  log.info(`Agent initialized: ${agent.agentId}`);
217
121
 
122
+ // Set up hot reload watchers
123
+ const watchers: Array<() => void> = [];
124
+ const soulPath = path.join(workspacePath, "SOUL.md");
125
+ const userPath = path.join(workspacePath, "USER.md");
126
+ const ethicsPath = path.join(workspacePath, "ETHICS.md");
127
+
128
+ if (fs.existsSync(soulPath)) {
129
+ watchers.push(watchSoul(soulPath, () => agent.reloadSoul()));
130
+ log.debug("Watching SOUL.md for changes");
131
+ }
132
+ if (fs.existsSync(userPath)) {
133
+ watchers.push(watchUser(userPath, () => agent.reloadUser()));
134
+ log.debug("Watching USER.md for changes");
135
+ }
136
+ if (fs.existsSync(ethicsPath)) {
137
+ watchers.push(watchEthics(ethicsPath, async () => agent.reloadEthics()));
138
+ log.debug("Watching ETHICS.md for changes");
139
+ }
140
+
218
141
  // Initialize LLM runner
219
142
  const runner = new AgentRunner(config);
220
143
  const provider = config.models?.defaultProvider ?? "gemini";
@@ -223,56 +146,77 @@ export async function startGateway(config: Config): Promise<void> {
223
146
 
224
147
  // Initialize channel manager
225
148
  const channelManager = new ChannelManager(config);
226
-
227
- // Track sessions
228
- const sessionStore = new Map<string, SessionMessages>();
229
149
 
230
- // Handle messages from channels
231
- channelManager.onMessage(async (message: IncomingMessage) => {
232
- log.info(`Message from ${message.channel}:${message.accountId} - Session: ${message.sessionId}`);
233
- log.debug(`Content: ${message.content.substring(0, 100)}...`);
234
-
235
- // Get or create session
236
- let session = sessionStore.get(message.sessionId);
237
- if (!session) {
238
- session = { messages: [] };
239
- sessionStore.set(message.sessionId, session);
150
+ function getUnifiedUserId(channel: string, peerId: string): string {
151
+ const userConfig = config.user;
152
+ if (!userConfig) return peerId;
153
+
154
+ const channelId = userConfig.channels?.[channel];
155
+ if (channelId && channelId === peerId) {
156
+ return userConfig.id;
240
157
  }
158
+ return peerId;
159
+ }
241
160
 
242
- // Add user message
243
- session.messages.push({ role: "user", content: message.content });
161
+ // Handle messages from channels (Telegram, Discord, WhatsApp, Slack)
162
+ channelManager.onMessage(async (message: IncomingMessage) => {
163
+ log.info(`📥 Message from ${message.channel}:${message.accountId}`);
164
+ log.info(` Session: ${message.sessionId}`);
165
+ log.info(` Content: ${message.content.substring(0, 150)}${message.content.length > 150 ? "..." : ""}`);
166
+
167
+ const userId = getUnifiedUserId(message.channel, message.peerId);
168
+ const telegramMeta = message.metadata?.telegram as { messageId?: number } | undefined;
169
+ const messageId = telegramMeta?.messageId?.toString();
170
+ await Promise.all([
171
+ channelManager.markAsRead(message.channel, message.sessionId, messageId),
172
+ channelManager.startTyping(message.channel, message.sessionId),
173
+ ]);
174
+
175
+ dbService.addMessage(message.sessionId, "user", message.content, userId);
176
+ const history = dbService.getMessages(message.sessionId);
177
+ const messages = history.map((row: ChatMessageRow) => ({
178
+ role: row.role as "user" | "assistant" | "system",
179
+ content: row.content,
180
+ }));
244
181
 
245
182
  try {
246
- // Build system prompt
247
183
  const systemPrompt = agent.buildPrompt();
184
+ log.info(`🤖 Calling LLM (${provider}/${model})...`);
248
185
 
249
- // Generate response
250
186
  const response = await runner.generate({
251
187
  provider: provider as any,
252
188
  system: systemPrompt,
253
- messages: session.messages,
189
+ messages,
254
190
  maxTokens: 4096,
255
191
  });
256
192
 
257
- // Add assistant message to history
258
- session.messages.push({ role: "assistant", content: response.content });
193
+ const responseContent = response.content || "...";
194
+ log.info(`📤 LLM response: ${responseContent.substring(0, 100)}${responseContent.length > 100 ? "..." : ""}`);
259
195
 
260
- // Send response back through channel
261
- await channelManager.send(message.channel, message.sessionId, { content: response.content });
262
-
263
- log.info(`Response sent to ${message.sessionId}`);
196
+ dbService.addMessage(message.sessionId, "assistant", responseContent);
197
+ await channelManager.stopTyping(message.channel, message.sessionId);
198
+ await channelManager.send(message.channel, message.sessionId, { content: responseContent });
199
+ log.info(`✅ Response sent to ${message.sessionId}`);
264
200
  } catch (error) {
265
- log.error(`Error processing message: ${(error as Error).message}`);
266
- await channelManager.send(message.channel, message.sessionId, {
267
- content: `Error: ${(error as Error).message}`
201
+ await channelManager.stopTyping(message.channel, message.sessionId);
202
+ log.error(`❌ Error: ${(error as Error).message}`);
203
+ await channelManager.send(message.channel, message.sessionId, {
204
+ content: `Error: ${(error as Error).message}`,
268
205
  });
269
206
  }
270
207
  });
271
208
 
272
- // Initialize and start channels
273
209
  await channelManager.initialize();
274
210
  await channelManager.startAll();
275
211
 
212
+ // ── Auth helper ──────────────────────────────────────────────────────────
213
+ function checkAuth(req: Request, url: URL): boolean {
214
+ if (!token) return true;
215
+ const authHeader = req.headers.get("authorization");
216
+ const provided = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
217
+ return provided === token;
218
+ }
219
+
276
220
  const server = Bun.serve<WebSocketData>({
277
221
  port,
278
222
  hostname: host,
@@ -280,46 +224,428 @@ export async function startGateway(config: Config): Promise<void> {
280
224
  async fetch(req, server) {
281
225
  const url = new URL(req.url);
282
226
 
227
+ // ── WebSocket upgrade ────────────────────────────────────────────────
283
228
  if (url.pathname === "/ws" || url.pathname === "/ws/") {
284
- const authHeader = req.headers.get("authorization");
285
- if (token) {
286
- const providedToken = authHeader?.replace(/^Bearer\s+/i, "") ?? url.searchParams.get("token");
287
- if (providedToken !== token) {
288
- return new Response("Unauthorized", { status: 401 });
289
- }
229
+ if (!checkAuth(req, url)) {
230
+ return new Response("Unauthorized", { status: 401 });
290
231
  }
291
-
292
232
  const sessionId = url.searchParams.get("session") ?? "agent:main:webchat:dm:default";
293
-
294
233
  const success = server.upgrade(req, {
295
234
  data: { sessionId, authenticatedAt: Date.now() },
296
235
  });
297
-
298
236
  if (success) return undefined;
299
237
  return new Response("WebSocket upgrade failed", { status: 400 });
300
238
  }
301
239
 
240
+ // ── Dashboard ────────────────────────────────────────────────────────
302
241
  if (url.pathname === "/ui" || url.pathname === "/ui/") {
303
- return new Response(UI_HTML, {
304
- headers: { "Content-Type": "text/html" },
305
- });
242
+ return token
243
+ ? Response.redirect(`/dashboard?token=${token}`, 301)
244
+ : Response.redirect("/dashboard", 301);
245
+ }
246
+
247
+ if (url.pathname.startsWith("/dashboard")) {
248
+ const dashboardDir = path.join(process.cwd(), "packages/dashboard/dist/dashboard/browser");
249
+ let subPath = url.pathname.replace(/^\/dashboard\/?/, "");
250
+ if (!subPath) subPath = "index.html";
251
+
252
+ const filePath = path.join(dashboardDir, subPath);
253
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
254
+ if (subPath === "index.html" && token) {
255
+ const urlToken = url.searchParams.get("token");
256
+ if (!urlToken || urlToken !== token) {
257
+ return Response.redirect(`/dashboard?token=${token}`, 302);
258
+ }
259
+ const html = fs.readFileSync(filePath, "utf-8");
260
+ const injected = html.replace(
261
+ "</head>",
262
+ ` <script>window.HIVE_AUTH_TOKEN = "${token}";</script>\n</head>`
263
+ );
264
+ return new Response(injected, { headers: { "Content-Type": "text/html" } });
265
+ }
266
+ return new Response(Bun.file(filePath));
267
+ }
268
+ const indexHtml = path.join(dashboardDir, "index.html");
269
+ if (fs.existsSync(indexHtml)) return new Response(Bun.file(indexHtml));
270
+ return new Response("Dashboard build not found. Run 'bun run build' in packages/dashboard.", { status: 404 });
306
271
  }
307
272
 
273
+ // ── Rutas que requieren autenticación ────────────────────────────────
274
+ if (!checkAuth(req, url)) {
275
+ return new Response("Unauthorized", { status: 401 });
276
+ }
277
+
278
+ // ── Health ───────────────────────────────────────────────────────────
308
279
  if (url.pathname === "/health" || url.pathname === "/health/") {
309
280
  return Response.json({ status: "ok", pid: process.pid });
310
281
  }
311
282
 
283
+ // ── Status ───────────────────────────────────────────────────────────
284
+ // FIX 3 — Cache-Control: max-age=5 para que el dashboard no haga
285
+ // polling más frecuente de cada 5 segundos (causa del spam en Network tab)
312
286
  if (url.pathname === "/status" || url.pathname === "/status/") {
287
+ return new Response(
288
+ JSON.stringify({
289
+ status: "ok",
290
+ version: "0.1.7",
291
+ uptime: Math.floor((Date.now() - startTime) / 1000),
292
+ gateway: { host, port },
293
+ sessions: sessionManager.list().map((s) => ({
294
+ id: s.id,
295
+ createdAt: s.createdAt,
296
+ messageCount: s.messageCount,
297
+ })),
298
+ channels: channelManager.listChannels(),
299
+ queue: { activeSessions: 0 },
300
+ }),
301
+ {
302
+ headers: {
303
+ "Content-Type": "application/json",
304
+ // Decirle al browser/dashboard que este endpoint no cambia
305
+ // más rápido que 5 segundos — frena el polling descontrolado
306
+ "Cache-Control": "max-age=5",
307
+ },
308
+ }
309
+ );
310
+ }
311
+
312
+ // ── Config ───────────────────────────────────────────────────────────
313
+ if (url.pathname === "/api/config") {
314
+ if (req.method === "GET") {
315
+ // FIX 1 — nunca devolver el config raw con secrets
316
+ return Response.json(redactConfig(config));
317
+ }
318
+ }
319
+
320
+ // ── Agents ───────────────────────────────────────────────────────────
321
+ // FIX 4 — incluir estado real (connected, uptime, provider, model)
322
+ if (url.pathname === "/api/agents") {
323
+ const agents = (config.agents?.list ?? []).map((a) => ({
324
+ ...a,
325
+ connected: true,
326
+ currentTask: null,
327
+ uptime: Math.floor((Date.now() - startTime) / 1000),
328
+ provider,
329
+ model,
330
+ }));
331
+ return Response.json(agents);
332
+ }
333
+
334
+ // ── Channels API ─────────────────────────────────────────────────────
335
+ if (url.pathname === "/api/channels" || url.pathname === "/api/channels/") {
336
+ if (req.method === "GET") {
337
+ return Response.json(channelManager.listAllAvailableChannels());
338
+ }
339
+ if (req.method === "POST") {
340
+ const body = await req.json().catch(() => ({}));
341
+ const { name, accountId, config: channelConfigData } = body;
342
+ if (!name || !accountId || !channelConfigData) {
343
+ return new Response("Missing name, accountId or config", { status: 400 });
344
+ }
345
+ config.channels = config.channels || {};
346
+ config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
347
+ const channelEntry = config.channels[name] as any;
348
+ channelEntry.accounts = channelEntry.accounts || {};
349
+ channelEntry.accounts[accountId] = channelConfigData;
350
+ saveConfig(config);
351
+ await channelManager.removeChannel(name, accountId);
352
+ await channelManager.startChannel(name, accountId);
353
+ return Response.json({ success: true });
354
+ }
355
+ }
356
+
357
+ const channelDetailMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/([^/]+)$/);
358
+ if (channelDetailMatch) {
359
+ const name = channelDetailMatch[1];
360
+ const accountId = channelDetailMatch[2];
361
+
362
+ if (req.method === "GET") {
363
+ const accConfig = channelManager.getAccountConfig(name, accountId);
364
+ if (!accConfig) return new Response("Channel account not found", { status: 404 });
365
+ return Response.json({ name, accountId, config: accConfig });
366
+ }
367
+ if (req.method === "PUT") {
368
+ const body = await req.json().catch(() => ({}));
369
+ if (!body.config) return new Response("Missing config", { status: 400 });
370
+ config.channels = config.channels || {};
371
+ config.channels[name] = config.channels[name] || { enabled: true, accounts: {} };
372
+ const channelEntry = config.channels[name] as any;
373
+ channelEntry.accounts = channelEntry.accounts || {};
374
+ channelEntry.accounts[accountId] = body.config;
375
+ saveConfig(config);
376
+ await channelManager.removeChannel(name, accountId);
377
+ await channelManager.startChannel(name, accountId);
378
+ return Response.json({ success: true });
379
+ }
380
+ if (req.method === "DELETE") {
381
+ if (config.channels?.[name]) {
382
+ const channelEntry = config.channels[name] as any;
383
+ if (channelEntry.accounts) {
384
+ delete channelEntry.accounts[accountId];
385
+ if (Object.keys(channelEntry.accounts).length === 0) {
386
+ delete config.channels[name];
387
+ }
388
+ }
389
+ saveConfig(config);
390
+ await channelManager.removeChannel(name, accountId);
391
+ }
392
+ return Response.json({ success: true });
393
+ }
394
+ }
395
+
396
+ const channelActionMatch = url.pathname.match(
397
+ /^\/api\/channels\/([^/]+)\/([^/]+)\/(start|stop)$/
398
+ );
399
+ if (channelActionMatch) {
400
+ const [, name, accountId, action] = channelActionMatch;
401
+ if (req.method === "POST") {
402
+ try {
403
+ if (action === "start") await channelManager.startChannel(name, accountId);
404
+ else await channelManager.stopChannel(name, accountId);
405
+ return Response.json({ success: true });
406
+ } catch (error) {
407
+ return Response.json(
408
+ { success: false, error: (error as Error).message },
409
+ { status: 500 }
410
+ );
411
+ }
412
+ }
413
+ }
414
+
415
+ // ── Skills API ───────────────────────────────────────────────────────
416
+ if (url.pathname === "/api/skills" || url.pathname === "/api/skills/") {
417
+ if (req.method === "GET") return Response.json(agent.getSkills());
418
+ if (req.method === "POST") {
419
+ const body = await req.json().catch(() => ({}));
420
+ const { name, description, content, raw } = body;
421
+ if (!name || (!content && !raw)) {
422
+ return new Response("Missing name or content", { status: 400 });
423
+ }
424
+ const managedDir = expandPath(config.skills?.managedDir ?? "~/.hive/skills");
425
+ const skillDir = path.join(managedDir, name);
426
+ const skillFile = path.join(skillDir, "SKILL.md");
427
+ fs.mkdirSync(skillDir, { recursive: true });
428
+ const skillMd = raw || `---\nname: ${name}\ndescription: ${description || ""}\n---\n${content}`;
429
+ fs.writeFileSync(skillFile, skillMd);
430
+ agent.reloadSkills();
431
+ return Response.json({ success: true });
432
+ }
433
+ }
434
+
435
+ if (url.pathname.startsWith("/api/skills/")) {
436
+ const skillName = url.pathname.replace(/^\/api\/skills\//, "");
437
+
438
+ if (skillName === "bundled/toggle" && req.method === "POST") {
439
+ const body = await req.json().catch(() => ({}));
440
+ const { name: bundledName, enabled } = body;
441
+ if (!bundledName) return new Response("Missing name", { status: 400 });
442
+ config.skills = config.skills || {};
443
+ config.skills.allowBundled = config.skills.allowBundled || [];
444
+ if (enabled) {
445
+ if (!config.skills.allowBundled.includes(bundledName)) {
446
+ config.skills.allowBundled.push(bundledName);
447
+ }
448
+ } else {
449
+ config.skills.allowBundled = config.skills.allowBundled.filter((n) => n !== bundledName);
450
+ }
451
+ saveConfig(config);
452
+ await agent.updateConfig(config);
453
+ agent.reloadSkills();
454
+ return Response.json({ success: true });
455
+ }
456
+
457
+ if (req.method === "PUT") {
458
+ const body = await req.json().catch(() => ({}));
459
+ const { name, description, content, raw } = body;
460
+ if (!content && !raw) return new Response("Missing content", { status: 400 });
461
+ const skill = agent.getSkills().find((s) => s.name === skillName);
462
+ if (!skill || (skill.source !== "managed" && skill.source !== "workspace")) {
463
+ return new Response("Skill not found or not editable", { status: 404 });
464
+ }
465
+ const skillFile = path.join(skill.path, "SKILL.md");
466
+ const skillMd = raw || `---\nname: ${name || skillName}\ndescription: ${description || ""}\n---\n${content}`;
467
+ fs.writeFileSync(skillFile, skillMd);
468
+ agent.reloadSkills();
469
+ return Response.json({ success: true });
470
+ }
471
+
472
+ if (req.method === "DELETE") {
473
+ const skill = agent.getSkills().find((s) => s.name === skillName);
474
+ if (skill?.source === "managed") {
475
+ fs.rmSync(skill.path, { recursive: true, force: true });
476
+ agent.reloadSkills();
477
+ } else if (skill?.source === "bundled") {
478
+ config.skills = config.skills || {};
479
+ config.skills.allowBundled = (config.skills.allowBundled || []).filter((n) => n !== skillName);
480
+ saveConfig(config);
481
+ await agent.updateConfig(config);
482
+ agent.reloadSkills();
483
+ }
484
+ return Response.json({ success: true });
485
+ }
486
+ }
487
+
488
+ // ── Model Config API ─────────────────────────────────────────────────
489
+ if (url.pathname === "/api/config/models") {
490
+ if (req.method === "GET") {
491
+ return Response.json({
492
+ config: config.models || {},
493
+ availableProviders: ["openai", "anthropic", "gemini", "kimi", "ollama", "openrouter", "deepseek"],
494
+ });
495
+ }
496
+ if (req.method === "POST") {
497
+ const body = await req.json().catch(() => ({}));
498
+ const { defaultProvider, defaults, providers } = body;
499
+ config.models = config.models || {};
500
+ if (defaultProvider) config.models.defaultProvider = defaultProvider;
501
+ if (defaults) config.models.defaults = { ...(config.models.defaults || {}), ...defaults };
502
+ if (providers) config.models.providers = { ...(config.models.providers || {}), ...providers };
503
+ saveConfig(config);
504
+ await agent.updateConfig(config);
505
+ return Response.json({ success: true });
506
+ }
507
+ }
508
+
509
+ // ── MCP API ──────────────────────────────────────────────────────────
510
+ if (url.pathname.startsWith("/api/mcp/")) {
511
+ const mcp = agent.getMCPManager();
512
+ if (!mcp) return new Response("MCP is disabled", { status: 404 });
513
+
514
+ if (url.pathname === "/api/mcp/servers") {
515
+ if (req.method === "GET") {
516
+ return Response.json(mcp.listServers());
517
+ }
518
+ if (req.method === "POST") {
519
+ const body = await req.json().catch(() => ({}));
520
+ if (!body.name || !body.config) {
521
+ return new Response("Missing name or config", { status: 400 });
522
+ }
523
+ config.mcp = config.mcp || {};
524
+ config.mcp.servers = config.mcp.servers || {};
525
+ config.mcp.servers[body.name] = body.config;
526
+ saveConfig(config);
527
+ await agent.updateConfig(config);
528
+ return Response.json({ success: true });
529
+ }
530
+ }
531
+
532
+ // FIX 5 — separar el endpoint de tools del de detalles del servidor
533
+ const toolsMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)\/tools$/);
534
+ if (toolsMatch && req.method === "GET") {
535
+ const serverName = toolsMatch[1];
536
+ const tools = mcp.getServerTools(serverName);
537
+ return Response.json(tools);
538
+ }
539
+
540
+ const serverMatch = url.pathname.match(/^\/api\/mcp\/servers\/([^/]+)$/);
541
+ if (serverMatch) {
542
+ const serverName = serverMatch[1];
543
+ const details = mcp.getServerDetails(serverName);
544
+
545
+ if (req.method === "GET") {
546
+ if (!details) return new Response("Server not found", { status: 404 });
547
+ return Response.json(details);
548
+ }
549
+ if (req.method === "PUT") {
550
+ const body = await req.json().catch(() => ({}));
551
+ if (!body.config) return new Response("Missing config", { status: 400 });
552
+ config.mcp = config.mcp || {};
553
+ config.mcp.servers = config.mcp.servers || {};
554
+ config.mcp.servers[serverName] = body.config;
555
+ saveConfig(config);
556
+ await agent.updateConfig(config);
557
+ return Response.json({ success: true });
558
+ }
559
+ if (req.method === "DELETE") {
560
+ if (config.mcp?.servers?.[serverName]) {
561
+ delete config.mcp.servers[serverName];
562
+ saveConfig(config);
563
+ await agent.updateConfig(config);
564
+ }
565
+ return Response.json({ success: true });
566
+ }
567
+ if (req.method === "POST") {
568
+ if (!details) return new Response("Server not found", { status: 404 });
569
+ const body = await req.json().catch(() => ({}));
570
+ if (body.action === "connect") {
571
+ await mcp.connectServer(serverName);
572
+ return Response.json({ success: true });
573
+ }
574
+ if (body.action === "disconnect") {
575
+ await mcp.disconnectServer(serverName);
576
+ return Response.json({ success: true });
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ // ── Workspace API ────────────────────────────────────────────────────
583
+ for (const wsType of ["soul", "user", "ethics"] as const) {
584
+ if (url.pathname === `/api/workspace/${wsType}`) {
585
+ const filePath = path.join(workspacePath, `${wsType.toUpperCase()}.md`);
586
+
587
+ if (req.method === "GET") {
588
+ const defaults: Record<string, string> = {
589
+ soul: "# Agent Soul\n\nDefine your agent's personality here.",
590
+ user: "# User Profile\n\nAdd user preferences here.",
591
+ ethics: "# Ethics\n\nDefine ethical guidelines here.",
592
+ };
593
+ const content = fs.existsSync(filePath)
594
+ ? fs.readFileSync(filePath, "utf-8")
595
+ : defaults[wsType];
596
+ return new Response(content, { headers: { "Content-Type": "text/plain" } });
597
+ }
598
+
599
+ if (req.method === "POST") {
600
+ const content = await req.text();
601
+ fs.mkdirSync(workspacePath, { recursive: true });
602
+ fs.writeFileSync(filePath, content);
603
+ if (wsType === "soul") agent.reloadSoul();
604
+ if (wsType === "user") agent.reloadUser();
605
+ if (wsType === "ethics") await agent.reloadEthics();
606
+ return Response.json({ success: true, savedAt: new Date().toISOString() });
607
+ }
608
+ }
609
+ }
610
+
611
+ // ── Reload API ───────────────────────────────────────────────────────
612
+ if (url.pathname === "/api/reload" && req.method === "POST") {
613
+ try {
614
+ const newConfig = loadConfig();
615
+ await agent.updateConfig(newConfig);
616
+ await agent.reload();
617
+ log.info("Configuration reloaded via API");
618
+ return Response.json({ success: true, message: "Configuration reloaded" });
619
+ } catch (error) {
620
+ return Response.json(
621
+ { success: false, error: (error as Error).message },
622
+ { status: 500 }
623
+ );
624
+ }
625
+ }
626
+
627
+ // ── User Channel Linking API ────────────────────────────────────────────
628
+ if (url.pathname === "/api/user/channels" && req.method === "POST") {
629
+ const body = await req.json().catch(() => ({}));
630
+ const { channel, channelUserId } = body;
631
+
632
+ if (!channel || !channelUserId) {
633
+ return Response.json({ success: false, error: "Missing channel or channelUserId" }, { status: 400 });
634
+ }
635
+
636
+ config.user = config.user || { id: "", name: "User" };
637
+ config.user.channels = config.user.channels || {};
638
+ config.user.channels[channel] = channelUserId;
639
+
640
+ saveConfig(config);
641
+ log.info(`Linked channel ${channel} to user ID ${channelUserId}`);
642
+
643
+ return Response.json({ success: true, channels: config.user.channels });
644
+ }
645
+
646
+ if (url.pathname === "/api/user/channels" && req.method === "GET") {
313
647
  return Response.json({
314
- sessions: sessionManager.list().map((s) => ({
315
- id: s.id,
316
- createdAt: s.createdAt,
317
- messageCount: s.messageCount,
318
- })),
319
- channels: channelManager.listChannels(),
320
- queue: {
321
- activeSessions: 0,
322
- },
648
+ user: config.user || { id: "", name: "User", channels: {} },
323
649
  });
324
650
  }
325
651
 
@@ -329,10 +655,19 @@ export async function startGateway(config: Config): Promise<void> {
329
655
  websocket: {
330
656
  open(ws) {
331
657
  const data = ws.data;
332
- log.debug(`WebSocket connected: ${data.sessionId}`);
333
-
658
+ const parts = data.sessionId.split(":");
659
+ const isWebchat = parts[2] === "webchat";
660
+
661
+ if (isWebchat) log.info(`WebChat connected: ${data.sessionId}`);
662
+ else log.debug(`WebSocket connected: ${data.sessionId}`);
663
+
334
664
  sessionManager.create(data.sessionId, ws);
335
-
665
+
666
+ if (isWebchat) {
667
+ const channel = channelManager.getChannel("webchat") as any;
668
+ if (channel?.registerConnection) channel.registerConnection(ws);
669
+ }
670
+
336
671
  ws.send(JSON.stringify({
337
672
  type: "status",
338
673
  sessionId: data.sessionId,
@@ -342,7 +677,7 @@ export async function startGateway(config: Config): Promise<void> {
342
677
 
343
678
  async message(ws, message) {
344
679
  const data = ws.data;
345
-
680
+
346
681
  let msg: InboundMessage;
347
682
  try {
348
683
  msg = JSON.parse(message.toString()) as InboundMessage;
@@ -363,58 +698,74 @@ export async function startGateway(config: Config): Promise<void> {
363
698
  return;
364
699
  }
365
700
 
701
+ if (msg.type === "join") {
702
+ ws.send(JSON.stringify({ type: "joined", sessionId: msg.sessionId } as OutboundMessage));
703
+ return;
704
+ }
705
+
366
706
  if (msg.type === "command" || (msg.content && isSlashCommand(msg.content))) {
367
707
  const result = await executeSlashCommand(msg.sessionId, msg.content ?? `/${msg.command}`, ws);
368
- ws.send(JSON.stringify(result));
369
- return;
708
+ if (result) {
709
+ ws.send(JSON.stringify(result));
710
+ return;
711
+ }
370
712
  }
371
713
 
372
714
  if (msg.type === "message" && msg.content) {
373
- log.debug(`Message received for session ${msg.sessionId}`, { content: msg.content.substring(0, 100) });
715
+ log.info(`WebChat message from session ${msg.sessionId}: ${msg.content.substring(0, 100)}`);
374
716
 
375
- // Get or create session
376
- let session = sessionStore.get(msg.sessionId);
377
- if (!session) {
378
- session = { messages: [] };
379
- sessionStore.set(msg.sessionId, session);
380
- }
717
+ const parsedSession = parseSessionId(msg.sessionId);
718
+ const userId = parsedSession?.userId;
719
+
720
+ dbService.addMessage(msg.sessionId, "user", msg.content, userId);
381
721
 
382
- // Add user message
383
- session.messages.push({ role: "user", content: msg.content });
722
+ // FIX 6 — typing indicator inmediato ANTES de encolar
723
+ // El usuario ve "escribiendo..." de inmediato, no después del queue
724
+ ws.send(JSON.stringify({
725
+ type: "typing",
726
+ isTyping: true,
727
+ sessionId: msg.sessionId,
728
+ } as OutboundMessage));
384
729
 
385
730
  laneQueue.enqueue(msg.sessionId, async (_task, signal) => {
386
731
  if (signal.aborted) {
387
- ws.send(JSON.stringify({
388
- type: "error",
389
- sessionId: msg.sessionId,
390
- error: "Task cancelled",
391
- } as OutboundMessage));
732
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
733
+ ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, error: "Task cancelled" } as OutboundMessage));
392
734
  return;
393
735
  }
394
736
 
395
737
  try {
738
+ const history = dbService.getMessages(msg.sessionId);
739
+ const messages = history.map((row: ChatMessageRow) => ({
740
+ role: row.role as "user" | "assistant" | "system",
741
+ content: row.content,
742
+ }));
396
743
  const systemPrompt = agent.buildPrompt();
744
+ log.info(`Generating response for session ${msg.sessionId}...`);
397
745
 
398
746
  const response = await runner.generate({
399
747
  provider: provider as any,
400
748
  system: systemPrompt,
401
- messages: session!.messages,
749
+ messages,
402
750
  maxTokens: 4096,
403
751
  });
404
752
 
405
- session!.messages.push({ role: "assistant", content: response.content });
753
+ const content = response.content?.trim() || "...";
754
+ dbService.addMessage(msg.sessionId, "assistant", content);
755
+ log.info(`Response sent to session ${msg.sessionId} (${content.length} chars)`);
406
756
 
407
- ws.send(JSON.stringify({
408
- type: "message",
409
- sessionId: msg.sessionId,
410
- content: response.content,
411
- } as OutboundMessage));
757
+ // Detener typing ANTES de enviar la respuesta
758
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
759
+ ws.send(JSON.stringify({ type: "message", sessionId: msg.sessionId, content } as OutboundMessage));
412
760
  } catch (error) {
761
+ // Detener typing aunque falle — nunca dejar el spinner infinito
762
+ ws.send(JSON.stringify({ type: "typing", isTyping: false, sessionId: msg.sessionId } as OutboundMessage));
413
763
  ws.send(JSON.stringify({
414
764
  type: "error",
415
765
  sessionId: msg.sessionId,
416
766
  error: (error as Error).message,
417
767
  } as OutboundMessage));
768
+ log.error(`Error for session ${msg.sessionId}: ${(error as Error).message}`);
418
769
  }
419
770
  });
420
771
 
@@ -430,8 +781,18 @@ export async function startGateway(config: Config): Promise<void> {
430
781
 
431
782
  close(ws) {
432
783
  const data = ws.data;
433
- log.debug(`WebSocket disconnected: ${data.sessionId}`);
784
+ const parts = data.sessionId.split(":");
785
+ const isWebchat = parts[2] === "webchat";
786
+
787
+ if (isWebchat) log.info(`WebChat disconnected: ${data.sessionId}`);
788
+ else log.debug(`WebSocket disconnected: ${data.sessionId}`);
789
+
434
790
  laneQueue.cancel(data.sessionId);
791
+
792
+ if (isWebchat) {
793
+ const channel = channelManager.getChannel("webchat") as any;
794
+ if (channel?.unregisterConnection) channel.unregisterConnection(data.sessionId);
795
+ }
435
796
  },
436
797
  },
437
798
  });
@@ -441,19 +802,30 @@ export async function startGateway(config: Config): Promise<void> {
441
802
  log.info(`WebSocket: ws://${host}:${port}/ws`);
442
803
  log.info(`Channels: ${channelManager.listChannels().map((c) => c.name).join(", ") || "none"}`);
443
804
 
805
+ // FIX 7 — SIGTERM desconecta MCP limpiamente antes de cerrar
444
806
  process.on("SIGTERM", async () => {
445
807
  log.info("Received SIGTERM, shutting down gracefully...");
808
+ watchers.forEach((close) => close());
809
+ const mcp = agent.getMCPManager();
810
+ if (mcp) {
811
+ log.info("Disconnecting MCP servers...");
812
+ await mcp.disconnectAll().catch(() => { });
813
+ }
446
814
  await channelManager.stopAll();
447
815
  server.stop();
448
- try {
449
- fs.unlinkSync(pidFile);
450
- } catch {
451
- // Ignore
452
- }
816
+ try { fs.unlinkSync(pidFile); } catch { }
453
817
  process.exit(0);
454
818
  });
455
819
 
456
- process.on("SIGHUP", () => {
820
+ process.on("SIGHUP", async () => {
457
821
  log.info("Received SIGHUP, reloading configuration...");
822
+ try {
823
+ const newConfig = loadConfig();
824
+ await agent.updateConfig(newConfig);
825
+ await agent.reload();
826
+ log.info("Configuration reloaded successfully");
827
+ } catch (error) {
828
+ log.error(`Failed to reload configuration: ${(error as Error).message}`);
829
+ }
458
830
  });
459
- }
831
+ }