@sleep2agi/commhub-server 0.5.0-preview.3 → 0.5.0-preview.30
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 +162 -0
- package/package.json +7 -6
- package/src/auth.ts +340 -0
- package/src/db-adapter.ts +238 -0
- package/src/db.ts +225 -10
- package/src/index.ts +634 -39
- package/src/tools.ts +448 -164
package/src/tools.ts
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
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
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
5
6
|
|
|
6
7
|
function ts(): string {
|
|
7
8
|
return new Date().toTimeString().slice(0, 8);
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function registerTools(server: McpServer, clientIP?: string) {
|
|
11
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null) {
|
|
12
|
+
// If enforceNetworkId is set, override any client-supplied network_id
|
|
13
|
+
const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
|
|
14
|
+
|
|
15
|
+
// Check if the user has write access to the enforced network
|
|
16
|
+
// utok_ (no networkId) cannot do MCP writes — only ntok_/atok_ with network binding can
|
|
17
|
+
const canWrite = (): boolean => {
|
|
18
|
+
if (!enforceUserId) return true; // legacy global token mode, allow
|
|
19
|
+
if (!enforceNetworkId) return false; // utok_ has no network → cannot write MCP
|
|
20
|
+
const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
|
|
21
|
+
return !!role && role !== "viewer"; // owner/admin/member can write, viewer cannot
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
|
|
25
|
+
if (!networkId) return sql;
|
|
26
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
27
|
+
params.push(networkId);
|
|
28
|
+
return sql;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
32
|
+
const params: any[] = [alias];
|
|
33
|
+
let sql = "SELECT status FROM sessions WHERE alias = ?1";
|
|
34
|
+
sql = addScope(sql, params, networkId);
|
|
35
|
+
return db.get<any>(sql, ...params);
|
|
36
|
+
};
|
|
11
37
|
// ═══════════════════════════════════════════
|
|
12
38
|
// Child Agent Tools (4)
|
|
13
39
|
// ═══════════════════════════════════════════
|
|
@@ -36,53 +62,58 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
36
62
|
channels: z.string().max(2000).optional().describe("JSON array of channels"),
|
|
37
63
|
model: z.string().max(200).optional().describe("AI model name"),
|
|
38
64
|
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
65
|
+
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
39
66
|
},
|
|
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 }) => {
|
|
41
|
-
|
|
67
|
+
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 }) => {
|
|
68
|
+
const effectiveNetId = getNetworkId(netId);
|
|
69
|
+
if (!canWrite()) {
|
|
70
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
71
|
+
}
|
|
72
|
+
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
|
|
42
73
|
const trimmedOutput = output?.slice(0, 4000);
|
|
43
74
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
75
|
+
db.transaction(() => {
|
|
76
|
+
// Only delete same-alias sessions within the same network
|
|
77
|
+
if (effectiveNetId) {
|
|
78
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
|
|
79
|
+
} else {
|
|
80
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
|
|
81
|
+
}
|
|
47
82
|
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'))
|
|
83
|
+
`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)
|
|
84
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
|
|
50
85
|
ON CONFLICT(resume_id) DO UPDATE SET
|
|
51
|
-
alias = COALESCE(?2, sessions.alias),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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]
|
|
86
|
+
alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
87
|
+
server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
|
|
88
|
+
hostname = COALESCE(?6, sessions.hostname), agent = COALESCE(?7, sessions.agent),
|
|
89
|
+
project_dir = COALESCE(?8, sessions.project_dir), version = COALESCE(?9, sessions.version),
|
|
90
|
+
status = ?10, task = COALESCE(?11, sessions.task),
|
|
91
|
+
output = COALESCE(?12, sessions.output), progress = COALESCE(?13, sessions.progress),
|
|
92
|
+
score = COALESCE(?14, sessions.score), node_id = COALESCE(?15, sessions.node_id),
|
|
93
|
+
session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
|
|
94
|
+
channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
|
|
95
|
+
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
96
|
+
[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, effectiveNetId ?? null]
|
|
71
97
|
);
|
|
72
|
-
|
|
73
|
-
} catch (e) {
|
|
74
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
75
|
-
throw e;
|
|
76
|
-
}
|
|
98
|
+
});
|
|
77
99
|
|
|
78
100
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
79
101
|
if (status === "working" && task) {
|
|
80
102
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2
|
|
84
|
-
|
|
85
|
-
);
|
|
103
|
+
const runParams: any[] = [alias, task];
|
|
104
|
+
let runSql = `UPDATE tasks SET status = 'running', started_at = datetime('now')
|
|
105
|
+
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`;
|
|
106
|
+
runSql = addScope(runSql, runParams, effectiveNetId);
|
|
107
|
+
const runResult = db.run(runSql, runParams);
|
|
108
|
+
if (runResult.changes > 0) {
|
|
109
|
+
// Find task_id for logging
|
|
110
|
+
const findParams: any[] = [alias, task];
|
|
111
|
+
let findSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running'";
|
|
112
|
+
findSql = addScope(findSql, findParams, effectiveNetId);
|
|
113
|
+
findSql += " ORDER BY started_at DESC LIMIT 1";
|
|
114
|
+
const t = db.get<{ task_id: string }>(findSql, ...findParams);
|
|
115
|
+
if (t) logTaskEvent(t.task_id, null, "running", alias);
|
|
116
|
+
}
|
|
86
117
|
} catch {}
|
|
87
118
|
}
|
|
88
119
|
|
|
@@ -92,8 +123,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
92
123
|
// Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
|
|
93
124
|
const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
|
|
94
125
|
db.run(
|
|
95
|
-
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
|
|
96
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
|
|
126
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
|
|
127
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
|
|
97
128
|
ON CONFLICT(node_id) DO UPDATE SET
|
|
98
129
|
node_name = COALESCE(?2, nodes.node_name),
|
|
99
130
|
alias = COALESCE(?3, nodes.alias),
|
|
@@ -103,16 +134,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
103
134
|
channels = COALESCE(?7, nodes.channels),
|
|
104
135
|
server = COALESCE(?8, nodes.server),
|
|
105
136
|
hostname = COALESCE(?9, nodes.hostname),
|
|
137
|
+
network_id = COALESCE(?10, nodes.network_id),
|
|
106
138
|
updated_at = datetime('now')`,
|
|
107
|
-
[node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
|
|
139
|
+
[node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
|
|
108
140
|
);
|
|
109
141
|
} catch {}
|
|
110
142
|
}
|
|
111
143
|
|
|
112
144
|
// inbox uses alias for routing
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
const inboxParams: any[] = [alias];
|
|
146
|
+
let inboxSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
147
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
148
|
+
const row = db.get<{ cnt: number }>(inboxSql, ...inboxParams);
|
|
116
149
|
|
|
117
150
|
return {
|
|
118
151
|
content: [
|
|
@@ -140,50 +173,54 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
140
173
|
artifacts: z.array(z.string().max(2000)).max(50).optional().describe("Output URLs or file paths"),
|
|
141
174
|
score: z.number().min(0).max(10).optional(),
|
|
142
175
|
duration_minutes: z.number().min(0).optional(),
|
|
176
|
+
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
143
177
|
},
|
|
144
|
-
async ({ alias, task, result, artifacts, score, duration_minutes }) => {
|
|
145
|
-
|
|
178
|
+
async ({ alias, task, result, artifacts, score, duration_minutes, network_id: netId }) => {
|
|
179
|
+
const effectiveNetId = getNetworkId(netId);
|
|
180
|
+
if (!canWrite()) {
|
|
181
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
182
|
+
}
|
|
183
|
+
console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}${effectiveNetId ? " [net]" : ""}`);
|
|
146
184
|
const id = uuidv4();
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
db.run(
|
|
150
|
-
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
|
|
151
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
|
152
|
-
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
|
|
153
|
-
);
|
|
154
|
-
|
|
185
|
+
let updatedTaskId: string | null = null;
|
|
186
|
+
db.transaction(() => {
|
|
155
187
|
db.run(
|
|
156
|
-
`
|
|
157
|
-
|
|
158
|
-
[alias]
|
|
188
|
+
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes, network_id)
|
|
189
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
|
|
190
|
+
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null, effectiveNetId ?? null]
|
|
159
191
|
);
|
|
192
|
+
const sessionParams: any[] = [alias];
|
|
193
|
+
let sessionSql = `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
|
|
194
|
+
WHERE alias = ?1`;
|
|
195
|
+
sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
|
|
196
|
+
db.run(sessionSql, sessionParams);
|
|
160
197
|
|
|
161
198
|
// V2: sync tasks table — try by task_id first, then by content
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')
|
|
165
|
-
|
|
166
|
-
);
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
199
|
+
const taskParams: any[] = [result.slice(0, 4000), task];
|
|
200
|
+
let taskSql = `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
|
|
201
|
+
WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`;
|
|
202
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
203
|
+
const tu = db.run(taskSql, taskParams);
|
|
204
|
+
if (tu.changes === 0) {
|
|
205
|
+
const matchParams: any[] = [alias, task];
|
|
206
|
+
let matchSql = `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
|
|
207
|
+
AND status IN ('delivered', 'acked', 'running')`;
|
|
208
|
+
matchSql = addScope(matchSql, matchParams, effectiveNetId);
|
|
209
|
+
matchSql += " ORDER BY created_at DESC LIMIT 1";
|
|
210
|
+
const match = db.get<{ task_id: string }>(matchSql, ...matchParams);
|
|
173
211
|
if (match) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
212
|
+
const matchUpdateParams: any[] = [result.slice(0, 4000), match.task_id];
|
|
213
|
+
let matchUpdateSql = "UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now') WHERE task_id = ?2";
|
|
214
|
+
matchUpdateSql = addScope(matchUpdateSql, matchUpdateParams, effectiveNetId);
|
|
215
|
+
db.run(matchUpdateSql, matchUpdateParams);
|
|
216
|
+
updatedTaskId = match.task_id;
|
|
179
217
|
}
|
|
218
|
+
} else {
|
|
219
|
+
updatedTaskId = task;
|
|
180
220
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
185
|
-
throw e;
|
|
186
|
-
}
|
|
221
|
+
});
|
|
222
|
+
// Log event after transaction
|
|
223
|
+
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
187
224
|
|
|
188
225
|
return {
|
|
189
226
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
|
|
@@ -199,16 +236,20 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
199
236
|
limit: z.number().min(1).max(100).optional().default(10),
|
|
200
237
|
},
|
|
201
238
|
async ({ alias, limit }) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
239
|
+
const effectiveNetId = getNetworkId(null);
|
|
240
|
+
const countParams: any[] = [alias];
|
|
241
|
+
let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
242
|
+
countSql = addScope(countSql, countParams, effectiveNetId);
|
|
243
|
+
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
205
244
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
245
|
+
const rowsParams: any[] = [alias];
|
|
246
|
+
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
|
|
247
|
+
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
248
|
+
rowsSql = addScope(rowsSql, rowsParams, effectiveNetId);
|
|
249
|
+
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
250
|
+
LIMIT ?${rowsParams.length + 1}`;
|
|
251
|
+
rowsParams.push(limit);
|
|
252
|
+
const rows = db.all(rowsSql, ...rowsParams);
|
|
212
253
|
|
|
213
254
|
return {
|
|
214
255
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
@@ -225,8 +266,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
225
266
|
response: z.string().max(10000).optional(),
|
|
226
267
|
},
|
|
227
268
|
async ({ alias, message_id, response }) => {
|
|
269
|
+
const effectiveNetId = getNetworkId(null);
|
|
270
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
228
271
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
229
|
-
const
|
|
272
|
+
const ackParams: any[] = [message_id, alias];
|
|
273
|
+
let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
|
|
274
|
+
ackSql = addScope(ackSql, ackParams, effectiveNetId);
|
|
275
|
+
const result = db.run(ackSql, ackParams);
|
|
230
276
|
if (result.changes === 0) {
|
|
231
277
|
return {
|
|
232
278
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
|
|
@@ -234,10 +280,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
234
280
|
}
|
|
235
281
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
236
282
|
try {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
283
|
+
const taskParams: any[] = [message_id];
|
|
284
|
+
let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
|
|
285
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
286
|
+
const ackResult = db.run(taskSql, taskParams);
|
|
287
|
+
if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
|
|
241
288
|
} catch {}
|
|
242
289
|
return {
|
|
243
290
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
@@ -255,25 +302,33 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
255
302
|
{
|
|
256
303
|
filter_status: z.string().max(50).optional(),
|
|
257
304
|
filter_server: z.string().max(200).optional(),
|
|
305
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
258
306
|
},
|
|
259
|
-
async ({ filter_status, filter_server }) => {
|
|
260
|
-
|
|
307
|
+
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
308
|
+
const effectiveNetId = getNetworkId(netId);
|
|
309
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
|
|
261
310
|
|
|
262
311
|
const sessions = db.transaction(() => {
|
|
263
312
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
264
|
-
|
|
313
|
+
const staleParams: any[] = [cutoff];
|
|
314
|
+
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
315
|
+
staleSql = addScope(staleSql, staleParams, effectiveNetId);
|
|
316
|
+
db.run(staleSql, staleParams);
|
|
265
317
|
|
|
266
318
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
267
319
|
const params: any[] = [];
|
|
320
|
+
if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
|
|
268
321
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
269
322
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
270
323
|
sql += " ORDER BY updated_at DESC";
|
|
271
|
-
return db.
|
|
272
|
-
})
|
|
324
|
+
return db.all(sql, ...params);
|
|
325
|
+
});
|
|
273
326
|
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
327
|
+
const summaryParams: any[] = [];
|
|
328
|
+
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
329
|
+
summarySql = addScope(summarySql, summaryParams, effectiveNetId);
|
|
330
|
+
summarySql += " GROUP BY status";
|
|
331
|
+
const summary = db.all(summarySql, ...summaryParams);
|
|
277
332
|
|
|
278
333
|
return {
|
|
279
334
|
content: [
|
|
@@ -291,14 +346,23 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
291
346
|
"Get detailed status of a specific session by alias.",
|
|
292
347
|
{ alias: z.string().min(1).max(200).describe("Session alias") },
|
|
293
348
|
async ({ alias }) => {
|
|
349
|
+
const effectiveNetId = getNetworkId(null);
|
|
294
350
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
351
|
+
const sessionParams: any[] = [alias];
|
|
352
|
+
let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
|
|
353
|
+
sessionSql = addScope(sessionSql, sessionParams, effectiveNetId);
|
|
354
|
+
const session = db.get(sessionSql, ...sessionParams);
|
|
355
|
+
|
|
356
|
+
const pendingParams: any[] = [alias];
|
|
357
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
358
|
+
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
359
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
360
|
+
|
|
361
|
+
const recentParams: any[] = [alias];
|
|
362
|
+
let recentSql = "SELECT * FROM completions WHERE session_name = ?1";
|
|
363
|
+
recentSql = addScope(recentSql, recentParams, effectiveNetId);
|
|
364
|
+
recentSql += " ORDER BY completed_at DESC LIMIT 5";
|
|
365
|
+
const recent = db.all(recentSql, ...recentParams);
|
|
302
366
|
|
|
303
367
|
return {
|
|
304
368
|
content: [
|
|
@@ -321,36 +385,53 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
321
385
|
context: z.string().max(10000).optional(),
|
|
322
386
|
from_session: z.string().max(200).optional().default("hub"),
|
|
323
387
|
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
388
|
+
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
324
389
|
},
|
|
325
|
-
async ({ alias, task, priority, context, from_session, ttl_seconds }) => {
|
|
390
|
+
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
391
|
+
const effectiveNetId = getNetworkId(netId);
|
|
392
|
+
|
|
393
|
+
// Role check: viewer cannot send tasks
|
|
394
|
+
if (!canWrite()) {
|
|
395
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// License check
|
|
399
|
+
const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
|
|
400
|
+
if (license?.expires_at) {
|
|
401
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
402
|
+
if (license.expires_at < now) {
|
|
403
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
404
|
+
ok: false, error: "license_expired",
|
|
405
|
+
message: "Trial expired. Activate a license: anet activate <key>",
|
|
406
|
+
}) }] };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
326
410
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
327
411
|
const id = uuidv4();
|
|
328
412
|
// 事务:inbox + tasks 双写
|
|
329
|
-
|
|
330
|
-
db.run("BEGIN IMMEDIATE");
|
|
413
|
+
db.transaction(() => {
|
|
331
414
|
db.run(
|
|
332
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
|
|
333
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
|
|
334
|
-
[id, alias, priority, task, context ?? null, from_session]
|
|
415
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
416
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
417
|
+
[id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
|
|
335
418
|
);
|
|
336
419
|
db.run(
|
|
337
|
-
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
|
|
338
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6))`,
|
|
339
|
-
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds
|
|
420
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
421
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
422
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null]
|
|
340
423
|
);
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
344
|
-
throw e;
|
|
345
|
-
}
|
|
424
|
+
});
|
|
425
|
+
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
346
426
|
|
|
347
|
-
const session =
|
|
427
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
348
428
|
|
|
349
429
|
// SSE push by alias
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
430
|
+
const pendingParams: any[] = [alias];
|
|
431
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
432
|
+
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
433
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
434
|
+
if (session) pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
|
|
354
435
|
|
|
355
436
|
return {
|
|
356
437
|
content: [
|
|
@@ -376,17 +457,19 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
376
457
|
from_session: z.string().max(200).optional().default("hub"),
|
|
377
458
|
},
|
|
378
459
|
async ({ alias, message, from_session }) => {
|
|
460
|
+
const effectiveNetId = getNetworkId(null);
|
|
461
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
379
462
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
380
463
|
const id = uuidv4();
|
|
381
464
|
db.run(
|
|
382
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
383
|
-
VALUES (?1, ?2, 'message', 'normal', ?3, ?4)`,
|
|
384
|
-
[id, alias, message, from_session]
|
|
465
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
466
|
+
VALUES (?1, ?2, 'message', 'normal', ?3, ?4, ?5)`,
|
|
467
|
+
[id, alias, message, from_session, effectiveNetId ?? null]
|
|
385
468
|
);
|
|
386
469
|
|
|
387
|
-
const session =
|
|
470
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
388
471
|
|
|
389
|
-
pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
472
|
+
if (session) pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
390
473
|
|
|
391
474
|
return {
|
|
392
475
|
content: [
|
|
@@ -415,35 +498,38 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
415
498
|
from_session: z.string().max(200).optional().default("hub"),
|
|
416
499
|
},
|
|
417
500
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
501
|
+
const effectiveNetId = getNetworkId(null);
|
|
502
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
418
503
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
419
504
|
const id = uuidv4();
|
|
420
|
-
|
|
421
|
-
db.run("BEGIN IMMEDIATE");
|
|
505
|
+
const replyLogged = db.transaction(() => {
|
|
422
506
|
db.run(
|
|
423
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
|
|
424
|
-
VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
|
|
425
|
-
[id, alias, text, from_session, in_reply_to ?? null]
|
|
507
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
508
|
+
VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
|
|
509
|
+
[id, alias, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
|
|
426
510
|
);
|
|
427
511
|
|
|
428
512
|
// 更新 tasks 表
|
|
429
513
|
if (in_reply_to) {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')
|
|
433
|
-
|
|
434
|
-
);
|
|
514
|
+
const updateParams: any[] = [replyStatus, text, in_reply_to];
|
|
515
|
+
let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
|
|
516
|
+
WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
517
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
518
|
+
const result = db.run(updateSql, updateParams);
|
|
435
519
|
if (result.changes === 0) {
|
|
436
520
|
console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
|
|
521
|
+
return false;
|
|
437
522
|
}
|
|
523
|
+
return true;
|
|
438
524
|
}
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
525
|
+
return false;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Log event after commit (outside transaction)
|
|
529
|
+
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
444
530
|
|
|
445
|
-
const session =
|
|
446
|
-
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
531
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
532
|
+
if (session) pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
447
533
|
|
|
448
534
|
return {
|
|
449
535
|
content: [{
|
|
@@ -463,11 +549,14 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
463
549
|
from_session: z.string().max(200).optional().default("hub"),
|
|
464
550
|
},
|
|
465
551
|
async ({ task_id, from_session }) => {
|
|
552
|
+
const effectiveNetId = getNetworkId(null);
|
|
553
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
466
554
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
);
|
|
555
|
+
const updateParams: any[] = [task_id];
|
|
556
|
+
let updateSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')";
|
|
557
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
558
|
+
const result = db.run(updateSql, updateParams);
|
|
559
|
+
if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
|
|
471
560
|
return {
|
|
472
561
|
content: [{
|
|
473
562
|
type: "text" as const,
|
|
@@ -477,6 +566,194 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
477
566
|
}
|
|
478
567
|
);
|
|
479
568
|
|
|
569
|
+
// ── V2: retry_task (重新投递失败/过期任务) ──
|
|
570
|
+
server.tool(
|
|
571
|
+
"retry_task",
|
|
572
|
+
"Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
|
|
573
|
+
{
|
|
574
|
+
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
575
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
576
|
+
},
|
|
577
|
+
async ({ task_id, from_session }) => {
|
|
578
|
+
const effectiveNetId = getNetworkId(null);
|
|
579
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
580
|
+
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
581
|
+
// Find the original task
|
|
582
|
+
const taskParams: any[] = [task_id];
|
|
583
|
+
let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
584
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
585
|
+
const task = db.get<any>(taskSql, ...taskParams);
|
|
586
|
+
if (!task) {
|
|
587
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
588
|
+
}
|
|
589
|
+
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
590
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
591
|
+
}
|
|
592
|
+
db.transaction(() => {
|
|
593
|
+
// Reset task status
|
|
594
|
+
const updateParams: any[] = [task_id];
|
|
595
|
+
let updateSql = `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
|
|
596
|
+
WHERE task_id = ?1`;
|
|
597
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
598
|
+
db.run(updateSql, updateParams);
|
|
599
|
+
// Re-queue in inbox with new ID (original ID may already exist)
|
|
600
|
+
const retryInboxId = uuidv4();
|
|
601
|
+
db.run(
|
|
602
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
|
|
603
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
|
|
604
|
+
[retryInboxId, task.to_name, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
608
|
+
// SSE push
|
|
609
|
+
if (scopedSessionStatus(task.to_name, effectiveNetId ?? task.network_id)) {
|
|
610
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
// ── V2: get_task (查询任务状态) ──
|
|
619
|
+
server.tool(
|
|
620
|
+
"get_task",
|
|
621
|
+
"Get task details by task_id. Returns status, result, timestamps.",
|
|
622
|
+
{
|
|
623
|
+
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
624
|
+
},
|
|
625
|
+
async ({ task_id }) => {
|
|
626
|
+
const effectiveNetId = getNetworkId(null);
|
|
627
|
+
const params: any[] = [task_id];
|
|
628
|
+
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
629
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
630
|
+
const task = db.get<any>(sql, ...params);
|
|
631
|
+
return {
|
|
632
|
+
content: [{
|
|
633
|
+
type: "text" as const,
|
|
634
|
+
text: JSON.stringify(task ? { ok: true, task } : { ok: false, error: "task not found" }),
|
|
635
|
+
}],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
// ── V2: list_tasks (查询任务列表) ──
|
|
641
|
+
server.tool(
|
|
642
|
+
"list_tasks",
|
|
643
|
+
"List tasks with filters. Agents can query their own pending/running tasks.",
|
|
644
|
+
{
|
|
645
|
+
alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
|
|
646
|
+
status: z.string().max(50).optional().describe("Filter by status"),
|
|
647
|
+
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
648
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
649
|
+
limit: z.number().min(1).max(100).optional().default(20),
|
|
650
|
+
},
|
|
651
|
+
async ({ alias, status, from_name, network_id: netId, limit }) => {
|
|
652
|
+
const effectiveNetId = getNetworkId(netId);
|
|
653
|
+
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
654
|
+
const params: any[] = [];
|
|
655
|
+
if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
|
|
656
|
+
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
657
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
658
|
+
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
659
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
660
|
+
params.push(limit);
|
|
661
|
+
const tasks = db.all(sql, ...params);
|
|
662
|
+
|
|
663
|
+
// Stats
|
|
664
|
+
const statsParams: any[] = [];
|
|
665
|
+
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
666
|
+
statsSql = addScope(statsSql, statsParams, effectiveNetId);
|
|
667
|
+
statsSql += " GROUP BY status";
|
|
668
|
+
const stats = db.all(statsSql, ...statsParams);
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
content: [{
|
|
672
|
+
type: "text" as const,
|
|
673
|
+
text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
|
|
674
|
+
}],
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
// ── V2: cancel_task (取消任务) ──
|
|
680
|
+
server.tool(
|
|
681
|
+
"cancel_task",
|
|
682
|
+
"Cancel a pending task. Works on delivered/acked/running tasks.",
|
|
683
|
+
{
|
|
684
|
+
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
685
|
+
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
686
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
687
|
+
},
|
|
688
|
+
async ({ task_id, reason, from_session }) => {
|
|
689
|
+
const effectiveNetId = getNetworkId(null);
|
|
690
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
691
|
+
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
692
|
+
const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
|
|
693
|
+
let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
694
|
+
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
695
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
696
|
+
const result = db.run(updateSql, updateParams);
|
|
697
|
+
// Also ack the inbox entry to prevent agent from picking it up
|
|
698
|
+
if (result.changes > 0) {
|
|
699
|
+
const inboxParams: any[] = [task_id];
|
|
700
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
701
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
702
|
+
db.run(inboxSql, inboxParams);
|
|
703
|
+
logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
// ── V2: reassign_task (转移任务到另一个 agent) ──
|
|
712
|
+
server.tool(
|
|
713
|
+
"reassign_task",
|
|
714
|
+
"Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
|
|
715
|
+
{
|
|
716
|
+
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
717
|
+
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
718
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
719
|
+
},
|
|
720
|
+
async ({ task_id, new_alias, from_session }) => {
|
|
721
|
+
const effectiveNetId = getNetworkId(null);
|
|
722
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
723
|
+
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
724
|
+
const taskParams: any[] = [task_id];
|
|
725
|
+
let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
726
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
727
|
+
const task = db.get<any>(taskSql, ...taskParams);
|
|
728
|
+
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
729
|
+
if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
|
|
730
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
731
|
+
}
|
|
732
|
+
const oldAlias = task.to_name;
|
|
733
|
+
db.transaction(() => {
|
|
734
|
+
// Ack old inbox to prevent original agent from picking it up
|
|
735
|
+
const inboxParams: any[] = [task_id];
|
|
736
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
737
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
738
|
+
db.run(inboxSql, inboxParams);
|
|
739
|
+
|
|
740
|
+
const updateParams: any[] = [new_alias, task_id];
|
|
741
|
+
let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2";
|
|
742
|
+
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
743
|
+
db.run(updateSql, updateParams);
|
|
744
|
+
|
|
745
|
+
const newInboxId = uuidv4();
|
|
746
|
+
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)",
|
|
747
|
+
[newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
748
|
+
});
|
|
749
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
750
|
+
if (scopedSessionStatus(new_alias, effectiveNetId ?? task.network_id)) {
|
|
751
|
+
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
752
|
+
}
|
|
753
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
754
|
+
}
|
|
755
|
+
);
|
|
756
|
+
|
|
480
757
|
server.tool(
|
|
481
758
|
"broadcast",
|
|
482
759
|
"Send a message to multiple sessions.",
|
|
@@ -484,23 +761,27 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
484
761
|
message: z.string().min(1).max(10000),
|
|
485
762
|
filter_server: z.string().max(200).optional(),
|
|
486
763
|
filter_status: z.string().max(50).optional(),
|
|
764
|
+
network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
|
|
487
765
|
},
|
|
488
|
-
async ({ message, filter_server, filter_status }) => {
|
|
489
|
-
|
|
490
|
-
|
|
766
|
+
async ({ message, filter_server, filter_status, network_id: netId }) => {
|
|
767
|
+
const effectiveNetId = getNetworkId(netId);
|
|
768
|
+
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
769
|
+
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
|
|
770
|
+
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
491
771
|
const params: any[] = [];
|
|
772
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
492
773
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
493
774
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
494
775
|
|
|
495
|
-
const targets = db.
|
|
776
|
+
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
496
777
|
const ids: string[] = [];
|
|
497
778
|
|
|
498
779
|
for (const t of targets) {
|
|
499
780
|
const id = uuidv4();
|
|
500
781
|
db.run(
|
|
501
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
502
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub')`,
|
|
503
|
-
[id, t.alias, message]
|
|
782
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
783
|
+
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub', ?4)`,
|
|
784
|
+
[id, t.alias, message, effectiveNetId ?? t.network_id ?? null]
|
|
504
785
|
);
|
|
505
786
|
ids.push(id);
|
|
506
787
|
}
|
|
@@ -524,16 +805,19 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
524
805
|
{
|
|
525
806
|
since: z.string().optional().describe("ISO 8601 datetime, default last 24h"),
|
|
526
807
|
alias: z.string().max(200).optional().describe("Filter by session alias"),
|
|
808
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
527
809
|
limit: z.number().min(1).max(500).optional().default(50),
|
|
528
810
|
},
|
|
529
|
-
async ({ since, alias, limit }) => {
|
|
811
|
+
async ({ since, alias, network_id: netId, limit }) => {
|
|
812
|
+
const effectiveNetId = getNetworkId(netId);
|
|
530
813
|
console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
|
|
531
814
|
const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
532
815
|
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
533
816
|
const params: any[] = [cutoff];
|
|
817
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
534
818
|
|
|
535
819
|
if (alias) {
|
|
536
|
-
sql +=
|
|
820
|
+
sql += ` AND session_name = ?${params.length + 1}`;
|
|
537
821
|
params.push(alias);
|
|
538
822
|
}
|
|
539
823
|
|
|
@@ -541,7 +825,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
541
825
|
sql += ` ORDER BY completed_at DESC LIMIT ?${paramIdx}`;
|
|
542
826
|
params.push(limit);
|
|
543
827
|
|
|
544
|
-
const rows = db.
|
|
828
|
+
const rows = db.all(sql, ...params);
|
|
545
829
|
return {
|
|
546
830
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completions: rows }) }],
|
|
547
831
|
};
|