@sleep2agi/commhub-server 0.5.0-preview.3 → 0.5.0-preview.31

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/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
- console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
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
- try {
45
- db.run("BEGIN IMMEDIATE");
46
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
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
- tmux_name = COALESCE(?3, sessions.tmux_name),
53
- server = COALESCE(?4, sessions.server),
54
- ip = COALESCE(?5, sessions.ip),
55
- hostname = COALESCE(?6, sessions.hostname),
56
- agent = COALESCE(?7, sessions.agent),
57
- project_dir = COALESCE(?8, sessions.project_dir),
58
- version = COALESCE(?9, sessions.version),
59
- status = ?10,
60
- task = COALESCE(?11, sessions.task),
61
- output = COALESCE(?12, sessions.output),
62
- progress = COALESCE(?13, sessions.progress),
63
- score = COALESCE(?14, sessions.score),
64
- node_id = COALESCE(?15, sessions.node_id),
65
- session_id = COALESCE(?16, sessions.session_id),
66
- config_path = COALESCE(?17, sessions.config_path),
67
- channels = COALESCE(?18, sessions.channels),
68
- last_seen_at = datetime('now'),
69
- updated_at = datetime('now')`,
70
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null]
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
- db.run("COMMIT");
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
- db.run(
82
- `UPDATE tasks SET status = 'running', started_at = datetime('now')
83
- WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
84
- [alias, task]
85
- );
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 row = db.query<{ cnt: number }, [string]>(
114
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
115
- ).get(alias);
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
- console.log(`[${ts()}] ${alias} report_completion: ${task.slice(0, 60)}`);
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
- try {
148
- db.run("BEGIN IMMEDIATE");
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
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
157
- WHERE alias = ?1`,
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 taskUpdate = db.run(
163
- `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
164
- WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
165
- [result.slice(0, 4000), task]
166
- );
167
- if (taskUpdate.changes === 0) {
168
- // fallback: match most recent task by to_name + content (legacy path)
169
- const match = db.query<{ task_id: string }, [string, string]>(
170
- `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
171
- AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`
172
- ).get(alias, task);
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
- db.run(
175
- `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
176
- WHERE task_id = ?2`,
177
- [result.slice(0, 4000), match.task_id]
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
- db.run("COMMIT");
183
- } catch (e) {
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 rows0 = db.query<{ cnt: number }, [string]>(
203
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
204
- ).get(alias);
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 rows = db.query<any, [string, number]>(
207
- `SELECT id, type, priority, content, context, from_session, created_at
208
- FROM inbox WHERE session_name = ?1 AND acked = 0
209
- ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
210
- LIMIT ?2`
211
- ).all(alias, limit);
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 result = db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2", [message_id, alias]);
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
- db.run(
238
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
239
- [message_id]
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
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_server ? " server=" + filter_server : ""}`);
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
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
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.query(sql).all(...params);
272
- })();
324
+ return db.all(sql, ...params);
325
+ });
273
326
 
274
- const summary = db.query<any, []>(
275
- "SELECT status, COUNT(*) as count FROM sessions GROUP BY status"
276
- ).all();
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 session = db.query("SELECT * FROM sessions WHERE alias = ?1").get(alias);
296
- const pending = db.query<{ cnt: number }, [string]>(
297
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
298
- ).get(alias);
299
- const recent = db.query(
300
- "SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5"
301
- ).all(alias);
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
- try {
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
- db.run("COMMIT");
342
- } catch (e) {
343
- try { db.run("ROLLBACK"); } catch {}
344
- throw e;
345
- }
424
+ });
425
+ logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
346
426
 
347
- const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
427
+ const session = scopedSessionStatus(alias, effectiveNetId);
348
428
 
349
429
  // SSE push by alias
350
- const pending = db.query<{ cnt: number }, [string]>(
351
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
352
- ).get(alias);
353
- pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
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 = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
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
- try {
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 result = db.run(
431
- `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
432
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
433
- [replyStatus, text, in_reply_to]
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
- db.run("COMMIT");
440
- } catch (e) {
441
- try { db.run("ROLLBACK"); } catch {}
442
- throw e;
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 = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
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 result = db.run(
468
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
469
- [task_id]
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
- console.log(`[${ts()}] hub broadcast: ${message.slice(0, 60)}${filter_server ? " [server=" + filter_server + "]" : ""}`);
490
- let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
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.query<{ alias: string }, any[]>(sql).all(...params);
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 += " AND session_name = ?2";
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.query(sql).all(...params);
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
  };