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.
- 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 +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/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 +148 -6
- 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/daemon/index.js +12 -3
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +46 -12
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/ufoo/agentsStore.js +44 -0
package/src/agent/launcher.js
CHANGED
|
@@ -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模式 + 非
|
|
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
|
-
// 清理
|
|
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);
|
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) {
|