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.
Files changed (47) hide show
  1. package/README.md +21 -0
  2. package/README.zh-CN.md +21 -0
  3. package/bin/ufoo.js +15 -7
  4. package/modules/AGENTS.template.md +4 -102
  5. package/package.json +3 -2
  6. package/scripts/global-chat-switch-benchmark.js +406 -0
  7. package/src/agent/activityDetector.js +328 -0
  8. package/src/agent/activityStatePublisher.js +67 -0
  9. package/src/agent/activityStateWriter.js +40 -0
  10. package/src/agent/internalRunner.js +13 -0
  11. package/src/agent/launcher.js +47 -7
  12. package/src/agent/notifier.js +73 -4
  13. package/src/agent/ptyRunner.js +81 -34
  14. package/src/agent/ufooAgent.js +192 -6
  15. package/src/bus/message.js +1 -9
  16. package/src/bus/subscriber.js +2 -0
  17. package/src/bus/utils.js +10 -0
  18. package/src/chat/agentBar.js +21 -3
  19. package/src/chat/agentViewController.js +2 -0
  20. package/src/chat/chatLogController.js +28 -5
  21. package/src/chat/commandExecutor.js +127 -3
  22. package/src/chat/commands.js +8 -0
  23. package/src/chat/daemonConnection.js +77 -4
  24. package/src/chat/daemonCoordinator.js +36 -0
  25. package/src/chat/daemonMessageRouter.js +22 -0
  26. package/src/chat/daemonTransport.js +47 -5
  27. package/src/chat/daemonTransportDefaults.js +1 -0
  28. package/src/chat/dashboardKeyController.js +89 -1
  29. package/src/chat/dashboardView.js +312 -93
  30. package/src/chat/index.js +683 -41
  31. package/src/chat/inputHistoryController.js +33 -3
  32. package/src/chat/inputListenerController.js +22 -12
  33. package/src/chat/layout.js +12 -7
  34. package/src/chat/projectCloseController.js +119 -0
  35. package/src/chat/projectRuntimes.js +55 -0
  36. package/src/chat/statusLineController.js +52 -6
  37. package/src/chat/streamTracker.js +6 -0
  38. package/src/chat/transport.js +41 -5
  39. package/src/cli.js +167 -4
  40. package/src/daemon/index.js +54 -5
  41. package/src/daemon/ipcServer.js +6 -1
  42. package/src/daemon/ops.js +245 -35
  43. package/src/daemon/status.js +3 -1
  44. package/src/init/index.js +32 -3
  45. package/src/projects/projectId.js +29 -0
  46. package/src/projects/registry.js +279 -0
  47. package/src/ufoo/agentsStore.js +44 -0
@@ -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 failed = [];
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
- failed.push(evt);
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 (failed.length > 0) {
278
+ if (requeue.length > 0) {
218
279
  try {
219
- const content = failed.map((e) => JSON.stringify(e)).join("\n") + "\n";
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(() => {
@@ -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 = 3;
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, 10000);
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
- void fallbackHeadless(`restart failed: ${err.message || err}`);
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(`Max PTY restarts (${MAX_RESTARTS}) reached, falling back to headless runner`);
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; action=${watchdogAction}`;
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
- if (watchdogAction === "fallback") {
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) {
@@ -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
- agent_type: "", // Not included in active_meta, but not needed
48
- nickname,
49
- last_heartbeat: "",
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,
@@ -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
  */
@@ -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,