@sleep2agi/commhub-server 0.4.4 → 0.5.0-preview.10

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.4.4",
3
+ "version": "0.5.0-preview.10",
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,134 @@
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
+ }
package/src/db.ts CHANGED
@@ -58,7 +58,183 @@ db.exec(`
58
58
  );
59
59
  `);
60
60
 
61
+ // ── V2 schema migration (ALTER TABLE, safe to re-run) ──
62
+
63
+ // sessions: add node_id, session_id, config_path, channels, last_seen_at
64
+ for (const col of [
65
+ { name: "node_id", def: "TEXT" },
66
+ { name: "session_id", def: "TEXT" },
67
+ { name: "config_path", def: "TEXT" },
68
+ { name: "channels", def: "TEXT" },
69
+ { name: "last_seen_at", def: "TEXT" },
70
+ ]) {
71
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
72
+ }
73
+
74
+ // inbox: add in_reply_to, requires_response, expires_at, scope
75
+ for (const col of [
76
+ { name: "in_reply_to", def: "TEXT" },
77
+ { name: "requires_response", def: "TEXT DEFAULT 'reply'" },
78
+ { name: "expires_at", def: "TEXT" },
79
+ { name: "scope", def: "TEXT DEFAULT 'single'" },
80
+ ]) {
81
+ try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
82
+ }
83
+
84
+ // indexes for new columns
85
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_type ON inbox(type)"); } catch {}
86
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_from ON inbox(from_session)"); } catch {}
87
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_reply ON inbox(in_reply_to)"); } catch {}
88
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_node ON sessions(node_id)"); } catch {}
89
+
90
+ // tasks table (V2)
91
+ db.exec(`
92
+ CREATE TABLE IF NOT EXISTS tasks (
93
+ task_id TEXT PRIMARY KEY,
94
+ from_node_id TEXT,
95
+ from_name TEXT NOT NULL DEFAULT 'hub',
96
+ to_node_id TEXT,
97
+ to_name TEXT NOT NULL,
98
+ priority TEXT NOT NULL DEFAULT 'normal',
99
+ status TEXT NOT NULL DEFAULT 'created',
100
+ content TEXT NOT NULL,
101
+ result TEXT,
102
+ in_reply_to TEXT,
103
+ requires_response TEXT DEFAULT 'reply',
104
+ scope TEXT DEFAULT 'single',
105
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
106
+ delivered_at TEXT,
107
+ started_at TEXT,
108
+ completed_at TEXT,
109
+ expires_at TEXT
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_tasks_to ON tasks(to_name);
113
+ CREATE INDEX IF NOT EXISTS idx_tasks_from ON tasks(from_name);
114
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
115
+ CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at);
116
+ `);
117
+
118
+ // nodes table (V2 Sprint 2) — persistent node identity, separate from runtime sessions
119
+ db.exec(`
120
+ CREATE TABLE IF NOT EXISTS nodes (
121
+ node_id TEXT PRIMARY KEY,
122
+ node_name TEXT NOT NULL,
123
+ alias TEXT,
124
+ runtime TEXT,
125
+ model TEXT,
126
+ config_path TEXT,
127
+ channels TEXT,
128
+ server TEXT,
129
+ hostname TEXT,
130
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
131
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
132
+ );
133
+
134
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(node_name);
135
+ CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
136
+ `);
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: add network_id to existing tables ──
205
+ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
206
+ try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
207
+ }
208
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
209
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
210
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
211
+
61
212
  // Helpers
62
213
  export function uuidv4(): string {
63
214
  return crypto.randomUUID();
64
215
  }
