kitty-hive 0.1.1 → 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.
- package/README.md +106 -129
- package/README.zh.md +136 -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 +32 -16
- package/dist/db.js +210 -113
- package/dist/db.js.map +1 -1
- package/dist/federation-http.d.ts +2 -0
- package/dist/federation-http.js +233 -0
- package/dist/federation-http.js.map +1 -0
- package/dist/index.js +79 -76
- 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 +30 -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 +22 -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 +38 -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 +14 -29
- 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 +1 -1
- package/dist/tools/task.js +6 -5
- 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 +2 -2
- 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,18 @@
|
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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]
|
|
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(
|
|
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
|