u-foo 1.9.0 → 1.9.2

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.
@@ -24,14 +24,15 @@ When you see a probe marker command like `/ufoo <marker>` (Claude) or `$ufoo <ma
24
24
 
25
25
  ### When to Record
26
26
 
27
- **"If it has information value, write it down."**
27
+ **"Only record decisions that matter beyond this session."**
28
28
 
29
- Record a decision whenever your work produces knowledge that would be useful to your future self, other agents, or the user. The threshold is LOWwhen in doubt, record it.
29
+ Record a decision for important, plan-level knowledge that other agents or your future self need. The threshold is HIGHmost tasks do NOT need a decision.
30
30
 
31
- - **Always record**: architectural choices, trade-off analysis, research findings, non-obvious gotchas, naming/convention changes, external API behavior discovered, performance observations, bug root causes
32
- - **Also record**: open questions you couldn't resolve, assumptions you made, approaches you considered and rejected (with reasons), edge cases noticed but not handled
31
+ - **Always record**: architectural choices, plan-level decisions with multiple options, cross-agent coordination decisions, trade-off analysis where alternatives were considered and rejected
32
+ - **Also record**: design patterns that set precedent, integration contracts between systems, decisions that constrain future work
33
+ - **Do NOT record**: routine bug fixes, simple implementation details, trivial observations, findings that only matter within the current task
33
34
  - **Write the decision BEFORE acting on it** — if your session dies, the knowledge survives
34
- - **Granularity**: one sentence or multi-page analysis match the depth to the information value
35
+ - **Rule of thumb**: if another agent wouldn't need to know about it, don't write a decision
35
36
 
36
37
  ### Commands
37
38
 
@@ -121,7 +122,35 @@ Notes:
121
122
 
122
123
  ---
123
124
 
124
- ## 3. Initialization (uinit)
125
+ ## 3. Message Format
126
+
127
+ Bus messages use a unified prefix format to distinguish sources:
128
+
129
+ - `[ufoo]<from:id(nickname)>` — message from another agent via the bus
130
+ - `[manual]<to:id(nickname)>` — manual user input directed at an agent
131
+
132
+ When you see `[ufoo]<from:xxx>` in your prompt, it's an inter-agent message — `xxx` is the sender's ID and nickname.
133
+ When you see `[manual]<to:xxx>`, it's a direct user instruction to an agent — `xxx` is the recipient's ID and nickname.
134
+
135
+ ---
136
+
137
+ ## 4. Team Activity (Input History)
138
+
139
+ Your bootstrap prompt may include a `## Team Activity` section showing recent prompts sent to all agents. Use this to understand:
140
+ - What each agent is currently working on
141
+ - Who sent what tasks to whom
142
+ - The overall coordination flow
143
+
144
+ Commands:
145
+ ```bash
146
+ ufoo history build # Rebuild timeline from bus + session data
147
+ ufoo history show [limit] # Show recent entries
148
+ ufoo history prompt [limit] # Render as injectable prompt block
149
+ ```
150
+
151
+ ---
152
+
153
+ ## 5. Initialization (uinit)
125
154
 
126
155
  Trigger: `/uinit` or `/ufoo init`
127
156
 
package/bin/ufoo.js CHANGED
@@ -5,7 +5,7 @@ const { runDaemonCli } = require("../src/daemon/run");
5
5
  const { runChat } = require("../src/chat");
6
6
  const { runInternalRunner } = require("../src/agent/internalRunner");
7
7
  const { runPtyRunner } = require("../src/agent/ptyRunner");
8
- const { resolveGlobalControllerProjectRoot } = require("../src/globalMode");
8
+ const { resolveGlobalControllerProjectRoot } = require("../src/projects");
9
9
 
10
10
  const rawArgv = process.argv.slice(2);
11
11
 
