@sleep2agi/commhub-server 0.8.1-preview.2 → 0.8.1-preview.4

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.1-preview.2",
3
+ "version": "0.8.1-preview.4",
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/db.ts CHANGED
@@ -19,6 +19,18 @@ db.exec(`
19
19
  output TEXT,
20
20
  progress INTEGER DEFAULT 0,
21
21
  score REAL,
22
+ cpu_load_1min REAL,
23
+ cpu_cores INTEGER,
24
+ mem_total_gb REAL,
25
+ mem_used_gb REAL,
26
+ mem_avail_gb REAL,
27
+ disk_total_gb REAL,
28
+ disk_used_gb REAL,
29
+ disk_avail_gb REAL,
30
+ process_rss_bytes INTEGER,
31
+ process_cpu_pct REAL,
32
+ process_uptime_seconds REAL,
33
+ process_in_flight_count INTEGER,
22
34
  network_id TEXT NOT NULL DEFAULT 'default',
23
35
  registered_at TEXT NOT NULL DEFAULT (datetime('now')),
24
36
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -55,7 +67,7 @@ db.exec(`
55
67
 
56
68
  // ── V2 schema migration (ALTER TABLE, safe to re-run) ──
57
69
 
58
- // sessions: add node_id, session_id, config_path, channels, last_seen_at, model
70
+ // sessions: add node_id, session_id, config_path, channels, last_seen_at, model, host telemetry
59
71
  for (const col of [
60
72
  { name: "node_id", def: "TEXT" },
61
73
  { name: "session_id", def: "TEXT" },
@@ -63,10 +75,51 @@ for (const col of [
63
75
  { name: "channels", def: "TEXT" },
64
76
  { name: "last_seen_at", def: "TEXT" },
65
77
  { name: "model", def: "TEXT" },
78
+ { name: "cpu_load_1min", def: "REAL" },
79
+ { name: "cpu_cores", def: "INTEGER" },
80
+ { name: "mem_total_gb", def: "REAL" },
81
+ { name: "mem_used_gb", def: "REAL" },
82
+ { name: "mem_avail_gb", def: "REAL" },
83
+ { name: "disk_total_gb", def: "REAL" },
84
+ { name: "disk_used_gb", def: "REAL" },
85
+ { name: "disk_avail_gb", def: "REAL" },
86
+ { name: "process_rss_bytes", def: "INTEGER" },
87
+ { name: "process_cpu_pct", def: "REAL" },
88
+ { name: "process_uptime_seconds", def: "REAL" },
89
+ { name: "process_in_flight_count", def: "INTEGER" },
66
90
  ]) {
67
91
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
68
92
  }
69
93
 
94
+ db.exec(`
95
+ CREATE TABLE IF NOT EXISTS agent_telemetry (
96
+ id TEXT PRIMARY KEY,
97
+ network_id TEXT NOT NULL DEFAULT 'default',
98
+ resume_id TEXT,
99
+ alias TEXT,
100
+ hostname TEXT,
101
+ ip TEXT,
102
+ cpu_load_1min REAL,
103
+ cpu_cores INTEGER,
104
+ mem_total_gb REAL,
105
+ mem_used_gb REAL,
106
+ mem_avail_gb REAL,
107
+ disk_total_gb REAL,
108
+ disk_used_gb REAL,
109
+ disk_avail_gb REAL,
110
+ process_rss_bytes INTEGER,
111
+ process_cpu_pct REAL,
112
+ process_uptime_seconds REAL,
113
+ process_in_flight_count INTEGER,
114
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
115
+ );
116
+
117
+ CREATE INDEX IF NOT EXISTS idx_agent_telemetry_host_time
118
+ ON agent_telemetry(network_id, hostname, ip, created_at);
119
+ CREATE INDEX IF NOT EXISTS idx_agent_telemetry_alias_time
120
+ ON agent_telemetry(network_id, alias, created_at);
121
+ `);
122
+
70
123
  // inbox: add in_reply_to, requires_response, expires_at, scope
71
124
  for (const col of [
72
125
  { name: "in_reply_to", def: "TEXT" },
@@ -376,6 +429,19 @@ function migrateSessionsNetworkAliasUnique() {
376
429
  config_path TEXT,
377
430
  channels TEXT,
378
431
  last_seen_at TEXT,
432
+ model TEXT,
433
+ cpu_load_1min REAL,
434
+ cpu_cores INTEGER,
435
+ mem_total_gb REAL,
436
+ mem_used_gb REAL,
437
+ mem_avail_gb REAL,
438
+ disk_total_gb REAL,
439
+ disk_used_gb REAL,
440
+ disk_avail_gb REAL,
441
+ process_rss_bytes INTEGER,
442
+ process_cpu_pct REAL,
443
+ process_uptime_seconds REAL,
444
+ process_in_flight_count INTEGER,
379
445
  network_id TEXT NOT NULL DEFAULT 'default',
380
446
  UNIQUE (network_id, alias)
381
447
  )
@@ -384,12 +450,18 @@ function migrateSessionsNetworkAliasUnique() {
384
450
  INSERT OR REPLACE INTO sessions_migrated (
385
451
  resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version,
386
452
  status, task, output, progress, score, registered_at, updated_at, node_id,
387
- session_id, config_path, channels, last_seen_at, network_id
453
+ session_id, config_path, channels, last_seen_at, model, cpu_load_1min,
454
+ cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
455
+ disk_used_gb, disk_avail_gb, process_rss_bytes, process_cpu_pct,
456
+ process_uptime_seconds, process_in_flight_count, network_id
388
457
  )
389
458
  SELECT
390
459
  resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version,
391
460
  status, task, output, progress, score, registered_at, updated_at, node_id,
392
- session_id, config_path, channels, last_seen_at,
461
+ session_id, config_path, channels, last_seen_at, model, cpu_load_1min,
462
+ cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
463
+ disk_used_gb, disk_avail_gb, process_rss_bytes, process_cpu_pct,
464
+ process_uptime_seconds, process_in_flight_count,
393
465
  COALESCE(NULLIF(network_id, ''), 'default')
394
466
  FROM sessions
395
467
  ORDER BY updated_at
package/src/index.ts CHANGED
@@ -229,6 +229,104 @@ function singleNetworkId(scope: RestNetworkScope): string | null {
229
229
  return null;
230
230
  }
231
231
 
232
+ function sqliteTime(date: Date): string {
233
+ return date.toISOString().replace("T", " ").slice(0, 19);
234
+ }
235
+
236
+ function parseSqliteTime(value: string | null | undefined): number {
237
+ if (!value) return 0;
238
+ const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
239
+ const ts = Date.parse(normalized);
240
+ return Number.isFinite(ts) ? ts : 0;
241
+ }
242
+
243
+ function cpuPct(load: number | null | undefined, cores: number | null | undefined): number | null {
244
+ if (typeof load !== "number" || typeof cores !== "number" || cores <= 0) return null;
245
+ return Math.round((load / cores) * 1000) / 10;
246
+ }
247
+
248
+ function serverAlertLevel(row: any): { level: "green" | "yellow" | "red"; alerts: string[] } {
249
+ const alerts: string[] = [];
250
+ const pct = cpuPct(row?.cpu_load_1min, row?.cpu_cores);
251
+ if (pct !== null && pct >= 80) alerts.push(`cpu ${pct}%`);
252
+ if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 0.5) alerts.push(`memory ${row.mem_avail_gb}GB available`);
253
+ if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 1) alerts.push(`disk ${row.disk_avail_gb}GB available`);
254
+ if (alerts.length > 0) return { level: "red", alerts };
255
+
256
+ if (pct !== null && pct >= 60) alerts.push(`cpu ${pct}%`);
257
+ if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 1) alerts.push(`memory ${row.mem_avail_gb}GB available`);
258
+ if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 5) alerts.push(`disk ${row.disk_avail_gb}GB available`);
259
+ return { level: alerts.length > 0 ? "yellow" : "green", alerts };
260
+ }
261
+
262
+ function agentHealthChip(status: unknown, lastSeen: string | null | undefined): "online" | "offline" | "stale" {
263
+ if (String(status || "").toLowerCase() === "offline") return "offline";
264
+ const ts = parseSqliteTime(lastSeen);
265
+ if (!ts || Date.now() - ts > 5 * 60 * 1000) return "stale";
266
+ return "online";
267
+ }
268
+
269
+ function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
270
+ const buckets = new Map<number, {
271
+ ts: number;
272
+ count: number;
273
+ cpu_pct_sum: number;
274
+ cpu_pct_count: number;
275
+ cpu_load_sum: number;
276
+ cpu_load_count: number;
277
+ mem_avail_min: number | null;
278
+ mem_used_max: number | null;
279
+ disk_avail_min: number | null;
280
+ disk_used_max: number | null;
281
+ }>();
282
+
283
+ for (const row of rows) {
284
+ const ts = parseSqliteTime(row.created_at);
285
+ if (!ts || ts < fromMs) continue;
286
+ const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
287
+ const bucket = buckets.get(bucketTs) ?? {
288
+ ts: bucketTs,
289
+ count: 0,
290
+ cpu_pct_sum: 0,
291
+ cpu_pct_count: 0,
292
+ cpu_load_sum: 0,
293
+ cpu_load_count: 0,
294
+ mem_avail_min: null,
295
+ mem_used_max: null,
296
+ disk_avail_min: null,
297
+ disk_used_max: null,
298
+ };
299
+ bucket.count += 1;
300
+ const pct = cpuPct(row.cpu_load_1min, row.cpu_cores);
301
+ if (pct !== null) {
302
+ bucket.cpu_pct_sum += pct;
303
+ bucket.cpu_pct_count += 1;
304
+ }
305
+ if (typeof row.cpu_load_1min === "number") {
306
+ bucket.cpu_load_sum += row.cpu_load_1min;
307
+ bucket.cpu_load_count += 1;
308
+ }
309
+ if (typeof row.mem_avail_gb === "number") bucket.mem_avail_min = bucket.mem_avail_min === null ? row.mem_avail_gb : Math.min(bucket.mem_avail_min, row.mem_avail_gb);
310
+ if (typeof row.mem_used_gb === "number") bucket.mem_used_max = bucket.mem_used_max === null ? row.mem_used_gb : Math.max(bucket.mem_used_max, row.mem_used_gb);
311
+ if (typeof row.disk_avail_gb === "number") bucket.disk_avail_min = bucket.disk_avail_min === null ? row.disk_avail_gb : Math.min(bucket.disk_avail_min, row.disk_avail_gb);
312
+ if (typeof row.disk_used_gb === "number") bucket.disk_used_max = bucket.disk_used_max === null ? row.disk_used_gb : Math.max(bucket.disk_used_max, row.disk_used_gb);
313
+ buckets.set(bucketTs, bucket);
314
+ }
315
+
316
+ return Array.from(buckets.values())
317
+ .sort((a, b) => a.ts - b.ts)
318
+ .map((b) => ({
319
+ ts: new Date(b.ts).toISOString(),
320
+ count: b.count,
321
+ cpu_pct: b.cpu_pct_count ? Math.round((b.cpu_pct_sum / b.cpu_pct_count) * 10) / 10 : null,
322
+ cpu_load_1min: b.cpu_load_count ? Math.round((b.cpu_load_sum / b.cpu_load_count) * 100) / 100 : null,
323
+ mem_avail_gb: b.mem_avail_min,
324
+ mem_used_gb: b.mem_used_max,
325
+ disk_avail_gb: b.disk_avail_min,
326
+ disk_used_gb: b.disk_used_max,
327
+ }));
328
+ }
329
+
232
330
  function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
233
331
  if (!authCtx) return true; // legacy global token or open dev mode
234
332
  if (isAdmin) return true;
@@ -266,7 +364,7 @@ function corsHeaders(req: Request): Record<string, string> {
266
364
  const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
267
365
  return {
268
366
  "Access-Control-Allow-Origin": allowed,
269
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
367
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
270
368
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
271
369
  "Access-Control-Max-Age": "86400",
272
370
  };
@@ -841,6 +939,172 @@ Bun.serve({
841
939
  return withCors(req, Response.json({ ok: true, sessions, summary }));
842
940
  }
843
941
 
942
+ // ── REST: aggregate agents by physical server ──
943
+ if (url.pathname === "/api/servers") {
944
+ const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
945
+ const staleParams: any[] = [cutoff];
946
+ let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
947
+ staleSql = addNetworkScope(staleSql, staleParams, restScope);
948
+ db.run(staleSql, staleParams);
949
+
950
+ const params: any[] = [];
951
+ let sql = `
952
+ SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
953
+ COALESCE(last_seen_at, updated_at) AS last_seen
954
+ FROM sessions
955
+ WHERE 1=1
956
+ `;
957
+ sql = addNetworkScope(sql, params, restScope);
958
+ sql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC";
959
+
960
+ const grouped = new Map<string, {
961
+ hostname: string;
962
+ ip: string;
963
+ agent_count: number;
964
+ cpu_load_1min: number | null;
965
+ cpu_cores: number | null;
966
+ mem_avail_gb: number | null;
967
+ mem_used_gb: number | null;
968
+ last_seen: string | null;
969
+ }>();
970
+
971
+ for (const row of db.all<any>(sql, ...params)) {
972
+ const hostname = row.hostname || "unknown";
973
+ const ip = row.ip || "unknown";
974
+ const key = `${hostname}\u0000${ip}`;
975
+ const existing = grouped.get(key);
976
+ if (existing) {
977
+ existing.agent_count += 1;
978
+ continue;
979
+ }
980
+ grouped.set(key, {
981
+ hostname,
982
+ ip,
983
+ agent_count: 1,
984
+ cpu_load_1min: row.cpu_load_1min ?? null,
985
+ cpu_cores: row.cpu_cores ?? null,
986
+ mem_avail_gb: row.mem_avail_gb ?? null,
987
+ mem_used_gb: row.mem_used_gb ?? null,
988
+ last_seen: row.last_seen ?? null,
989
+ });
990
+ }
991
+
992
+ return withCors(req, Response.json(Array.from(grouped.values())));
993
+ }
994
+
995
+ const serverDetailMatch = url.pathname.match(/^\/api\/server\/([^/]+)\/(health|agents)$/);
996
+ if (serverDetailMatch && req.method === "GET") {
997
+ const host = decodeURIComponent(serverDetailMatch[1]);
998
+ const detailKind = serverDetailMatch[2];
999
+ if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
1000
+
1001
+ const cutoff = sqliteTime(new Date(Date.now() - 10 * 60 * 1000));
1002
+ const staleParams: any[] = [cutoff];
1003
+ let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
1004
+ staleSql = addNetworkScope(staleSql, staleParams, restScope);
1005
+ db.run(staleSql, staleParams);
1006
+
1007
+ if (detailKind === "agents") {
1008
+ const params: any[] = [host, host];
1009
+ let sql = `
1010
+ SELECT alias, agent, status, task, progress, model, hostname, ip,
1011
+ cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb, mem_total_gb,
1012
+ disk_avail_gb, disk_used_gb, disk_total_gb,
1013
+ process_rss_bytes, process_cpu_pct, process_uptime_seconds, process_in_flight_count,
1014
+ COALESCE(last_seen_at, updated_at) AS last_seen
1015
+ FROM sessions
1016
+ WHERE (hostname = ?1 OR ip = ?2)
1017
+ `;
1018
+ sql = addNetworkScope(sql, params, restScope);
1019
+ sql += " ORDER BY alias";
1020
+ const agents = db.all<any>(sql, ...params).map((s) => ({
1021
+ alias: s.alias,
1022
+ runtime: normalizeRuntime(s.agent),
1023
+ raw_agent: s.agent ?? null,
1024
+ model: s.model ?? null,
1025
+ status: s.status ?? "offline",
1026
+ task: s.task ?? null,
1027
+ progress: s.progress ?? 0,
1028
+ last_seen: s.last_seen ?? null,
1029
+ health: agentHealthChip(s.status, s.last_seen),
1030
+ hostname: s.hostname ?? null,
1031
+ ip: s.ip ?? null,
1032
+ telemetry: {
1033
+ cpu_load_1min: s.cpu_load_1min ?? null,
1034
+ cpu_cores: s.cpu_cores ?? null,
1035
+ cpu_pct: cpuPct(s.cpu_load_1min, s.cpu_cores),
1036
+ mem_total_gb: s.mem_total_gb ?? null,
1037
+ mem_used_gb: s.mem_used_gb ?? null,
1038
+ mem_avail_gb: s.mem_avail_gb ?? null,
1039
+ disk_total_gb: s.disk_total_gb ?? null,
1040
+ disk_used_gb: s.disk_used_gb ?? null,
1041
+ disk_avail_gb: s.disk_avail_gb ?? null,
1042
+ process_rss_bytes: s.process_rss_bytes ?? null,
1043
+ process_cpu_pct: s.process_cpu_pct ?? null,
1044
+ process_uptime_seconds: s.process_uptime_seconds ?? null,
1045
+ process_in_flight_count: s.process_in_flight_count ?? null,
1046
+ },
1047
+ }));
1048
+ if (agents.length === 0) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
1049
+ return withCors(req, Response.json({ ok: true, host, agent_count: agents.length, agents }));
1050
+ }
1051
+
1052
+ const latestParams: any[] = [host, host];
1053
+ let latestSql = `
1054
+ SELECT hostname, ip, COUNT(*) OVER () AS agent_count,
1055
+ cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
1056
+ disk_total_gb, disk_used_gb, disk_avail_gb,
1057
+ COALESCE(last_seen_at, updated_at) AS last_seen
1058
+ FROM sessions
1059
+ WHERE (hostname = ?1 OR ip = ?2)
1060
+ `;
1061
+ latestSql = addNetworkScope(latestSql, latestParams, restScope);
1062
+ latestSql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC LIMIT 1";
1063
+ const latest = db.get<any>(latestSql, ...latestParams);
1064
+ if (!latest) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
1065
+
1066
+ const since24h = sqliteTime(new Date(Date.now() - 24 * 60 * 60 * 1000));
1067
+ const histParams: any[] = [host, host, since24h];
1068
+ let histSql = `
1069
+ SELECT created_at, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
1070
+ disk_total_gb, disk_used_gb, disk_avail_gb
1071
+ FROM agent_telemetry
1072
+ WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
1073
+ `;
1074
+ histSql = addNetworkScope(histSql, histParams, restScope);
1075
+ histSql += " ORDER BY created_at ASC";
1076
+ const historyRows = db.all<any>(histSql, ...histParams);
1077
+ const now = Date.now();
1078
+ const alert = serverAlertLevel(latest);
1079
+
1080
+ return withCors(req, Response.json({
1081
+ ok: true,
1082
+ host,
1083
+ hostname: latest.hostname ?? null,
1084
+ ip: latest.ip ?? null,
1085
+ agent_count: latest.agent_count ?? 0,
1086
+ alert_level: alert.level,
1087
+ alerts: alert.alerts,
1088
+ latest: {
1089
+ cpu_load_1min: latest.cpu_load_1min ?? null,
1090
+ cpu_cores: latest.cpu_cores ?? null,
1091
+ cpu_pct: cpuPct(latest.cpu_load_1min, latest.cpu_cores),
1092
+ mem_total_gb: latest.mem_total_gb ?? null,
1093
+ mem_used_gb: latest.mem_used_gb ?? null,
1094
+ mem_avail_gb: latest.mem_avail_gb ?? null,
1095
+ disk_total_gb: latest.disk_total_gb ?? null,
1096
+ disk_used_gb: latest.disk_used_gb ?? null,
1097
+ disk_avail_gb: latest.disk_avail_gb ?? null,
1098
+ last_seen: latest.last_seen ?? null,
1099
+ },
1100
+ history: {
1101
+ "5m": bucketTelemetry(historyRows, now - 5 * 60 * 1000, 60 * 1000),
1102
+ "1h": bucketTelemetry(historyRows, now - 60 * 60 * 1000, 5 * 60 * 1000),
1103
+ "24h": bucketTelemetry(historyRows, now - 24 * 60 * 60 * 1000, 60 * 60 * 1000),
1104
+ },
1105
+ }));
1106
+ }
1107
+
844
1108
  // ── REST: send task ──
845
1109
  if (url.pathname === "/api/task" && req.method === "POST") {
846
1110
  let raw: unknown;
@@ -1109,6 +1373,52 @@ Bun.serve({
1109
1373
  return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
1110
1374
  }
1111
1375
 
1376
+ // ── REST: delete node (Dashboard/CLI remote cleanup) ──
1377
+ const nodeDeleteMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)$/);
1378
+ if (nodeDeleteMatch && req.method === "DELETE") {
1379
+ const ref = decodeURIComponent(nodeDeleteMatch[1]);
1380
+ const params: any[] = [ref, ref, ref];
1381
+ let sql = "SELECT * FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
1382
+ sql = addNetworkScope(sql, params, restScope);
1383
+ sql += " ORDER BY updated_at DESC LIMIT 1";
1384
+ const node = db.get<any>(sql, ...params);
1385
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
1386
+
1387
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
1388
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
1389
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1390
+ }
1391
+
1392
+ db.transaction(() => {
1393
+ db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
1394
+ if (node.alias) {
1395
+ db.run(
1396
+ "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
1397
+ [node.alias, node.network_id ?? null]
1398
+ );
1399
+ }
1400
+ });
1401
+
1402
+ if (node.alias) {
1403
+ pushEvent(node.alias, {
1404
+ type: "node_deleted",
1405
+ node_id: node.node_id,
1406
+ node_name: node.node_name,
1407
+ alias: node.alias,
1408
+ network_id: node.network_id ?? null,
1409
+ }, node.network_id ?? null);
1410
+ }
1411
+
1412
+ return withCors(req, Response.json({
1413
+ ok: true,
1414
+ deleted: true,
1415
+ node_id: node.node_id,
1416
+ node_name: node.node_name,
1417
+ alias: node.alias,
1418
+ network_id: node.network_id ?? null,
1419
+ }));
1420
+ }
1421
+
1112
1422
  // ── REST: nodes table (V2 Sprint 2) ──
1113
1423
  if (url.pathname === "/api/nodes") {
1114
1424
  const nodeId = url.searchParams.get("node_id");
package/src/tools.ts CHANGED
@@ -29,6 +29,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
29
29
  return !!role && role !== "viewer"; // owner/admin/member can write
30
30
  };
31
31
 
32
+ const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
33
+ const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
34
+ const message = !netId
35
+ ? "network_id required (utok current_network is null; pass explicit network_id from /api/auth/me networks[0].network_id)"
36
+ : action === "send_task"
37
+ ? "Viewer role cannot send tasks"
38
+ : "Viewer role cannot write to this network";
39
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
40
+ };
41
+
32
42
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
33
43
  if (!networkId) return sql;
34
44
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -109,25 +119,57 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
109
119
  model: z.string().max(200).optional().describe("AI model name"),
110
120
  node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
111
121
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
122
+ host: z.object({
123
+ hostname: z.string().max(200).optional(),
124
+ ip: z.string().max(200).optional(),
125
+ cpu_load_1min: z.number().nullable().optional(),
126
+ cpu_cores: z.number().nullable().optional(),
127
+ mem_total_gb: z.number().nullable().optional(),
128
+ mem_used_gb: z.number().nullable().optional(),
129
+ mem_avail_gb: z.number().nullable().optional(),
130
+ disk_total_gb: z.number().nullable().optional(),
131
+ disk_used_gb: z.number().nullable().optional(),
132
+ disk_avail_gb: z.number().nullable().optional(),
133
+ }).optional().describe("Host telemetry reported by agent-node"),
134
+ process_telemetry: z.object({
135
+ rss: z.number().nullable().optional(),
136
+ cpu_pct: z.number().nullable().optional(),
137
+ uptime_seconds: z.number().nullable().optional(),
138
+ in_flight_count: z.number().nullable().optional(),
139
+ }).optional().describe("Per-agent process telemetry reported by agent-node"),
112
140
  },
