kitty-hive 0.1.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 +267 -0
- package/README.zh.md +213 -0
- package/channel.ts +590 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +19 -0
- package/dist/auth.js.map +1 -0
- package/dist/db.d.ts +63 -0
- package/dist/db.js +395 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +462 -0
- package/dist/index.js.map +1 -0
- package/dist/models.d.ts +65 -0
- package/dist/models.js +10 -0
- package/dist/models.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +871 -0
- package/dist/server.js.map +1 -0
- package/dist/state-machine.d.ts +6 -0
- package/dist/state-machine.js +55 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/tools/dm.d.ts +12 -0
- package/dist/tools/dm.js +63 -0
- package/dist/tools/dm.js.map +1 -0
- package/dist/tools/room.d.ts +39 -0
- package/dist/tools/room.js +35 -0
- package/dist/tools/room.js.map +1 -0
- package/dist/tools/start.d.ts +16 -0
- package/dist/tools/start.js +54 -0
- package/dist/tools/start.js.map +1 -0
- package/dist/tools/task.d.ts +47 -0
- package/dist/tools/task.js +222 -0
- package/dist/tools/task.js.map +1 -0
- package/dist/tools/team.d.ts +25 -0
- package/dist/tools/team.js +44 -0
- package/dist/tools/team.js.map +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +14 -0
- package/dist/utils.js.map +1 -0
- package/package.json +45 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { getAgentById, getAgentByName, touchAgent, initDB, getUnreadForAgent, setReadCursor, cleanupStaleTasks } from './db.js';
|
|
8
|
+
import { handleStart } from './tools/start.js';
|
|
9
|
+
import { handleDM, handleDMAsync } from './tools/dm.js';
|
|
10
|
+
import { handleTaskCreate, handleTaskCreateAsync, handleTaskClaim, handleCheck, handleWorkflowPropose, handleWorkflowApprove, handleStepComplete, handleWorkflowReject } from './tools/task.js';
|
|
11
|
+
import { handleEvents, handleList, handleInfo } from './tools/room.js';
|
|
12
|
+
import { handleTeamCreate, handleTeamJoin, handleTeamList } from './tools/team.js';
|
|
13
|
+
import * as db from './db.js';
|
|
14
|
+
const LOG_PRIORITY = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
15
|
+
let logLevel = 'info';
|
|
16
|
+
export function setLogLevel(level) { logLevel = level; }
|
|
17
|
+
function log(level, msg) {
|
|
18
|
+
if (LOG_PRIORITY[level] <= LOG_PRIORITY[logLevel]) {
|
|
19
|
+
const prefix = level === 'info' ? '' : `[${level}] `;
|
|
20
|
+
console.log(`${prefix}${msg}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// sessionId → Session
|
|
24
|
+
const sessions = {};
|
|
25
|
+
// sessionId → agentId
|
|
26
|
+
const sessionAgents = new Map();
|
|
27
|
+
// agentId → Set<sessionId>
|
|
28
|
+
const agentSessions = new Map();
|
|
29
|
+
function bindSession(sessionId, agentId) {
|
|
30
|
+
// Clean up old binding if this session was previously bound to a different agent
|
|
31
|
+
const oldAgentId = sessionAgents.get(sessionId);
|
|
32
|
+
if (oldAgentId && oldAgentId !== agentId) {
|
|
33
|
+
const oldSet = agentSessions.get(oldAgentId);
|
|
34
|
+
if (oldSet) {
|
|
35
|
+
oldSet.delete(sessionId);
|
|
36
|
+
if (oldSet.size === 0)
|
|
37
|
+
agentSessions.delete(oldAgentId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
sessionAgents.set(sessionId, agentId);
|
|
41
|
+
let set = agentSessions.get(agentId);
|
|
42
|
+
if (!set) {
|
|
43
|
+
set = new Set();
|
|
44
|
+
agentSessions.set(agentId, set);
|
|
45
|
+
}
|
|
46
|
+
set.add(sessionId);
|
|
47
|
+
log("debug", `[bind] session=${sessionId} → agent=${agentId} (total sessions for agent: ${set.size})`);
|
|
48
|
+
}
|
|
49
|
+
function unbindSession(sessionId) {
|
|
50
|
+
const agentId = sessionAgents.get(sessionId);
|
|
51
|
+
if (agentId) {
|
|
52
|
+
const set = agentSessions.get(agentId);
|
|
53
|
+
if (set) {
|
|
54
|
+
set.delete(sessionId);
|
|
55
|
+
if (set.size === 0)
|
|
56
|
+
agentSessions.delete(agentId);
|
|
57
|
+
}
|
|
58
|
+
sessionAgents.delete(sessionId);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// --- Push notifications ---
|
|
62
|
+
function notifyRoomMembers(roomId, excludeAgentId, message) {
|
|
63
|
+
const members = db.getRoomMembers(roomId);
|
|
64
|
+
log("debug", `[notify] room=${roomId} members=${members.join(',')} exclude=${excludeAgentId}`);
|
|
65
|
+
for (const memberId of members) {
|
|
66
|
+
if (memberId === excludeAgentId)
|
|
67
|
+
continue;
|
|
68
|
+
const sids = agentSessions.get(memberId);
|
|
69
|
+
log("debug", `[notify] agent=${memberId} sessions=${sids ? [...sids].join(',') : 'NONE'}`);
|
|
70
|
+
if (!sids)
|
|
71
|
+
continue;
|
|
72
|
+
for (const sid of sids) {
|
|
73
|
+
const session = sessions[sid];
|
|
74
|
+
if (!session) {
|
|
75
|
+
log("warn", `[notify] session ${sid} NOT FOUND in sessions map`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
// Logging notification (generic)
|
|
80
|
+
session.server.sendLoggingMessage({
|
|
81
|
+
level: 'info',
|
|
82
|
+
data: message ?? `New event in room ${roomId}`,
|
|
83
|
+
}, sid);
|
|
84
|
+
// Resource updated notification (inbox changed)
|
|
85
|
+
session.server.server.sendResourceUpdated({ uri: 'hive://inbox' });
|
|
86
|
+
log("debug", `[notify] sent to session ${sid} OK (logging + resource updated)`);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
log("warn", `[notify] failed to send to session ${sid}: ${err}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function notifyAgents(agentIds, excludeAgentId, message) {
|
|
95
|
+
for (const agentId of agentIds) {
|
|
96
|
+
if (agentId === excludeAgentId)
|
|
97
|
+
continue;
|
|
98
|
+
const sids = agentSessions.get(agentId);
|
|
99
|
+
if (!sids)
|
|
100
|
+
continue;
|
|
101
|
+
for (const sid of sids) {
|
|
102
|
+
const session = sessions[sid];
|
|
103
|
+
if (!session)
|
|
104
|
+
continue;
|
|
105
|
+
try {
|
|
106
|
+
session.server.sendLoggingMessage({ level: 'info', data: message ?? 'New task event' }, sid);
|
|
107
|
+
session.server.server.sendResourceUpdated({ uri: 'hive://inbox' });
|
|
108
|
+
}
|
|
109
|
+
catch { /* ignore */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function notifyTaskParticipants(taskId, excludeAgentId, message) {
|
|
114
|
+
const task = db.getTaskById(taskId);
|
|
115
|
+
if (!task)
|
|
116
|
+
return;
|
|
117
|
+
const participants = new Set();
|
|
118
|
+
participants.add(task.creator_agent_id);
|
|
119
|
+
if (task.assignee_agent_id)
|
|
120
|
+
participants.add(task.assignee_agent_id);
|
|
121
|
+
// Also notify workflow step assignees
|
|
122
|
+
if (task.workflow_json) {
|
|
123
|
+
try {
|
|
124
|
+
const steps = JSON.parse(task.workflow_json);
|
|
125
|
+
for (const step of steps) {
|
|
126
|
+
for (const a of step.assignees || []) {
|
|
127
|
+
if (a.startsWith('role:')) {
|
|
128
|
+
const agent = db.findAgentByRole(a.slice(5));
|
|
129
|
+
if (agent)
|
|
130
|
+
participants.add(agent.id);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const agent = db.getAgentById(a) || db.getAgentByName(a);
|
|
134
|
+
if (agent)
|
|
135
|
+
participants.add(agent.id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
notifyAgents([...participants], excludeAgentId, message);
|
|
143
|
+
}
|
|
144
|
+
// --- Agent resolution ---
|
|
145
|
+
const asParam = z.string().optional().describe('Your agent name or ID (from hive.start)');
|
|
146
|
+
function resolveAgent(extra, asValue) {
|
|
147
|
+
const sessionId = extra?.sessionId;
|
|
148
|
+
// Logged by caller, not here (too noisy from polling)
|
|
149
|
+
// 1) Session binding
|
|
150
|
+
if (sessionId) {
|
|
151
|
+
const agentId = sessionAgents.get(sessionId);
|
|
152
|
+
if (agentId) {
|
|
153
|
+
const agent = getAgentById(agentId);
|
|
154
|
+
if (agent) {
|
|
155
|
+
touchAgent(agent.id);
|
|
156
|
+
return agent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 2) `as` param fallback
|
|
161
|
+
if (asValue) {
|
|
162
|
+
const agent = getAgentById(asValue) || getAgentByName(asValue);
|
|
163
|
+
if (agent) {
|
|
164
|
+
touchAgent(agent.id);
|
|
165
|
+
return agent;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// silent — too noisy from polling/stateless requests
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
function authError() {
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: 'text', text: 'Error: Not authenticated. Call hive.start first, or pass `as` with your agent name.' }],
|
|
174
|
+
isError: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// --- MCP Server factory (one per session) ---
|
|
178
|
+
function createMcpServer() {
|
|
179
|
+
const mcp = new McpServer({
|
|
180
|
+
name: 'kitty-hive',
|
|
181
|
+
version: '0.1.0',
|
|
182
|
+
}, {
|
|
183
|
+
capabilities: {
|
|
184
|
+
logging: {},
|
|
185
|
+
resources: { subscribe: true },
|
|
186
|
+
},
|
|
187
|
+
instructions: [
|
|
188
|
+
'kitty-hive is a multi-agent collaboration server.',
|
|
189
|
+
'',
|
|
190
|
+
'## Getting Started',
|
|
191
|
+
'Call hive.start with your name to register. All tools except hive.start and hive.check require `as` param.',
|
|
192
|
+
'',
|
|
193
|
+
'## Task Rules (IMPORTANT)',
|
|
194
|
+
'When you receive a task (via hive.task or task-assigned notification):',
|
|
195
|
+
'1. Analyze the task and propose a workflow using hive.workflow.propose',
|
|
196
|
+
'2. The workflow should have clear steps with assignees from your team',
|
|
197
|
+
'3. Wait for the creator to approve (hive.workflow.approve) before starting',
|
|
198
|
+
'4. Execute each step, call hive.workflow.step.complete when done',
|
|
199
|
+
'5. If you see an unassigned task, claim it with hive.task.claim',
|
|
200
|
+
'',
|
|
201
|
+
'When you CREATE a task:',
|
|
202
|
+
'- Always specify `to` (agent name or "role:xxx") to assign it',
|
|
203
|
+
'- Use `input.description` to describe what needs to be done',
|
|
204
|
+
'- The assignee will propose a workflow — you MUST show the proposal to the user and get explicit confirmation before calling hive.workflow.approve',
|
|
205
|
+
'- NEVER auto-approve a workflow without asking the user first',
|
|
206
|
+
'',
|
|
207
|
+
'## Rooms',
|
|
208
|
+
'- lobby: global, everyone auto-joins',
|
|
209
|
+
'- dm: private 1:1 messaging (auto-created by hive.dm)',
|
|
210
|
+
'- team: group collaboration (hive.team.create/join/list)',
|
|
211
|
+
'',
|
|
212
|
+
'## Shared Artifacts',
|
|
213
|
+
'Use ~/.kitty-hive/artifacts/<task_id>/ for cross-agent file exchange.',
|
|
214
|
+
].join('\n'),
|
|
215
|
+
});
|
|
216
|
+
mcp.tool('hive.start', 'Register as an agent and join the lobby. Returns your agent_id. Session is auto-bound for push notifications.', {
|
|
217
|
+
name: z.string().optional().describe('Display name (random if omitted)'),
|
|
218
|
+
roles: z.string().optional().describe('Comma-separated roles: ux,frontend,backend'),
|
|
219
|
+
tool: z.string().optional().describe('Agent tool: claude, codex, shell'),
|
|
220
|
+
expertise: z.string().optional().describe('Free-text expertise description'),
|
|
221
|
+
}, async (params, extra) => {
|
|
222
|
+
const result = handleStart(params);
|
|
223
|
+
if (extra.sessionId)
|
|
224
|
+
bindSession(extra.sessionId, result.agent_id);
|
|
225
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
226
|
+
});
|
|
227
|
+
mcp.tool('hive.dm', 'Send a direct message. Use "agent@node" for cross-node DM (federation).', {
|
|
228
|
+
as: asParam,
|
|
229
|
+
to: z.string().describe('Target agent name, or "agent@node" for federation'),
|
|
230
|
+
content: z.string().describe('Message content'),
|
|
231
|
+
}, async (params, extra) => {
|
|
232
|
+
const agent = resolveAgent(extra, params.as);
|
|
233
|
+
if (!agent)
|
|
234
|
+
return authError();
|
|
235
|
+
const result = await handleDMAsync(agent.id, params);
|
|
236
|
+
if (!result.federated) {
|
|
237
|
+
notifyRoomMembers(result.room_id, agent.id, JSON.stringify({
|
|
238
|
+
type: 'dm', from: agent.display_name, room_id: result.room_id,
|
|
239
|
+
preview: params.content && params.content.length > 200 ? params.content.slice(0, 200) + ' [summary]' : params.content,
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
243
|
+
});
|
|
244
|
+
mcp.tool('hive.task', 'Create a task and delegate. Use "agent@node" for cross-node delegation.', {
|
|
245
|
+
as: asParam,
|
|
246
|
+
to: z.string().optional().describe('Target: agent name, "role:ux", or "agent@node" for federation'),
|
|
247
|
+
title: z.string().describe('Task title'),
|
|
248
|
+
input: z.record(z.string(), z.unknown()).optional().describe('Structured task input (optional)'),
|
|
249
|
+
}, async (params, extra) => {
|
|
250
|
+
const agent = resolveAgent(extra, params.as);
|
|
251
|
+
if (!agent)
|
|
252
|
+
return authError();
|
|
253
|
+
const result = await handleTaskCreateAsync(agent.id, { to: params.to, title: params.title, input: params.input });
|
|
254
|
+
if (result.assignee) {
|
|
255
|
+
notifyAgents([result.assignee.id], agent.id, JSON.stringify({
|
|
256
|
+
type: 'task-assigned', from: agent.display_name,
|
|
257
|
+
task_id: result.task_id, title: params.title,
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
261
|
+
});
|
|
262
|
+
mcp.tool('hive.task.claim', 'Claim an unassigned task. Only works on tasks with status "created" and no assignee.', {
|
|
263
|
+
as: asParam,
|
|
264
|
+
task_id: z.string().describe('Task ID to claim'),
|
|
265
|
+
}, async (params, extra) => {
|
|
266
|
+
const agent = resolveAgent(extra, params.as);
|
|
267
|
+
if (!agent)
|
|
268
|
+
return authError();
|
|
269
|
+
const result = handleTaskClaim(params.task_id, agent.id);
|
|
270
|
+
notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
|
|
271
|
+
type: 'task-claimed', from: agent.display_name,
|
|
272
|
+
task_id: params.task_id, preview: `${agent.display_name} claimed: ${result.title}`,
|
|
273
|
+
}));
|
|
274
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
275
|
+
});
|
|
276
|
+
mcp.tool('hive.tasks', 'List tasks. Shows all tasks you created or are assigned to, grouped by status.', {
|
|
277
|
+
as: asParam,
|
|
278
|
+
status: z.string().optional().describe('Filter by status: created, proposing, approved, in_progress, completed, failed, canceled'),
|
|
279
|
+
}, async (params, extra) => {
|
|
280
|
+
const agent = resolveAgent(extra, params.as);
|
|
281
|
+
if (!agent)
|
|
282
|
+
return authError();
|
|
283
|
+
const tasks = db.getAgentTasks(agent.id, params.status);
|
|
284
|
+
const board = tasks.map(t => ({
|
|
285
|
+
task_id: t.id,
|
|
286
|
+
title: t.title,
|
|
287
|
+
status: t.status,
|
|
288
|
+
creator: db.getAgentById(t.creator_agent_id)?.display_name ?? 'unknown',
|
|
289
|
+
assignee: t.assignee_agent_id ? (db.getAgentById(t.assignee_agent_id)?.display_name ?? 'unknown') : 'unassigned',
|
|
290
|
+
current_step: t.current_step,
|
|
291
|
+
has_workflow: !!t.workflow_json,
|
|
292
|
+
created_at: t.created_at,
|
|
293
|
+
}));
|
|
294
|
+
return { content: [{ type: 'text', text: JSON.stringify(board, null, 2) }] };
|
|
295
|
+
});
|
|
296
|
+
mcp.tool('hive.check', 'Check the current state of a task by task ID.', { task_id: z.string().describe('Task ID to check') }, async (params) => {
|
|
297
|
+
const result = handleCheck(params);
|
|
298
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
299
|
+
});
|
|
300
|
+
mcp.tool('hive.room.events', 'Fetch events from a room. Use "since" for incremental polling.', {
|
|
301
|
+
as: asParam,
|
|
302
|
+
room_id: z.string().describe('Room ID'),
|
|
303
|
+
since: z.number().optional().describe('Return events after this seq number'),
|
|
304
|
+
limit: z.number().optional().describe('Max events to return (default 50, max 200)'),
|
|
305
|
+
}, async (params, extra) => {
|
|
306
|
+
const agent = resolveAgent(extra, params.as);
|
|
307
|
+
if (!agent)
|
|
308
|
+
return authError();
|
|
309
|
+
const result = handleEvents(agent.id, {
|
|
310
|
+
room_id: params.room_id, since: params.since, limit: params.limit,
|
|
311
|
+
});
|
|
312
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
313
|
+
});
|
|
314
|
+
mcp.tool('hive.room.list', 'List rooms you are a member of.', {
|
|
315
|
+
as: asParam,
|
|
316
|
+
kind: z.string().optional().describe('Filter by room kind: dm, team, lobby'),
|
|
317
|
+
active_only: z.boolean().optional().describe('Only show active (non-closed) rooms (default true)'),
|
|
318
|
+
}, async (params, extra) => {
|
|
319
|
+
const agent = resolveAgent(extra, params.as);
|
|
320
|
+
if (!agent)
|
|
321
|
+
return authError();
|
|
322
|
+
const result = handleList(agent.id, { kind: params.kind, active_only: params.active_only });
|
|
323
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
324
|
+
});
|
|
325
|
+
mcp.tool('hive.room.info', 'Get detailed information about a room including members and recent events.', {
|
|
326
|
+
as: asParam,
|
|
327
|
+
room_id: z.string().describe('Room ID'),
|
|
328
|
+
}, async (params, extra) => {
|
|
329
|
+
const agent = resolveAgent(extra, params.as);
|
|
330
|
+
if (!agent)
|
|
331
|
+
return authError();
|
|
332
|
+
const result = handleInfo(agent.id, { room_id: params.room_id });
|
|
333
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
334
|
+
});
|
|
335
|
+
mcp.tool('hive.inbox', 'Check your inbox for unread messages and events across all rooms. Marks returned messages as read.', {
|
|
336
|
+
as: asParam,
|
|
337
|
+
}, async (params, extra) => {
|
|
338
|
+
const agent = resolveAgent(extra, params.as);
|
|
339
|
+
if (!agent)
|
|
340
|
+
return authError();
|
|
341
|
+
const unread = getUnreadForAgent(agent.id);
|
|
342
|
+
// Mark as read
|
|
343
|
+
for (const u of unread) {
|
|
344
|
+
if (u.latest.length > 0) {
|
|
345
|
+
if (u.type === 'room') {
|
|
346
|
+
const events = db.getRoomEvents(u.id, 0, 10000);
|
|
347
|
+
if (events.length > 0)
|
|
348
|
+
setReadCursor(agent.id, 'room', u.id, events[events.length - 1].seq);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
const events = db.getTaskEvents(u.id, 0, 100);
|
|
352
|
+
if (events.length > 0)
|
|
353
|
+
setReadCursor(agent.id, 'task', u.id, events[events.length - 1].seq);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (unread.length === 0) {
|
|
358
|
+
return { content: [{ type: 'text', text: '[]' }] };
|
|
359
|
+
}
|
|
360
|
+
return { content: [{ type: 'text', text: JSON.stringify(unread) }] };
|
|
361
|
+
});
|
|
362
|
+
// --- Workflow tools ---
|
|
363
|
+
mcp.tool('hive.workflow.propose', 'Propose a workflow for a task. The task creator must approve before it starts.', {
|
|
364
|
+
as: asParam,
|
|
365
|
+
task_id: z.string().describe('Task ID'),
|
|
366
|
+
workflow: z.array(z.object({
|
|
367
|
+
step: z.number(),
|
|
368
|
+
title: z.string(),
|
|
369
|
+
assignees: z.array(z.string()).describe('Agent names or "role:xxx"'),
|
|
370
|
+
action: z.string(),
|
|
371
|
+
completion: z.enum(['all', 'any']).default('all'),
|
|
372
|
+
on_reject: z.string().optional().describe('"revise" or "back:N"'),
|
|
373
|
+
})).describe('Workflow steps'),
|
|
374
|
+
}, async (params, extra) => {
|
|
375
|
+
const agent = resolveAgent(extra, params.as);
|
|
376
|
+
if (!agent)
|
|
377
|
+
return authError();
|
|
378
|
+
handleWorkflowPropose(params.task_id, agent.id, params.workflow);
|
|
379
|
+
notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
|
|
380
|
+
type: 'task-propose', from: agent.display_name,
|
|
381
|
+
task_id: params.task_id, preview: `Workflow proposed: ${params.workflow.length} steps`,
|
|
382
|
+
}));
|
|
383
|
+
return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, status: 'proposing', steps: params.workflow.length }) }] };
|
|
384
|
+
});
|
|
385
|
+
mcp.tool('hive.workflow.approve', 'Approve a proposed workflow. Automatically starts step 1.', {
|
|
386
|
+
as: asParam,
|
|
387
|
+
task_id: z.string().describe('Task ID'),
|
|
388
|
+
}, async (params, extra) => {
|
|
389
|
+
const agent = resolveAgent(extra, params.as);
|
|
390
|
+
if (!agent)
|
|
391
|
+
return authError();
|
|
392
|
+
const action = handleWorkflowApprove(params.task_id, agent.id);
|
|
393
|
+
notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
|
|
394
|
+
type: 'step-start', from: agent.display_name,
|
|
395
|
+
task_id: params.task_id, preview: `Step 1 started, assignees: ${action.assignees?.join(', ')}`,
|
|
396
|
+
}));
|
|
397
|
+
return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, status: 'approved', action }) }] };
|
|
398
|
+
});
|
|
399
|
+
mcp.tool('hive.workflow.step.complete', 'Mark your part of the current step as complete.', {
|
|
400
|
+
as: asParam,
|
|
401
|
+
task_id: z.string().describe('Task ID'),
|
|
402
|
+
step: z.number().describe('Step number'),
|
|
403
|
+
result: z.string().optional().describe('Result description'),
|
|
404
|
+
}, async (params, extra) => {
|
|
405
|
+
const agent = resolveAgent(extra, params.as);
|
|
406
|
+
if (!agent)
|
|
407
|
+
return authError();
|
|
408
|
+
const action = handleStepComplete(params.task_id, agent.id, params.step, params.result);
|
|
409
|
+
const resultPreview = params.result && params.result.length > 200 ? params.result.slice(0, 200) + ' [summary]' : params.result;
|
|
410
|
+
const msg = action?.type === 'task-complete' ? 'Task completed!'
|
|
411
|
+
: action?.type === 'step-start' ? `Step ${action.step} started. Previous step result: ${resultPreview || 'none'}`
|
|
412
|
+
: `Step ${params.step} progress recorded, waiting for others`;
|
|
413
|
+
notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
|
|
414
|
+
type: action?.type || 'step-complete', from: agent.display_name,
|
|
415
|
+
task_id: params.task_id, preview: msg,
|
|
416
|
+
}));
|
|
417
|
+
return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, action: action || 'waiting' }) }] };
|
|
418
|
+
});
|
|
419
|
+
mcp.tool('hive.workflow.reject', 'Reject the current step output. Sends the task back to a previous step.', {
|
|
420
|
+
as: asParam,
|
|
421
|
+
task_id: z.string().describe('Task ID'),
|
|
422
|
+
step: z.number().describe('Step number being rejected'),
|
|
423
|
+
reason: z.string().optional().describe('Rejection reason'),
|
|
424
|
+
}, async (params, extra) => {
|
|
425
|
+
const agent = resolveAgent(extra, params.as);
|
|
426
|
+
if (!agent)
|
|
427
|
+
return authError();
|
|
428
|
+
const action = handleWorkflowReject(params.task_id, agent.id, params.step, params.reason);
|
|
429
|
+
notifyTaskParticipants(params.task_id, agent.id, JSON.stringify({
|
|
430
|
+
type: 'task-reject', from: agent.display_name,
|
|
431
|
+
task_id: params.task_id, preview: `Step ${params.step} rejected → back to step ${action.step}`,
|
|
432
|
+
}));
|
|
433
|
+
return { content: [{ type: 'text', text: JSON.stringify({ task_id: params.task_id, action }) }] };
|
|
434
|
+
});
|
|
435
|
+
// --- Team tools ---
|
|
436
|
+
mcp.tool('hive.team.create', 'Create a team room for group collaboration.', {
|
|
437
|
+
as: asParam,
|
|
438
|
+
name: z.string().describe('Team name'),
|
|
439
|
+
}, async (params, extra) => {
|
|
440
|
+
const agent = resolveAgent(extra, params.as);
|
|
441
|
+
if (!agent)
|
|
442
|
+
return authError();
|
|
443
|
+
const result = handleTeamCreate(agent.id, { name: params.name });
|
|
444
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
445
|
+
});
|
|
446
|
+
mcp.tool('hive.team.join', 'Join an existing team room by name or ID.', {
|
|
447
|
+
as: asParam,
|
|
448
|
+
room_id: z.string().optional().describe('Team room ID'),
|
|
449
|
+
name: z.string().optional().describe('Team name'),
|
|
450
|
+
}, async (params, extra) => {
|
|
451
|
+
const agent = resolveAgent(extra, params.as);
|
|
452
|
+
if (!agent)
|
|
453
|
+
return authError();
|
|
454
|
+
const result = handleTeamJoin(agent.id, { room_id: params.room_id, name: params.name });
|
|
455
|
+
notifyRoomMembers(result.room_id, agent.id, JSON.stringify({
|
|
456
|
+
type: 'join', from: agent.display_name, room_id: result.room_id,
|
|
457
|
+
preview: `${agent.display_name} joined the team`,
|
|
458
|
+
}));
|
|
459
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
460
|
+
});
|
|
461
|
+
mcp.tool('hive.team.list', 'List all available teams.', {}, async () => {
|
|
462
|
+
const result = handleTeamList();
|
|
463
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
464
|
+
});
|
|
465
|
+
// --- Federation MCP tools ---
|
|
466
|
+
mcp.tool('hive.peers', 'List connected federation peers.', {}, async () => {
|
|
467
|
+
const peers = db.listPeers();
|
|
468
|
+
const result = peers.map(p => ({
|
|
469
|
+
name: p.name, url: p.url, status: p.status,
|
|
470
|
+
exposed: p.exposed, last_seen: p.last_seen,
|
|
471
|
+
}));
|
|
472
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
473
|
+
});
|
|
474
|
+
mcp.tool('hive.remote.agents', 'List agents on a remote peer node.', {
|
|
475
|
+
peer: z.string().describe('Peer node name'),
|
|
476
|
+
}, async (params) => {
|
|
477
|
+
const peer = db.getPeerByName(params.peer);
|
|
478
|
+
if (!peer)
|
|
479
|
+
throw new Error(`Peer "${params.peer}" not found`);
|
|
480
|
+
const res = await fetch(peer.url.replace('/mcp', '/federation/agents'), {
|
|
481
|
+
method: 'POST',
|
|
482
|
+
headers: {
|
|
483
|
+
'Content-Type': 'application/json',
|
|
484
|
+
'Authorization': `Bearer ${peer.secret}`,
|
|
485
|
+
'X-Hive-Peer': peer.name,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
if (!res.ok)
|
|
489
|
+
throw new Error(`Failed to fetch agents from peer "${params.peer}"`);
|
|
490
|
+
const data = await res.json();
|
|
491
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
492
|
+
});
|
|
493
|
+
// --- Inbox resource ---
|
|
494
|
+
mcp.resource('inbox', 'hive://inbox', { mimeType: 'application/json', description: 'Your unread messages and events across all rooms' }, async (uri, extra) => {
|
|
495
|
+
const sessionId = extra?.sessionId;
|
|
496
|
+
let agentId;
|
|
497
|
+
if (sessionId)
|
|
498
|
+
agentId = sessionAgents.get(sessionId);
|
|
499
|
+
if (!agentId) {
|
|
500
|
+
return { contents: [{ uri: uri.href, text: '{"error":"Not authenticated. Call hive.start first."}' }] };
|
|
501
|
+
}
|
|
502
|
+
const unread = getUnreadForAgent(agentId);
|
|
503
|
+
return {
|
|
504
|
+
contents: [{
|
|
505
|
+
uri: uri.href,
|
|
506
|
+
text: JSON.stringify(unread.length > 0 ? unread : { message: 'No unread messages.' }),
|
|
507
|
+
}],
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
return mcp;
|
|
511
|
+
}
|
|
512
|
+
// --- HTTP server ---
|
|
513
|
+
export async function startServer(port, dbPath) {
|
|
514
|
+
initDB(dbPath);
|
|
515
|
+
const httpServer = createServer(async (req, res) => {
|
|
516
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
517
|
+
// --- Federation routes ---
|
|
518
|
+
if (url.pathname.startsWith('/federation/')) {
|
|
519
|
+
await handleFederation(req, res, url);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (url.pathname !== '/mcp') {
|
|
523
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
524
|
+
res.end(JSON.stringify({ error: 'Not found. MCP endpoint is at /mcp' }));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// Don't log heartbeat/polling requests
|
|
528
|
+
const isQuiet = req.method === 'GET'; // SSE keepalive
|
|
529
|
+
// --- GET: SSE stream ---
|
|
530
|
+
if (req.method === 'GET') {
|
|
531
|
+
const sid = req.headers['mcp-session-id'];
|
|
532
|
+
if (!sid || !sessions[sid]) {
|
|
533
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
534
|
+
res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
log("debug", `[sse] opening SSE stream for session=${sid} agent=${sessionAgents.get(sid) || 'unbound'}`);
|
|
538
|
+
await sessions[sid].transport.handleRequest(req, res);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// --- DELETE: session termination ---
|
|
542
|
+
if (req.method === 'DELETE') {
|
|
543
|
+
const sid = req.headers['mcp-session-id'];
|
|
544
|
+
if (!sid || !sessions[sid]) {
|
|
545
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
546
|
+
res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
log("info", `[session] DELETE session=${sid}`);
|
|
550
|
+
unbindSession(sid);
|
|
551
|
+
await sessions[sid].transport.handleRequest(req, res);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// --- POST: JSON-RPC ---
|
|
555
|
+
try {
|
|
556
|
+
const chunks = [];
|
|
557
|
+
for await (const chunk of req)
|
|
558
|
+
chunks.push(chunk);
|
|
559
|
+
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
560
|
+
const sid = req.headers['mcp-session-id'];
|
|
561
|
+
const method = body?.method || (Array.isArray(body) ? `batch[${body.length}]` : 'unknown');
|
|
562
|
+
const tool = body?.params?.name || '';
|
|
563
|
+
const isHeartbeat = tool === 'hive.inbox' || method === 'notifications/initialized';
|
|
564
|
+
if (!isHeartbeat) {
|
|
565
|
+
log("info", `[rpc] method=${method} sid=${sid || 'none'} tool=${tool || '-'}`);
|
|
566
|
+
}
|
|
567
|
+
if (sid && sessions[sid]) {
|
|
568
|
+
// Existing session
|
|
569
|
+
await sessions[sid].transport.handleRequest(req, res, body);
|
|
570
|
+
}
|
|
571
|
+
else if (!sid && isInitializeRequest(body)) {
|
|
572
|
+
// New session: create server + transport
|
|
573
|
+
const server = createMcpServer();
|
|
574
|
+
const transport = new StreamableHTTPServerTransport({
|
|
575
|
+
sessionIdGenerator: () => randomUUID(),
|
|
576
|
+
onsessioninitialized: (newSid) => {
|
|
577
|
+
log("info", `[session] new session created: ${newSid}`);
|
|
578
|
+
sessions[newSid] = { transport, server };
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
transport.onclose = () => {
|
|
582
|
+
const tSid = transport.sessionId;
|
|
583
|
+
if (tSid) {
|
|
584
|
+
log("debug", `[session] transport closed: ${tSid}`);
|
|
585
|
+
unbindSession(tSid);
|
|
586
|
+
delete sessions[tSid];
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
await server.connect(transport);
|
|
590
|
+
await transport.handleRequest(req, res, body);
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
// Stateless fallback: create temporary server + transport per request
|
|
594
|
+
// This supports clients that don't maintain sessions (e.g. HTTP adapters)
|
|
595
|
+
if (!isHeartbeat)
|
|
596
|
+
log("debug", `[rpc] stateless request: method=${method} tool=${tool || '-'}`);
|
|
597
|
+
const server = createMcpServer();
|
|
598
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
599
|
+
await server.connect(transport);
|
|
600
|
+
await transport.handleRequest(req, res, body);
|
|
601
|
+
res.on('close', () => { transport.close(); server.close(); });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
console.error('[rpc] Error handling MCP request:', error);
|
|
606
|
+
if (!res.headersSent) {
|
|
607
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
608
|
+
res.end(JSON.stringify({
|
|
609
|
+
jsonrpc: '2.0',
|
|
610
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
611
|
+
id: null,
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
httpServer.listen(port, () => {
|
|
617
|
+
console.log(`🐝 kitty-hive listening on http://localhost:${port}/mcp`);
|
|
618
|
+
console.log(` Database: ${dbPath || '~/.kitty-hive/hive.db'}`);
|
|
619
|
+
console.log(` Mode: stateful (SSE push enabled)`);
|
|
620
|
+
});
|
|
621
|
+
// Cleanup stale task rooms every hour
|
|
622
|
+
setInterval(() => {
|
|
623
|
+
const count = cleanupStaleTasks(7);
|
|
624
|
+
if (count > 0)
|
|
625
|
+
log("info", `[cleanup] removed ${count} stale tasks`);
|
|
626
|
+
}, 60 * 60 * 1000);
|
|
627
|
+
process.on('SIGINT', async () => {
|
|
628
|
+
for (const sid in sessions) {
|
|
629
|
+
try {
|
|
630
|
+
await sessions[sid].transport.close();
|
|
631
|
+
}
|
|
632
|
+
catch { /* ignore */ }
|
|
633
|
+
delete sessions[sid];
|
|
634
|
+
}
|
|
635
|
+
process.exit(0);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
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
|
+
//# sourceMappingURL=server.js.map
|