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.
- package/SKILLS/ufoo/SKILL.md +35 -6
- package/bin/ufoo.js +1 -1
- package/modules/bus/SKILLS/ubus/SKILL.md +6 -4
- package/modules/context/SKILLS/uctx/SKILL.md +2 -1
- package/package.json +1 -1
- package/src/agent/defaultBootstrap.js +28 -5
- package/src/agent/ufooAgent.js +1 -2
- package/src/bus/daemon.js +26 -1
- package/src/bus/index.js +23 -3
- package/src/chat/commandExecutor.js +1 -1
- package/src/chat/index.js +8 -5
- package/src/cli.js +63 -0
- package/src/daemon/index.js +52 -34
- package/src/daemon/promptRequest.js +1 -1
- package/src/group/bootstrap.js +1 -1
- package/src/history/inputTimeline.js +601 -0
- package/src/{globalMode.js → projects/identity.js} +1 -1
- package/src/projects/index.js +11 -0
- package/src/ufoo/paths.js +2 -0
- /package/src/{chat/projectRuntimes.js → projects/runtimes.js} +0 -0
package/SKILLS/ufoo/SKILL.md
CHANGED
|
@@ -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
|
-
**"
|
|
27
|
+
**"Only record decisions that matter beyond this session."**
|
|
28
28
|
|
|
29
|
-
Record a decision
|
|
29
|
+
Record a decision for important, plan-level knowledge that other agents or your future self need. The threshold is HIGH — most tasks do NOT need a decision.
|
|
30
30
|
|
|
31
|
-
- **Always record**: architectural choices,
|
|
32
|
-
- **Also record**:
|
|
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
|
-
- **
|
|
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.
|
|
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/
|
|
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
|
-
|
|
106
|
-
Type:
|
|
107
|
-
Content: {"
|
|
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 "
|
|
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
|
|
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
|
@@ -26,17 +26,40 @@ function hasMetaCommandArgs(args = []) {
|
|
|
26
26
|
return hasArg(args, ["-h", "--help", "-v", "--version"]);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
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: {
|
package/src/agent/ufooAgent.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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("../
|
|
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
|
-
|
|
55
|
-
|
|
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
|
}
|
package/src/daemon/index.js
CHANGED
|
@@ -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
|
|
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("../
|
|
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
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
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("../
|
|
8
|
+
const { isGlobalControllerProjectRoot } = require("../projects");
|
|
9
9
|
|
|
10
10
|
function normalizeProjectRoute(route) {
|
|
11
11
|
if (!route || typeof route !== "object") return null;
|
package/src/group/bootstrap.js
CHANGED
|
@@ -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
|
-
"-
|
|
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("./
|
|
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,
|
|
File without changes
|