@sleep2agi/commhub-server 0.5.0-preview.28 → 0.5.0-preview.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/auth.ts +1 -0
- package/src/db.ts +7 -2
- package/src/index.ts +176 -57
- package/src/tools.ts +220 -124
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.0-preview.
|
|
3
|
+
"version": "0.5.0-preview.29",
|
|
4
4
|
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/auth.ts
CHANGED
|
@@ -224,6 +224,7 @@ export function createToken(userId: string, name: string, networkId?: string): {
|
|
|
224
224
|
if (networkId) {
|
|
225
225
|
const role = getUserNetworkRole(userId, networkId);
|
|
226
226
|
if (!role) return { ok: false, error: "not a member of this network" };
|
|
227
|
+
if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
|
|
227
228
|
}
|
|
228
229
|
const token = generateToken();
|
|
229
230
|
const tokenId = generateId("tok");
|
package/src/db.ts
CHANGED
|
@@ -46,6 +46,7 @@ db.exec(`
|
|
|
46
46
|
artifacts TEXT,
|
|
47
47
|
score REAL,
|
|
48
48
|
duration_minutes REAL,
|
|
49
|
+
network_id TEXT,
|
|
49
50
|
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
50
51
|
);
|
|
51
52
|
`);
|
|
@@ -296,12 +297,15 @@ try {
|
|
|
296
297
|
} catch {}
|
|
297
298
|
|
|
298
299
|
// ── V3: add network_id to existing tables ──
|
|
299
|
-
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
|
|
300
|
+
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "completions"]) {
|
|
300
301
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
301
302
|
}
|
|
302
303
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
|
|
303
304
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
|
|
304
305
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
|
|
306
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)"); } catch {}
|
|
307
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
|
|
308
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions(network_id)"); } catch {}
|
|
305
309
|
|
|
306
310
|
// Helpers
|
|
307
311
|
export function uuidv4(): string {
|
|
@@ -344,7 +348,8 @@ export function logAudit(userId: string | null, username: string | null, action:
|
|
|
344
348
|
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
345
349
|
try {
|
|
346
350
|
db.run(
|
|
347
|
-
|
|
351
|
+
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
352
|
+
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
|
|
348
353
|
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
349
354
|
);
|
|
350
355
|
} catch {}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.j
|
|
|
7
7
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
8
|
|
|
9
9
|
const PORT = Number(process.env.PORT) || 9200;
|
|
10
|
+
const HOST = process.env.HOST || "0.0.0.0";
|
|
10
11
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
11
12
|
|
|
12
13
|
// ── Rate limiter (in-memory, per IP) ──
|
|
@@ -72,12 +73,77 @@ function resolveRequestAuth(req: Request): { userId: string; networkId: string |
|
|
|
72
73
|
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
type RestNetworkScope = {
|
|
77
|
+
networkId: string | null;
|
|
78
|
+
networkIds: string[] | null;
|
|
79
|
+
denied?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function getUserNetworkIds(userId: string): string[] {
|
|
83
|
+
return db.all<{ network_id: string }>(
|
|
84
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
85
|
+
userId
|
|
86
|
+
).map((row) => row.network_id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
|
|
90
|
+
const requested = url.searchParams.get("network_id");
|
|
91
|
+
|
|
92
|
+
// Legacy global token or open dev mode keeps the old global behavior.
|
|
93
|
+
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
94
|
+
|
|
95
|
+
// Network tokens are forcibly scoped to their bound network.
|
|
96
|
+
if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
|
|
97
|
+
|
|
98
|
+
// System admins may intentionally inspect all networks.
|
|
99
|
+
if (isAdmin) return { networkId: requested || null, networkIds: null };
|
|
100
|
+
|
|
101
|
+
if (requested) {
|
|
102
|
+
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
103
|
+
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
104
|
+
return { networkId: requested, networkIds: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
111
|
+
if (scope.networkId) {
|
|
112
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
113
|
+
params.push(scope.networkId);
|
|
114
|
+
} else if (scope.networkIds) {
|
|
115
|
+
if (scope.networkIds.length === 0) {
|
|
116
|
+
sql += " AND 1=0";
|
|
117
|
+
} else {
|
|
118
|
+
const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
|
|
119
|
+
sql += ` AND ${column} IN (${placeholders})`;
|
|
120
|
+
params.push(...scope.networkIds);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return sql;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
127
|
+
if (scope.networkId) return scope.networkId;
|
|
128
|
+
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
133
|
+
if (!authCtx) return true; // legacy global token or open dev mode
|
|
134
|
+
if (isAdmin) return true;
|
|
135
|
+
if (!networkId) return false;
|
|
136
|
+
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
137
|
+
return !!role && role !== "viewer";
|
|
138
|
+
}
|
|
139
|
+
|
|
75
140
|
// ── REST input schema ───────────────────────────────
|
|
76
141
|
const TaskSchema = z.object({
|
|
77
142
|
alias: z.string().min(1).max(200),
|
|
78
143
|
task: z.string().min(1).max(10000),
|
|
79
144
|
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
80
145
|
from: z.string().max(200).optional(),
|
|
146
|
+
network_id: z.string().max(200).optional(),
|
|
81
147
|
});
|
|
82
148
|
|
|
83
149
|
const BroadcastSchema = z.object({
|
|
@@ -137,6 +203,7 @@ setInterval(() => {
|
|
|
137
203
|
|
|
138
204
|
Bun.serve({
|
|
139
205
|
port: PORT,
|
|
206
|
+
hostname: HOST,
|
|
140
207
|
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
141
208
|
|
|
142
209
|
async fetch(req, server) {
|
|
@@ -553,19 +620,24 @@ Bun.serve({
|
|
|
553
620
|
// Resolve network scope for REST queries — enforce isolation
|
|
554
621
|
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
555
622
|
const restAuth = resolveRequestAuth(req);
|
|
556
|
-
const isAdmin = restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin";
|
|
557
|
-
|
|
558
|
-
|
|
623
|
+
const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
624
|
+
const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
|
|
625
|
+
if (restScope.denied) {
|
|
626
|
+
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
627
|
+
}
|
|
559
628
|
|
|
560
629
|
// ── REST: all sessions status ──
|
|
561
630
|
if (url.pathname === "/api/status") {
|
|
562
631
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
632
|
+
const staleParams: any[] = [cutoff];
|
|
633
|
+
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
634
|
+
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
635
|
+
db.run(staleSql, staleParams);
|
|
636
|
+
const params: any[] = [];
|
|
637
|
+
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
638
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
639
|
+
sql += " ORDER BY updated_at DESC";
|
|
640
|
+
const sessions = db.all(sql, ...params);
|
|
569
641
|
return withCors(req, Response.json({ ok: true, sessions }));
|
|
570
642
|
}
|
|
571
643
|
|
|
@@ -582,18 +654,40 @@ Bun.serve({
|
|
|
582
654
|
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
583
655
|
}
|
|
584
656
|
const body = parsed.data;
|
|
657
|
+
let taskNetId: string | null = null;
|
|
658
|
+
if (restAuth?.networkId) {
|
|
659
|
+
taskNetId = restAuth.networkId;
|
|
660
|
+
} else if (body.network_id) {
|
|
661
|
+
if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
|
|
662
|
+
return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
|
|
663
|
+
}
|
|
664
|
+
taskNetId = body.network_id;
|
|
665
|
+
} else {
|
|
666
|
+
taskNetId = restAuth ? singleNetworkId(restScope) : null;
|
|
667
|
+
}
|
|
668
|
+
if (restAuth && !taskNetId) {
|
|
669
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when multiple networks are available" }, { status: 400 }));
|
|
670
|
+
}
|
|
671
|
+
if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
672
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
673
|
+
}
|
|
585
674
|
const id = crypto.randomUUID();
|
|
586
675
|
const fromSession = body.from || "api";
|
|
587
676
|
db.run(
|
|
588
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
589
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
|
|
590
|
-
[id, body.alias, body.priority, body.task, fromSession]
|
|
677
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
678
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
|
|
679
|
+
[id, body.alias, body.priority, body.task, fromSession, taskNetId]
|
|
591
680
|
);
|
|
592
681
|
// SSE push: 秒达
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
682
|
+
const pendingParams: any[] = [body.alias];
|
|
683
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
684
|
+
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
685
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
686
|
+
const sessionParams: any[] = [body.alias];
|
|
687
|
+
let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
|
|
688
|
+
if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
|
|
689
|
+
const targetSession = db.get<any>(sessionSql, ...sessionParams);
|
|
690
|
+
if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
|
|
597
691
|
return withCors(req, Response.json({ ok: true, message_id: id }));
|
|
598
692
|
}
|
|
599
693
|
|
|
@@ -610,18 +704,25 @@ Bun.serve({
|
|
|
610
704
|
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
611
705
|
}
|
|
612
706
|
const body = parsed.data;
|
|
613
|
-
|
|
707
|
+
if (restAuth && !restScope.networkId && !isAdmin) {
|
|
708
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
|
|
709
|
+
}
|
|
710
|
+
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
711
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
712
|
+
}
|
|
713
|
+
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
614
714
|
const params: any[] = [];
|
|
715
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
615
716
|
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
616
717
|
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
617
|
-
const targets = db.all<{ alias: string }>(sql, ...params);
|
|
718
|
+
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
618
719
|
const ids: string[] = [];
|
|
619
720
|
for (const t of targets) {
|
|
620
721
|
const id = crypto.randomUUID();
|
|
621
722
|
db.run(
|
|
622
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
623
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
|
|
624
|
-
[id, t.alias, body.message]
|
|
723
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
724
|
+
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api', ?4)`,
|
|
725
|
+
[id, t.alias, body.message, t.network_id]
|
|
625
726
|
);
|
|
626
727
|
ids.push(id);
|
|
627
728
|
}
|
|
@@ -679,36 +780,49 @@ Bun.serve({
|
|
|
679
780
|
|
|
680
781
|
// ── REST: recent messages (for Dashboard communication graph) ──
|
|
681
782
|
if (url.pathname === "/api/messages") {
|
|
682
|
-
const limit = Number(url.searchParams.get("limit")) || 100;
|
|
783
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
683
784
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
785
|
+
const params: any[] = [since];
|
|
786
|
+
let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
|
|
787
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
788
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
789
|
+
params.push(limit);
|
|
790
|
+
const rows = db.all(sql, ...params);
|
|
687
791
|
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
688
792
|
}
|
|
689
793
|
|
|
690
794
|
// ── REST: stats summary ──
|
|
691
795
|
if (url.pathname === "/api/stats") {
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
796
|
+
const taskStatsParams: any[] = [];
|
|
797
|
+
let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
798
|
+
taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
|
|
799
|
+
taskStatsSql += " GROUP BY status";
|
|
800
|
+
const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
|
|
801
|
+
|
|
802
|
+
const sessionStatsParams: any[] = [];
|
|
803
|
+
let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
804
|
+
sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
|
|
805
|
+
sessionStatsSql += " GROUP BY status";
|
|
806
|
+
const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
|
|
807
|
+
|
|
808
|
+
const totalTasksParams: any[] = [];
|
|
809
|
+
let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
|
|
810
|
+
totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
|
|
811
|
+
const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
|
|
812
|
+
|
|
813
|
+
const totalNodesParams: any[] = [];
|
|
814
|
+
let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
|
|
815
|
+
totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
|
|
816
|
+
const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
|
|
817
|
+
|
|
818
|
+
const recentTasksParams: any[] = [];
|
|
819
|
+
let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
|
|
820
|
+
recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
|
|
821
|
+
recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
|
|
822
|
+
const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
|
|
709
823
|
return withCors(req, Response.json({
|
|
710
824
|
ok: true,
|
|
711
|
-
network_id:
|
|
825
|
+
network_id: restScope.networkId || null,
|
|
712
826
|
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
713
827
|
sessions: { by_status: sessionStats },
|
|
714
828
|
nodes: { total: totalNodes?.cnt || 0 },
|
|
@@ -741,10 +855,11 @@ Bun.serve({
|
|
|
741
855
|
if (url.pathname === "/api/task_events") {
|
|
742
856
|
const taskId = url.searchParams.get("task_id");
|
|
743
857
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
744
|
-
let sql = "SELECT * FROM task_events";
|
|
858
|
+
let sql = "SELECT * FROM task_events WHERE 1=1";
|
|
745
859
|
const params: any[] = [];
|
|
746
|
-
|
|
747
|
-
sql +=
|
|
860
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
861
|
+
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
862
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
748
863
|
params.push(limit);
|
|
749
864
|
const rows = db.all(sql, ...params);
|
|
750
865
|
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
@@ -754,10 +869,9 @@ Bun.serve({
|
|
|
754
869
|
if (url.pathname === "/api/nodes") {
|
|
755
870
|
const nodeId = url.searchParams.get("node_id");
|
|
756
871
|
const alias = url.searchParams.get("alias");
|
|
757
|
-
const netFilter = url.searchParams.get("network_id");
|
|
758
872
|
let sql = "SELECT * FROM nodes WHERE 1=1";
|
|
759
873
|
const params: any[] = [];
|
|
760
|
-
|
|
874
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
761
875
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
762
876
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
763
877
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -771,12 +885,11 @@ Bun.serve({
|
|
|
771
885
|
const status = url.searchParams.get("status");
|
|
772
886
|
const toName = url.searchParams.get("to_name");
|
|
773
887
|
const fromName = url.searchParams.get("from_name");
|
|
774
|
-
const netFilter = restNetId || url.searchParams.get("network_id"); // token-enforced takes priority
|
|
775
888
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
776
889
|
|
|
777
890
|
let sql = "SELECT * FROM tasks WHERE 1=1";
|
|
778
891
|
const params: any[] = [];
|
|
779
|
-
|
|
892
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
780
893
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
781
894
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
782
895
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
@@ -785,16 +898,22 @@ Bun.serve({
|
|
|
785
898
|
params.push(limit);
|
|
786
899
|
|
|
787
900
|
const rows = db.all(sql, ...params);
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
901
|
+
const statsParams: any[] = [];
|
|
902
|
+
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
903
|
+
statsSql = addNetworkScope(statsSql, statsParams, restScope);
|
|
904
|
+
statsSql += " GROUP BY status";
|
|
905
|
+
const stats = db.all<any>(statsSql, ...statsParams);
|
|
791
906
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
792
907
|
}
|
|
793
908
|
|
|
794
909
|
// ── REST: recent completions ──
|
|
795
910
|
if (url.pathname === "/api/completions") {
|
|
796
911
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
797
|
-
const
|
|
912
|
+
const params: any[] = [since];
|
|
913
|
+
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
914
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
915
|
+
sql += " ORDER BY completed_at DESC LIMIT 100";
|
|
916
|
+
const rows = db.all(sql, ...params);
|
|
798
917
|
return withCors(req, Response.json({ ok: true, completions: rows }));
|
|
799
918
|
}
|
|
800
919
|
|
|
@@ -915,8 +1034,8 @@ console.log(`
|
|
|
915
1034
|
║ Transport: Streamable HTTP (Bun native) ║
|
|
916
1035
|
║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
|
|
917
1036
|
║ ║
|
|
918
|
-
║ MCP: http
|
|
919
|
-
║ REST: http
|
|
920
|
-
║ Health: http
|
|
1037
|
+
║ MCP: http://${HOST}:${PORT}/mcp ║
|
|
1038
|
+
║ REST: http://${HOST}:${PORT}/api ║
|
|
1039
|
+
║ Health: http://${HOST}:${PORT}/health ║
|
|
921
1040
|
╚══════════════════════════════════════════════════╝
|
|
922
1041
|
`);
|
package/src/tools.ts
CHANGED
|
@@ -20,6 +20,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
20
20
|
const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
|
|
21
21
|
return !!role && role !== "viewer"; // owner/admin/member can write, viewer cannot
|
|
22
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
|
+
};
|
|
23
37
|
// ═══════════════════════════════════════════
|
|
24
38
|
// Child Agent Tools (4)
|
|
25
39
|
// ═══════════════════════════════════════════
|
|
@@ -52,6 +66,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
52
66
|
},
|
|
53
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 }) => {
|
|
54
68
|
const effectiveNetId = getNetworkId(netId);
|
|
69
|
+
if (!canWrite()) {
|
|
70
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
71
|
+
}
|
|
55
72
|
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
|
|
56
73
|
const trimmedOutput = output?.slice(0, 4000);
|
|
57
74
|
|
|
@@ -83,16 +100,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
83
100
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
84
101
|
if (status === "working" && task) {
|
|
85
102
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2
|
|
89
|
-
|
|
90
|
-
);
|
|
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);
|
|
91
108
|
if (runResult.changes > 0) {
|
|
92
109
|
// Find task_id for logging
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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);
|
|
96
115
|
if (t) logTaskEvent(t.task_id, null, "running", alias);
|
|
97
116
|
}
|
|
98
117
|
} catch {}
|
|
@@ -104,8 +123,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
104
123
|
// Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
|
|
105
124
|
const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
|
|
106
125
|
db.run(
|
|
107
|
-
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
|
|
108
|
-
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'))
|
|
109
128
|
ON CONFLICT(node_id) DO UPDATE SET
|
|
110
129
|
node_name = COALESCE(?2, nodes.node_name),
|
|
111
130
|
alias = COALESCE(?3, nodes.alias),
|
|
@@ -115,16 +134,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
115
134
|
channels = COALESCE(?7, nodes.channels),
|
|
116
135
|
server = COALESCE(?8, nodes.server),
|
|
117
136
|
hostname = COALESCE(?9, nodes.hostname),
|
|
137
|
+
network_id = COALESCE(?10, nodes.network_id),
|
|
118
138
|
updated_at = datetime('now')`,
|
|
119
|
-
[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]
|
|
120
140
|
);
|
|
121
141
|
} catch {}
|
|
122
142
|
}
|
|
123
143
|
|
|
124
144
|
// inbox uses alias for routing
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
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);
|
|
128
149
|
|
|
129
150
|
return {
|
|
130
151
|
content: [
|
|
@@ -152,43 +173,53 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
152
173
|
artifacts: z.array(z.string().max(2000)).max(50).optional().describe("Output URLs or file paths"),
|
|
153
174
|
score: z.number().min(0).max(10).optional(),
|
|
154
175
|
duration_minutes: z.number().min(0).optional(),
|
|
176
|
+
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
155
177
|
},
|
|
156
|
-
async ({ alias, task, result, artifacts, score, duration_minutes }) => {
|
|
157
|
-
|
|
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]" : ""}`);
|
|
158
184
|
const id = uuidv4();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
|
|
162
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
|
163
|
-
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
|
|
164
|
-
);
|
|
185
|
+
let updatedTaskId: string | null = null;
|
|
186
|
+
db.transaction(() => {
|
|
165
187
|
db.run(
|
|
166
|
-
`
|
|
167
|
-
|
|
168
|
-
[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]
|
|
169
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);
|
|
197
|
+
|
|
170
198
|
// V2: sync tasks table — try by task_id first, then by content
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')
|
|
174
|
-
|
|
175
|
-
);
|
|
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);
|
|
176
204
|
if (tu.changes === 0) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
AND status IN ('delivered', 'acked', 'running')
|
|
180
|
-
|
|
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);
|
|
181
211
|
if (match) {
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
217
|
}
|
|
218
|
+
} else {
|
|
219
|
+
updatedTaskId = task;
|
|
185
220
|
}
|
|
186
|
-
return tu.changes;
|
|
187
221
|
});
|
|
188
222
|
// Log event after transaction
|
|
189
|
-
const updatedTaskId = taskUpdateChanges > 0 ? task : (db.get<{ task_id: string }>(
|
|
190
|
-
"SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1",
|
|
191
|
-
alias)?.task_id);
|
|
192
223
|
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
193
224
|
|
|
194
225
|
return {
|
|
@@ -205,16 +236,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
205
236
|
limit: z.number().min(1).max(100).optional().default(10),
|
|
206
237
|
},
|
|
207
238
|
async ({ alias, limit }) => {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
211
244
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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);
|
|
218
253
|
|
|
219
254
|
return {
|
|
220
255
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
@@ -231,8 +266,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
231
266
|
response: z.string().max(10000).optional(),
|
|
232
267
|
},
|
|
233
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" }) }] };
|
|
234
271
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
235
|
-
const
|
|
272
|
+
const ackParams: any[] = [message_id, alias];
|
|
273
|
+
let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
|
|
274
|
+
ackSql = addScope(ackSql, ackParams, effectiveNetId);
|
|
275
|
+
const result = db.run(ackSql, ackParams);
|
|
236
276
|
if (result.changes === 0) {
|
|
237
277
|
return {
|
|
238
278
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
|
|
@@ -240,10 +280,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
240
280
|
}
|
|
241
281
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
242
282
|
try {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
);
|
|
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);
|
|
247
287
|
if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
|
|
248
288
|
} catch {}
|
|
249
289
|
return {
|
|
@@ -270,7 +310,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
270
310
|
|
|
271
311
|
const sessions = db.transaction(() => {
|
|
272
312
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
273
|
-
|
|
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);
|
|
274
317
|
|
|
275
318
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
276
319
|
const params: any[] = [];
|
|
@@ -281,8 +324,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
281
324
|
return db.all(sql, ...params);
|
|
282
325
|
});
|
|
283
326
|
|
|
284
|
-
const
|
|
285
|
-
|
|
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);
|
|
286
332
|
|
|
287
333
|
return {
|
|
288
334
|
content: [
|
|
@@ -300,14 +346,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
300
346
|
"Get detailed status of a specific session by alias.",
|
|
301
347
|
{ alias: z.string().min(1).max(200).describe("Session alias") },
|
|
302
348
|
async ({ alias }) => {
|
|
349
|
+
const effectiveNetId = getNetworkId(null);
|
|
303
350
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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);
|
|
311
366
|
|
|
312
367
|
return {
|
|
313
368
|
content: [
|
|
@@ -359,23 +414,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
359
414
|
db.run(
|
|
360
415
|
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
361
416
|
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
362
|
-
[id, alias, priority, task, context ?? null, from_session, effectiveNetId]
|
|
417
|
+
[id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
|
|
363
418
|
);
|
|
364
419
|
db.run(
|
|
365
420
|
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
366
421
|
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
367
|
-
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
|
|
422
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null]
|
|
368
423
|
);
|
|
369
424
|
});
|
|
370
425
|
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
371
426
|
|
|
372
|
-
const session =
|
|
427
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
373
428
|
|
|
374
429
|
// SSE push by alias
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 });
|
|
379
435
|
|
|
380
436
|
return {
|
|
381
437
|
content: [
|
|
@@ -401,18 +457,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
401
457
|
from_session: z.string().max(200).optional().default("hub"),
|
|
402
458
|
},
|
|
403
459
|
async ({ alias, message, from_session }) => {
|
|
460
|
+
const effectiveNetId = getNetworkId(null);
|
|
404
461
|
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
405
462
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
406
463
|
const id = uuidv4();
|
|
407
464
|
db.run(
|
|
408
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
409
|
-
VALUES (?1, ?2, 'message', 'normal', ?3, ?4)`,
|
|
410
|
-
[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]
|
|
411
468
|
);
|
|
412
469
|
|
|
413
|
-
const session =
|
|
470
|
+
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
414
471
|
|
|
415
|
-
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 });
|
|
416
473
|
|
|
417
474
|
return {
|
|
418
475
|
content: [
|
|
@@ -441,23 +498,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
441
498
|
from_session: z.string().max(200).optional().default("hub"),
|
|
442
499
|
},
|
|
443
500
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
501
|
+
const effectiveNetId = getNetworkId(null);
|
|
444
502
|
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
445
503
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
446
504
|
const id = uuidv4();
|
|
447
505
|
const replyLogged = db.transaction(() => {
|
|
448
506
|
db.run(
|
|
449
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
|
|
450
|
-
VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
|
|
451
|
-
[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]
|
|
452
510
|
);
|
|
453
511
|
|
|
454
512
|
// 更新 tasks 表
|
|
455
513
|
if (in_reply_to) {
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')
|
|
459
|
-
|
|
460
|
-
);
|
|
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);
|
|
461
519
|
if (result.changes === 0) {
|
|
462
520
|
console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
|
|
463
521
|
return false;
|
|
@@ -470,8 +528,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
470
528
|
// Log event after commit (outside transaction)
|
|
471
529
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
472
530
|
|
|
473
|
-
const session =
|
|
474
|
-
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 });
|
|
475
533
|
|
|
476
534
|
return {
|
|
477
535
|
content: [{
|
|
@@ -491,11 +549,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
491
549
|
from_session: z.string().max(200).optional().default("hub"),
|
|
492
550
|
},
|
|
493
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" }) }] };
|
|
494
554
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
);
|
|
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);
|
|
499
559
|
if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
|
|
500
560
|
return {
|
|
501
561
|
content: [{
|
|
@@ -515,10 +575,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
515
575
|
from_session: z.string().max(200).optional().default("hub"),
|
|
516
576
|
},
|
|
517
577
|
async ({ task_id, from_session }) => {
|
|
578
|
+
const effectiveNetId = getNetworkId(null);
|
|
518
579
|
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
519
580
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
520
581
|
// Find the original task
|
|
521
|
-
const
|
|
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);
|
|
522
586
|
if (!task) {
|
|
523
587
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
524
588
|
}
|
|
@@ -527,22 +591,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
527
591
|
}
|
|
528
592
|
db.transaction(() => {
|
|
529
593
|
// Reset task status
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
WHERE task_id = ?1
|
|
533
|
-
|
|
534
|
-
);
|
|
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);
|
|
535
599
|
// Re-queue in inbox with new ID (original ID may already exist)
|
|
536
600
|
const retryInboxId = uuidv4();
|
|
537
601
|
db.run(
|
|
538
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
|
|
539
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
|
|
540
|
-
[retryInboxId, task.to_name, task.priority, task.content, from_session]
|
|
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]
|
|
541
605
|
);
|
|
542
606
|
});
|
|
543
607
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
544
608
|
// SSE push
|
|
545
|
-
|
|
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
|
+
}
|
|
546
612
|
return {
|
|
547
613
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
548
614
|
};
|
|
@@ -557,7 +623,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
557
623
|
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
558
624
|
},
|
|
559
625
|
async ({ task_id }) => {
|
|
560
|
-
const
|
|
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);
|
|
561
631
|
return {
|
|
562
632
|
content: [{
|
|
563
633
|
type: "text" as const,
|
|
@@ -591,8 +661,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
591
661
|
const tasks = db.all(sql, ...params);
|
|
592
662
|
|
|
593
663
|
// Stats
|
|
594
|
-
const
|
|
595
|
-
|
|
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);
|
|
596
669
|
|
|
597
670
|
return {
|
|
598
671
|
content: [{
|
|
@@ -613,16 +686,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
613
686
|
from_session: z.string().max(200).optional().default("hub"),
|
|
614
687
|
},
|
|
615
688
|
async ({ task_id, reason, from_session }) => {
|
|
689
|
+
const effectiveNetId = getNetworkId(null);
|
|
616
690
|
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
617
691
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')
|
|
621
|
-
|
|
622
|
-
);
|
|
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);
|
|
623
697
|
// Also ack the inbox entry to prevent agent from picking it up
|
|
624
698
|
if (result.changes > 0) {
|
|
625
|
-
|
|
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);
|
|
626
703
|
logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
627
704
|
}
|
|
628
705
|
return {
|
|
@@ -641,9 +718,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
641
718
|
from_session: z.string().max(200).optional().default("hub"),
|
|
642
719
|
},
|
|
643
720
|
async ({ task_id, new_alias, from_session }) => {
|
|
721
|
+
const effectiveNetId = getNetworkId(null);
|
|
644
722
|
if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
645
723
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
646
|
-
const
|
|
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);
|
|
647
728
|
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
648
729
|
if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
|
|
649
730
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
@@ -651,14 +732,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
651
732
|
const oldAlias = task.to_name;
|
|
652
733
|
db.transaction(() => {
|
|
653
734
|
// Ack old inbox to prevent original agent from picking it up
|
|
654
|
-
|
|
655
|
-
|
|
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
|
+
|
|
656
745
|
const newInboxId = uuidv4();
|
|
657
|
-
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
|
|
658
|
-
[newInboxId, new_alias, task.priority, task.content, from_session]);
|
|
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]);
|
|
659
748
|
});
|
|
660
749
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
661
|
-
|
|
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
|
+
}
|
|
662
753
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
663
754
|
}
|
|
664
755
|
);
|
|
@@ -673,22 +764,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
673
764
|
network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
|
|
674
765
|
},
|
|
675
766
|
async ({ message, filter_server, filter_status, network_id: netId }) => {
|
|
676
|
-
|
|
677
|
-
|
|
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";
|
|
678
771
|
const params: any[] = [];
|
|
679
|
-
|
|
772
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
680
773
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
681
774
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
682
775
|
|
|
683
|
-
const targets = db.all<{ alias: string }>(sql, ...params);
|
|
776
|
+
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
684
777
|
const ids: string[] = [];
|
|
685
778
|
|
|
686
779
|
for (const t of targets) {
|
|
687
780
|
const id = uuidv4();
|
|
688
781
|
db.run(
|
|
689
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session)
|
|
690
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub')`,
|
|
691
|
-
[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]
|
|
692
785
|
);
|
|
693
786
|
ids.push(id);
|
|
694
787
|
}
|
|
@@ -712,16 +805,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
712
805
|
{
|
|
713
806
|
since: z.string().optional().describe("ISO 8601 datetime, default last 24h"),
|
|
714
807
|
alias: z.string().max(200).optional().describe("Filter by session alias"),
|
|
808
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
715
809
|
limit: z.number().min(1).max(500).optional().default(50),
|
|
716
810
|
},
|
|
717
|
-
async ({ since, alias, limit }) => {
|
|
811
|
+
async ({ since, alias, network_id: netId, limit }) => {
|
|
812
|
+
const effectiveNetId = getNetworkId(netId);
|
|
718
813
|
console.log(`[${ts()}] hub → get_completions${alias ? ": " + alias : ""}`);
|
|
719
814
|
const cutoff = since ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
720
815
|
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
721
816
|
const params: any[] = [cutoff];
|
|
817
|
+
sql = addScope(sql, params, effectiveNetId);
|
|
722
818
|
|
|
723
819
|
if (alias) {
|
|
724
|
-
sql +=
|
|
820
|
+
sql += ` AND session_name = ?${params.length + 1}`;
|
|
725
821
|
params.push(alias);
|
|
726
822
|
}
|
|
727
823
|
|