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.
@@ -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
- if (!req.url) {
121
- res.writeHead(404);
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 (req.url.startsWith("/ufoo/online/rooms")) {
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 (req.url.startsWith("/ufoo/online/channels")) {
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
- name: channel.name || "",
296
- type: channel.type || "public",
297
- members: channel.members.size,
298
- created_at: channel.created_at,
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
- 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
+ }
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
- room.members.forEach((member) => {
854
- if (member !== client) {
855
- this.send(member.ws, payload);
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(member.ws, { type: "wake", from: client.subscriberId });
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
- members.forEach((member) => {
881
- if (member !== client) {
882
- this.send(member.ws, payload);
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(member.ws, { type: "wake", from: client.subscriberId });
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
- 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
+ }
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
+ }