u-foo 1.5.0 → 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.
@@ -8,6 +8,8 @@ const EventBus = require("../bus");
8
8
  const { isAgentPidAlive } = require("../bus/utils");
9
9
  const { showBanner } = require("../utils/banner");
10
10
  const AgentNotifier = require("./notifier");
11
+ const { ActivityDetector } = require("./activityDetector");
12
+ const { createActivityStatePublisher } = require("./activityStatePublisher");
11
13
  const { getUfooPaths } = require("../ufoo/paths");
12
14
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
13
15
  const PtyWrapper = require("./ptyWrapper");
@@ -488,11 +490,10 @@ class AgentLauncher {
488
490
  } else if (process.env.UFOO_FORCE_PTY === "1") {
489
491
  shouldUsePty = true; // 强制使用PTY (测试/调试)
490
492
  } else {
491
- // 自动检测:Terminal模式 + 非tmux + 非internal
493
+ // 自动检测:Terminal/tmux模式 + 非internal
492
494
  shouldUsePty =
493
495
  process.stdin.isTTY &&
494
496
  process.stdout.isTTY &&
495
- !process.env.TMUX && // tmux已有PTY,避免套嵌
496
497
  !process.env.UFOO_INTERNAL_AGENT; // internal有专用runner(当前阶段)
497
498
  }
498
499
 
@@ -515,13 +516,32 @@ class AgentLauncher {
515
516
 
516
517
  // 启用Ready检测(监控agent初始化状态)
517
518
  const readyDetector = new ReadyDetector(this.agentType);
519
+ // 启用ActivityDetector(持续活动状态监控)
520
+ const launcherActivityDetector = new ActivityDetector(this.agentType, {
521
+ mode: resolveLaunchMode(),
522
+ startOnOutput: true,
523
+ });
524
+ const launcherPublisher = createActivityStatePublisher({
525
+ agentsFile: getUfooPaths(this.cwd).agentsFile,
526
+ subscriber: subscriberId,
527
+ projectRoot: this.cwd,
528
+ });
529
+ const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
530
+ launcherActivityDetector.onChange((newState, oldState) => {
531
+ const snap = launcherActivityDetector.getState();
532
+ launcherPublisher.publish(newState, {
533
+ since: snap.since,
534
+ previous: oldState,
535
+ detail: snap.detail,
536
+ });
537
+ });
518
538
  wrapper.enableMonitoring((data) => {
519
539
  readyDetector.processOutput(data);
540
+ const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
541
+ launcherActivityDetector.processOutput(text);
520
542
  });
521
-
522
- // 当检测到agent ready时,通知daemon可以提前inject probe
523
- const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
524
543
  readyDetector.onReady(async () => {
544
+ launcherActivityDetector.markReady();
525
545
  // Claude Code's Ink TUI renders ❯ prompt before the input handler
526
546
  // is fully mounted. Wait a short period for the TUI to be ready to
527
547
  // accept injected text, otherwise only the trailing CR is processed
@@ -563,8 +583,9 @@ class AgentLauncher {
563
583
 
564
584
  // 设置退出回调(复用清理逻辑)
565
585
  wrapper.onExit = async ({ exitCode, signal }) => {
566
- // 清理forceReady timer
586
+ // 清理 timers
567
587
  clearTimeout(forceReadyTimer);
588
+ launcherActivityDetector.destroy();
568
589
 
569
590
  // 清理 bus 状态
570
591
  try {
@@ -613,7 +634,7 @@ class AgentLauncher {
613
634
  const originalMonitor = wrapper.monitor;
614
635
  wrapper.monitor = {
615
636
  onOutput: (data) => {
616
- // Call original monitor (ReadyDetector)
637
+ // Call original monitor (ReadyDetector + ActivityDetector)
617
638
  if (originalMonitor && originalMonitor.onOutput) {
618
639
  originalMonitor.onOutput(data);
619
640
  }
@@ -755,6 +776,25 @@ class AgentLauncher {
755
776
  await originalOnExit(exitInfo);
756
777
  }
757
778
  };
779
+
780
+ // Handle external SIGTERM/SIGINT (e.g. daemon closeAgent)
781
+ // Without this, the PTY child may survive as an orphan process
782
+ // and the terminal window stays open.
783
+ let termSignalHandled = false;
784
+ const handleTermSignal = (sig) => {
785
+ if (termSignalHandled) return;
786
+ termSignalHandled = true;
787
+ // Save onExit ref before cleanup() nulls it
788
+ const exitHandler = wrapper.onExit;
789
+ wrapper.cleanup();
790
+ if (exitHandler) {
791
+ exitHandler({ exitCode: null, signal: sig });
792
+ } else {
793
+ process.exit(sig === "SIGTERM" ? 143 : 130);
794
+ }
795
+ };
796
+ process.on("SIGTERM", () => handleTermSignal("SIGTERM"));
797
+ process.on("SIGINT", () => handleTermSignal("SIGINT"));
758
798
  } catch (err) {
759
799
  console.error(`[PTY] Failed to start, falling back to spawn:`, err.message);
760
800
  this._spawnDirect(args, subscriberId);
@@ -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) {