@sleep2agi/commhub-server 0.5.0-preview.2 → 0.5.0-preview.20

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 ADDED
@@ -0,0 +1,110 @@
1
+ # @sleep2agi/commhub-server
2
+
3
+ AI Agent 通信中枢 — MCP Server + SSE Push + REST API。
4
+
5
+ ## 快速启动
6
+
7
+ ```bash
8
+ # 需要 Bun
9
+ bunx @sleep2agi/commhub-server
10
+
11
+ # 或指定端口 + auth
12
+ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
13
+ ```
14
+
15
+ 启动后:
16
+ - MCP: `http://0.0.0.0:9200/mcp` (Claude Code / Codex 连接)
17
+ - SSE: `http://0.0.0.0:9200/events/:alias` (Agent 实时推送)
18
+ - REST: `http://0.0.0.0:9200/api/*` (Dashboard / 监控)
19
+ - Health: `http://0.0.0.0:9200/health`
20
+
21
+ ## MCP 工具 (17 个)
22
+
23
+ ### Agent 端 (从 Agent 调用)
24
+ | 工具 | 说明 |
25
+ |------|------|
26
+ | `report_status` | 心跳 + 状态更新 (idle/working/blocked/error/offline) |
27
+ | `report_completion` | 任务完成汇报 |
28
+ | `get_inbox` | 拉取待办任务 |
29
+ | `ack_inbox` | 确认收到任务 |
30
+
31
+ ### Hub 端 (从指挥室 / Dashboard 调用)
32
+ | 工具 | 说明 |
33
+ |------|------|
34
+ | `send_task` | 下发任务 (+ 可选 ttl_seconds) |
35
+ | `send_message` | 发消息 (不触发 Agent 处理) |
36
+ | `send_reply` | 回复任务 (replied/failed/cancelled + in_reply_to) |
37
+ | `send_ack` | 确认任务 (不入 inbox) |
38
+ | `retry_task` | 重试失败/过期/取消的任务 |
39
+ | `cancel_task` | 取消待处理任务 |
40
+ | `reassign_task` | 转移任务到另一个 Agent |
41
+ | `get_task` | 查询任务详情 |
42
+ | `get_all_status` | 全局状态面板 |
43
+ | `get_session_status` | 单 session 详情 |
44
+ | `broadcast` | 群发消息 |
45
+
46
+ ## REST API
47
+
48
+ | 端点 | 方法 | 说明 |
49
+ |------|------|------|
50
+ | `/health` | GET | 健康检查 (无需 auth) |
51
+ | `/api/status` | GET | 所有 session |
52
+ | `/api/tasks` | GET | 任务列表 (支持 status/from_name/to_name/task_id/limit 过滤) |
53
+ | `/api/nodes` | GET | 节点持久化信息 |
54
+ | `/api/task_events` | GET | 任务审计日志 |
55
+ | `/api/messages` | GET | 消息列表 |
56
+ | `/api/completions` | GET | 完成记录 |
57
+ | `/mcp` | POST | MCP Streamable HTTP |
58
+
59
+ ## 数据库 (6 表)
60
+
61
+ SQLite WAL 模式, 自动创建在 `~/.commhub/commhub.db`
62
+
63
+ | 表 | 说明 |
64
+ |---|------|
65
+ | `sessions` | 运行时 session (21 列, 含 node_id/session_id/channels) |
66
+ | `inbox` | 消息队列 (13 列, 含 in_reply_to/scope) |
67
+ | `tasks` | 任务生命周期 (17 列, 完整状态机) |
68
+ | `nodes` | 持久化节点身份 (11 列, 独立于 session) |
69
+ | `completions` | 完成记录 (7 列) |
70
+ | `task_events` | 审计日志 (7 列, 每次状态变化记录) |
71
+
72
+ 任务状态机:
73
+ ```
74
+ created → delivered → acked → running → replied
75
+ → failed → retry → delivered
76
+ → cancelled
77
+ delivered → expired (5min patrol)
78
+ delivered/acked/running → reassign → delivered (新agent)
79
+ ```
80
+
81
+ ## 环境变量
82
+
83
+ | 变量 | 默认 | 说明 |
84
+ |------|------|------|
85
+ | `PORT` | 9200 | 监听端口 |
86
+ | `HOST` | 0.0.0.0 | 监听地址 |
87
+ | `COMMHUB_AUTH_TOKEN` | (无) | Bearer token 鉴权 |
88
+ | `COMMHUB_DB` | ~/.commhub/commhub.db | 数据库路径 |
89
+
90
+ ## 鉴权
91
+
92
+ 两种认证方式:
93
+ 1. **V3 用户系统** (推荐): `POST /api/auth/register` → 获取 `atok_xxx` token
94
+ 2. **全局 token** (传统): `COMMHUB_AUTH_TOKEN` 环境变量
95
+
96
+ Header: `Authorization: Bearer <token>` 或 Query: `?token=<token>`
97
+
98
+ ## V3 功能
99
+
100
+ - **用户系统**: 注册/登录/Token 认证
101
+ - **多网络**: 每个用户可创建多个独立网络
102
+ - **网络隔离**: 不同网络的数据完全隔离
103
+ - **试用授权**: 14 天免费试用, 到期需授权码
104
+ - **审计日志**: 所有操作记录
105
+ - **限流**: 注册 30/min, 登录 10/min (per IP)
106
+ - `/health` 不需要 auth
107
+
108
+ ## License
109
+
110
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.2",
3
+ "version": "0.5.0-preview.20",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * V3 Auth module — user registration, login, token management
3
+ */
4
+ import { db, generateId, hashPassword, hashToken, generateToken, uuidv4 } from "./db.js";
5
+
6
+ export interface AuthUser {
7
+ user_id: string;
8
+ username: string;
9
+ display_name: string | null;
10
+ email: string | null;
11
+ role: string;
12
+ }
13
+
14
+ export interface AuthResult {
15
+ ok: boolean;
16
+ error?: string;
17
+ user?: AuthUser;
18
+ token?: string;
19
+ }
20
+
21
+ export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
22
+ if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
23
+ if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
24
+ if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
25
+
26
+ const existing = db.query<any, [string]>("SELECT user_id FROM users WHERE username = ?1").get(username);
27
+ if (existing) return { ok: false, error: "username already taken" };
28
+
29
+ const userId = generateId("u");
30
+ const pwHash = hashPassword(password);
31
+
32
+ db.run(
33
+ "INSERT INTO users (user_id, username, password_hash, email, display_name) VALUES (?1, ?2, ?3, ?4, ?5)",
34
+ [userId, username, pwHash, email || null, displayName || username]
35
+ );
36
+
37
+ // Auto-create default network
38
+ const networkId = generateId("net");
39
+ db.run(
40
+ "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
41
+ [networkId, "default", userId, "Auto-created default network"]
42
+ );
43
+
44
+ // Auto-create API token
45
+ const token = generateToken();
46
+ const tokenId = generateId("tok");
47
+ db.run(
48
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
49
+ [tokenId, hashToken(token), userId, networkId, "default", "full"]
50
+ );
51
+
52
+ return {
53
+ ok: true,
54
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
55
+ token,
56
+ };
57
+ }
58
+
59
+ export function login(username: string, password: string): AuthResult {
60
+ const user = db.query<any, [string]>(
61
+ "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
62
+ ).get(username);
63
+
64
+ if (!user) return { ok: false, error: "invalid username or password" };
65
+ if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
66
+
67
+ // Find or create token
68
+ let tokenRow = db.query<any, [string]>(
69
+ "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
70
+ ).get(user.user_id);
71
+
72
+ let token: string;
73
+ if (tokenRow) {
74
+ // Generate new token (rotate)
75
+ token = generateToken();
76
+ db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
77
+ [hashToken(token), tokenRow.token_id]);
78
+ } else {
79
+ token = generateToken();
80
+ const tokenId = generateId("tok");
81
+ const networkId = db.query<any, [string]>(
82
+ "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
83
+ ).get(user.user_id)?.network_id;
84
+ db.run(
85
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
86
+ [tokenId, hashToken(token), user.user_id, networkId || null, "login"]
87
+ );
88
+ }
89
+
90
+ return {
91
+ ok: true,
92
+ user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
93
+ token,
94
+ };
95
+ }
96
+
97
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
98
+ const tHash = hashToken(token);
99
+ const row = db.query<any, [string]>(
100
+ `SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
101
+ FROM api_tokens t JOIN users u ON t.user_id = u.user_id
102
+ WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`
103
+ ).get(tHash);
104
+
105
+ if (!row) return null;
106
+
107
+ // Update last_used
108
+ db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
109
+
110
+ return {
111
+ user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
112
+ networkId: row.network_id,
113
+ };
114
+ }
115
+
116
+ export function getUserNetworks(userId: string) {
117
+ return db.query<any, [string]>(
118
+ "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
119
+ ).all(userId);
120
+ }
121
+
122
+ export function createNetwork(userId: string, name: string, description?: string) {
123
+ const existing = db.query<any, [string, string]>(
124
+ "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
125
+ ).get(userId, name);
126
+ if (existing) return { ok: false, error: "network name already exists" };
127
+
128
+ const networkId = generateId("net");
129
+ db.run(
130
+ "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
131
+ [networkId, name, userId, description || null]
132
+ );
133
+ return { ok: true, network_id: networkId, network_name: name };
134
+ }
135
+
136
+ export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
137
+ if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
138
+ const user = db.query<any, [string]>("SELECT password_hash FROM users WHERE user_id = ?1").get(userId);
139
+ if (!user) return { ok: false, error: "user not found" };
140
+ if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
141
+ db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
142
+ return { ok: true };
143
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Database Adapter Interface — async-first, supports SQLite and PostgreSQL
3
+ *
4
+ * SQLite adapter: wraps bun:sqlite sync calls in Promise (zero overhead)
5
+ * PostgreSQL adapter: uses bun:sql native async
6
+ *
7
+ * All callers use await — sync SQLite just resolves immediately.
8
+ */
9
+
10
+ export interface QueryResult {
11
+ changes: number;
12
+ }
13
+
14
+ export interface DbAdapter {
15
+ /** Execute a write query (INSERT/UPDATE/DELETE) */
16
+ run(sql: string, params?: any[]): QueryResult;
17
+
18
+ /** Query a single row */
19
+ get<T = any>(sql: string, ...params: any[]): T | null;
20
+
21
+ /** Query multiple rows */
22
+ all<T = any>(sql: string, ...params: any[]): T[];
23
+
24
+ /** Execute raw SQL (DDL) */
25
+ exec(sql: string): void;
26
+
27
+ /** Run a function inside a transaction */
28
+ transaction<T>(fn: () => T): T;
29
+
30
+ /** Close connection */
31
+ close(): void;
32
+
33
+ /** Dialect identifier */
34
+ readonly dialect: 'sqlite' | 'postgres';
35
+ }
36
+
37
+ /**
38
+ * Phase 1 strategy:
39
+ *
40
+ * Current code is sync (bun:sqlite). We keep it sync for now.
41
+ * All DB access goes through the adapter interface above.
42
+ *
43
+ * When we add PostgreSQL (Phase 2), the adapter interface
44
+ * will change to async. At that point we'll update callers
45
+ * in a single pass. The unified call sites from Phase 1
46
+ * make that pass mechanical rather than archaeological.
47
+ *
48
+ * Why not async-first now?
49
+ * - bun:sqlite is sync, wrapping in Promise adds noise
50
+ * - All MCP tool handlers are already async, so the future
51
+ * migration is: db.run() → await db.run(), straightforward
52
+ * - 750+ lines of tools.ts would need gratuitous await for zero benefit today
53
+ *
54
+ * The contract: every DB call goes through adapter methods,
55
+ * never through raw db.query() or db.run() on the bun:sqlite object.
56
+ * This is what makes Phase 2 feasible.
57
+ */
58
+
59
+ /** SQL helpers for cross-dialect compatibility */
60
+ export function sqlNow(dialect: 'sqlite' | 'postgres'): string {
61
+ return dialect === 'postgres' ? 'NOW()' : "datetime('now')";
62
+ }
63
+
64
+ export function sqlAddSeconds(dialect: 'sqlite' | 'postgres', seconds: number | string): string {
65
+ return dialect === 'postgres'
66
+ ? `NOW() + INTERVAL '${seconds} seconds'`
67
+ : `datetime('now', '+${seconds} seconds')`;
68
+ }
69
+
70
+ export function sqlPlaceholder(dialect: 'sqlite' | 'postgres', index: number): string {
71
+ return dialect === 'postgres' ? `$${index}` : `?${index}`;
72
+ }
package/src/db.ts CHANGED
@@ -135,7 +135,162 @@ db.exec(`
135
135
  CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
136
136
  `);
