@sleep2agi/commhub-server 0.5.0-preview.1 → 0.5.0-preview.11

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.1",
3
+ "version": "0.5.0-preview.11",
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
@@ -115,7 +115,126 @@ db.exec(`
115
115
  CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at);
116
116
  `);
117
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
+
118
212
  // Helpers
119
213
  export function uuidv4(): string {
120
214
  return crypto.randomUUID();
121
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;
@@ -84,6 +85,11 @@ setInterval(() => {
84
85
  );
85
86
  if (result.changes > 0) {
86
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");
87
93
  }
88
94
  } catch {}
89
95
  }, 5 * 60 * 1000);
@@ -135,6 +141,115 @@ Bun.serve({
135
141
  return createSSEStream(sessionName);
136
142
  }
137
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
+ if (url.pathname === "/api/auth/me" && req.method === "PUT") {
175
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
176
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
177
+ const resolved = resolveToken(token);
178
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
179
+ try {
180
+ const body = await req.json() as any;
181
+ const updates: string[] = [];
182
+ const params: any[] = [];
183
+ if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
184
+ if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
185
+ if (updates.length > 0) {
186
+ updates.push(`updated_at = datetime('now')`);
187
+ params.push(resolved.user.user_id);
188
+ db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
189
+ }
190
+ // Re-fetch
191
+ 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);
192
+ return withCors(req, Response.json({ ok: true, user }));
193
+ } catch (e: any) {
194
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
195
+ }
196
+ }
197
+
198
+ // ── V3: Network management ──
199
+ if (url.pathname === "/api/networks" && req.method === "GET") {
200
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
201
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
202
+ const resolved = resolveToken(token);
203
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
204
+ const networks = getUserNetworks(resolved.user.user_id);
205
+ return withCors(req, Response.json({ ok: true, networks }));
206
+ }
207
+
208
+ if (url.pathname === "/api/networks" && req.method === "POST") {
209
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
210
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
211
+ const resolved = resolveToken(token);
212
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
213
+ try {
214
+ const body = await req.json() as any;
215
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
216
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
217
+ } catch (e: any) {
218
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
219
+ }
220
+ }
221
+
222
+ // ── V3: Admin APIs (require auth) ──
223
+ if (url.pathname === "/api/users" && req.method === "GET") {
224
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
225
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
226
+ const resolved = resolveToken(token);
227
+ if (!resolved || resolved.user.role !== "admin") {
228
+ return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
229
+ }
230
+ const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
231
+ return withCors(req, Response.json({ ok: true, users }));
232
+ }
233
+
234
+ const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
235
+ if (netDetailMatch && req.method === "GET") {
236
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
237
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
238
+ const resolved = resolveToken(token);
239
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
240
+ const networkId = netDetailMatch[1];
241
+ const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
242
+ if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
243
+ // Get network stats
244
+ const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
245
+ const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
246
+ const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
247
+ return withCors(req, Response.json({
248
+ ok: true, network,
249
+ stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
250
+ }));
251
+ }
252
+
138
253
  // ── REST: health (public, no auth) ──
