@sleep2agi/commhub-server 0.8.5-preview.0 → 0.8.5-preview.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/package.json +1 -1
- package/src/index.ts +57 -5
- package/src/tools.ts +148 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.5-preview.
|
|
3
|
+
"version": "0.8.5-preview.1",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -341,6 +341,36 @@ function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null
|
|
|
341
341
|
return !!role && role !== "viewer";
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
type RestDeliveryTarget =
|
|
345
|
+
| { state: "online"; alias: string; session: any }
|
|
346
|
+
| { state: "offline"; alias: string; session: any; message: string }
|
|
347
|
+
| { state: "not_found"; alias: string; message: string };
|
|
348
|
+
|
|
349
|
+
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
350
|
+
const params: any[] = [alias];
|
|
351
|
+
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
352
|
+
if (networkId) {
|
|
353
|
+
sql += " AND network_id = ?2";
|
|
354
|
+
params.push(networkId);
|
|
355
|
+
}
|
|
356
|
+
const session = db.get<any>(sql, ...params);
|
|
357
|
+
if (!session) {
|
|
358
|
+
return { state: "not_found", alias, message: `alias not found: ${alias}` };
|
|
359
|
+
}
|
|
360
|
+
const lastSeen = session.last_seen_at || session.updated_at;
|
|
361
|
+
const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
|
|
362
|
+
const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
|
|
363
|
+
if (String(session.status || "").toLowerCase() === "offline" || stale) {
|
|
364
|
+
return {
|
|
365
|
+
state: "offline",
|
|
366
|
+
alias,
|
|
367
|
+
session,
|
|
368
|
+
message: `alias is offline; task queued in inbox: ${alias}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { state: "online", alias, session };
|
|
372
|
+
}
|
|
373
|
+
|
|
344
374
|
// ── REST input schema ───────────────────────────────
|
|
345
375
|
const TaskSchema = z.object({
|
|
346
376
|
alias: z.string().min(1).max(200),
|
|
@@ -1199,6 +1229,17 @@ Bun.serve({
|
|
|
1199
1229
|
}
|
|
1200
1230
|
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
1201
1231
|
const targetAlias = canonical.alias;
|
|
1232
|
+
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
1233
|
+
if (target.state === "not_found") {
|
|
1234
|
+
return withCors(req, Response.json({
|
|
1235
|
+
ok: false,
|
|
1236
|
+
error: "alias_not_found",
|
|
1237
|
+
message: target.message,
|
|
1238
|
+
alias: targetAlias,
|
|
1239
|
+
queued: false,
|
|
1240
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
1241
|
+
}, { status: 404 }));
|
|
1242
|
+
}
|
|
1202
1243
|
const id = crypto.randomUUID();
|
|
1203
1244
|
const fromSession = body.from || "api";
|
|
1204
1245
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
@@ -1251,15 +1292,26 @@ Bun.serve({
|
|
|
1251
1292
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
1252
1293
|
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
1253
1294
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
const targetSession = db.get<any>(sessionSql, ...sessionParams);
|
|
1258
|
-
if (targetSession) pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
|
|
1295
|
+
if (target.state === "online") {
|
|
1296
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
|
|
1297
|
+
}
|
|
1259
1298
|
// #212 — stamp the dedup index only after the inbox/tasks insert
|
|
1260
1299
|
// succeeds. Mirrors the MCP `send_task` path so a failed write
|
|
1261
1300
|
// never shadows a legitimate retry.
|
|
1262
1301
|
sharedSendDedup.record(fromSession, targetAlias, body.task);
|
|
1302
|
+
if (target.state === "offline") {
|
|
1303
|
+
return withCors(req, Response.json({
|
|
1304
|
+
ok: false,
|
|
1305
|
+
error: "alias_offline",
|
|
1306
|
+
message: target.message,
|
|
1307
|
+
alias: targetAlias,
|
|
1308
|
+
queued: true,
|
|
1309
|
+
task_id: id,
|
|
1310
|
+
message_id: id,
|
|
1311
|
+
session_status: target.session.status ?? "offline",
|
|
1312
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
1313
|
+
}, { status: 202 }));
|
|
1314
|
+
}
|
|
1263
1315
|
return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
|
|
1264
1316
|
}
|
|
1265
1317
|
|
package/src/tools.ts
CHANGED
|
@@ -112,12 +112,75 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
112
112
|
return sql;
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
+
type DeliveryTarget =
|
|
116
|
+
| { state: "online"; alias: string; session: any }
|
|
117
|
+
| { state: "offline"; alias: string; session: any; message: string }
|
|
118
|
+
| { state: "not_found"; alias: string; message: string };
|
|
119
|
+
|
|
115
120
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
116
121
|
const params: any[] = [alias];
|
|
117
|
-
let sql = "SELECT status FROM sessions WHERE alias = ?1";
|
|
122
|
+
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
118
123
|
sql = addScope(sql, params, networkId);
|
|
119
124
|
return db.get<any>(sql, ...params);
|
|
120
125
|
};
|
|
126
|
+
|
|
127
|
+
const resolveDeliveryTarget = (alias: string, networkId?: string | null): DeliveryTarget => {
|
|
128
|
+
const session = scopedSessionStatus(alias, networkId);
|
|
129
|
+
if (!session) {
|
|
130
|
+
return {
|
|
131
|
+
state: "not_found",
|
|
132
|
+
alias,
|
|
133
|
+
message: `alias not found: ${alias}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const lastSeen = session.last_seen_at || session.updated_at;
|
|
137
|
+
const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
|
|
138
|
+
const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
|
|
139
|
+
if (String(session.status || "").toLowerCase() === "offline" || stale) {
|
|
140
|
+
return {
|
|
141
|
+
state: "offline",
|
|
142
|
+
alias,
|
|
143
|
+
session,
|
|
144
|
+
message: `alias is offline; message queued in inbox: ${alias}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { state: "online", alias, session };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const deliveryTargetReply = (target: DeliveryTarget, ids: Record<string, string> = {}) => {
|
|
151
|
+
if (target.state === "not_found") {
|
|
152
|
+
return {
|
|
153
|
+
content: [{
|
|
154
|
+
type: "text" as const,
|
|
155
|
+
text: JSON.stringify({
|
|
156
|
+
ok: false,
|
|
157
|
+
error: "alias_not_found",
|
|
158
|
+
message: target.message,
|
|
159
|
+
alias: target.alias,
|
|
160
|
+
queued: false,
|
|
161
|
+
...ids,
|
|
162
|
+
}),
|
|
163
|
+
}],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (target.state === "offline") {
|
|
167
|
+
return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: "text" as const,
|
|
170
|
+
text: JSON.stringify({
|
|
171
|
+
ok: false,
|
|
172
|
+
error: "alias_offline",
|
|
173
|
+
message: target.message,
|
|
174
|
+
alias: target.alias,
|
|
175
|
+
queued: true,
|
|
176
|
+
session_status: target.session.status ?? "offline",
|
|
177
|
+
...ids,
|
|
178
|
+
}),
|
|
179
|
+
}],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
};
|
|
121
184
|
// ═══════════════════════════════════════════
|
|
122
185
|
// Child Agent Tools (4)
|
|
123
186
|
// ═══════════════════════════════════════════
|
|
@@ -658,6 +721,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
658
721
|
|
|
659
722
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
660
723
|
const targetAlias = canonical.alias;
|
|
724
|
+
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
725
|
+
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
661
726
|
|
|
662
727
|
// #212 dedup guardrail. If this exact (from, to, content) has already
|
|
663
728
|
// been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
|
|
@@ -707,8 +772,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
707
772
|
// retry.
|
|
708
773
|
sharedSendDedup.record(from_session, targetAlias, task);
|
|
709
774
|
|
|
710
|
-
const session = scopedSessionStatus(targetAlias, effectiveNetId);
|
|
711
|
-
|
|
712
775
|
// SSE push by alias.
|
|
713
776
|
// The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
|
|
714
777
|
// not by network_id. Earlier we gated the push on a network-scoped session
|
|
@@ -720,7 +783,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
720
783
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
721
784
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
722
785
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
723
|
-
|
|
786
|
+
if (target.state === "online") {
|
|
787
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (target.state === "offline") {
|
|
791
|
+
return {
|
|
792
|
+
content: [{
|
|
793
|
+
type: "text" as const,
|
|
794
|
+
text: JSON.stringify({
|
|
795
|
+
ok: false,
|
|
796
|
+
error: "alias_offline",
|
|
797
|
+
message: target.message,
|
|
798
|
+
alias: targetAlias,
|
|
799
|
+
queued: true,
|
|
800
|
+
task_id: id,
|
|
801
|
+
message_id: id,
|
|
802
|
+
session_status: target.session.status ?? "offline",
|
|
803
|
+
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
804
|
+
}),
|
|
805
|
+
}],
|
|
806
|
+
};
|
|
807
|
+
}
|
|
724
808
|
|
|
725
809
|
return {
|
|
726
810
|
content: [
|
|
@@ -730,7 +814,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
730
814
|
ok: true,
|
|
731
815
|
message_id: id,
|
|
732
816
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
733
|
-
session_status: session?.status ?? "unknown",
|
|
817
|
+
session_status: target.session?.status ?? "unknown",
|
|
734
818
|
}),
|
|
735
819
|
},
|
|
736
820
|
],
|
|
@@ -749,6 +833,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
749
833
|
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
750
834
|
const effectiveNetId = getNetworkId(null);
|
|
751
835
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
836
|
+
const target = resolveDeliveryTarget(alias, effectiveNetId);
|
|
837
|
+
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
752
838
|
console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
|
|
753
839
|
const id = uuidv4();
|
|
754
840
|
db.run(
|
|
@@ -757,9 +843,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
757
843
|
[id, alias, message, from_session, effectiveNetId ?? null]
|
|
758
844
|
);
|
|
759
845
|
|
|
760
|
-
|
|
846
|
+
if (target.state === "online") {
|
|
847
|
+
pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
|
|
848
|
+
}
|
|
761
849
|
|
|
762
|
-
|
|
850
|
+
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
851
|
+
if (offlineReply) return offlineReply;
|
|
763
852
|
|
|
764
853
|
return {
|
|
765
854
|
content: [
|
|
@@ -768,7 +857,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
768
857
|
text: JSON.stringify({
|
|
769
858
|
ok: true,
|
|
770
859
|
message_id: id,
|
|
771
|
-
session_status: session?.status ?? "unknown",
|
|
860
|
+
session_status: target.session?.status ?? "unknown",
|
|
772
861
|
}),
|
|
773
862
|
},
|
|
774
863
|
],
|
|
@@ -792,6 +881,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
792
881
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
793
882
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
794
883
|
const id = uuidv4();
|
|
884
|
+
let taskBefore: { status: string } | null = null;
|
|
885
|
+
if (in_reply_to) {
|
|
886
|
+
const taskParams: any[] = [in_reply_to];
|
|
887
|
+
let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
|
|
888
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
889
|
+
taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
|
|
890
|
+
if (!taskBefore) {
|
|
891
|
+
return {
|
|
892
|
+
content: [{
|
|
893
|
+
type: "text" as const,
|
|
894
|
+
text: JSON.stringify({
|
|
895
|
+
ok: false,
|
|
896
|
+
error: "reply_task_not_found",
|
|
897
|
+
message: `cannot apply reply: task not found (${in_reply_to})`,
|
|
898
|
+
in_reply_to,
|
|
899
|
+
reply_queued: false,
|
|
900
|
+
}),
|
|
901
|
+
}],
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
|
|
905
|
+
return {
|
|
906
|
+
content: [{
|
|
907
|
+
type: "text" as const,
|
|
908
|
+
text: JSON.stringify({
|
|
909
|
+
ok: false,
|
|
910
|
+
error: "reply_task_terminal",
|
|
911
|
+
message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
|
|
912
|
+
in_reply_to,
|
|
913
|
+
task_status: taskBefore.status,
|
|
914
|
+
reply_queued: false,
|
|
915
|
+
}),
|
|
916
|
+
}],
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
795
920
|
const replyLogged = db.transaction(() => {
|
|
796
921
|
db.run(
|
|
797
922
|
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
@@ -815,6 +940,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
815
940
|
return false;
|
|
816
941
|
});
|
|
817
942
|
|
|
943
|
+
if (in_reply_to && !replyLogged) {
|
|
944
|
+
return {
|
|
945
|
+
content: [{
|
|
946
|
+
type: "text" as const,
|
|
947
|
+
text: JSON.stringify({
|
|
948
|
+
ok: false,
|
|
949
|
+
error: "reply_not_applied",
|
|
950
|
+
message: "reply was not applied to task",
|
|
951
|
+
in_reply_to,
|
|
952
|
+
reply_queued: false,
|
|
953
|
+
}),
|
|
954
|
+
}],
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
818
958
|
// Log event after commit (outside transaction)
|
|
819
959
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
820
960
|
|