113
- 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 }) => {
141
+ 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, host, process_telemetry: proc }) => {
114
142
  const effectiveNetId = getNetworkId(netId);
115
143
  const sessionNetId = effectiveNetId ?? "default";
116
144
  if (!callerTokenIsNetwork || !enforceNetworkId) {
117
145
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
118
146
  }
119
147
  if (!canWrite(effectiveNetId)) {
120
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
148
+ return writeDeniedReply(effectiveNetId);
121
149
  }
122
150
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
123
151
  const trimmedOutput = output?.slice(0, 4000);
152
+ const hostHostname = host?.hostname || hn || null;
153
+ const hostIp = host?.ip || clientIP || null;
154
+ const cpuLoad1m = typeof host?.cpu_load_1min === "number" ? host.cpu_load_1min : null;
155
+ const cpuCores = typeof host?.cpu_cores === "number" ? host.cpu_cores : null;
156
+ const memTotalGb = typeof host?.mem_total_gb === "number" ? host.mem_total_gb : null;
157
+ const memUsedGb = typeof host?.mem_used_gb === "number" ? host.mem_used_gb : null;
158
+ const memAvailGb = typeof host?.mem_avail_gb === "number" ? host.mem_avail_gb : null;
159
+ const diskTotalGb = typeof host?.disk_total_gb === "number" ? host.disk_total_gb : null;
160
+ const diskUsedGb = typeof host?.disk_used_gb === "number" ? host.disk_used_gb : null;
161
+ const diskAvailGb = typeof host?.disk_avail_gb === "number" ? host.disk_avail_gb : null;
162
+ const processRssBytes = typeof proc?.rss === "number" ? proc.rss : null;
163
+ const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
164
+ const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
165
+ const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
124
166
 
125
167
  db.transaction(() => {
126
168
  // Only delete same-alias sessions within the same network
127
169
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, sessionNetId]);
128
170
  db.run(
129
- `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, model, last_seen_at, updated_at)
130
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, datetime('now'), datetime('now'))
171
+ `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, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_cpu_pct, process_uptime_seconds, process_in_flight_count, last_seen_at, updated_at)
172
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, datetime('now'), datetime('now'))
131
173
  ON CONFLICT(resume_id) DO UPDATE SET
