u-foo 1.2.14 → 1.3.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.
@@ -45,6 +45,9 @@ 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.channelHistoryLimit = options.channelHistoryLimit ?? 200;
50
+ this.eventSeq = 0;
48
51
 
49
52
  // Step 2 + 3: Payload limits
50
53
  this.maxHttpBodyBytes = options.maxHttpBodyBytes ?? 65536; // 64 KB
@@ -79,6 +82,23 @@ class OnlineServer extends EventEmitter {
79
82
  this.tlsKey = options.tlsKey || null;
80
83
  }
81
84
 
85
+ parseRequestUrl(rawUrl) {
86
+ try {
87
+ return new URL(rawUrl || "/", "http://localhost");
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ corsHeaders(extra = {}) {
94
+ return {
95
+ "Access-Control-Allow-Origin": "*",
96
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
97
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
98
+ ...extra,
99
+ };
100
+ }
101
+
82
102
  loadTokens(options) {
83
103
  if (options.tokens) {
84
104
  return new Set(Array.isArray(options.tokens) ? options.tokens : Object.keys(options.tokens));
@@ -117,27 +137,61 @@ class OnlineServer extends EventEmitter {
117
137
  }
118
138
 
119
139
  const requestHandler = (req, res) => {
120
- if (!req.url) {
121
- res.writeHead(404);
140
+ const parsedUrl = this.parseRequestUrl(req.url);
141
+ if (!parsedUrl) {
142
+ res.writeHead(404, this.corsHeaders());
122
143
  res.end();
123
144
  return;
124
145
  }
125
146
 
126
- if (req.url.startsWith("/ufoo/online/rooms")) {
147
+ const pathname = parsedUrl.pathname || "/";
148
+ const publicMessagesMatch = pathname.match(/^\/ufoo\/online\/public\/channels\/([^/]+)\/messages$/);
149
+ const privateMessagesMatch = pathname.match(/^\/ufoo\/online\/channels\/([^/]+)\/messages$/);
150
+
151
+ if (req.method === "OPTIONS") {
152
+ res.writeHead(204, this.corsHeaders());
153
+ res.end();
154
+ return;
155
+ }
156
+
157
+ if (publicMessagesMatch) {
158
+ const channelRef = decodeURIComponent(publicMessagesMatch[1] || "");
159
+ this.handleChannelMessagesRequest(req, res, channelRef, parsedUrl);
160
+ return;
161
+ }
162
+
163
+ if (privateMessagesMatch) {
164
+ const channelRef = decodeURIComponent(privateMessagesMatch[1] || "");
165
+ if (!this.authenticateHttp(req, res)) return;
166
+ this.handleChannelMessagesRequest(req, res, channelRef, parsedUrl);
167
+ return;
168
+ }
169
+
170
+ if (pathname === "/ufoo/online/public/channels") {
171
+ this.handlePublicChannelsRequest(req, res, parsedUrl);
172
+ return;
173
+ }
174
+
175
+ if (pathname === "/ufoo/online/public/rooms") {
176
+ this.handlePublicRoomsRequest(req, res, parsedUrl);
177
+ return;
178
+ }
179
+
180
+ if (pathname.startsWith("/ufoo/online/rooms")) {
127
181
  // Step 4: HTTP auth
128
182
  if (!this.authenticateHttp(req, res)) return;
129
183
  this.handleRoomsRequest(req, res);
130
184
  return;
131
185
  }
132
186
 
133
- if (req.url.startsWith("/ufoo/online/channels")) {
187
+ if (pathname.startsWith("/ufoo/online/channels")) {
134
188
  // Step 4: HTTP auth
135
189
  if (!this.authenticateHttp(req, res)) return;
136
190
  this.handleChannelsRequest(req, res);
137
191
  return;
138
192
  }
139
193
 
140
- res.writeHead(200, { "Content-Type": "text/plain" });
194
+ res.writeHead(200, this.corsHeaders({ "Content-Type": "text/plain" }));
141
195
  res.end("ufoo-online: running\n");
142
196
  };
143
197
 
@@ -258,7 +312,7 @@ class OnlineServer extends EventEmitter {
258
312
  }
259
313
 
260
314
  sendJson(res, statusCode, payload) {
261
- res.writeHead(statusCode, { "Content-Type": "application/json" });
315
+ res.writeHead(statusCode, this.corsHeaders({ "Content-Type": "application/json" }));
262
316
  res.end(JSON.stringify(payload));
263
317
  }
264
318
 
@@ -286,17 +340,123 @@ class OnlineServer extends EventEmitter {
286
340
  type: room.type,
287
341
  members: room.members.size,
288
342
  created_at: room.created_at,
343
+ created_by: room.created_by || "",
344
+ password_required: room.type === "private",
289
345
  }));
290
346
  }
291
347
 
292
348
  listChannels() {
293
- return Array.from(this.channels.entries()).map(([channelId, channel]) => ({
349
+ return Array.from(this.channels.entries()).map(([channelId, channel]) => {
350
+ const history = this.channelMessageHistory.get(channelId) || [];
351
+ const last = history.length > 0 ? history[history.length - 1] : null;
352
+ return {
353
+ channel_id: channelId,
354
+ name: channel.name || "",
355
+ type: channel.type || "public",
356
+ members: channel.members.size,
357
+ created_at: channel.created_at,
358
+ created_by: channel.created_by || "",
359
+ message_count: history.length,
360
+ last_message_at: channel.last_message_at || (last ? last.ts : null),
361
+ };
362
+ });
363
+ }
364
+
365
+ listPublicRooms(type = "") {
366
+ return this.listRooms()
367
+ .filter((room) => !type || room.type === type)
368
+ .map((room) => ({
369
+ room_id: room.room_id,
370
+ name: room.name || "",
371
+ type: room.type,
372
+ created_at: room.created_at,
373
+ created_by: room.created_by || "",
374
+ password_required: room.password_required !== false,
375
+ }));
376
+ }
377
+
378
+ listChannelMessages(channelRef, limit = 80) {
379
+ const resolved = this.resolveChannel(channelRef);
380
+ if (!resolved) return null;
381
+ const history = this.channelMessageHistory.get(resolved.channelId) || [];
382
+ const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 500)) : 80;
383
+ const start = Math.max(0, history.length - safeLimit);
384
+ return {
385
+ channel_id: resolved.channelId,
386
+ name: resolved.channel.name || "",
387
+ type: resolved.channel.type || "public",
388
+ messages: history.slice(start),
389
+ };
390
+ }
391
+
392
+ handlePublicRoomsRequest(req, res, parsedUrl) {
393
+ if (req.method !== "GET") {
394
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
395
+ return;
396
+ }
397
+ const type = String(parsedUrl.searchParams.get("type") || "").trim();
398
+ this.sendJson(res, 200, { ok: true, rooms: this.listPublicRooms(type) });
399
+ }
400
+
401
+ handlePublicChannelsRequest(req, res, parsedUrl) {
402
+ if (req.method !== "GET") {
403
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
404
+ return;
405
+ }
406
+ const type = String(parsedUrl.searchParams.get("type") || "").trim();
407
+ const channels = this.listChannels().filter((channel) => !type || channel.type === type);
408
+ this.sendJson(res, 200, { ok: true, channels });
409
+ }
410
+
411
+ handleChannelMessagesRequest(req, res, channelRef, parsedUrl) {
412
+ if (req.method !== "GET") {
413
+ this.sendJson(res, 405, { ok: false, error: "Method not allowed" });
414
+ return;
415
+ }
416
+ const limitRaw = Number.parseInt(String(parsedUrl.searchParams.get("limit") || "80"), 10);
417
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 80;
418
+ const channelData = this.listChannelMessages(channelRef, limit);
419
+ if (!channelData) {
420
+ this.sendJson(res, 404, { ok: false, error: "Channel not found" });
421
+ return;
422
+ }
423
+ this.sendJson(res, 200, {
424
+ ok: true,
425
+ channel: {
426
+ channel_id: channelData.channel_id,
427
+ name: channelData.name,
428
+ type: channelData.type,
429
+ },
430
+ messages: channelData.messages,
431
+ });
432
+ }
433
+
434
+ recordChannelMessage(channelId, channel, client, eventPayload) {
435
+ const rawText = eventPayload?.payload?.message;
436
+ let text = "";
437
+ if (typeof rawText === "string") text = rawText;
438
+ else if (rawText !== null && rawText !== undefined) text = JSON.stringify(rawText);
439
+ if (!text) return;
440
+
441
+ const history = this.channelMessageHistory.get(channelId) || [];
442
+ const ts = eventPayload.ts || new Date().toISOString();
443
+ const entry = {
444
+ event_id: `event_${String(++this.eventSeq).padStart(8, "0")}`,
445
+ ts,
294
446
  channel_id: channelId,
295
- name: channel.name || "",
296
- type: channel.type || "public",
297
- members: channel.members.size,
298
- created_at: channel.created_at,
299
- }));
447
+ channel_name: channel?.name || channelId,
448
+ from: client.subscriberId || "",
449
+ nickname: client.nickname || "",
450
+ text,
451
+ };
452
+ history.push(entry);
453
+ if (history.length > this.channelHistoryLimit) {
454
+ history.splice(0, history.length - this.channelHistoryLimit);
455
+ }
456
+ this.channelMessageHistory.set(channelId, history);
457
+ if (channel) {
458
+ channel.last_message_at = ts;
459
+ }
300
460
  }
301
461
 
302
462
  handleRoomsRequest(req, res) {
@@ -320,6 +480,7 @@ class OnlineServer extends EventEmitter {
320
480
  }
321
481
  const name = String(payload.name || "").trim();
322
482
  const type = String(payload.type).trim();
483
+ const createdBy = String(payload.created_by || payload.creator || "").trim();
323
484
  if (!["public", "private"].includes(type)) {
324
485
  this.sendJson(res, 400, { ok: false, error: "Invalid room type" });
325
486
  return;
@@ -328,6 +489,10 @@ class OnlineServer extends EventEmitter {
328
489
  const nameErr = this.validateIdentifier(name, "name");
329
490
  if (nameErr) { this.sendJson(res, 400, { ok: false, error: nameErr }); return; }
330
491
  }
492
+ if (createdBy) {
493
+ const creatorErr = this.validateIdentifier(createdBy, "created_by");
494
+ if (creatorErr) { this.sendJson(res, 400, { ok: false, error: creatorErr }); return; }
495
+ }
331
496
  if (this.rooms.size >= this.maxRooms) {
332
497
  this.sendJson(res, 429, { ok: false, error: "Room limit reached" });
333
498
  return;
@@ -354,8 +519,12 @@ class OnlineServer extends EventEmitter {
354
519
  type,
355
520
  members: new Set(),
356
521
  created_at: new Date().toISOString(),
522
+ created_by: createdBy,
523
+ });
524
+ this.sendJson(res, 200, {
525
+ ok: true,
526
+ room: { room_id: roomId, name, type, created_by: createdBy, password_required: type === "private" },
357
527
  });
358
- this.sendJson(res, 200, { ok: true, room: { room_id: roomId, name, type } });
359
528
  })
360
529
  .catch(() => {
361
530
  // Step 3: 413 on payload too large
@@ -388,12 +557,17 @@ class OnlineServer extends EventEmitter {
388
557
  }
389
558
  const name = String(payload.name || "").trim();
390
559
  const type = String(payload.type || "public").trim();
560
+ const createdBy = String(payload.created_by || payload.creator || "").trim();
391
561
  if (!name) {
392
562
  this.sendJson(res, 400, { ok: false, error: "Invalid channel name" });
393
563
  return;
394
564
  }
395
565
  const chNameErr = this.validateIdentifier(name, "name");
396
566
  if (chNameErr) { this.sendJson(res, 400, { ok: false, error: chNameErr }); return; }
567
+ if (createdBy) {
568
+ const creatorErr = this.validateIdentifier(createdBy, "created_by");
569
+ if (creatorErr) { this.sendJson(res, 400, { ok: false, error: creatorErr }); return; }
570
+ }
397
571
  if (!["world", "public"].includes(type)) {
398
572
  this.sendJson(res, 400, { ok: false, error: "Invalid channel type" });
399
573
  return;
@@ -420,9 +594,10 @@ class OnlineServer extends EventEmitter {
420
594
  type,
421
595
  members: new Set(),
422
596
  created_at: new Date().toISOString(),
597
+ created_by: createdBy,
423
598
  });
424
599
  this.channelNames.set(name, channelId);
425
- this.sendJson(res, 200, { ok: true, channel: { channel_id: channelId, name, type } });
600
+ this.sendJson(res, 200, { ok: true, channel: { channel_id: channelId, name, type, created_by: createdBy } });
426
601
  })
427
602
  .catch(() => {
428
603
  // Step 3: 413 on payload too large
@@ -728,6 +903,7 @@ class OnlineServer extends EventEmitter {
728
903
  type: "public",
729
904
  members: new Set(),
730
905
  created_at: new Date().toISOString(),
906
+ created_by: "",
731
907
  };
732
908
  this.channels.set(channelRef, channel);
733
909
  this.channelNames.set(channelRef, channelRef);
@@ -877,6 +1053,9 @@ class OnlineServer extends EventEmitter {
877
1053
  const members = channel ? channel.members : null;
878
1054
  if (!members || members.size === 0) return;
879
1055
  payload.channel = channelId;
1056
+ if (kind === "message") {
1057
+ this.recordChannelMessage(channelId, channel, client, payload);
1058
+ }
880
1059
  members.forEach((member) => {
881
1060
  if (member !== client) {
882
1061
  this.send(member.ws, payload);
@@ -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
+ }