137
137
 
138
+ // task_events table (V2 Sprint 2) — audit log for task state changes
139
+ db.exec(`
140
+ CREATE TABLE IF NOT EXISTS task_events (
141
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
142
+ task_id TEXT NOT NULL,
143
+ from_status TEXT,
144
+ to_status TEXT NOT NULL,
145
+ actor TEXT NOT NULL DEFAULT 'system',
146
+ detail TEXT,
147
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
148
+ );
149
+
150
+ CREATE INDEX IF NOT EXISTS idx_task_events_task_time ON task_events(task_id, created_at DESC);
151
+ CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
152
+ `);
153
+
154
+ // ── V3: users table ──
155
+ db.exec(`
156
+ CREATE TABLE IF NOT EXISTS users (
157
+ user_id TEXT PRIMARY KEY,
158
+ username TEXT UNIQUE NOT NULL,
159
+ password_hash TEXT NOT NULL,
160
+ email TEXT,
161
+ display_name TEXT,
162
+ role TEXT DEFAULT 'user',
163
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
164
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
165
+ );
166
+
167
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
168
+ `);
169
+
170
+ // ── V3: networks table ──
171
+ db.exec(`
172
+ CREATE TABLE IF NOT EXISTS networks (
173
+ network_id TEXT PRIMARY KEY,
174
+ network_name TEXT NOT NULL,
175
+ owner_id TEXT NOT NULL,
176
+ description TEXT,
177
+ settings TEXT,
178
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
179
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
180
+ UNIQUE(owner_id, network_name)
181
+ );
182
+
183
+ CREATE INDEX IF NOT EXISTS idx_networks_owner ON networks(owner_id);
184
+ `);
185
+
186
+ // ── V3: api_tokens table ──
187
+ db.exec(`
188
+ CREATE TABLE IF NOT EXISTS api_tokens (
189
+ token_id TEXT PRIMARY KEY,
190
+ token_hash TEXT NOT NULL,
191
+ user_id TEXT NOT NULL,
192
+ network_id TEXT,
193
+ name TEXT NOT NULL DEFAULT 'default',
194
+ scope TEXT DEFAULT 'full',
195
+ expires_at TEXT,
196
+ last_used_at TEXT,
197
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
198
+ );
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_tokens_hash ON api_tokens(token_hash);
201
+ CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
202
+ `);
203
+
204
+ // ── V3: audit_log table ──
205
+ db.exec(`
206
+ CREATE TABLE IF NOT EXISTS audit_log (
207
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
208
+ user_id TEXT,
209
+ username TEXT,
210
+ action TEXT NOT NULL,
211
+ target_type TEXT,
212
+ target_id TEXT,
213
+ detail TEXT,
214
+ ip TEXT,
215
+ network_id TEXT,
216
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
217
+ );
218
+
219
+ CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
220
+ CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
221
+ CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
222
+ `);
223
+
224
+ // ── V3: licenses table ──
225
+ db.exec(`
226
+ CREATE TABLE IF NOT EXISTS licenses (
227
+ id TEXT PRIMARY KEY,
228
+ license_key TEXT UNIQUE NOT NULL,
229
+ type TEXT DEFAULT 'trial',
230
+ max_agents INTEGER DEFAULT 5,
231
+ max_networks INTEGER DEFAULT 3,
232
+ max_tasks_day INTEGER DEFAULT 500,
233
+ activated_at TEXT,
234
+ expires_at TEXT,
235
+ owner_id TEXT,
236
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
237
+ );
238
+ `);
239
+
240
+ // Auto-create trial license on first run
241
+ const existingLicense = db.query<any, []>("SELECT id FROM licenses LIMIT 1").get();
242
+ if (!existingLicense) {
243
+ const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
244
+ db.run(
245
+ "INSERT INTO licenses (id, license_key, type, expires_at) VALUES (?1, ?2, 'trial', datetime('now', '+14 days'))",
246
+ [`lic_${trialId}`, `trial-${trialId}`]
247
+ );
248
+ console.log("[commhub] 🎉 14-day free trial started!");
249
+ }
250
+
251
+ // ── V3: add network_id to existing tables ──
252
+ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
253
+ try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
254
+ }
255
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
256
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
257
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
258
+
138
259
  // Helpers