132
174
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
133
175
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -139,9 +181,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
139
181
  session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
140
182
  channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
141
183
  model = COALESCE(?20, sessions.model),
184
+ cpu_load_1min = COALESCE(?21, sessions.cpu_load_1min),
185
+ cpu_cores = COALESCE(?22, sessions.cpu_cores),
186
+ mem_total_gb = COALESCE(?23, sessions.mem_total_gb),
187
+ mem_used_gb = COALESCE(?24, sessions.mem_used_gb),
188
+ mem_avail_gb = COALESCE(?25, sessions.mem_avail_gb),
189
+ disk_total_gb = COALESCE(?26, sessions.disk_total_gb),
190
+ disk_used_gb = COALESCE(?27, sessions.disk_used_gb),
191
+ disk_avail_gb = COALESCE(?28, sessions.disk_avail_gb),
192
+ process_rss_bytes = COALESCE(?29, sessions.process_rss_bytes),
193
+ process_cpu_pct = COALESCE(?30, sessions.process_cpu_pct),
194
+ process_uptime_seconds = COALESCE(?31, sessions.process_uptime_seconds),
195
+ process_in_flight_count = COALESCE(?32, sessions.process_in_flight_count),
142
196
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
143
- [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, sessionNetId, mdl ?? null]
197
+ [resume_id, alias, tmux ?? null, srv ?? null, hostIp, hostHostname, 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, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processCpuPct, processUptimeSeconds, processInFlightCount]
144
198
  );
