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.
Files changed (91) hide show
  1. package/README.md +67 -8
  2. package/README.zh-CN.md +9 -7
  3. package/SKILLS/ufoo/SKILL.md +117 -0
  4. package/SKILLS/uinit/SKILL.md +73 -0
  5. package/SKILLS/ustatus/SKILL.md +36 -0
  6. package/bin/uclaude.js +13 -0
  7. package/bin/ucodex.js +13 -0
  8. package/bin/ufoo +9 -31
  9. package/bin/ufoo.js +13 -0
  10. package/modules/AGENTS.template.md +15 -7
  11. package/modules/bus/README.md +28 -23
  12. package/modules/bus/SKILLS/ubus/SKILL.md +18 -8
  13. package/modules/context/README.md +18 -40
  14. package/modules/context/SKILLS/uctx/SKILL.md +61 -1
  15. package/package.json +16 -4
  16. package/scripts/.archived/bash-to-js-migration/README.md +46 -0
  17. package/scripts/.archived/bash-to-js-migration/banner.sh +89 -0
  18. package/scripts/{bus-inject.sh → .archived/bash-to-js-migration/bus-inject.sh} +35 -3
  19. package/scripts/{bus.sh → .archived/bash-to-js-migration/bus.sh} +3 -1
  20. package/scripts/banner.sh +2 -89
  21. package/scripts/postinstall.js +59 -0
  22. package/src/agent/cliRunner.js +33 -5
  23. package/src/agent/internalRunner.js +78 -51
  24. package/src/agent/launcher.js +702 -0
  25. package/src/agent/notifier.js +200 -0
  26. package/src/agent/ptyRunner.js +377 -0
  27. package/src/agent/ptyWrapper.js +354 -0
  28. package/src/agent/readyDetector.js +159 -0
  29. package/src/agent/ufooAgent.js +37 -42
  30. package/src/bus/API_DESIGN.md +204 -0
  31. package/src/bus/activate.js +156 -0
  32. package/src/bus/daemon.js +308 -0
  33. package/src/bus/index.js +785 -0
  34. package/src/bus/inject.js +285 -0
  35. package/src/bus/message.js +302 -0
  36. package/src/bus/nickname.js +86 -0
  37. package/src/bus/queue.js +131 -0
  38. package/src/bus/shake.js +26 -0
  39. package/src/bus/subscriber.js +296 -0
  40. package/src/bus/utils.js +357 -0
  41. package/src/chat/index.js +1842 -249
  42. package/src/cli.js +658 -95
  43. package/src/config.js +9 -2
  44. package/src/context/decisions.js +314 -0
  45. package/src/context/doctor.js +183 -0
  46. package/src/context/index.js +38 -0
  47. package/src/daemon/index.js +749 -94
  48. package/src/daemon/ops.js +395 -51
  49. package/src/daemon/providerSessions.js +291 -0
  50. package/src/daemon/run.js +34 -1
  51. package/src/daemon/status.js +24 -7
  52. package/src/doctor/index.js +50 -0
  53. package/src/init/index.js +264 -0
  54. package/src/skills/index.js +159 -0
  55. package/src/status/index.js +252 -0
  56. package/src/terminal/detect.js +64 -0
  57. package/src/terminal/index.js +8 -0
  58. package/src/terminal/iterm2.js +126 -0
  59. package/src/ufoo/agentsStore.js +41 -0
  60. package/src/ufoo/paths.js +46 -0
  61. package/src/utils/banner.js +73 -0
  62. package/bin/uclaude +0 -65
  63. package/bin/ucodex +0 -65
  64. package/modules/bus/scripts/bus-alert.sh +0 -185
  65. package/modules/bus/scripts/bus-listen.sh +0 -117
  66. package/modules/context/ASSUMPTIONS.md +0 -7
  67. package/modules/context/CONSTRAINTS.md +0 -7
  68. package/modules/context/CONTEXT-STRUCTURE.md +0 -49
  69. package/modules/context/DECISION-PROTOCOL.md +0 -62
  70. package/modules/context/HANDOFF.md +0 -33
  71. package/modules/context/RULES.md +0 -15
  72. package/modules/context/SKILLS/README.md +0 -14
  73. package/modules/context/SYSTEM.md +0 -18
  74. package/modules/context/TEMPLATES/assumptions.md +0 -4
  75. package/modules/context/TEMPLATES/constraints.md +0 -4
  76. package/modules/context/TEMPLATES/decision.md +0 -16
  77. package/modules/context/TEMPLATES/project-context-readme.md +0 -6
  78. package/modules/context/TEMPLATES/system.md +0 -3
  79. package/modules/context/TEMPLATES/terminology.md +0 -4
  80. package/modules/context/TERMINOLOGY.md +0 -10
  81. /package/scripts/{bus-alert.sh → .archived/bash-to-js-migration/bus-alert.sh} +0 -0
  82. /package/scripts/{bus-autotrigger.sh → .archived/bash-to-js-migration/bus-autotrigger.sh} +0 -0
  83. /package/scripts/{bus-daemon.sh → .archived/bash-to-js-migration/bus-daemon.sh} +0 -0
  84. /package/scripts/{bus-listen.sh → .archived/bash-to-js-migration/bus-listen.sh} +0 -0
  85. /package/scripts/{context-decisions.sh → .archived/bash-to-js-migration/context-decisions.sh} +0 -0
  86. /package/scripts/{context-doctor.sh → .archived/bash-to-js-migration/context-doctor.sh} +0 -0
  87. /package/scripts/{context-lint.sh → .archived/bash-to-js-migration/context-lint.sh} +0 -0
  88. /package/scripts/{doctor.sh → .archived/bash-to-js-migration/doctor.sh} +0 -0
  89. /package/scripts/{init.sh → .archived/bash-to-js-migration/init.sh} +0 -0
  90. /package/scripts/{skills.sh → .archived/bash-to-js-migration/skills.sh} +0 -0
  91. /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 = path.join(projectRoot, ".ufoo", "bus", "bus.json");
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.subscribers || {});
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 runAppleScript(lines) {
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", lines.flatMap((l) => ["-e", l]));
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 || "osascript failed"));
69
+ else reject(new Error(stderr || "Failed to open Terminal.app"));
36
70
  });