139
260
  export function uuidv4(): string {
140
261
  return crypto.randomUUID();
141
262
  }
263
+
264
+ export function generateId(prefix: string): string {
265
+ return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
266
+ }
267
+
268
+ export function hashPassword(password: string): string {
269
+ return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
270
+ }
271
+
272
+ export function hashToken(token: string): string {
273
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex");
274
+ }
275
+
276
+ export function generateToken(): string {
277
+ return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
278
+ }
279
+
280
+ export function logAudit(userId: string | null, username: string | null, action: string, targetType?: string, targetId?: string, detail?: string, ip?: string, networkId?: string) {
281
+ try {
282
+ db.run(
283
+ "INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
284
+ [userId, username, action, targetType ?? null, targetId ?? null, detail ?? null, ip ?? null, networkId ?? null]
285
+ );
286
+ } catch {}
287
+ }
288
+
289
+ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
290
+ try {
291
+ db.run(
292
+ "INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
293
+ [taskId, fromStatus, toStatus, actor, detail ?? null]
294
+ );
295
+ } catch {}
296
+ }
package/src/index.ts CHANGED
@@ -2,33 +2,76 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
- import { db } from "./db.js";
5
+ import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
+ import { register, login, resolveToken, getUserNetworks, createNetwork, changePassword, type AuthUser } from "./auth.js";
7
8
 