139
254
  if (url.pathname === "/health") {
140
255
  const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
@@ -281,6 +396,50 @@ Bun.serve({
281
396
  return withCors(req, Response.json({ ok: true, messages: rows }));
282
397
  }
283
398
 
399
+ // ── REST: stats summary ──
400
+ if (url.pathname === "/api/stats") {
401
+ const taskStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
402
+ const sessionStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
403
+ const totalTasks = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
404
+ const totalNodes = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
405
+ const recentTasks = db.query<any, []>(
406
+ "SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5"
407
+ ).all();
408
+ return withCors(req, Response.json({
409
+ ok: true,
410
+ tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
411
+ sessions: { by_status: sessionStats },
412
+ nodes: { total: totalNodes?.cnt || 0 },
413
+ recent_tasks: recentTasks,
414
+ }));
415
+ }
416
+
417
+ // ── REST: task events (V2 Sprint 2) ──
418
+ if (url.pathname === "/api/task_events") {
419
+ const taskId = url.searchParams.get("task_id");
420
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
421
+ let sql = "SELECT * FROM task_events";
422
+ const params: any[] = [];
423
+ if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
424
+ sql += " ORDER BY created_at DESC LIMIT ?";
425
+ params.push(limit);
426
+ const rows = db.query(sql).all(...params);
427
+ return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
428
+ }
429
+
430
+ // ── REST: nodes table (V2 Sprint 2) ──
431
+ if (url.pathname === "/api/nodes") {
432
+ const nodeId = url.searchParams.get("node_id");
433
+ const alias = url.searchParams.get("alias");
434
+ let sql = "SELECT * FROM nodes WHERE 1=1";
435
+ const params: any[] = [];
436
+ if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
437
+ if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
438
+ sql += " ORDER BY updated_at DESC";
439
+ const rows = db.query(sql).all(...params);
440
+ return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
441
+ }
442
+
284
443
  // ── REST: tasks table (V2) ──
285
444
  if (url.pathname === "/api/tasks") {
286
445
  const taskId = url.searchParams.get("task_id");
@@ -299,7 +458,8 @@ Bun.serve({
299
458
  params.push(limit);
300
459
 
301
460
  const rows = db.query(sql).all(...params);
302
- return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
461
+ const stats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
462
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
303
463
  }
304
464
 
305
465
  // ── REST: recent completions ──
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 {
@@ -34,8 +34,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
34
34
  session_id: z.string().max(200).optional().describe("Runtime session/thread ID"),
35
35
  config_path: z.string().max(1000).optional().describe("Config file path"),
36
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)"),
39
+ network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
37
40
  },
38
- 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 }) => {
41
+ 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 }) => {
39
42
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
40
43
  const trimmedOutput = output?.slice(0, 4000);
41
44
 
@@ -43,8 +46,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
43
46
  db.run("BEGIN IMMEDIATE");
44
47
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
45
48
  db.run(
46
- `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)
47
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'), datetime('now'))
49
+ `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)
50
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
48
51
  ON CONFLICT(resume_id) DO UPDATE SET
49
52
  alias = COALESCE(?2, sessions.alias),
50
53
  tmux_name = COALESCE(?3, sessions.tmux_name),
@@ -63,9 +66,10 @@ export function registerTools(server: McpServer, clientIP?: string) {
63
66
  session_id = COALESCE(?16, sessions.session_id),
64
67
  config_path = COALESCE(?17, sessions.config_path),
65
68
  channels = COALESCE(?18, sessions.channels),
69
+ network_id = COALESCE(?19, sessions.network_id),
66
70
  last_seen_at = datetime('now'),
67
71
  updated_at = datetime('now')`,
68
- [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]
72
+ [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]
69
73
  );
70
74
  db.run("COMMIT");
71
75
  } catch (e) {
@@ -76,11 +80,41 @@ export function registerTools(server: McpServer, clientIP?: string) {
76
80
  // V2: sync tasks table — report_status(working) → tasks.running
77
81
  if (status === "working" && task) {
78
82
  try {
79
- db.run(
83
+ const runResult = db.run(
80
84
  `UPDATE tasks SET status = 'running', started_at = datetime('now')
81
85
  WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
82
86
  [alias, task]
83
87
  );
88
+ if (runResult.changes > 0) {
89
+ // Find task_id for logging
90
+ const t = db.query<{ task_id: string }, [string, string]>(
91
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
92
+ ).get(alias, task);
93
+ if (t) logTaskEvent(t.task_id, null, "running", alias);
94
+ }
95
+ } catch {}
96
+ }
97
+
98
+ // V2: upsert nodes table for persistent node identity
99
+ if (node_id) {
100
+ try {
101
+ // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
102
+ const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
103
+ db.run(
104
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
105
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
106
+ ON CONFLICT(node_id) DO UPDATE SET
107
+ node_name = COALESCE(?2, nodes.node_name),
108
+ alias = COALESCE(?3, nodes.alias),
109
+ runtime = COALESCE(?4, nodes.runtime),
110
+ model = COALESCE(?5, nodes.model),
111
+ config_path = COALESCE(?6, nodes.config_path),
112
+ channels = COALESCE(?7, nodes.channels),
113
+ server = COALESCE(?8, nodes.server),
114
+ hostname = COALESCE(?9, nodes.hostname),
115
+ updated_at = datetime('now')`,
116
+ [node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
117
+ );
84
118
  } catch {}
85
119
  }
86
120
 
@@ -155,6 +189,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
155
189
  }
156
190
 
157
191
  db.run("COMMIT");
192
+ // Log event after commit
193
+ const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
194
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
195
+ ).get(alias)?.task_id);
196
+ if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
158
197
  } catch (e) {
159
198
  try { db.run("ROLLBACK"); } catch {}
160
199
  throw e;
@@ -209,10 +248,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
209
248
  }
210
249
  // V2: sync tasks table — ack_inbox means delivered→acked
211
250
  try {
212
- db.run(
251
+ const ackResult = db.run(
213
252
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
214
253
  [message_id]
215
254
  );
255
+ if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
216
256
  } catch {}
217
257
  return {
218
258
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -295,24 +335,27 @@ export function registerTools(server: McpServer, clientIP?: string) {
295
335
  priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
296
336
  context: z.string().max(10000).optional(),
297
337
  from_session: z.string().max(200).optional().default("hub"),
338
+ ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
339
+ network_id: z.string().max(200).optional().describe("Network scope"),
298
340
  },
299
- async ({ alias, task, priority, context, from_session }) => {
341
+ async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
300
342
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
301
343
  const id = uuidv4();
302
344
  // 事务:inbox + tasks 双写
303
345
  try {
304
346
  db.run("BEGIN IMMEDIATE");
305
347
  db.run(
306
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
307
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
308
- [id, alias, priority, task, context ?? null, from_session]
348
+ `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
349
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
350
+ [id, alias, priority, task, context ?? null, from_session, netId ?? null]
309
351
  );
310
352
  db.run(
311
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
312
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'))`,
313
- [id, from_session, alias, priority, task]
353
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
354
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
355
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, netId ?? null]
314
356
  );
315
357
  db.run("COMMIT");
358
+ logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
316
359
  } catch (e) {
317
360
  try { db.run("ROLLBACK"); } catch {}
318
361
  throw e;
@@ -391,6 +434,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
391
434
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
392
435
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
393
436
  const id = uuidv4();
437
+ let replyLogged = false;
394
438
  try {
395
439
  db.run("BEGIN IMMEDIATE");
396
440
  db.run(
@@ -408,6 +452,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
408
452
  );
409
453
  if (result.changes === 0) {
410
454
  console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
455
+ } else {
456
+ replyLogged = true;
411
457
  }
412
458
  }
413
459
  db.run("COMMIT");
@@ -416,6 +462,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
416
462
  throw e;
417
463
  }