216
+
217
+ export function generateId(prefix: string): string {
218
+ return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
219
+ }
220
+
221
+ export function hashPassword(password: string): string {
222
+ return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
223
+ }
224
+
225
+ export function hashToken(token: string): string {
226
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex");
227
+ }
228
+
229
+ export function generateToken(): string {
230
+ return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
231
+ }
232
+
233
+ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
234
+ try {
235
+ db.run(
236
+ "INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
237
+ [taskId, fromStatus, toStatus, actor, detail ?? null]
238
+ );
239
+ } catch {}
240
+ }
package/src/index.ts CHANGED
@@ -2,8 +2,9 @@ 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
+ import { register, login, resolveToken, getUserNetworks, createNetwork, 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;
@@ -74,6 +75,25 @@ function withCors(req: Request, res: Response): Response {
74
75
  const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
75
76
 
76
77
 
78
+ // ── Task expiration patrol (every 5 minutes) ──
79
+ setInterval(() => {
80
+ try {
81
+ const result = db.run(
82
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
83
+ WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
84
+ AND status IN ('created', 'delivered')`
85
+ );
86
+ if (result.changes > 0) {
87
+ console.log(`[patrol] expired ${result.changes} stale task(s)`);
88
+ // Log events for expired tasks
89
+ const expired = db.query<{ task_id: string }, []>(
90
+ "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
91
+ ).all();
92
+ for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
93
+ }
94
+ } catch {}
95
+ }, 5 * 60 * 1000);
96
+
77
97
  Bun.serve({
78
98
  port: PORT,
79
99
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
@@ -88,13 +108,16 @@ Bun.serve({
88
108
 
89
109
  // ── WebSocket: tmux terminal ──
90
110
  const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
91
- if (wsMatch && server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) {
92
- return; // upgraded
111
+ if (wsMatch) {
112
+ const authErr = requireAuth(req);
113
+ if (authErr) return withCors(req, authErr);
114
+ if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) return;
93
115
  }
94
116
 
95
117
  // ── MCP Streamable HTTP endpoint ──
96
- // MCP protocol handles its own auth — skip token check here
97
118
  if (url.pathname === "/mcp") {
119
+ const authErr = requireAuth(req);
120
+ if (authErr) return withCors(req, authErr);
98
121
  const fwd = req.headers.get("x-forwarded-for");
99
122
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
100
123
  const transport = new WebStandardStreamableHTTPServerTransport({
@@ -118,6 +141,91 @@ Bun.serve({
118
141
  return createSSEStream(sessionName);
119
142
  }
120
143
 
144
+ // ── V3: Auth endpoints (public) ──
145
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
146
+ try {
147
+ const body = await req.json() as any;
148
+ const result = register(body.username, body.password, body.email, body.display_name);
149
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
150
+ } catch (e: any) {
151
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
152
+ }
153
+ }
154
+
155
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
156
+ try {
157
+ const body = await req.json() as any;
158
+ const result = login(body.username, body.password);
159
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
160
+ } catch (e: any) {
161
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
162
+ }
163
+ }
164
+
165
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
166
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
167
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
168
+ const resolved = resolveToken(token);
169
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
170
+ const networks = getUserNetworks(resolved.user.user_id);
171
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
172
+ }
173
+
174
+ // ── V3: Network management ──
175
+ if (url.pathname === "/api/networks" && req.method === "GET") {
176
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
177
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
178
+ const resolved = resolveToken(token);
179
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
180
+ const networks = getUserNetworks(resolved.user.user_id);
181
+ return withCors(req, Response.json({ ok: true, networks }));
182
+ }
183
+
184
+ if (url.pathname === "/api/networks" && req.method === "POST") {
185
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
186
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
187
+ const resolved = resolveToken(token);
188
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
189
+ try {
190
+ const body = await req.json() as any;
191
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
192
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
193
+ } catch (e: any) {
194
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
195
+ }
196
+ }
197
+
198
+ // ── V3: Admin APIs (require auth) ──
199
+ if (url.pathname === "/api/users" && req.method === "GET") {
200
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
201
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
202
+ const resolved = resolveToken(token);
203
+ if (!resolved || resolved.user.role !== "admin") {
204
+ return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
205
+ }
206
+ const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
207
+ return withCors(req, Response.json({ ok: true, users }));
208
+ }
209
+
210
+ const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
211
+ if (netDetailMatch && req.method === "GET") {
212
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
213
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
214
+ const resolved = resolveToken(token);
215
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
216
+ const networkId = netDetailMatch[1];
217
+ const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
218
+ if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
219
+ // Get network stats
220
+ const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
221
+ const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
222
+ const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
223
+ return withCors(req, Response.json({
224
+ ok: true, network,
225
+ stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
226
+ }));
227
+ }
228
+
121
229
  // ── REST: health (public, no auth) ──
122
230
  if (url.pathname === "/health") {
123
231
  const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
@@ -264,6 +372,72 @@ Bun.serve({
264
372
  return withCors(req, Response.json({ ok: true, messages: rows }));
265
373
  }
266
374
 
375
+ // ── REST: stats summary ──
376
+ if (url.pathname === "/api/stats") {
377
+ const taskStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
378
+ const sessionStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
379
+ const totalTasks = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
380
+ const totalNodes = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
381
+ const recentTasks = db.query<any, []>(
382
+ "SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5"
383
+ ).all();
384
+ return withCors(req, Response.json({
385
+ ok: true,
386
+ tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
387
+ sessions: { by_status: sessionStats },
388
+ nodes: { total: totalNodes?.cnt || 0 },
389
+ recent_tasks: recentTasks,
390
+ }));
391
+ }
392
+
393
+ // ── REST: task events (V2 Sprint 2) ──
394
+ if (url.pathname === "/api/task_events") {
395
+ const taskId = url.searchParams.get("task_id");
396
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
397
+ let sql = "SELECT * FROM task_events";
398
+ const params: any[] = [];
399
+ if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
400
+ sql += " ORDER BY created_at DESC LIMIT ?";
401
+ params.push(limit);
402
+ const rows = db.query(sql).all(...params);
403
+ return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
404
+ }
405
+
406
+ // ── REST: nodes table (V2 Sprint 2) ──
407
+ if (url.pathname === "/api/nodes") {
408
+ const nodeId = url.searchParams.get("node_id");
409
+ const alias = url.searchParams.get("alias");
410
+ let sql = "SELECT * FROM nodes WHERE 1=1";
411
+ const params: any[] = [];
412
+ if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
413
+ if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
414
+ sql += " ORDER BY updated_at DESC";
415
+ const rows = db.query(sql).all(...params);
416
+ return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
417
+ }
418
+
419
+ // ── REST: tasks table (V2) ──
420
+ if (url.pathname === "/api/tasks") {
421
+ const taskId = url.searchParams.get("task_id");
422
+ const status = url.searchParams.get("status");
423
+ const toName = url.searchParams.get("to_name");
424
+ const fromName = url.searchParams.get("from_name");
425
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
426
+
427
+ let sql = "SELECT * FROM tasks WHERE 1=1";
428
+ const params: any[] = [];
429
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
430
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
431
+ if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
432
+ if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
433
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
434
+ params.push(limit);
435
+
436
+ const rows = db.query(sql).all(...params);
437
+ const stats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
438
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
439
+ }
440
+
267
441
  // ── REST: recent completions ──
268
442
  if (url.pathname === "/api/completions") {
269
443
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
@@ -280,6 +454,7 @@ Endpoints:
280
454
  GET /health - Health check
281
455
  GET /api/status - All sessions ${AUTH_TOKEN ? "(auth required)" : ""}
282
456
  POST /api/task - Send task via REST ${AUTH_TOKEN ? "(auth required)" : ""}
457
+ GET /api/tasks - Tasks table (V2) ${AUTH_TOKEN ? "(auth required)" : ""}
283
458
  GET /api/completions - Recent completions ${AUTH_TOKEN ? "(auth required)" : ""}
284
459
  GET /api/tmux/:name - Capture tmux pane output ${AUTH_TOKEN ? "(auth required)" : ""}
285
460
  POST /api/tmux/:name/send - Send keys to tmux ${AUTH_TOKEN ? "(auth required)" : ""}
package/src/tools.ts CHANGED
@@ -1,6 +1,6 @@
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 {
@@ -18,7 +18,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
18
18
  {
19
19
  resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
20
20
  alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
21
- status: z.enum(["working", "idle", "blocked", "error", "waiting_input"]),
21
+ status: z.enum(["working", "idle", "blocked", "error", "waiting_input", "offline"]),
22
22
  task: z.string().max(10000).optional().describe("Current task description"),
23
23
  output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
24
24
  score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
@@ -29,36 +29,92 @@ export function registerTools(server: McpServer, clientIP?: string) {
29
29
  project_dir: z.string().max(1000).optional().describe("Agent working directory"),
30
30
  version: z.string().max(100).optional().describe("Agent version"),
31
31
  tmux_name: z.string().max(200).optional().describe("tmux session name"),
32
+ // V2 fields
33
+ node_id: z.string().max(200).optional().describe("Stable node identifier"),
34
+ session_id: z.string().max(200).optional().describe("Runtime session/thread ID"),
35
+ config_path: z.string().max(1000).optional().describe("Config file path"),
36
+ channels: z.string().max(2000).optional().describe("JSON array of channels"),
37
+ model: z.string().max(200).optional().describe("AI model name"),
38
+ node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
32
39
  },
33
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux }) => {
40
+ 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 }) => {
34
41
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
35
42
  const trimmedOutput = output?.slice(0, 4000);
36
43
 
37
- // Wrap DELETE + UPSERT in transaction to prevent race conditions
38
- db.run("BEGIN IMMEDIATE");
39
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
40
- db.run(
41
- `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, updated_at)
42
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))
43
- ON CONFLICT(resume_id) DO UPDATE SET
44
- alias = COALESCE(?2, sessions.alias),
45
- tmux_name = COALESCE(?3, sessions.tmux_name),
46
- server = COALESCE(?4, sessions.server),
47
- ip = COALESCE(?5, sessions.ip),
48
- hostname = COALESCE(?6, sessions.hostname),
49
- agent = COALESCE(?7, sessions.agent),
50
- project_dir = COALESCE(?8, sessions.project_dir),
51
- version = COALESCE(?9, sessions.version),
52
- status = ?10,
53
- task = COALESCE(?11, sessions.task),
54
- output = COALESCE(?12, sessions.output),
55
- progress = COALESCE(?13, sessions.progress),
56
- score = COALESCE(?14, sessions.score),
57
- updated_at = datetime('now')`,
58
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null]
59
- );
44
+ try {
45
+ db.run("BEGIN IMMEDIATE");
46
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
47
+ db.run(
48
+ `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)
49
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'), datetime('now'))
50
+ ON CONFLICT(resume_id) DO UPDATE SET
51
+ alias = COALESCE(?2, sessions.alias),
52
+ tmux_name = COALESCE(?3, sessions.tmux_name),
53
+ server = COALESCE(?4, sessions.server),
54
+ ip = COALESCE(?5, sessions.ip),
55
+ hostname = COALESCE(?6, sessions.hostname),
56
+ agent = COALESCE(?7, sessions.agent),
57
+ project_dir = COALESCE(?8, sessions.project_dir),
58
+ version = COALESCE(?9, sessions.version),
59
+ status = ?10,
60
+ task = COALESCE(?11, sessions.task),
61
+ output = COALESCE(?12, sessions.output),
62
+ progress = COALESCE(?13, sessions.progress),
63
+ score = COALESCE(?14, sessions.score),
64
+ node_id = COALESCE(?15, sessions.node_id),
65
+ session_id = COALESCE(?16, sessions.session_id),
66
+ config_path = COALESCE(?17, sessions.config_path),
67
+ channels = COALESCE(?18, sessions.channels),
68
+ last_seen_at = datetime('now'),
69
+ updated_at = datetime('now')`,
70
+ [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]
71
+ );
72
+ db.run("COMMIT");
73
+ } catch (e) {
74
+ try { db.run("ROLLBACK"); } catch {}
75
+ throw e;
76
+ }
77
+
78
+ // V2: sync tasks table — report_status(working) → tasks.running
79
+ if (status === "working" && task) {
80
+ try {
81
+ const runResult = db.run(
82
+ `UPDATE tasks SET status = 'running', started_at = datetime('now')
83
+ WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
84
+ [alias, task]
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
+ }
93
+ } catch {}
94
+ }
60
95
 