8
9
  const PORT = Number(process.env.PORT) || 9200;
9
10
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
10
11
 
12
+ // ── Rate limiter (in-memory, per IP) ──
13
+ const rateLimits = new Map<string, { count: number; resetAt: number }>();
14
+ function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
15
+ // Skip rate limiting for localhost/internal/unknown (dev/test)
16
+ if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
17
+ const now = Date.now();
18
+ const entry = rateLimits.get(ip);
19
+ if (!entry || now > entry.resetAt) {
20
+ rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
21
+ return true;
22
+ }
23
+ if (entry.count >= maxPerMinute) return false;
24
+ entry.count++;
25
+ return true;
26
+ }
27
+ // Cleanup stale entries every 5 minutes
28
+ setInterval(() => {
29
+ const now = Date.now();
30
+ for (const [ip, entry] of rateLimits) {
31
+ if (now > entry.resetAt) rateLimits.delete(ip);
32
+ }
33
+ }, 300000);
34
+
11
35
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
12
- function createServer(clientIP?: string): McpServer {
36
+ function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
13
37
  const server = new McpServer({
14
38
  name: "commhub",
15
- version: "0.4.1",
39
+ version: "0.5.0",
16
40
  });
17
- registerTools(server, clientIP);
41
+ registerTools(server, clientIP, enforceNetworkId);
18
42
  return server;
19
43
  }
20
44
 
21
45
  // ── Auth helper ─────────────────────────────────────
22
46
  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
47
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
27
48
  const url = new URL(req.url);
28
- if (url.searchParams.get("token") === AUTH_TOKEN) return null;
49
+ const token = header || url.searchParams.get("token") || "";
50
+
51
+ // V3: check api_tokens first
52
+ if (token) {
53
+ const resolved = resolveToken(token);
54
+ if (resolved) return null; // valid user token
55
+ }
56
+
57
+ // Legacy: check global COMMHUB_AUTH_TOKEN
58
+ if (!AUTH_TOKEN) return null; // no token = open mode (dev)
59
+ if (token === AUTH_TOKEN) return null;
60
+
29
61
  return Response.json({ error: "unauthorized" }, { status: 401 });
30
62
  }
31
63
 
64
+ // Extract user + network from request token (for authorization)
65
+ function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
66
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
67
+ const url = new URL(req.url);
68
+ const token = header || url.searchParams.get("token") || "";
69
+ if (!token) return null;
70
+ const resolved = resolveToken(token);
71
+ if (!resolved) return null;
72
+ return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
73
+ }
74
+
32
75
  // ── REST input schema ───────────────────────────────
33
76
  const TaskSchema = z.object({
34
77
  alias: z.string().min(1).max(200),
@@ -84,6 +127,11 @@ setInterval(() => {
84
127
  );
85
128
  if (result.changes > 0) {
86
129
  console.log(`[patrol] expired ${result.changes} stale task(s)`);
130
+ // Log events for expired tasks
131
+ const expired = db.query<{ task_id: string }, []>(
132
+ "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
133
+ ).all();
134
+ for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
87
135
  }
88
136
  } catch {}
89
137
  }, 5 * 60 * 1000);