199
+ if (host || proc) {
200
+ db.run(
201
+ `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
202
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'))`,
203
+ [uuidv4(), sessionNetId, resume_id, alias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processCpuPct, processUptimeSeconds, processInFlightCount]
204
+ );
205
+ }
145
206
  });
146
207
 
147
208
  // V2: sync tasks table — report_status(working) → tasks.running
@@ -225,7 +286,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
225
286
  async ({ alias, task, result, artifacts, score, duration_minutes, network_id: netId }) => {
226
287
  const effectiveNetId = getNetworkId(netId);
227
288
  if (!canWrite(effectiveNetId)) {
228
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
289
+ return writeDeniedReply(effectiveNetId);
229
290
  }
230
291
  console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}${effectiveNetId ? " [net]" : ""}`);
231
292
  const id = uuidv4();
@@ -337,7 +398,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
337
398
  },
338
399
  async ({ alias, message_id, response }) => {
339
400
  const effectiveNetId = getNetworkId(null);
340
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
401
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
341
402
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
342
403
  const ackParams: any[] = [message_id, alias];
343
404
  let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
@@ -478,7 +539,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
478
539
 
479
540
  // Role check: viewer cannot send tasks
480
541
  if (!canWrite(effectiveNetId)) {
481
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
542
+ return writeDeniedReply(effectiveNetId, "send_task");
482
543
  }
483
544
 
484
545
  // License check
@@ -557,7 +618,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
557
618
  },
558
619
  async ({ alias, message, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
559
620
  const effectiveNetId = getNetworkId(null);
560
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
621
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
561
622
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
562
623
  const id = uuidv4();
563
624
  db.run(
@@ -598,7 +659,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
598
659
  },
599
660
  async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
600
661
  const effectiveNetId = getNetworkId(null);
601
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
662
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
602
663
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
603
664
  const id = uuidv4();
604
665
  const replyLogged = db.transaction(() => {
@@ -673,7 +734,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
673
734
  },
674
735
  async ({ task_id, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
675
736
  const effectiveNetId = getNetworkId(null);
676
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
737
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
677
738
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
678
739
  const updateParams: any[] = [task_id];
679
740
  let updateSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')";
@@ -699,7 +760,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
699
760
  },
700
761
  async ({ task_id, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
701
762
  const effectiveNetId = getNetworkId(null);
702
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
763
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
703
764
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
704
765
  // Find the original task
705
766
  const taskParams: any[] = [task_id];
@@ -810,7 +871,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
810
871
  },
811
872
  async ({ task_id, reason, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
812
873
  const effectiveNetId = getNetworkId(null);
813
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
874
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
814
875
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
815
876
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
816
877
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
@@ -842,7 +903,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
842
903
  },
843
904
  async ({ task_id, new_alias, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
844
905
  const effectiveNetId = getNetworkId(null);
845
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
906
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
846
907
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
847
908
  const taskParams: any[] = [task_id];
848
909
  let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
@@ -886,7 +947,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
886
947
  },
887
948
  async ({ message, filter_server, filter_status, network_id: netId }) => {
888
949
  const effectiveNetId = getNetworkId(netId);
889
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
950
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
890
951
  console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
891
952
  let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
892
953
  const params: any[] = [];