kitty-hive 0.1.0 → 0.2.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 (60) hide show
  1. package/README.md +106 -129
  2. package/README.zh.md +136 -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 +32 -16
  8. package/dist/db.js +210 -113
  9. package/dist/db.js.map +1 -1
  10. package/dist/federation-http.d.ts +2 -0
  11. package/dist/federation-http.js +233 -0
  12. package/dist/federation-http.js.map +1 -0
  13. package/dist/index.js +82 -79
  14. package/dist/index.js.map +1 -1
  15. package/dist/log.d.ts +3 -0
  16. package/dist/log.js +10 -0
  17. package/dist/log.js.map +1 -0
  18. package/dist/mcp/agent-tools.d.ts +2 -0
  19. package/dist/mcp/agent-tools.js +66 -0
  20. package/dist/mcp/agent-tools.js.map +1 -0
  21. package/dist/mcp/dm-tools.d.ts +2 -0
  22. package/dist/mcp/dm-tools.js +54 -0
  23. package/dist/mcp/dm-tools.js.map +1 -0
  24. package/dist/mcp/federation-tools.d.ts +2 -0
  25. package/dist/mcp/federation-tools.js +30 -0
  26. package/dist/mcp/federation-tools.js.map +1 -0
  27. package/dist/mcp/server.d.ts +2 -0
  28. package/dist/mcp/server.js +62 -0
  29. package/dist/mcp/server.js.map +1 -0
  30. package/dist/mcp/task-tools.d.ts +2 -0
  31. package/dist/mcp/task-tools.js +128 -0
  32. package/dist/mcp/task-tools.js.map +1 -0
  33. package/dist/mcp/team-tools.d.ts +2 -0
  34. package/dist/mcp/team-tools.js +95 -0
  35. package/dist/mcp/team-tools.js.map +1 -0
  36. package/dist/models.d.ts +22 -10
  37. package/dist/models.js +1 -3
  38. package/dist/models.js.map +1 -1
  39. package/dist/server.d.ts +2 -3
  40. package/dist/server.js +38 -761
  41. package/dist/server.js.map +1 -1
  42. package/dist/sessions.d.ts +15 -0
  43. package/dist/sessions.js +107 -0
  44. package/dist/sessions.js.map +1 -0
  45. package/dist/tools/dm.d.ts +3 -2
  46. package/dist/tools/dm.js +14 -29
  47. package/dist/tools/dm.js.map +1 -1
  48. package/dist/tools/start.d.ts +3 -3
  49. package/dist/tools/start.js +18 -15
  50. package/dist/tools/start.js.map +1 -1
  51. package/dist/tools/task.d.ts +1 -1
  52. package/dist/tools/task.js +6 -5
  53. package/dist/tools/task.js.map +1 -1
  54. package/dist/tools/team.d.ts +62 -13
  55. package/dist/tools/team.js +102 -30
  56. package/dist/tools/team.js.map +1 -1
  57. package/package.json +2 -2
  58. package/dist/tools/room.d.ts +0 -39
  59. package/dist/tools/room.js +0 -35
  60. package/dist/tools/room.js.map +0 -1
