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

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/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