u-foo 1.5.0 → 1.7.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/modules/AGENTS.template.md +4 -102
- package/package.json +1 -1
- 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 +110 -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/activate.js +22 -2
- package/src/bus/daemon.js +1 -1
- package/src/bus/inject.js +29 -10
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +34 -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/commandExecutor.js +15 -0
- package/src/chat/daemonConnection.js +45 -7
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +13 -2
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +9 -0
- package/src/chat/dashboardView.js +32 -9
- package/src/chat/index.js +176 -8
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/transport.js +41 -5
- package/src/cli.js +14 -0
- package/src/config.js +1 -0
- package/src/daemon/index.js +63 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +189 -14
- package/src/daemon/status.js +17 -1
- package/src/init/index.js +32 -3
- package/src/terminal/adapterRouter.js +13 -1
- package/src/terminal/adapters/hostAdapter.js +409 -0
- package/src/ufoo/agentsStore.js +44 -0
package/src/agent/launcher.js
CHANGED
|
@@ -8,8 +8,11 @@ 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");
|
|
15
|
+
const { probeHostCapabilities } = require("../terminal/adapters/hostAdapter");
|
|
13
16
|
const PtyWrapper = require("./ptyWrapper");
|
|
14
17
|
const ReadyDetector = require("./readyDetector");
|
|
15
18
|
|
|
@@ -150,6 +153,14 @@ function findPreviousSession(cwd, agentType, tty, tmuxPane) {
|
|
|
150
153
|
function resolveLaunchMode() {
|
|
151
154
|
const explicit = process.env.UFOO_LAUNCH_MODE || "";
|
|
152
155
|
if (explicit) return explicit;
|
|
156
|
+
if (process.env.UFOO_HOST_SESSION_ID) return "host";
|
|
157
|
+
// Deprecated: HORIZON_SESSION_ID fallback (remove after migration)
|
|
158
|
+
if (process.env.HORIZON_SESSION_ID) {
|
|
159
|
+
if (process.env.UFOO_DEBUG) {
|
|
160
|
+
console.error("[launcher] HORIZON_SESSION_ID is deprecated, use UFOO_HOST_SESSION_ID");
|
|
161
|
+
}
|
|
162
|
+
return "host";
|
|
163
|
+
}
|
|
153
164
|
if (process.env.TMUX_PANE) return "tmux";
|
|
154
165
|
return "terminal";
|
|
155
166
|
}
|
|
@@ -160,6 +171,48 @@ function shouldShowLaunchBanner(agentType = "") {
|
|
|
160
171
|
return true;
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
async function resolveHostRegistrationData(launchMode) {
|
|
175
|
+
if (launchMode !== "host") {
|
|
176
|
+
return {
|
|
177
|
+
hostInjectSock: "",
|
|
178
|
+
hostDaemonSock: "",
|
|
179
|
+
hostName: "",
|
|
180
|
+
hostSessionId: "",
|
|
181
|
+
hostCapabilities: null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const hostInjectSock = process.env.UFOO_HOST_INJECT_SOCK
|
|
186
|
+
|| process.env.HORIZON_INJECT_SOCK
|
|
187
|
+
|| "";
|
|
188
|
+
const hostDaemonSock = process.env.UFOO_HOST_DAEMON_SOCK || "";
|
|
189
|
+
const hostName = process.env.UFOO_HOST_NAME || "";
|
|
190
|
+
const hostSessionId = process.env.UFOO_HOST_SESSION_ID
|
|
191
|
+
|| process.env.HORIZON_SESSION_ID
|
|
192
|
+
|| "";
|
|
193
|
+
|
|
194
|
+
let hostCapabilities = null;
|
|
195
|
+
if (hostInjectSock || hostDaemonSock) {
|
|
196
|
+
try {
|
|
197
|
+
hostCapabilities = await probeHostCapabilities({
|
|
198
|
+
injectSock: hostInjectSock,
|
|
199
|
+
daemonSock: hostDaemonSock,
|
|
200
|
+
timeoutMs: 1000,
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
hostCapabilities = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
hostInjectSock,
|
|
209
|
+
hostDaemonSock,
|
|
210
|
+
hostName,
|
|
211
|
+
hostSessionId,
|
|
212
|
+
hostCapabilities,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
163
216
|
/**
|
|
164
217
|
* Agent 启动器
|
|
165
218
|
* 统一处理 agent 启动流程:初始化、daemon 注册、banner、命令执行
|
|
@@ -312,6 +365,13 @@ class AgentLauncher {
|
|
|
312
365
|
const previousSession = shouldReuse
|
|
313
366
|
? findPreviousSession(this.cwd, this.agentType, tty, tmuxPane)
|
|
314
367
|
: null;
|
|
368
|
+
const {
|
|
369
|
+
hostInjectSock,
|
|
370
|
+
hostDaemonSock,
|
|
371
|
+
hostName,
|
|
372
|
+
hostSessionId,
|
|
373
|
+
hostCapabilities,
|
|
374
|
+
} = await resolveHostRegistrationData(launchMode);
|
|
315
375
|
|
|
316
376
|
const req = {
|
|
317
377
|
type: IPC_REQUEST_TYPES.REGISTER_AGENT,
|
|
@@ -321,6 +381,11 @@ class AgentLauncher {
|
|
|
321
381
|
launchMode,
|
|
322
382
|
tmuxPane,
|
|
323
383
|
tty,
|
|
384
|
+
hostInjectSock,
|
|
385
|
+
hostDaemonSock,
|
|
386
|
+
hostName,
|
|
387
|
+
hostSessionId,
|
|
388
|
+
hostCapabilities,
|
|
324
389
|
skipProbe: process.env.UFOO_SKIP_SESSION_PROBE === "1",
|
|
325
390
|
// 传递旧 session 信息用于复用(仅 terminal/tmux 模式)
|
|
326
391
|
reuseSession: previousSession ? {
|
|
@@ -488,11 +553,10 @@ class AgentLauncher {
|
|
|
488
553
|
} else if (process.env.UFOO_FORCE_PTY === "1") {
|
|
489
554
|
shouldUsePty = true; // 强制使用PTY (测试/调试)
|
|
490
555
|
} else {
|
|
491
|
-
// 自动检测:Terminal模式 + 非
|
|
556
|
+
// 自动检测:Terminal/tmux模式 + 非internal
|
|
492
557
|
shouldUsePty =
|
|
493
558
|
process.stdin.isTTY &&
|
|
494
559
|
process.stdout.isTTY &&
|
|
495
|
-
!process.env.TMUX && // tmux已有PTY,避免套嵌
|
|
496
560
|
!process.env.UFOO_INTERNAL_AGENT; // internal有专用runner(当前阶段)
|
|
497
561
|
}
|
|
498
562
|
|
|
@@ -515,13 +579,32 @@ class AgentLauncher {
|
|
|
515
579
|
|
|
516
580
|
// 启用Ready检测(监控agent初始化状态)
|
|
517
581
|
const readyDetector = new ReadyDetector(this.agentType);
|
|
582
|
+
// 启用ActivityDetector(持续活动状态监控)
|
|
583
|
+
const launcherActivityDetector = new ActivityDetector(this.agentType, {
|
|
584
|
+
mode: resolveLaunchMode(),
|
|
585
|
+
startOnOutput: true,
|
|
586
|
+
});
|
|
587
|
+
const launcherPublisher = createActivityStatePublisher({
|
|
588
|
+
agentsFile: getUfooPaths(this.cwd).agentsFile,
|
|
589
|
+
subscriber: subscriberId,
|
|
590
|
+
projectRoot: this.cwd,
|
|
591
|
+
});
|
|
592
|
+
const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
|
|
593
|
+
launcherActivityDetector.onChange((newState, oldState) => {
|
|
594
|
+
const snap = launcherActivityDetector.getState();
|
|
595
|
+
launcherPublisher.publish(newState, {
|
|
596
|
+
since: snap.since,
|
|
597
|
+
previous: oldState,
|
|
598
|
+
detail: snap.detail,
|
|
599
|
+
});
|
|
600
|
+
});
|
|
518
601
|
wrapper.enableMonitoring((data) => {
|
|
519
602
|
readyDetector.processOutput(data);
|
|
603
|
+
const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
604
|
+
launcherActivityDetector.processOutput(text);
|
|
520
605
|
});
|
|
521
|
-
|
|
522
|
-
// 当检测到agent ready时,通知daemon可以提前inject probe
|
|
523
|
-
const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
|
|
524
606
|
readyDetector.onReady(async () => {
|
|
607
|
+
launcherActivityDetector.markReady();
|
|
525
608
|
// Claude Code's Ink TUI renders ❯ prompt before the input handler
|
|
526
609
|
// is fully mounted. Wait a short period for the TUI to be ready to
|
|
527
610
|
// accept injected text, otherwise only the trailing CR is processed
|
|
@@ -563,8 +646,9 @@ class AgentLauncher {
|
|
|
563
646
|
|
|
564
647
|
// 设置退出回调(复用清理逻辑)
|
|
565
648
|
wrapper.onExit = async ({ exitCode, signal }) => {
|
|
566
|
-
// 清理
|
|
649
|
+
// 清理 timers
|
|
567
650
|
clearTimeout(forceReadyTimer);
|
|
651
|
+
launcherActivityDetector.destroy();
|
|
568
652
|
|
|
569
653
|
// 清理 bus 状态
|
|
570
654
|
try {
|
|
@@ -613,7 +697,7 @@ class AgentLauncher {
|
|
|
613
697
|
const originalMonitor = wrapper.monitor;
|
|
614
698
|
wrapper.monitor = {
|
|
615
699
|
onOutput: (data) => {
|
|
616
|
-
// Call original monitor (ReadyDetector)
|
|
700
|
+
// Call original monitor (ReadyDetector + ActivityDetector)
|
|
617
701
|
if (originalMonitor && originalMonitor.onOutput) {
|
|
618
702
|
originalMonitor.onOutput(data);
|
|
619
703
|
}
|
|
@@ -755,6 +839,25 @@ class AgentLauncher {
|
|
|
755
839
|
await originalOnExit(exitInfo);
|
|
756
840
|
}
|
|
757
841
|
};
|
|
842
|
+
|
|
843
|
+
// Handle external SIGTERM/SIGINT (e.g. daemon closeAgent)
|
|
844
|
+
// Without this, the PTY child may survive as an orphan process
|
|
845
|
+
// and the terminal window stays open.
|
|
846
|
+
let termSignalHandled = false;
|
|
847
|
+
const handleTermSignal = (sig) => {
|
|
848
|
+
if (termSignalHandled) return;
|
|
849
|
+
termSignalHandled = true;
|
|
850
|
+
// Save onExit ref before cleanup() nulls it
|
|
851
|
+
const exitHandler = wrapper.onExit;
|
|
852
|
+
wrapper.cleanup();
|
|
853
|
+
if (exitHandler) {
|
|
854
|
+
exitHandler({ exitCode: null, signal: sig });
|
|
855
|
+
} else {
|
|
856
|
+
process.exit(sig === "SIGTERM" ? 143 : 130);
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
process.on("SIGTERM", () => handleTermSignal("SIGTERM"));
|
|
860
|
+
process.on("SIGINT", () => handleTermSignal("SIGINT"));
|
|
758
861
|
} catch (err) {
|
|
759
862
|
console.error(`[PTY] Failed to start, falling back to spawn:`, err.message);
|
|
760
863
|
this._spawnDirect(args, subscriberId);
|
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) {
|