61
- db.run("COMMIT");
96
+ // V2: upsert nodes table for persistent node identity
97
+ if (node_id) {
98
+ try {
99
+ // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
100
+ const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
101
+ db.run(
102
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
103
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
104
+ ON CONFLICT(node_id) DO UPDATE SET
105
+ node_name = COALESCE(?2, nodes.node_name),
106
+ alias = COALESCE(?3, nodes.alias),
107
+ runtime = COALESCE(?4, nodes.runtime),
108
+ model = COALESCE(?5, nodes.model),
109
+ config_path = COALESCE(?6, nodes.config_path),
110
+ channels = COALESCE(?7, nodes.channels),
111
+ server = COALESCE(?8, nodes.server),
112
+ hostname = COALESCE(?9, nodes.hostname),
113
+ updated_at = datetime('now')`,
114
+ [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
115
+ );
116
+ } catch {}
117
+ }
62
118
 
63
119
  // inbox uses alias for routing
64
120
  const row = db.query<{ cnt: number }, [string]>(
@@ -95,17 +151,51 @@ export function registerTools(server: McpServer, clientIP?: string) {
95
151
  async ({ alias, task, result, artifacts, score, duration_minutes }) => {
96
152
  console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}`);
97
153
  const id = uuidv4();
98
- db.run(
99
- `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
100
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
101
- [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
102
- );
154
+ try {
155
+ db.run("BEGIN IMMEDIATE");
156
+ db.run(
157
+ `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
158
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
159
+ [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
160
+ );
103
161
 
104
- db.run(
105
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
106
- WHERE alias = ?1`,
107
- [alias]
108
- );
162
+ db.run(
163
+ `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
164
+ WHERE alias = ?1`,
165
+ [alias]
166
+ );
167
+
168
+ // V2: sync tasks table — try by task_id first, then by content
169
+ const taskUpdate = db.run(
170
+ `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
171
+ WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
172
+ [result.slice(0, 4000), task]
173
+ );
174
+ if (taskUpdate.changes === 0) {
175
+ // fallback: match most recent task by to_name + content (legacy path)
176
+ const match = db.query<{ task_id: string }, [string, string]>(
177
+ `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
178
+ AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`
179
+ ).get(alias, task);
180
+ if (match) {
181
+ db.run(
182
+ `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
183
+ WHERE task_id = ?2`,
184
+ [result.slice(0, 4000), match.task_id]
185
+ );
186
+ }
187
+ }
188
+
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");
195
+ } catch (e) {
196
+ try { db.run("ROLLBACK"); } catch {}
197
+ throw e;
198
+ }
109
199
 
110
200
  return {
111
201
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
@@ -154,6 +244,14 @@ export function registerTools(server: McpServer, clientIP?: string) {
154
244
  content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
155
245
  };
156
246
  }
247
+ // V2: sync tasks table — ack_inbox means delivered→acked
248
+ try {
249
+ const ackResult = db.run(
250
+ `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
251
+ [message_id]
252
+ );
253
+ if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
254
+ } catch {}
157
255
  return {
158
256
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
159
257
  };
@@ -235,16 +333,30 @@ export function registerTools(server: McpServer, clientIP?: string) {
235
333
  priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
236
334
  context: z.string().max(10000).optional(),
237
335
  from_session: z.string().max(200).optional().default("hub"),
336
+ ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
238
337
  },
239
- async ({ alias, task, priority, context, from_session }) => {
338
+ async ({ alias, task, priority, context, from_session, ttl_seconds }) => {
240
339
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
241
340
  const id = uuidv4();
242
- // inbox.session_name stores alias
243
- db.run(
244
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session)
245
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
246
- [id, alias, priority, task, context ?? null, from_session]
247
- );
341
+ // 事务:inbox + tasks 双写
342
+ try {
343
+ db.run("BEGIN IMMEDIATE");
344
+ db.run(
345
+ `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
346
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
347
+ [id, alias, priority, task, context ?? null, from_session]
348
+ );
349
+ db.run(
350
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
351
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6))`,
352
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`]
353
+ );
354
+ db.run("COMMIT");
355
+ logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
356
+ } catch (e) {
357
+ try { db.run("ROLLBACK"); } catch {}
358
+ throw e;
359
+ }
248
360
 
