u-foo 1.2.16 → 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.
- package/modules/online/README.md +18 -0
- package/package.json +2 -1
- package/src/agent/cliRunner.js +1 -1
- package/src/agent/launcher.js +23 -4
- package/src/agent/ptyRunner.js +39 -16
- package/src/agent/ufooAgent.js +2 -1
- package/src/assistant/agent.js +2 -1
- package/src/assistant/bridge.js +9 -3
- package/src/assistant/constants.js +15 -0
- package/src/assistant/engine.js +7 -2
- package/src/assistant/ufooEngineCli.js +9 -3
- package/src/chat/commandExecutor.js +188 -13
- package/src/chat/commands.js +11 -0
- package/src/chat/daemonMessageRouter.js +107 -0
- package/src/cli/groupCoreCommands.js +246 -0
- package/src/cli/onlineCoreCommands.js +8 -0
- package/src/cli.js +325 -2
- package/src/daemon/groupOrchestrator.js +557 -0
- package/src/daemon/index.js +319 -1
- package/src/daemon/status.js +48 -0
- package/src/group/diagram.js +222 -0
- package/src/group/templates.js +280 -0
- package/src/group/validateTemplate.js +234 -0
- package/src/online/server.js +320 -28
- package/src/shared/eventContract.js +5 -0
- package/src/ufoo/paths.js +2 -0
- package/templates/groups/dev-basic.json +78 -0
- package/templates/groups/research-quick.json +49 -0
package/src/online/server.js
CHANGED
|
@@ -45,6 +45,10 @@ class OnlineServer extends EventEmitter {
|
|
|
45
45
|
|
|
46
46
|
this.rooms = new Map();
|
|
47
47
|
this.roomPasswords = new Map();
|
|
48
|
+
this.channelMessageHistory = new Map();
|
|
49
|
+
this.roomMessageHistory = new Map();
|
|
50
|
+
this.channelHistoryLimit = options.channelHistoryLimit ?? 200;
|
|
51
|
+
this.eventSeq = 0;
|
|
48
52
|
|
|
49
53
|
// Step 2 + 3: Payload limits
|
|
50
54
|
this.maxHttpBodyBytes = options.maxHttpBodyBytes ?? 65536; // 64 KB
|
|
@@ -79,6 +83,23 @@ class OnlineServer extends EventEmitter {
|
|
|
79
83
|
this.tlsKey = options.tlsKey || null;
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
parseRequestUrl(rawUrl) {
|
|
87
|
+
try {
|
|
88
|
+
return new URL(rawUrl || "/", "http://localhost");
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
corsHeaders(extra = {}) {
|
|
95
|
+
return {
|
|
96
|
+
"Access-Control-Allow-Origin": "*",
|
|
97
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
98
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
99
|
+
...extra,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
loadTokens(options) {
|
|
83
104
|
if (options.tokens) {
|
|
84
105
|
return new Set(Array.isArray(options.tokens) ? options.tokens : Object.keys(options.tokens));
|
|
@@ -117,27 +138,68 @@ class OnlineServer extends EventEmitter {
|
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
const requestHandler = (req, res) => {
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
const parsedUrl = this.parseRequestUrl(req.url);
|
|
142
|
+
if (!parsedUrl) {
|
|
143
|
+
res.writeHead(404, this.corsHeaders());
|
|
144
|
+
res.end();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pathname = parsedUrl.pathname || "/";
|
|
149
|
+
const publicMessagesMatch = pathname.match(/^\/ufoo\/online\/public\/channels\/([^/]+)\/messages$/);
|
|
150
|
+
const privateMessagesMatch = pathname.match(/^\/ufoo\/online\/channels\/([^/]+)\/messages$/);
|
|
151
|
+
const roomMessagesMatch = pathname.match(/^\/ufoo\/online\/rooms\/([^/]+)\/messages$/);
|
|
152
|
+
|
|
153
|
+
if (req.method === "OPTIONS") {
|
|
154
|
+
res.writeHead(204, this.corsHeaders());
|
|
122
155
|
res.end();
|
|
123
156
|
return;
|
|
124
157
|
}
|
|
125
158
|
|
|
126
|
-
if (
|
|
159
|
+
if (publicMessagesMatch) {
|
|
160
|
+
const channelRef = decodeURIComponent(publicMessagesMatch[1] || "");
|
|
161
|
+
this.handleChannelMessagesRequest(req, res, channelRef, parsedUrl);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (privateMessagesMatch) {
|
|
166
|
+
const channelRef = decodeURIComponent(privateMessagesMatch[1] || "");
|
|
167
|
+
if (!this.authenticateHttp(req, res)) return;
|
|
168
|
+
this.handleChannelMessagesRequest(req, res, channelRef, parsedUrl);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (pathname === "/ufoo/online/public/channels") {
|
|
173
|
+
this.handlePublicChannelsRequest(req, res, parsedUrl);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (pathname === "/ufoo/online/public/rooms") {
|
|
178
|
+
this.handlePublicRoomsRequest(req, res, parsedUrl);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (roomMessagesMatch) {
|
|
183
|
+
const roomId = decodeURIComponent(roomMessagesMatch[1] || "");
|
|
184
|
+
this.handleRoomMessagesRequest(req, res, roomId, parsedUrl);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (pathname.startsWith("/ufoo/online/rooms")) {
|
|
127
189
|
// Step 4: HTTP auth
|
|
128
190
|
if (!this.authenticateHttp(req, res)) return;
|
|
129
191
|
this.handleRoomsRequest(req, res);
|
|
130
192
|
return;
|
|
131
193
|
}
|
|
132
194
|
|
|
133
|
-
if (
|
|
195
|
+
if (pathname.startsWith("/ufoo/online/channels")) {
|
|
134
196
|
// Step 4: HTTP auth
|
|
135
197
|
if (!this.authenticateHttp(req, res)) return;
|
|
136
198
|
this.handleChannelsRequest(req, res);
|
|
137
199
|
return;
|
|
138
200
|
}
|
|
139
201
|
|
|
140
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
202
|
+
res.writeHead(200, this.corsHeaders({ "Content-Type": "text/plain" }));
|
|
141
203
|
res.end("ufoo-online: running\n");
|
|
142
204
|
};
|
|
143
205
|
|
|
@@ -258,7 +320,7 @@ class OnlineServer extends EventEmitter {
|
|
|
258
320
|
}
|
|
259
321
|
|
|
260
322
|
sendJson(res, statusCode, payload) {
|
|
261
|
-
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
323
|
+
res.writeHead(statusCode, this.corsHeaders({ "Content-Type": "application/json" }));
|
|
262
324
|
res.end(JSON.stringify(payload));
|
|
263
325
|
}
|
|
264
326
|
|
|
@@ -286,17 +348,195 @@ class OnlineServer extends EventEmitter {
|
|
|
286
348
|
type: room.type,
|
|
287
349
|
members: room.members.size,
|
|
288
350
|
created_at: room.created_at,
|
|
351
|
+
created_by: room.created_by || "",
|
|
352
|
+
password_required: room.type === "private",
|
|
289
353
|
}));
|
|
290
354
|
}
|
|
291
355
|
|
|
292
356
|
listChannels() {
|
|
293
|
-
return Array.from(this.channels.entries()).map(([channelId, channel]) =>
|
|
357
|
+
return Array.from(this.channels.entries()).map(([channelId, channel]) => {
|
|
358
|
+
const history = this.channelMessageHistory.get(channelId) || [];
|
|
359
|
+
const last = history.length > 0 ? history[history.length - 1] : null;
|
|
360
|
+
return {
|
|
361
|
+
channel_id: channelId,
|
|
362
|
+
name: channel.name || "",
|
|
363
|
+
type: channel.type || "public",
|
|
364
|
+
members: channel.members.size,
|
|
365
|
+
created_at: channel.created_at,
|
|
366
|
+
created_by: channel.created_by || "",
|
|
367
|
+
message_count: history.length,
|
|
368
|
+
last_message_at: channel.last_message_at || (last ? last.ts : null),
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
listPublicRooms(type = "") {
|
|
374
|
+
return this.listRooms()
|
|
375
|
+
.filter((room) => !type || room.type === type)
|
|
376
|
+
.map((room) => ({
|
|
377
|
+
room_id: room.room_id,
|
|
378
|
+
name: room.name || "",
|
|
379
|
+
type: room.type,
|
|
380
|
+
created_at: room.created_at,
|
|
381
|
+
created_by: room.created_by || "",
|
|
382
|
+
password_required: room.password_required !== false,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
listChannelMessages(channelRef, limit = 80) {
|
|
387
|
+
const resolved = this.resolveChannel(channelRef);
|
|
388
|
+
if (!resolved) return null;
|
|
389
|
+
const history = this.channelMessageHistory.get(resolved.channelId) || [];
|
|
390
|
+
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 80;
|
|
391
|
+
const start = Math.max(0, history.length - safeLimit);
|
|
392
|
+
return {
|
|
393
|
+
channel_id: resolved.channelId,
|
|
394
|
+
name: resolved.channel.name || "",
|
|
395
|
+
type: resolved.channel.type || "public",
|
|
396
|
+
messages: history.slice(start),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
handlePublicRoomsRequest(req, res, parsedUrl) {
|
|
401
|
+
if (req.method !== "GET") {
|
|
402
|
+
this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const type = String(parsedUrl.searchParams.get("type") || "").trim();
|
|
406
|
+
this.sendJson(res, 200, { ok: true, rooms: this.listPublicRooms(type) });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
handlePublicChannelsRequest(req, res, parsedUrl) {
|
|
410
|
+
if (req.method !== "GET") {
|
|
411
|
+
this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const type = String(parsedUrl.searchParams.get("type") || "").trim();
|
|
415
|
+
const channels = this.listChannels().filter((channel) => !type || channel.type === type);
|
|
416
|
+
this.sendJson(res, 200, { ok: true, channels });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
handleChannelMessagesRequest(req, res, channelRef, parsedUrl) {
|
|
420
|
+
if (req.method !== "GET") {
|
|
421
|
+
this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const limitRaw = Number.parseInt(String(parsedUrl.searchParams.get("limit") || "80"), 10);
|
|
425
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 80;
|
|
426
|
+
const channelData = this.listChannelMessages(channelRef, limit);
|
|
427
|
+
if (!channelData) {
|
|
428
|
+
this.sendJson(res, 404, { ok: false, error: "Channel not found" });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
this.sendJson(res, 200, {
|
|
432
|
+
ok: true,
|
|
433
|
+
channel: {
|
|
434
|
+
channel_id: channelData.channel_id,
|
|
435
|
+
name: channelData.name,
|
|
436
|
+
type: channelData.type,
|
|
437
|
+
},
|
|
438
|
+
messages: channelData.messages,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
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
|
+
|
|
475
|
+
recordChannelMessage(channelId, channel, client, eventPayload) {
|
|
476
|
+
const rawText = eventPayload?.payload?.message;
|
|
477
|
+
let text = "";
|
|
478
|
+
if (typeof rawText === "string") text = rawText;
|
|
479
|
+
else if (rawText !== null && rawText !== undefined) text = JSON.stringify(rawText);
|
|
480
|
+
if (!text) return;
|
|
481
|
+
|
|
482
|
+
const history = this.channelMessageHistory.get(channelId) || [];
|
|
483
|
+
const ts = eventPayload.ts || new Date().toISOString();
|
|
484
|
+
const entry = {
|
|
485
|
+
event_id: `event_${String(++this.eventSeq).padStart(8, "0")}`,
|
|
486
|
+
ts,
|
|
294
487
|
channel_id: channelId,
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
488
|
+
channel_name: channel?.name || channelId,
|
|
489
|
+
from: client.subscriberId || "",
|
|
490
|
+
nickname: client.nickname || "",
|
|
491
|
+
text,
|
|
492
|
+
};
|
|
493
|
+
history.push(entry);
|
|
494
|
+
if (history.length > this.channelHistoryLimit) {
|
|
495
|
+
history.splice(0, history.length - this.channelHistoryLimit);
|
|
496
|
+
}
|
|
497
|
+
this.channelMessageHistory.set(channelId, history);
|
|
498
|
+
if (channel) {
|
|
499
|
+
channel.last_message_at = ts;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
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
|
+
};
|
|
300
540
|
}
|
|
301
541
|
|
|
302
542
|
handleRoomsRequest(req, res) {
|
|
@@ -320,6 +560,7 @@ class OnlineServer extends EventEmitter {
|
|
|
320
560
|
}
|
|
321
561
|
const name = String(payload.name || "").trim();
|
|
322
562
|
const type = String(payload.type).trim();
|
|
563
|
+
const createdBy = String(payload.created_by || payload.creator || "").trim();
|
|
323
564
|
if (!["public", "private"].includes(type)) {
|
|
324
565
|
this.sendJson(res, 400, { ok: false, error: "Invalid room type" });
|
|
325
566
|
return;
|
|
@@ -328,6 +569,10 @@ class OnlineServer extends EventEmitter {
|
|
|
328
569
|
const nameErr = this.validateIdentifier(name, "name");
|
|
329
570
|
if (nameErr) { this.sendJson(res, 400, { ok: false, error: nameErr }); return; }
|
|
330
571
|
}
|
|
572
|
+
if (createdBy) {
|
|
573
|
+
const creatorErr = this.validateIdentifier(createdBy, "created_by");
|
|
574
|
+
if (creatorErr) { this.sendJson(res, 400, { ok: false, error: creatorErr }); return; }
|
|
575
|
+
}
|
|
331
576
|
if (this.rooms.size >= this.maxRooms) {
|
|
332
577
|
this.sendJson(res, 429, { ok: false, error: "Room limit reached" });
|
|
333
578
|
return;
|
|
@@ -353,9 +598,14 @@ class OnlineServer extends EventEmitter {
|
|
|
353
598
|
name,
|
|
354
599
|
type,
|
|
355
600
|
members: new Set(),
|
|
601
|
+
observers: new Set(),
|
|
356
602
|
created_at: new Date().toISOString(),
|
|
603
|
+
created_by: createdBy,
|
|
604
|
+
});
|
|
605
|
+
this.sendJson(res, 200, {
|
|
606
|
+
ok: true,
|
|
607
|
+
room: { room_id: roomId, name, type, created_by: createdBy, password_required: type === "private" },
|
|
357
608
|
});
|
|
358
|
-
this.sendJson(res, 200, { ok: true, room: { room_id: roomId, name, type } });
|
|
359
609
|
})
|
|
360
610
|
.catch(() => {
|
|
361
611
|
// Step 3: 413 on payload too large
|
|
@@ -388,12 +638,17 @@ class OnlineServer extends EventEmitter {
|
|
|
388
638
|
}
|
|
389
639
|
const name = String(payload.name || "").trim();
|
|
390
640
|
const type = String(payload.type || "public").trim();
|
|
641
|
+
const createdBy = String(payload.created_by || payload.creator || "").trim();
|
|
391
642
|
if (!name) {
|
|
392
643
|
this.sendJson(res, 400, { ok: false, error: "Invalid channel name" });
|
|
393
644
|
return;
|
|
394
645
|
}
|
|
395
646
|
const chNameErr = this.validateIdentifier(name, "name");
|
|
396
647
|
if (chNameErr) { this.sendJson(res, 400, { ok: false, error: chNameErr }); return; }
|
|
648
|
+
if (createdBy) {
|
|
649
|
+
const creatorErr = this.validateIdentifier(createdBy, "created_by");
|
|
650
|
+
if (creatorErr) { this.sendJson(res, 400, { ok: false, error: creatorErr }); return; }
|
|
651
|
+
}
|
|
397
652
|
if (!["world", "public"].includes(type)) {
|
|
398
653
|
this.sendJson(res, 400, { ok: false, error: "Invalid channel type" });
|
|
399
654
|
return;
|
|
@@ -419,10 +674,12 @@ class OnlineServer extends EventEmitter {
|
|
|
419
674
|
name,
|
|
420
675
|
type,
|
|
421
676
|
members: new Set(),
|
|
677
|
+
observers: new Set(),
|
|
422
678
|
created_at: new Date().toISOString(),
|
|
679
|
+
created_by: createdBy,
|
|
423
680
|
});
|
|
424
681
|
this.channelNames.set(name, channelId);
|
|
425
|
-
this.sendJson(res, 200, { ok: true, channel: { channel_id: channelId, name, type } });
|
|
682
|
+
this.sendJson(res, 200, { ok: true, channel: { channel_id: channelId, name, type, created_by: createdBy } });
|
|
426
683
|
})
|
|
427
684
|
.catch(() => {
|
|
428
685
|
// Step 3: 413 on payload too large
|
|
@@ -475,6 +732,7 @@ class OnlineServer extends EventEmitter {
|
|
|
475
732
|
authed: false,
|
|
476
733
|
subscriberId: null,
|
|
477
734
|
nickname: null,
|
|
735
|
+
role: "agent", // "agent" or "observer"
|
|
478
736
|
channels: new Set(),
|
|
479
737
|
helloReceived: false,
|
|
480
738
|
connectedAt: Date.now(),
|
|
@@ -621,6 +879,12 @@ class OnlineServer extends EventEmitter {
|
|
|
621
879
|
client.pendingWorld = world;
|
|
622
880
|
client.rooms = new Set();
|
|
623
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
|
+
|
|
624
888
|
this.send(client.ws, {
|
|
625
889
|
type: "hello_ack",
|
|
626
890
|
ok: true,
|
|
@@ -727,7 +991,9 @@ class OnlineServer extends EventEmitter {
|
|
|
727
991
|
name: channelRef,
|
|
728
992
|
type: "public",
|
|
729
993
|
members: new Set(),
|
|
994
|
+
observers: new Set(),
|
|
730
995
|
created_at: new Date().toISOString(),
|
|
996
|
+
created_by: "",
|
|
731
997
|
};
|
|
732
998
|
this.channels.set(channelRef, channel);
|
|
733
999
|
this.channelNames.set(channelRef, channelRef);
|
|
@@ -762,7 +1028,12 @@ class OnlineServer extends EventEmitter {
|
|
|
762
1028
|
const channelId = resolved.channelId;
|
|
763
1029
|
const channelInfo = resolved.channel;
|
|
764
1030
|
|
|
765
|
-
|
|
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
|
+
}
|
|
766
1037
|
client.channels.add(channelId);
|
|
767
1038
|
this.send(client.ws, { type: "join_ack", ok: true, channel: channelId });
|
|
768
1039
|
}
|
|
@@ -787,6 +1058,7 @@ class OnlineServer extends EventEmitter {
|
|
|
787
1058
|
const channelInfo = resolved?.channel || null;
|
|
788
1059
|
if (channelInfo) {
|
|
789
1060
|
channelInfo.members.delete(client);
|
|
1061
|
+
if (channelInfo.observers) channelInfo.observers.delete(client);
|
|
790
1062
|
}
|
|
791
1063
|
client.channels.delete(channelId);
|
|
792
1064
|
this.send(client.ws, { type: "leave_ack", ok: true, channel: channelId });
|
|
@@ -794,6 +1066,10 @@ class OnlineServer extends EventEmitter {
|
|
|
794
1066
|
|
|
795
1067
|
handleEvent(client, message) {
|
|
796
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
|
+
}
|
|
797
1073
|
if (!client.subscriberId) {
|
|
798
1074
|
this.sendError(client.ws, "Unknown subscriber", false, "SUBSCRIBER_UNKNOWN");
|
|
799
1075
|
return;
|
|
@@ -850,14 +1126,19 @@ class OnlineServer extends EventEmitter {
|
|
|
850
1126
|
this.sendError(client.ws, "Room not found", false, "ROOM_NOT_FOUND");
|
|
851
1127
|
return;
|
|
852
1128
|
}
|
|
853
|
-
|
|
854
|
-
if (
|
|
855
|
-
this.send(
|
|
1129
|
+
const broadcastRoom = (recipient) => {
|
|
1130
|
+
if (recipient !== client) {
|
|
1131
|
+
this.send(recipient.ws, payload);
|
|
856
1132
|
if (payload.payload && payload.payload.kind === "wake") {
|
|
857
|
-
this.send(
|
|
1133
|
+
this.send(recipient.ws, { type: "wake", from: client.subscriberId });
|
|
858
1134
|
}
|
|
859
1135
|
}
|
|
860
|
-
}
|
|
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
|
+
}
|
|
861
1142
|
return;
|
|
862
1143
|
}
|
|
863
1144
|
|
|
@@ -874,17 +1155,20 @@ class OnlineServer extends EventEmitter {
|
|
|
874
1155
|
this.sendError(client.ws, "Join channel first", false, "NOT_IN_CHANNEL");
|
|
875
1156
|
return;
|
|
876
1157
|
}
|
|
877
|
-
const members = channel ? channel.members : null;
|
|
878
|
-
if (!members || members.size === 0) return;
|
|
879
1158
|
payload.channel = channelId;
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1159
|
+
if (kind === "message") {
|
|
1160
|
+
this.recordChannelMessage(channelId, channel, client, payload);
|
|
1161
|
+
}
|
|
1162
|
+
const broadcastChannel = (recipient) => {
|
|
1163
|
+
if (recipient !== client) {
|
|
1164
|
+
this.send(recipient.ws, payload);
|
|
883
1165
|
if (payload.payload && payload.payload.kind === "wake") {
|
|
884
|
-
this.send(
|
|
1166
|
+
this.send(recipient.ws, { type: "wake", from: client.subscriberId });
|
|
885
1167
|
}
|
|
886
1168
|
}
|
|
887
|
-
}
|
|
1169
|
+
};
|
|
1170
|
+
if (channel.members) channel.members.forEach(broadcastChannel);
|
|
1171
|
+
if (channel.observers) channel.observers.forEach(broadcastChannel);
|
|
888
1172
|
return;
|
|
889
1173
|
}
|
|
890
1174
|
|
|
@@ -934,7 +1218,12 @@ class OnlineServer extends EventEmitter {
|
|
|
934
1218
|
return;
|
|
935
1219
|
}
|
|
936
1220
|
|
|
937
|
-
|
|
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
|
+
}
|
|
938
1227
|
client.rooms.add(roomId);
|
|
939
1228
|
this.send(client.ws, { type: "join_ack", ok: true, room: roomId });
|
|
940
1229
|
}
|
|
@@ -948,6 +1237,7 @@ class OnlineServer extends EventEmitter {
|
|
|
948
1237
|
const room = this.rooms.get(roomId);
|
|
949
1238
|
if (room) {
|
|
950
1239
|
room.members.delete(client);
|
|
1240
|
+
if (room.observers) room.observers.delete(client);
|
|
951
1241
|
}
|
|
952
1242
|
client.rooms.delete(roomId);
|
|
953
1243
|
this.send(client.ws, { type: "leave_ack", ok: true, room: roomId });
|
|
@@ -965,6 +1255,7 @@ class OnlineServer extends EventEmitter {
|
|
|
965
1255
|
const channelInfo = this.channels.get(channel);
|
|
966
1256
|
if (channelInfo) {
|
|
967
1257
|
channelInfo.members.delete(client);
|
|
1258
|
+
if (channelInfo.observers) channelInfo.observers.delete(client);
|
|
968
1259
|
}
|
|
969
1260
|
});
|
|
970
1261
|
client.channels.clear();
|
|
@@ -974,6 +1265,7 @@ class OnlineServer extends EventEmitter {
|
|
|
974
1265
|
const room = this.rooms.get(roomId);
|
|
975
1266
|
if (room) {
|
|
976
1267
|
room.members.delete(client);
|
|
1268
|
+
if (room.observers) room.observers.delete(client);
|
|
977
1269
|
}
|
|
978
1270
|
});
|
|
979
1271
|
client.rooms.clear();
|
|
@@ -7,8 +7,13 @@ const IPC_REQUEST_TYPES = {
|
|
|
7
7
|
BUS_SEND: "bus_send",
|
|
8
8
|
CLOSE_AGENT: "close_agent",
|
|
9
9
|
LAUNCH_AGENT: "launch_agent",
|
|
10
|
+
LAUNCH_GROUP: "launch_group",
|
|
10
11
|
RESUME_AGENTS: "resume_agents",
|
|
11
12
|
LIST_RECOVERABLE_AGENTS: "list_recoverable_agents",
|
|
13
|
+
STOP_GROUP: "stop_group",
|
|
14
|
+
GROUP_STATUS: "group_status",
|
|
15
|
+
GROUP_TEMPLATE_VALIDATE: "group_template_validate",
|
|
16
|
+
GROUP_DIAGRAM: "group_diagram",
|
|
12
17
|
REGISTER_AGENT: "register_agent",
|
|
13
18
|
AGENT_READY: "agent_ready",
|
|
14
19
|
AGENT_REPORT: "agent_report",
|
package/src/ufoo/paths.js
CHANGED
|
@@ -17,6 +17,7 @@ function getUfooPaths(projectRoot) {
|
|
|
17
17
|
const busDaemonCountsDir = path.join(busDaemonDir, "counts");
|
|
18
18
|
|
|
19
19
|
const runDir = path.join(ufooDir, "run");
|
|
20
|
+
const groupsDir = path.join(ufooDir, "groups");
|
|
20
21
|
const ufooDaemonPid = path.join(runDir, "ufoo-daemon.pid");
|
|
21
22
|
const ufooDaemonLog = path.join(runDir, "ufoo-daemon.log");
|
|
22
23
|
const ufooSock = path.join(runDir, "ufoo.sock");
|
|
@@ -35,6 +36,7 @@ function getUfooPaths(projectRoot) {
|
|
|
35
36
|
busDaemonLog,
|
|
36
37
|
busDaemonCountsDir,
|
|
37
38
|
runDir,
|
|
39
|
+
groupsDir,
|
|
38
40
|
ufooDaemonPid,
|
|
39
41
|
ufooDaemonLog,
|
|
40
42
|
ufooSock,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"template": {
|
|
4
|
+
"id": "software-dev-basic",
|
|
5
|
+
"alias": "dev-basic",
|
|
6
|
+
"name": "Software Dev Basic"
|
|
7
|
+
},
|
|
8
|
+
"defaults": {
|
|
9
|
+
"launch_mode": "auto",
|
|
10
|
+
"start_timeout_ms": 15000
|
|
11
|
+
},
|
|
12
|
+
"agents": [
|
|
13
|
+
{
|
|
14
|
+
"id": "pm",
|
|
15
|
+
"nickname": "pm",
|
|
16
|
+
"type": "codex",
|
|
17
|
+
"role": "task coordinator",
|
|
18
|
+
"prompt_profile": "task-breakdown",
|
|
19
|
+
"accept_from": [],
|
|
20
|
+
"report_to": [],
|
|
21
|
+
"startup_order": 1,
|
|
22
|
+
"depends_on": []
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "architect",
|
|
26
|
+
"nickname": "architect",
|
|
27
|
+
"type": "claude",
|
|
28
|
+
"role": "system architect",
|
|
29
|
+
"prompt_profile": "architecture-review",
|
|
30
|
+
"accept_from": [
|
|
31
|
+
"pm"
|
|
32
|
+
],
|
|
33
|
+
"report_to": [
|
|
34
|
+
"pm"
|
|
35
|
+
],
|
|
36
|
+
"startup_order": 2,
|
|
37
|
+
"depends_on": [
|
|
38
|
+
"pm"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "builder",
|
|
43
|
+
"nickname": "builder",
|
|
44
|
+
"type": "codex",
|
|
45
|
+
"role": "implementation engineer",
|
|
46
|
+
"prompt_profile": "code-implement",
|
|
47
|
+
"accept_from": [
|
|
48
|
+
"pm",
|
|
49
|
+
"architect"
|
|
50
|
+
],
|
|
51
|
+
"report_to": [
|
|
52
|
+
"pm"
|
|
53
|
+
],
|
|
54
|
+
"startup_order": 3,
|
|
55
|
+
"depends_on": [
|
|
56
|
+
"pm",
|
|
57
|
+
"architect"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"edges": [
|
|
62
|
+
{
|
|
63
|
+
"from": "pm",
|
|
64
|
+
"to": "architect",
|
|
65
|
+
"kind": "task"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"from": "pm",
|
|
69
|
+
"to": "builder",
|
|
70
|
+
"kind": "task"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"from": "architect",
|
|
74
|
+
"to": "builder",
|
|
75
|
+
"kind": "review"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"template": {
|
|
4
|
+
"id": "research-quick",
|
|
5
|
+
"alias": "research-quick",
|
|
6
|
+
"name": "Research Quick"
|
|
7
|
+
},
|
|
8
|
+
"defaults": {
|
|
9
|
+
"launch_mode": "auto",
|
|
10
|
+
"start_timeout_ms": 10000
|
|
11
|
+
},
|
|
12
|
+
"agents": [
|
|
13
|
+
{
|
|
14
|
+
"id": "researcher",
|
|
15
|
+
"nickname": "researcher",
|
|
16
|
+
"type": "claude",
|
|
17
|
+
"role": "collect references and summarize findings",
|
|
18
|
+
"prompt_profile": "research-scan",
|
|
19
|
+
"accept_from": [],
|
|
20
|
+
"report_to": [],
|
|
21
|
+
"startup_order": 1,
|
|
22
|
+
"depends_on": []
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "coder",
|
|
26
|
+
"nickname": "coder",
|
|
27
|
+
"type": "codex",
|
|
28
|
+
"role": "prototype and verify implementation",
|
|
29
|
+
"prompt_profile": "rapid-prototype",
|
|
30
|
+
"accept_from": [
|
|
31
|
+
"researcher"
|
|
32
|
+
],
|
|
33
|
+
"report_to": [
|
|
34
|
+
"researcher"
|
|
35
|
+
],
|
|
36
|
+
"startup_order": 2,
|
|
37
|
+
"depends_on": [
|
|
38
|
+
"researcher"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"edges": [
|
|
43
|
+
{
|
|
44
|
+
"from": "researcher",
|
|
45
|
+
"to": "coder",
|
|
46
|
+
"kind": "task"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|