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