@@ -102,15 +102,17 @@ If pending events exist, show:
102
102
  ```
103
103
  === Pending Messages ===
104
104
 
105
- @you from claude-code:abc123
106
- Type: task/delegate
107
- Content: {"task":"review","file":"src/main.ts"}
105
+ [ufoo]<from:claude-code:abc123(architect)>
106
+ Type: message/targeted/message
107
+ Content: {"message":"review src/main.ts","injection_mode":"immediate"}
108
108
 
109
109
  ---
110
110
  Please handle the above messages, after completion you can reply:
111
- ufoo bus send "claude-code:abc123" "Review completed, found 2 issues..."
111
+ ufoo bus send "architect" "Review completed, found 2 issues..."
112
112
  ```
113
113
 
114
+ Note: Messages use `[ufoo]<from:id(nickname)>` prefix to distinguish from manual user input.
115
+
114
116
  ### 5. IMPORTANT: Acknowledge messages after handling
115
117
 
116
118
  After you have read and processed the messages, you MUST acknowledge them to prevent repeated notifications:
@@ -13,7 +13,8 @@ description: |
13
13
  Fast context check for daily use. Run at session start or anytime.
14
14
 
15
15
  Pre-flight reminder:
16
- - If the user is asking for evaluation/recommendation/plan, write a decision before replying.
16
+ - If the user is asking for an important architectural decision, a plan with multiple options, or a cross-agent coordination choice, write a decision before replying.
17
+ Do NOT write decisions for routine tasks, simple bug fixes, or trivial findings.
17
18
  Use: `ufoo ctx decisions new "<Title>"`
18
19
 
19
20
  ## Decision format (canonical)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -26,17 +26,40 @@ function hasMetaCommandArgs(args = []) {
26
26
  return hasArg(args, ["-h", "--help", "-v", "--version"]);
27
27
  }
28
28
 
