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.
- package/README.md +121 -126
- package/README.zh.md +153 -105
- package/channel.ts +194 -431
- package/dist/auth.d.ts +10 -6
- package/dist/auth.js +32 -17
- package/dist/auth.js.map +1 -1
- package/dist/db.d.ts +47 -17
- package/dist/db.js +268 -123
- package/dist/db.js.map +1 -1
- package/dist/federation-heartbeat.d.ts +7 -0
- package/dist/federation-heartbeat.js +45 -0
- package/dist/federation-heartbeat.js.map +1 -0
- package/dist/federation-http.d.ts +7 -0
- package/dist/federation-http.js +287 -0
- package/dist/federation-http.js.map +1 -0
- package/dist/index.js +336 -114
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +3 -0
- package/dist/log.js +10 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp/agent-tools.d.ts +2 -0
- package/dist/mcp/agent-tools.js +66 -0
- package/dist/mcp/agent-tools.js.map +1 -0
- package/dist/mcp/dm-tools.d.ts +2 -0
- package/dist/mcp/dm-tools.js +54 -0
- package/dist/mcp/dm-tools.js.map +1 -0
- package/dist/mcp/federation-tools.d.ts +2 -0
- package/dist/mcp/federation-tools.js +44 -0
- package/dist/mcp/federation-tools.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +62 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/task-tools.d.ts +2 -0
- package/dist/mcp/task-tools.js +128 -0
- package/dist/mcp/task-tools.js.map +1 -0
- package/dist/mcp/team-tools.d.ts +2 -0
- package/dist/mcp/team-tools.js +95 -0
- package/dist/mcp/team-tools.js.map +1 -0
- package/dist/models.d.ts +28 -10
- package/dist/models.js +1 -3
- package/dist/models.js.map +1 -1
- package/dist/server.d.ts +2 -3
- package/dist/server.js +56 -761
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts +15 -0
- package/dist/sessions.js +107 -0
- package/dist/sessions.js.map +1 -0
- package/dist/tools/dm.d.ts +3 -2
- package/dist/tools/dm.js +35 -32
- package/dist/tools/dm.js.map +1 -1
- package/dist/tools/start.d.ts +3 -3
- package/dist/tools/start.js +18 -15
- package/dist/tools/start.js.map +1 -1
- package/dist/tools/task.d.ts +7 -2
- package/dist/tools/task.js +113 -15
- package/dist/tools/task.js.map +1 -1
- package/dist/tools/team.d.ts +62 -13
- package/dist/tools/team.js +102 -30
- package/dist/tools/team.js.map +1 -1
- package/package.json +4 -3
- package/dist/tools/room.d.ts +0 -39
- package/dist/tools/room.js +0 -35
- 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 {
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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]
|
|
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(
|
|
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
|