@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.5-preview.0",
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
- const sessionParams: any[] = [targetAlias];
1255
- let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
1256
- if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
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
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
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
- const session = scopedSessionStatus(alias, effectiveNetId);
846
+ if (target.state === "online") {
847
+ pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
848
+ }
761
849
 
762
- pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
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