kitty-hive 0.1.1 → 0.3.0

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.
Files changed (63) hide show
  1. package/README.md +121 -126
  2. package/README.zh.md +153 -105
  3. package/channel.ts +194 -431
  4. package/dist/auth.d.ts +10 -6
  5. package/dist/auth.js +32 -17
  6. package/dist/auth.js.map +1 -1
  7. package/dist/db.d.ts +47 -17
  8. package/dist/db.js +268 -123
  9. package/dist/db.js.map +1 -1
  10. package/dist/federation-heartbeat.d.ts +7 -0
  11. package/dist/federation-heartbeat.js +45 -0
  12. package/dist/federation-heartbeat.js.map +1 -0
  13. package/dist/federation-http.d.ts +7 -0
  14. package/dist/federation-http.js +287 -0
  15. package/dist/federation-http.js.map +1 -0
  16. package/dist/index.js +336 -114
  17. package/dist/index.js.map +1 -1
  18. package/dist/log.d.ts +3 -0
  19. package/dist/log.js +10 -0
  20. package/dist/log.js.map +1 -0
  21. package/dist/mcp/agent-tools.d.ts +2 -0
  22. package/dist/mcp/agent-tools.js +66 -0
  23. package/dist/mcp/agent-tools.js.map +1 -0
  24. package/dist/mcp/dm-tools.d.ts +2 -0
  25. package/dist/mcp/dm-tools.js +54 -0
  26. package/dist/mcp/dm-tools.js.map +1 -0
  27. package/dist/mcp/federation-tools.d.ts +2 -0
  28. package/dist/mcp/federation-tools.js +44 -0
  29. package/dist/mcp/federation-tools.js.map +1 -0
  30. package/dist/mcp/server.d.ts +2 -0
  31. package/dist/mcp/server.js +62 -0
  32. package/dist/mcp/server.js.map +1 -0
  33. package/dist/mcp/task-tools.d.ts +2 -0
  34. package/dist/mcp/task-tools.js +128 -0
  35. package/dist/mcp/task-tools.js.map +1 -0
  36. package/dist/mcp/team-tools.d.ts +2 -0
  37. package/dist/mcp/team-tools.js +95 -0
  38. package/dist/mcp/team-tools.js.map +1 -0
  39. package/dist/models.d.ts +28 -10
  40. package/dist/models.js +1 -3
  41. package/dist/models.js.map +1 -1
  42. package/dist/server.d.ts +2 -3
  43. package/dist/server.js +56 -761
  44. package/dist/server.js.map +1 -1
  45. package/dist/sessions.d.ts +15 -0
  46. package/dist/sessions.js +107 -0
  47. package/dist/sessions.js.map +1 -0
  48. package/dist/tools/dm.d.ts +3 -2
  49. package/dist/tools/dm.js +35 -32
  50. package/dist/tools/dm.js.map +1 -1
  51. package/dist/tools/start.d.ts +3 -3
  52. package/dist/tools/start.js +18 -15
  53. package/dist/tools/start.js.map +1 -1
  54. package/dist/tools/task.d.ts +7 -2
  55. package/dist/tools/task.js +113 -15
  56. package/dist/tools/task.js.map +1 -1
  57. package/dist/tools/team.d.ts +62 -13
  58. package/dist/tools/team.js +102 -30
  59. package/dist/tools/team.js.map +1 -1
  60. package/package.json +4 -3
  61. package/dist/tools/room.d.ts +0 -39
  62. package/dist/tools/room.js +0 -35
  63. package/dist/tools/room.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,520 +1,19 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { createServer } from 'node:http';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