@@ -114,10 +162,13 @@ Bun.serve({
114
162
  if (authErr) return withCors(req, authErr);
115
163
  const fwd = req.headers.get("x-forwarded-for");
116
164
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
165
+ // V3: resolve token → enforce network_id in all MCP tools
166
+ const authCtx = resolveRequestAuth(req);
167
+ const enforceNetId = authCtx?.networkId || null;
117
168
  const transport = new WebStandardStreamableHTTPServerTransport({
118
169
  sessionIdGenerator: undefined,
119
170
  });
120
- const server = createServer(clientIP);
171
+ const server = createServer(clientIP, enforceNetId);
121
172
  await server.connect(transport);
122
173
  const response = await transport.handleRequest(req);
123
174
  // Disconnect after response to prevent McpServer leak
@@ -135,6 +186,184 @@ Bun.serve({
135
186
  return createSSEStream(sessionName);
136
187
  }
137
188
 
189
+ // ── V3: License endpoints ──
190
+ if (url.pathname === "/api/license" && req.method === "GET") {
191
+ const license = db.query<any, []>("SELECT * FROM licenses ORDER BY created_at LIMIT 1").get();
192
+ if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
193
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
194
+ const expired = license.expires_at && license.expires_at < now;
195
+ const daysLeft = license.expires_at
196
+ ? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
197
+ : null;
198
+ return withCors(req, Response.json({
199
+ ok: true,
200
+ license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
201
+ limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
202
+ }));
203
+ }
204
+
205
+ if (url.pathname === "/api/license/activate" && req.method === "POST") {
206
+ try {
207
+ const body = await req.json() as any;
208
+ const key = body.key;
209
+ if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
210
+ // For now: accept any key starting with "anet-" as valid pro license
211
+ if (!key.startsWith("anet-") || key.length < 16) {
212
+ return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
213
+ }
214
+ // Upgrade existing license or create new
215
+ db.run("DELETE FROM licenses");
216
+ const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
217
+ db.run(
218
+ "INSERT INTO licenses (id, license_key, type, max_agents, max_networks, max_tasks_day, activated_at, expires_at) VALUES (?1, ?2, 'pro', 50, 10, 10000, datetime('now'), datetime('now', '+365 days'))",
219
+ [licId, key]
220
+ );
221
+ return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
222
+ } catch (e: any) {
223
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
224
+ }
225
+ }
226
+
227
+ // ── V3: Auth endpoints (public) ──
228
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
229
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
230
+ if (!checkRateLimit(clientIP, 30)) {
231
+ return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
232
+ }
233
+ try {
234
+ const body = await req.json() as any;
235
+ const result = register(body.username, body.password, body.email, body.display_name);
236
+ if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
237
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
238
+ } catch (e: any) {
239
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
240
+ }
241
+ }
242
+
243
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
244
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
245
+ if (!checkRateLimit(clientIP, 10)) {
246
+ logAudit(null, null, "login_rate_limited", "auth", null, clientIP);
247
+ return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
248
+ }
249
+ try {
250
+ const body = await req.json() as any;
251
+ const result = login(body.username, body.password);
252
+ if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
253
+ else logAudit(null, body.username, "login_failed", "user", null, "invalid credentials");
254
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
255
+ } catch (e: any) {
256
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
257
+ }
258
+ }
259
+
260
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
261
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
262
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
263
+ const resolved = resolveToken(token);
264
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
265
+ const networks = getUserNetworks(resolved.user.user_id);
266
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
267
+ }
268
+
269
+ if (url.pathname === "/api/auth/me" && req.method === "PUT") {
270
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
271
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
272
+ const resolved = resolveToken(token);
273
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
274
+ try {
275
+ const body = await req.json() as any;
276
+ const updates: string[] = [];
277
+ const params: any[] = [];
278
+ if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
279
+ if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
280
+ if (updates.length > 0) {
281
+ updates.push(`updated_at = datetime('now')`);
282
+ params.push(resolved.user.user_id);
283
+ db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
284
+ }
285
+ // Re-fetch
286
+ const user = db.query<any, [string]>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1").get(resolved.user.user_id);
287
+ return withCors(req, Response.json({ ok: true, user }));
288
+ } catch (e: any) {
289
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
290
+ }
291
+ }
292
+
293
+ if (url.pathname === "/api/auth/password" && req.method === "POST") {
294
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
295
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
296
+ const resolved = resolveToken(token);
297
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
298
+ try {
299
+ const body = await req.json() as any;
300
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
301
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
302
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
303
+ } catch (e: any) {
304
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
305
+ }
306
+ }
307
+
308
+ // ── V3: Network management ──
309
+ if (url.pathname === "/api/networks" && req.method === "GET") {
310
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
311
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
312
+ const resolved = resolveToken(token);
313
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
314
+ const networks = getUserNetworks(resolved.user.user_id);
315
+ return withCors(req, Response.json({ ok: true, networks }));
316
+ }
317
+
318
+ if (url.pathname === "/api/networks" && req.method === "POST") {
319
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
320
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
321
+ const resolved = resolveToken(token);
322
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
323
+ try {
324
+ const body = await req.json() as any;
325
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
326
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
327
+ } catch (e: any) {
328
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
329
+ }
330
+ }
331
+
332
+ // ── V3: Admin APIs (require auth) ──
333
+ if (url.pathname === "/api/users" && req.method === "GET") {
334
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
335
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
336
+ const resolved = resolveToken(token);
337
+ if (!resolved || resolved.user.role !== "admin") {
338
+ return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
339
+ }
340
+ const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
341
+ return withCors(req, Response.json({ ok: true, users }));
342
+ }
343
+
344
+ const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
345
+ if (netDetailMatch && req.method === "GET") {
346
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
347
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
348
+ const resolved = resolveToken(token);
349
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
350
+ const networkId = netDetailMatch[1];
351
+ const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
352
+ if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
353
+ // Ownership check: only owner or admin can view
354
+ if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
355
+ return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
356
+ }
357
+ // Get network stats
358
+ const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
359
+ const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
360
+ const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
361
+ return withCors(req, Response.json({
362
+ ok: true, network,
363
+ stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
364
+ }));
365
+ }
366
+
138
367
  // ── REST: health (public, no auth) ──