37
71
  });
38
72
  }
39
73
 
40
- function escapeCommand(cmd) {
41
- return cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
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
- function shellEscape(value) {
45
- const str = String(value);
46
- return `'${str.replace(/'/g, `'\\''`)}'`;
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 = path.join(projectRoot, ".ufoo", "run");
52
- const logFile = path.join(logDir, `agent-${agent}-${Date.now()}.log`);
53
- const errLog = fs.openSync(logFile, "a");
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 child = spawn(process.execPath, [runner, "agent-runner", agent], {
56
- detached: true,
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: { ...process.env, UFOO_INTERNAL_AGENT: "1", UFOO_NICKNAME: nickname || "" },
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
- child.unref();
191
+
192
+ // 注册到进程管理器(父子进程监控)
193
+ if (processManager) {
194
+ processManager.register(subscriberId, child);
195
+ }
196
+
197
+ children.push(child);
62
198
  }
63
- setTimeout(() => {
64
- try {
65
- fs.closeSync(errLog);
66
- } catch {
67
- // ignore
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
- }, 1000);
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 spawnAgent(projectRoot, agent, count = 1, nickname = "") {
293
+ async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
73
294
  const config = loadConfig(projectRoot);
74
- if (config.launchMode === "internal") {
75
- await spawnInternalAgent(projectRoot, agent, count, nickname);
76
- return;
295
+ let mode = config.launchMode || "auto";
296
+ if (mode === "auto") {
297
+ mode = detectLaunchMode();
77
298
  }
78
- if (process.platform !== "darwin") {
79
- throw new Error("spawnAgent is only supported on macOS Terminal.app");
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
- const binary = agent === "codex" ? "ucodex" : "uclaude";
82
- const cwdCmd = `cd "${projectRoot}"`;
83
- const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
84
- const runCmd = `${cwdCmd} && ${nickEnv}${binary}`;
85
- const script = [
86
- 'tell application "Terminal"',
87
- `do script "${escapeCommand(runCmd)}"`,
88
- "activate",
89
- "end tell",
90
- ];
91
- for (let i = 0; i < count; i += 1) {
92
- // eslint-disable-next-line no-await-in-loop
93
- await runAppleScript(script);
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
- async function closeAgent(projectRoot, agentId) {
98
- if (process.platform !== "darwin") {
99
- return false;
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 = path.join(projectRoot, ".ufoo", "bus", "bus.json");
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.subscribers?.[resolvedId];
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 = { spawnAgent, closeAgent };
464
+ module.exports = { launchAgent, closeAgent, resumeAgents };