@sleep2agi/commhub-server 0.5.0-preview.5 → 0.5.0-preview.7

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,99 @@
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
+ 设置 `COMMHUB_AUTH_TOKEN` 后, 所有端点需要 Bearer token:
93
+ - Header: `Authorization: Bearer <token>`
94
+ - 或 Query: `?token=<token>`
95
+ - `/health` 不需要 auth
96
+
97
+ ## License
98
+
99
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.5",
3
+ "version": "0.5.0-preview.7",
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/db.ts CHANGED
@@ -147,7 +147,7 @@ db.exec(`
147
147
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
148
148
  );
149
149
 
150
- CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id);
150
+ CREATE INDEX IF NOT EXISTS idx_task_events_task_time ON task_events(task_id, created_at DESC);
151
151
  CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
152
152
  `);
153
153
 
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ 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 } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
7
 
8
8
  const PORT = Number(process.env.PORT) || 9200;
@@ -84,6 +84,11 @@ setInterval(() => {
84
84
  );
85
85
  if (result.changes > 0) {
86
86
  console.log(`[patrol] expired ${result.changes} stale task(s)`);
87
+ // Log events for expired tasks
88
+ const expired = db.query<{ task_id: string }, []>(
89
+ "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
90
+ ).all();
91
+ for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
87
92
  }
88
93
  } catch {}
89
94
  }, 5 * 60 * 1000);
package/src/tools.ts CHANGED
@@ -78,11 +78,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
78
78
  // V2: sync tasks table — report_status(working) → tasks.running
79
79
  if (status === "working" && task) {
80
80
  try {
81
- db.run(
81
+ const runResult = db.run(
82
82
  `UPDATE tasks SET status = 'running', started_at = datetime('now')
83
83
  WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
84
84
  [alias, task]
85
85
  );
86
+ if (runResult.changes > 0) {
87
+ // Find task_id for logging
88
+ const t = db.query<{ task_id: string }, [string, string]>(
89
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
90
+ ).get(alias, task);
91
+ if (t) logTaskEvent(t.task_id, null, "running", alias);
92
+ }
86
93
  } catch {}
87
94
  }
88
95
 
@@ -180,6 +187,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
180
187
  }
181
188
 
182
189
  db.run("COMMIT");
190
+ // Log event after commit
191
+ const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
192
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
193
+ ).get(alias)?.task_id);
194
+ if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
183
195
  } catch (e) {
184
196
  try { db.run("ROLLBACK"); } catch {}
185
197
  throw e;
@@ -419,6 +431,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
419
431
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
420
432
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
421
433
  const id = uuidv4();
434
+ let replyLogged = false;
422
435
  try {
423
436
  db.run("BEGIN IMMEDIATE");
424
437
  db.run(
@@ -437,7 +450,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
437
450
  if (result.changes === 0) {
438
451
  console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
439
452
  } else {
440
- logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
453
+ replyLogged = true;
441
454
  }
442
455
  }
443
456
  db.run("COMMIT");
@@ -446,6 +459,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
446
459
  throw e;
447
460
  }
448
461
 
462
+ // Log event after commit (outside transaction)
463
+ if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
464
+
449
465
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
450
466
  pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
451
467
 
@@ -472,6 +488,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
472
488
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
473
489
  [task_id]
474
490
  );
491
+ if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
475
492
  return {
476
493
  content: [{
477
494
  type: "text" as const,
@@ -548,6 +565,69 @@ export function registerTools(server: McpServer, clientIP?: string) {
548
565
  }
549
566
  );
550
567
 
568
+ // ── V2: cancel_task (取消任务) ──
569
+ server.tool(
570
+ "cancel_task",
571
+ "Cancel a pending task. Works on delivered/acked/running tasks.",
572
+ {
573
+ task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
574
+ reason: z.string().max(1000).optional().describe("Cancellation reason"),
575
+ from_session: z.string().max(200).optional().default("hub"),
576
+ },
577
+ async ({ task_id, reason, from_session }) => {
578
+ console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
579
+ const result = db.run(
580
+ `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
581
+ WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
582
+ [reason || "cancelled by " + from_session, task_id]
583
+ );
584
+ // Also ack the inbox entry to prevent agent from picking it up
585
+ if (result.changes > 0) {
586
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
587
+ logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
588
+ }
589
+ return {
590
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
591
+ };
592
+ }
593
+ );
594
+
595
+ // ── V2: reassign_task (转移任务到另一个 agent) ──
596
+ server.tool(
597
+ "reassign_task",
598
+ "Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
599
+ {
600
+ task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
601
+ new_alias: z.string().min(1).max(200).describe("Target agent alias"),
602
+ from_session: z.string().max(200).optional().default("hub"),
603
+ },
604
+ async ({ task_id, new_alias, from_session }) => {
605
+ console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
606
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
607
+ if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
608
+ if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
609
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
610
+ }
611
+ const oldAlias = task.to_name;
612
+ try {
613
+ db.run("BEGIN IMMEDIATE");
614
+ // Ack old inbox to prevent original agent from picking it up
615
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
616
+ 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]);
617
+ const newInboxId = uuidv4();
618
+ db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
619
+ [newInboxId, new_alias, task.priority, task.content, from_session]);
620
+ db.run("COMMIT");
621
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
622
+ } catch (e) {
623
+ try { db.run("ROLLBACK"); } catch {}
624
+ throw e;
625
+ }
626
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
627
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
628
+ }
629
+ );
630
+
551
631
  server.tool(
552
632
  "broadcast",
553
633
  "Send a message to multiple sessions.",