@sleep2agi/commhub-server 0.4.2
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/bin/commhub.ts +49 -0
- package/package.json +42 -0
- package/src/db.ts +64 -0
- package/src/index.ts +379 -0
- package/src/push.ts +109 -0
- package/src/tools.ts +378 -0
package/bin/commhub.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for @sleep2agi/commhub-server
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx @sleep2agi/commhub-server
|
|
7
|
+
* npx @sleep2agi/commhub-server --port 9200
|
|
8
|
+
* npx @sleep2agi/commhub-server --port 9200 --token my-secret
|
|
9
|
+
* npx @sleep2agi/commhub-server --db ~/.commhub/commhub.db
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
if (args[i] === "--port" || args[i] === "-p") process.env.PORT = args[++i];
|
|
16
|
+
if (args[i] === "--token" || args[i] === "-t") process.env.COMMHUB_AUTH_TOKEN = args[++i];
|
|
17
|
+
if (args[i] === "--db") process.env.COMMHUB_DB = args[++i];
|
|
18
|
+
if (args[i] === "--cors") process.env.COMMHUB_CORS_ORIGINS = args[++i];
|
|
19
|
+
if (args[i] === "--help" || args[i] === "-h") {
|
|
20
|
+
console.log(`
|
|
21
|
+
CommHub MCP Server — AI Agent 通信中枢
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
commhub-server [options]
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--port, -p <port> Port to listen on (default: 9200, env: PORT)
|
|
28
|
+
--token, -t <token> Auth token (env: COMMHUB_AUTH_TOKEN)
|
|
29
|
+
--db <path> SQLite database path (default: ~/.commhub/commhub.db, env: COMMHUB_DB)
|
|
30
|
+
--cors <origins> CORS origins, comma-separated (env: COMMHUB_CORS_ORIGINS)
|
|
31
|
+
--help, -h Show this help
|
|
32
|
+
|
|
33
|
+
Environment Variables:
|
|
34
|
+
PORT Server port (default: 9200)
|
|
35
|
+
COMMHUB_AUTH_TOKEN Bearer token for authentication
|
|
36
|
+
COMMHUB_DB SQLite database file path
|
|
37
|
+
COMMHUB_CORS_ORIGINS Allowed CORS origins (comma-separated)
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
commhub-server --port 9200
|
|
41
|
+
commhub-server --port 9200 --token my-secret-token
|
|
42
|
+
PORT=9200 COMMHUB_AUTH_TOKEN=secret commhub-server
|
|
43
|
+
`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Load the server
|
|
49
|
+
import("../src/index.js");
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sleep2agi/commhub-server",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"commhub-server": "bin/commhub.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"bin"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "bun run src/index.ts",
|
|
16
|
+
"dev": "bun --watch run src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"commhub",
|
|
20
|
+
"mcp",
|
|
21
|
+
"agent",
|
|
22
|
+
"ai",
|
|
23
|
+
"sse",
|
|
24
|
+
"claude",
|
|
25
|
+
"orchestration",
|
|
26
|
+
"communication",
|
|
27
|
+
"hub"
|
|
28
|
+
],
|
|
29
|
+
"author": "sleep2agi",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/sleep2agi/agent-comm-hub",
|
|
34
|
+
"directory": "server"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.2.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "fs";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
|
|
5
|
+
const DB_PATH = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
|
|
6
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
7
|
+
|
|
8
|
+
console.log(`[commhub] database: ${DB_PATH}`);
|
|
9
|
+
export const db = new Database(DB_PATH);
|
|
10
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
11
|
+
db.exec("PRAGMA busy_timeout=5000");
|
|
12
|
+
|
|
13
|
+
// Schema
|
|
14
|
+
db.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
16
|
+
resume_id TEXT PRIMARY KEY,
|
|
17
|
+
alias TEXT UNIQUE,
|
|
18
|
+
tmux_name TEXT,
|
|
19
|
+
server TEXT DEFAULT 'unknown',
|
|
20
|
+
ip TEXT,
|
|
21
|
+
hostname TEXT,
|
|
22
|
+
agent TEXT,
|
|
23
|
+
project_dir TEXT,
|
|
24
|
+
version TEXT,
|
|
25
|
+
status TEXT DEFAULT 'offline',
|
|
26
|
+
task TEXT,
|
|
27
|
+
output TEXT,
|
|
28
|
+
progress INTEGER DEFAULT 0,
|
|
29
|
+
score REAL,
|
|
30
|
+
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
31
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS inbox (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
session_name TEXT NOT NULL,
|
|
37
|
+
type TEXT DEFAULT 'task',
|
|
38
|
+
priority TEXT DEFAULT 'normal',
|
|
39
|
+
content TEXT NOT NULL,
|
|
40
|
+
context TEXT,
|
|
41
|
+
from_session TEXT DEFAULT 'hub',
|
|
42
|
+
acked INTEGER DEFAULT 0,
|
|
43
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_pending
|
|
47
|
+
ON inbox(session_name, acked) WHERE acked = 0;
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS completions (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
session_name TEXT NOT NULL,
|
|
52
|
+
task TEXT NOT NULL,
|
|
53
|
+
result TEXT NOT NULL,
|
|
54
|
+
artifacts TEXT,
|
|
55
|
+
score REAL,
|
|
56
|
+
duration_minutes REAL,
|
|
57
|
+
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
58
|
+
);
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
// Helpers
|
|
62
|
+
export function uuidv4(): string {
|
|
63
|
+
return crypto.randomUUID();
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
import { db } from "./db.js";
|
|
6
|
+
import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
|
|
7
|
+
|
|
8
|
+
const PORT = Number(process.env.PORT) || 9200;
|
|
9
|
+
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
10
|
+
|
|
11
|
+
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
12
|
+
function createServer(clientIP?: string): McpServer {
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "commhub",
|
|
15
|
+
version: "0.4.1",
|
|
16
|
+
});
|
|
17
|
+
registerTools(server, clientIP);
|
|
18
|
+
return server;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Auth helper ─────────────────────────────────────
|
|
22
|
+
function requireAuth(req: Request): Response | null {
|
|
23
|
+
if (!AUTH_TOKEN) return null; // no token = open mode (dev)
|
|
24
|
+
const header = req.headers.get("Authorization");
|
|
25
|
+
if (header === `Bearer ${AUTH_TOKEN}`) return null;
|
|
26
|
+
// Also check query param for MCP clients that can't set headers
|
|
27
|
+
const url = new URL(req.url);
|
|
28
|
+
if (url.searchParams.get("token") === AUTH_TOKEN) return null;
|
|
29
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── REST input schema ───────────────────────────────
|
|
33
|
+
const TaskSchema = z.object({
|
|
34
|
+
alias: z.string().min(1).max(200),
|
|
35
|
+
task: z.string().min(1).max(10000),
|
|
36
|
+
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const BroadcastSchema = z.object({
|
|
40
|
+
message: z.string().min(1).max(10000),
|
|
41
|
+
filter_server: z.string().max(200).optional(),
|
|
42
|
+
filter_status: z.string().max(50).optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── HTTP Server (Bun native) ────────────────────────
|
|
46
|
+
const CORS_ORIGINS = process.env.COMMHUB_CORS_ORIGINS
|
|
47
|
+
? process.env.COMMHUB_CORS_ORIGINS.split(",").map((s) => s.trim())
|
|
48
|
+
: ["http://localhost:3000", "http://localhost:3001"];
|
|
49
|
+
|
|
50
|
+
function corsHeaders(req: Request): Record<string, string> {
|
|
51
|
+
const origin = req.headers.get("Origin") || "";
|
|
52
|
+
const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
|
|
53
|
+
return {
|
|
54
|
+
"Access-Control-Allow-Origin": allowed,
|
|
55
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
56
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
57
|
+
"Access-Control-Max-Age": "86400",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withCors(req: Request, res: Response): Response {
|
|
62
|
+
const headers = corsHeaders(req);
|
|
63
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
64
|
+
res.headers.set(k, v);
|
|
65
|
+
}
|
|
66
|
+
return res;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── WebSocket tmux sessions ────────────────────────
|
|
70
|
+
const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Bun.serve({
|
|
74
|
+
port: PORT,
|
|
75
|
+
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
76
|
+
|
|
77
|
+
async fetch(req, server) {
|
|
78
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
79
|
+
|
|
80
|
+
// ── CORS preflight ──
|
|
81
|
+
if (req.method === "OPTIONS") {
|
|
82
|
+
return new Response(null, { status: 204, headers: corsHeaders(req) });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── WebSocket: tmux terminal ──
|
|
86
|
+
const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
87
|
+
if (wsMatch && server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) {
|
|
88
|
+
return; // upgraded
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── MCP Streamable HTTP endpoint ──
|
|
92
|
+
// MCP protocol handles its own auth — skip token check here
|
|
93
|
+
if (url.pathname === "/mcp") {
|
|
94
|
+
const fwd = req.headers.get("x-forwarded-for");
|
|
95
|
+
const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
96
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
97
|
+
sessionIdGenerator: undefined,
|
|
98
|
+
});
|
|
99
|
+
const server = createServer(clientIP);
|
|
100
|
+
await server.connect(transport);
|
|
101
|
+
const response = await transport.handleRequest(req);
|
|
102
|
+
// Disconnect after response to prevent McpServer leak
|
|
103
|
+
setImmediate(() => server.close().catch(() => {}));
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── SSE push: Agent 实时接收任务推送 ──
|
|
108
|
+
// GET /events/知识哥 → 保持长连接,send_task 时秒推
|
|
109
|
+
const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
|
|
110
|
+
if (eventsMatch && req.method === "GET") {
|
|
111
|
+
const authErr = requireAuth(req);
|
|
112
|
+
if (authErr) return authErr;
|
|
113
|
+
const sessionName = decodeURIComponent(eventsMatch[1]);
|
|
114
|
+
return createSSEStream(sessionName);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── REST: health (public, no auth) ──
|
|
118
|
+
if (url.pathname === "/health") {
|
|
119
|
+
const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
|
|
120
|
+
const sse = getSSEStats();
|
|
121
|
+
return withCors(req, Response.json({
|
|
122
|
+
ok: true,
|
|
123
|
+
version: "0.4.1",
|
|
124
|
+
transport: "streamable-http",
|
|
125
|
+
sessions: count?.cnt ?? 0,
|
|
126
|
+
sse_connections: sse.total,
|
|
127
|
+
sse_sessions: sse.sessions,
|
|
128
|
+
auth: AUTH_TOKEN ? "enabled" : "disabled",
|
|
129
|
+
uptime: process.uptime(),
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── All REST /api endpoints require auth (if token configured) ──
|
|
134
|
+
const authErr = requireAuth(req);
|
|
135
|
+
if (authErr) return withCors(req, authErr);
|
|
136
|
+
|
|
137
|
+
// ── REST: all sessions status ──
|
|
138
|
+
if (url.pathname === "/api/status") {
|
|
139
|
+
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
140
|
+
db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
|
|
141
|
+
const sessions = db.query("SELECT * FROM sessions ORDER BY updated_at DESC").all();
|
|
142
|
+
return withCors(req, Response.json({ ok: true, sessions }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── REST: send task ──
|
|
146
|
+
if (url.pathname === "/api/task" && req.method === "POST") {
|
|
147
|
+
let raw: unknown;
|
|
148
|
+
try {
|
|
149
|
+
raw = await req.json();
|
|
150
|
+
} catch {
|
|
151
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
152
|
+
}
|
|
153
|
+
const parsed = TaskSchema.safeParse(raw);
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
156
|
+
}
|
|
157
|
+
const body = parsed.data;
|
|
158
|
+
const id = crypto.randomUUID();
|
|
159
|
+
db.run(
|
|
160
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
161
|
+
VALUES (?1, ?2, 'task', ?3, ?4, 'api')`,
|
|
162
|
+
[id, body.alias, body.priority, body.task]
|
|
163
|
+
);
|
|
164
|
+
// SSE push: 秒达
|
|
165
|
+
const pending = db.query<{ cnt: number }, [string]>(
|
|
166
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
167
|
+
).get(body.alias);
|
|
168
|
+
pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority });
|
|
169
|
+
return withCors(req, Response.json({ ok: true, message_id: id }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── REST: broadcast ──
|
|
173
|
+
if (url.pathname === "/api/broadcast" && req.method === "POST") {
|
|
174
|
+
let raw: unknown;
|
|
175
|
+
try {
|
|
176
|
+
raw = await req.json();
|
|
177
|
+
} catch {
|
|
178
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
179
|
+
}
|
|
180
|
+
const parsed = BroadcastSchema.safeParse(raw);
|
|
181
|
+
if (!parsed.success) {
|
|
182
|
+
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
183
|
+
}
|
|
184
|
+
const body = parsed.data;
|
|
185
|
+
let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
|
|
186
|
+
const params: any[] = [];
|
|
187
|
+
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
188
|
+
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
189
|
+
const targets = db.query<{ alias: string }, any[]>(sql).all(...params);
|
|
190
|
+
const ids: string[] = [];
|
|
191
|
+
for (const t of targets) {
|
|
192
|
+
const id = crypto.randomUUID();
|
|
193
|
+
db.run(
|
|
194
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
195
|
+
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
|
|
196
|
+
[id, t.alias, body.message]
|
|
197
|
+
);
|
|
198
|
+
ids.push(id);
|
|
199
|
+
}
|
|
200
|
+
pushBroadcast(targets.map(t => t.alias), { type: "broadcast", inbox_count: 1, message: body.message.slice(0, 200) });
|
|
201
|
+
return withCors(req, Response.json({ ok: true, recipients: targets.length, message_ids: ids }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── REST: tmux capture-pane ──
|
|
205
|
+
const tmuxCapture = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
206
|
+
if (tmuxCapture && req.method === "GET") {
|
|
207
|
+
const name = tmuxCapture[1];
|
|
208
|
+
const lines = Number(url.searchParams.get("lines")) || 30;
|
|
209
|
+
try {
|
|
210
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", name, "-p"], {
|
|
211
|
+
stdout: "pipe", stderr: "pipe",
|
|
212
|
+
});
|
|
213
|
+
const text = await new Response(proc.stdout).text();
|
|
214
|
+
const err = await new Response(proc.stderr).text();
|
|
215
|
+
const code = await proc.exited;
|
|
216
|
+
if (code !== 0) {
|
|
217
|
+
return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
|
|
218
|
+
}
|
|
219
|
+
const trimmed = text.split("\n").slice(-lines).join("\n");
|
|
220
|
+
return withCors(req, Response.json({ ok: true, tmux_name: name, lines: lines, output: trimmed }));
|
|
221
|
+
} catch (e) {
|
|
222
|
+
return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── REST: tmux send-keys ──
|
|
227
|
+
const tmuxSend = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)\/send$/);
|
|
228
|
+
if (tmuxSend && req.method === "POST") {
|
|
229
|
+
const name = tmuxSend[1];
|
|
230
|
+
let body: { text?: string; enter?: boolean };
|
|
231
|
+
try { body = await req.json(); } catch {
|
|
232
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
233
|
+
}
|
|
234
|
+
if (!body.text || typeof body.text !== "string") {
|
|
235
|
+
return withCors(req, Response.json({ error: "text is required" }, { status: 400 }));
|
|
236
|
+
}
|
|
237
|
+
const args = ["tmux", "send-keys", "-t", name, body.text];
|
|
238
|
+
if (body.enter !== false) args.push("Enter");
|
|
239
|
+
try {
|
|
240
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
241
|
+
const err = await new Response(proc.stderr).text();
|
|
242
|
+
const code = await proc.exited;
|
|
243
|
+
if (code !== 0) {
|
|
244
|
+
return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
|
|
245
|
+
}
|
|
246
|
+
return withCors(req, Response.json({ ok: true, sent: body.text }));
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── REST: recent completions ──
|
|
253
|
+
if (url.pathname === "/api/completions") {
|
|
254
|
+
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
255
|
+
const rows = db.query("SELECT * FROM completions WHERE completed_at >= ?1 ORDER BY completed_at DESC LIMIT 100").all(since);
|
|
256
|
+
return withCors(req, Response.json({ ok: true, completions: rows }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return withCors(req, new Response(
|
|
260
|
+
`CommHub MCP Server v0.4.1 (Streamable HTTP + SSE Push)
|
|
261
|
+
|
|
262
|
+
Endpoints:
|
|
263
|
+
POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
|
|
264
|
+
GET /events/:session - SSE realtime push (Agent subscribes here)
|
|
265
|
+
GET /health - Health check
|
|
266
|
+
GET /api/status - All sessions ${AUTH_TOKEN ? "(auth required)" : ""}
|
|
267
|
+
POST /api/task - Send task via REST ${AUTH_TOKEN ? "(auth required)" : ""}
|
|
268
|
+
GET /api/completions - Recent completions ${AUTH_TOKEN ? "(auth required)" : ""}
|
|
269
|
+
GET /api/tmux/:name - Capture tmux pane output ${AUTH_TOKEN ? "(auth required)" : ""}
|
|
270
|
+
POST /api/tmux/:name/send - Send keys to tmux ${AUTH_TOKEN ? "(auth required)" : ""}
|
|
271
|
+
|
|
272
|
+
Auth: ${AUTH_TOKEN ? "Bearer token enabled (set COMMHUB_AUTH_TOKEN)" : "disabled (set COMMHUB_AUTH_TOKEN to enable)"}
|
|
273
|
+
`,
|
|
274
|
+
{ status: 200, headers: { "Content-Type": "text/plain" } }
|
|
275
|
+
));
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// ── WebSocket handler for tmux terminal streaming ──
|
|
279
|
+
websocket: {
|
|
280
|
+
open(ws) {
|
|
281
|
+
const { tmuxName } = ws.data as { tmuxName: string };
|
|
282
|
+
console.log(`[ws] tmux terminal opened: ${tmuxName}`);
|
|
283
|
+
let lastOutput = "";
|
|
284
|
+
|
|
285
|
+
// Poll capture-pane every 200ms and send diffs
|
|
286
|
+
const interval = setInterval(async () => {
|
|
287
|
+
try {
|
|
288
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
|
|
289
|
+
stdout: "pipe", stderr: "pipe",
|
|
290
|
+
});
|
|
291
|
+
const output = await new Response(proc.stdout).text();
|
|
292
|
+
const code = await proc.exited;
|
|
293
|
+
if (code !== 0) return;
|
|
294
|
+
|
|
295
|
+
if (output !== lastOutput) {
|
|
296
|
+
lastOutput = output;
|
|
297
|
+
ws.send(JSON.stringify({ type: "output", data: output }));
|
|
298
|
+
}
|
|
299
|
+
} catch { /* session gone */ }
|
|
300
|
+
}, 200);
|
|
301
|
+
|
|
302
|
+
wsTmuxIntervals.set(ws, interval);
|
|
303
|
+
|
|
304
|
+
// Send initial capture immediately
|
|
305
|
+
(async () => {
|
|
306
|
+
try {
|
|
307
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
|
|
308
|
+
stdout: "pipe", stderr: "pipe",
|
|
309
|
+
});
|
|
310
|
+
const output = await new Response(proc.stdout).text();
|
|
311
|
+
const code = await proc.exited;
|
|
312
|
+
if (code === 0) {
|
|
313
|
+
lastOutput = output;
|
|
314
|
+
ws.send(JSON.stringify({ type: "output", data: output }));
|
|
315
|
+
} else {
|
|
316
|
+
const err = await new Response(proc.stderr).text();
|
|
317
|
+
ws.send(JSON.stringify({ type: "error", data: err.trim() || "tmux session not found" }));
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {
|
|
320
|
+
ws.send(JSON.stringify({ type: "error", data: (e as Error).message }));
|
|
321
|
+
}
|
|
322
|
+
})();
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
async message(ws, message) {
|
|
326
|
+
const { tmuxName } = ws.data as { tmuxName: string };
|
|
327
|
+
try {
|
|
328
|
+
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
|
329
|
+
|
|
330
|
+
if (msg.type === "input" && typeof msg.data === "string") {
|
|
331
|
+
// Send individual characters/sequences via send-keys
|
|
332
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, "-l", msg.data], {
|
|
333
|
+
stdout: "pipe", stderr: "pipe",
|
|
334
|
+
});
|
|
335
|
+
await proc.exited;
|
|
336
|
+
} else if (msg.type === "key" && typeof msg.data === "string") {
|
|
337
|
+
// Send special key names (Enter, C-c, etc.)
|
|
338
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, msg.data], {
|
|
339
|
+
stdout: "pipe", stderr: "pipe",
|
|
340
|
+
});
|
|
341
|
+
await proc.exited;
|
|
342
|
+
} else if (msg.type === "resize" && msg.cols && msg.rows) {
|
|
343
|
+
// Resize tmux pane
|
|
344
|
+
Bun.spawn(["tmux", "resize-window", "-t", tmuxName, "-x", String(msg.cols), "-y", String(msg.rows)], {
|
|
345
|
+
stdout: "pipe", stderr: "pipe",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
} catch { /* ignore malformed messages */ }
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
close(ws) {
|
|
352
|
+
const { tmuxName } = ws.data as { tmuxName: string };
|
|
353
|
+
console.log(`[ws] tmux terminal closed: ${tmuxName}`);
|
|
354
|
+
const interval = wsTmuxIntervals.get(ws);
|
|
355
|
+
if (interval) { clearInterval(interval); wsTmuxIntervals.delete(ws); }
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── Graceful shutdown ───────────────────────────────
|
|
361
|
+
function shutdown() {
|
|
362
|
+
console.log("[commhub] shutting down...");
|
|
363
|
+
db.close();
|
|
364
|
+
process.exit(0);
|
|
365
|
+
}
|
|
366
|
+
process.on("SIGTERM", shutdown);
|
|
367
|
+
process.on("SIGINT", shutdown);
|
|
368
|
+
|
|
369
|
+
console.log(`
|
|
370
|
+
╔══════════════════════════════════════════════════╗
|
|
371
|
+
║ CommHub MCP Server v0.4.1 ║
|
|
372
|
+
║ Transport: Streamable HTTP (Bun native) ║
|
|
373
|
+
║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
|
|
374
|
+
║ ║
|
|
375
|
+
║ MCP: http://0.0.0.0:${PORT}/mcp ║
|
|
376
|
+
║ REST: http://0.0.0.0:${PORT}/api ║
|
|
377
|
+
║ Health: http://0.0.0.0:${PORT}/health ║
|
|
378
|
+
╚══════════════════════════════════════════════════╝
|
|
379
|
+
`);
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// ── SSE Push: 实时推送事件给 Agent ──────────────────
|
|
2
|
+
// Agent 连 GET /events/:session → 保持 SSE 长连接
|
|
3
|
+
// send_task 写 inbox 后 → pushEvent() → 秒达
|
|
4
|
+
|
|
5
|
+
type SSEClient = {
|
|
6
|
+
controller: ReadableStreamDefaultController;
|
|
7
|
+
encoder: TextEncoder;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// 一个 session 可能有多个 SSE 连接(重连时短暂并存)
|
|
11
|
+
const clients = new Map<string, SSEClient[]>();
|
|
12
|
+
|
|
13
|
+
function ts(): string {
|
|
14
|
+
return new Date().toTimeString().slice(0, 8);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 创建 SSE Response 并注册到 clients map */
|
|
18
|
+
export function createSSEStream(sessionName: string): Response {
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
let ctrl: ReadableStreamDefaultController;
|
|
21
|
+
|
|
22
|
+
const stream = new ReadableStream({
|
|
23
|
+
start(controller) {
|
|
24
|
+
ctrl = controller;
|
|
25
|
+
const client: SSEClient = { controller, encoder };
|
|
26
|
+
|
|
27
|
+
if (!clients.has(sessionName)) {
|
|
28
|
+
clients.set(sessionName, []);
|
|
29
|
+
}
|
|
30
|
+
clients.get(sessionName)!.push(client);
|
|
31
|
+
console.log(`[${ts()}] SSE ← ${sessionName} connected (${clients.get(sessionName)!.length} clients)`);
|
|
32
|
+
|
|
33
|
+
// 发送初始心跳
|
|
34
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName })}\n\n`));
|
|
35
|
+
|
|
36
|
+
// Periodic keepalive every 30s to prevent proxy/LB idle timeout
|
|
37
|
+
const keepalive = setInterval(() => {
|
|
38
|
+
try {
|
|
39
|
+
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
|
40
|
+
} catch {
|
|
41
|
+
clearInterval(keepalive);
|
|
42
|
+
}
|
|
43
|
+
}, 30_000);
|
|
44
|
+
(client as any)._keepalive = keepalive;
|
|
45
|
+
},
|
|
46
|
+
cancel() {
|
|
47
|
+
// 断线清理
|
|
48
|
+
const arr = clients.get(sessionName);
|
|
49
|
+
if (arr) {
|
|
50
|
+
const idx = arr.findIndex(c => c.controller === ctrl);
|
|
51
|
+
if (idx !== -1) {
|
|
52
|
+
clearInterval((arr[idx] as any)._keepalive);
|
|
53
|
+
arr.splice(idx, 1);
|
|
54
|
+
}
|
|
55
|
+
if (arr.length === 0) clients.delete(sessionName);
|
|
56
|
+
console.log(`[${ts()}] SSE ✕ ${sessionName} disconnected (${arr.length} remaining)`);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new Response(stream, {
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": "text/event-stream",
|
|
64
|
+
"Cache-Control": "no-cache, no-transform",
|
|
65
|
+
"Connection": "keep-alive",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
71
|
+
export function pushEvent(sessionName: string, event: Record<string, unknown>): void {
|
|
72
|
+
const arr = clients.get(sessionName);
|
|
73
|
+
if (!arr || arr.length === 0) return;
|
|
74
|
+
|
|
75
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
76
|
+
const dead: number[] = [];
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < arr.length; i++) {
|
|
79
|
+
try {
|
|
80
|
+
arr[i].controller.enqueue(arr[i].encoder.encode(data));
|
|
81
|
+
} catch {
|
|
82
|
+
dead.push(i);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 清理死连接
|
|
87
|
+
for (let i = dead.length - 1; i >= 0; i--) {
|
|
88
|
+
arr.splice(dead[i], 1);
|
|
89
|
+
}
|
|
90
|
+
if (arr.length === 0) clients.delete(sessionName);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 广播给多个 session */
|
|
94
|
+
export function pushBroadcast(sessionNames: string[], event: Record<string, unknown>): void {
|
|
95
|
+
for (const name of sessionNames) {
|
|
96
|
+
pushEvent(name, event);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 获取当前 SSE 连接统计 */
|
|
101
|
+
export function getSSEStats(): { total: number; sessions: Record<string, number> } {
|
|
102
|
+
let total = 0;
|
|
103
|
+
const sessions: Record<string, number> = {};
|
|
104
|
+
for (const [name, arr] of clients) {
|
|
105
|
+
sessions[name] = arr.length;
|
|
106
|
+
total += arr.length;
|
|
107
|
+
}
|
|
108
|
+
return { total, sessions };
|
|
109
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { db, uuidv4 } from "./db.js";
|
|
4
|
+
import { pushEvent, pushBroadcast } from "./push.js";
|
|
5
|
+
|
|
6
|
+
function ts(): string {
|
|
7
|
+
return new Date().toTimeString().slice(0, 8);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function registerTools(server: McpServer, clientIP?: string) {
|
|
11
|
+
// ═══════════════════════════════════════════
|
|
12
|
+
// Child Agent Tools (4)
|
|
13
|
+
// ═══════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
server.tool(
|
|
16
|
+
"report_status",
|
|
17
|
+
"Report agent status. Returns inbox_count so you know if there are pending tasks.",
|
|
18
|
+
{
|
|
19
|
+
resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
|
|
20
|
+
alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
|
|
21
|
+
status: z.enum(["working", "idle", "blocked", "error", "waiting_input"]),
|
|
22
|
+
task: z.string().max(10000).optional().describe("Current task description"),
|
|
23
|
+
output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
|
|
24
|
+
score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
|
|
25
|
+
progress: z.number().min(0).max(100).optional().describe("Progress 0-100"),
|
|
26
|
+
server: z.string().max(200).optional().describe("Server identifier"),
|
|
27
|
+
hostname: z.string().max(200).optional().describe("Agent hostname"),
|
|
28
|
+
agent: z.string().max(100).optional().describe("Agent type (claude-code / codex / opencode)"),
|
|
29
|
+
project_dir: z.string().max(1000).optional().describe("Agent working directory"),
|
|
30
|
+
version: z.string().max(100).optional().describe("Agent version"),
|
|
31
|
+
tmux_name: z.string().max(200).optional().describe("tmux session name"),
|
|
32
|
+
},
|
|
33
|
+
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux }) => {
|
|
34
|
+
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
|
|
35
|
+
const trimmedOutput = output?.slice(0, 4000);
|
|
36
|
+
|
|
37
|
+
// Wrap DELETE + UPSERT in transaction to prevent race conditions
|
|
38
|
+
db.run("BEGIN IMMEDIATE");
|
|
39
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, updated_at)
|
|
42
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))
|
|
43
|
+
ON CONFLICT(resume_id) DO UPDATE SET
|
|
44
|
+
alias = COALESCE(?2, sessions.alias),
|
|
45
|
+
tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
46
|
+
server = COALESCE(?4, sessions.server),
|
|
47
|
+
ip = COALESCE(?5, sessions.ip),
|
|
48
|
+
hostname = COALESCE(?6, sessions.hostname),
|
|
49
|
+
agent = COALESCE(?7, sessions.agent),
|
|
50
|
+
project_dir = COALESCE(?8, sessions.project_dir),
|
|
51
|
+
version = COALESCE(?9, sessions.version),
|
|
52
|
+
status = ?10,
|
|
53
|
+
task = COALESCE(?11, sessions.task),
|
|
54
|
+
output = COALESCE(?12, sessions.output),
|
|
55
|
+
progress = COALESCE(?13, sessions.progress),
|
|
56
|
+
score = COALESCE(?14, sessions.score),
|
|
57
|
+
updated_at = datetime('now')`,
|
|
58
|
+
[resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
db.run("COMMIT");
|
|
62
|
+
|
|
63
|
+
// inbox uses alias for routing
|
|
64
|
+
const row = db.query<{ cnt: number }, [string]>(
|
|
65
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
66
|
+
).get(alias);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
content: [
|
|
70
|
+
{
|
|
71
|
+
type: "text" as const,
|
|
72
|
+
text: JSON.stringify({
|
|
73
|
+
ok: true,
|
|
74
|
+
resume_id,
|
|
75
|
+
alias,
|
|
76
|
+
inbox_count: row?.cnt ?? 0,
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
server.tool(
|
|
85
|
+
"report_completion",
|
|
86
|
+
"Report task completion with results and optional artifacts.",
|
|
87
|
+
{
|
|
88
|
+
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
89
|
+
task: z.string().min(1).max(10000).describe("Completed task description"),
|
|
90
|
+
result: z.string().min(1).max(50000).describe("Result summary"),
|
|
91
|
+
artifacts: z.array(z.string().max(2000)).max(50).optional().describe("Output URLs or file paths"),
|
|
92
|
+
score: z.number().min(0).max(10).optional(),
|
|
93
|
+
duration_minutes: z.number().min(0).optional(),
|
|
94
|
+
},
|
|
95
|
+
async ({ alias, task, result, artifacts, score, duration_minutes }) => {
|
|
96
|
+
console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}`);
|
|
97
|
+
const id = uuidv4();
|
|
98
|
+
db.run(
|
|
99
|
+
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
|
|
100
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
|
101
|
+
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
db.run(
|
|
105
|
+
`UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
|
|
106
|
+
WHERE alias = ?1`,
|
|
107
|
+
[alias]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
server.tool(
|
|
117
|
+
"get_inbox",
|
|
118
|
+
"Get pending commands for your session.",
|
|
119
|
+
{
|
|
120
|
+
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
121
|
+
limit: z.number().min(1).max(100).optional().default(10),
|
|
122
|
+
},
|
|
123
|
+
async ({ alias, limit }) => {
|
|
124
|
+
const rows0 = db.query<{ cnt: number }, [string]>(
|
|
125
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
126
|
+
).get(alias);
|
|
127
|
+
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
128
|
+
const rows = db.query<any, [string, number]>(
|
|
129
|
+
`SELECT id, type, priority, content, context, from_session, created_at
|
|
130
|
+
FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
131
|
+
ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
132
|
+
LIMIT ?2`
|
|
133
|
+
).all(alias, limit);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
server.tool(
|
|
142
|
+
"ack_inbox",
|
|
143
|
+
"Acknowledge receipt of a command.",
|
|
144
|
+
{
|
|
145
|
+
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
146
|
+
message_id: z.string().min(1).max(200),
|
|
147
|
+
response: z.string().max(10000).optional(),
|
|
148
|
+
},
|
|
149
|
+
async ({ alias, message_id, response }) => {
|
|
150
|
+
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
151
|
+
const result = db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2", [message_id, alias]);
|
|
152
|
+
if (result.changes === 0) {
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// ═══════════════════════════════════════════
|
|
164
|
+
// Hub Tools (5)
|
|
165
|
+
// ═══════════════════════════════════════════
|
|
166
|
+
|
|
167
|
+
server.tool(
|
|
168
|
+
"get_all_status",
|
|
169
|
+
"Get status of all sessions. Hub uses this for the patrol loop.",
|
|
170
|
+
{
|
|
171
|
+
filter_status: z.string().max(50).optional(),
|
|
172
|
+
filter_server: z.string().max(200).optional(),
|
|
173
|
+
},
|
|
174
|
+
async ({ filter_status, filter_server }) => {
|
|
175
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_server ? " server=" + filter_server : ""}`);
|
|
176
|
+
|
|
177
|
+
const sessions = db.transaction(() => {
|
|
178
|
+
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
179
|
+
db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
|
|
180
|
+
|
|
181
|
+
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
182
|
+
const params: any[] = [];
|
|
183
|
+
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
184
|
+
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
185
|
+
sql += " ORDER BY updated_at DESC";
|
|
186
|
+
return db.query(sql).all(...params);
|
|
187
|
+
})();
|
|
188
|
+
|
|
189
|
+
const summary = db.query<any, []>(
|
|
190
|
+
"SELECT status, COUNT(*) as count FROM sessions GROUP BY status"
|
|
191
|
+
).all();
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text" as const,
|
|
197
|
+
text: JSON.stringify({ ok: true, sessions, summary }),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
server.tool(
|
|
205
|
+
"get_session_status",
|
|
206
|
+
"Get detailed status of a specific session by alias.",
|
|
207
|
+
{ alias: z.string().min(1).max(200).describe("Session alias") },
|
|
208
|
+
async ({ alias }) => {
|
|
209
|
+
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
210
|
+
const session = db.query("SELECT * FROM sessions WHERE alias = ?1").get(alias);
|
|
211
|
+
const pending = db.query<{ cnt: number }, [string]>(
|
|
212
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
213
|
+
).get(alias);
|
|
214
|
+
const recent = db.query(
|
|
215
|
+
"SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5"
|
|
216
|
+
).all(alias);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
content: [
|
|
220
|
+
{
|
|
221
|
+
type: "text" as const,
|
|
222
|
+
text: JSON.stringify({ ok: true, session, inbox_pending: pending?.cnt ?? 0, recent_completions: recent }),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.tool(
|
|
230
|
+
"send_task",
|
|
231
|
+
"Dispatch a task to a session's inbox (by alias).",
|
|
232
|
+
{
|
|
233
|
+
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
234
|
+
task: z.string().min(1).max(10000).describe("Task content"),
|
|
235
|
+
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
|
|
236
|
+
context: z.string().max(10000).optional(),
|
|
237
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
238
|
+
},
|
|
239
|
+
async ({ alias, task, priority, context, from_session }) => {
|
|
240
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
241
|
+
const id = uuidv4();
|
|
242
|
+
// inbox.session_name stores alias
|
|
243
|
+
db.run(
|
|
244
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session)
|
|
245
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
|
|
246
|
+
[id, alias, priority, task, context ?? null, from_session]
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
|
|
250
|
+
|
|
251
|
+
// SSE push by alias
|
|
252
|
+
const pending = db.query<{ cnt: number }, [string]>(
|
|
253
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
254
|
+
).get(alias);
|
|
255
|
+
pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
content: [
|
|
259
|
+
{
|
|
260
|
+
type: "text" as const,
|
|
261
|
+
text: JSON.stringify({
|
|
262
|
+
ok: true,
|
|
263
|
+
message_id: id,
|
|
264
|
+
session_status: session?.status ?? "unknown",
|
|
265
|
+
}),
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
server.tool(
|
|
273
|
+
"send_message",
|
|
274
|
+
"Send a message to a session (no task lifecycle, just chat). Use for replies, status updates, or casual communication.",
|
|
275
|
+
{
|
|
276
|
+
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
277
|
+
message: z.string().min(1).max(10000).describe("Message content"),
|
|
278
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
279
|
+
},
|
|
280
|
+
async ({ alias, message, from_session }) => {
|
|
281
|
+
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
282
|
+
const id = uuidv4();
|
|
283
|
+
db.run(
|
|
284
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
285
|
+
VALUES (?1, ?2, 'message', 'normal', ?3, ?4)`,
|
|
286
|
+
[id, alias, message, from_session]
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
|
|
290
|
+
|
|
291
|
+
pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
content: [
|
|
295
|
+
{
|
|
296
|
+
type: "text" as const,
|
|
297
|
+
text: JSON.stringify({
|
|
298
|
+
ok: true,
|
|
299
|
+
message_id: id,
|
|
300
|
+
session_status: session?.status ?? "unknown",
|
|
301
|
+
}),
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
server.tool(
|
|
309
|
+
"broadcast",
|
|
310
|
+
"Send a message to multiple sessions.",
|
|
311
|
+
{
|
|
312
|
+
message: z.string().min(1).max(10000),
|
|
313
|
+
filter_server: z.string().max(200).optional(),
|
|
314
|
+
filter_status: z.string().max(50).optional(),
|
|
315
|
+
},
|
|
316
|
+
async ({ message, filter_server, filter_status }) => {
|
|
317
|
+
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${filter_server ? " [server=" + filter_server + "]" : ""}`);
|
|
318
|
+
let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
|
|
319
|
+
const params: any[] = [];
|
|
320
|
+
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
321
|
+
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
322
|
+
|
|
323
|
+
const targets = db.query<{ alias: string }, any[]>(sql).all(...params);
|
|
324
|
+
const ids: string[] = [];
|
|
325
|
+
|
|
326
|
+
for (const t of targets) {
|
|
327
|
+
const id = uuidv4();
|
|
328
|
+
db.run(
|
|
329
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
330
|
+
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub')`,
|
|
331
|
+
[id, t.alias, message]
|
|
332
|
+
);
|
|
333
|
+
ids.push(id);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
pushBroadcast(targets.map(t => t.alias), { type: "broadcast", inbox_count: 1, message: message.slice(0, 200) });
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
content: [
|
|
340
|
+
{
|
|
341
|
+
type: "text" as const,
|
|
342
|
+
text: JSON.stringify({ ok: true, recipients: targets.length, message_ids: ids }),
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
server.tool(
|
|
350
|
+
"get_completions",
|
|
351
|
+
"Get recent task completions.",
|
|
352
|
+
{
|
|
353
|
+
since: z.string().optional().describe("ISO 8601 datetime, default last 24h"),
|
|
354
|
+
alias: z.string().max(200).optional().describe("Filter by session alias"),
|
|
355
|
+
limit: z.number().min(1).max(500).optional().default(50),
|
|
356
|
+
},
|
|
357
|
+
async ({ since, alias, limit }) => {
|
|
358
|
+
console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
|
|
359
|
+
const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
360
|
+
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
361
|
+
const params: any[] = [cutoff];
|
|
362
|
+
|
|
363
|
+
if (alias) {
|
|
364
|
+
sql += " AND session_name = ?2";
|
|
365
|
+
params.push(alias);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const paramIdx = params.length + 1;
|
|
369
|
+
sql += ` ORDER BY completed_at DESC LIMIT ?${paramIdx}`;
|
|
370
|
+
params.push(limit);
|
|
371
|
+
|
|
372
|
+
const rows = db.query(sql).all(...params);
|
|
373
|
+
return {
|
|
374
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completions: rows }) }],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
}
|