u-foo 1.3.0 → 1.4.0

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/online/server.js +127 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -46,6 +46,7 @@ class OnlineServer extends EventEmitter {
46
46
  this.rooms = new Map();
47
47
  this.roomPasswords = new Map();
48
48
  this.channelMessageHistory = new Map();
49
+ this.roomMessageHistory = new Map();
49
50
  this.channelHistoryLimit = options.channelHistoryLimit ?? 200;
50
51
  this.eventSeq = 0;
51
52
 
@@ -147,6 +148,7 @@ class OnlineServer extends EventEmitter {
147
148
  const pathname = parsedUrl.pathname || "/";
148
149
  const publicMessagesMatch = pathname.match(/^\/ufoo\/online\/public\/channels\/([^/]+)\/messages$/);
149
150
  const privateMessagesMatch = pathname.match(/^\/ufoo\/online\/channels\/([^/]+)\/messages$/);
151
+ const roomMessagesMatch = pathname.match(/^\/ufoo\/online\/rooms\/([^/]+)\/messages$/);
150
152
 
151
153
  if (req.method === "OPTIONS") {
152
154
  res.writeHead(204, this.corsHeaders());
@@ -177,6 +179,12 @@ class OnlineServer extends EventEmitter {
177
179
  return;
178
180
  }
179
181
 
182
+ if (roomMessagesMatch) {
183
+ const roomId = decodeURIComponent(roomMessagesMatch[1] || "");
184
+ this.handleRoomMessagesRequest(req, res, roomId, parsedUrl);
185
+ return;
186
+ }
187
+
180
188
  if (pathname.startsWith("/ufoo/online/rooms")) {
181
189
  // Step 4: HTTP auth
182
190
  if (!this.authenticateHttp(req, res)) return;
@@ -431,6 +439,39 @@ class OnlineServer extends EventEmitter {
431
439
  });
432
440
  }
433
441
 
442
+ handleRoomMessagesRequest(req, res, roomId, parsedUrl) {
443
+ if (req.method !== "GET") {
444
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
445
+ return;
446
+ }
447
+ const room = this.rooms.get(roomId);
448
+ if (!room) {
449
+ this.sendJson(res, 404, { ok: false, error: "Room not found" });
450
+ return;
451
+ }
452
+ // Private rooms require password via query param
453
+ if (room.type === "private") {
454
+ const password = String(parsedUrl.searchParams.get("password") || "");
455
+ const stored = this.roomPasswords.get(roomId);
456
+ if (!stored || !this.verifyPassword(password, stored)) {
457
+ this.sendJson(res, 403, { ok: false, error: "Invalid room password" });
458
+ return;
459
+ }
460
+ }
461
+ const limitRaw = Number.parseInt(String(parsedUrl.searchParams.get("limit") || "80"), 10);
462
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 80;
463
+ const roomData = this.listRoomMessages(roomId, limit);
464
+ if (!roomData) {
465
+ this.sendJson(res, 404, { ok: false, error: "Room not found" });
466
+ return;
467
+ }
468
+ this.sendJson(res, 200, {
469
+ ok: true,
470
+ room: { room_id: roomData.room_id, name: roomData.name, type: roomData.type },
471
+ messages: roomData.messages,
472
+ });
473
+ }
474
+
434
475
  recordChannelMessage(channelId, channel, client, eventPayload) {
435
476
  const rawText = eventPayload?.payload?.message;
436
477
  let text = "";
@@ -459,6 +500,45 @@ class OnlineServer extends EventEmitter {
459
500
  }
460
501
  }
461
502
 
503
+ recordRoomMessage(roomId, room, client, eventPayload) {
504
+ const rawText = eventPayload?.payload?.message;
505
+ let text = "";
506
+ if (typeof rawText === "string") text = rawText;
507
+ else if (rawText !== null && rawText !== undefined) text = JSON.stringify(rawText);
508
+ if (!text) return;
509
+
510
+ const history = this.roomMessageHistory.get(roomId) || [];
511
+ const ts = eventPayload.ts || new Date().toISOString();
512
+ const entry = {
513
+ event_id: `event_${String(++this.eventSeq).padStart(8, "0")}`,
514
+ ts,
515
+ room_id: roomId,
516
+ room_name: room?.name || roomId,
517
+ from: client.subscriberId || "",
518
+ nickname: client.nickname || "",
519
+ text,
520
+ };
521
+ history.push(entry);
522
+ if (history.length > this.channelHistoryLimit) {
523
+ history.splice(0, history.length - this.channelHistoryLimit);
524
+ }
525
+ this.roomMessageHistory.set(roomId, history);
526
+ }
527
+
528
+ listRoomMessages(roomId, limit = 80) {
529
+ const room = this.rooms.get(roomId);
530
+ if (!room) return null;
531
+ const history = this.roomMessageHistory.get(roomId) || [];
532
+ const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 80;
533
+ const start = Math.max(0, history.length - safeLimit);
534
+ return {
535
+ room_id: roomId,
536
+ name: room.name || "",
537
+ type: room.type,
538
+ messages: history.slice(start),
539
+ };
540
+ }
541
+
462
542
  handleRoomsRequest(req, res) {
463
543
  if (req.method === "GET") {
464
544
  this.sendJson(res, 200, { ok: true, rooms: this.listRooms() });
@@ -518,6 +598,7 @@ class OnlineServer extends EventEmitter {
518
598
  name,
519
599
  type,
520
600
  members: new Set(),
601
+ observers: new Set(),
521
602
  created_at: new Date().toISOString(),
522
603
  created_by: createdBy,
523
604
  });
@@ -593,6 +674,7 @@ class OnlineServer extends EventEmitter {
593
674
  name,
594
675
  type,
595
676
  members: new Set(),
677
+ observers: new Set(),
596
678
  created_at: new Date().toISOString(),
597
679
  created_by: createdBy,
598
680
  });
@@ -650,6 +732,7 @@ class OnlineServer extends EventEmitter {
650
732
  authed: false,
651
733
  subscriberId: null,
652
734
  nickname: null,
735
+ role: "agent", // "agent" or "observer"
653
736
  channels: new Set(),
654
737
  helloReceived: false,
655
738
  connectedAt: Date.now(),
@@ -796,6 +879,12 @@ class OnlineServer extends EventEmitter {
796
879
  client.pendingWorld = world;
797
880
  client.rooms = new Set();
798
881
 
882
+ // Observer role: set from capabilities
883
+ const caps = Array.isArray(info.capabilities) ? info.capabilities : [];
884
+ if (caps.includes("observer")) {
885
+ client.role = "observer";
886
+ }
887
+
799
888
  this.send(client.ws, {
800
889
  type: "hello_ack",
801
890
  ok: true,
@@ -902,6 +991,7 @@ class OnlineServer extends EventEmitter {
902
991
  name: channelRef,
903
992
  type: "public",
904
993
  members: new Set(),
994
+ observers: new Set(),
905
995
  created_at: new Date().toISOString(),
906
996
  created_by: "",
907
997
  };
@@ -938,7 +1028,12 @@ class OnlineServer extends EventEmitter {
938
1028
  const channelId = resolved.channelId;
939
1029
  const channelInfo = resolved.channel;
940
1030
 
941
- channelInfo.members.add(client);
1031
+ if (client.role === "observer") {
1032
+ if (!channelInfo.observers) channelInfo.observers = new Set();
1033
+ channelInfo.observers.add(client);
1034
+ } else {
1035
+ channelInfo.members.add(client);
1036
+ }
942
1037
  client.channels.add(channelId);
943
1038
  this.send(client.ws, { type: "join_ack", ok: true, channel: channelId });
944
1039
  }
@@ -963,6 +1058,7 @@ class OnlineServer extends EventEmitter {
963
1058
  const channelInfo = resolved?.channel || null;
964
1059
  if (channelInfo) {
965
1060
  channelInfo.members.delete(client);
1061
+ if (channelInfo.observers) channelInfo.observers.delete(client);
966
1062
  }
967
1063
  client.channels.delete(channelId);
968
1064
  this.send(client.ws, { type: "leave_ack", ok: true, channel: channelId });
@@ -970,6 +1066,10 @@ class OnlineServer extends EventEmitter {
970
1066
 
971
1067
  handleEvent(client, message) {
972
1068
  if (!this.requireAuth(client)) return;
1069
+ if (client.role === "observer") {
1070
+ this.sendError(client.ws, "Observers are read-only", false, "EVENT_OBSERVER_READONLY");
1071
+ return;
1072
+ }
973
1073
  if (!client.subscriberId) {
974
1074
  this.sendError(client.ws, "Unknown subscriber", false, "SUBSCRIBER_UNKNOWN");
975
1075
  return;
@@ -1026,14 +1126,19 @@ class OnlineServer extends EventEmitter {
1026
1126
  this.sendError(client.ws, "Room not found", false, "ROOM_NOT_FOUND");
1027
1127
  return;
1028
1128
  }
1029
- room.members.forEach((member) => {
1030
- if (member !== client) {
1031
- this.send(member.ws, payload);
1129
+ const broadcastRoom = (recipient) => {
1130
+ if (recipient !== client) {
1131
+ this.send(recipient.ws, payload);
1032
1132
  if (payload.payload && payload.payload.kind === "wake") {
1033
- this.send(member.ws, { type: "wake", from: client.subscriberId });
1133
+ this.send(recipient.ws, { type: "wake", from: client.subscriberId });
1034
1134
  }
1035
1135
  }
1036
- });
1136
+ };
1137
+ room.members.forEach(broadcastRoom);
1138
+ if (room.observers) room.observers.forEach(broadcastRoom);
1139
+ if (kind === "message") {
1140
+ this.recordRoomMessage(payload.room, room, client, payload);
1141
+ }
1037
1142
  return;
1038
1143
  }
1039
1144
 
@@ -1050,20 +1155,20 @@ class OnlineServer extends EventEmitter {
1050
1155
  this.sendError(client.ws, "Join channel first", false, "NOT_IN_CHANNEL");
1051
1156
  return;
1052
1157
  }
1053
- const members = channel ? channel.members : null;
1054
- if (!members || members.size === 0) return;
1055
1158
  payload.channel = channelId;
1056
1159
  if (kind === "message") {
1057
1160
  this.recordChannelMessage(channelId, channel, client, payload);
1058
1161
  }
1059
- members.forEach((member) => {
1060
- if (member !== client) {
1061
- this.send(member.ws, payload);
1162
+ const broadcastChannel = (recipient) => {
1163
+ if (recipient !== client) {
1164
+ this.send(recipient.ws, payload);
1062
1165
  if (payload.payload && payload.payload.kind === "wake") {
1063
- this.send(member.ws, { type: "wake", from: client.subscriberId });
1166
+ this.send(recipient.ws, { type: "wake", from: client.subscriberId });
1064
1167
  }
1065
1168
  }
1066
- });
1169
+ };
1170
+ if (channel.members) channel.members.forEach(broadcastChannel);
1171
+ if (channel.observers) channel.observers.forEach(broadcastChannel);
1067
1172
  return;
1068
1173
  }
1069
1174
 
@@ -1113,7 +1218,12 @@ class OnlineServer extends EventEmitter {
1113
1218
  return;
1114
1219
  }
1115
1220
 
1116
- room.members.add(client);
1221
+ if (client.role === "observer") {
1222
+ if (!room.observers) room.observers = new Set();
1223
+ room.observers.add(client);
1224
+ } else {
1225
+ room.members.add(client);
1226
+ }
1117
1227
  client.rooms.add(roomId);
1118
1228
  this.send(client.ws, { type: "join_ack", ok: true, room: roomId });
1119
1229
  }
@@ -1127,6 +1237,7 @@ class OnlineServer extends EventEmitter {
1127
1237
  const room = this.rooms.get(roomId);
1128
1238
  if (room) {
1129
1239
  room.members.delete(client);
1240
+ if (room.observers) room.observers.delete(client);
1130
1241
  }
1131
1242
  client.rooms.delete(roomId);
1132
1243
  this.send(client.ws, { type: "leave_ack", ok: true, room: roomId });
@@ -1144,6 +1255,7 @@ class OnlineServer extends EventEmitter {
1144
1255
  const channelInfo = this.channels.get(channel);
1145
1256
  if (channelInfo) {
1146
1257
  channelInfo.members.delete(client);
1258
+ if (channelInfo.observers) channelInfo.observers.delete(client);
1147
1259
  }
1148
1260
  });
1149
1261
  client.channels.clear();
@@ -1153,6 +1265,7 @@ class OnlineServer extends EventEmitter {
1153
1265
  const room = this.rooms.get(roomId);
1154
1266
  if (room) {
1155
1267
  room.members.delete(client);
1268
+ if (room.observers) room.observers.delete(client);
1156
1269
  }
1157
1270
  });
1158
1271
  client.rooms.clear();