u-foo 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/bin/ufoo.js +15 -7
- package/modules/AGENTS.template.md +4 -102
- package/package.json +3 -2
- package/scripts/global-chat-switch-benchmark.js +406 -0
- package/src/agent/activityDetector.js +328 -0
- package/src/agent/activityStatePublisher.js +67 -0
- package/src/agent/activityStateWriter.js +40 -0
- package/src/agent/internalRunner.js +13 -0
- package/src/agent/launcher.js +47 -7
- package/src/agent/notifier.js +73 -4
- package/src/agent/ptyRunner.js +81 -34
- package/src/agent/ufooAgent.js +192 -6
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +2 -0
- package/src/bus/utils.js +10 -0
- package/src/chat/agentBar.js +21 -3
- package/src/chat/agentViewController.js +2 -0
- package/src/chat/chatLogController.js +28 -5
- package/src/chat/commandExecutor.js +127 -3
- package/src/chat/commands.js +8 -0
- package/src/chat/daemonConnection.js +77 -4
- package/src/chat/daemonCoordinator.js +36 -0
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +47 -5
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +89 -1
- package/src/chat/dashboardView.js +312 -93
- package/src/chat/index.js +683 -41
- package/src/chat/inputHistoryController.js +33 -3
- package/src/chat/inputListenerController.js +22 -12
- package/src/chat/layout.js +12 -7
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/streamTracker.js +6 -0
- package/src/chat/transport.js +41 -5
- package/src/cli.js +167 -4
- package/src/daemon/index.js +54 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +245 -35
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/projects/projectId.js +29 -0
- package/src/projects/registry.js +279 -0
- package/src/ufoo/agentsStore.js +44 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Activity State Detector
|
|
3
|
+
* 持续监控 agent 启动后的活动状态(readyDetector 的补充)
|
|
4
|
+
*
|
|
5
|
+
* State machine:
|
|
6
|
+
* STARTING → READY → WORKING ↔ IDLE
|
|
7
|
+
* ↓ ↑
|
|
8
|
+
* WAITING_INPUT --+
|
|
9
|
+
* ↓ ↑
|
|
10
|
+
* BLOCKED ----+
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const ACTIVITY_STATES = {
|
|
14
|
+
starting: "starting",
|
|
15
|
+
ready: "ready",
|
|
16
|
+
working: "working",
|
|
17
|
+
idle: "idle",
|
|
18
|
+
waiting_input: "waiting_input",
|
|
19
|
+
blocked: "blocked",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_BUFFER_SIZE = 4000;
|
|
23
|
+
const DEFAULT_TAIL_LINES = 10;
|
|
24
|
+
const DEFAULT_BLOCKED_TIMEOUT_MS = 300000;
|
|
25
|
+
const DEFAULT_INTERNAL_QUIET_MS = 3500;
|
|
26
|
+
const DEFAULT_EXTERNAL_QUIET_MS = 5000;
|
|
27
|
+
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
28
|
+
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
29
|
+
|
|
30
|
+
// Agent-specific patterns that indicate the agent is waiting for user input.
|
|
31
|
+
// Patterns are anchored to prompt/question contexts to reduce false positives.
|
|
32
|
+
const INPUT_PATTERNS = {
|
|
33
|
+
"claude-code": [
|
|
34
|
+
/\bAllow\b.*\bDeny\b/, // Claude Code permission dialog: "Allow | Deny"
|
|
35
|
+
/\ballow mcp\b/i, // MCP tool approval prompt
|
|
36
|
+
/Enter to select.*\u2191\/\u2193 to navigate/, // Ink TUI interactive prompt navigation bar (permissions, AskUserQuestion, Plan approval)
|
|
37
|
+
],
|
|
38
|
+
codex: [
|
|
39
|
+
/\[Y\/n\]/, // Bracket-style prompt
|
|
40
|
+
/\by\/n\b/i, // y/n prompt (common in confirmation dialogs)
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const COMMON_PATTERNS = [
|
|
45
|
+
/Continue\?\s*$/m, // Line-ending "Continue?"
|
|
46
|
+
/Proceed\?\s*$/m, // Line-ending "Proceed?"
|
|
47
|
+
/Press enter/i, // "Press enter to continue"
|
|
48
|
+
/\(y\/n\)\s*:?\s*$/m, // "(y/n)" at line end
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Deny-list for per-line context around a matched prompt.
|
|
52
|
+
// Prevents false positives from code output while avoiding global-buffer suppression.
|
|
53
|
+
const LINE_DENY_CONTEXT_PATTERNS = [
|
|
54
|
+
/function\s+\w+/, // Function definition context
|
|
55
|
+
/\/\//, // Code comment
|
|
56
|
+
/import\s+/, // Import statement
|
|
57
|
+
/require\s*\(/, // Require statement
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function toPositiveInt(value, fallback) {
|
|
61
|
+
const parsed = Number.parseInt(String(value || ""), 10);
|
|
62
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getDefaultQuietWindowMs(mode) {
|
|
67
|
+
const normalized = String(mode || "").trim().toLowerCase();
|
|
68
|
+
if (normalized.includes("internal")) return DEFAULT_INTERNAL_QUIET_MS;
|
|
69
|
+
return DEFAULT_EXTERNAL_QUIET_MS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class ActivityDetector {
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} agentType - e.g. "claude-code", "codex"
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {string} [options.mode] - launch mode ("internal-pty"/"terminal"/"tmux"/"iterm2")
|
|
77
|
+
* @param {number} [options.bufferSize=4000] - rolling buffer size in chars
|
|
78
|
+
* @param {number} [options.tailLines=10] - number of tail lines used for quiet-time prompt detection
|
|
79
|
+
* @param {boolean} [options.startOnOutput=false] - allow STARTING -> WORKING on first output
|
|
80
|
+
* @param {number} [options.quietWindowMs] - output quiet window before WAITING_INPUT/IDLE classification
|
|
81
|
+
* @param {number} [options.blockedTimeoutMs=300000] - 5 min WAITING_INPUT → BLOCKED
|
|
82
|
+
*/
|
|
83
|
+
constructor(agentType, options = {}) {
|
|
84
|
+
this.agentType = agentType;
|
|
85
|
+
this.mode = String(options.mode || "").trim().toLowerCase();
|
|
86
|
+
this.bufferSize = toPositiveInt(options.bufferSize, DEFAULT_BUFFER_SIZE);
|
|
87
|
+
this.tailLines = toPositiveInt(options.tailLines, DEFAULT_TAIL_LINES);
|
|
88
|
+
this.startOnOutput = options.startOnOutput === true;
|
|
89
|
+
this.blockedTimeoutMs = toPositiveInt(options.blockedTimeoutMs, DEFAULT_BLOCKED_TIMEOUT_MS);
|
|
90
|
+
const optionQuietMs = toPositiveInt(options.quietWindowMs, 0);
|
|
91
|
+
const envQuietMs = toPositiveInt(process.env.UFOO_ACTIVITY_QUIET_MS, 0);
|
|
92
|
+
this.quietWindowMs = optionQuietMs || envQuietMs || getDefaultQuietWindowMs(this.mode);
|
|
93
|
+
|
|
94
|
+
this.state = ACTIVITY_STATES.starting;
|
|
95
|
+
this.since = Date.now();
|
|
96
|
+
this.detail = "";
|
|
97
|
+
this.buffer = "";
|
|
98
|
+
this.callbacks = [];
|
|
99
|
+
this.blockedTimer = null;
|
|
100
|
+
this.quietTimer = null;
|
|
101
|
+
this.quietToken = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Register a state-change callback: fn(newState, oldState, detail)
|
|
106
|
+
*/
|
|
107
|
+
onChange(callback) {
|
|
108
|
+
this.callbacks.push(callback);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Transition to a new state (internal)
|
|
113
|
+
*/
|
|
114
|
+
_setState(newState, detail = "") {
|
|
115
|
+
if (newState === this.state) return;
|
|
116
|
+
const oldState = this.state;
|
|
117
|
+
this.state = newState;
|
|
118
|
+
this.since = Date.now();
|
|
119
|
+
this.detail = detail;
|
|
120
|
+
for (const cb of this.callbacks) {
|
|
121
|
+
try {
|
|
122
|
+
cb(newState, oldState, detail);
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore callback errors
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* STARTING → READY
|
|
131
|
+
*/
|
|
132
|
+
markReady() {
|
|
133
|
+
if (this.state !== ACTIVITY_STATES.starting) return;
|
|
134
|
+
this._setState(ACTIVITY_STATES.ready);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* any → WORKING
|
|
139
|
+
* - clears WAITING/BLOCKED timers
|
|
140
|
+
* - cancels pending quiet classification
|
|
141
|
+
* - resets buffer by default when coming from non-WORKING states
|
|
142
|
+
*/
|
|
143
|
+
markWorking(options = {}) {
|
|
144
|
+
const hasResetFlag = Object.prototype.hasOwnProperty.call(options, "resetBuffer");
|
|
145
|
+
const resetBuffer = hasResetFlag ? Boolean(options.resetBuffer) : this.state !== ACTIVITY_STATES.working;
|
|
146
|
+
this._clearBlockedTimer();
|
|
147
|
+
this._clearQuietTimer();
|
|
148
|
+
if (resetBuffer) {
|
|
149
|
+
this.buffer = "";
|
|
150
|
+
}
|
|
151
|
+
this._setState(ACTIVITY_STATES.working);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* WORKING/WAITING_INPUT/BLOCKED → IDLE
|
|
156
|
+
* Allows recovery from stuck states when queue drains, marker hits, etc.
|
|
157
|
+
*/
|
|
158
|
+
markIdle() {
|
|
159
|
+
if (this.state !== ACTIVITY_STATES.working
|
|
160
|
+
&& this.state !== ACTIVITY_STATES.waiting_input
|
|
161
|
+
&& this.state !== ACTIVITY_STATES.blocked) return;
|
|
162
|
+
this._clearBlockedTimer();
|
|
163
|
+
this._clearQuietTimer();
|
|
164
|
+
this.buffer = "";
|
|
165
|
+
this._setState(ACTIVITY_STATES.idle);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Process PTY output.
|
|
170
|
+
* Any output (except STARTING) implies WORKING.
|
|
171
|
+
* WAITING_INPUT/IDLE are only classified after quiet window.
|
|
172
|
+
* @param {string} text - cleaned (ANSI-stripped) output text
|
|
173
|
+
*/
|
|
174
|
+
processOutput(text) {
|
|
175
|
+
const normalized = this._normalizeOutput(text);
|
|
176
|
+
if (!normalized) return;
|
|
177
|
+
if (!this._hasMeaningfulOutput(normalized)) return;
|
|
178
|
+
if (this.state === ACTIVITY_STATES.starting) {
|
|
179
|
+
if (!this.startOnOutput) return;
|
|
180
|
+
this._setState(ACTIVITY_STATES.working, "output");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.buffer += normalized;
|
|
184
|
+
// Rolling buffer: keep last N chars
|
|
185
|
+
if (this.buffer.length > this.bufferSize) {
|
|
186
|
+
this.buffer = this.buffer.slice(-this.bufferSize);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (this.state !== ACTIVITY_STATES.working) {
|
|
190
|
+
this._clearBlockedTimer();
|
|
191
|
+
this._setState(ACTIVITY_STATES.working);
|
|
192
|
+
}
|
|
193
|
+
this._scheduleQuietClassification();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_normalizeOutput(text) {
|
|
197
|
+
if (!text) return "";
|
|
198
|
+
return String(text)
|
|
199
|
+
.replace(OSC_PATTERN, "")
|
|
200
|
+
.replace(/\r\n/g, "\n")
|
|
201
|
+
.replace(/\r/g, "\n")
|
|
202
|
+
.replace(ANSI_PATTERN, "");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_hasMeaningfulOutput(text) {
|
|
206
|
+
if (!text) return false;
|
|
207
|
+
const visible = String(text).replace(/[\s\u0000-\u001F\u007F]+/g, "");
|
|
208
|
+
return visible.length > 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_scheduleQuietClassification() {
|
|
212
|
+
this.quietToken += 1;
|
|
213
|
+
const quietToken = this.quietToken;
|
|
214
|
+
this._clearQuietTimer();
|
|
215
|
+
this.quietTimer = setTimeout(() => {
|
|
216
|
+
if (quietToken !== this.quietToken) return;
|
|
217
|
+
this.quietTimer = null;
|
|
218
|
+
this._classifyAfterQuietWindow();
|
|
219
|
+
}, this.quietWindowMs);
|
|
220
|
+
if (this.quietTimer && typeof this.quietTimer.unref === "function") {
|
|
221
|
+
this.quietTimer.unref();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
_classifyAfterQuietWindow() {
|
|
226
|
+
if (this.state !== ACTIVITY_STATES.working) return;
|
|
227
|
+
const tailBuffer = this._tailWindow();
|
|
228
|
+
|
|
229
|
+
// Check agent-specific patterns only after output has stabilized.
|
|
230
|
+
const agentPatterns = INPUT_PATTERNS[this.agentType] || [];
|
|
231
|
+
const allPatterns = [...agentPatterns, ...COMMON_PATTERNS];
|
|
232
|
+
for (const pattern of allPatterns) {
|
|
233
|
+
const match = pattern.exec(tailBuffer);
|
|
234
|
+
if (!match) continue;
|
|
235
|
+
const matchedText = String(match[0] || "");
|
|
236
|
+
const matchIndex = Number.isFinite(match.index)
|
|
237
|
+
? match.index
|
|
238
|
+
: Math.max(0, tailBuffer.length - matchedText.length);
|
|
239
|
+
if (this._hasDeniedContext(tailBuffer, matchIndex, matchedText.length)) continue;
|
|
240
|
+
this._setState(ACTIVITY_STATES.waiting_input, pattern.source);
|
|
241
|
+
this._startBlockedTimer();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this._setState(ACTIVITY_STATES.idle);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_tailWindow() {
|
|
249
|
+
if (!this.buffer) return "";
|
|
250
|
+
const lines = this.buffer.split("\n");
|
|
251
|
+
if (lines.length <= this.tailLines) return this.buffer;
|
|
252
|
+
return lines.slice(-this.tailLines).join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
_lineAt(haystack, index) {
|
|
256
|
+
const safeIndex = Math.max(0, Math.min(index, haystack.length));
|
|
257
|
+
const lineStart = haystack.lastIndexOf("\n", safeIndex - 1) + 1;
|
|
258
|
+
const lineEndCandidate = haystack.indexOf("\n", safeIndex);
|
|
259
|
+
const lineEnd = lineEndCandidate >= 0 ? lineEndCandidate : haystack.length;
|
|
260
|
+
return haystack.slice(lineStart, lineEnd);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_isInsideCodeFence(haystack, index) {
|
|
264
|
+
const before = haystack.slice(0, Math.max(0, index));
|
|
265
|
+
const fences = before.match(/```/g);
|
|
266
|
+
return (fences ? fences.length : 0) % 2 === 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_hasDeniedContext(haystack, matchIndex, matchLength = 0) {
|
|
270
|
+
if (this._isInsideCodeFence(haystack, matchIndex)) return true;
|
|
271
|
+
const centerIndex = Math.max(0, matchIndex + Math.max(0, Math.trunc(matchLength / 2)));
|
|
272
|
+
const line = this._lineAt(haystack, centerIndex);
|
|
273
|
+
return LINE_DENY_CONTEXT_PATTERNS.some((deny) => deny.test(line));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Start the WAITING_INPUT → BLOCKED timer
|
|
278
|
+
*/
|
|
279
|
+
_startBlockedTimer() {
|
|
280
|
+
this._clearBlockedTimer();
|
|
281
|
+
this.blockedTimer = setTimeout(() => {
|
|
282
|
+
this.blockedTimer = null;
|
|
283
|
+
if (this.state === ACTIVITY_STATES.waiting_input) {
|
|
284
|
+
this._setState(ACTIVITY_STATES.blocked, `waiting_input for ${this.blockedTimeoutMs}ms`);
|
|
285
|
+
}
|
|
286
|
+
}, this.blockedTimeoutMs);
|
|
287
|
+
// Allow process to exit even if timer is pending
|
|
288
|
+
if (this.blockedTimer && typeof this.blockedTimer.unref === "function") {
|
|
289
|
+
this.blockedTimer.unref();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_clearBlockedTimer() {
|
|
294
|
+
if (this.blockedTimer) {
|
|
295
|
+
clearTimeout(this.blockedTimer);
|
|
296
|
+
this.blockedTimer = null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_clearQuietTimer() {
|
|
301
|
+
if (this.quietTimer) {
|
|
302
|
+
clearTimeout(this.quietTimer);
|
|
303
|
+
this.quietTimer = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get current state snapshot
|
|
309
|
+
*/
|
|
310
|
+
getState() {
|
|
311
|
+
return {
|
|
312
|
+
state: this.state,
|
|
313
|
+
since: this.since,
|
|
314
|
+
detail: this.detail,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Clean up timers
|
|
320
|
+
*/
|
|
321
|
+
destroy() {
|
|
322
|
+
this._clearBlockedTimer();
|
|
323
|
+
this._clearQuietTimer();
|
|
324
|
+
this.callbacks = [];
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = { ACTIVITY_STATES, ActivityDetector };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { writeActivityState } = require("./activityStateWriter");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Unified activity state publisher.
|
|
7
|
+
* Encapsulates the "write to disk + broadcast event" pattern used by
|
|
8
|
+
* ptyRunner, launcher, notifier, and internalRunner.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {string} options.agentsFile - Path to all-agents.json
|
|
12
|
+
* @param {string} options.subscriber - Subscriber ID (e.g. "claude-code:abc123")
|
|
13
|
+
* @param {string} options.projectRoot - Project root (unused, kept for API compat)
|
|
14
|
+
* @param {boolean} [options.force=true] - Force overwrite priority-protected states
|
|
15
|
+
*/
|
|
16
|
+
function createActivityStatePublisher(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
agentsFile,
|
|
19
|
+
subscriber,
|
|
20
|
+
force = true,
|
|
21
|
+
} = options;
|
|
22
|
+
|
|
23
|
+
let lastState = "";
|
|
24
|
+
|
|
25
|
+
function publish(state, extra = {}) {
|
|
26
|
+
if (state === lastState) return false;
|
|
27
|
+
const since = extra.since || undefined;
|
|
28
|
+
const changed = writeActivityState(agentsFile, subscriber, state, { since, force });
|
|
29
|
+
if (!changed) return false;
|
|
30
|
+
lastState = state;
|
|
31
|
+
// Write to bus events directory for daemon bridge to pick up.
|
|
32
|
+
// Writes directly to events dir to avoid queueing into subscriber pending files.
|
|
33
|
+
try {
|
|
34
|
+
const eventsDir = path.join(
|
|
35
|
+
path.dirname(path.dirname(agentsFile)),
|
|
36
|
+
"bus", "events"
|
|
37
|
+
);
|
|
38
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
39
|
+
const eventFile = path.join(eventsDir, `${date}.jsonl`);
|
|
40
|
+
const entry = {
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
type: "status/agent",
|
|
43
|
+
event: "activity_state_changed",
|
|
44
|
+
publisher: subscriber,
|
|
45
|
+
target: "*",
|
|
46
|
+
data: {
|
|
47
|
+
subscriber,
|
|
48
|
+
state,
|
|
49
|
+
previous: extra.previous || "",
|
|
50
|
+
...extra.detail ? { detail: extra.detail } : {},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
fs.appendFileSync(eventFile, JSON.stringify(entry) + "\n");
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore event write errors — dashboard polling is the fallback
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getLastState() {
|
|
61
|
+
return lastState;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { publish, getLastState };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { createActivityStatePublisher };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralized helper for writing activity_state to all-agents.json.
|
|
5
|
+
* Used by both ptyRunner and notifier to avoid duplicated read-modify-write logic.
|
|
6
|
+
*
|
|
7
|
+
* - Only writes when state actually changes (monotonic activity_since).
|
|
8
|
+
* - Respects priority: won't overwrite working/waiting_input/blocked with idle
|
|
9
|
+
* unless explicitly requested via `force` option.
|
|
10
|
+
*/
|
|
11
|
+
function writeActivityState(agentsFilePath, subscriber, state, options = {}) {
|
|
12
|
+
const { since, force = false } = options;
|
|
13
|
+
try {
|
|
14
|
+
if (!agentsFilePath || !fs.existsSync(agentsFilePath)) return false;
|
|
15
|
+
const data = JSON.parse(fs.readFileSync(agentsFilePath, "utf8"));
|
|
16
|
+
if (!data.agents || !data.agents[subscriber]) return false;
|
|
17
|
+
|
|
18
|
+
const current = data.agents[subscriber].activity_state;
|
|
19
|
+
|
|
20
|
+
// Skip if state unchanged (monotonic update)
|
|
21
|
+
if (current === state) return false;
|
|
22
|
+
|
|
23
|
+
// Don't overwrite higher-priority states with lower-priority states
|
|
24
|
+
// unless force is set (e.g. explicit markIdle from ptyRunner/launcher)
|
|
25
|
+
if (!force && (current === "working" || current === "waiting_input" || current === "blocked")) {
|
|
26
|
+
if (state === "idle" || state === "ready") return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
data.agents[subscriber].activity_state = state;
|
|
30
|
+
data.agents[subscriber].activity_since = since
|
|
31
|
+
? new Date(since).toISOString()
|
|
32
|
+
: new Date().toISOString();
|
|
33
|
+
fs.writeFileSync(agentsFilePath, JSON.stringify(data, null, 2));
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { writeActivityState };
|
|
@@ -5,6 +5,7 @@ const { spawnSync } = require("child_process");
|
|
|
5
5
|
const EventBus = require("../bus");
|
|
6
6
|
const { runCliAgent } = require("./cliRunner");
|
|
7
7
|
const { normalizeCliOutput } = require("./normalizeOutput");
|
|
8
|
+
const { createActivityStatePublisher } = require("./activityStatePublisher");
|
|
8
9
|
|
|
9
10
|
function sleep(ms) {
|
|
10
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -220,6 +221,16 @@ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
|
|
|
220
221
|
process.on("SIGINT", stop);
|
|
221
222
|
|
|
222
223
|
const cliSessionState = { cliSessionId, needsSave: false };
|
|
224
|
+
const agentsFile = getUfooPaths(projectRoot).agentsFile;
|
|
225
|
+
const activityPublisher = createActivityStatePublisher({
|
|
226
|
+
agentsFile, subscriber, projectRoot,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
function setActivityState(state) {
|
|
230
|
+
activityPublisher.publish(state);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setActivityState("ready");
|
|
223
234
|
|
|
224
235
|
// 心跳更新函数
|
|
225
236
|
const updateHeartbeat = () => {
|
|
@@ -248,6 +259,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
|
|
|
248
259
|
try {
|
|
249
260
|
const lines = drainQueue(queueFile);
|
|
250
261
|
if (lines.length > 0) {
|
|
262
|
+
setActivityState("working");
|
|
251
263
|
const events = [];
|
|
252
264
|
for (const line of lines) {
|
|
253
265
|
try {
|
|
@@ -289,6 +301,7 @@ async function runInternalRunner({ projectRoot, agentType = "codex" }) {
|
|
|
289
301
|
// 处理消息后更新心跳
|
|
290
302
|
updateHeartbeat();
|
|
291
303
|
lastHeartbeat = now;
|
|
304
|
+
setActivityState("idle");
|
|
292
305
|
}
|
|
293
306
|
} finally {
|
|
294
307
|
processing = false;
|
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);
|