u-foo 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/bin/ufoo.js +15 -7
- package/modules/AGENTS.template.md +4 -102
- package/package.json +3 -2
- package/scripts/global-chat-switch-benchmark.js +406 -0
- package/src/agent/activityDetector.js +328 -0
- package/src/agent/activityStatePublisher.js +67 -0
- package/src/agent/activityStateWriter.js +40 -0
- package/src/agent/internalRunner.js +13 -0
- package/src/agent/launcher.js +47 -7
- package/src/agent/notifier.js +73 -4
- package/src/agent/ptyRunner.js +81 -34
- package/src/agent/ufooAgent.js +192 -6
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +2 -0
- package/src/bus/utils.js +10 -0
- package/src/chat/agentBar.js +21 -3
- package/src/chat/agentViewController.js +2 -0
- package/src/chat/chatLogController.js +28 -5
- package/src/chat/commandExecutor.js +127 -3
- package/src/chat/commands.js +8 -0
- package/src/chat/daemonConnection.js +77 -4
- package/src/chat/daemonCoordinator.js +36 -0
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +47 -5
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +89 -1
- package/src/chat/dashboardView.js +312 -93
- package/src/chat/index.js +683 -41
- package/src/chat/inputHistoryController.js +33 -3
- package/src/chat/inputListenerController.js +22 -12
- package/src/chat/layout.js +12 -7
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/streamTracker.js +6 -0
- package/src/chat/transport.js +41 -5
- package/src/cli.js +167 -4
- package/src/daemon/index.js +54 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +245 -35
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/projects/projectId.js +29 -0
- package/src/projects/registry.js +279 -0
- package/src/ufoo/agentsStore.js +44 -0
package/src/agent/notifier.js
CHANGED
|
@@ -6,6 +6,7 @@ const { getUfooPaths } = require("../ufoo/paths");
|
|
|
6
6
|
const { shakeTerminalByTty } = require("../bus/shake");
|
|
7
7
|
const { isITerm2 } = require("../terminal/detect");
|
|
8
8
|
const iterm2 = require("../terminal/iterm2");
|
|
9
|
+
const { createActivityStatePublisher } = require("./activityStatePublisher");
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Agent 消息通知监听器
|
|
@@ -16,7 +17,11 @@ class AgentNotifier {
|
|
|
16
17
|
this.projectRoot = projectRoot;
|
|
17
18
|
this.subscriber = subscriber;
|
|
18
19
|
this.interval = 2000; // 2秒轮询一次
|
|
20
|
+
this.workingHoldMs = Number.parseInt(process.env.UFOO_ACTIVITY_WORKING_HOLD_MS || "", 10) || 5000;
|
|
19
21
|
this.lastCount = 0;
|
|
22
|
+
this.lastWorkingAt = 0;
|
|
23
|
+
this.injectFailCount = 0;
|
|
24
|
+
this.maxInjectRetries = 5;
|
|
20
25
|
this.timer = null;
|
|
21
26
|
this.stopped = false;
|
|
22
27
|
this.autoTrigger = process.env.UFOO_AUTO_TRIGGER !== "0"; // 默认启用自动触发
|
|
@@ -37,6 +42,12 @@ class AgentNotifier {
|
|
|
37
42
|
const busDir = paths.busDir;
|
|
38
43
|
this.injector = new Injector(busDir, paths.agentsFile);
|
|
39
44
|
this.eventBus = new EventBus(projectRoot);
|
|
45
|
+
this.activityPublisher = createActivityStatePublisher({
|
|
46
|
+
agentsFile: paths.agentsFile,
|
|
47
|
+
subscriber,
|
|
48
|
+
projectRoot,
|
|
49
|
+
force: false, // notifier is low-priority; don't overwrite working/waiting_input/blocked
|
|
50
|
+
});
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
isUfooCodeSubscriber() {
|
|
@@ -97,6 +108,36 @@ class AgentNotifier {
|
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 更新 activity_state(terminal/tmux agent 基础支持)
|
|
113
|
+
* 基于消息投递推断 WORKING,无 pending 时推断 IDLE
|
|
114
|
+
*/
|
|
115
|
+
updateActivityState(state) {
|
|
116
|
+
return this.activityPublisher.publish(state);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
getCurrentActivityState() {
|
|
120
|
+
try {
|
|
121
|
+
if (!this.agentsFile || !fs.existsSync(this.agentsFile)) return "";
|
|
122
|
+
const data = JSON.parse(fs.readFileSync(this.agentsFile, "utf8"));
|
|
123
|
+
const meta = data.agents && data.agents[this.subscriber];
|
|
124
|
+
return meta && typeof meta.activity_state === "string"
|
|
125
|
+
? String(meta.activity_state).trim().toLowerCase()
|
|
126
|
+
: "";
|
|
127
|
+
} catch {
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
isBusyState(state = "") {
|
|
133
|
+
const value = String(state || "").trim().toLowerCase();
|
|
134
|
+
return value === "working"
|
|
135
|
+
|| value === "starting"
|
|
136
|
+
|| value === "running"
|
|
137
|
+
|| value === "waiting_input"
|
|
138
|
+
|| value === "blocked";
|
|
139
|
+
}
|
|
140
|
+
|
|
100
141
|
/**
|
|
101
142
|
* 获取当前队列中的消息数量
|
|
102
143
|
*/
|
|
@@ -191,14 +232,29 @@ class AgentNotifier {
|
|
|
191
232
|
return 0;
|
|
192
233
|
}
|
|
193
234
|
|
|
235
|
+
const activityState = this.getCurrentActivityState();
|
|
236
|
+
if (this.isBusyState(activityState)) {
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Back off on consecutive inject failures to avoid tight retry loop
|
|
241
|
+
if (this.injectFailCount >= this.maxInjectRetries) {
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
194
245
|
const events = this.drainPending();
|
|
195
246
|
if (events.length === 0) return 0;
|
|
196
|
-
const
|
|
247
|
+
const requeue = [];
|
|
197
248
|
let delivered = 0;
|
|
249
|
+
let consumedOne = false;
|
|
198
250
|
for (const evt of events) {
|
|
199
251
|
if (!evt || evt.event !== "message" || !evt.data || typeof evt.data.message !== "string") {
|
|
200
252
|
continue;
|
|
201
253
|
}
|
|
254
|
+
if (consumedOne) {
|
|
255
|
+
requeue.push(evt);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
202
258
|
const message = String(evt.data.message);
|
|
203
259
|
try {
|
|
204
260
|
// Inject the actual message text into the terminal/tmux agent
|
|
@@ -206,22 +262,30 @@ class AgentNotifier {
|
|
|
206
262
|
// eslint-disable-next-line no-await-in-loop
|
|
207
263
|
await this.injector.inject(this.subscriber, message);
|
|
208
264
|
delivered += 1;
|
|
265
|
+
consumedOne = true;
|
|
266
|
+
this.injectFailCount = 0;
|
|
267
|
+
this.updateActivityState("working");
|
|
209
268
|
// eslint-disable-next-line no-await-in-loop
|
|
210
269
|
await this.emitDelivery(evt, "ok");
|
|
211
270
|
} catch (err) {
|
|
212
|
-
|
|
271
|
+
consumedOne = true;
|
|
272
|
+
this.injectFailCount += 1;
|
|
273
|
+
requeue.push(evt);
|
|
213
274
|
// eslint-disable-next-line no-await-in-loop
|
|
214
275
|
await this.emitDelivery(evt, "error", err.message || "inject failed");
|
|
215
276
|
}
|
|
216
277
|
}
|
|
217
|
-
if (
|
|
278
|
+
if (requeue.length > 0) {
|
|
218
279
|
try {
|
|
219
|
-
const content =
|
|
280
|
+
const content = requeue.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
220
281
|
fs.appendFileSync(this.queueFile, content, "utf8");
|
|
221
282
|
} catch {
|
|
222
283
|
// ignore requeue failures
|
|
223
284
|
}
|
|
224
285
|
}
|
|
286
|
+
if (delivered > 0) {
|
|
287
|
+
this.lastWorkingAt = Date.now();
|
|
288
|
+
}
|
|
225
289
|
return delivered;
|
|
226
290
|
}
|
|
227
291
|
|
|
@@ -261,6 +325,7 @@ class AgentNotifier {
|
|
|
261
325
|
if (this.stopped) return;
|
|
262
326
|
|
|
263
327
|
const currentCount = this.getMessageCount();
|
|
328
|
+
const nowMs = Date.now();
|
|
264
329
|
|
|
265
330
|
// 有新消息
|
|
266
331
|
if (currentCount > this.lastCount) {
|
|
@@ -297,6 +362,9 @@ class AgentNotifier {
|
|
|
297
362
|
}
|
|
298
363
|
|
|
299
364
|
this.lastCount = this.getMessageCount();
|
|
365
|
+
if (!this.lastWorkingAt || nowMs - this.lastWorkingAt >= this.workingHoldMs) {
|
|
366
|
+
this.updateActivityState("idle");
|
|
367
|
+
}
|
|
300
368
|
this.refreshTitle();
|
|
301
369
|
this.updateHeartbeat();
|
|
302
370
|
}
|
|
@@ -311,6 +379,7 @@ class AgentNotifier {
|
|
|
311
379
|
if (this.lastNickname) {
|
|
312
380
|
this.setTitle(this.lastNickname);
|
|
313
381
|
}
|
|
382
|
+
this.updateActivityState("ready");
|
|
314
383
|
|
|
315
384
|
// 启动轮询
|
|
316
385
|
this.timer = setInterval(() => {
|
package/src/agent/ptyRunner.js
CHANGED
|
@@ -4,8 +4,9 @@ const net = require("net");
|
|
|
4
4
|
const { spawnSync } = require("child_process");
|
|
5
5
|
const EventBus = require("../bus");
|
|
6
6
|
const { PTY_SOCKET_MESSAGE_TYPES, PTY_SOCKET_SUBSCRIBE_MODES } = require("../shared/ptySocketContract");
|
|
7
|
-
const { runInternalRunner } = require("./internalRunner");
|
|
8
7
|
const { getUfooPaths } = require("../ufoo/paths");
|
|
8
|
+
const { ActivityDetector } = require("./activityDetector");
|
|
9
|
+
const { createActivityStatePublisher } = require("./activityStatePublisher");
|
|
9
10
|
|
|
10
11
|
function sleep(ms) {
|
|
11
12
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -140,6 +141,10 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
140
141
|
};
|
|
141
142
|
|
|
142
143
|
const eventBus = new EventBus(projectRoot);
|
|
144
|
+
const activityDetector = new ActivityDetector(agentType, {
|
|
145
|
+
mode: "internal-pty",
|
|
146
|
+
});
|
|
147
|
+
const agentsFilePath = getUfooPaths(projectRoot).agentsFile;
|
|
143
148
|
|
|
144
149
|
let running = true;
|
|
145
150
|
let busy = false;
|
|
@@ -156,13 +161,13 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
156
161
|
let suppressEcho = false;
|
|
157
162
|
let echoMarker = "";
|
|
158
163
|
let suppressTimer = null;
|
|
159
|
-
let fallbackInProgress = false;
|
|
160
164
|
let ptyProcess = null;
|
|
161
165
|
let restartCount = 0;
|
|
162
166
|
let lastSpawnTime = 0;
|
|
163
|
-
const MAX_RESTARTS =
|
|
167
|
+
const MAX_RESTARTS = 10;
|
|
164
168
|
const RESTART_STABLE_MS = 30000; // reset counter if process ran > 30s
|
|
165
169
|
const RESTART_DELAY_MS = 2000;
|
|
170
|
+
const RESTART_BACKOFF_CAP_MS = 30000;
|
|
166
171
|
const READY_QUIET_MS = 3000; // TUI is "ready" after 3s of no output
|
|
167
172
|
const messageQueue = [];
|
|
168
173
|
const injectServer = setupInjectServer();
|
|
@@ -171,7 +176,6 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
171
176
|
const idleMs = 30000;
|
|
172
177
|
const watchdogMs = 120000;
|
|
173
178
|
const maxQueue = 200;
|
|
174
|
-
const watchdogAction = String(process.env.UFOO_PTY_WATCHDOG_ACTION || "restart").toLowerCase();
|
|
175
179
|
let sendQueue = Promise.resolve();
|
|
176
180
|
const DROP_LINE_PATTERNS = [
|
|
177
181
|
/__UFOO_DONE_/,
|
|
@@ -503,6 +507,51 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
503
507
|
}
|
|
504
508
|
}
|
|
505
509
|
|
|
510
|
+
// Unified activity state publisher (write + broadcast)
|
|
511
|
+
const activityPublisher = createActivityStatePublisher({
|
|
512
|
+
agentsFile: agentsFilePath,
|
|
513
|
+
subscriber,
|
|
514
|
+
projectRoot,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
function writeActivityState() {
|
|
518
|
+
const snap = activityDetector.getState();
|
|
519
|
+
activityPublisher.publish(snap.state, {
|
|
520
|
+
since: snap.since,
|
|
521
|
+
detail: snap.detail,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
activityDetector.onChange((newState, oldState) => {
|
|
526
|
+
const snap = activityDetector.getState();
|
|
527
|
+
activityPublisher.publish(newState, {
|
|
528
|
+
since: snap.since,
|
|
529
|
+
previous: oldState,
|
|
530
|
+
detail: snap.detail,
|
|
531
|
+
});
|
|
532
|
+
// Quiet-window detector may classify IDLE sooner than stream fallback timer.
|
|
533
|
+
// Release queue only when no explicit marker is being awaited.
|
|
534
|
+
if (newState === "idle" && busy && !currentMarker && !suppressEcho) {
|
|
535
|
+
if (idleTimer) {
|
|
536
|
+
clearTimeout(idleTimer);
|
|
537
|
+
idleTimer = null;
|
|
538
|
+
}
|
|
539
|
+
if (watchdogTimer) {
|
|
540
|
+
clearTimeout(watchdogTimer);
|
|
541
|
+
watchdogTimer = null;
|
|
542
|
+
}
|
|
543
|
+
if (currentPublisher) {
|
|
544
|
+
enqueueSend(currentPublisher, JSON.stringify({ stream: true, done: true, reason: "idle" }));
|
|
545
|
+
}
|
|
546
|
+
busy = false;
|
|
547
|
+
currentPublisher = "";
|
|
548
|
+
processQueue();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
// Ensure daemon/dashboard can read initial state immediately after runner boots,
|
|
552
|
+
// instead of waiting for the next 30s heartbeat tick.
|
|
553
|
+
writeActivityState();
|
|
554
|
+
|
|
506
555
|
function attachPty(proc) {
|
|
507
556
|
proc.onData((data) => {
|
|
508
557
|
const raw = String(data || "");
|
|
@@ -516,6 +565,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
516
565
|
const clean = stripAnsi(raw).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
517
566
|
if (!clean) return;
|
|
518
567
|
outputBuffer += clean;
|
|
568
|
+
activityDetector.processOutput(clean);
|
|
519
569
|
if (suppressEcho) {
|
|
520
570
|
if (echoMarker && outputBuffer.includes(echoMarker)) {
|
|
521
571
|
const idx = outputBuffer.indexOf(echoMarker);
|
|
@@ -545,6 +595,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
545
595
|
}
|
|
546
596
|
currentMarker = "";
|
|
547
597
|
busy = false;
|
|
598
|
+
activityDetector.markIdle();
|
|
548
599
|
currentPublisher = "";
|
|
549
600
|
if (watchdogTimer) {
|
|
550
601
|
clearTimeout(watchdogTimer);
|
|
@@ -567,6 +618,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
567
618
|
readyTimer = null;
|
|
568
619
|
if (!ptyReady) {
|
|
569
620
|
ptyReady = true;
|
|
621
|
+
activityDetector.markReady();
|
|
570
622
|
// Discard TUI startup noise accumulated before ready
|
|
571
623
|
outputBuffer = "";
|
|
572
624
|
pendingOutput = [];
|
|
@@ -583,6 +635,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
583
635
|
enqueueSend(currentPublisher, JSON.stringify({ stream: true, done: true, reason: "idle" }));
|
|
584
636
|
}
|
|
585
637
|
busy = false;
|
|
638
|
+
activityDetector.markIdle();
|
|
586
639
|
currentPublisher = "";
|
|
587
640
|
processQueue();
|
|
588
641
|
}, idleMs);
|
|
@@ -620,6 +673,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
620
673
|
|
|
621
674
|
// Reset busy state
|
|
622
675
|
busy = false;
|
|
676
|
+
activityDetector.markIdle();
|
|
623
677
|
currentPublisher = "";
|
|
624
678
|
currentMarker = "";
|
|
625
679
|
|
|
@@ -634,7 +688,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
634
688
|
restartCount++;
|
|
635
689
|
|
|
636
690
|
if (restartCount <= MAX_RESTARTS) {
|
|
637
|
-
const delay = Math.min(restartCount * RESTART_DELAY_MS,
|
|
691
|
+
const delay = Math.min(restartCount * RESTART_DELAY_MS, RESTART_BACKOFF_CAP_MS);
|
|
638
692
|
logNote(`Auto-restarting PTY in ${delay}ms (attempt ${restartCount}/${MAX_RESTARTS})`);
|
|
639
693
|
setTimeout(() => {
|
|
640
694
|
if (!running) return;
|
|
@@ -643,12 +697,26 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
643
697
|
processQueue();
|
|
644
698
|
} catch (err) {
|
|
645
699
|
logNote(`Restart failed: ${err.message || err}`);
|
|
646
|
-
|
|
700
|
+
// Keep retrying instead of falling back
|
|
701
|
+
restartCount++;
|
|
702
|
+
if (restartCount <= MAX_RESTARTS) {
|
|
703
|
+
const retryDelay = Math.min(restartCount * RESTART_DELAY_MS, RESTART_BACKOFF_CAP_MS);
|
|
704
|
+
setTimeout(() => {
|
|
705
|
+
if (!running) return;
|
|
706
|
+
try {
|
|
707
|
+
ptyProcess = spawnPtyProcess();
|
|
708
|
+
processQueue();
|
|
709
|
+
} catch {
|
|
710
|
+
logNote(`PTY spawn keeps failing after ${restartCount} attempts. Agent is offline.`);
|
|
711
|
+
}
|
|
712
|
+
}, retryDelay);
|
|
713
|
+
} else {
|
|
714
|
+
logNote(`PTY spawn failed after ${MAX_RESTARTS} attempts. Agent is offline. Fix the issue and re-launch.`);
|
|
715
|
+
}
|
|
647
716
|
}
|
|
648
717
|
}, delay);
|
|
649
718
|
} else {
|
|
650
|
-
logNote(`
|
|
651
|
-
void fallbackHeadless("max PTY restarts exceeded");
|
|
719
|
+
logNote(`PTY crashed ${MAX_RESTARTS} times within ${RESTART_STABLE_MS}ms. Agent is offline. Fix the issue and re-launch.`);
|
|
652
720
|
}
|
|
653
721
|
});
|
|
654
722
|
}
|
|
@@ -691,24 +759,6 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
691
759
|
ptyProcess = spawnPtyProcess();
|
|
692
760
|
}
|
|
693
761
|
|
|
694
|
-
async function fallbackHeadless(reason) {
|
|
695
|
-
if (fallbackInProgress) return;
|
|
696
|
-
fallbackInProgress = true;
|
|
697
|
-
logNote(`Fallback to headless: ${reason}`);
|
|
698
|
-
if (outputBuffer) {
|
|
699
|
-
flushOutput();
|
|
700
|
-
}
|
|
701
|
-
cleanupInjectServer(injectServer);
|
|
702
|
-
try {
|
|
703
|
-
if (ptyProcess) ptyProcess.kill();
|
|
704
|
-
} catch {
|
|
705
|
-
// ignore
|
|
706
|
-
}
|
|
707
|
-
running = false;
|
|
708
|
-
await runInternalRunner({ projectRoot, agentType });
|
|
709
|
-
process.exit(0);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
762
|
const stop = () => {
|
|
713
763
|
running = false;
|
|
714
764
|
cleanupInjectServer(injectServer);
|
|
@@ -732,6 +782,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
732
782
|
const next = messageQueue.shift();
|
|
733
783
|
if (!next) return;
|
|
734
784
|
busy = true;
|
|
785
|
+
activityDetector.markWorking();
|
|
735
786
|
currentPublisher = next.publisher;
|
|
736
787
|
currentMarker = next.marker || "";
|
|
737
788
|
if (suppressTimer) {
|
|
@@ -794,21 +845,16 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
794
845
|
watchdogTimer = setTimeout(() => {
|
|
795
846
|
watchdogTimer = null;
|
|
796
847
|
if (!busy) return;
|
|
797
|
-
const timeoutNote = `[internal-pty] marker timeout;
|
|
848
|
+
const timeoutNote = `[internal-pty] marker timeout; restarting PTY`;
|
|
798
849
|
if (currentPublisher) enqueueSend(currentPublisher, timeoutNote);
|
|
799
850
|
if (currentPublisher) {
|
|
800
851
|
enqueueSend(currentPublisher, JSON.stringify({ stream: true, done: true, reason: "timeout" }));
|
|
801
852
|
}
|
|
802
853
|
logNote(timeoutNote);
|
|
803
|
-
|
|
804
|
-
void fallbackHeadless("marker timeout");
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
if (watchdogAction === "restart") {
|
|
808
|
-
restartPty("marker timeout");
|
|
809
|
-
}
|
|
854
|
+
restartPty("marker timeout");
|
|
810
855
|
currentMarker = "";
|
|
811
856
|
busy = false;
|
|
857
|
+
activityDetector.markIdle();
|
|
812
858
|
currentPublisher = "";
|
|
813
859
|
processQueue();
|
|
814
860
|
}, watchdogMs);
|
|
@@ -828,6 +874,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
|
|
|
828
874
|
} catch {
|
|
829
875
|
// ignore heartbeat errors
|
|
830
876
|
}
|
|
877
|
+
writeActivityState();
|
|
831
878
|
};
|
|
832
879
|
|
|
833
880
|
while (running) {
|
package/src/agent/ufooAgent.js
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
resolveAnthropicMessagesUrl,
|
|
11
11
|
} = require("../code/nativeRunner");
|
|
12
12
|
const { DEFAULT_ASSISTANT_TIMEOUT_MS } = require("../assistant/constants");
|
|
13
|
+
const { normalizeAgentTypeAlias } = require("../bus/utils");
|
|
13
14
|
|
|
14
15
|
function loadSessionState(projectRoot) {
|
|
15
16
|
const dir = getUfooPaths(projectRoot).agentDir;
|
|
@@ -28,13 +29,166 @@ function saveSessionState(projectRoot, state) {
|
|
|
28
29
|
fs.writeFileSync(path.join(dir, "ufoo-agent.json"), JSON.stringify(state, null, 2));
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
function toReportAgentSnapshot(value = {}) {
|
|
33
|
+
const last = value && typeof value.last === "object" ? value.last : null;
|
|
34
|
+
return {
|
|
35
|
+
agent_id: String(value && value.agent_id ? value.agent_id : ""),
|
|
36
|
+
pending_count: Number(value && value.pending_count ? value.pending_count : 0) || 0,
|
|
37
|
+
updated_at: String(value && value.updated_at ? value.updated_at : ""),
|
|
38
|
+
last: last
|
|
39
|
+
? {
|
|
40
|
+
phase: String(last.phase || ""),
|
|
41
|
+
task_id: String(last.task_id || ""),
|
|
42
|
+
ok: last.ok !== false,
|
|
43
|
+
}
|
|
44
|
+
: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isBusyActivityState(value = "") {
|
|
49
|
+
const state = String(value || "").trim().toLowerCase();
|
|
50
|
+
return state === "working" || state === "starting" || state === "running"
|
|
51
|
+
|| state === "waiting_input" || state === "blocked";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clipPromptText(value = "", maxChars = 240) {
|
|
55
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
56
|
+
if (!text) return "";
|
|
57
|
+
if (text.length <= maxChars) return text;
|
|
58
|
+
return `${text.slice(0, maxChars)}...[truncated]`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveHistoryAgentId(rawTarget, activeIdSet, nicknames) {
|
|
62
|
+
const target = String(rawTarget || "").trim();
|
|
63
|
+
if (!target) return "";
|
|
64
|
+
if (target === "*" || target === "broadcast") return "";
|
|
65
|
+
if (activeIdSet.has(target)) return target;
|
|
66
|
+
if (nicknames[target]) return nicknames[target];
|
|
67
|
+
|
|
68
|
+
const targetAlias = normalizeAgentTypeAlias(target);
|
|
69
|
+
if (!targetAlias) return "";
|
|
70
|
+
|
|
71
|
+
const matches = [];
|
|
72
|
+
for (const id of activeIdSet) {
|
|
73
|
+
const prefix = String(id).split(":")[0] || "";
|
|
74
|
+
const alias = normalizeAgentTypeAlias(prefix);
|
|
75
|
+
if (alias === targetAlias) matches.push(id);
|
|
76
|
+
}
|
|
77
|
+
return matches.length === 1 ? matches[0] : "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildAgentPromptHistory(projectRoot, agents = [], nicknames = {}, options = {}) {
|
|
81
|
+
const perAgentLimit = Number.isFinite(options.perAgentLimit) && options.perAgentLimit > 0
|
|
82
|
+
? Math.floor(options.perAgentLimit)
|
|
83
|
+
: 6;
|
|
84
|
+
const maxFiles = Number.isFinite(options.maxFiles) && options.maxFiles > 0
|
|
85
|
+
? Math.floor(options.maxFiles)
|
|
86
|
+
: 3;
|
|
87
|
+
const eventsDir = getUfooPaths(projectRoot).busEventsDir;
|
|
88
|
+
const activeIds = new Set((Array.isArray(agents) ? agents : []).map((item) => String(item.id || "")).filter(Boolean));
|
|
89
|
+
if (activeIds.size === 0) {
|
|
90
|
+
return { per_agent: [], scanned_files: 0, matched_events: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entries = new Map();
|
|
94
|
+
for (const item of agents) {
|
|
95
|
+
if (!item || !item.id) continue;
|
|
96
|
+
entries.set(item.id, {
|
|
97
|
+
agent_id: String(item.id),
|
|
98
|
+
nickname: String(item.nickname || ""),
|
|
99
|
+
samples: [],
|
|
100
|
+
sample_count: 0,
|
|
101
|
+
total_count: 0,
|
|
102
|
+
first_ts: "",
|
|
103
|
+
last_ts: "",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let files = [];
|
|
108
|
+
try {
|
|
109
|
+
files = fs
|
|
110
|
+
.readdirSync(eventsDir)
|
|
111
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
112
|
+
.sort()
|
|
113
|
+
.slice(-maxFiles)
|
|
114
|
+
.reverse();
|
|
115
|
+
} catch {
|
|
116
|
+
return { per_agent: [], scanned_files: 0, matched_events: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let matchedEvents = 0;
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
let lines = [];
|
|
122
|
+
try {
|
|
123
|
+
const raw = fs.readFileSync(path.join(eventsDir, file), "utf8");
|
|
124
|
+
lines = raw.split(/\r?\n/).filter(Boolean).reverse();
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
let evt = null;
|
|
131
|
+
try {
|
|
132
|
+
evt = JSON.parse(line);
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!evt || evt.event !== "message") continue;
|
|
137
|
+
const targetAgentId = resolveHistoryAgentId(evt.target, activeIds, nicknames);
|
|
138
|
+
if (!targetAgentId) continue;
|
|
139
|
+
const prompt = evt.data && typeof evt.data.message === "string"
|
|
140
|
+
? clipPromptText(evt.data.message)
|
|
141
|
+
: "";
|
|
142
|
+
if (!prompt) continue;
|
|
143
|
+
|
|
144
|
+
const row = entries.get(targetAgentId);
|
|
145
|
+
if (!row) continue;
|
|
146
|
+
matchedEvents += 1;
|
|
147
|
+
row.total_count += 1;
|
|
148
|
+
const ts = String(evt.timestamp || evt.ts || "");
|
|
149
|
+
if (!row.last_ts) row.last_ts = ts;
|
|
150
|
+
row.first_ts = ts || row.first_ts;
|
|
151
|
+
if (row.samples.length < perAgentLimit) {
|
|
152
|
+
row.samples.push({
|
|
153
|
+
ts,
|
|
154
|
+
publisher: String(evt.publisher || ""),
|
|
155
|
+
prompt,
|
|
156
|
+
});
|
|
157
|
+
row.sample_count = row.samples.length;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const perAgent = Array.from(entries.values())
|
|
163
|
+
.filter((row) => row.total_count > 0)
|
|
164
|
+
.sort((a, b) => {
|
|
165
|
+
const left = String(a.last_ts || "");
|
|
166
|
+
const right = String(b.last_ts || "");
|
|
167
|
+
return right.localeCompare(left);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
per_agent: perAgent,
|
|
172
|
+
scanned_files: files.length,
|
|
173
|
+
matched_events: matchedEvents,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
31
177
|
function loadBusSummary(projectRoot, maxLines = 20) {
|
|
32
|
-
// Use daemon's buildStatus as the single source of truth
|
|
178
|
+
// Use daemon's buildStatus as the single source of truth.
|
|
33
179
|
let agents = [];
|
|
34
180
|
let nicknames = {};
|
|
181
|
+
let reports = { pending_total: 0, agents: [] };
|
|
182
|
+
let promptHistory = { per_agent: [], scanned_files: 0, matched_events: 0 };
|
|
183
|
+
let summary = {
|
|
184
|
+
active_count: 0,
|
|
185
|
+
busy_count: 0,
|
|
186
|
+
ready_count: 0,
|
|
187
|
+
pending_total: 0,
|
|
188
|
+
};
|
|
35
189
|
try {
|
|
36
190
|
const status = buildStatus(projectRoot);
|
|
37
|
-
const activeMeta = status.active_meta
|
|
191
|
+
const activeMeta = Array.isArray(status && status.active_meta) ? status.active_meta : [];
|
|
38
192
|
agents = activeMeta.map((item) => {
|
|
39
193
|
const nickname = item.nickname || "";
|
|
40
194
|
if (nickname) {
|
|
@@ -42,16 +196,45 @@ function loadBusSummary(projectRoot, maxLines = 20) {
|
|
|
42
196
|
}
|
|
43
197
|
return {
|
|
44
198
|
id: item.id,
|
|
199
|
+
nickname,
|
|
45
200
|
status: "active",
|
|
46
201
|
online: true,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
202
|
+
launch_mode: String(item.launch_mode || ""),
|
|
203
|
+
activity_state: String(item.activity_state || ""),
|
|
204
|
+
activity_since: String(item.activity_since || ""),
|
|
50
205
|
};
|
|
51
206
|
});
|
|
207
|
+
|
|
208
|
+
const reportState = status && status.reports && typeof status.reports === "object"
|
|
209
|
+
? status.reports
|
|
210
|
+
: {};
|
|
211
|
+
const reportAgents = Array.isArray(reportState.agents)
|
|
212
|
+
? reportState.agents.slice(0, 50).map((item) => toReportAgentSnapshot(item))
|
|
213
|
+
: [];
|
|
214
|
+
reports = {
|
|
215
|
+
pending_total: Number(reportState.pending_total || 0) || 0,
|
|
216
|
+
agents: reportAgents,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const busyCount = agents.filter((item) => isBusyActivityState(item.activity_state)).length;
|
|
220
|
+
summary = {
|
|
221
|
+
active_count: agents.length,
|
|
222
|
+
busy_count: busyCount,
|
|
223
|
+
ready_count: Math.max(agents.length - busyCount, 0),
|
|
224
|
+
pending_total: reports.pending_total,
|
|
225
|
+
};
|
|
226
|
+
promptHistory = buildAgentPromptHistory(projectRoot, agents, nicknames);
|
|
52
227
|
} catch {
|
|
53
228
|
agents = [];
|
|
54
229
|
nicknames = {};
|
|
230
|
+
reports = { pending_total: 0, agents: [] };
|
|
231
|
+
promptHistory = { per_agent: [], scanned_files: 0, matched_events: 0 };
|
|
232
|
+
summary = {
|
|
233
|
+
active_count: 0,
|
|
234
|
+
busy_count: 0,
|
|
235
|
+
ready_count: 0,
|
|
236
|
+
pending_total: 0,
|
|
237
|
+
};
|
|
55
238
|
}
|
|
56
239
|
|
|
57
240
|
const eventsDir = getUfooPaths(projectRoot).busEventsDir;
|
|
@@ -74,7 +257,7 @@ function loadBusSummary(projectRoot, maxLines = 20) {
|
|
|
74
257
|
recent = [];
|
|
75
258
|
}
|
|
76
259
|
|
|
77
|
-
return { agents, nicknames, recent };
|
|
260
|
+
return { agents, nicknames, reports, agent_prompt_history: promptHistory, summary, recent };
|
|
78
261
|
}
|
|
79
262
|
|
|
80
263
|
function buildSystemPrompt(context) {
|
|
@@ -106,6 +289,9 @@ function buildSystemPrompt(context) {
|
|
|
106
289
|
"- Use top-level assistant_call for project exploration, temporary shell tasks, and quick execution support.",
|
|
107
290
|
"- assistant_call fields: kind (explore|bash|mixed), task (required), context/expect (optional), provider (codex|claude|ufoo, optional), model/timeout_ms (optional).",
|
|
108
291
|
"- Prefer assistant_call over launching coding agents when the task is short-lived.",
|
|
292
|
+
"- Primary routing signal is semantic continuity from agent_prompt_history; prefer the agent that already handled similar prompts.",
|
|
293
|
+
"- Launch a new coding agent when the request is a new topic without clear ownership in existing histories.",
|
|
294
|
+
"- If best-matching target agent is busy, keep routing to that same agent (queue semantics) instead of rerouting only by idle status.",
|
|
109
295
|
"- Legacy compatibility: if model emits ops.assistant_call, daemon will still process it.",
|
|
110
296
|
"- If no action needed, return reply with empty dispatch/ops.",
|
|
111
297
|
agentGuidance,
|
package/src/bus/message.js
CHANGED
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
appendJSONL,
|
|
8
8
|
readLastLine,
|
|
9
9
|
isPidAlive,
|
|
10
|
+
normalizeAgentTypeAlias,
|
|
10
11
|
} = require("./utils");
|
|
11
12
|
const NicknameManager = require("./nickname");
|
|
12
13
|
|
|
@@ -14,15 +15,6 @@ const SEQ_LOCK_TIMEOUT_MS = 5000;
|
|
|
14
15
|
const SEQ_LOCK_POLL_MS = 25;
|
|
15
16
|
const SEQ_LOCK_STALE_MS = 30000;
|
|
16
17
|
|
|
17
|
-
function normalizeAgentTypeAlias(value = "") {
|
|
18
|
-
const text = String(value || "").trim().toLowerCase();
|
|
19
|
-
if (!text) return "";
|
|
20
|
-
if (text === "codex") return "codex";
|
|
21
|
-
if (text === "claude" || text === "claude-code") return "claude-code";
|
|
22
|
-
if (text === "ufoo" || text === "ucode" || text === "ufoo-code") return "ufoo-code";
|
|
23
|
-
return text;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
18
|
/**
|
|
27
19
|
* 消息管理器
|
|
28
20
|
*/
|
package/src/bus/subscriber.js
CHANGED
|
@@ -202,6 +202,8 @@ class SubscriberManager {
|
|
|
202
202
|
agent_type: agentType,
|
|
203
203
|
nickname: finalNickname,
|
|
204
204
|
status: "active",
|
|
205
|
+
activity_state: "starting",
|
|
206
|
+
activity_since: getTimestamp(),
|
|
205
207
|
joined_at: existingMeta?.joined_at || getTimestamp(),
|
|
206
208
|
last_seen: getTimestamp(),
|
|
207
209
|
pid: overridePid || getJoinedPid(),
|
package/src/bus/utils.js
CHANGED
|
@@ -331,7 +331,17 @@ function isMetaActive(meta) {
|
|
|
331
331
|
return false;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
function normalizeAgentTypeAlias(value = "") {
|
|
335
|
+
const text = String(value || "").trim().toLowerCase();
|
|
336
|
+
if (!text) return "";
|
|
337
|
+
if (text === "codex") return "codex";
|
|
338
|
+
if (text === "claude" || text === "claude-code") return "claude-code";
|
|
339
|
+
if (text === "ufoo" || text === "ucode" || text === "ufoo-code") return "ufoo-code";
|
|
340
|
+
return text;
|
|
341
|
+
}
|
|
342
|
+
|
|
334
343
|
module.exports = {
|
|
344
|
+
normalizeAgentTypeAlias,
|
|
335
345
|
getTimestamp,
|
|
336
346
|
getDate,
|
|
337
347
|
generateInstanceId,
|