u-foo 1.8.4 → 1.8.8
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/package.json +1 -1
- package/src/agent/activityDetector.js +33 -0
- package/src/agent/claudeSessionFiles.js +127 -0
- package/src/agent/launcher.js +13 -2
- package/src/bus/index.js +12 -6
- package/src/bus/message.js +16 -0
- package/src/bus/store.js +72 -25
- package/src/bus/subscriber.js +16 -3
- package/src/chat/commandExecutor.js +0 -1
- package/src/chat/commands.js +10 -2
- package/src/chat/daemonCoordinator.js +1 -0
- package/src/chat/daemonMessageRouter.js +27 -16
- package/src/chat/daemonReconnect.js +6 -3
- package/src/chat/index.js +1 -0
- package/src/chat/inputMath.js +175 -38
- package/src/chat/inputSubmitHandler.js +10 -5
- package/src/chat/settingsController.js +3 -1
- package/src/chat/text.js +6 -0
- package/src/code/agent.js +27 -6
- package/src/code/nativeRunner.js +8 -4
- package/src/code/prompts/actions.js +21 -0
- package/src/code/prompts/efficiency.js +18 -0
- package/src/code/prompts/environment.js +50 -0
- package/src/code/prompts/identity.js +20 -0
- package/src/code/prompts/index.js +103 -0
- package/src/code/prompts/safety.js +11 -0
- package/src/code/prompts/sections.js +60 -0
- package/src/code/prompts/system.js +16 -0
- package/src/code/prompts/tasks.js +17 -0
- package/src/code/prompts/toolDescriptions/bash.js +21 -0
- package/src/code/prompts/toolDescriptions/edit.js +16 -0
- package/src/code/prompts/toolDescriptions/read.js +17 -0
- package/src/code/prompts/toolDescriptions/write.js +16 -0
- package/src/code/prompts/ufoo.js +21 -0
- package/src/daemon/groupOrchestrator.js +97 -7
- package/src/daemon/index.js +53 -14
- package/src/daemon/nicknameScope.js +80 -0
- package/src/daemon/ops.js +19 -6
- package/src/daemon/soloBootstrap.js +15 -2
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* BLOCKED ----+
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
const { isTranscriptActive } = require("./claudeSessionFiles");
|
|
14
|
+
|
|
13
15
|
const ACTIVITY_STATES = {
|
|
14
16
|
starting: "starting",
|
|
15
17
|
ready: "ready",
|
|
@@ -24,6 +26,7 @@ const DEFAULT_TAIL_LINES = 10;
|
|
|
24
26
|
const DEFAULT_BLOCKED_TIMEOUT_MS = 300000;
|
|
25
27
|
const DEFAULT_INTERNAL_QUIET_MS = 3500;
|
|
26
28
|
const DEFAULT_EXTERNAL_QUIET_MS = 5000;
|
|
29
|
+
const DEFAULT_TRANSCRIPT_THRESHOLD_MS = 5000;
|
|
27
30
|
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
28
31
|
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
29
32
|
|
|
@@ -34,6 +37,10 @@ const INPUT_PATTERNS = {
|
|
|
34
37
|
/\bAllow\b.*\bDeny\b/, // Claude Code permission dialog: "Allow | Deny"
|
|
35
38
|
/\ballow mcp\b/i, // MCP tool approval prompt
|
|
36
39
|
/Enter to select.*\u2191\/\u2193 to navigate/, // Ink TUI interactive prompt navigation bar (permissions, AskUserQuestion, Plan approval)
|
|
40
|
+
/\u276f\s+.*\n.*\u276f\s+/, // AskUserQuestion multi-option selector (❯ markers)
|
|
41
|
+
/Do you want to proceed/i, // Plan mode approval prompt
|
|
42
|
+
/\bApprove\b.*\bReject\b/, // Plan approval dialog
|
|
43
|
+
/\bYes\b.*\bNo\b.*\bAlways allow\b/, // Permission with "Always allow" option
|
|
37
44
|
],
|
|
38
45
|
codex: [
|
|
39
46
|
/\[Y\/n\]/, // Bracket-style prompt
|
|
@@ -79,6 +86,8 @@ class ActivityDetector {
|
|
|
79
86
|
* @param {boolean} [options.startOnOutput=false] - allow STARTING -> WORKING on first output
|
|
80
87
|
* @param {number} [options.quietWindowMs] - output quiet window before WAITING_INPUT/IDLE classification
|
|
81
88
|
* @param {number} [options.blockedTimeoutMs=300000] - 5 min WAITING_INPUT → BLOCKED
|
|
89
|
+
* @param {number} [options.pid] - agent PID for transcript mtime checking (claude-code only)
|
|
90
|
+
* @param {number} [options.transcriptThresholdMs=5000] - transcript mtime threshold for busy detection
|
|
82
91
|
*/
|
|
83
92
|
constructor(agentType, options = {}) {
|
|
84
93
|
this.agentType = agentType;
|
|
@@ -87,6 +96,8 @@ class ActivityDetector {
|
|
|
87
96
|
this.tailLines = toPositiveInt(options.tailLines, DEFAULT_TAIL_LINES);
|
|
88
97
|
this.startOnOutput = options.startOnOutput === true;
|
|
89
98
|
this.blockedTimeoutMs = toPositiveInt(options.blockedTimeoutMs, DEFAULT_BLOCKED_TIMEOUT_MS);
|
|
99
|
+
this.pid = toPositiveInt(options.pid, 0);
|
|
100
|
+
this.transcriptThresholdMs = toPositiveInt(options.transcriptThresholdMs, DEFAULT_TRANSCRIPT_THRESHOLD_MS);
|
|
90
101
|
const optionQuietMs = toPositiveInt(options.quietWindowMs, 0);
|
|
91
102
|
const envQuietMs = toPositiveInt(process.env.UFOO_ACTIVITY_QUIET_MS, 0);
|
|
92
103
|
this.quietWindowMs = optionQuietMs || envQuietMs || getDefaultQuietWindowMs(this.mode);
|
|
@@ -126,6 +137,13 @@ class ActivityDetector {
|
|
|
126
137
|
}
|
|
127
138
|
}
|
|
128
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Set the agent PID (for transcript mtime checking after spawn).
|
|
142
|
+
*/
|
|
143
|
+
setPid(pid) {
|
|
144
|
+
this.pid = toPositiveInt(pid, 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
/**
|
|
130
148
|
* STARTING → READY
|
|
131
149
|
*/
|
|
@@ -224,6 +242,21 @@ class ActivityDetector {
|
|
|
224
242
|
|
|
225
243
|
_classifyAfterQuietWindow() {
|
|
226
244
|
if (this.state !== ACTIVITY_STATES.working) return;
|
|
245
|
+
|
|
246
|
+
// Transcript mtime check: if Claude Code's transcript was recently written,
|
|
247
|
+
// the agent is still working (e.g. waiting for API response). Reschedule
|
|
248
|
+
// classification instead of prematurely going idle.
|
|
249
|
+
if (this.pid && this.agentType === "claude-code") {
|
|
250
|
+
try {
|
|
251
|
+
if (isTranscriptActive(this.pid, this.transcriptThresholdMs)) {
|
|
252
|
+
this._scheduleQuietClassification();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// ignore — fall through to normal classification
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
227
260
|
const tailBuffer = this._tailWindow();
|
|
228
261
|
|
|
229
262
|
// Check agent-specific patterns only after output has stabilized.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code session file utilities.
|
|
5
|
+
*
|
|
6
|
+
* Reads Claude Code's PID files (~/.claude/sessions/{pid}.json) and
|
|
7
|
+
* transcript files (~/.claude/projects/{encoded-cwd}/{sessionId}.jsonl)
|
|
8
|
+
* to provide external status signals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
|
|
15
|
+
const CLAUDE_CONFIG_DIR = path.join(os.homedir(), ".claude");
|
|
16
|
+
const SESSIONS_DIR = path.join(CLAUDE_CONFIG_DIR, "sessions");
|
|
17
|
+
const PROJECTS_DIR = path.join(CLAUDE_CONFIG_DIR, "projects");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Encode a CWD path to Claude Code's project directory name.
|
|
21
|
+
* e.g. "/Users/icy/Code/ufoo" → "-Users-icy-Code-ufoo"
|
|
22
|
+
*/
|
|
23
|
+
function encodeProjectPath(cwd) {
|
|
24
|
+
return String(cwd || "").replace(/\//g, "-");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a Claude Code PID file.
|
|
29
|
+
* Returns { pid, sessionId, cwd, startedAt, kind, entrypoint } or null.
|
|
30
|
+
*/
|
|
31
|
+
function readClaudePidFile(pid) {
|
|
32
|
+
try {
|
|
33
|
+
const filePath = path.join(SESSIONS_DIR, `${pid}.json`);
|
|
34
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
35
|
+
const data = JSON.parse(raw);
|
|
36
|
+
if (!data || typeof data !== "object") return null;
|
|
37
|
+
return data;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find the transcript JSONL file for a Claude Code session.
|
|
45
|
+
* Returns the file path or "" if not found.
|
|
46
|
+
*/
|
|
47
|
+
function findTranscriptFile(cwd, sessionId) {
|
|
48
|
+
if (!cwd || !sessionId) return "";
|
|
49
|
+
const projectDir = path.join(PROJECTS_DIR, encodeProjectPath(cwd));
|
|
50
|
+
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(transcriptPath)) return transcriptPath;
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the mtime (in ms) of a Claude Code transcript file.
|
|
61
|
+
* Returns 0 if the file doesn't exist or can't be read.
|
|
62
|
+
*/
|
|
63
|
+
function getTranscriptMtimeMs(transcriptPath) {
|
|
64
|
+
if (!transcriptPath) return 0;
|
|
65
|
+
try {
|
|
66
|
+
const stat = fs.statSync(transcriptPath);
|
|
67
|
+
return stat.mtimeMs || 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a Claude Code instance (by PID) is actively writing to its transcript.
|
|
75
|
+
* Returns true if the transcript was modified within `thresholdMs` (default 5000ms).
|
|
76
|
+
*
|
|
77
|
+
* This is useful as a supplementary busy signal — when PTY output goes quiet
|
|
78
|
+
* (e.g. during API wait), the transcript file is still being written to.
|
|
79
|
+
*/
|
|
80
|
+
function isTranscriptActive(pid, thresholdMs = 5000) {
|
|
81
|
+
const pidData = readClaudePidFile(pid);
|
|
82
|
+
if (!pidData || !pidData.sessionId || !pidData.cwd) return false;
|
|
83
|
+
const transcriptPath = findTranscriptFile(pidData.cwd, pidData.sessionId);
|
|
84
|
+
if (!transcriptPath) return false;
|
|
85
|
+
const mtime = getTranscriptMtimeMs(transcriptPath);
|
|
86
|
+
if (!mtime) return false;
|
|
87
|
+
return (Date.now() - mtime) < thresholdMs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Find all live Claude Code sessions.
|
|
92
|
+
* Returns array of { pid, sessionId, cwd, startedAt, kind, transcriptPath }.
|
|
93
|
+
*/
|
|
94
|
+
function listClaudeSessions() {
|
|
95
|
+
const results = [];
|
|
96
|
+
try {
|
|
97
|
+
const files = fs.readdirSync(SESSIONS_DIR);
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
if (!/^\d+\.json$/.test(file)) continue;
|
|
100
|
+
const pid = parseInt(file.slice(0, -5), 10);
|
|
101
|
+
// Check if process is alive
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
} catch {
|
|
105
|
+
continue; // dead process
|
|
106
|
+
}
|
|
107
|
+
const data = readClaudePidFile(pid);
|
|
108
|
+
if (!data) continue;
|
|
109
|
+
const transcriptPath = findTranscriptFile(data.cwd, data.sessionId);
|
|
110
|
+
results.push({ ...data, pid, transcriptPath });
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
encodeProjectPath,
|
|
120
|
+
readClaudePidFile,
|
|
121
|
+
findTranscriptFile,
|
|
122
|
+
getTranscriptMtimeMs,
|
|
123
|
+
isTranscriptActive,
|
|
124
|
+
listClaudeSessions,
|
|
125
|
+
SESSIONS_DIR,
|
|
126
|
+
PROJECTS_DIR,
|
|
127
|
+
};
|
package/src/agent/launcher.js
CHANGED
|
@@ -491,7 +491,10 @@ class AgentLauncher {
|
|
|
491
491
|
const child = spawn(this.command, args, {
|
|
492
492
|
cwd: this.cwd,
|
|
493
493
|
stdio: "inherit",
|
|
494
|
-
env:
|
|
494
|
+
env: {
|
|
495
|
+
...process.env,
|
|
496
|
+
...(this.agentType === "claude-code" ? { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" } : {}),
|
|
497
|
+
},
|
|
495
498
|
});
|
|
496
499
|
|
|
497
500
|
child.on("error", (err) => {
|
|
@@ -588,7 +591,11 @@ class AgentLauncher {
|
|
|
588
591
|
try {
|
|
589
592
|
const wrapper = new PtyWrapper(this.command, args, {
|
|
590
593
|
cwd: this.cwd,
|
|
591
|
-
env:
|
|
594
|
+
env: {
|
|
595
|
+
...process.env,
|
|
596
|
+
// Enable Claude Code SDK session state events for precise idle/busy detection
|
|
597
|
+
...(this.agentType === "claude-code" ? { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" } : {}),
|
|
598
|
+
},
|
|
592
599
|
// 未来扩展:ioAdapter: new TerminalIOAdapter()
|
|
593
600
|
});
|
|
594
601
|
|
|
@@ -716,6 +723,10 @@ class AgentLauncher {
|
|
|
716
723
|
|
|
717
724
|
// 启动PTY
|
|
718
725
|
wrapper.spawn();
|
|
726
|
+
// Pass PID to ActivityDetector for transcript mtime checking
|
|
727
|
+
if (wrapper.pty && wrapper.pty.pid) {
|
|
728
|
+
launcherActivityDetector.setPid(wrapper.pty.pid);
|
|
729
|
+
}
|
|
719
730
|
wrapper.attachStreams(process.stdin, process.stdout, process.stderr);
|
|
720
731
|
|
|
721
732
|
|
package/src/bus/index.js
CHANGED
|
@@ -285,13 +285,19 @@ class EventBus {
|
|
|
285
285
|
|
|
286
286
|
// 如果 publisher 不在 agents 列表中,自动注册它(懒加载模式)
|
|
287
287
|
if (publisher !== "unknown" && this.busData.agents && !this.busData.agents[publisher]) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
288
|
+
let subscriber;
|
|
289
|
+
if (publisher === "ufoo-agent") {
|
|
290
|
+
// Keep the reserved controller on its fixed identity even after metadata loss.
|
|
291
|
+
subscriber = (await this.subscriberManager.join("ufoo-agent", "ufoo-agent", "ufoo-agent")).subscriber;
|
|
292
|
+
} else {
|
|
293
|
+
// 解析 agent 信息
|
|
294
|
+
const parts = publisher.split(":");
|
|
295
|
+
const agentType = parts[0] || "unknown-agent";
|
|
296
|
+
const sessionId = parts[1] || require("./utils").generateInstanceId();
|
|
292
297
|
|
|
293
|
-
|
|
294
|
-
|
|
298
|
+
// 自动加入总线(静默模式,不输出日志)
|
|
299
|
+
subscriber = (await this.subscriberManager.join(sessionId, agentType, null)).subscriber;
|
|
300
|
+
}
|
|
295
301
|
this.saveBusData();
|
|
296
302
|
publisher = subscriber; // 使用规范化的 subscriber ID
|
|
297
303
|
}
|
package/src/bus/message.js
CHANGED
|
@@ -178,6 +178,7 @@ class MessageManager {
|
|
|
178
178
|
resolveTarget(target) {
|
|
179
179
|
const nicknameManager = new NicknameManager(this.busData);
|
|
180
180
|
const normalizedTarget = normalizeAgentTypeAlias(target);
|
|
181
|
+
const controllerTarget = target === "ufoo-agent";
|
|
181
182
|
|
|
182
183
|
// 0. Exact subscriber ID match (allows ids without ":" e.g. "ufoo-agent")
|
|
183
184
|
const subscribers = this.busData.agents || {};
|
|
@@ -185,6 +186,21 @@ class MessageManager {
|
|
|
185
186
|
return [target];
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
// Reserved controller ID should never fan out to legacy typed aliases.
|
|
190
|
+
if (controllerTarget) {
|
|
191
|
+
try {
|
|
192
|
+
const queueDir = this.queueManager && this.queueManager.getQueueDir
|
|
193
|
+
? this.queueManager.getQueueDir(target)
|
|
194
|
+
: path.join(this.busDir, "queues", target);
|
|
195
|
+
if (queueDir && fs.existsSync(queueDir)) {
|
|
196
|
+
return [target];
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// ignore queue lookup errors
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
188
204
|
// 1. 尝试作为订阅者 ID
|
|
189
205
|
if (target.includes(":")) {
|
|
190
206
|
return [target];
|
package/src/bus/store.js
CHANGED
|
@@ -47,6 +47,75 @@ function allocateRecoveredNickname(agentType, used) {
|
|
|
47
47
|
return nick;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function recoverQueueEntry(data, subscriber, queueDir, usedNicknames, now) {
|
|
51
|
+
if (!subscriber || data.agents[subscriber]) return false;
|
|
52
|
+
|
|
53
|
+
if (subscriber === "ufoo-agent") {
|
|
54
|
+
const tty = readQueueTty(queueDir);
|
|
55
|
+
const ttyInfo = tty ? getTtyProcessInfo(tty) : null;
|
|
56
|
+
data.agents[subscriber] = {
|
|
57
|
+
agent_type: "ufoo-agent",
|
|
58
|
+
nickname: "ufoo-agent",
|
|
59
|
+
status: "active",
|
|
60
|
+
joined_at: now,
|
|
61
|
+
last_seen: now,
|
|
62
|
+
pid: 0,
|
|
63
|
+
tty,
|
|
64
|
+
tty_shell_pid: ttyInfo && ttyInfo.shellPid ? ttyInfo.shellPid : 0,
|
|
65
|
+
tmux_pane: "",
|
|
66
|
+
launch_mode: "",
|
|
67
|
+
};
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parts = subscriber.split(":");
|
|
72
|
+
if (parts.length !== 2) return false;
|
|
73
|
+
const [agentType, sessionId] = parts;
|
|
74
|
+
if (!agentType || !sessionId) return false;
|
|
75
|
+
if (!isRecoverableSessionId(sessionId)) return false;
|
|
76
|
+
|
|
77
|
+
const tty = readQueueTty(queueDir);
|
|
78
|
+
const ttyInfo = tty ? getTtyProcessInfo(tty) : null;
|
|
79
|
+
const activeByTty = Boolean(ttyInfo && ttyInfo.alive && ttyInfo.hasAgent);
|
|
80
|
+
const nickname = activeByTty ? allocateRecoveredNickname(agentType, usedNicknames) : "";
|
|
81
|
+
|
|
82
|
+
data.agents[subscriber] = {
|
|
83
|
+
agent_type: agentType,
|
|
84
|
+
nickname,
|
|
85
|
+
status: activeByTty ? "active" : "inactive",
|
|
86
|
+
joined_at: now,
|
|
87
|
+
last_seen: now,
|
|
88
|
+
pid: 0,
|
|
89
|
+
tty,
|
|
90
|
+
tty_shell_pid: ttyInfo && ttyInfo.shellPid ? ttyInfo.shellPid : 0,
|
|
91
|
+
tmux_pane: "",
|
|
92
|
+
launch_mode: "",
|
|
93
|
+
};
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reconcileReservedControllerAliases(data, now) {
|
|
98
|
+
if (!data.agents || !data.agents["ufoo-agent"]) return false;
|
|
99
|
+
|
|
100
|
+
let changed = false;
|
|
101
|
+
for (const [id, meta] of Object.entries(data.agents)) {
|
|
102
|
+
if (!id.startsWith("ufoo-agent:")) continue;
|
|
103
|
+
if (!meta || meta.status !== "active") continue;
|
|
104
|
+
if (String(meta.agent_type || "").trim() !== "ufoo-agent") continue;
|
|
105
|
+
const hasRuntimeBinding = Boolean(
|
|
106
|
+
meta.tty
|
|
107
|
+
|| meta.tmux_pane
|
|
108
|
+
|| meta.host_inject_sock
|
|
109
|
+
|| meta.host_daemon_sock
|
|
110
|
+
);
|
|
111
|
+
if (hasRuntimeBinding) continue;
|
|
112
|
+
meta.status = "inactive";
|
|
113
|
+
meta.last_seen = now;
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
return changed;
|
|
117
|
+
}
|
|
118
|
+
|
|
50
119
|
class BusStore {
|
|
51
120
|
constructor(projectRoot) {
|
|
52
121
|
this.projectRoot = projectRoot;
|
|
@@ -89,33 +158,11 @@ class BusStore {
|
|
|
89
158
|
if (!stat.isDirectory()) continue;
|
|
90
159
|
|
|
91
160
|
const subscriber = safeNameToSubscriber(entry);
|
|
92
|
-
|
|
93
|
-
if (parts.length !== 2) continue;
|
|
94
|
-
const [agentType, sessionId] = parts;
|
|
95
|
-
if (!agentType || !sessionId) continue;
|
|
96
|
-
if (!isRecoverableSessionId(sessionId)) continue;
|
|
97
|
-
if (data.agents[subscriber]) continue;
|
|
98
|
-
|
|
99
|
-
const tty = readQueueTty(queueDir);
|
|
100
|
-
const ttyInfo = tty ? getTtyProcessInfo(tty) : null;
|
|
101
|
-
const activeByTty = Boolean(ttyInfo && ttyInfo.alive && ttyInfo.hasAgent);
|
|
102
|
-
const nickname = activeByTty ? allocateRecoveredNickname(agentType, usedNicknames) : "";
|
|
103
|
-
|
|
104
|
-
data.agents[subscriber] = {
|
|
105
|
-
agent_type: agentType,
|
|
106
|
-
nickname,
|
|
107
|
-
status: activeByTty ? "active" : "inactive",
|
|
108
|
-
joined_at: now,
|
|
109
|
-
last_seen: now,
|
|
110
|
-
pid: 0,
|
|
111
|
-
tty,
|
|
112
|
-
tty_shell_pid: ttyInfo && ttyInfo.shellPid ? ttyInfo.shellPid : 0,
|
|
113
|
-
tmux_pane: "",
|
|
114
|
-
launch_mode: "",
|
|
115
|
-
};
|
|
116
|
-
recovered = true;
|
|
161
|
+
recovered = recoverQueueEntry(data, subscriber, queueDir, usedNicknames, now) || recovered;
|
|
117
162
|
}
|
|
118
163
|
|
|
164
|
+
recovered = reconcileReservedControllerAliases(data, now) || recovered;
|
|
165
|
+
|
|
119
166
|
if (recovered) {
|
|
120
167
|
saveAgentsData(this.agentsFile, data);
|
|
121
168
|
}
|
package/src/bus/subscriber.js
CHANGED
|
@@ -99,9 +99,10 @@ class SubscriberManager {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
async cleanupDuplicateTty(currentSubscriber, ttyPath) {
|
|
102
|
-
if (!ttyPath) return;
|
|
103
|
-
if (!this.busData.agents) return;
|
|
102
|
+
if (!ttyPath) return null;
|
|
103
|
+
if (!this.busData.agents) return null;
|
|
104
104
|
|
|
105
|
+
let inheritedNickname = null;
|
|
105
106
|
const entries = Object.entries(this.busData.agents);
|
|
106
107
|
for (const [id, meta] of entries) {
|
|
107
108
|
if (id === currentSubscriber) continue;
|
|
@@ -111,6 +112,10 @@ class SubscriberManager {
|
|
|
111
112
|
: (await this.queueManager.readTty(id));
|
|
112
113
|
if (!metaTty) continue;
|
|
113
114
|
if (metaTty === ttyPath) {
|
|
115
|
+
// Inherit user-set nickname from the displaced entry
|
|
116
|
+
if (meta.nickname && !inheritedNickname) {
|
|
117
|
+
inheritedNickname = meta.nickname;
|
|
118
|
+
}
|
|
114
119
|
// Remove stale subscriber using same tty
|
|
115
120
|
delete this.busData.agents[id];
|
|
116
121
|
try {
|
|
@@ -125,6 +130,7 @@ class SubscriberManager {
|
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
}
|
|
133
|
+
return inheritedNickname;
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
/**
|
|
@@ -186,7 +192,12 @@ class SubscriberManager {
|
|
|
186
192
|
const ttyInfo = finalTty ? getTtyProcessInfo(finalTty) : null;
|
|
187
193
|
|
|
188
194
|
// 清理同一 tty 的旧订阅者(避免重复启动污染)
|
|
189
|
-
|
|
195
|
+
// Inherit nickname from displaced entry when this is a new subscriber
|
|
196
|
+
// with no explicit nickname (e.g. session restart on same TTY)
|
|
197
|
+
const inheritedNickname = await this.cleanupDuplicateTty(subscriber, finalTty);
|
|
198
|
+
if (inheritedNickname && !nickname && !existingMeta) {
|
|
199
|
+
finalNickname = inheritedNickname;
|
|
200
|
+
}
|
|
190
201
|
|
|
191
202
|
// 更新订阅者信息(保留已有字段,如 provider_session_*)
|
|
192
203
|
const preserved = existingMeta && typeof existingMeta === "object"
|
|
@@ -288,6 +299,7 @@ class SubscriberManager {
|
|
|
288
299
|
}
|
|
289
300
|
|
|
290
301
|
this.busData.agents[subscriber].status = "inactive";
|
|
302
|
+
this.busData.agents[subscriber].activity_state = "";
|
|
291
303
|
this.busData.agents[subscriber].last_seen = getTimestamp();
|
|
292
304
|
|
|
293
305
|
return true;
|
|
@@ -350,6 +362,7 @@ class SubscriberManager {
|
|
|
350
362
|
for (const [id, meta] of Object.entries(this.busData.agents)) {
|
|
351
363
|
if (meta.status === "active" && !isMetaActive(meta)) {
|
|
352
364
|
meta.status = "inactive";
|
|
365
|
+
meta.activity_state = "";
|
|
353
366
|
meta.last_seen = getTimestamp();
|
|
354
367
|
}
|
|
355
368
|
}
|
package/src/chat/commands.js
CHANGED
|
@@ -38,10 +38,10 @@ const COMMAND_TREE = {
|
|
|
38
38
|
"/group": {
|
|
39
39
|
desc: "Agent group orchestration",
|
|
40
40
|
children: {
|
|
41
|
-
diagram: { desc: "Render group diagram (ascii|mermaid)" },
|
|
42
41
|
run: { desc: "Launch a group template" },
|
|
43
|
-
status: { desc: "Show group runtime status" },
|
|
44
42
|
stop: { desc: "Stop a running group" },
|
|
43
|
+
status: { desc: "Show group runtime status" },
|
|
44
|
+
diagram: { desc: "Render group diagram (ascii|mermaid)" },
|
|
45
45
|
template: { desc: "Template ops (list/show/validate/new)" },
|
|
46
46
|
templates: { desc: "List available templates" },
|
|
47
47
|
},
|
|
@@ -136,6 +136,13 @@ function parseCommand(text) {
|
|
|
136
136
|
return { command, args };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
function shouldEchoCommandInChat(text) {
|
|
140
|
+
const parsed = parseCommand(String(text || "").trim());
|
|
141
|
+
if (!parsed) return true;
|
|
142
|
+
if (parsed.command === "group" && parsed.args[0] === "run") return false;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
139
146
|
function parseAtTarget(text) {
|
|
140
147
|
if (!text.startsWith("@")) return null;
|
|
141
148
|
const trimmed = text.slice(1).trim();
|
|
@@ -155,5 +162,6 @@ module.exports = {
|
|
|
155
162
|
sortCommands,
|
|
156
163
|
buildCommandRegistry,
|
|
157
164
|
parseCommand,
|
|
165
|
+
shouldEchoCommandInChat,
|
|
158
166
|
parseAtTarget,
|
|
159
167
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { IPC_RESPONSE_TYPES, BUS_STATUS_PHASES } = require("../shared/eventContract");
|
|
2
2
|
const { renderMarkdownLines } = require("../shared/markdownRenderer");
|
|
3
|
+
const { decodeEscapedNewlines } = require("./text");
|
|
3
4
|
|
|
4
5
|
function createDaemonMessageRouter(options = {}) {
|
|
5
6
|
const {
|
|
@@ -51,7 +52,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
if (typeof displayMessage === "string") {
|
|
54
|
-
displayMessage = displayMessage
|
|
55
|
+
displayMessage = decodeEscapedNewlines(displayMessage);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
return { displayMessage, streamPayload };
|
|
@@ -198,8 +199,23 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
198
199
|
function handleResponseMessage(msg) {
|
|
199
200
|
const payload = msg.data || {};
|
|
200
201
|
if (payload.reply) {
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
const replyText = decodeEscapedNewlines(payload.reply);
|
|
203
|
+
resolveStatusLine(`{gray-fg}←{/gray-fg} ${escapeBlessed(replyText)}`);
|
|
204
|
+
const ops = Array.isArray(payload.ops) ? payload.ops : [];
|
|
205
|
+
const isLifecycleStatusOnly = ops.length > 0
|
|
206
|
+
&& ops.every((op) => op && (op.action === "close" || op.action === "launch"));
|
|
207
|
+
const group = payload.group && typeof payload.group === "object" ? payload.group : null;
|
|
208
|
+
const isGroupStartedConfirmation = Boolean(
|
|
209
|
+
group &&
|
|
210
|
+
group.group_id &&
|
|
211
|
+
Array.isArray(group.members) &&
|
|
212
|
+
!group.dry_run &&
|
|
213
|
+
/^Group started\b/i.test(replyText)
|
|
214
|
+
);
|
|
215
|
+
// Suppress lifecycle confirmations from chat history — status line plus structured payload is enough.
|
|
216
|
+
if (!isLifecycleStatusOnly && !isGroupStartedConfirmation) {
|
|
217
|
+
logMessage("reply", `{white-fg}←{/white-fg} ${escapeBlessed(replyText)}`);
|
|
218
|
+
}
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
if (payload.recoverable && typeof payload.recoverable === "object") {
|
|
@@ -277,7 +293,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
277
293
|
|
|
278
294
|
if (payload.dispatch && payload.dispatch.length > 0) {
|
|
279
295
|
const targets = payload.dispatch.map((d) => d.target || d).join(", ");
|
|
280
|
-
|
|
296
|
+
resolveStatusLine(`{gray-fg}→{/gray-fg} Dispatched to: ${escapeBlessed(targets)}`);
|
|
281
297
|
}
|
|
282
298
|
|
|
283
299
|
if (
|
|
@@ -350,6 +366,10 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
350
366
|
|
|
351
367
|
const { displayMessage, streamPayload } = normalizeDisplayMessage(data.message || "");
|
|
352
368
|
|
|
369
|
+
// Skip silent events (e.g. delivery confirmations from notifier) and empty messages
|
|
370
|
+
if (data.silent && !streamPayload) return true;
|
|
371
|
+
if (!displayMessage && !streamPayload) return true;
|
|
372
|
+
|
|
353
373
|
const isAgentViewTarget =
|
|
354
374
|
getCurrentView() === "agent" &&
|
|
355
375
|
isAgentViewUsesBus() &&
|
|
@@ -361,7 +381,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
361
381
|
if (isAgentViewTarget) {
|
|
362
382
|
if (streamPayload) {
|
|
363
383
|
const delta = typeof streamPayload.delta === "string"
|
|
364
|
-
? streamPayload.delta
|
|
384
|
+
? decodeEscapedNewlines(streamPayload.delta)
|
|
365
385
|
: "";
|
|
366
386
|
if (delta) writeToAgentTerm(delta);
|
|
367
387
|
} else if (displayMessage) {
|
|
@@ -371,15 +391,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
371
391
|
}
|
|
372
392
|
|
|
373
393
|
if (data.event === "delivery" && consumePendingDelivery(publisher, displayName)) {
|
|
374
|
-
|
|
375
|
-
const detail = typeof data.message === "string" && data.message
|
|
376
|
-
? data.message
|
|
377
|
-
: (ok ? `Delivered to @${displayName}` : `Delivery failed to @${displayName}`);
|
|
378
|
-
if (ok) {
|
|
379
|
-
logMessage("status", `{white-fg}✓{/white-fg} ${escapeBlessed(detail)}`);
|
|
380
|
-
} else {
|
|
381
|
-
logMessage("error", `{white-fg}✗{/white-fg} ${escapeBlessed(detail)}`);
|
|
382
|
-
}
|
|
394
|
+
// Delivery confirmations are already shown in the status bar — suppress from chat.
|
|
383
395
|
requestStatus();
|
|
384
396
|
renderScreen();
|
|
385
397
|
return true;
|
|
@@ -391,7 +403,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
391
403
|
|
|
392
404
|
if (streamPayload) {
|
|
393
405
|
const delta = typeof streamPayload.delta === "string"
|
|
394
|
-
? streamPayload.delta
|
|
406
|
+
? decodeEscapedNewlines(streamPayload.delta)
|
|
395
407
|
: "";
|
|
396
408
|
const state = beginStream(publisher, prefixLabel, continuationPrefix, data);
|
|
397
409
|
if (delta) appendStreamDelta(state, delta);
|
|
@@ -426,7 +438,6 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
426
438
|
|
|
427
439
|
function handleErrorMessage(msg) {
|
|
428
440
|
resolveStatusLine(`{gray-fg}✗{/gray-fg} Error: ${msg.error}`);
|
|
429
|
-
logMessage("error", `{white-fg}✗{/white-fg} Error: ${msg.error}`);
|
|
430
441
|
renderScreen();
|
|
431
442
|
return false;
|
|
432
443
|
}
|
|
@@ -9,14 +9,17 @@ function restartDaemonFlow(options = {}) {
|
|
|
9
9
|
startDaemon,
|
|
10
10
|
daemonConnection,
|
|
11
11
|
logMessage,
|
|
12
|
+
resolveStatusLine = null,
|
|
12
13
|
} = options;
|
|
13
14
|
|
|
15
|
+
const statusMsg = resolveStatusLine || ((text) => logMessage("status", text));
|
|
16
|
+
|
|
14
17
|
let restartInProgress = false;
|
|
15
18
|
|
|
16
19
|
return async function restartDaemon() {
|
|
17
20
|
if (restartInProgress) return;
|
|
18
21
|
restartInProgress = true;
|
|
19
|
-
|
|
22
|
+
statusMsg("{gray-fg}⚙{/gray-fg} Restarting daemon...");
|
|
20
23
|
try {
|
|
21
24
|
const connection = resolveDaemonConnection(daemonConnection);
|
|
22
25
|
if (connection) {
|
|
@@ -26,9 +29,9 @@ function restartDaemonFlow(options = {}) {
|
|
|
26
29
|
startDaemon(projectRoot);
|
|
27
30
|
const connected = connection ? await connection.connect() : false;
|
|
28
31
|
if (connected) {
|
|
29
|
-
|
|
32
|
+
statusMsg("{gray-fg}✓{/gray-fg} Daemon reconnected");
|
|
30
33
|
} else {
|
|
31
|
-
|
|
34
|
+
statusMsg("{gray-fg}✗{/gray-fg} Failed to reconnect to daemon");
|
|
32
35
|
}
|
|
33
36
|
} finally {
|
|
34
37
|
restartInProgress = false;
|
package/src/chat/index.js
CHANGED