139
368
  if (url.pathname === "/health") {
140
369
  const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
@@ -159,7 +388,11 @@ Bun.serve({
159
388
  if (url.pathname === "/api/status") {
160
389
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
161
390
  db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
162
- const sessions = db.query("SELECT * FROM sessions ORDER BY updated_at DESC").all();
391
+ const netFilter = url.searchParams.get("network_id");
392
+ const sql = netFilter
393
+ ? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
394
+ : "SELECT * FROM sessions ORDER BY updated_at DESC";
395
+ const sessions = netFilter ? db.query(sql).all(netFilter) : db.query(sql).all();
163
396
  return withCors(req, Response.json({ ok: true, sessions }));
164
397
  }
165
398
 
@@ -281,12 +514,77 @@ Bun.serve({
281
514
  return withCors(req, Response.json({ ok: true, messages: rows }));
282
515
  }
283
516
 
517
+ // ── REST: stats summary ──
518
+ if (url.pathname === "/api/stats") {
519
+ const n = url.searchParams.get("network_id");
520
+ // Parameterized queries to prevent SQL injection
521
+ const taskStats = n
522
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(n)
523
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
524
+ const sessionStats = n
525
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status").all(n)
526
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
527
+ const totalTasks = n
528
+ ? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1").get(n)
529
+ : db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
530
+ const totalNodes = n
531
+ ? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(n)
532
+ : db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
533
+ const recentTasks = n
534
+ ? db.query<any, [string]>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5").all(n)
535
+ : db.query<any, []>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5").all();
536
+ return withCors(req, Response.json({
537
+ ok: true,
538
+ network_id: n || null,
539
+ tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
540
+ sessions: { by_status: sessionStats },
541
+ nodes: { total: totalNodes?.cnt || 0 },
542
+ recent_tasks: recentTasks,
543
+ }));
544
+ }
545
+
546
+ // ── REST: audit log (V3) ──
547
+ if (url.pathname === "/api/audit-log") {
548
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
549
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
550
+ const resolved = resolveToken(token);
551
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
552
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
553
+ const action = url.searchParams.get("action");
554
+ const userId = url.searchParams.get("user_id");
555
+ let sql = "SELECT * FROM audit_log WHERE 1=1";
556
+ const params: any[] = [];
557
+ // Non-admin can only see own logs
558
+ if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
559
+ if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
560
+ if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
561
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
562
+ params.push(limit);
563
+ const logs = db.query(sql).all(...params);
564
+ return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
565
+ }
566
+
567
+ // ── REST: task events (V2 Sprint 2) ──
568
+ if (url.pathname === "/api/task_events") {
569
+ const taskId = url.searchParams.get("task_id");
570
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
571
+ let sql = "SELECT * FROM task_events";
572
+ const params: any[] = [];
573
+ if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
574
+ sql += " ORDER BY created_at DESC LIMIT ?";
575
+ params.push(limit);
576
+ const rows = db.query(sql).all(...params);
577
+ return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
578
+ }
579
+
284
580
  // ── REST: nodes table (V2 Sprint 2) ──
285
581
  if (url.pathname === "/api/nodes") {
286
582
  const nodeId = url.searchParams.get("node_id");
287
583
  const alias = url.searchParams.get("alias");
584
+ const netFilter = url.searchParams.get("network_id");
288
585
  let sql = "SELECT * FROM nodes WHERE 1=1";
289
586
  const params: any[] = [];
587
+ if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
290
588
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
291
589
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
292
590
  sql += " ORDER BY updated_at DESC";
@@ -300,10 +598,12 @@ Bun.serve({
300
598
  const status = url.searchParams.get("status");
301
599
  const toName = url.searchParams.get("to_name");
302
600
  const fromName = url.searchParams.get("from_name");
601
+ const netFilter = url.searchParams.get("network_id");
303
602
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
304
603
 
305
604
  let sql = "SELECT * FROM tasks WHERE 1=1";
306
605
  const params: any[] = [];
606
+ if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
307
607
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
308
608
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
309
609
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -312,7 +612,10 @@ Bun.serve({
312
612
  params.push(limit);
313
613
 
314
614
  const rows = db.query(sql).all(...params);
315
- return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
615
+ const stats = netFilter
616
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(netFilter)
617
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
618
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
316
619
  }
317
620
 
318
621
  // ── REST: recent completions ──
package/src/tools.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4 } from "./db.js";
3
+ import { db, uuidv4, logTaskEvent } from "./db.js";
4
4
  import { pushEvent, pushBroadcast } from "./push.js";
5
5
 
6
6
  function ts(): string {
7
7
  return new Date().toTimeString().slice(0, 8);
8
8
  }
9
9
 
10
- export function registerTools(server: McpServer, clientIP?: string) {
10
+ export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
11
+ // If enforceNetworkId is set, override any client-supplied network_id
12
+ const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
11
13
  // ═══════════════════════════════════════════
12
14
  // Child Agent Tools (4)
13
15
  // ═══════════════════════════════════════════
@@ -35,17 +37,25 @@ export function registerTools(server: McpServer, clientIP?: string) {
35
37
  config_path: z.string().max(1000).optional().describe("Config file path"),
36
38
  channels: z.string().max(2000).optional().describe("JSON array of channels"),
37
39
  model: z.string().max(200).optional().describe("AI model name"),
40
+ node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
41
+ network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
38
42
  },
39
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl }) => {
40
- console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
43
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId }) => {
44
+ const effectiveNetId = getNetworkId(netId);
45
+ console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
41
46
  const trimmedOutput = output?.slice(0, 4000);
42
47
 
43
48
  try {
44
49
  db.run("BEGIN IMMEDIATE");
45
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
50
+ // Only delete same-alias sessions within the same network (prevent cross-network alias conflict)
51
+ if (effectiveNetId) {
52
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
53
+ } else {
54
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
55
+ }
46
56
  db.run(
47
- `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, last_seen_at, updated_at)
48
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'), datetime('now'))
57
+ `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, last_seen_at, updated_at)
58
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
49
59
  ON CONFLICT(resume_id) DO UPDATE SET
50
60
  alias = COALESCE(?2, sessions.alias),
51
61
  tmux_name = COALESCE(?3, sessions.tmux_name),
@@ -64,9 +74,10 @@ export function registerTools(server: McpServer, clientIP?: string) {
64
74
  session_id = COALESCE(?16, sessions.session_id),
65
75
  config_path = COALESCE(?17, sessions.config_path),
66
76
  channels = COALESCE(?18, sessions.channels),
77
+ network_id = COALESCE(?19, sessions.network_id),
67
78
  last_seen_at = datetime('now'),
68
79
  updated_at = datetime('now')`,
69
- [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, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null]
80
+ [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, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, netId ?? null]
70
81
  );
71
82
  db.run("COMMIT");
72
83
  } catch (e) {
@@ -77,11 +88,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
77
88
  // V2: sync tasks table — report_status(working) → tasks.running
78
89
  if (status === "working" && task) {
79
90
  try {
80
- db.run(
91
+ const runResult = db.run(
81
92
  `UPDATE tasks SET status = 'running', started_at = datetime('now')
82
93
  WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
83
94
  [alias, task]
84
95
  );
96
+ if (runResult.changes > 0) {
97
+ // Find task_id for logging
98
+ const t = db.query<{ task_id: string }, [string, string]>(
99
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
100
+ ).get(alias, task);
101
+ if (t) logTaskEvent(t.task_id, null, "running", alias);
102
+ }
85
103
  } catch {}
86
104
  }
87
105
 
@@ -103,7 +121,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
103
121
  server = COALESCE(?8, nodes.server),
104
122
  hostname = COALESCE(?9, nodes.hostname),
105
123
  updated_at = datetime('now')`,
106
- [node_id, alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
124
+ [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
107
125
  );
108
126
  } catch {}
109
127
  }
@@ -179,6 +197,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
179
197
  }
180
198
 
181
199
  db.run("COMMIT");
200
+ // Log event after commit
201
+ const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
202
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
203
+ ).get(alias)?.task_id);
204
+ if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
182
205
  } catch (e) {
183
206
  try { db.run("ROLLBACK"); } catch {}
184
207
  throw e;
@@ -233,10 +256,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
233
256
  }
234
257
  // V2: sync tasks table — ack_inbox means delivered→acked
235
258
  try {
236
- db.run(
259
+ const ackResult = db.run(
237
260
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
238
261
  [message_id]
239
262
  );
263
+ if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
240
264
  } catch {}
241
265
  return {
242
266
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -254,9 +278,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
254
278
  {
255
279
  filter_status: z.string().max(50).optional(),
256
280
  filter_server: z.string().max(200).optional(),
281
+ network_id: z.string().max(200).optional().describe("Filter by network"),
257
282
  },
258
- async ({ filter_status, filter_server }) => {
259
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_server ? " server=" + filter_server : ""}`);
283
+ async ({ filter_status, filter_server, network_id: netId }) => {
284
+ const effectiveNetId = getNetworkId(netId);
285
+ console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
260
286
 
261
287
  const sessions = db.transaction(() => {
262
288
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
@@ -264,6 +290,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
264
290
 
265
291
  let sql = "SELECT * FROM sessions WHERE 1=1";
266
292
  const params: any[] = [];
293
+ if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
267
294
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
268
295
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
269
296
  sql += " ORDER BY updated_at DESC";
@@ -319,24 +346,41 @@ export function registerTools(server: McpServer, clientIP?: string) {
319
346
  priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
320
347
  context: z.string().max(10000).optional(),
321
348
  from_session: z.string().max(200).optional().default("hub"),
349
+ ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
350
+ network_id: z.string().max(200).optional().describe("Network scope"),
322
351
  },
323
- async ({ alias, task, priority, context, from_session }) => {
352
+ async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
353
+ const effectiveNetId = getNetworkId(netId);
354
+
355
+ // License check
356
+ const license = db.query<any, []>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1").get();
357
+ if (license?.expires_at) {
358
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
359
+ if (license.expires_at < now) {
360
+ return { content: [{ type: "text" as const, text: JSON.stringify({
361
+ ok: false, error: "license_expired",
362
+ message: "Trial expired. Activate a license: anet activate <key>",
363
+ }) }] };
364
+ }
365
+ }
366
+
324
367
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
325
368
  const id = uuidv4();
326
369
  // 事务:inbox + tasks 双写
327
370
  try {
328
371
  db.run("BEGIN IMMEDIATE");
329
372
  db.run(
330
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
331
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
332
- [id, alias, priority, task, context ?? null, from_session]
373
+ `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
374
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
375
+ [id, alias, priority, task, context ?? null, from_session, effectiveNetId]
333
376
  );
334
377
  db.run(
335
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
336
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'))`,
337
- [id, from_session, alias, priority, task]
378
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
379
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
380
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
338
381
  );
339
382
  db.run("COMMIT");
383
+ logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
340
384
  } catch (e) {
341
385
  try { db.run("ROLLBACK"); } catch {}
342
386
  throw e;
@@ -415,6 +459,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
415
459
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
416
460
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
417
461
  const id = uuidv4();
462
+ let replyLogged = false;
418
463
  try {
419
464
  db.run("BEGIN IMMEDIATE");
420
465
  db.run(
@@ -432,6 +477,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
432
477
  );
433
478
  if (result.changes === 0) {
434
479
  console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
480
+ } else {
481
+ replyLogged = true;
435
482
  }
436
483
  }
437
484
  db.run("COMMIT");
@@ -440,6 +487,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
440
487
  throw e;
441
488
  }
442
489
 
490
+ // Log event after commit (outside transaction)
491
+ if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
492
+
443
493
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
444
494
  pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
445
495
 
@@ -466,6 +516,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
466
516
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
467
517
  [task_id]
468
518
  );
519
+ if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
469
520
  return {
470
521
  content: [{
471
522
  type: "text" as const,
@@ -475,6 +526,173 @@ export function registerTools(server: McpServer, clientIP?: string) {
475
526
  }
476
527
  );
477
528
 
529
+ // ── V2: retry_task (重新投递失败/过期任务) ──
530
+ server.tool(
531
+ "retry_task",
532
+ "Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
533
+ {
534
+ task_id: z.string().min(1).max(200).describe("Task ID to retry"),
535
+ from_session: z.string().max(200).optional().default("hub"),
536
+ },
537
+ async ({ task_id, from_session }) => {
538
+ console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
539
+ // Find the original task
540
+ const task = db.query<any, [string]>(
541
+ "SELECT * FROM tasks WHERE task_id = ?1"
542
+ ).get(task_id);
543
+ if (!task) {
544
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
545
+ }
546
+ if (!["failed", "expired", "cancelled"].includes(task.status)) {
547
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
548
+ }
549
+ try {
550
+ db.run("BEGIN IMMEDIATE");
551
+ // Reset task status
552
+ db.run(
553
+ `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
554
+ WHERE task_id = ?1`,
555
+ [task_id]
556
+ );
557
+ // Re-queue in inbox with new ID (original ID may already exist)
558
+ const retryInboxId = uuidv4();
559
+ db.run(
560
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
561
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
562
+ [retryInboxId, task.to_name, task.priority, task.content, from_session]
563
+ );
564
+ db.run("COMMIT");
565
+ logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
566
+ } catch (e) {
567
+ try { db.run("ROLLBACK"); } catch {}
568
+ throw e;
569
+ }
570
+ // SSE push
571
+ pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
572
+ return {
573
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
574
+ };
575
+ }
576
+ );
577
+
578
+ // ── V2: get_task (查询任务状态) ──
579
+ server.tool(
580
+ "get_task",
581
+ "Get task details by task_id. Returns status, result, timestamps.",
582
+ {
583
+ task_id: z.string().min(1).max(200).describe("Task ID to query"),
584
+ },
585
+ async ({ task_id }) => {
586
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
587
+ return {
588
+ content: [{
589
+ type: "text" as const,
590
+ text: JSON.stringify(task ? { ok: true, task } : { ok: false, error: "task not found" }),
591
+ }],
592
+ };
593
+ }
594
+ );
595
+
596
+ // ── V2: list_tasks (查询任务列表) ──
597
+ server.tool(
598
+ "list_tasks",
599
+ "List tasks with filters. Agents can query their own pending/running tasks.",
600
+ {
601
+ alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
602
+ status: z.string().max(50).optional().describe("Filter by status"),
603
+ from_name: z.string().max(200).optional().describe("Filter by sender"),
604
+ network_id: z.string().max(200).optional().describe("Filter by network"),
605
+ limit: z.number().min(1).max(100).optional().default(20),
606
+ },
607
+ async ({ alias, status, from_name, network_id: netId, limit }) => {
608
+ const effectiveNetId = getNetworkId(netId);
609
+ let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
610
+ const params: any[] = [];
611
+ if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
612
+ if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
613
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
614
+ if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
615
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
616
+ params.push(limit);
617
+ const tasks = db.query(sql).all(...params);
618
+
619
+ // Stats
620
+ const stats = db.query<any, []>(
621
+ "SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
622
+ ).all();
623
+
624
+ return {
625
+ content: [{
626
+ type: "text" as const,
627
+ text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
628
+ }],
629
+ };
630
+ }
631
+ );
632
+
633
+ // ── V2: cancel_task (取消任务) ──
634
+ server.tool(
635
+ "cancel_task",
636
+ "Cancel a pending task. Works on delivered/acked/running tasks.",
637
+ {
638
+ task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
639
+ reason: z.string().max(1000).optional().describe("Cancellation reason"),
640
+ from_session: z.string().max(200).optional().default("hub"),
641
+ },
642
+ async ({ task_id, reason, from_session }) => {
643
+ console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
644
+ const result = db.run(
645
+ `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
646
+ WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
647
+ [reason || "cancelled by " + from_session, task_id]
648
+ );
649
+ // Also ack the inbox entry to prevent agent from picking it up
650
+ if (result.changes > 0) {
651
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
652
+ logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
653
+ }
654
+ return {
655
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
656
+ };
657
+ }
658
+ );
659
+
660
+ // ── V2: reassign_task (转移任务到另一个 agent) ──
661
+ server.tool(
662
+ "reassign_task",
663
+ "Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
664
+ {
665
+ task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
666
+ new_alias: z.string().min(1).max(200).describe("Target agent alias"),
667
+ from_session: z.string().max(200).optional().default("hub"),
668
+ },
669
+ async ({ task_id, new_alias, from_session }) => {
670
+ console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
671
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
672
+ if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
673
+ if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
674
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
675
+ }
676
+ const oldAlias = task.to_name;
677
+ try {
678
+ db.run("BEGIN IMMEDIATE");
679
+ // Ack old inbox to prevent original agent from picking it up
680
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
681
+ db.run("UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2", [new_alias, task_id]);
682
+ const newInboxId = uuidv4();
683
+ db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
684
+ [newInboxId, new_alias, task.priority, task.content, from_session]);
685
+ db.run("COMMIT");
686
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
687
+ } catch (e) {
688
+ try { db.run("ROLLBACK"); } catch {}
689
+ throw e;
690
+ }
691
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
692
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
693
+ }
694
+ );
695
+
478
696
  server.tool(
479
697
  "broadcast",
480
698
  "Send a message to multiple sessions.",
@@ -482,11 +700,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
482
700
  message: z.string().min(1).max(10000),
483
701
  filter_server: z.string().max(200).optional(),
484
702
  filter_status: z.string().max(50).optional(),
703
+ network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
485
704
  },
486
- async ({ message, filter_server, filter_status }) => {
487
- console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${filter_server ? " [server=" + filter_server + "]" : ""}`);
705
+ async ({ message, filter_server, filter_status, network_id: netId }) => {
706
+ console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${netId ? " [net=" + netId.slice(0, 12) + "]" : ""}`);
488
707
  let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
489
708
  const params: any[] = [];
709
+ if (netId) { sql += " AND network_id = ?"; params.push(netId); }
490
710
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
491
711
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
492
712