249
361
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
250
362
 
@@ -305,6 +417,251 @@ export function registerTools(server: McpServer, clientIP?: string) {
305
417
  }
306
418
  );
307
419
 
420
+ // ── V2: send_reply (关联 task_id,不触发 think) ──
421
+ server.tool(
422
+ "send_reply",
423
+ "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
424
+ {
425
+ alias: z.string().min(1).max(200).describe("Target session alias"),
426
+ text: z.string().min(1).max(10000).describe("Reply content"),
427
+ in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
428
+ status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
429
+ from_session: z.string().max(200).optional().default("hub"),
430
+ },
431
+ async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
432
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
433
+ const id = uuidv4();
434
+ let replyLogged = false;
435
+ try {
436
+ db.run("BEGIN IMMEDIATE");
437
+ db.run(
438
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
439
+ VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
440
+ [id, alias, text, from_session, in_reply_to ?? null]
441
+ );
442
+
443
+ // 更新 tasks 表
444
+ if (in_reply_to) {
445
+ const result = db.run(
446
+ `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
447
+ WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
448
+ [replyStatus, text, in_reply_to]
449
+ );
450
+ if (result.changes === 0) {
451
+ console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
452
+ } else {
453
+ replyLogged = true;
454
+ }
455
+ }
456
+ db.run("COMMIT");
457
+ } catch (e) {
458
+ try { db.run("ROLLBACK"); } catch {}
459
+ throw e;
460
+ }
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
+
465
+ const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
466
+ pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
467
+
468
+ return {
469
+ content: [{
470
+ type: "text" as const,
471
+ text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
472
+ }],
473
+ };
474
+ }
475
+ );
476
+
477
+ // ── V2: send_ack (不入 inbox,仅更新状态) ──
478
+ server.tool(
479
+ "send_ack",
480
+ "Acknowledge receipt of a task. Does NOT enter inbox. Updates task status only.",
481
+ {
482
+ task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
483
+ from_session: z.string().max(200).optional().default("hub"),
484
+ },
485
+ async ({ task_id, from_session }) => {
486
+ console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
487
+ const result = db.run(
488
+ `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
489
+ [task_id]
490
+ );
491
+ if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
492
+ return {
493
+ content: [{
494
+ type: "text" as const,
495
+ text: JSON.stringify({ ok: result.changes > 0, task_id, updated: result.changes }),
496
+ }],
497
+ };
498
+ }
499
+ );
500
+
501
+ // ── V2: retry_task (重新投递失败/过期任务) ──
502
+ server.tool(
503
+ "retry_task",
504
+ "Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
505
+ {
506
+ task_id: z.string().min(1).max(200).describe("Task ID to retry"),
507
+ from_session: z.string().max(200).optional().default("hub"),
508
+ },
509
+ async ({ task_id, from_session }) => {
510
+ console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
511
+ // Find the original task
512
+ const task = db.query<any, [string]>(
513
+ "SELECT * FROM tasks WHERE task_id = ?1"
514
+ ).get(task_id);
515
+ if (!task) {
516
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
517
+ }
518
+ if (!["failed", "expired", "cancelled"].includes(task.status)) {
519
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
520
+ }
521
+ try {
522
+ db.run("BEGIN IMMEDIATE");
523
+ // Reset task status
524
+ db.run(
525
+ `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
526
+ WHERE task_id = ?1`,
527
+ [task_id]
528
+ );
529
+ // Re-queue in inbox with new ID (original ID may already exist)
530
+ const retryInboxId = uuidv4();
531
+ db.run(
532
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
533
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
534
+ [retryInboxId, task.to_name, task.priority, task.content, from_session]
535
+ );
536
+ db.run("COMMIT");
537
+ logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
538
+ } catch (e) {
539
+ try { db.run("ROLLBACK"); } catch {}
540
+ throw e;
541
+ }
542
+ // SSE push
543
+ pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
544
+ return {
545
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
546
+ };
547
+ }
548
+ );
549
+
550
+ // ── V2: get_task (查询任务状态) ──
551
+ server.tool(
552
+ "get_task",
553
+ "Get task details by task_id. Returns status, result, timestamps.",
554
+ {
555
+ task_id: z.string().min(1).max(200).describe("Task ID to query"),
556
+ },
557
+ async ({ task_id }) => {
558
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
559
+ return {
560
+ content: [{
561
+ type: "text" as const,
562
+ text: JSON.stringify(task ? { ok: true, task } : { ok: false, error: "task not found" }),
563
+ }],
564
+ };
565
+ }
566
+ );
567
+
568
+ // ── V2: list_tasks (查询任务列表) ──
569
+ server.tool(
570
+ "list_tasks",
571
+ "List tasks with filters. Agents can query their own pending/running tasks.",
572
+ {
573
+ alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
574
+ status: z.string().max(50).optional().describe("Filter by status"),
575
+ from_name: z.string().max(200).optional().describe("Filter by sender"),
576
+ limit: z.number().min(1).max(100).optional().default(20),
577
+ },
578
+ async ({ alias, status, from_name, limit }) => {
579
+ let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
580
+ const params: any[] = [];
581
+ if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
582
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
583
+ if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
584
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
585
+ params.push(limit);
586
+ const tasks = db.query(sql).all(...params);
587
+
588
+ // Stats
589
+ const stats = db.query<any, []>(
590
+ "SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
591
+ ).all();
592
+
593
+ return {
594
+ content: [{
595
+ type: "text" as const,
596
+ text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
597
+ }],
598
+ };
599
+ }
600
+ );
601
+
602
+ // ── V2: cancel_task (取消任务) ──
603
+ server.tool(
604
+ "cancel_task",
605
+ "Cancel a pending task. Works on delivered/acked/running tasks.",
606
+ {
607
+ task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
608
+ reason: z.string().max(1000).optional().describe("Cancellation reason"),
609
+ from_session: z.string().max(200).optional().default("hub"),
610
+ },
611
+ async ({ task_id, reason, from_session }) => {
612
+ console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
613
+ const result = db.run(
614
+ `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
615
+ WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
616
+ [reason || "cancelled by " + from_session, task_id]
617
+ );
618
+ // Also ack the inbox entry to prevent agent from picking it up
619
+ if (result.changes > 0) {
620
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
621
+ logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
622
+ }
623
+ return {
624
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
625
+ };
626
+ }
627
+ );
628
+
629
+ // ── V2: reassign_task (转移任务到另一个 agent) ──
630
+ server.tool(
631
+ "reassign_task",
632
+ "Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
633
+ {
634
+ task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
635
+ new_alias: z.string().min(1).max(200).describe("Target agent alias"),
636
+ from_session: z.string().max(200).optional().default("hub"),
637
+ },
638
+ async ({ task_id, new_alias, from_session }) => {
639
+ console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
640
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
641
+ if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
642
+ if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
643
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
644
+ }
645
+ const oldAlias = task.to_name;
646
+ try {
647
+ db.run("BEGIN IMMEDIATE");
648
+ // Ack old inbox to prevent original agent from picking it up
649
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
650
+ 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]);
651
+ const newInboxId = uuidv4();
652
+ db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
653
+ [newInboxId, new_alias, task.priority, task.content, from_session]);
654
+ db.run("COMMIT");
655
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
656
+ } catch (e) {
657
+ try { db.run("ROLLBACK"); } catch {}
658
+ throw e;
659
+ }
660
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
661
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
662
+ }
663
+ );
664
+
308
665
  server.tool(
309
666
  "broadcast",
310
667
  "Send a message to multiple sessions.",