@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 +99 -0
- package/package.json +1 -1
- package/src/auth.ts +134 -0
- package/src/db.ts +176 -0
- package/src/index.ts +179 -4
- package/src/tools.ts +401 -44
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
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
|
|
92
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.",
|