- import { createServer } from 'node:http';
6
- import { z } from 'zod';
7
- import { getAgentById, getAgentByName, touchAgent, initDB, getUnreadForAgent, setReadCursor, cleanupStaleTasks } from './db.js';
8
- import { handleStart } from './tools/start.js';
9
- import { handleDM, handleDMAsync } from './tools/dm.js';
10
- import { handleTaskCreate, handleTaskCreateAsync, handleTaskClaim, handleCheck, handleWorkflowPropose, handleWorkflowApprove, handleStepComplete, handleWorkflowReject } from './tools/task.js';
11
- import { handleEvents, handleList, handleInfo } from './tools/room.js';
12
- import { handleTeamCreate, handleTeamJoin, handleTeamList } from './tools/team.js';
13
- import * as db from './db.js';
14
- const LOG_PRIORITY = { error: 0, warn: 1, info: 2, debug: 3 };
15
- let logLevel = 'info';
16
- export function setLogLevel(level) { logLevel = level; }
17
- function log(level, msg) {
18
- if (LOG_PRIORITY[level] <= LOG_PRIORITY[logLevel]) {
19
- const prefix = level === 'info' ? '' : `[${level}] `;
20
- console.log(`${prefix}${msg}`);
21
- }
22
- }
23
- // sessionId → Session
24
- const sessions = {};
25
- // sessionId → agentId
26
- const sessionAgents = new Map();
27
- // agentId → Set<sessionId>
28
- const agentSessions = new Map();
29
- function bindSession(sessionId, agentId) {
30
- // Clean up old binding if this session was previously bound to a different agent
31
- const oldAgentId = sessionAgents.get(sessionId);
32
- if (oldAgentId && oldAgentId !== agentId) {
33
- const oldSet = agentSessions.get(oldAgentId);
34
- if (oldSet) {
35
- oldSet.delete(sessionId);
36
- if (oldSet.size === 0)
37
- agentSessions.delete(oldAgentId);
38
- }
39
- }
40
- sessionAgents.set(sessionId, agentId);
41
- let set = agentSessions.get(agentId);
42
- if (!set) {
43
- set = new Set();
44
- agentSessions.set(agentId, set);
45
- }
46
- set.add(sessionId);
47
- log("debug", `[bind] session=${sessionId} → agent=${agentId} (total sessions for agent: ${set.size})`);
48
- }
49
- function unbindSession(sessionId) {
50
- const agentId = sessionAgents.get(sessionId);
51
- if (agentId) {
52
- const set = agentSessions.get(agentId);
53
- if (set) {
54
- set.delete(sessionId);
55
- if (set.size === 0)
56
- agentSessions.delete(agentId);
57
- }
58
- sessionAgents.delete(sessionId);
59
- }
60
- }
61
- // --- Push notifications ---
62
- function notifyRoomMembers(roomId, excludeAgentId, message) {
63
- const members = db.getRoomMembers(roomId);
64
- log("debug", `[notify] room=${roomId} members=${members.join(',')} exclude=${excludeAgentId}`);
65
- for (const memberId of members) {
66
- if (memberId === excludeAgentId)
67
- continue;
68
- const sids = agentSessions.get(memberId);
69
- log("debug", `[notify] agent=${memberId} sessions=${sids ? [...sids].join(',') : 'NONE'}`);
70
- if (!sids)
71
- continue;
72
- for (const sid of sids) {
73
- const session = sessions[sid];
74
- if (!session) {
75
- log("warn", `[notify] session ${sid} NOT FOUND in sessions map`);
76
- continue;
77
- }
78
- try {
79
- // Logging notification (generic)
80
- session.server.sendLoggingMessage({
81
- level: 'info',
82
- data: message ?? `New event in room ${roomId}`,
83
- }, sid);
84
- // Resource updated notification (inbox changed)
85
- session.server.server.sendResourceUpdated({ uri: 'hive://inbox' });
86
- log("debug", `[notify] sent to session ${sid} OK (logging + resource updated)`);
87
- }
88
- catch (err) {
89
- log("warn", `[notify] failed to send to session ${sid}: ${err}`);
90
- }
91
- }
92
- }
93
- }
94
- function notifyAgents(agentIds, excludeAgentId, message) {
95
- for (const agentId of agentIds) {
96
- if (agentId === excludeAgentId)
97
- continue;
98
- const sids = agentSessions.get(agentId);
99
- if (!sids)
100
- continue;
101
- for (const sid of sids) {
102
- const session = sessions[sid];
103
- if (!session)
104
- continue;
105
- try {
106
- session.server.sendLoggingMessage({ level: 'info', data: message ?? 'New task event' }, sid);
107
- session.server.server.sendResourceUpdated({ uri: 'hive://inbox' });
108
- }
109
- catch { /* ignore */ }
110
- }
111
- }
112
- }
113
- function notifyTaskParticipants(taskId, excludeAgentId, message) {
114
- const task = db.getTaskById(taskId);
115
- if (!task)
116
- return;
117
- const participants = new Set();
118
- participants.add(task.creator_agent_id);
119
- if (task.assignee_agent_id)
120
- participants.add(task.assignee_agent_id);
121
- // Also notify workflow step assignees
122
- if (task.workflow_json) {
123
- try {
124
- const steps = JSON.parse(task.workflow_json);
125
- for (const step of steps) {
126
- for (const a of step.assignees || []) {
127
- if (a.startsWith('role:')) {
128
- const agent = db.findAgentByRole(a.slice(5));
129
- if (agent)
130
- participants.add(agent.id);
131
- }
132
- else {
133
- const agent = db.getAgentById(a) || db.getAgentByName(a);
134
- if (agent)
135
- participants.add(agent.id);
136
- }
137
- }
138
- }
139
- }
140
- catch { /* ignore */ }
141
- }
142
- notifyAgents([...participants], excludeAgentId, message);
143
- }
144
- // --- Agent resolution ---
145
- const asParam = z.string().optional().describe('Your agent name or ID (from hive.start)');
146
- function resolveAgent(extra, asValue) {
147
- const sessionId = extra?.sessionId;
148
- // Logged by caller, not here (too noisy from polling)
149
- // 1) Session binding
150
- if (sessionId) {
151
- const agentId = sessionAgents.get(sessionId);
152
- if (agentId) {
153
- const agent = getAgentById(agentId);
154
- if (agent) {
155
- touchAgent(agent.id);
156
- return agent;
157
- }
158
- }
159
- }
160
- // 2) `as` param fallback
161
- if (asValue) {
162
- const agent = getAgentById(asValue) || getAgentByName(asValue);
163
- if (agent) {
164
- touchAgent(agent.id);
165
- return agent;
166
- }
167
- }
168
- // silent — too noisy from polling/stateless requests
169
- return null;
170
- }
171
- function authError() {
172
- return {
173
- content: [{ type: 'text', text: 'Error: Not authenticated. Call hive.start first, or pass `as` with your agent name.' }],
174
- isError: true,
175
- };
176
- }
177
- // --- MCP Server factory (one per session) ---
178
- function createMcpServer() {
179
- const mcp = new McpServer({
180
- name: 'kitty-hive',
181
- version: '0.1.0',
182
- }, {
183
- capabilities: {
184
- logging: {},
185
- resources: { subscribe: true },
186
- },
187
- instructions: [
188
- 'kitty-hive is a multi-agent collaboration server.',
189
- '',
190
- '## Getting Started',
191
- 'Call hive.start with your name to register. All tools except hive.start and hive.check require `as` param.',
192
- '',
193
- '## Task Rules (IMPORTANT)',
194
- 'When you receive a task (via hive.task or task-assigned notification):',
195
- '1. Analyze the task and propose a workflow using hive.workflow.propose',
196
- '2. The workflow should have clear steps with assignees from your team',
197
- '3. Wait for the creator to approve (hive.workflow.approve) before starting',
198
- '4. Execute each step, call hive.workflow.step.complete when done',
199
- '5. If you see an unassigned task, claim it with hive.task.claim',
200
- '',
201
- 'When you CREATE a task:',
202
- '- Always specify `to` (agent name or "role:xxx") to assign it',
203
- '- Use `input.description` to describe what needs to be done',
204
- '- The assignee will propose a workflow — you MUST show the proposal to the user and get explicit confirmation before calling hive.workflow.approve',
205
- '- NEVER auto-approve a workflow without asking the user first',
206
- '',
207
- '## Rooms',
208
- '- lobby: global, everyone auto-joins',
209
- '- dm: private 1:1 messaging (auto-created by hive.dm)',
210
- '- team: group collaboration (hive.team.create/join/list)',
211
- '',
212
- '## Shared Artifacts',
213
- 'Use ~/.kitty-hive/artifacts/<task_id>/ for cross-agent file exchange.',
214
- ].join('\n'),
215
- });
216
- mcp.tool('hive.start', 'Register as an agent and join the lobby. Returns your agent_id. Session is auto-bound for push notifications.', {
217
- name: z.string().optional().describe('Display name (random if omitted)'),
218
- roles: z.string().optional().describe('Comma-separated roles: ux,frontend,backend'),
219
- tool: z.string().optional().describe('Agent tool: claude, codex, shell'),
220
- expertise: z.string().optional().describe('Free-text expertise description'),
221
- }, async (params, extra) => {
222
- const result = handleStart(params);
223
- if (extra.sessionId)
224
- bindSession(extra.sessionId, result.agent_id);
225
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
226
- });
227
- mcp.tool('hive.dm', 'Send a direct message. Use "agent@node" for cross-node DM (federation).', {
228
- as: asParam,
229
- to: z.string().describe('Target agent name, or "agent@node" for federation'),
230
- content: z.string().describe('Message content'),
231
- }, async (params, extra) => {
232
- const agent = resolveAgent(extra, params.as);
233
- if (!agent)
234
- return authError();
235
- const result = await handleDMAsync(agent.id, params);
236
- if (!result.federated) {
237
- notifyRoomMembers(result.room_id, agent.id, JSON.stringify({
238
- type: 'dm', from: agent.display_name, room_id: result.room_id,
239
- preview: params.content && params.content.length > 200 ? params.content.slice(0, 200) + ' [summary]' : params.content,
240
- }));
241
- }
242
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
243
- });
244
- mcp.tool('hive.task', 'Create a task and delegate. Use "agent@node" for cross-node delegation.', {
245
- as: asParam,
246
- to: z.string().optional().describe('Target: agent name, "role:ux", or "agent@node" for federation'),
247
- title: z.string().describe('Task title'),
248
- input: z.record(z.string(), z.unknown()).optional().describe('Structured task input (optional)'),
249
- }, async (params, extra) => {
250
- const agent = resolveAgent(extra, params.as);
251
- if (!agent)
252
- return authError();
253
- const result = await handleTaskCreateAsync(agent.id, { to: params.to, title: params.title, input: params.input });
254
- if (result.assignee) {
255
- notifyAgents([result.assignee.id], agent.id, JSON.stringify({
256
- type: 'task-assigned', from: agent.display_name,
257
- task_id: result.task_id, title: params.title,
258
- }));
259
- }
260
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
261
- });
262
- mcp.tool('hive.task.claim', 'Claim an unassigned task. Only works on tasks with status "created" and no assignee.', {
263
- as: asParam,
264
- task_id: z.string().describe('Task ID to claim'),
265
- }, async (params, extra) => {
266
- const agent = resolveAgent(extra, params.as);
267
- if (!agent)
268
- return authError();
269
- const result = handleTaskClaim(params.task_id, agent.id);
270
- notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
271
- type: 'task-claimed', from: agent.display_name,
272
- task_id: params.task_id, preview: `${agent.display_name} claimed: ${result.title}`,
273
- }));
274
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
275
- });
276
- mcp.tool('hive.tasks', 'List tasks. Shows all tasks you created or are assigned to, grouped by status.', {
277
- as: asParam,
278
- status: z.string().optional().describe('Filter by status: created, proposing, approved, in_progress, completed, failed, canceled'),
279
- }, async (params, extra) => {
280
- const agent = resolveAgent(extra, params.as);
281
- if (!agent)
282
- return authError();
283
- const tasks = db.getAgentTasks(agent.id, params.status);
284
- const board = tasks.map(t => ({
285
- task_id: t.id,
286
- title: t.title,
287
- status: t.status,
288
- creator: db.getAgentById(t.creator_agent_id)?.display_name ?? 'unknown',
289
- assignee: t.assignee_agent_id ? (db.getAgentById(t.assignee_agent_id)?.display_name ?? 'unknown') : 'unassigned',
290
- current_step: t.current_step,
291
- has_workflow: !!t.workflow_json,
292
- created_at: t.created_at,
293
- }));
294
- return { content: [{ type: 'text', text: JSON.stringify(board, null, 2) }] };
295
- });
296
- mcp.tool('hive.check', 'Check the current state of a task by task ID.', { task_id: z.string().describe('Task ID to check') }, async (params) => {
297
- const result = handleCheck(params);
298
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
299
- });
300
- mcp.tool('hive.room.events', 'Fetch events from a room. Use "since" for incremental polling.', {
301
- as: asParam,
302
- room_id: z.string().describe('Room ID'),
303
- since: z.number().optional().describe('Return events after this seq number'),
304
- limit: z.number().optional().describe('Max events to return (default 50, max 200)'),
305
- }, async (params, extra) => {
306
- const agent = resolveAgent(extra, params.as);
307
- if (!agent)
308
- return authError();
309
- const result = handleEvents(agent.id, {
310
- room_id: params.room_id, since: params.since, limit: params.limit,
311
- });
312
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
313
- });
314
- mcp.tool('hive.room.list', 'List rooms you are a member of.', {
315
- as: asParam,
316
- kind: z.string().optional().describe('Filter by room kind: dm, team, lobby'),
317
- active_only: z.boolean().optional().describe('Only show active (non-closed) rooms (default true)'),
318
- }, async (params, extra) => {
319
- const agent = resolveAgent(extra, params.as);
320
- if (!agent)
321
- return authError();
322
- const result = handleList(agent.id, { kind: params.kind, active_only: params.active_only });
323
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
324
- });
325
- mcp.tool('hive.room.info', 'Get detailed information about a room including members and recent events.', {
326
- as: asParam,
327
- room_id: z.string().describe('Room ID'),
328
- }, async (params, extra) => {
329
- const agent = resolveAgent(extra, params.as);
330
- if (!agent)
331
- return authError();
332
- const result = handleInfo(agent.id, { room_id: params.room_id });
333
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
334
- });
335
- mcp.tool('hive.inbox', 'Check your inbox for unread messages and events across all rooms. Marks returned messages as read.', {
336
- as: asParam,
337
- }, async (params, extra) => {
338
- const agent = resolveAgent(extra, params.as);
339
- if (!agent)
340
- return authError();
341
- const unread = getUnreadForAgent(agent.id);
342
- // Mark as read
343
- for (const u of unread) {
344
- if (u.latest.length > 0) {
345
- if (u.type === 'room') {
346
- const events = db.getRoomEvents(u.id, 0, 10000);
347
- if (events.length > 0)
348
- setReadCursor(agent.id, 'room', u.id, events[events.length - 1].seq);
349
- }
350
- else {
351
- const events = db.getTaskEvents(u.id, 0, 100);
352
- if (events.length > 0)
353
- setReadCursor(agent.id, 'task', u.id, events[events.length - 1].seq);
354
- }
355
- }
356
- }
357
- if (unread.length === 0) {
358
- return { content: [{ type: 'text', text: '[]' }] };
359
- }
360
- return { content: [{ type: 'text', text: JSON.stringify(unread) }] };
361
- });
362
- // --- Workflow tools ---
363
- mcp.tool('hive.workflow.propose', 'Propose a workflow for a task. The task creator must approve before it starts.', {
364
- as: asParam,
365
- task_id: z.string().describe('Task ID'),
366
- workflow: z.array(z.object({
367
- step: z.number(),
368
- title: z.string(),
369
- assignees: z.array(z.string()).describe('Agent names or "role:xxx"'),
370
- action: z.string(),
371
- completion: z.enum(['all', 'any']).default('all'),
372
- on_reject: z.string().optional().describe('"revise" or "back:N"'),
373
- })).describe('Workflow steps'),
374
- }, async (params, extra) => {
375
- const agent = resolveAgent(extra, params.as);
376
- if (!agent)
377
- return authError();
378
- handleWorkflowPropose(params.task_id, agent.id, params.workflow);
379
- notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
380
- type: 'task-propose', from: agent.display_name,
381
- task_id: params.task_id, preview: `Workflow proposed: ${params.workflow.length} steps`,
382
- }));
383
- return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, status: 'proposing', steps: params.workflow.length }) }] };
384
- });
385
- mcp.tool('hive.workflow.approve', 'Approve a proposed workflow. Automatically starts step 1.', {
386
- as: asParam,
387
- task_id: z.string().describe('Task ID'),
388
- }, async (params, extra) => {
389
- const agent = resolveAgent(extra, params.as);
390
- if (!agent)
391
- return authError();
392
- const action = handleWorkflowApprove(params.task_id, agent.id);
393
- notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
394
- type: 'step-start', from: agent.display_name,
395
- task_id: params.task_id, preview: `Step 1 started, assignees: ${action.assignees?.join(', ')}`,
396
- }));
397
- return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, status: 'approved', action }) }] };
398
- });
399
- mcp.tool('hive.workflow.step.complete', 'Mark your part of the current step as complete.', {
400
- as: asParam,
401
- task_id: z.string().describe('Task ID'),
402
- step: z.number().describe('Step number'),
403
- result: z.string().optional().describe('Result description'),
404
- }, async (params, extra) => {
405
- const agent = resolveAgent(extra, params.as);
406
- if (!agent)
407
- return authError();
408
- const action = handleStepComplete(params.task_id, agent.id, params.step, params.result);
409
- const resultPreview = params.result && params.result.length > 200 ? params.result.slice(0, 200) + ' [summary]' : params.result;
410
- const msg = action?.type === 'task-complete' ? 'Task completed!'
411
- : action?.type === 'step-start' ? `Step ${action.step} started. Previous step result: ${resultPreview || 'none'}`
412
- : `Step ${params.step} progress recorded, waiting for others`;
413
- notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
414
- type: action?.type || 'step-complete', from: agent.display_name,
415
- task_id: params.task_id, preview: msg,
416
- }));
417
- return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, action: action || 'waiting' }) }] };
418
- });
419
- mcp.tool('hive.workflow.reject', 'Reject the current step output. Sends the task back to a previous step.', {
420
- as: asParam,
421
- task_id: z.string().describe('Task ID'),
422
- step: z.number().describe('Step number being rejected'),
423
- reason: z.string().optional().describe('Rejection reason'),
424
- }, async (params, extra) => {
425
- const agent = resolveAgent(extra, params.as);
426
- if (!agent)
427
- return authError();
428
- const action = handleWorkflowReject(params.task_id, agent.id, params.step, params.reason);
429
- notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
430
- type: 'task-reject', from: agent.display_name,
431
- task_id: params.task_id, preview: `Step ${params.step} rejected → back to step ${action.step}`,
432
- }));
433
- return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, action }) }] };
434
- });
435
- // --- Team tools ---
436
- mcp.tool('hive.team.create', 'Create a team room for group collaboration.', {
437
- as: asParam,
438
- name: z.string().describe('Team name'),
439
- }, async (params, extra) => {
440
- const agent = resolveAgent(extra, params.as);
441
- if (!agent)
442
- return authError();
443
- const result = handleTeamCreate(agent.id, { name: params.name });
444
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
445
- });
446
- mcp.tool('hive.team.join', 'Join an existing team room by name or ID.', {
447
- as: asParam,
448
- room_id: z.string().optional().describe('Team room ID'),
449
- name: z.string().optional().describe('Team name'),
450
- }, async (params, extra) => {
451
- const agent = resolveAgent(extra, params.as);
452
- if (!agent)
453
- return authError();
454
- const result = handleTeamJoin(agent.id, { room_id: params.room_id, name: params.name });
455
- notifyRoomMembers(result.room_id, agent.id, JSON.stringify({
456
- type: 'join', from: agent.display_name, room_id: result.room_id,
457
- preview: `${agent.display_name} joined the team`,
458
- }));
459
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
460
- });
461
- mcp.tool('hive.team.list', 'List all available teams.', {}, async () => {
462
- const result = handleTeamList();
463
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
464
- });
465
- // --- Federation MCP tools ---
466
- mcp.tool('hive.peers', 'List connected federation peers.', {}, async () => {
467
- const peers = db.listPeers();
468
- const result = peers.map(p => ({
469
- name: p.name, url: p.url, status: p.status,
470
- exposed: p.exposed, last_seen: p.last_seen,
471
- }));
472
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
473
- });
474
- mcp.tool('hive.remote.agents', 'List agents on a remote peer node.', {
475
- peer: z.string().describe('Peer node name'),
476
- }, async (params) => {
477
- const peer = db.getPeerByName(params.peer);
478
- if (!peer)
479
- throw new Error(`Peer "${params.peer}" not found`);
480
- const res = await fetch(peer.url.replace('/mcp', '/federation/agents'), {
481
- method: 'POST',
482
- headers: {
483
- 'Content-Type': 'application/json',
484
- 'Authorization': `Bearer ${peer.secret}`,
485
- 'X-Hive-Peer': peer.name,
486
- },
487
- });
488
- if (!res.ok)
489
- throw new Error(`Failed to fetch agents from peer "${params.peer}"`);
490
- const data = await res.json();
491
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
492
- });
493
- // --- Inbox resource ---
494
- mcp.resource('inbox', 'hive://inbox', { mimeType: 'application/json', description: 'Your unread messages and events across all rooms' }, async (uri, extra) => {
495
- const sessionId = extra?.sessionId;
496
- let agentId;
497
- if (sessionId)
498
- agentId = sessionAgents.get(sessionId);
499
- if (!agentId) {
500
- return { contents: [{ uri: uri.href, text: '{"error":"Not authenticated. Call hive.start first."}' }] };
501
- }
502
- const unread = getUnreadForAgent(agentId);
503
- return {
504
- contents: [{
505
- uri: uri.href,
506
- text: JSON.stringify(unread.length > 0 ? unread : { message: 'No unread messages.' }),
507
- }],
508
- };
509
- });
510
- return mcp;
511
- }
512
- // --- HTTP server ---
5
+ import { initDB, cleanupStaleTasks } from './db.js';
6
+ import { log, setLogLevel } from './log.js';
7
+ import { sessions, unbindSession, sessionAgents, activeSSE } from './sessions.js';
8
+ import { createMcpServer } from './mcp/server.js';
9
+ import { handleFederation, cleanupOldFiles, getNodeName } from './federation-http.js';
10
+ import { startHeartbeat } from './federation-heartbeat.js';
11
+ export { setLogLevel };
513
12
  export async function startServer(port, dbPath) {
514
13
  initDB(dbPath);
515
14
  const httpServer = createServer(async (req, res) => {
516
15
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
517
- // --- Federation routes ---
16
+ // Federation routes
518
17
  if (url.pathname.startsWith('/federation/')) {
519
18
  await handleFederation(req, res, url);
520
19
  return;
@@ -524,9 +23,7 @@ export async function startServer(port, dbPath) {
524
23
  res.end(JSON.stringify({ error: 'Not found. MCP endpoint is at /mcp' }));
525
24
  return;
526
25
  }
527
- // Don't log heartbeat/polling requests
528
- const isQuiet = req.method === 'GET'; // SSE keepalive
529
- // --- GET: SSE stream ---
26
+ // GET: SSE stream
530
27
  if (req.method === 'GET') {
531
28
  const sid = req.headers['mcp-session-id'];
532
29
  if (!sid || !sessions[sid]) {
@@ -534,11 +31,16 @@ export async function startServer(port, dbPath) {
534
31
  res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
535
32
  return;
536
33
  }
537
- log("debug", `[sse] opening SSE stream for session=${sid} agent=${sessionAgents.get(sid) || 'unbound'}`);
34
+ log('info', `[sse] opening stream for session=${sid} agent=${sessionAgents.get(sid) || 'unbound'}`);
35
+ activeSSE.add(sid);
36
+ res.on('close', () => {
37
+ activeSSE.delete(sid);
38
+ log('info', `[sse] stream closed for session=${sid}`);
39
+ });
538
40
  await sessions[sid].transport.handleRequest(req, res);
539
41
  return;
540
42
  }
541
- // --- DELETE: session termination ---
43
+ // DELETE: session termination
542
44
  if (req.method === 'DELETE') {
543
45
  const sid = req.headers['mcp-session-id'];
544
46
  if (!sid || !sessions[sid]) {
@@ -546,12 +48,12 @@ export async function startServer(port, dbPath) {
546
48
  res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
547
49
  return;
548
50
  }
549
- log("info", `[session] DELETE session=${sid}`);
51
+ log('info', `[session] DELETE session=${sid}`);
550
52
  unbindSession(sid);
551
53
  await sessions[sid].transport.handleRequest(req, res);
552
54
  return;
553
55
  }
554
- // --- POST: JSON-RPC ---
56
+ // POST: JSON-RPC
555
57
  try {
556
58
  const chunks = [];
557
59
  for await (const chunk of req)
@@ -562,26 +64,37 @@ export async function startServer(port, dbPath) {
562
64
  const tool = body?.params?.name || '';
563
65
  const isHeartbeat = tool === 'hive.inbox' || method === 'notifications/initialized';
564
66
  if (!isHeartbeat) {
565
- log("info", `[rpc] method=${method} sid=${sid || 'none'} tool=${tool || '-'}`);
67
+ log('info', `[rpc] method=${method} sid=${sid || 'none'} tool=${tool || '-'}`);
566
68
  }
567
69
  if (sid && sessions[sid]) {
568
- // Existing session
569
70
  await sessions[sid].transport.handleRequest(req, res, body);
570
71
  }
72
+ else if (sid && !sessions[sid]) {
73
+ // Stale session id (e.g. after server restart) — reject with 404 so the client
74
+ // knows to re-initialize. Do NOT fall through to stateless: that would create
75
+ // a transport with no sessionId, breaking session binding for push notifications.
76
+ log('info', `[session] stale sid=${sid} method=${method} → 404 (client should re-init)`);
77
+ res.writeHead(404, { 'Content-Type': 'application/json' });
78
+ res.end(JSON.stringify({
79
+ jsonrpc: '2.0',
80
+ error: { code: -32001, message: 'Session not found. Re-initialize.' },
81
+ id: body?.id ?? null,
82
+ }));
83
+ return;
84
+ }
571
85
  else if (!sid && isInitializeRequest(body)) {
572
- // New session: create server + transport
573
86
  const server = createMcpServer();
574
87
  const transport = new StreamableHTTPServerTransport({
575
88
  sessionIdGenerator: () => randomUUID(),
576
89
  onsessioninitialized: (newSid) => {
577
- log("info", `[session] new session created: ${newSid}`);
90
+ log('info', `[session] new session: ${newSid}`);
578
91
  sessions[newSid] = { transport, server };
579
92
  },
580
93
  });
581
94
  transport.onclose = () => {
582
95
  const tSid = transport.sessionId;
583
96
  if (tSid) {
584
- log("debug", `[session] transport closed: ${tSid}`);
97
+ log('debug', `[session] transport closed: ${tSid}`);
585
98
  unbindSession(tSid);
586
99
  delete sessions[tSid];
587
100
  }
@@ -590,10 +103,9 @@ export async function startServer(port, dbPath) {
590
103
  await transport.handleRequest(req, res, body);
591
104
  }
592
105
  else {
593
- // Stateless fallback: create temporary server + transport per request
594
- // This supports clients that don't maintain sessions (e.g. HTTP adapters)
106
+ // Stateless fallback (per-request server, no session)
595
107
  if (!isHeartbeat)
596
- log("debug", `[rpc] stateless request: method=${method} tool=${tool || '-'}`);
108
+ log('debug', `[rpc] stateless: method=${method} tool=${tool || '-'}`);
597
109
  const server = createMcpServer();
598
110
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
599
111
  await server.connect(transport);
@@ -602,7 +114,7 @@ export async function startServer(port, dbPath) {
602
114
  }
603
115
  }
604
116
  catch (error) {
605
- console.error('[rpc] Error handling MCP request:', error);
117
+ console.error('[rpc] error:', error);
606
118
  if (!res.headersSent) {
607
119
  res.writeHead(500, { 'Content-Type': 'application/json' });
608
120
  res.end(JSON.stringify({
@@ -615,15 +127,31 @@ export async function startServer(port, dbPath) {
615
127
  });
616
128
  httpServer.listen(port, () => {
617
129
  console.log(`🐝 kitty-hive listening on http://localhost:${port}/mcp`);
130
+ console.log(` Node: ${getNodeName()}`);
618
131
  console.log(` Database: ${dbPath || '~/.kitty-hive/hive.db'}`);
619
132
  console.log(` Mode: stateful (SSE push enabled)`);
620
133
  });
621
- // Cleanup stale task rooms every hour
622
134
  setInterval(() => {
623
135
  const count = cleanupStaleTasks(7);
624
136
  if (count > 0)
625
- log("info", `[cleanup] removed ${count} stale tasks`);
137
+ log('info', `[cleanup] removed ${count} stale tasks`);
626
138
  }, 60 * 60 * 1000);
139
+ // Sweep old federation files at boot, then daily
140
+ try {
141
+ const swept = cleanupOldFiles(7);
142
+ if (swept.removed > 0)
143
+ log('info', `[cleanup] removed ${swept.removed} old federation files (kept ${swept.kept})`);
144
+ }
145
+ catch (err) {
146
+ log('warn', `[cleanup] file sweep failed: ${err.message ?? err}`);
147
+ }
148
+ setInterval(() => {
149
+ try {
150
+ cleanupOldFiles(7);
151
+ }
152
+ catch { /* ignore */ }
153
+ }, 24 * 60 * 60 * 1000);
154
+ startHeartbeat(60 * 1000);
627
155
  process.on('SIGINT', async () => {
628
156
  for (const sid in sessions) {
629
157
  try {
@@ -635,237 +163,4 @@ export async function startServer(port, dbPath) {
635
163
  process.exit(0);
636
164
  });
637
165
  }
638
- // --- Federation handler ---
639
- import { mkdirSync as mkdirSyncFs, writeFileSync as writeFileSyncFs, readFileSync as readFileSyncFs, existsSync as existsSyncFs } from 'node:fs';
640
- import { join as joinPath } from 'node:path';
641
- import { homedir as homedirFn } from 'node:os';
642
- function authenticatePeer(req) {
643
- const authHeader = req.headers.authorization;
644
- if (!authHeader)
645
- return null;
646
- const match = authHeader.match(/^Bearer\s+(\S+)$/i);
647
- if (!match)
648
- return null;
649
- const peer = db.getPeerBySecret(match[1]);
650
- if (peer)
651
- db.touchPeer(peer.name);
652
- return peer ?? null;
653
- }
654
- async function readBody(req) {
655
- const chunks = [];
656
- for await (const chunk of req)
657
- chunks.push(chunk);
658
- return Buffer.concat(chunks).toString();
659
- }
660
- function filesDir() {
661
- const dir = joinPath(homedirFn(), '.kitty-hive', 'files');
662
- mkdirSyncFs(dir, { recursive: true });
663
- return dir;
664
- }
665
- async function handleFederation(req, res, url) {
666
- const peer = authenticatePeer(req);
667
- if (!peer) {
668
- res.writeHead(401, { 'Content-Type': 'application/json' });
669
- res.end(JSON.stringify({ error: 'Unauthorized' }));
670
- return;
671
- }
672
- const peerName = req.headers['x-hive-peer'] || peer.name;
673
- log("info", `[federation] ${req.method} ${url.pathname} from peer=${peerName}`);
674
- // --- GET /federation/agents ---
675
- if (url.pathname === '/federation/agents' && req.method === 'POST') {
676
- const exposed = peer.exposed ? peer.exposed.split(',').map(s => s.trim()).filter(Boolean) : [];
677
- const agents = exposed.map(name => {
678
- const agent = db.getAgentByName(name);
679
- return agent ? { name: agent.display_name, roles: agent.roles, status: agent.status } : null;
680
- }).filter(Boolean);
681
- res.writeHead(200, { 'Content-Type': 'application/json' });
682
- res.end(JSON.stringify({ node: getNodeName(), agents }));
683
- return;
684
- }
685
- // --- POST /federation/dm ---
686
- if (url.pathname === '/federation/dm' && req.method === 'POST') {
687
- const body = JSON.parse(await readBody(req));
688
- const { from, to, content, file_id } = body;
689
- if (!from || !to || !content) {
690
- res.writeHead(400, { 'Content-Type': 'application/json' });
691
- res.end(JSON.stringify({ error: 'Missing from, to, or content' }));
692
- return;
693
- }
694
- // Check if target agent is exposed to this peer
695
- if (!db.isPeerExposed(peer.name, to)) {
696
- res.writeHead(403, { 'Content-Type': 'application/json' });
697
- res.end(JSON.stringify({ error: `Agent "${to}" is not accessible` }));
698
- return;
699
- }
700
- // Create or find remote agent placeholder
701
- const remoteAgentName = `${from}`;
702
- let remoteAgent = db.getAgentByName(remoteAgentName);
703
- if (!remoteAgent) {
704
- remoteAgent = db.createAgent(remoteAgentName, 'remote', `peer:${peerName}`, '');
705
- }
706
- // Deliver DM
707
- const targetAgent = db.getAgentByName(to);
708
- if (!targetAgent) {
709
- res.writeHead(404, { 'Content-Type': 'application/json' });
710
- res.end(JSON.stringify({ error: `Agent "${to}" not found` }));
711
- return;
712
- }
713
- const msgContent = file_id ? `${content}\n\n[file: ${file_id}]` : content;
714
- const result = handleDM(remoteAgent.id, { to, content: msgContent });
715
- // Notify local agent
716
- notifyRoomMembers(result.room_id, remoteAgent.id, JSON.stringify({
717
- type: 'dm', from: remoteAgentName, room_id: result.room_id,
718
- preview: content.length > 200 ? content.slice(0, 200) + ' [summary]' : content,
719
- }));
720
- res.writeHead(200, { 'Content-Type': 'application/json' });
721
- res.end(JSON.stringify({ delivered: true, event_id: result.event_id }));
722
- return;
723
- }
724
- // --- POST /federation/file (upload) ---
725
- if (url.pathname === '/federation/file' && req.method === 'POST') {
726
- const chunks = [];
727
- for await (const chunk of req)
728
- chunks.push(chunk);
729
- const data = Buffer.concat(chunks);
730
- const filename = req.headers['x-filename'] || 'upload';
731
- const fileId = 'f_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
732
- const fileDir = joinPath(filesDir(), fileId);
733
- mkdirSyncFs(fileDir, { recursive: true });
734
- writeFileSyncFs(joinPath(fileDir, filename), data);
735
- log("info", `[federation] file received: ${fileId}/${filename} (${data.length} bytes)`);
736
- res.writeHead(200, { 'Content-Type': 'application/json' });
737
- res.end(JSON.stringify({ file_id: fileId, filename, size: data.length }));
738
- return;
739
- }
740
- // --- GET /federation/file/:id ---
741
- if (url.pathname.startsWith('/federation/file/') && req.method === 'GET') {
742
- const fileId = url.pathname.split('/').pop();
743
- if (!fileId) {
744
- res.writeHead(400, { 'Content-Type': 'application/json' });
745
- res.end(JSON.stringify({ error: 'Missing file ID' }));
746
- return;
747
- }
748
- const fileDir = joinPath(filesDir(), fileId);
749
- if (!existsSyncFs(fileDir)) {
750
- res.writeHead(404, { 'Content-Type': 'application/json' });
751
- res.end(JSON.stringify({ error: 'File not found' }));
752
- return;
753
- }
754
- // Read first file in directory
755
- const { readdirSync } = await import('node:fs');
756
- const files = readdirSync(fileDir);
757
- if (files.length === 0) {
758
- res.writeHead(404, { 'Content-Type': 'application/json' });
759
- res.end(JSON.stringify({ error: 'File not found' }));
760
- return;
761
- }
762
- const filePath = joinPath(fileDir, files[0]);
763
- const fileData = readFileSyncFs(filePath);
764
- res.writeHead(200, {
765
- 'Content-Type': 'application/octet-stream',
766
- 'Content-Disposition': `attachment; filename="${files[0]}"`,
767
- });
768
- res.end(fileData);
769
- return;
770
- }
771
- // --- POST /federation/task ---
772
- if (url.pathname === '/federation/task' && req.method === 'POST') {
773
- const body = JSON.parse(await readBody(req));
774
- const { from, to, title, input } = body;
775
- if (!from || !to || !title) {
776
- res.writeHead(400, { 'Content-Type': 'application/json' });
777
- res.end(JSON.stringify({ error: 'Missing from, to, or title' }));
778
- return;
779
- }
780
- if (!db.isPeerExposed(peer.name, to)) {
781
- res.writeHead(403, { 'Content-Type': 'application/json' });
782
- res.end(JSON.stringify({ error: `Agent "${to}" is not accessible` }));
783
- return;
784
- }
785
- // Create remote agent placeholder
786
- let remoteAgent = db.getAgentByName(from);
787
- if (!remoteAgent) {
788
- remoteAgent = db.createAgent(from, 'remote', `peer:${peerName}`, '');
789
- }
790
- const targetAgent = db.getAgentByName(to);
791
- if (!targetAgent) {
792
- res.writeHead(404, { 'Content-Type': 'application/json' });
793
- res.end(JSON.stringify({ error: `Agent "${to}" not found` }));
794
- return;
795
- }
796
- const result = handleTaskCreate(remoteAgent.id, { to, title, input });
797
- // Notify assignee
798
- notifyTaskParticipants(result.task_id, remoteAgent.id, JSON.stringify({
799
- type: 'task-assigned', from, task_id: result.task_id, preview: title,
800
- }));
801
- res.writeHead(200, { 'Content-Type': 'application/json' });
802
- res.end(JSON.stringify({ task_id: result.task_id, status: result.status }));
803
- return;
804
- }
805
- // --- POST /federation/task/event ---
806
- if (url.pathname === '/federation/task/event' && req.method === 'POST') {
807
- const body = JSON.parse(await readBody(req));
808
- const { from, task_id, type, workflow, step, reason, result: stepResult } = body;
809
- if (!from || !task_id || !type) {
810
- res.writeHead(400, { 'Content-Type': 'application/json' });
811
- res.end(JSON.stringify({ error: 'Missing from, task_id, or type' }));
812
- return;
813
- }
814
- let remoteAgent = db.getAgentByName(from);
815
- if (!remoteAgent) {
816
- remoteAgent = db.createAgent(from, 'remote', `peer:${peerName}`, '');
817
- }
818
- const task = db.getTaskById(task_id);
819
- if (!task) {
820
- res.writeHead(404, { 'Content-Type': 'application/json' });
821
- res.end(JSON.stringify({ error: `Task not found: ${task_id}` }));
822
- return;
823
- }
824
- try {
825
- let action = null;
826
- switch (type) {
827
- case 'task-propose':
828
- handleWorkflowPropose(task_id, remoteAgent.id, workflow);
829
- break;
830
- case 'task-approve':
831
- action = handleWorkflowApprove(task_id, remoteAgent.id);
832
- break;
833
- case 'step-complete':
834
- action = handleStepComplete(task_id, remoteAgent.id, step, stepResult);
835
- break;
836
- case 'task-reject':
837
- action = handleWorkflowReject(task_id, remoteAgent.id, step, reason);
838
- break;
839
- default:
840
- res.writeHead(400, { 'Content-Type': 'application/json' });
841
- res.end(JSON.stringify({ error: `Unknown event type: ${type}` }));
842
- return;
843
- }
844
- notifyTaskParticipants(task_id, remoteAgent.id, JSON.stringify({
845
- type, from, task_id, preview: `${type} from ${from}`,
846
- }));
847
- res.writeHead(200, { 'Content-Type': 'application/json' });
848
- res.end(JSON.stringify({ ok: true, action }));
849
- }
850
- catch (err) {
851
- res.writeHead(400, { 'Content-Type': 'application/json' });
852
- res.end(JSON.stringify({ error: err.message }));
853
- }
854
- return;
855
- }
856
- res.writeHead(404, { 'Content-Type': 'application/json' });
857
- res.end(JSON.stringify({ error: 'Unknown federation endpoint' }));
858
- }
859
- function getNodeName() {
860
- try {
861
- const configPath = joinPath(homedirFn(), '.kitty-hive', 'config.json');
862
- if (existsSyncFs(configPath)) {
863
- const config = JSON.parse(readFileSyncFs(configPath, 'utf8'));
864
- if (config.name)
865
- return config.name;
866
- }
867
- }
868
- catch { /* ignore */ }
869
- return require('os').hostname().split('.')[0];
870
- }
871
166
  //# sourceMappingURL=server.js.map