u-foo 1.3.0 → 1.4.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": "u-foo",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
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",
@@ -201,6 +201,8 @@ function runOnlineInbox(nickname, opts = {}) {
201
201
  checkInbox(nickname, {
202
202
  clear: !!opts.clear,
203
203
  unread: !!opts.unread,
204
+ room: opts.room || "",
205
+ channel: opts.channel || "",
204
206
  });
205
207
  }
206
208
 
@@ -280,6 +282,8 @@ async function runOnlineCommand(subcmd, payload = {}, options = {}) {
280
282
  return runOnlineInbox(payload.nickname, {
281
283
  clear: opts.clear,
282
284
  unread: opts.unread,
285
+ room: opts.room || "",
286
+ channel: opts.channel || "",
283
287
  });
284
288
  default:
285
289
  throw createUnknownOnlineError(subcmd);
@@ -374,6 +378,8 @@ async function runOnlineCommand(subcmd, payload = {}, options = {}) {
374
378
  return runOnlineInbox(argv[1], {
375
379
  clear: hasFallbackFlag(argv, "--clear"),
376
380
  unread: hasFallbackFlag(argv, "--unread"),
381
+ room: getFallbackOpt(argv, "--room"),
382
+ channel: getFallbackOpt(argv, "--channel"),
377
383
  });
378
384
  }
379
385
  default:
@@ -135,6 +135,15 @@ function normalizeOnlineSender(from = "") {
135
135
  return text;
136
136
  }
137
137
 
138
+ function inboxFileName(nickname, route) {
139
+ // Namespace inbox by room/channel to prevent cross-room message leakage
140
+ if (route) {
141
+ const safe = String(route).replace(/[^a-zA-Z0-9_-]/g, "_");
142
+ return `${nickname}__${safe}.jsonl`;
143
+ }
144
+ return `${nickname}.jsonl`;
145
+ }
146
+
138
147
  function appendToInbox(nickname, msg) {
139
148
  const dir = inboxDir();
140
149
  fs.mkdirSync(dir, { recursive: true });
@@ -143,7 +152,8 @@ function appendToInbox(nickname, msg) {
143
152
  _source: messageSource(msg),
144
153
  _receivedAt: new Date().toISOString(),
145
154
  };
146
- fs.appendFileSync(path.join(dir, `${nickname}.jsonl`), JSON.stringify(entry) + "\n");
155
+ const route = msg.room || msg.channel || "";
156
+ fs.appendFileSync(path.join(dir, inboxFileName(nickname, route)), JSON.stringify(entry) + "\n");
147
157
  }
148
158
 
149
159
  // --- Helpers ---
@@ -14,6 +14,28 @@ function inboxFilePath(nickname) {
14
14
  return path.join(inboxDir(), `${nickname}.jsonl`);
15
15
  }
16
16
 
17
+ /**
18
+ * Find all inbox files for a given nickname (including room/channel-namespaced ones).
19
+ * Returns array of { file, route } objects.
20
+ */
21
+ function findInboxFiles(nickname) {
22
+ const dir = inboxDir();
23
+ if (!fs.existsSync(dir)) return [];
24
+ const prefix = `${nickname}__`;
25
+ const exact = `${nickname}.jsonl`;
26
+ const results = [];
27
+ for (const name of fs.readdirSync(dir)) {
28
+ if (!name.endsWith(".jsonl")) continue;
29
+ if (name === exact) {
30
+ results.push({ file: path.join(dir, name), route: "" });
31
+ } else if (name.startsWith(prefix)) {
32
+ const route = name.slice(prefix.length, -".jsonl".length);
33
+ results.push({ file: path.join(dir, name), route });
34
+ }
35
+ }
36
+ return results;
37
+ }
38
+
17
39
  function readMarkerPath(nickname) {
18
40
  return path.join(inboxDir(), `${nickname}.read`);
19
41
  }
@@ -80,6 +102,8 @@ function cleanupInbox(file) {
80
102
  function checkInbox(nickname, options = {}) {
81
103
  const clear = options.clear || false;
82
104
  const unreadOnly = options.unread || false;
105
+ const filterRoom = options.room || "";
106
+ const filterChannel = options.channel || "";
83
107
 
84
108
  if (!nickname) {
85
109
  console.error("nickname is required");
@@ -87,23 +111,24 @@ function checkInbox(nickname, options = {}) {
87
111
  return;
88
112
  }
89
113
 
90
- const file = inboxFilePath(nickname);
91
114
  const markerFile = readMarkerPath(nickname);
92
115
 
116
+ // Gather all inbox files for this nickname
117
+ const inboxFiles = findInboxFiles(nickname);
118
+
93
119
  if (clear) {
94
- if (fs.existsSync(file)) {
95
- fs.unlinkSync(file);
96
- console.log(`Inbox cleared for ${nickname}.`);
97
- } else {
98
- console.log(`No inbox file for ${nickname}.`);
120
+ let cleared = 0;
121
+ for (const { file } of inboxFiles) {
122
+ if (fs.existsSync(file)) { fs.unlinkSync(file); cleared++; }
99
123
  }
124
+ console.log(cleared > 0 ? `Inbox cleared for ${nickname} (${cleared} file(s)).` : `No inbox file for ${nickname}.`);
100
125
  return;
101
126
  }
102
127
 
103
- cleanupInbox(file);
104
-
105
128
  let messages = [];
106
- if (fs.existsSync(file)) {
129
+ for (const { file } of inboxFiles) {
130
+ cleanupInbox(file);
131
+ if (!fs.existsSync(file)) continue;
107
132
  const lines = fs.readFileSync(file, "utf-8").split("\n").filter(Boolean);
108
133
  for (const line of lines) {
109
134
  try {
@@ -114,6 +139,13 @@ function checkInbox(nickname, options = {}) {
114
139
  }
115
140
  }
116
141
 
142
+ // Filter by room/channel if specified
143
+ if (filterRoom) {
144
+ messages = messages.filter((m) => m.room === filterRoom);
145
+ } else if (filterChannel) {
146
+ messages = messages.filter((m) => m.channel === filterChannel);
147
+ }
148
+
117
149
  function displayWidth(str) {
118
150
  let w = 0;
119
151
  for (const ch of str) {
@@ -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,20 @@ 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
+
188
+ // DELETE /ufoo/online/rooms/:id
189
+ const roomDeleteMatch = pathname.match(/^\/ufoo\/online\/rooms\/([^/]+)$/);
190
+ if (roomDeleteMatch && req.method === "DELETE") {
191
+ if (!this.authenticateHttp(req, res)) return;
192
+ this.handleRoomDelete(req, res, decodeURIComponent(roomDeleteMatch[1]));
193
+ return;
194
+ }
195
+
180
196
  if (pathname.startsWith("/ufoo/online/rooms")) {
181
197
  // Step 4: HTTP auth
182
198
  if (!this.authenticateHttp(req, res)) return;
@@ -431,6 +447,39 @@ class OnlineServer extends EventEmitter {
431
447
  });
432
448
  }
433
449
 
450
+ handleRoomMessagesRequest(req, res, roomId, parsedUrl) {
451
+ if (req.method !== "GET") {
452
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
453
+ return;
454
+ }
455
+ const room = this.rooms.get(roomId);
456
+ if (!room) {
457
+ this.sendJson(res, 404, { ok: false, error: "Room not found" });
458
+ return;
459
+ }
460
+ // Private rooms require password via query param
461
+ if (room.type === "private") {
462
+ const password = String(parsedUrl.searchParams.get("password") || "");
463
+ const stored = this.roomPasswords.get(roomId);
464
+ if (!stored || !this.verifyPassword(password, stored)) {
465
+ this.sendJson(res, 403, { ok: false, error: "Invalid room password" });
466
+ return;
467
+ }
468
+ }
469
+ const limitRaw = Number.parseInt(String(parsedUrl.searchParams.get("limit") || "80"), 10);
470
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 80;
471
+ const roomData = this.listRoomMessages(roomId, limit);
472
+ if (!roomData) {
473
+ this.sendJson(res, 404, { ok: false, error: "Room not found" });
474
+ return;
475
+ }
476
+ this.sendJson(res, 200, {
477
+ ok: true,
478
+ room: { room_id: roomData.room_id, name: roomData.name, type: roomData.type },
479
+ messages: roomData.messages,
480
+ });
481
+ }
482
+
434
483
  recordChannelMessage(channelId, channel, client, eventPayload) {
435
484
  const rawText = eventPayload?.payload?.message;
436
485
  let text = "";
@@ -459,6 +508,45 @@ class OnlineServer extends EventEmitter {
459
508
  }
460
509
  }
461
510
 
511
+ recordRoomMessage(roomId, room, client, eventPayload) {
512
+ const rawText = eventPayload?.payload?.message;
513
+ let text = "";
514
+ if (typeof rawText === "string") text = rawText;
515
+ else if (rawText !== null && rawText !== undefined) text = JSON.stringify(rawText);
516
+ if (!text) return;
517
+
518
+ const history = this.roomMessageHistory.get(roomId) || [];
519
+ const ts = eventPayload.ts || new Date().toISOString();
520
+ const entry = {
521
+ event_id: `event_${String(++this.eventSeq).padStart(8, "0")}`,
522
+ ts,
523
+ room_id: roomId,
524
+ room_name: room?.name || roomId,
525
+ from: client.subscriberId || "",
526
+ nickname: client.nickname || "",
527
+ text,
528
+ };
529
+ history.push(entry);
530
+ if (history.length > this.channelHistoryLimit) {
531
+ history.splice(0, history.length - this.channelHistoryLimit);
532
+ }
533
+ this.roomMessageHistory.set(roomId, history);
534
+ }
535
+
536
+ listRoomMessages(roomId, limit = 80) {
537
+ const room = this.rooms.get(roomId);
538
+ if (!room) return null;
539
+ const history = this.roomMessageHistory.get(roomId) || [];
540
+ const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 80;
541
+ const start = Math.max(0, history.length - safeLimit);
542
+ return {
543
+ room_id: roomId,
544
+ name: room.name || "",
545
+ type: room.type,
546
+ messages: history.slice(start),
547
+ };
548
+ }
549
+
462
550
  handleRoomsRequest(req, res) {
463
551
  if (req.method === "GET") {
464
552
  this.sendJson(res, 200, { ok: true, rooms: this.listRooms() });
@@ -518,6 +606,7 @@ class OnlineServer extends EventEmitter {
518
606
  name,
519
607
  type,
520
608
  members: new Set(),
609
+ observers: new Set(),
521
610
  created_at: new Date().toISOString(),
522
611
  created_by: createdBy,
523
612
  });
@@ -536,6 +625,25 @@ class OnlineServer extends EventEmitter {
536
625
  this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
537
626
  }
538
627
 
628
+ handleRoomDelete(req, res, roomId) {
629
+ const room = this.rooms.get(roomId);
630
+ if (!room) {
631
+ this.sendJson(res, 404, { ok: false, error: "Room not found" });
632
+ return;
633
+ }
634
+ // Disconnect all members and observers
635
+ const kick = (client) => {
636
+ client.rooms.delete(roomId);
637
+ this.sendError(client.ws, "Room deleted", true, "ROOM_DELETED");
638
+ };
639
+ room.members.forEach(kick);
640
+ if (room.observers) room.observers.forEach(kick);
641
+ this.rooms.delete(roomId);
642
+ this.roomPasswords.delete(roomId);
643
+ this.roomMessageHistory.delete(roomId);
644
+ this.sendJson(res, 200, { ok: true, deleted: roomId });
645
+ }
646
+
539
647
  handleChannelsRequest(req, res) {
540
648
  if (req.method === "GET") {
541
649
  this.sendJson(res, 200, { ok: true, channels: this.listChannels() });
@@ -593,6 +701,7 @@ class OnlineServer extends EventEmitter {
593
701
  name,
594
702
  type,
595
703
  members: new Set(),
704
+ observers: new Set(),
596
705
  created_at: new Date().toISOString(),
597
706
  created_by: createdBy,
598
707
  });
@@ -650,6 +759,7 @@ class OnlineServer extends EventEmitter {
650
759
  authed: false,
651
760
  subscriberId: null,
652
761
  nickname: null,
762
+ role: "agent", // "agent" or "observer"
653
763
  channels: new Set(),
654
764
  helloReceived: false,
655
765
  connectedAt: Date.now(),
@@ -796,6 +906,12 @@ class OnlineServer extends EventEmitter {
796
906
  client.pendingWorld = world;
797
907
  client.rooms = new Set();
798
908
 
909
+ // Observer role: set from capabilities
910
+ const caps = Array.isArray(info.capabilities) ? info.capabilities : [];
911
+ if (caps.includes("observer")) {
912
+ client.role = "observer";
913
+ }
914
+
799
915
  this.send(client.ws, {
800
916
  type: "hello_ack",
801
917
  ok: true,
@@ -902,6 +1018,7 @@ class OnlineServer extends EventEmitter {
902
1018
  name: channelRef,
903
1019
  type: "public",
904
1020
  members: new Set(),
1021
+ observers: new Set(),
905
1022
  created_at: new Date().toISOString(),
906
1023
  created_by: "",
907
1024
  };
@@ -938,7 +1055,12 @@ class OnlineServer extends EventEmitter {
938
1055
  const channelId = resolved.channelId;
939
1056
  const channelInfo = resolved.channel;
940
1057
 
941
- channelInfo.members.add(client);
1058
+ if (client.role === "observer") {
1059
+ if (!channelInfo.observers) channelInfo.observers = new Set();
1060
+ channelInfo.observers.add(client);
1061
+ } else {
1062
+ channelInfo.members.add(client);
1063
+ }
942
1064
  client.channels.add(channelId);
943
1065
  this.send(client.ws, { type: "join_ack", ok: true, channel: channelId });
944
1066
  }
@@ -963,6 +1085,7 @@ class OnlineServer extends EventEmitter {
963
1085
  const channelInfo = resolved?.channel || null;
964
1086
  if (channelInfo) {
965
1087
  channelInfo.members.delete(client);
1088
+ if (channelInfo.observers) channelInfo.observers.delete(client);
966
1089
  }
967
1090
  client.channels.delete(channelId);
968
1091
  this.send(client.ws, { type: "leave_ack", ok: true, channel: channelId });
@@ -970,6 +1093,10 @@ class OnlineServer extends EventEmitter {
970
1093
 
971
1094
  handleEvent(client, message) {
972
1095
  if (!this.requireAuth(client)) return;
1096
+ if (client.role === "observer") {
1097
+ this.sendError(client.ws, "Observers are read-only", false, "EVENT_OBSERVER_READONLY");
1098
+ return;
1099
+ }
973
1100
  if (!client.subscriberId) {
974
1101
  this.sendError(client.ws, "Unknown subscriber", false, "SUBSCRIBER_UNKNOWN");
975
1102
  return;
@@ -1026,14 +1153,19 @@ class OnlineServer extends EventEmitter {
1026
1153
  this.sendError(client.ws, "Room not found", false, "ROOM_NOT_FOUND");
1027
1154
  return;
1028
1155
  }
1029
- room.members.forEach((member) => {
1030
- if (member !== client) {
1031
- this.send(member.ws, payload);
1156
+ const broadcastRoom = (recipient) => {
1157
+ if (recipient !== client) {
1158
+ this.send(recipient.ws, payload);
1032
1159
  if (payload.payload && payload.payload.kind === "wake") {
1033
- this.send(member.ws, { type: "wake", from: client.subscriberId });
1160
+ this.send(recipient.ws, { type: "wake", from: client.subscriberId });
1034
1161
  }
1035
1162
  }
1036
- });
1163
+ };
1164
+ room.members.forEach(broadcastRoom);
1165
+ if (room.observers) room.observers.forEach(broadcastRoom);
1166
+ if (kind === "message") {
1167
+ this.recordRoomMessage(payload.room, room, client, payload);
1168
+ }
1037
1169
  return;
1038
1170
  }
1039
1171
 
@@ -1050,20 +1182,20 @@ class OnlineServer extends EventEmitter {
1050
1182
  this.sendError(client.ws, "Join channel first", false, "NOT_IN_CHANNEL");
1051
1183
  return;
1052
1184
  }
1053
- const members = channel ? channel.members : null;
1054
- if (!members || members.size === 0) return;
1055
1185
  payload.channel = channelId;
1056
1186
  if (kind === "message") {
1057
1187
  this.recordChannelMessage(channelId, channel, client, payload);
1058
1188
  }
1059
- members.forEach((member) => {
1060
- if (member !== client) {
1061
- this.send(member.ws, payload);
1189
+ const broadcastChannel = (recipient) => {
1190
+ if (recipient !== client) {
1191
+ this.send(recipient.ws, payload);
1062
1192
  if (payload.payload && payload.payload.kind === "wake") {
1063
- this.send(member.ws, { type: "wake", from: client.subscriberId });
1193
+ this.send(recipient.ws, { type: "wake", from: client.subscriberId });
1064
1194
  }
1065
1195
  }
1066
- });
1196
+ };
1197
+ if (channel.members) channel.members.forEach(broadcastChannel);
1198
+ if (channel.observers) channel.observers.forEach(broadcastChannel);
1067
1199
  return;
1068
1200
  }
1069
1201
 
@@ -1113,7 +1245,12 @@ class OnlineServer extends EventEmitter {
1113
1245
  return;
1114
1246
  }
1115
1247
 
1116
- room.members.add(client);
1248
+ if (client.role === "observer") {
1249
+ if (!room.observers) room.observers = new Set();
1250
+ room.observers.add(client);
1251
+ } else {
1252
+ room.members.add(client);
1253
+ }
1117
1254
  client.rooms.add(roomId);
1118
1255
  this.send(client.ws, { type: "join_ack", ok: true, room: roomId });
1119
1256
  }
@@ -1127,6 +1264,7 @@ class OnlineServer extends EventEmitter {
1127
1264
  const room = this.rooms.get(roomId);
1128
1265
  if (room) {
1129
1266
  room.members.delete(client);
1267
+ if (room.observers) room.observers.delete(client);
1130
1268
  }
1131
1269
  client.rooms.delete(roomId);
1132
1270
  this.send(client.ws, { type: "leave_ack", ok: true, room: roomId });
@@ -1144,6 +1282,7 @@ class OnlineServer extends EventEmitter {
1144
1282
  const channelInfo = this.channels.get(channel);
1145
1283
  if (channelInfo) {
1146
1284
  channelInfo.members.delete(client);
1285
+ if (channelInfo.observers) channelInfo.observers.delete(client);
1147
1286
  }
1148
1287
  });
1149
1288
  client.channels.clear();
@@ -1153,6 +1292,7 @@ class OnlineServer extends EventEmitter {
1153
1292
  const room = this.rooms.get(roomId);
1154
1293
  if (room) {
1155
1294
  room.members.delete(client);
1295
+ if (room.observers) room.observers.delete(client);
1156
1296
  }
1157
1297
  });
1158
1298
  client.rooms.clear();