package/dist/server.js CHANGED
@@ -1,520 +1,18 @@
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 } from './federation-http.js';
10
+ export { setLogLevel };
513
11
  export async function startServer(port, dbPath) {
514
12
  initDB(dbPath);
515
13
  const httpServer = createServer(async (req, res) => {
516
14
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
517
- // --- Federation routes ---
15
+ // Federation routes
518
16
  if (url.pathname.startsWith('/federation/')) {
519
17
  await handleFederation(req, res, url);
520
18
  return;
@@ -524,9 +22,7 @@ export async function startServer(port, dbPath) {
524
22
  res.end(JSON.stringify({ error: 'Not found. MCP endpoint is at /mcp' }));
525
23
  return;
526
24
  }
527
- // Don't log heartbeat/polling requests
528
- const isQuiet = req.method === 'GET'; // SSE keepalive
529
- // --- GET: SSE stream ---
25
+ // GET: SSE stream
530
26
  if (req.method === 'GET') {
531
27
  const sid = req.headers['mcp-session-id'];
532
28
  if (!sid || !sessions[sid]) {
@@ -534,11 +30,16 @@ export async function startServer(port, dbPath) {
534
30
  res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
535
31
  return;
536
32
  }
537
- log("debug", `[sse] opening SSE stream for session=${sid} agent=${sessionAgents.get(sid) || 'unbound'}`);
33
+ log('info', `[sse] opening stream for session=${sid} agent=${sessionAgents.get(sid) || 'unbound'}`);
34
+ activeSSE.add(sid);
35
+ res.on('close', () => {
36
+ activeSSE.delete(sid);
37
+ log('info', `[sse] stream closed for session=${sid}`);
38
+ });
538
39
  await sessions[sid].transport.handleRequest(req, res);
539
40
  return;
540
41
  }
541
- // --- DELETE: session termination ---
42
+ // DELETE: session termination
542
43
  if (req.method === 'DELETE') {
543
44
  const sid = req.headers['mcp-session-id'];
544
45
  if (!sid || !sessions[sid]) {
@@ -546,12 +47,12 @@ export async function startServer(port, dbPath) {
546
47
  res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
547
48
  return;
548
49
  }
549
- log("info", `[session] DELETE session=${sid}`);
50
+ log('info', `[session] DELETE session=${sid}`);
550
51
  unbindSession(sid);
551
52
  await sessions[sid].transport.handleRequest(req, res);
552
53
  return;
553
54
  }
554
- // --- POST: JSON-RPC ---
55
+ // POST: JSON-RPC
555
56
  try {
556
57
  const chunks = [];
557
58
  for await (const chunk of req)
@@ -562,26 +63,37 @@ export async function startServer(port, dbPath) {
562
63
  const tool = body?.params?.name || '';
563
64
  const isHeartbeat = tool === 'hive.inbox' || method === 'notifications/initialized';
564
65
  if (!isHeartbeat) {
565
- log("info", `[rpc] method=${method} sid=${sid || 'none'} tool=${tool || '-'}`);
66
+ log('info', `[rpc] method=${method} sid=${sid || 'none'} tool=${tool || '-'}`);
566
67
  }
567
68
  if (sid && sessions[sid]) {
568
- // Existing session
569
69
  await sessions[sid].transport.handleRequest(req, res, body);
570
70
  }
71
+ else if (sid && !sessions[sid]) {
72
+ // Stale session id (e.g. after server restart) — reject with 404 so the client
73
+ // knows to re-initialize. Do NOT fall through to stateless: that would create
74
+ // a transport with no sessionId, breaking session binding for push notifications.
75
+ log('info', `[session] stale sid=${sid} method=${method} → 404 (client should re-init)`);
76
+ res.writeHead(404, { 'Content-Type': 'application/json' });
77
+ res.end(JSON.stringify({
78
+ jsonrpc: '2.0',
79
+ error: { code: -32001, message: 'Session not found. Re-initialize.' },
80
+ id: body?.id ?? null,
81
+ }));
82
+ return;
83
+ }
571
84
  else if (!sid && isInitializeRequest(body)) {
572
- // New session: create server + transport
573
85
  const server = createMcpServer();
574
86
  const transport = new StreamableHTTPServerTransport({
575
87
  sessionIdGenerator: () => randomUUID(),
576
88
  onsessioninitialized: (newSid) => {
577
- log("info", `[session] new session created: ${newSid}`);
89
+ log('info', `[session] new session: ${newSid}`);
578
90
  sessions[newSid] = { transport, server };
579
91
  },
580
92
  });
581
93
  transport.onclose = () => {
582
94
  const tSid = transport.sessionId;
583
95
  if (tSid) {
584
- log("debug", `[session] transport closed: ${tSid}`);
96
+ log('debug', `[session] transport closed: ${tSid}`);
585
97
  unbindSession(tSid);
586
98
  delete sessions[tSid];
587
99
  }
@@ -590,10 +102,9 @@ export async function startServer(port, dbPath) {
590
102
  await transport.handleRequest(req, res, body);
591
103
  }
592
104
  else {
593
- // Stateless fallback: create temporary server + transport per request
594
- // This supports clients that don't maintain sessions (e.g. HTTP adapters)
105
+ // Stateless fallback (per-request server, no session)
595
106
  if (!isHeartbeat)
596
- log("debug", `[rpc] stateless request: method=${method} tool=${tool || '-'}`);
107
+ log('debug', `[rpc] stateless: method=${method} tool=${tool || '-'}`);
597
108
  const server = createMcpServer();
598
109
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
599
110
  await server.connect(transport);
@@ -602,7 +113,7 @@ export async function startServer(port, dbPath) {
602
113
  }
603
114
  }
604
115
  catch (error) {
605
- console.error('[rpc] Error handling MCP request:', error);
116
+ console.error('[rpc] error:', error);
606
117
  if (!res.headersSent) {
607
118
  res.writeHead(500, { 'Content-Type': 'application/json' });
608
119
  res.end(JSON.stringify({
@@ -618,11 +129,10 @@ export async function startServer(port, dbPath) {
618
129
  console.log(` Database: ${dbPath || '~/.kitty-hive/hive.db'}`);
619
130
  console.log(` Mode: stateful (SSE push enabled)`);
620
131
  });
621
- // Cleanup stale task rooms every hour
622
132
  setInterval(() => {
623
133
  const count = cleanupStaleTasks(7);
624
134
  if (count > 0)
625
- log("info", `[cleanup] removed ${count} stale tasks`);
135
+ log('info', `[cleanup] removed ${count} stale tasks`);
626
136
  }, 60 * 60 * 1000);
627
137
  process.on('SIGINT', async () => {
628
138
  for (const sid in sessions) {
@@ -635,237 +145,4 @@ export async function startServer(port, dbPath) {
635
145
  process.exit(0);
636
146
  });
637
147
  }
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
148
  //# sourceMappingURL=server.js.map