418
464
 
465
+ // Log event after commit (outside transaction)
466
+ if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
467
+
419
468
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
420
469
  pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
421
470
 
@@ -442,6 +491,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
442
491
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
443
492
  [task_id]
444
493
  );
494
+ if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
445
495
  return {
446
496
  content: [{
447
497
  type: "text" as const,
@@ -451,6 +501,170 @@ export function registerTools(server: McpServer, clientIP?: string) {
451
501
  }
452
502
  );
453
503
 
504
+ // ── V2: retry_task (重新投递失败/过期任务) ──
505
+ server.tool(
506
+ "retry_task",
507
+ "Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
508
+ {
509
+ task_id: z.string().min(1).max(200).describe("Task ID to retry"),
510
+ from_session: z.string().max(200).optional().default("hub"),
511
+ },
512
+ async ({ task_id, from_session }) => {
513
+ console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
514
+ // Find the original task
515
+ const task = db.query<any, [string]>(
516
+ "SELECT * FROM tasks WHERE task_id = ?1"
517
+ ).get(task_id);
518
+ if (!task) {
519
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
520
+ }
521
+ if (!["failed", "expired", "cancelled"].includes(task.status)) {
522
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
523
+ }
524
+ try {
525
+ db.run("BEGIN IMMEDIATE");
526
+ // Reset task status
527
+ db.run(
528
+ `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
529
+ WHERE task_id = ?1`,
530
+ [task_id]
531
+ );
532
+ // Re-queue in inbox with new ID (original ID may already exist)
533
+ const retryInboxId = uuidv4();
534
+ db.run(
535
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
536
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
537
+ [retryInboxId, task.to_name, task.priority, task.content, from_session]
538
+ );
539
+ db.run("COMMIT");
540
+ logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
541
+ } catch (e) {
542
+ try { db.run("ROLLBACK"); } catch {}
543
+ throw e;
544
+ }
545
+ // SSE push
546
+ pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
547
+ return {
548
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
549
+ };
550
+ }
551
+ );
552
+
553
+ // ── V2: get_task (查询任务状态) ──
554
+ server.tool(
555
+ "get_task",
556
+ "Get task details by task_id. Returns status, result, timestamps.",
557
+ {
558
+ task_id: z.string().min(1).max(200).describe("Task ID to query"),
559
+ },
560
+ async ({ task_id }) => {
561
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
562
+ return {
563
+ content: [{
564
+ type: "text" as const,
565
+ text: JSON.stringify(task ? { ok: true, task } : { ok: false, error: "task not found" }),
566
+ }],
567
+ };
568
+ }
569
+ );
570
+
571
+ // ── V2: list_tasks (查询任务列表) ──
572
+ server.tool(
573
+ "list_tasks",
574
+ "List tasks with filters. Agents can query their own pending/running tasks.",
575
+ {
576
+ alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
577
+ status: z.string().max(50).optional().describe("Filter by status"),
578
+ from_name: z.string().max(200).optional().describe("Filter by sender"),
579
+ limit: z.number().min(1).max(100).optional().default(20),
580
+ },
581
+ async ({ alias, status, from_name, limit }) => {
582
+ let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
583
+ const params: any[] = [];
584
+ if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
585
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
586
+ if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
587
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
588
+ params.push(limit);
589
+ const tasks = db.query(sql).all(...params);
590
+
591
+ // Stats
592
+ const stats = db.query<any, []>(
593
+ "SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
594
+ ).all();
595
+
596
+ return {
597
+ content: [{
598
+ type: "text" as const,
599
+ text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
600
+ }],
601
+ };
602
+ }
603
+ );
604
+
605
+ // ── V2: cancel_task (取消任务) ──
606
+ server.tool(
607
+ "cancel_task",
608
+ "Cancel a pending task. Works on delivered/acked/running tasks.",
609
+ {
610
+ task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
611
+ reason: z.string().max(1000).optional().describe("Cancellation reason"),
612
+ from_session: z.string().max(200).optional().default("hub"),
613
+ },
614
+ async ({ task_id, reason, from_session }) => {
615
+ console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
616
+ const result = db.run(
617
+ `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
618
+ WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
619
+ [reason || "cancelled by " + from_session, task_id]
620
+ );
621
+ // Also ack the inbox entry to prevent agent from picking it up
622
+ if (result.changes > 0) {
623
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
624
+ logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
625
+ }
626
+ return {
627
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
628
+ };
629
+ }
630
+ );
631
+
632
+ // ── V2: reassign_task (转移任务到另一个 agent) ──
633
+ server.tool(
634
+ "reassign_task",
635
+ "Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
636
+ {
637
+ task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
638
+ new_alias: z.string().min(1).max(200).describe("Target agent alias"),
639
+ from_session: z.string().max(200).optional().default("hub"),
640
+ },
641
+ async ({ task_id, new_alias, from_session }) => {
642
+ console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
643
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
644
+ if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
645
+ if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
646
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
647
+ }
648
+ const oldAlias = task.to_name;
649
+ try {
650
+ db.run("BEGIN IMMEDIATE");
651
+ // Ack old inbox to prevent original agent from picking it up
652
+ db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
653
+ 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]);
654
+ const newInboxId = uuidv4();
655
+ db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
656
+ [newInboxId, new_alias, task.priority, task.content, from_session]);
657
+ db.run("COMMIT");
658
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
659
+ } catch (e) {
660
+ try { db.run("ROLLBACK"); } catch {}
661
+ throw e;
662
+ }
663
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
664
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
665
+ }
666
+ );
667
+
454
668
  server.tool(
455
669
  "broadcast",
456
670
  "Send a message to multiple sessions.",