@sleep2agi/commhub-server 0.5.0-preview.9 → 0.5.1

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
@@ -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
- console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
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
- try {
45
- db.run("BEGIN IMMEDIATE");
46
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
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
- 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]
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
- db.run("COMMIT");
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 runResult = db.run(
82
- `UPDATE tasks SET status = 'running', started_at = datetime('now')
83
- WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
84
- [alias, task]
85
- );
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 t = db.query<{ task_id: string }, [string, string]>(
89
- "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
90
- ).get(alias, task);
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 row = db.query<{ cnt: number }, [string]>(
121
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
122
- ).get(alias);
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
- console.log(`[${ts()}] ${alias} report_completion: ${task.slice(0, 60)}`);
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
- try {
155
- db.run("BEGIN IMMEDIATE");
156
- db.run(
157
- `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
158
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
159
- [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
160
- );
161
-
193
+ let updatedTaskId: string | null = null;
194
+ db.transaction(() => {
162
195
  db.run(
163
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
164
- WHERE alias = ?1`,
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 taskUpdate = db.run(
170
- `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
171
- WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
172
- [result.slice(0, 4000), task]
173
- );
174
- if (taskUpdate.changes === 0) {
175
- // fallback: match most recent task by to_name + content (legacy path)
176
- const match = db.query<{ task_id: string }, [string, string]>(
177
- `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
178
- AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`
179
- ).get(alias, task);
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
- db.run(
182
- `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
183
- WHERE task_id = ?2`,
184
- [result.slice(0, 4000), match.task_id]
185
- );
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
- db.run("COMMIT");
190
- // Log event after commit
191
- const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
192
- "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
193
- ).get(alias)?.task_id);
194
- if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
195
- } catch (e) {
196
- try { db.run("ROLLBACK"); } catch {}
197
- throw e;
198
- }
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 rows0 = db.query<{ cnt: number }, [string]>(
215
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
216
- ).get(alias);
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 rows = db.query<any, [string, number]>(
219
- `SELECT id, type, priority, content, context, from_session, created_at
220
- FROM inbox WHERE session_name = ?1 AND acked = 0
221
- ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
222
- LIMIT ?2`
223
- ).all(alias, limit);
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 result = db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2", [message_id, alias]);
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 ackResult = db.run(
250
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
251
- [message_id]
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
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_server ? " server=" + filter_server : ""}`);
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
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
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.query(sql).all(...params);
285
- })();
332
+ return db.all(sql, ...params);
333
+ });
286
334
 
287
- const summary = db.query<any, []>(
288
- "SELECT status, COUNT(*) as count FROM sessions GROUP BY status"
289
- ).all();
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 session = db.query("SELECT * FROM sessions WHERE alias = ?1").get(alias);
309
- const pending = db.query<{ cnt: number }, [string]>(
310
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
311
- ).get(alias);
312
- const recent = db.query(
313
- "SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5"
314
- ).all(alias);
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().default("hub"),
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
- try {
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
- db.run("COMMIT");
355
- logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
356
- } catch (e) {
357
- try { db.run("ROLLBACK"); } catch {}
358
- throw e;
359
- }
360
-
361
- const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
362
-
363
- // SSE push by alias
364
- const pending = db.query<{ cnt: number }, [string]>(
365
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
366
- ).get(alias);
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().default("hub"),
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 = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
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().default("hub"),
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
- let replyLogged = false;
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 result = db.run(
446
- `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
447
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
448
- [replyStatus, text, in_reply_to]
449
- );
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
- } else {
453
- replyLogged = true;
535
+ return false;
454
536
  }
537
+ return true;
455
538
  }
456
- db.run("COMMIT");
457
- } catch (e) {
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 = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
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().default("hub"),
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 result = db.run(
488
- `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
489
- [task_id]
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().default("hub"),
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 task = db.query<any, [string]>(
513
- "SELECT * FROM tasks WHERE task_id = ?1"
514
- ).get(task_id);
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
- try {
522
- db.run("BEGIN IMMEDIATE");
606
+ db.transaction(() => {
523
607
  // Reset task status
524
- db.run(
525
- `UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
526
- WHERE task_id = ?1`,
527
- [task_id]
528
- );
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
- db.run("COMMIT");
537
- logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
538
- } catch (e) {
539
- try { db.run("ROLLBACK"); } catch {}
540
- throw e;
541
- }
542
- // SSE push
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 task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
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.query(sql).all(...params);
673
+ const tasks = db.all(sql, ...params);
587
674
 
588
675
  // Stats
589
- const stats = db.query<any, []>(
590
- "SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
591
- ).all();
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().default("hub"),
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 result = db.run(
614
- `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
615
- WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
616
- [reason || "cancelled by " + from_session, task_id]
617
- );
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
- db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
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().default("hub"),
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 task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
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
- try {
647
- db.run("BEGIN IMMEDIATE");
745
+ db.transaction(() => {
648
746
  // Ack old inbox to prevent original agent from picking it up
649
- db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
650
- db.run("UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2", [new_alias, task_id]);
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
- db.run("COMMIT");
655
- logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
656
- } catch (e) {
657
- try { db.run("ROLLBACK"); } catch {}
658
- throw e;
659
- }
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
- console.log(`[${ts()}] hub broadcast: ${message.slice(0, 60)}${filter_server ? " [server=" + filter_server + "]" : ""}`);
675
- let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
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.query<{ alias: string }, any[]>(sql).all(...params);
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 += " AND session_name = ?2";
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.query(sql).all(...params);
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
  };