u-foo 1.8.9 → 1.9.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.
@@ -0,0 +1,601 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { getUfooPaths } = require("../ufoo/paths");
7
+ const { loadAgentsData } = require("../ufoo/agentsStore");
8
+
9
+ const HISTORY_DEBUG = process.env.UFOO_HISTORY_DEBUG === "1";
10
+ const debugLog = (...args) => { if (HISTORY_DEBUG) console.error("[history]", ...args); };
11
+
12
+ /**
13
+ * Input Timeline — aggregates all agent inputs into a unified chat-like history.
14
+ *
15
+ * Sources:
16
+ * 1. Bus events (message/targeted) — inter-agent messages, appended in real-time
17
+ * 2. Claude ​Code session JSONL — manual user inputs, synced by daemon every ~30s
18
+ * 3. Codex session rollout files — manual user inputs, synced by daemon every ~30s
19
+ *
20
+ * Incremental builds use a watermark file tracking:
21
+ * - busLastSeq: last processed bus event seq number
22
+ * - lastTs: last processed timestamp (used to skip session file records + mtime filter)
23
+ * - entryCount: maintained count (avoids full-scan just to count)
24
+ * - builtAt: when last build ran
25
+ *
26
+ * Output format (JSONL per entry):
27
+ * {
28
+ * ts: ISO timestamp,
29
+ * source: "bus" | "manual",
30
+ * from: display label (nickname or "user"),
31
+ * fromId: subscriber ID or "user",
32
+ * to: display label,
33
+ * toId: subscriber ID or "",
34
+ * message: string
35
+ * }
36
+ */
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Paths (all derived from getUfooPaths to avoid hardcoding)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function getHistoryDir(projectRoot) {
43
+ return getUfooPaths(projectRoot).historyDir;
44
+ }
45
+
46
+ function getTimelineFile(projectRoot) {
47
+ return path.join(getHistoryDir(projectRoot), "input-timeline.jsonl");
48
+ }
49
+
50
+ function getWatermarkFile(projectRoot) {
51
+ return path.join(getHistoryDir(projectRoot), "watermark.json");
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Watermark
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const WATERMARK_LOCK_STALE_MS = 10000;
59
+
60
+ function readWatermark(projectRoot) {
61
+ try {
62
+ const file = getWatermarkFile(projectRoot);
63
+ if (fs.existsSync(file)) {
64
+ return JSON.parse(fs.readFileSync(file, "utf8"));
65
+ }
66
+ } catch {
67
+ // corrupted — treat as fresh
68
+ }
69
+ return { busLastSeq: 0, lastTs: "", entryCount: 0 };
70
+ }
71
+
72
+ /**
73
+ * Synchronous non-blocking file lock for watermark writes.
74
+ * Returns lock handle on success, null if lock is held (caller skips update).
75
+ */
76
+ function acquireWatermarkLock(projectRoot) {
77
+ const lockFile = path.join(getHistoryDir(projectRoot), "watermark.lock");
78
+ try {
79
+ const fd = fs.openSync(lockFile, "wx");
80
+ fs.writeSync(fd, `${process.pid}\n`);
81
+ return { fd, lockFile };
82
+ } catch (err) {
83
+ if (err && err.code === "EEXIST") {
84
+ try {
85
+ const stat = fs.statSync(lockFile);
86
+ if (Date.now() - stat.mtimeMs > WATERMARK_LOCK_STALE_MS) {
87
+ fs.unlinkSync(lockFile);
88
+ const fd = fs.openSync(lockFile, "wx");
89
+ fs.writeSync(fd, `${process.pid}\n`);
90
+ return { fd, lockFile };
91
+ }
92
+ } catch {
93
+ // give up
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function releaseWatermarkLock(lock) {
101
+ if (!lock) return;
102
+ try { fs.closeSync(lock.fd); } catch { /* ignore */ }
103
+ try { fs.unlinkSync(lock.lockFile); } catch { /* ignore */ }
104
+ }
105
+
106
+ function writeWatermark(projectRoot, watermark) {
107
+ fs.mkdirSync(getHistoryDir(projectRoot), { recursive: true });
108
+ fs.writeFileSync(getWatermarkFile(projectRoot), JSON.stringify(watermark, null, 2) + "\n", "utf8");
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // JSONL helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Stream-parse a JSONL file line by line.
117
+ * Calls fn(record) for each valid line; stops early if fn returns false.
118
+ */
119
+ function streamJSONL(filePath, fn) {
120
+ if (!fs.existsSync(filePath)) return;
121
+ const raw = fs.readFileSync(filePath, "utf8");
122
+ let start = 0;
123
+ while (start < raw.length) {
124
+ let end = raw.indexOf("\n", start);
125
+ if (end === -1) end = raw.length;
126
+ const line = raw.slice(start, end).trim();
127
+ start = end + 1;
128
+ if (!line) continue;
129
+ try {
130
+ const record = JSON.parse(line);
131
+ if (fn(record) === false) return;
132
+ } catch {
133
+ // skip malformed
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Read the last N records from a JSONL file (tail-read, avoids full load).
140
+ */
141
+ function readTailJSONL(filePath, limit = 50) {
142
+ if (!fs.existsSync(filePath)) return [];
143
+ const stat = fs.statSync(filePath);
144
+ if (stat.size === 0) return [];
145
+
146
+ if (stat.size < 512 * 1024) {
147
+ const results = [];
148
+ streamJSONL(filePath, (r) => { results.push(r); });
149
+ return results.slice(-limit);
150
+ }
151
+
152
+ const chunkSize = Math.min(stat.size, limit * 2048);
153
+ const buf = Buffer.alloc(chunkSize);
154
+ const fd = fs.openSync(filePath, "r");
155
+ try {
156
+ const offset = Math.max(0, stat.size - chunkSize);
157
+ fs.readSync(fd, buf, 0, chunkSize, offset);
158
+ const lines = buf.toString("utf8").split(/\r?\n/).filter(Boolean);
159
+ const startIdx = offset > 0 ? 1 : 0; // skip possible partial first line
160
+ const results = [];
161
+ for (let i = startIdx; i < lines.length; i++) {
162
+ try { results.push(JSON.parse(lines[i])); } catch { /* skip */ }
163
+ }
164
+ return results.slice(-limit);
165
+ } finally {
166
+ fs.closeSync(fd);
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Lookups
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function buildNicknameLookup(projectRoot) {
175
+ const data = loadAgentsData(getUfooPaths(projectRoot).agentsFile);
176
+ const lookup = new Map();
177
+ for (const [id, meta] of Object.entries(data.agents || {})) {
178
+ if (meta && meta.nickname) lookup.set(id, meta.nickname);
179
+ }
180
+ return lookup;
181
+ }
182
+
183
+ function buildSessionLookup(projectRoot) {
184
+ const data = loadAgentsData(getUfooPaths(projectRoot).agentsFile);
185
+ const lookup = new Map();
186
+ for (const [id, meta] of Object.entries(data.agents || {})) {
187
+ if (meta && meta.provider_session_id) {
188
+ lookup.set(meta.provider_session_id, {
189
+ subscriberId: id,
190
+ nickname: meta.nickname || id,
191
+ });
192
+ }
193
+ }
194
+ return lookup;
195
+ }
196
+
197
+ /**
198
+ * Derive the Claude projects directory for this project root.
199
+ * Claude stores sessions at: ~/.claude/projects/<path-with-dashes>/<sessionId>.jsonl
200
+ */
201
+ function getClaudeProjectDir(projectRoot) {
202
+ const slug = path.resolve(projectRoot).replace(/\//g, "-");
203
+ return path.join(os.homedir(), ".claude", "projects", slug);
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Text extraction helpers
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function extractUserText(record) {
211
+ const msg = record.message;
212
+ if (!msg || typeof msg !== "object") return "";
213
+ const content = msg.content;
214
+ if (typeof content === "string") return content.replace(/<[^>]+>/g, "").trim();
215
+ if (Array.isArray(content)) {
216
+ return content
217
+ .map((c) => (typeof c === "string" ? c : c && c.text ? c.text : ""))
218
+ .join("")
219
+ .replace(/<[^>]+>/g, "")
220
+ .trim();
221
+ }
222
+ return "";
223
+ }
224
+
225
+ function isProbeMarker(text) {
226
+ return /^\/ufoo\s+\S+$/.test(text) || /^\$ufoo\s+\S+$/.test(text);
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Collectors
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Collect new bus events since watermark.busLastSeq.
235
+ * Skips event files whose date is strictly before the watermark date.
236
+ */
237
+ function collectBusMessages(projectRoot, watermark = {}) {
238
+ const eventsDir = getUfooPaths(projectRoot).busEventsDir;
239
+ if (!fs.existsSync(eventsDir)) return { entries: [], maxSeq: watermark.busLastSeq || 0 };
240
+
241
+ const minSeq = watermark.busLastSeq || 0;
242
+ const nicknames = buildNicknameLookup(projectRoot);
243
+ const entries = [];
244
+ let maxSeq = minSeq;
245
+ const watermarkDate = watermark.lastTs ? watermark.lastTs.slice(0, 10) : "";
246
+
247
+ const files = fs.readdirSync(eventsDir).filter((f) => f.endsWith(".jsonl")).sort();
248
+ for (const file of files) {
249
+ if (watermarkDate && file < `${watermarkDate}.jsonl`) continue;
250
+ streamJSONL(path.join(eventsDir, file), (evt) => {
251
+ if (!evt.seq || evt.seq <= minSeq) return;
252
+ if (evt.type !== "message/targeted" || evt.event !== "message") return;
253
+ if (!evt.data || !evt.data.message) return;
254
+ if (evt.seq > maxSeq) maxSeq = evt.seq;
255
+ entries.push({
256
+ ts: evt.timestamp,
257
+ source: "bus",
258
+ from: nicknames.get(evt.publisher) || evt.publisher,
259
+ fromId: evt.publisher,
260
+ to: nicknames.get(evt.target) || evt.target,
261
+ toId: evt.target,
262
+ message: evt.data.message,
263
+ });
264
+ });
265
+ }
266
+
267
+ return { entries, maxSeq };
268
+ }
269
+
270
+ /**
271
+ * Collect new manual user inputs from Claude ​Code session files.
272
+ * Uses mtime to skip unmodified files; within modified files filters by timestamp.
273
+ */
274
+ function collectClaudeManualInputs(projectRoot, watermark = {}) {
275
+ const claudeProjectDir = getClaudeProjectDir(projectRoot);
276
+ if (!fs.existsSync(claudeProjectDir)) return [];
277
+
278
+ const sessionLookup = buildSessionLookup(projectRoot);
279
+ const entries = [];
280
+ const cutoffMs = watermark.lastTs ? new Date(watermark.lastTs).getTime() : 0;
281
+
282
+ const sessionToAgent = new Map();
283
+ for (const [sessionId, info] of sessionLookup) {
284
+ if (info.subscriberId.startsWith("claude-code:")) {
285
+ sessionToAgent.set(sessionId, info);
286
+ }
287
+ }
288
+
289
+ let sessionFiles;
290
+ if (sessionToAgent.size > 0) {
291
+ sessionFiles = [];
292
+ for (const sessionId of sessionToAgent.keys()) {
293
+ const filePath = path.join(claudeProjectDir, `${sessionId}.jsonl`);
294
+ if (fs.existsSync(filePath)) sessionFiles.push({ filePath, sessionId });
295
+ }
296
+ } else {
297
+ try {
298
+ sessionFiles = fs.readdirSync(claudeProjectDir)
299
+ .filter((f) => f.endsWith(".jsonl"))
300
+ .map((f) => ({ filePath: path.join(claudeProjectDir, f), sessionId: f.replace(".jsonl", "") }));
301
+ } catch {
302
+ return entries;
303
+ }
304
+ }
305
+
306
+ for (const { filePath, sessionId } of sessionFiles) {
307
+ if (cutoffMs > 0) {
308
+ try {
309
+ if (fs.statSync(filePath).mtimeMs <= cutoffMs) continue;
310
+ } catch { continue; }
311
+ }
312
+
313
+ const agent = sessionToAgent.get(sessionId);
314
+ const toLabel = agent ? agent.nickname : `session:${sessionId.slice(0, 8)}`;
315
+ const toId = agent ? agent.subscriberId : "";
316
+
317
+ streamJSONL(filePath, (record) => {
318
+ if (record.type !== "user") return;
319
+ if (cutoffMs > 0 && record.timestamp) {
320
+ if (new Date(record.timestamp).getTime() <= cutoffMs) return;
321
+ }
322
+ const text = extractUserText(record);
323
+ if (!text || isProbeMarker(text)) return;
324
+ entries.push({
325
+ ts: record.timestamp || "",
326
+ source: "manual",
327
+ from: "user",
328
+ fromId: "user",
329
+ to: toLabel,
330
+ toId,
331
+ message: text,
332
+ });
333
+ });
334
+ }
335
+
336
+ return entries;
337
+ }
338
+
339
+ /**
340
+ * Collect new manual user inputs from Codex session rollouts.
341
+ * Skips date directories older than watermark date; skips files by mtime.
342
+ */
343
+ function collectCodexManualInputs(projectRoot, watermark = {}) {
344
+ const sessionLookup = buildSessionLookup(projectRoot);
345
+ if (sessionLookup.size === 0) return [];
346
+
347
+ const entries = [];
348
+ const sessionsBase = path.join(os.homedir(), ".codex", "sessions");
349
+ if (!fs.existsSync(sessionsBase)) return entries;
350
+
351
+ const cutoffMs = watermark.lastTs ? new Date(watermark.lastTs).getTime() : 0;
352
+ const cutoffDate = watermark.lastTs ? watermark.lastTs.slice(0, 10) : "";
353
+
354
+ const codexSessions = new Map();
355
+ for (const [sessionId, info] of sessionLookup) {
356
+ if (info.subscriberId.startsWith("codex:")) codexSessions.set(sessionId, info);
357
+ }
358
+ if (codexSessions.size === 0) return entries;
359
+
360
+ let years;
361
+ try { years = fs.readdirSync(sessionsBase).filter((d) => /^\d{4}$/.test(d)); } catch { return entries; }
362
+
363
+ for (const y of years) {
364
+ if (cutoffDate && y < cutoffDate.slice(0, 4)) continue;
365
+ const yDir = path.join(sessionsBase, y);
366
+ let months;
367
+ try { months = fs.readdirSync(yDir).filter((d) => /^\d{2}$/.test(d)); } catch { continue; }
368
+ for (const m of months) {
369
+ if (cutoffDate && `${y}-${m}` < cutoffDate.slice(0, 7)) continue;
370
+ const mDir = path.join(yDir, m);
371
+ let days;
372
+ try { days = fs.readdirSync(mDir).filter((d) => /^\d{2}$/.test(d)); } catch { continue; }
373
+ for (const d of days) {
374
+ if (cutoffDate && `${y}-${m}-${d}` < cutoffDate) continue;
375
+ const dDir = path.join(mDir, d);
376
+ let files;
377
+ try {
378
+ files = fs.readdirSync(dDir).filter((f) => f.startsWith("rollout-") && f.endsWith(".jsonl"));
379
+ } catch { continue; }
380
+
381
+ for (const file of files) {
382
+ const filePath = path.join(dDir, file);
383
+ if (cutoffMs > 0) {
384
+ try { if (fs.statSync(filePath).mtimeMs <= cutoffMs) continue; } catch { continue; }
385
+ }
386
+
387
+ let sessionId = "";
388
+ streamJSONL(filePath, (rec) => {
389
+ if (rec.type === "session_meta" && rec.payload?.id) {
390
+ sessionId = rec.payload.id;
391
+ return false;
392
+ }
393
+ });
394
+
395
+ const agent = codexSessions.get(sessionId);
396
+ if (!agent) continue;
397
+
398
+ streamJSONL(filePath, (rec) => {
399
+ if (rec.type !== "message" || rec.role !== "user") return;
400
+ if (cutoffMs > 0 && rec.timestamp) {
401
+ if (new Date(rec.timestamp).getTime() <= cutoffMs) return;
402
+ }
403
+ const content = typeof rec.content === "string"
404
+ ? rec.content
405
+ : Array.isArray(rec.content)
406
+ ? rec.content.map((c) => c.text || "").join("")
407
+ : "";
408
+ if (!content) return;
409
+ entries.push({
410
+ ts: rec.timestamp ? new Date(rec.timestamp).toISOString() : new Date().toISOString(),
411
+ source: "manual",
412
+ from: "user",
413
+ fromId: "user",
414
+ to: agent.nickname,
415
+ toId: agent.subscriberId,
416
+ message: content,
417
+ });
418
+ });
419
+ }
420
+ }
421
+ }
422
+ }
423
+
424
+ return entries;
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Real-time append (called from EventBus.send)
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * Append a single bus message to the timeline immediately on send.
433
+ * Uses file lock to safely advance the watermark; if lock is contended,
434
+ * skips the watermark update (next build will catch up — no data lost).
435
+ */
436
+ function appendBusEntry(projectRoot, { seq, timestamp, publisher, target, message, nicknames = null }) {
437
+ try {
438
+ fs.mkdirSync(getHistoryDir(projectRoot), { recursive: true });
439
+ const timelineFile = getTimelineFile(projectRoot);
440
+
441
+ const nicknameMap = nicknames || buildNicknameLookup(projectRoot);
442
+ const entry = {
443
+ ts: timestamp,
444
+ source: "bus",
445
+ from: nicknameMap.get(publisher) || publisher,
446
+ fromId: publisher,
447
+ to: nicknameMap.get(target) || target,
448
+ toId: target,
449
+ message,
450
+ };
451
+
452
+ fs.appendFileSync(timelineFile, JSON.stringify(entry) + "\n", "utf8");
453
+
454
+ if (seq) {
455
+ const lock = acquireWatermarkLock(projectRoot);
456
+ if (lock) {
457
+ try {
458
+ const watermark = readWatermark(projectRoot);
459
+ if (seq > (watermark.busLastSeq || 0)) {
460
+ watermark.busLastSeq = seq;
461
+ if (timestamp && (!watermark.lastTs || timestamp > watermark.lastTs)) {
462
+ watermark.lastTs = timestamp;
463
+ }
464
+ watermark.entryCount = (watermark.entryCount || 0) + 1;
465
+ writeWatermark(projectRoot, watermark);
466
+ }
467
+ } finally {
468
+ releaseWatermarkLock(lock);
469
+ }
470
+ }
471
+ // lock contended → watermark update skipped; next build will reprocess
472
+ }
473
+ } catch (err) {
474
+ debugLog("appendBusEntry failed:", err.message);
475
+ }
476
+ }
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Incremental build
480
+ // ---------------------------------------------------------------------------
481
+
482
+ /**
483
+ * Build the timeline incrementally (or fully with force=true).
484
+ * Reads watermark → collects only new entries → appends → updates watermark.
485
+ * entryCount is maintained in the watermark to avoid full-file counting.
486
+ */
487
+ function buildTimeline(projectRoot, { force = false } = {}) {
488
+ fs.mkdirSync(getHistoryDir(projectRoot), { recursive: true });
489
+ const timelineFile = getTimelineFile(projectRoot);
490
+
491
+ const watermark = force ? { busLastSeq: 0, lastTs: "", entryCount: 0 } : readWatermark(projectRoot);
492
+
493
+ const busResult = collectBusMessages(projectRoot, watermark);
494
+ const claudeEntries = collectClaudeManualInputs(projectRoot, watermark);
495
+ const codexEntries = collectCodexManualInputs(projectRoot, watermark);
496
+
497
+ const newEntries = [...busResult.entries, ...claudeEntries, ...codexEntries];
498
+ newEntries.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
499
+
500
+ if (newEntries.length === 0 && !force) {
501
+ return { count: watermark.entryCount || 0, newCount: 0, file: timelineFile };
502
+ }
503
+
504
+ const lock = acquireWatermarkLock(projectRoot);
505
+ try {
506
+ if (force) {
507
+ const content = newEntries.map((e) => JSON.stringify(e)).join("\n") + (newEntries.length > 0 ? "\n" : "");
508
+ fs.writeFileSync(timelineFile, content, "utf8");
509
+ } else {
510
+ fs.appendFileSync(timelineFile, newEntries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
511
+ }
512
+
513
+ const prevCount = force ? 0 : (watermark.entryCount || 0);
514
+ const lastEntry = newEntries[newEntries.length - 1];
515
+ const newWatermark = {
516
+ busLastSeq: busResult.maxSeq,
517
+ lastTs: lastEntry ? lastEntry.ts : watermark.lastTs,
518
+ entryCount: prevCount + newEntries.length,
519
+ builtAt: new Date().toISOString(),
520
+ };
521
+ writeWatermark(projectRoot, newWatermark);
522
+ return { count: newWatermark.entryCount, newCount: newEntries.length, file: timelineFile };
523
+ } catch (err) {
524
+ debugLog("buildTimeline failed:", err.message);
525
+ throw err;
526
+ } finally {
527
+ releaseWatermarkLock(lock);
528
+ }
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // Read / format / render
533
+ // ---------------------------------------------------------------------------
534
+
535
+ function readTimeline(projectRoot, limit = 50) {
536
+ return readTailJSONL(getTimelineFile(projectRoot), limit);
537
+ }
538
+
539
+ function formatEntry(entry) {
540
+ if (entry.source === "bus") {
541
+ const label = entry.fromId && entry.fromId !== entry.from
542
+ ? `${entry.fromId}(${entry.from})` : entry.from;
543
+ return `[ufoo]<from:${label}> ${entry.message}`;
544
+ }
545
+ // manual: focus on who received it, not who sent (always user)
546
+ const toLabel = entry.toId && entry.toId !== entry.to
547
+ ? `${entry.toId}(${entry.to})` : entry.to;
548
+ return `[manual]<to:${toLabel}> ${entry.message}`;
549
+ }
550
+
551
+ function renderTimelineForPrompt(projectRoot, limit = 30) {
552
+ const entries = readTimeline(projectRoot, limit);
553
+ if (entries.length === 0) return "";
554
+
555
+ const lines = entries.map((entry) => {
556
+ const time = entry.ts ? entry.ts.slice(0, 16).replace("T", " ") : "?";
557
+ const prefix = entry.source === "bus"
558
+ ? `[ufoo]<from:${entry.from}>`
559
+ : `[manual]<to:${entry.to}>`;
560
+ const msg = entry.message.length > 200 ? entry.message.slice(0, 200) + "..." : entry.message;
561
+ return `${time} ${prefix} ${msg}`;
562
+ });
563
+
564
+ return [
565
+ "## Team Activity (recent agent inputs)",
566
+ "",
567
+ "This shows recent prompts sent to agents. Use it to understand what each agent is working on.",
568
+ "",
569
+ ...lines,
570
+ ].join("\n");
571
+ }
572
+
573
+ function showTimeline(projectRoot, limit = 50) {
574
+ const entries = readTimeline(projectRoot, limit);
575
+ if (entries.length === 0) {
576
+ console.log("No timeline entries found. Run `ufoo history build` first.");
577
+ return;
578
+ }
579
+ console.log(`=== Input Timeline (${entries.length} entries) ===\n`);
580
+ for (const entry of entries) {
581
+ const time = entry.ts ? entry.ts.slice(0, 19).replace("T", " ") : "?";
582
+ console.log(`[${time}] ${formatEntry(entry)}`);
583
+ }
584
+ }
585
+
586
+ module.exports = {
587
+ getHistoryDir,
588
+ getTimelineFile,
589
+ getWatermarkFile,
590
+ buildTimeline,
591
+ appendBusEntry,
592
+ readTimeline,
593
+ readWatermark,
594
+ formatEntry,
595
+ renderTimelineForPrompt,
596
+ showTimeline,
597
+ getClaudeProjectDir,
598
+ collectBusMessages,
599
+ collectClaudeManualInputs,
600
+ collectCodexManualInputs,
601
+ };
@@ -1,6 +1,6 @@
1
1
  const os = require("os");
2
2
  const path = require("path");
3
- const { canonicalProjectRoot, trimTrailingSlashes } = require("./projects/projectId");
3
+ const { canonicalProjectRoot, trimTrailingSlashes } = require("./projectId");
4
4
 
5
5
  function normalizeProjectRoot(projectRoot) {
6
6
  const input = String(projectRoot || "").trim();
@@ -0,0 +1,11 @@
1
+ // Projects module: unified identity + registry + runtimes interface
2
+ module.exports = {
3
+ // Identity functions (path canonicalization, global mode detection)
4
+ ...require("./identity"),
5
+ // Project ID generation
6
+ ...require("./projectId"),
7
+ // Project registry (CRUD runtime state)
8
+ ...require("./registry"),
9
+ // Project runtimes utilities (filtering, sorting, formatting)
10
+ ...require("./runtimes"),
11
+ };
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+
3
+ function asTrimmedString(value) {
4
+ return typeof value === "string" ? value.trim() : "";
5
+ }
6
+
7
+ function resolveSoloAgentType(config = {}, requestedAgent = "") {
8
+ const requested = asTrimmedString(requestedAgent).toLowerCase();
9
+ if (requested === "claude" || requested === "uclaude" || requested === "claude-code") return "claude";
10
+ if (requested === "codex" || requested === "ucodex" || requested === "openai") return "codex";
11
+ if (requested === "ucode" || requested === "ufoo" || requested === "ufoo-code") return "ucode";
12
+
13
+ const provider = asTrimmedString(config && config.agentProvider).toLowerCase();
14
+ if (provider === "claude-cli") return "claude";
15
+ if (provider === "ucode") return "ucode";
16
+ return "codex";
17
+ }
18
+
19
+ function buildPromptProfileCandidates(registry = null) {
20
+ const profiles = Array.isArray(registry && registry.profiles) ? registry.profiles : [];
21
+ return profiles.map((item) => ({
22
+ cmd: item.id,
23
+ desc: [item.summary || "", item.source || ""].filter(Boolean).join(" · "),
24
+ source: item.source || "",
25
+ }));
26
+ }
27
+
28
+ module.exports = {
29
+ resolveSoloAgentType,
30
+ buildPromptProfileCandidates,
31
+ };
package/src/ufoo/paths.js CHANGED
@@ -18,6 +18,7 @@ function getUfooPaths(projectRoot) {
18
18
 
19
19
  const runDir = path.join(ufooDir, "run");
20
20
  const groupsDir = path.join(ufooDir, "groups");
21
+ const historyDir = path.join(ufooDir, "history");
21
22
  const ufooDaemonPid = path.join(runDir, "ufoo-daemon.pid");
22
23
  const ufooDaemonLog = path.join(runDir, "ufoo-daemon.log");
23
24
  const ufooSock = path.join(runDir, "ufoo.sock");
@@ -37,6 +38,7 @@ function getUfooPaths(projectRoot) {
37
38
  busDaemonCountsDir,
38
39
  runDir,
39
40
  groupsDir,
41
+ historyDir,
40
42
  ufooDaemonPid,
41
43
  ufooDaemonLog,
42
44
  ufooSock,
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "defaults": {
9
9
  "launch_mode": "auto",
10
- "start_timeout_ms": 15000
10
+ "start_timeout_ms": 30000
11
11
  },
12
12
  "agents": [
13
13
  {
@@ -16,7 +16,13 @@
16
16
  "type": "auto",
17
17
  "role": "coordinate builders, track progress, enforce delivery cadence",
18
18
  "prompt_profile": "pmo-coordinator",
19
- "accept_from": [],
19
+ "accept_from": [
20
+ "builder-1",
21
+ "builder-2",
22
+ "builder-3",
23
+ "builder-4",
24
+ "reviewer"
25
+ ],
20
26
  "report_to": [
21
27
  "builder-1",
22
28
  "builder-2",