29
- function buildDefaultStartupBootstrapPrompt({ agentType = "" } = {}) {
29
+ /**
30
+ * Load the team activity timeline for prompt injection.
31
+ * The daemon syncs manual inputs every ~30s; bus messages are appended in real-time.
32
+ * Agent startup only reads — no build triggered here.
33
+ */
34
+ function loadTeamActivityContext(projectRoot) {
35
+ try {
36
+ const { renderTimelineForPrompt } = require("../history/inputTimeline");
37
+ return renderTimelineForPrompt(projectRoot, 20) || "";
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ function buildDefaultStartupBootstrapPrompt({ agentType = "", projectRoot = "" } = {}) {
30
44
  const normalizedAgent = asTrimmedString(agentType).toLowerCase();
31
45
  const displayAgent = normalizedAgent === "claude-code"
32
46
  ? "Claude"
33
47
  : (normalizedAgent === "codex" ? "Codex" : "agent");
34
- return [
48
+
49
+ const segments = [
35
50
  `Session bootstrap for ${displayAgent}.`,
36
51
  "Adopt the following ufoo coordination protocol silently.",
37
52
  "Do not reply to this bootstrap message unless the user explicitly asks about it. After applying it, continue the active task or wait for user input.",
38
53
  SHARED_UFOO_PROTOCOL,
39
- ].join("\n\n");
54
+ ];
55
+
56
+ const root = asTrimmedString(projectRoot) || process.cwd();
57
+ const teamActivity = loadTeamActivityContext(root);
58
+ if (teamActivity) {
59
+ segments.push(teamActivity);
60
+ }
61
+
62
+ return segments.join("\n\n");
40
63
  }
41
64
 
42
65
  function defaultBootstrapFile(projectRoot, agentType = "") {
@@ -78,7 +101,7 @@ function resolveDefaultManualBootstrap({
78
101
  if (hasArg(currentArgs, ["--append-system-prompt", "--system-prompt"])) {
79
102
  return { args: currentArgs, env: {}, mode: "skip" };
80
103
  }
81
- const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent });
104
+ const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent, projectRoot });
82
105
  const prepared = prepareDefaultBootstrapFile({
83
106
  projectRoot,
84
107
  agentType: normalizedAgent,
@@ -97,7 +120,7 @@ function resolveDefaultManualBootstrap({
97
120
  if (currentArgs.length > 0) {
98
121
  return { args: currentArgs, env: {}, mode: "skip" };
99
122
  }
100
- const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent });
123
+ const promptText = buildDefaultStartupBootstrapPrompt({ agentType: normalizedAgent, projectRoot });
101
124
  return {
102
125
  args: currentArgs,
103
126
  env: {
@@ -11,8 +11,7 @@ const {
11
11
  } = require("../code/nativeRunner");
12
12
  const { DEFAULT_ASSISTANT_TIMEOUT_MS } = require("../assistant/constants");
13
13
  const { normalizeAgentTypeAlias } = require("../bus/utils");
14
- const { listProjectRuntimes } = require("../projects/registry");
15
- const { isGlobalControllerProjectRoot } = require("../globalMode");
14
+ const { listProjectRuntimes, isGlobalControllerProjectRoot } = require("../projects");
16
15
 
17
16
  function loadSessionState(projectRoot) {
18
17
  const dir = getUfooPaths(projectRoot).agentDir;
package/src/bus/daemon.js CHANGED
@@ -19,17 +19,21 @@ function isBusyActivityState(value = "") {
19
19
  * Bus Daemon - 监控消息并自动注入命令
20
20
  */
21
21
  class BusDaemon {
22
- constructor(busDir, agentsFile, daemonDir, interval = 2000) {
22
+ constructor(busDir, agentsFile, daemonDir, interval = 2000, projectRoot = "") {
23
23
  this.busDir = busDir;
24
24
  this.agentsFile = agentsFile;
25
25
  this.interval = interval;
26
26
  this.daemonDir = daemonDir;
27
+ this.projectRoot = projectRoot || path.resolve(busDir, "..", "..");
27
28
  this.pidFile = path.join(this.daemonDir, "daemon.pid");
28
29
  this.logFile = path.join(this.daemonDir, "daemon.log");
29
30
  this.countsDir = path.join(this.daemonDir, "counts", `${process.pid}`);
30
31
  this.running = false;
31
32
  this.cleanupCounter = 0;
32
33
  this.cleanupInterval = 5; // 每 5 个周期清理一次
34
+ this.timelineSyncCounter = 0;
35
+ // 每 15 个周期同步一次 manual inputs (~15 × interval, default ~30s)
36
+ this.timelineSyncInterval = 15;
33
37
 
34
38
  this.queueManager = new QueueManager(busDir);
35
39
  this.injector = new Injector(busDir, agentsFile);
@@ -233,6 +237,13 @@ class BusDaemon {
233
237
  this.cleanupCounter = 0;
234
238
  }
235
239
 
240
+ // 定期同步 timeline(manual inputs from session files, ~15 × interval)
241
+ this.timelineSyncCounter++;
242
+ if (this.timelineSyncCounter >= this.timelineSyncInterval) {
243
+ this.syncTimeline();
244
+ this.timelineSyncCounter = 0;
245
+ }
246
+
236
247
  // 检查所有订阅者的队列
237
248
  await this.checkQueues();
238
249
  } catch (err) {
@@ -244,6 +255,20 @@ class BusDaemon {
244
255
  }
245
256
  }
246
257
 
258
+ /**
259
+ * 增量同步 timeline — 捕获 manual inputs(bus 消息已在 send() 时实时追加)
260
+ */
261
+ syncTimeline() {
262
+ try {
263
+ const { buildTimeline } = require("../history/inputTimeline");
264
+ buildTimeline(this.projectRoot);
265
+ } catch (err) {
266
+ if (process.env.UFOO_HISTORY_DEBUG === "1") {
267
+ console.error("[daemon][history] syncTimeline failed:", err.message);
268
+ }
269
+ }
270
+ }
271
+
247
272
  /**
248
273
  * 检查所有队列
249
274
  */
package/src/bus/index.js CHANGED
@@ -328,6 +328,23 @@ class EventBus {
328
328
  `Event sent: event=${eventName} seq=${result.seq} -> ${result.targets.join(", ")}`
329
329
  );
330
330
  }
331
+
332
+ // Real-time timeline append for message events
333
+ if (eventName === "message" && message) {
334
+ try {
335
+ const { appendBusEntry } = require("../history/inputTimeline");
336
+ appendBusEntry(this.projectRoot, {
337
+ seq: result.seq,
338
+ timestamp: new Date().toISOString(),
339
+ publisher,
340
+ target,
341
+ message,
342
+ });
343
+ } catch {
344
+ // non-critical
345
+ }
346
+ }
347
+
331
348
  return result;
332
349
  } catch (err) {
333
350
  logError(err.message);
@@ -364,7 +381,10 @@ class EventBus {
364
381
  console.log();
365
382
 
366
383
  for (const event of pending) {
367
- console.log(` ${colors.yellow}@you${colors.reset} from ${colors.cyan}${event.publisher}${colors.reset}`);
384
+ const publisherMeta = this.busData.agents?.[event.publisher];
385
+ const nick = publisherMeta?.nickname;
386
+ const fromLabel = nick ? `${event.publisher}(${nick})` : event.publisher;
387
+ console.log(` ${colors.yellow}[ufoo]<from:${fromLabel}>${colors.reset}`);
368
388
  console.log(` Type: ${event.type}/${event.event}`);
369
389
  console.log(` Content: ${JSON.stringify(event.data)}`);
370
390
  console.log();
@@ -731,7 +751,7 @@ class EventBus {
731
751
 
732
752
  if (countAfter > countBefore) {
733
753
  await sleep(50);
734
- const daemon = new BusDaemon(this.busDir, this.agentsFile, this.paths.busDaemonDir, 2000);
754
+ const daemon = new BusDaemon(this.busDir, this.agentsFile, this.paths.busDaemonDir, 2000, this.projectRoot);
735
755
  await daemon.injector.inject(target, options.command || "");
736
756
  if (options.shake !== false) {
737
757
  const tty = daemon.injector.readTty(target);
@@ -829,7 +849,7 @@ class EventBus {
829
849
  */
830
850
  async daemon(action, options = {}) {
831
851
  const interval = options.interval || 2000;
832
- const daemon = new BusDaemon(this.busDir, this.agentsFile, this.paths.busDaemonDir, interval);
852
+ const daemon = new BusDaemon(this.busDir, this.agentsFile, this.paths.busDaemonDir, interval, this.projectRoot);
833
853
 
834
854
  switch (action) {
835
855
  case "start":
@@ -6,7 +6,7 @@ const { runGroupCoreCommand } = require("../cli/groupCoreCommands");
6
6
  const { loadConfig: loadProjectConfig, saveConfig: saveProjectConfig, loadGlobalUcodeConfig, saveGlobalUcodeConfig } = require("../config");
7
7
  const { resolveTransport } = require("../code/nativeRunner");
8
8
  const { parseIntervalMs, formatIntervalMs } = require("./cronScheduler");
9
- const { isGlobalControllerProjectRoot, resolveGlobalControllerUfooDir } = require("../globalMode");
9
+ const { isGlobalControllerProjectRoot, resolveGlobalControllerUfooDir } = require("../projects");
10
10
  const { loadPromptProfileRegistry } = require("../group/promptProfiles");
11
11
  const { resolveSoloAgentType } = require("../solo/commands");
12
12
 
package/src/chat/index.js CHANGED
@@ -44,15 +44,18 @@ const { createDaemonCoordinator } = require("./daemonCoordinator");
44
44
  const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
45
45
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
46
46
  const { createDaemonTransport } = require("./daemonTransport");
47
- const { listProjectRuntimes, resolveRuntimeDir } = require("../projects/registry");
48
- const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
49
- const { loadTemplateRegistry } = require("../group/templates");
50
47
  const {
48
+ listProjectRuntimes,
49
+ resolveRuntimeDir,
50
+ canonicalProjectRoot,
51
+ buildProjectId,
51
52
  sortProjectRuntimes,
52
53
  parseTimestampMs,
53
54
  filterVisibleProjectRuntimes,
54
- } = require("./projectRuntimes");
55
- const { isGlobalControllerProjectRoot, resolveGlobalControllerProjectRoot } = require("../globalMode");
55
+ isGlobalControllerProjectRoot,
56
+ resolveGlobalControllerProjectRoot,
57
+ } = require("../projects");
58
+ const { loadTemplateRegistry } = require("../group/templates");
56
59
  const {
57
60
  DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
58
61
  setTransientAgentState: setTransientAgentStateValue,
package/src/cli.js CHANGED
@@ -1327,6 +1327,38 @@ async function runCli(argv) {
1327
1327
  }
1328
1328
  });
1329
1329
 
1330
+ const history = program.command("history").description("Agent input history timeline");
1331
+ history
1332
+ .command("build")
1333
+ .description("Build unified input timeline (incremental by default)")
1334
+ .option("--force", "Full rebuild (ignore watermark)")
1335
+ .action((opts) => {
1336
+ const { buildTimeline } = require("./history/inputTimeline");
1337
+ const result = buildTimeline(process.cwd(), { force: opts.force === true });
1338
+ console.log(`Timeline: ${result.count} total, ${result.newCount} new → ${result.file}`);
1339
+ });
1340
+ history
1341
+ .command("show")
1342
+ .description("Show recent timeline entries")
1343
+ .argument("[limit]", "Number of entries to show", "50")
1344
+ .action((limit) => {
1345
+ const { showTimeline } = require("./history/inputTimeline");
1346
+ showTimeline(process.cwd(), parseInt(limit, 10) || 50);
1347
+ });
1348
+ history
1349
+ .command("prompt")
1350
+ .description("Render timeline as injectable prompt context")
1351
+ .argument("[limit]", "Number of entries to include", "30")
1352
+ .action((limit) => {
1353
+ const { renderTimelineForPrompt } = require("./history/inputTimeline");
1354
+ const text = renderTimelineForPrompt(process.cwd(), parseInt(limit, 10) || 30);
1355
+ if (text) {
1356
+ console.log(text);
1357
+ } else {
1358
+ console.log("No timeline data. Run `ufoo history build` first.");
1359
+ }
1360
+ });
1361
+
1330
1362
  program.addHelpText(
1331
1363
  "after",
1332
1364
  `\nNotes:\n - If 'ufoo' isn't in PATH, run it via ${chalk.cyan(
@@ -1391,6 +1423,7 @@ async function runCli(argv) {
1391
1423
  console.log(" ufoo bus wake <target> [--reason <reason>] [--no-shake]");
1392
1424
  console.log(" ufoo bus <args...> (JS bus implementation)");
1393
1425
  console.log(" ufoo ctx <subcmd> ... (doctor|lint|decisions|sync)");
1426
+ console.log(" ufoo history <build|show|prompt> [limit]");
1394
1427
  console.log("");
1395
1428
  console.log("Notes:");
1396
1429
  console.log(" - For Codex notifications, use ufoo bus alert / ufoo bus listen");
@@ -2099,6 +2132,36 @@ async function runCli(argv) {
2099
2132
  return;
2100
2133
  }
2101
2134
 
2135
+ if (cmd === "history") {
2136
+ const sub = rest[0] || "show";
2137
+ const cwd = process.cwd();
2138
+ const { buildTimeline, showTimeline, renderTimelineForPrompt } = require("./history/inputTimeline");
2139
+
2140
+ if (sub === "build") {
2141
+ const force = rest.includes("--force");
2142
+ const result = buildTimeline(cwd, { force });
2143
+ console.log(`Timeline: ${result.count} total, ${result.newCount} new → ${result.file}`);
2144
+ return;
2145
+ }
2146
+ if (sub === "show") {
2147
+ const limit = parseInt(rest[1], 10) || 50;
2148
+ showTimeline(cwd, limit);
2149
+ return;
2150
+ }
2151
+ if (sub === "prompt") {
2152
+ const limit = parseInt(rest[1], 10) || 30;
2153
+ const text = renderTimelineForPrompt(cwd, limit);
2154
+ if (text) {
2155
+ console.log(text);
2156
+ } else {
2157
+ console.log("No timeline data. Run `ufoo history build` first.");
2158
+ }
2159
+ return;
2160
+ }
2161
+ console.log("Usage: ufoo history [build|show|prompt] [limit]");
2162
+ return;
2163
+ }
2164
+
2102
2165
  help();
2103
2166
  process.exitCode = 1;
2104
2167
  }
@@ -12,7 +12,7 @@ const { generateInstanceId, subscriberToSafeName } = require("../bus/utils");
12
12
  const { createDaemonIpcServer } = require("./ipcServer");
13
13
  const { IPC_REQUEST_TYPES, IPC_RESPONSE_TYPES, BUS_STATUS_PHASES } = require("../shared/eventContract");
14
14
  const { getUfooPaths } = require("../ufoo/paths");
15
- const { upsertProjectRuntime, markProjectStopped } = require("../projects/registry");
15
+ const { upsertProjectRuntime, markProjectStopped } = require("../projects");
16
16
  const { scheduleProviderSessionProbe, resolveSessionFromFile, persistProviderSession, loadProviderSessionCache } = require("./providerSessions");
17
17
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
18
18
  const { createDaemonCronController } = require("./cronOps");
@@ -22,7 +22,7 @@ const { runAssistantTask } = require("../assistant/bridge");
22
22
  const { runPromptWithAssistant } = require("./promptLoop");
23
23
  const { handlePromptRequest } = require("./promptRequest");
24
24
  const { recordAgentReport } = require("./reporting");
25
- const { isGlobalControllerProjectRoot } = require("../globalMode");
25
+ const { isGlobalControllerProjectRoot } = require("../projects");
26
26
  const {
27
27
  assignSoloRoleToExistingAgent,
28
28
  resolveSoloPromptProfile,
@@ -2071,43 +2071,61 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
2071
2071
  }
2072
2072
  log(`agent_ready id=${subscriberId} pid=${agentPid || 0} - resolving session`);
2073
2073
 
2074
- // Try direct file read first if we have agentPid (fast path)
2075
2074
  const parsedAgentPid = Number.parseInt(agentPid, 10);
2076
- if (Number.isFinite(parsedAgentPid) && parsedAgentPid > 0) {
2077
- const agentType = subscriberId.split(":")[0] || "";
2078
- const resolved = resolveSessionFromFile(agentType, {
2079
- pid: parsedAgentPid,
2080
- cwd: projectRoot,
2081
- });
2082
- if (resolved && resolved.sessionId) {
2083
- log(`agent_ready session resolved from file for ${subscriberId}: ${resolved.sessionId}`);
2084
- persistProviderSession(projectRoot, subscriberId, resolved);
2085
- if (providerSessions) {
2086
- providerSessions.set(subscriberId, {
2087
- sessionId: resolved.sessionId,
2088
- source: resolved.source || "",
2089
- updated_at: new Date().toISOString(),
2090
- });
2075
+ const agentType = subscriberId.split(":")[0] || "";
2076
+
2077
+ // In _spawnDirect (host mode), AGENT_READY is sent immediately after
2078
+ // spawn() before Claude has written ~/.claude/sessions/<pid>.json.
2079
+ // Retry with short backoff to handle the race condition.
2080
+ const RETRY_DELAYS_MS = [100, 500, 1000, 2000, 3000];
2081
+
2082
+ const tryResolveSession = (attempt) => {
2083
+ if (Number.isFinite(parsedAgentPid) && parsedAgentPid > 0) {
2084
+ const resolved = resolveSessionFromFile(agentType, {
2085
+ pid: parsedAgentPid,
2086
+ cwd: projectRoot,
2087
+ });
2088
+ if (resolved && resolved.sessionId) {
2089
+ const attemptNote = attempt > 1 ? ` (attempt ${attempt})` : "";
2090
+ log(`agent_ready session resolved from file for ${subscriberId}: ${resolved.sessionId}${attemptNote}`);
2091
+ persistProviderSession(projectRoot, subscriberId, resolved);
2092
+ if (providerSessions) {
2093
+ providerSessions.set(subscriberId, {
2094
+ sessionId: resolved.sessionId,
2095
+ source: resolved.source || "",
2096
+ updated_at: new Date().toISOString(),
2097
+ });
2098
+ }
2099
+ // Cancel the scheduled probe to prevent redundant /ufoo injection
2100
+ const handle = probeHandles.get(subscriberId);
2101
+ if (handle && typeof handle.cancel === "function") {
2102
+ handle.cancel();
2103
+ }
2104
+ probeHandles.delete(subscriberId);
2105
+ return;
2091
2106
  }
2092
- // Cancel the scheduled probe to prevent /ufoo injection
2093
- const handle = probeHandles.get(subscriberId);
2094
- if (handle && typeof handle.cancel === "function") {
2095
- handle.cancel();
2107
+
2108
+ // Session file not ready — retry if attempts remain
2109
+ const delayMs = RETRY_DELAYS_MS[attempt - 1];
2110
+ if (delayMs !== undefined) {
2111
+ setTimeout(() => tryResolveSession(attempt + 1), delayMs);
2112
+ return;
2096
2113
  }
2097
- probeHandles.delete(subscriberId);
2098
- return;
2099
2114
  }
2100
- }
2101
2115
 
2102
- // Fallback: trigger scheduled probe
2103
- const probeHandle = probeHandles.get(subscriberId);
2104
- if (probeHandle && typeof probeHandle.triggerNow === "function") {
2105
- probeHandle.triggerNow().catch((err) => {
2106
- log(`agent_ready probe trigger failed for ${subscriberId}: ${err.message}`);
2107
- });
2108
- } else {
2109
- log(`agent_ready no probe handle found for ${subscriberId}`);
2110
- }
2116
+ // Exhausted retries or no pid — fall back to scheduled probe
2117
+ const probeHandle = probeHandles.get(subscriberId);
2118
+ if (probeHandle && typeof probeHandle.triggerNow === "function") {
2119
+ log(`agent_ready falling back to probe for ${subscriberId}`);
2120
+ probeHandle.triggerNow().catch((err) => {
2121
+ log(`agent_ready probe trigger failed for ${subscriberId}: ${err.message}`);
2122
+ });
2123
+ } else {
2124
+ log(`agent_ready no probe handle found for ${subscriberId}`);
2125
+ }
2126
+ };
2127
+
2128
+ tryResolveSession(1);
2111
2129
  return;
2112
2130
  }
2113
2131
  };
@@ -5,7 +5,7 @@ const {
5
5
  listControllerInboxEntries,
6
6
  consumeControllerInboxEntries,
7
7
  } = require("../report/store");
8
- const { isGlobalControllerProjectRoot } = require("../globalMode");
8
+ const { isGlobalControllerProjectRoot } = require("../projects");
9
9
 
10
10
  function normalizeProjectRoute(route) {
11
11
  if (!route || typeof route !== "object") return null;
@@ -5,7 +5,7 @@ const crypto = require("crypto");
5
5
  const SHARED_UFOO_PROTOCOL = [
6
6
  "ufoo protocol:",
7
7
  "- At session start, sync shared context with `ufoo ctx decisions -l` and `ufoo ctx decisions -n 1`.",
8
- "- If your work produces knowledge with information value, record it before acting via `ufoo ctx decisions new \"Title\"`.",
8
+ "- Record a decision ONLY for important, plan-level knowledge: architectural choices, multi-option trade-off analysis, cross-agent coordination decisions, or plans that affect other agents. Do NOT record routine findings, simple bug fixes, or trivial observations. Use `ufoo ctx decisions new \"Title\"` BEFORE acting.",
9
9
  "- Use `ufoo bus send <target-nickname> \"<message>\"` for agent-to-agent handoffs.",
10
10
  "- If you receive pending bus work, execute it immediately, reply to the sender, then `ufoo bus ack \"$UFOO_SUBSCRIBER_ID\"`.",
11
11
  "- Use `ufoo report` for controller/runtime status updates, not as a substitute for direct handoffs.",
@@ -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
+ };
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,