u-foo 1.0.3 → 1.0.6
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 +67 -8
- package/README.zh-CN.md +9 -7
- package/SKILLS/ufoo/SKILL.md +117 -0
- package/SKILLS/uinit/SKILL.md +73 -0
- package/SKILLS/ustatus/SKILL.md +36 -0
- package/bin/uclaude.js +13 -0
- package/bin/ucodex.js +13 -0
- package/bin/ufoo +9 -31
- package/bin/ufoo.js +13 -0
- package/modules/AGENTS.template.md +15 -7
- package/modules/bus/README.md +28 -23
- package/modules/bus/SKILLS/ubus/SKILL.md +18 -8
- package/modules/context/README.md +18 -40
- package/modules/context/SKILLS/uctx/SKILL.md +61 -1
- package/package.json +16 -4
- package/scripts/.archived/bash-to-js-migration/README.md +46 -0
- package/scripts/.archived/bash-to-js-migration/banner.sh +89 -0
- package/scripts/{bus-inject.sh → .archived/bash-to-js-migration/bus-inject.sh} +35 -3
- package/scripts/{bus.sh → .archived/bash-to-js-migration/bus.sh} +3 -1
- package/scripts/banner.sh +2 -89
- package/scripts/postinstall.js +59 -0
- package/src/agent/cliRunner.js +33 -5
- package/src/agent/internalRunner.js +78 -51
- package/src/agent/launcher.js +702 -0
- package/src/agent/notifier.js +200 -0
- package/src/agent/ptyRunner.js +377 -0
- package/src/agent/ptyWrapper.js +354 -0
- package/src/agent/readyDetector.js +159 -0
- package/src/agent/ufooAgent.js +37 -42
- package/src/bus/API_DESIGN.md +204 -0
- package/src/bus/activate.js +156 -0
- package/src/bus/daemon.js +308 -0
- package/src/bus/index.js +785 -0
- package/src/bus/inject.js +285 -0
- package/src/bus/message.js +302 -0
- package/src/bus/nickname.js +86 -0
- package/src/bus/queue.js +131 -0
- package/src/bus/shake.js +26 -0
- package/src/bus/subscriber.js +296 -0
- package/src/bus/utils.js +357 -0
- package/src/chat/index.js +1842 -249
- package/src/cli.js +658 -95
- package/src/config.js +9 -2
- package/src/context/decisions.js +314 -0
- package/src/context/doctor.js +183 -0
- package/src/context/index.js +38 -0
- package/src/daemon/index.js +749 -94
- package/src/daemon/ops.js +395 -51
- package/src/daemon/providerSessions.js +291 -0
- package/src/daemon/run.js +34 -1
- package/src/daemon/status.js +24 -7
- package/src/doctor/index.js +50 -0
- package/src/init/index.js +264 -0
- package/src/skills/index.js +159 -0
- package/src/status/index.js +252 -0
- package/src/terminal/detect.js +64 -0
- package/src/terminal/index.js +8 -0
- package/src/terminal/iterm2.js +126 -0
- package/src/ufoo/agentsStore.js +41 -0
- package/src/ufoo/paths.js +46 -0
- package/src/utils/banner.js +73 -0
- package/bin/uclaude +0 -65
- package/bin/ucodex +0 -65
- package/modules/bus/scripts/bus-alert.sh +0 -185
- package/modules/bus/scripts/bus-listen.sh +0 -117
- package/modules/context/ASSUMPTIONS.md +0 -7
- package/modules/context/CONSTRAINTS.md +0 -7
- package/modules/context/CONTEXT-STRUCTURE.md +0 -49
- package/modules/context/DECISION-PROTOCOL.md +0 -62
- package/modules/context/HANDOFF.md +0 -33
- package/modules/context/RULES.md +0 -15
- package/modules/context/SKILLS/README.md +0 -14
- package/modules/context/SYSTEM.md +0 -18
- package/modules/context/TEMPLATES/assumptions.md +0 -4
- package/modules/context/TEMPLATES/constraints.md +0 -4
- package/modules/context/TEMPLATES/decision.md +0 -16
- package/modules/context/TEMPLATES/project-context-readme.md +0 -6
- package/modules/context/TEMPLATES/system.md +0 -3
- package/modules/context/TEMPLATES/terminology.md +0 -4
- package/modules/context/TERMINOLOGY.md +0 -10
- /package/scripts/{bus-alert.sh → .archived/bash-to-js-migration/bus-alert.sh} +0 -0
- /package/scripts/{bus-autotrigger.sh → .archived/bash-to-js-migration/bus-autotrigger.sh} +0 -0
- /package/scripts/{bus-daemon.sh → .archived/bash-to-js-migration/bus-daemon.sh} +0 -0
- /package/scripts/{bus-listen.sh → .archived/bash-to-js-migration/bus-listen.sh} +0 -0
- /package/scripts/{context-decisions.sh → .archived/bash-to-js-migration/context-decisions.sh} +0 -0
- /package/scripts/{context-doctor.sh → .archived/bash-to-js-migration/context-doctor.sh} +0 -0
- /package/scripts/{context-lint.sh → .archived/bash-to-js-migration/context-lint.sh} +0 -0
- /package/scripts/{doctor.sh → .archived/bash-to-js-migration/doctor.sh} +0 -0
- /package/scripts/{init.sh → .archived/bash-to-js-migration/init.sh} +0 -0
- /package/scripts/{skills.sh → .archived/bash-to-js-migration/skills.sh} +0 -0
- /package/scripts/{status.sh → .archived/bash-to-js-migration/status.sh} +0 -0
package/src/daemon/ops.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
const { spawn } = require("child_process");
|
|
1
|
+
const { spawn, spawnSync } = require("child_process");
|
|
2
2
|
const fs = require("fs");
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const { loadConfig } = require("../config");
|
|
5
|
+
const { getUfooPaths } = require("../ufoo/paths");
|
|
6
|
+
const { loadAgentsData, saveAgentsData } = require("../ufoo/agentsStore");
|
|
7
|
+
const { isAgentPidAlive } = require("../bus/utils");
|
|
8
|
+
const { isITerm2 } = require("../terminal/detect");
|
|
5
9
|
|
|
6
10
|
function resolveAgentId(projectRoot, agentId) {
|
|
7
11
|
if (!agentId) return agentId;
|
|
8
12
|
if (agentId.includes(":")) return agentId;
|
|
9
|
-
const busPath =
|
|
13
|
+
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
10
14
|
try {
|
|
11
15
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
12
|
-
const entries = Object.entries(bus.
|
|
16
|
+
const entries = Object.entries(bus.agents || {});
|
|
13
17
|
const match = entries.find(([, meta]) => meta?.nickname === agentId);
|
|
14
18
|
if (match) return match[0];
|
|
15
19
|
const targetType = agentId === "claude" ? "claude-code" : agentId;
|
|
@@ -23,87 +27,427 @@ function resolveAgentId(projectRoot, agentId) {
|
|
|
23
27
|
return agentId;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
function
|
|
30
|
+
function shellEscape(value) {
|
|
31
|
+
const str = String(value);
|
|
32
|
+
return `'${str.replace(/'/g, `'\\''`)}'`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function escapeAppleScriptString(str) {
|
|
36
|
+
return String(str).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 在 Terminal.app 中打开新窗口运行 agent
|
|
41
|
+
* 使用简单的 AppleScript,只负责打开窗口执行命令
|
|
42
|
+
* agent 进程的监控由 uclaude/ucodex 内部的 PTY wrapper 处理
|
|
43
|
+
*/
|
|
44
|
+
async function spawnTerminalAgent(projectRoot, agent, nickname = "") {
|
|
45
|
+
if (process.platform !== "darwin") {
|
|
46
|
+
throw new Error("Terminal mode is only supported on macOS");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const binary = agent === "codex" ? "ucodex" : "uclaude";
|
|
50
|
+
const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
|
|
51
|
+
const modeEnv = "UFOO_LAUNCH_MODE=terminal ";
|
|
52
|
+
const runCmd = `cd ${shellEscape(projectRoot)} && ${modeEnv}${nickEnv}${binary}`;
|
|
53
|
+
|
|
54
|
+
const script = [
|
|
55
|
+
'tell application "Terminal"',
|
|
56
|
+
`do script "${escapeAppleScriptString(runCmd)}"`,
|
|
57
|
+
"activate",
|
|
58
|
+
"end tell",
|
|
59
|
+
];
|
|
60
|
+
|
|
27
61
|
return new Promise((resolve, reject) => {
|
|
28
|
-
const proc = spawn("osascript",
|
|
62
|
+
const proc = spawn("osascript", script.flatMap((l) => ["-e", l]));
|
|
29
63
|
let stderr = "";
|
|
30
64
|
proc.stderr.on("data", (d) => {
|
|
31
65
|
stderr += d.toString("utf8");
|
|
32
66
|
});
|
|
33
67
|
proc.on("close", (code) => {
|
|
34
68
|
if (code === 0) resolve();
|
|
35
|
-
else reject(new Error(stderr || "
|
|
69
|
+
else reject(new Error(stderr || "Failed to open Terminal.app"));
|
|
36
70
|
});
|
|
37
71
|
});
|
|
38
72
|
}
|
|
39
73
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
74
|
+
/**
|
|
75
|
+
* 在 iTerm2 中打开新 tab 运行 agent
|
|
76
|
+
* 使用 AppleScript 控制 iTerm2,比 Terminal.app 更丰富的功能
|
|
77
|
+
*/
|
|
78
|
+
async function spawnITerm2Agent(projectRoot, agent, nickname = "") {
|
|
79
|
+
if (process.platform !== "darwin") {
|
|
80
|
+
throw new Error("iTerm2 mode is only supported on macOS");
|
|
81
|
+
}
|
|
43
82
|
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
83
|
+
const binary = agent === "codex" ? "ucodex" : "uclaude";
|
|
84
|
+
const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
|
|
85
|
+
const modeEnv = "UFOO_LAUNCH_MODE=terminal ";
|
|
86
|
+
const runCmd = `cd ${shellEscape(projectRoot)} && ${modeEnv}${nickEnv}${binary}`;
|
|
87
|
+
|
|
88
|
+
const script = [
|
|
89
|
+
'tell application "iTerm2"',
|
|
90
|
+
" tell current window",
|
|
91
|
+
` create tab with default profile command "${escapeAppleScriptString(runCmd)}"`,
|
|
92
|
+
" end tell",
|
|
93
|
+
" activate",
|
|
94
|
+
"end tell",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const proc = spawn("osascript", script.flatMap((l) => ["-e", l]));
|
|
99
|
+
let stderr = "";
|
|
100
|
+
proc.stderr.on("data", (d) => {
|
|
101
|
+
stderr += d.toString("utf8");
|
|
102
|
+
});
|
|
103
|
+
proc.on("close", (code) => {
|
|
104
|
+
if (code === 0) resolve();
|
|
105
|
+
else reject(new Error(stderr || "Failed to open iTerm2 tab"));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
47
108
|
}
|
|
48
109
|
|
|
49
|
-
async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "") {
|
|
110
|
+
async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
|
|
50
111
|
const runner = path.join(projectRoot, "bin", "ufoo.js");
|
|
51
|
-
const logDir =
|
|
52
|
-
|
|
53
|
-
|
|
112
|
+
const logDir = getUfooPaths(projectRoot).runDir;
|
|
113
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
const crypto = require("crypto");
|
|
116
|
+
const EventBus = require("../bus");
|
|
117
|
+
const children = [];
|
|
118
|
+
const subscriberIds = [];
|
|
119
|
+
|
|
120
|
+
// 初始化 bus
|
|
121
|
+
const bus = new EventBus(projectRoot);
|
|
122
|
+
await bus.init();
|
|
123
|
+
|
|
124
|
+
const originalPid = process.pid;
|
|
125
|
+
|
|
54
126
|
for (let i = 0; i < count; i += 1) {
|
|
55
|
-
const
|
|
56
|
-
|
|
127
|
+
const logFile = path.join(logDir, `agent-${agent}-${Date.now()}-${i}.log`);
|
|
128
|
+
const errLog = fs.openSync(logFile, "a");
|
|
129
|
+
|
|
130
|
+
// 预生成 session ID
|
|
131
|
+
const sessionId = crypto.randomBytes(4).toString("hex");
|
|
132
|
+
const agentType = agent === "codex" ? "codex" : "claude-code";
|
|
133
|
+
const subscriberId = `${agentType}:${sessionId}`;
|
|
134
|
+
subscriberIds.push(subscriberId);
|
|
135
|
+
|
|
136
|
+
// Daemon 预先在 bus 中注册
|
|
137
|
+
bus.loadBusData();
|
|
138
|
+
process.env.UFOO_PARENT_PID = String(originalPid);
|
|
139
|
+
|
|
140
|
+
const finalNickname = count > 1 ? `${nickname || agent}-${i + 1}` : (nickname || "");
|
|
141
|
+
const usePty = process.env.UFOO_INTERNAL_PTY !== "0";
|
|
142
|
+
const launchMode = usePty ? "internal-pty" : "internal";
|
|
143
|
+
|
|
144
|
+
// 传递 launch_mode 和 parent PID 到 join
|
|
145
|
+
await bus.subscriberManager.join(sessionId, agentType, finalNickname, {
|
|
146
|
+
launchMode,
|
|
147
|
+
parentPid: originalPid,
|
|
148
|
+
});
|
|
149
|
+
bus.saveBusData();
|
|
150
|
+
|
|
151
|
+
const runnerCmd = usePty ? "agent-pty-runner" : "agent-runner";
|
|
152
|
+
const child = spawn(process.execPath, [runner, runnerCmd, agent], {
|
|
153
|
+
// 关键改动:不使用 detached,daemon 作为父进程
|
|
154
|
+
detached: false,
|
|
57
155
|
stdio: ["ignore", errLog, errLog],
|
|
58
156
|
cwd: projectRoot,
|
|
59
|
-
env: {
|
|
157
|
+
env: {
|
|
158
|
+
...process.env,
|
|
159
|
+
UFOO_INTERNAL_AGENT: "1",
|
|
160
|
+
UFOO_INTERNAL_PTY: usePty ? "1" : "0",
|
|
161
|
+
UFOO_SUBSCRIBER_ID: subscriberId, // 直接传递 subscriber ID
|
|
162
|
+
UFOO_NICKNAME: finalNickname,
|
|
163
|
+
UFOO_LAUNCH_MODE: usePty ? "internal-pty" : "internal",
|
|
164
|
+
UFOO_PARENT_PID: String(originalPid),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// 本地日志记录
|
|
169
|
+
child.on("exit", (code, signal) => {
|
|
170
|
+
try {
|
|
171
|
+
fs.closeSync(errLog);
|
|
172
|
+
} catch {
|
|
173
|
+
// ignore
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (signal) {
|
|
177
|
+
fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} killed by signal ${signal}\n`);
|
|
178
|
+
} else {
|
|
179
|
+
fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} exited with code ${code}\n`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
child.on("error", (err) => {
|
|
184
|
+
fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} spawn failed: ${err.message}\n`);
|
|
185
|
+
try {
|
|
186
|
+
fs.closeSync(errLog);
|
|
187
|
+
} catch {
|
|
188
|
+
// ignore
|
|
189
|
+
}
|
|
60
190
|
});
|
|
61
|
-
|
|
191
|
+
|
|
192
|
+
// 注册到进程管理器(父子进程监控)
|
|
193
|
+
if (processManager) {
|
|
194
|
+
processManager.register(subscriberId, child);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
children.push(child);
|
|
62
198
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
199
|
+
|
|
200
|
+
return { children, subscriberIds };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Find the first idle tmux pane in the SAME window as ufoo chat.
|
|
205
|
+
* Looks for panes running a plain shell, excluding the chat pane itself.
|
|
206
|
+
* Returns the pane target (e.g. "%5") or null.
|
|
207
|
+
*/
|
|
208
|
+
function findIdleTmuxPane() {
|
|
209
|
+
const myPaneId = process.env.TMUX_PANE || "";
|
|
210
|
+
if (!myPaneId) return null;
|
|
211
|
+
|
|
212
|
+
// List panes in the same window as ufoo chat
|
|
213
|
+
const result = spawnSync("tmux", [
|
|
214
|
+
"list-panes", "-t", myPaneId,
|
|
215
|
+
"-F", "#{pane_id}\t#{pane_current_command}",
|
|
216
|
+
], { stdio: "pipe", encoding: "utf8" });
|
|
217
|
+
|
|
218
|
+
if (result.status !== 0 || !result.stdout) return null;
|
|
219
|
+
|
|
220
|
+
const shells = new Set(["bash", "zsh", "fish", "sh", "dash", "ksh", "login"]);
|
|
221
|
+
|
|
222
|
+
for (const line of result.stdout.trim().split("\n")) {
|
|
223
|
+
const [paneId, cmd] = line.split("\t");
|
|
224
|
+
if (!paneId || !cmd) continue;
|
|
225
|
+
// Skip ufoo chat's own pane
|
|
226
|
+
if (paneId === myPaneId) continue;
|
|
227
|
+
// Only use panes running a plain shell
|
|
228
|
+
if (shells.has(path.basename(cmd))) {
|
|
229
|
+
return paneId;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extraEnv = "") {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const binary = agent === "codex" ? "ucodex" : "uclaude";
|
|
238
|
+
const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
|
|
239
|
+
const modeEnv = "UFOO_LAUNCH_MODE=tmux ";
|
|
240
|
+
const ttyEnv = "UFOO_TTY_OVERRIDE=$(tty) ";
|
|
241
|
+
const args = Array.isArray(extraArgs) ? extraArgs : [];
|
|
242
|
+
const envPrefix = extraEnv ? `${String(extraEnv).trim()} ` : "";
|
|
243
|
+
const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
|
|
244
|
+
|
|
245
|
+
// tmux natively sets $TMUX_PANE for each pane — no need to override
|
|
246
|
+
const runCmd = `cd ${shellEscape(projectRoot)} && ${modeEnv}${nickEnv}${ttyEnv}${envPrefix}${binary}${argText}`;
|
|
247
|
+
const windowName = nickname || `${agent}-${Date.now()}`;
|
|
248
|
+
const targetSession = process.env.UFOO_TMUX_SESSION || "";
|
|
249
|
+
|
|
250
|
+
// Find an idle pane in the same window, or split a new one
|
|
251
|
+
const idlePane = findIdleTmuxPane();
|
|
252
|
+
const myPane = process.env.TMUX_PANE || "";
|
|
253
|
+
|
|
254
|
+
if (idlePane) {
|
|
255
|
+
// Reuse idle pane: send the launch command there
|
|
256
|
+
const proc = spawn("tmux", ["send-keys", "-t", idlePane, runCmd, "Enter"]);
|
|
257
|
+
let stderr = "";
|
|
258
|
+
proc.stderr.on("data", (d) => { stderr += d.toString("utf8"); });
|
|
259
|
+
proc.on("close", (code) => {
|
|
260
|
+
if (code === 0) resolve();
|
|
261
|
+
else reject(new Error(stderr || "tmux send-keys failed"));
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
// No idle pane — split current window to create a new pane
|
|
265
|
+
const splitTarget = myPane || (targetSession ? `${targetSession}:` : "");
|
|
266
|
+
const splitArgs = ["split-window", "-d", "-h"];
|
|
267
|
+
if (splitTarget) splitArgs.push("-t", splitTarget);
|
|
268
|
+
splitArgs.push(runCmd);
|
|
269
|
+
|
|
270
|
+
const proc = spawn("tmux", splitArgs);
|
|
271
|
+
let stderr = "";
|
|
272
|
+
proc.stderr.on("data", (d) => { stderr += d.toString("utf8"); });
|
|
273
|
+
proc.on("close", (code) => {
|
|
274
|
+
if (code === 0) resolve();
|
|
275
|
+
else reject(new Error(stderr || "tmux split-window failed"));
|
|
276
|
+
});
|
|
68
277
|
}
|
|
69
|
-
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Detect the effective launch mode based on the current environment.
|
|
283
|
+
*/
|
|
284
|
+
function detectLaunchMode() {
|
|
285
|
+
// Inside tmux → use tmux mode
|
|
286
|
+
if (process.env.TMUX) return "tmux";
|
|
287
|
+
// macOS with Terminal.app / iTerm → use terminal mode
|
|
288
|
+
if (process.platform === "darwin") return "terminal";
|
|
289
|
+
// Fallback
|
|
290
|
+
return "internal";
|
|
70
291
|
}
|
|
71
292
|
|
|
72
|
-
async function
|
|
293
|
+
async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
|
|
73
294
|
const config = loadConfig(projectRoot);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
295
|
+
let mode = config.launchMode || "auto";
|
|
296
|
+
if (mode === "auto") {
|
|
297
|
+
mode = detectLaunchMode();
|
|
77
298
|
}
|
|
78
|
-
|
|
79
|
-
|
|
299
|
+
|
|
300
|
+
if (mode === "tmux") {
|
|
301
|
+
// Check if tmux is available
|
|
302
|
+
const tmuxCheck = spawn("tmux", ["list-sessions"], { stdio: "pipe" });
|
|
303
|
+
let stdout = "";
|
|
304
|
+
tmuxCheck.stdout.on("data", (d) => {
|
|
305
|
+
stdout += d.toString("utf8");
|
|
306
|
+
});
|
|
307
|
+
const tmuxAvailable = await new Promise((resolve) => {
|
|
308
|
+
tmuxCheck.on("close", (code) => resolve(code === 0));
|
|
309
|
+
tmuxCheck.on("error", () => resolve(false));
|
|
310
|
+
});
|
|
311
|
+
if (!tmuxAvailable) {
|
|
312
|
+
throw new Error("tmux is not available or no tmux session is running");
|
|
313
|
+
}
|
|
314
|
+
// If UFOO_TMUX_SESSION not set, use first available session
|
|
315
|
+
if (!process.env.UFOO_TMUX_SESSION && stdout) {
|
|
316
|
+
const sessions = stdout.trim().split("\n");
|
|
317
|
+
if (sessions.length > 0) {
|
|
318
|
+
const firstSession = sessions[0].split(":")[0];
|
|
319
|
+
process.env.UFOO_TMUX_SESSION = firstSession;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (let i = 0; i < count; i += 1) {
|
|
323
|
+
const nick = count > 1 ? `${nickname || agent}-${i + 1}` : (nickname || "");
|
|
324
|
+
// eslint-disable-next-line no-await-in-loop
|
|
325
|
+
await spawnTmuxWindow(projectRoot, agent, nick);
|
|
326
|
+
}
|
|
327
|
+
return { mode: "tmux" };
|
|
80
328
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
329
|
+
|
|
330
|
+
// terminal mode - 使用 AppleScript 打开窗口 (iTerm2 优先)
|
|
331
|
+
if (mode === "terminal") {
|
|
332
|
+
const useITerm = isITerm2();
|
|
333
|
+
for (let i = 0; i < count; i += 1) {
|
|
334
|
+
const nick = count > 1 ? `${nickname || agent}-${i + 1}` : (nickname || "");
|
|
335
|
+
// eslint-disable-next-line no-await-in-loop
|
|
336
|
+
if (useITerm) {
|
|
337
|
+
await spawnITerm2Agent(projectRoot, agent, nick);
|
|
338
|
+
} else {
|
|
339
|
+
await spawnTerminalAgent(projectRoot, agent, nick);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { mode: "terminal" };
|
|
94
343
|
}
|
|
344
|
+
|
|
345
|
+
// internal mode - 使用 PTY 方式启动
|
|
346
|
+
const result = await spawnInternalAgent(projectRoot, agent, count, nickname, processManager);
|
|
347
|
+
return { mode: "internal", subscriberIds: result.subscriberIds };
|
|
95
348
|
}
|
|
96
349
|
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
350
|
+
function normalizeAgentType(agentType) {
|
|
351
|
+
if (agentType === "claude-code") return "claude";
|
|
352
|
+
if (agentType === "codex") return "codex";
|
|
353
|
+
return agentType;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function buildResumeArgs(agent, sessionId) {
|
|
357
|
+
if (!sessionId) return [];
|
|
358
|
+
if (agent === "codex") return ["resume", sessionId];
|
|
359
|
+
if (agent === "claude") return ["--session-id", sessionId];
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isActiveAgent(meta) {
|
|
364
|
+
if (!meta || meta.status !== "active") return false;
|
|
365
|
+
if (meta.pid && !isAgentPidAlive(meta.pid)) return false;
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function resumeAgents(projectRoot, target = "", processManager = null) {
|
|
370
|
+
const config = loadConfig(projectRoot);
|
|
371
|
+
const mode = config.launchMode || "internal";
|
|
372
|
+
const filePath = getUfooPaths(projectRoot).agentsFile;
|
|
373
|
+
const data = loadAgentsData(filePath);
|
|
374
|
+
const entries = Object.entries(data.agents || {});
|
|
375
|
+
|
|
376
|
+
let targets = entries;
|
|
377
|
+
if (target) {
|
|
378
|
+
if (target.includes(":")) {
|
|
379
|
+
targets = entries.filter(([id]) => id === target);
|
|
380
|
+
} else {
|
|
381
|
+
targets = entries.filter(([, meta]) => meta && meta.nickname === target);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const resumable = [];
|
|
386
|
+
const skipped = [];
|
|
387
|
+
|
|
388
|
+
for (const [id, meta] of targets) {
|
|
389
|
+
if (!meta || !meta.provider_session_id) {
|
|
390
|
+
skipped.push({ id, reason: "no provider session" });
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (isActiveAgent(meta)) {
|
|
394
|
+
skipped.push({ id, reason: "already active" });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
const agent = normalizeAgentType(meta.agent_type);
|
|
398
|
+
if (agent !== "codex" && agent !== "claude") {
|
|
399
|
+
skipped.push({ id, reason: "unsupported agent type" });
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
resumable.push({ id, meta, agent });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (resumable.length === 0) {
|
|
406
|
+
return { ok: true, resumed: [], skipped };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Clear old nicknames to allow reuse
|
|
410
|
+
let updated = false;
|
|
411
|
+
for (const item of resumable) {
|
|
412
|
+
if (item.meta && item.meta.nickname) {
|
|
413
|
+
data.agents[item.id] = { ...item.meta, nickname: "" };
|
|
414
|
+
updated = true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (updated) {
|
|
418
|
+
saveAgentsData(filePath, data);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const resumed = [];
|
|
422
|
+
|
|
423
|
+
// tmux 模式使用 tmux new-window 恢复
|
|
424
|
+
if (mode === "tmux") {
|
|
425
|
+
for (const item of resumable) {
|
|
426
|
+
const nickname = item.meta.nickname || "";
|
|
427
|
+
const sessionId = item.meta.provider_session_id;
|
|
428
|
+
const args = buildResumeArgs(item.agent, sessionId);
|
|
429
|
+
const envPrefix = "UFOO_SKIP_SESSION_PROBE=1";
|
|
430
|
+
// eslint-disable-next-line no-await-in-loop
|
|
431
|
+
await spawnTmuxWindow(projectRoot, item.agent, nickname, args, envPrefix);
|
|
432
|
+
resumed.push({ id: item.id, nickname, agent: item.agent, sessionId, reused: false });
|
|
433
|
+
}
|
|
434
|
+
return { ok: true, resumed, skipped };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// internal 模式暂不支持 resume(需要用户手动启动)
|
|
438
|
+
for (const item of resumable) {
|
|
439
|
+
skipped.push({ id: item.id, reason: "internal mode requires manual restart" });
|
|
100
440
|
}
|
|
441
|
+
return { ok: true, resumed, skipped };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function closeAgent(projectRoot, agentId) {
|
|
101
445
|
const resolvedId = resolveAgentId(projectRoot, agentId);
|
|
102
|
-
const busPath =
|
|
446
|
+
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
103
447
|
let pid = null;
|
|
104
448
|
try {
|
|
105
449
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
106
|
-
const entry = bus.
|
|
450
|
+
const entry = bus.agents?.[resolvedId];
|
|
107
451
|
if (entry && entry.pid) pid = entry.pid;
|
|
108
452
|
} catch {
|
|
109
453
|
pid = null;
|
|
@@ -117,4 +461,4 @@ async function closeAgent(projectRoot, agentId) {
|
|
|
117
461
|
}
|
|
118
462
|
}
|
|
119
463
|
|
|
120
|
-
module.exports = {
|
|
464
|
+
module.exports = { launchAgent, closeAgent, resumeAgents };
|