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
@@ -1,10 +1,87 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const net = require("net");
4
+ const { spawnSync } = require("child_process");
4
5
  const { runUfooAgent } = require("../agent/ufooAgent");
5
- const { spawnAgent, closeAgent } = require("./ops");
6
+ const { launchAgent, closeAgent, resumeAgents } = require("./ops");
6
7
  const { buildStatus } = require("./status");
7
- const { spawnSync } = require("child_process");
8
+ const EventBus = require("../bus");
9
+ const NicknameManager = require("../bus/nickname");
10
+ const { generateInstanceId, subscriberToSafeName } = require("../bus/utils");
11
+ const { getUfooPaths } = require("../ufoo/paths");
12
+ const { scheduleProviderSessionProbe, loadProviderSessionCache } = require("./providerSessions");
13
+ const { loadConfig } = require("../config");
14
+
15
+ /**
16
+ * Agent 进程管理器 - daemon 作为父进程监控所有 internal agents
17
+ */
18
+ class AgentProcessManager {
19
+ constructor(projectRoot) {
20
+ this.projectRoot = projectRoot;
21
+ this.processes = new Map(); // subscriber_id -> child_process
22
+ }
23
+
24
+ /**
25
+ * 注册子进程并监听退出事件
26
+ */
27
+ register(subscriberId, childProcess) {
28
+ if (!subscriberId || !childProcess) return;
29
+
30
+ this.processes.set(subscriberId, childProcess);
31
+
32
+ childProcess.on("exit", (code, signal) => {
33
+ this.processes.delete(subscriberId);
34
+
35
+ // 自动清理 bus 状态
36
+ try {
37
+ const eventBus = new EventBus(this.projectRoot);
38
+ eventBus.loadBusData();
39
+ if (eventBus.busData.agents?.[subscriberId]) {
40
+ eventBus.busData.agents[subscriberId].status = "inactive";
41
+ eventBus.busData.agents[subscriberId].last_seen = new Date().toISOString();
42
+ eventBus.saveBusData();
43
+ console.log(`[daemon] Agent ${subscriberId} exited (code=${code}, signal=${signal}), marked inactive`);
44
+ }
45
+ } catch (err) {
46
+ console.error(`[daemon] Failed to cleanup ${subscriberId}:`, err.message);
47
+ }
48
+ });
49
+
50
+ childProcess.on("error", (err) => {
51
+ console.error(`[daemon] Agent ${subscriberId} error:`, err.message);
52
+ this.processes.delete(subscriberId);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * 获取运行中的进程
58
+ */
59
+ get(subscriberId) {
60
+ return this.processes.get(subscriberId);
61
+ }
62
+
63
+ /**
64
+ * 获取所有进程数量
65
+ */
66
+ count() {
67
+ return this.processes.size;
68
+ }
69
+
70
+ /**
71
+ * 清理所有子进程
72
+ */
73
+ cleanup() {
74
+ for (const [subscriberId, child] of this.processes.entries()) {
75
+ try {
76
+ child.kill("SIGTERM");
77
+ console.log(`[daemon] Killed agent ${subscriberId}`);
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }
82
+ this.processes.clear();
83
+ }
84
+ }
8
85
 
9
86
  function sleep(ms) {
10
87
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -12,14 +89,15 @@ function sleep(ms) {
12
89
 
13
90
  async function renameSpawnedAgent(projectRoot, agentType, nickname, startIso) {
14
91
  if (!nickname) return null;
15
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
16
- const script = path.join(projectRoot, "scripts", "bus.sh");
92
+ const busPath = getUfooPaths(projectRoot).agentsFile;
17
93
  const targetType = agentType === "codex" ? "codex" : "claude-code";
18
94
  const deadline = Date.now() + 10000;
95
+ const eventBus = new EventBus(projectRoot);
96
+ let lastError = null;
19
97
  while (Date.now() < deadline) {
20
98
  try {
21
99
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
22
- let entries = Object.entries(bus.subscribers || {})
100
+ let entries = Object.entries(bus.agents || {})
23
101
  .filter(([, meta]) => meta && meta.agent_type === targetType && meta.status === "active");
24
102
  if (startIso) {
25
103
  entries = entries.filter(([, meta]) => (meta.joined_at || "") >= startIso);
@@ -32,16 +110,15 @@ async function renameSpawnedAgent(projectRoot, agentType, nickname, startIso) {
32
110
  if (candidates.length === 0) candidates = entries;
33
111
  candidates.sort((a, b) => (a[1].joined_at || "").localeCompare(b[1].joined_at || ""));
34
112
  const [agentId] = candidates[candidates.length - 1];
35
- const res = spawnSync("bash", [script, "rename", agentId, nickname], { cwd: projectRoot });
36
- if (res.status === 0) return { ok: true, agent_id: agentId, nickname };
37
- const err = (res.stderr || res.stdout || "").toString("utf8").trim();
38
- return { ok: false, agent_id: agentId, nickname, error: err || "rename failed" };
39
- } catch {
113
+ await eventBus.rename(agentId, nickname, "ufoo-agent");
114
+ return { ok: true, agent_id: agentId, nickname };
115
+ } catch (err) {
116
+ lastError = err && err.message ? err.message : String(err || "rename failed");
40
117
  // ignore and retry
41
118
  }
42
119
  await sleep(200);
43
120
  }
44
- return { ok: false, nickname, error: "rename timeout" };
121
+ return { ok: false, nickname, error: lastError || "rename timeout" };
45
122
  }
46
123
 
47
124
  function ensureDir(dir) {
@@ -49,15 +126,15 @@ function ensureDir(dir) {
49
126
  }
50
127
 
51
128
  function socketPath(projectRoot) {
52
- return path.join(projectRoot, ".ufoo", "run", "ufoo.sock");
129
+ return getUfooPaths(projectRoot).ufooSock;
53
130
  }
54
131
 
55
132
  function pidPath(projectRoot) {
56
- return path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.pid");
133
+ return getUfooPaths(projectRoot).ufooDaemonPid;
57
134
  }
58
135
 
59
136
  function logPath(projectRoot) {
60
- return path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.log");
137
+ return getUfooPaths(projectRoot).ufooDaemonLog;
61
138
  }
62
139
 
63
140
  function writePid(projectRoot) {
@@ -77,14 +154,12 @@ function isRunning(projectRoot) {
77
154
  if (!pid) return false;
78
155
  try {
79
156
  process.kill(pid, 0);
157
+ // PID 存活即认为 daemon 正在运行
158
+ // 不调用 isDaemonProcess() — ps 命令可能有瞬态失败导致误判
159
+ // 不删除 PID/socket — 破坏性操作会导致竞争条件
80
160
  return true;
81
161
  } catch {
82
- try {
83
- fs.unlinkSync(pidPath(projectRoot));
84
- } catch {
85
- // ignore
86
- }
87
- removeSocket(projectRoot);
162
+ // 进程已死
88
163
  return false;
89
164
  }
90
165
  }
@@ -108,7 +183,7 @@ function parseJsonLines(buffer) {
108
183
  }
109
184
 
110
185
  function readBus(projectRoot) {
111
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
186
+ const busPath = getUfooPaths(projectRoot).agentsFile;
112
187
  try {
113
188
  return JSON.parse(fs.readFileSync(busPath, "utf8"));
114
189
  } catch {
@@ -119,7 +194,7 @@ function readBus(projectRoot) {
119
194
  function listSubscribers(projectRoot, agentType) {
120
195
  const bus = readBus(projectRoot);
121
196
  if (!bus) return [];
122
- return Object.entries(bus.subscribers || {})
197
+ return Object.entries(bus.agents || {})
123
198
  .filter(([, meta]) => meta && meta.agent_type === agentType)
124
199
  .map(([id]) => id);
125
200
  }
@@ -136,18 +211,12 @@ async function waitForNewSubscriber(projectRoot, agentType, existing, timeoutMs
136
211
  return null;
137
212
  }
138
213
 
139
- function renameSubscriber(projectRoot, subscriberId, nickname) {
140
- const script = path.join(projectRoot, "scripts", "bus.sh");
141
- const res = spawnSync("bash", [script, "rename", subscriberId, nickname], { cwd: projectRoot });
142
- return res.status === 0;
143
- }
144
-
145
214
  function checkAndCleanupNickname(projectRoot, nickname) {
146
215
  if (!nickname) return { existing: null, cleaned: false };
147
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
216
+ const busPath = getUfooPaths(projectRoot).agentsFile;
148
217
  try {
149
218
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
150
- const entries = Object.entries(bus.subscribers || {})
219
+ const entries = Object.entries(bus.agents || {})
151
220
  .filter(([, meta]) => meta && meta.nickname === nickname);
152
221
 
153
222
  if (entries.length === 0) {
@@ -162,7 +231,7 @@ function checkAndCleanupNickname(projectRoot, nickname) {
162
231
 
163
232
  // Clean up offline agents with same nickname
164
233
  for (const [agentId] of entries) {
165
- delete bus.subscribers[agentId];
234
+ delete bus.agents[agentId];
166
235
  }
167
236
  fs.writeFileSync(busPath, JSON.stringify(bus, null, 2));
168
237
  return { existing: null, cleaned: true };
@@ -171,10 +240,10 @@ function checkAndCleanupNickname(projectRoot, nickname) {
171
240
  }
172
241
  }
173
242
 
174
- async function handleOps(projectRoot, ops = []) {
243
+ async function handleOps(projectRoot, ops = [], processManager = null) {
175
244
  const results = [];
176
245
  for (const op of ops) {
177
- if (op.action === "spawn") {
246
+ if (op.action === "launch") {
178
247
  const count = op.count || 1;
179
248
  const agent = op.agent === "codex" ? "codex" : "claude";
180
249
  const nickname = op.nickname || "";
@@ -182,7 +251,7 @@ async function handleOps(projectRoot, ops = []) {
182
251
  const startIso = startTime.toISOString();
183
252
  if (nickname && count > 1) {
184
253
  results.push({
185
- action: "spawn",
254
+ action: "launch",
186
255
  ok: false,
187
256
  agent,
188
257
  count,
@@ -196,7 +265,7 @@ async function handleOps(projectRoot, ops = []) {
196
265
  if (existing) {
197
266
  // Agent with this nickname already exists and is active
198
267
  results.push({
199
- action: "spawn",
268
+ action: "launch",
200
269
  ok: true,
201
270
  agent,
202
271
  count,
@@ -208,8 +277,15 @@ async function handleOps(projectRoot, ops = []) {
208
277
  continue;
209
278
  }
210
279
  // eslint-disable-next-line no-await-in-loop
211
- await spawnAgent(projectRoot, agent, count, nickname);
212
- results.push({ action: "spawn", ok: true, agent, count, nickname: nickname || undefined });
280
+ const launchResult = await launchAgent(projectRoot, agent, count, nickname, processManager);
281
+ results.push({
282
+ action: "launch",
283
+ mode: launchResult.mode,
284
+ ok: true,
285
+ agent,
286
+ count,
287
+ nickname: nickname || undefined
288
+ });
213
289
  if (nickname) {
214
290
  // eslint-disable-next-line no-await-in-loop
215
291
  const renameResult = await renameSpawnedAgent(projectRoot, agent, nickname, startIso);
@@ -218,78 +294,169 @@ async function handleOps(projectRoot, ops = []) {
218
294
  }
219
295
  }
220
296
  } catch (err) {
221
- results.push({ action: "spawn", ok: false, agent, count, error: err.message });
297
+ results.push({ action: "launch", ok: false, agent, count, error: err.message });
222
298
  }
223
299
  } else if (op.action === "close") {
224
300
  const ok = await closeAgent(projectRoot, op.agent_id);
225
301
  results.push({ action: "close", ok, agent_id: op.agent_id });
302
+ } else if (op.action === "rename") {
303
+ const agentId = op.agent_id || "";
304
+ const nickname = op.nickname || "";
305
+ if (!agentId || !nickname) {
306
+ results.push({
307
+ action: "rename",
308
+ ok: false,
309
+ agent_id: agentId,
310
+ nickname,
311
+ error: "rename requires agent_id and nickname",
312
+ });
313
+ continue;
314
+ }
315
+ try {
316
+ const eventBus = new EventBus(projectRoot);
317
+ eventBus.ensureBus();
318
+ eventBus.loadBusData();
319
+ let targetId = agentId;
320
+ if (!eventBus.busData?.agents?.[targetId]) {
321
+ const nicknameManager = new NicknameManager(eventBus.busData || { agents: {} });
322
+ const resolved = nicknameManager.resolveNickname(agentId);
323
+ if (resolved) targetId = resolved;
324
+ }
325
+ if (!eventBus.busData?.agents?.[targetId]) {
326
+ results.push({
327
+ action: "rename",
328
+ ok: false,
329
+ agent_id: agentId,
330
+ nickname,
331
+ error: `agent not found: ${agentId}`,
332
+ });
333
+ continue;
334
+ }
335
+ const result = await eventBus.rename(targetId, nickname, "ufoo-agent");
336
+ results.push({
337
+ action: "rename",
338
+ ok: true,
339
+ agent_id: result.subscriber,
340
+ nickname: result.newNickname,
341
+ old_nickname: result.oldNickname,
342
+ });
343
+ } catch (err) {
344
+ results.push({
345
+ action: "rename",
346
+ ok: false,
347
+ agent_id: agentId,
348
+ nickname,
349
+ error: err && err.message ? err.message : String(err || "rename failed"),
350
+ });
351
+ }
226
352
  }
227
353
  }
228
354
  return results;
229
355
  }
230
356
 
231
- function dispatchMessages(projectRoot, dispatch = [], daemonSubscriber = null) {
232
- const script = path.join(projectRoot, "scripts", "bus.sh");
233
- const defaultPublisher = daemonSubscriber || "ufoo-agent";
234
- const env = { ...process.env, AI_BUS_PUBLISHER: defaultPublisher };
357
+ async function dispatchMessages(projectRoot, dispatch = []) {
358
+ const eventBus = new EventBus(projectRoot);
359
+ // Always use "ufoo-agent" as the publisher for daemon messages
360
+ const defaultPublisher = "ufoo-agent";
235
361
  for (const item of dispatch) {
236
362
  if (!item || !item.target || !item.message) continue;
237
363
  const pub = item.publisher || defaultPublisher;
238
- env.AI_BUS_PUBLISHER = pub;
239
- if (item.target === "broadcast") {
240
- spawnSync("bash", [script, "broadcast", item.message], { env, cwd: projectRoot });
241
- } else {
242
- spawnSync("bash", [script, "send", item.target, item.message], { env, cwd: projectRoot });
364
+ try {
365
+ if (item.target === "broadcast") {
366
+ await eventBus.broadcast(item.message, pub);
367
+ } else {
368
+ await eventBus.send(item.target, item.message, pub);
369
+ }
370
+ } catch {
371
+ // ignore dispatch failures
243
372
  }
244
373
  }
245
374
  }
246
375
 
247
- function startBusBridge(projectRoot, onEvent, onStatus) {
248
- const script = path.join(projectRoot, "scripts", "bus.sh");
376
+ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
249
377
  const state = {
250
378
  subscriber: null,
251
379
  queueFile: null,
252
380
  pending: new Set(),
253
381
  };
382
+ const eventBus = new EventBus(projectRoot);
383
+ let joinInProgress = false;
254
384
 
255
- function ensureSubscriber() {
256
- if (state.subscriber) return;
257
- const debugFile = path.join(projectRoot, ".ufoo", "run", "bus-join-debug.txt");
385
+ function getAgentNickname(agentId) {
386
+ if (!agentId) return agentId;
258
387
  try {
259
- fs.writeFileSync(debugFile, `Attempting join at ${new Date().toISOString()}\n`, { flag: "a" });
260
- // Clear session env vars so join creates a new session
261
- const env = { ...process.env, CLAUDE_SESSION_ID: "", CODEX_SESSION_ID: "" };
262
- const res = spawnSync("bash", [script, "join"], { cwd: projectRoot, env });
263
- if (res.status !== 0) {
264
- const errMsg = (res.stderr || res.stdout || "").toString("utf8");
265
- fs.writeFileSync(debugFile, `Join failed: ${errMsg}\n`, { flag: "a" });
266
- return;
267
- }
268
- const out = (res.stdout || "").toString("utf8").trim();
269
- const sub = out.split(/\r?\n/).pop();
270
- if (!sub) {
271
- fs.writeFileSync(debugFile, `Join returned empty subscriber\n`, { flag: "a" });
272
- return;
388
+ const busPath = getUfooPaths(projectRoot).agentsFile;
389
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
390
+ const meta = bus.agents && bus.agents[agentId];
391
+ if (meta && meta.nickname) {
392
+ return meta.nickname;
273
393
  }
274
- state.subscriber = sub;
275
- const safe = sub.replace(/:/g, "_");
276
- state.queueFile = path.join(projectRoot, ".ufoo", "bus", "queues", safe, "pending.jsonl");
277
- fs.writeFileSync(debugFile, `Successfully joined as ${sub}\n`, { flag: "a" });
278
- } catch (err) {
279
- fs.writeFileSync(debugFile, `Exception: ${err.message || err}\n`, { flag: "a" });
394
+ } catch {
395
+ // Ignore errors, return original ID
280
396
  }
397
+ return agentId;
398
+ }
399
+
400
+ function ensureSubscriber() {
401
+ if (state.subscriber || joinInProgress) return;
402
+ const debugFile = path.join(getUfooPaths(projectRoot).runDir, "bus-join-debug.txt");
403
+ joinInProgress = true;
404
+ (async () => {
405
+ try {
406
+ fs.writeFileSync(debugFile, `Attempting join at ${new Date().toISOString()}\n`, { flag: "a" });
407
+ // Determine agent type based on provider configuration
408
+ const agentType = provider === "codex-cli" ? "codex" : "claude-code";
409
+ // Use fixed ID "ufoo-agent" for daemon's bus identity with explicit nickname
410
+ const sub = await eventBus.join("ufoo-agent", agentType, "ufoo-agent");
411
+ if (!sub) {
412
+ fs.writeFileSync(debugFile, "Join returned empty subscriber\n", { flag: "a" });
413
+ return;
414
+ }
415
+ state.subscriber = sub;
416
+ const safe = subscriberToSafeName(sub);
417
+ state.queueFile = path.join(getUfooPaths(projectRoot).busQueuesDir, safe, "pending.jsonl");
418
+ fs.writeFileSync(debugFile, `Successfully joined as ${sub} (type: ${agentType})\n`, { flag: "a" });
419
+ } catch (err) {
420
+ fs.writeFileSync(debugFile, `Exception: ${err.message || err}\n`, { flag: "a" });
421
+ } finally {
422
+ joinInProgress = false;
423
+ }
424
+ })();
281
425
  }
282
426
 
283
427
  function poll() {
284
428
  ensureSubscriber();
429
+ if (typeof shouldDrain === "function" && !shouldDrain()) return;
285
430
  if (!state.queueFile) return;
286
431
  if (!fs.existsSync(state.queueFile)) return;
287
- let content;
432
+ let content = "";
433
+ let readOk = false;
434
+ const processingFile = `${state.queueFile}.processing.${process.pid}.${Date.now()}`;
288
435
  try {
289
- content = fs.readFileSync(state.queueFile, "utf8");
436
+ fs.renameSync(state.queueFile, processingFile);
437
+ content = fs.readFileSync(processingFile, "utf8");
438
+ readOk = true;
290
439
  } catch {
440
+ try {
441
+ if (fs.existsSync(processingFile)) {
442
+ fs.renameSync(processingFile, state.queueFile);
443
+ }
444
+ } catch {
445
+ // ignore rollback errors
446
+ }
291
447
  return;
448
+ } finally {
449
+ if (readOk) {
450
+ try {
451
+ if (fs.existsSync(processingFile)) {
452
+ fs.rmSync(processingFile, { force: true });
453
+ }
454
+ } catch {
455
+ // ignore cleanup errors
456
+ }
457
+ }
292
458
  }
459
+
293
460
  const lines = content.split(/\r?\n/).filter(Boolean);
294
461
  if (!lines.length) return;
295
462
  for (const line of lines) {
@@ -306,21 +473,17 @@ function startBusBridge(projectRoot, onEvent, onStatus) {
306
473
  publisher: evt.publisher,
307
474
  target: evt.target,
308
475
  message: evt.data?.message || "",
309
- ts: evt.ts,
476
+ ts: evt.timestamp || evt.ts,
310
477
  });
311
478
  }
312
479
  if (evt.publisher && state.pending.has(evt.publisher)) {
313
480
  state.pending.delete(evt.publisher);
314
481
  if (onStatus) {
315
- onStatus({ phase: "done", text: `${evt.publisher} done`, key: evt.publisher });
482
+ const displayName = getAgentNickname(evt.publisher);
483
+ onStatus({ phase: "done", text: `${displayName} done`, key: evt.publisher });
316
484
  }
317
485
  }
318
486
  }
319
- try {
320
- fs.truncateSync(state.queueFile, 0);
321
- } catch {
322
- // ignore
323
- }
324
487
  }
325
488
 
326
489
  const interval = setInterval(poll, 1000);
@@ -329,13 +492,14 @@ function startBusBridge(projectRoot, onEvent, onStatus) {
329
492
  if (!target) return;
330
493
  state.pending.add(target);
331
494
  if (onStatus) {
332
- onStatus({ phase: "start", text: `${target} processing`, key: target });
495
+ const displayName = getAgentNickname(target);
496
+ onStatus({ phase: "start", text: `${displayName} processing`, key: target });
333
497
  }
334
498
  },
335
499
  getSubscriber() {
336
500
  ensureSubscriber();
337
501
  try {
338
- fs.writeFileSync(path.join(projectRoot, ".ufoo", "run", "bridge-debug.txt"),
502
+ fs.writeFileSync(path.join(getUfooPaths(projectRoot).runDir, "bridge-debug.txt"),
339
503
  `subscriber: ${state.subscriber || "NULL"}\nqueue: ${state.queueFile || "NULL"}\n`);
340
504
  } catch {}
341
505
  return state.subscriber;
@@ -346,13 +510,51 @@ function startBusBridge(projectRoot, onEvent, onStatus) {
346
510
  };
347
511
  }
348
512
 
349
- function startDaemon({ projectRoot, provider, model }) {
350
- if (!fs.existsSync(path.join(projectRoot, ".ufoo"))) {
513
+ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
514
+ const paths = getUfooPaths(projectRoot);
515
+ if (!fs.existsSync(paths.ufooDir)) {
351
516
  throw new Error("Missing .ufoo. Run: ufoo init");
352
517
  }
353
518
 
354
- const runDir = path.join(projectRoot, ".ufoo", "run");
519
+ const runDir = paths.runDir;
355
520
  ensureDir(runDir);
521
+
522
+ // 文件锁机制:防止多个 daemon 同时启动
523
+ const lockFile = path.join(runDir, "daemon.lock");
524
+ let lockFd;
525
+ try {
526
+ lockFd = fs.openSync(lockFile, "wx");
527
+ fs.writeSync(lockFd, `${process.pid}\n`);
528
+ } catch (err) {
529
+ if (err.code === "EEXIST") {
530
+ // 锁文件已存在,检查持有者是否还活着
531
+ let existingPid;
532
+ try {
533
+ existingPid = parseInt(fs.readFileSync(lockFile, "utf8").trim(), 10);
534
+ } catch {
535
+ existingPid = NaN;
536
+ }
537
+ if (existingPid && Number.isFinite(existingPid)) {
538
+ let alive = false;
539
+ try {
540
+ process.kill(existingPid, 0);
541
+ alive = true;
542
+ } catch {
543
+ // 进程已死
544
+ }
545
+ if (alive) {
546
+ throw new Error(`Daemon already running with PID ${existingPid}`);
547
+ }
548
+ }
549
+ // 持有者已死,接管锁
550
+ try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
551
+ lockFd = fs.openSync(lockFile, "wx");
552
+ fs.writeSync(lockFd, `${process.pid}\n`);
553
+ } else {
554
+ throw err;
555
+ }
556
+ }
557
+
356
558
  removeSocket(projectRoot);
357
559
  writePid(projectRoot);
358
560
 
@@ -361,6 +563,16 @@ function startDaemon({ projectRoot, provider, model }) {
361
563
  logFile.write(`[daemon] ${new Date().toISOString()} ${msg}\n`);
362
564
  };
363
565
 
566
+ // 创建进程管理器 - daemon 作为父进程监控所有 internal agents
567
+ const processManager = new AgentProcessManager(projectRoot);
568
+ log(`Process manager initialized`);
569
+
570
+ // Provider session cache (in-memory)
571
+ const providerSessions = loadProviderSessionCache(projectRoot);
572
+
573
+ // Probe handles (用于agent_ready时提前触发probe)
574
+ const probeHandles = new Map(); // subscriberId -> { triggerNow }
575
+
364
576
  const sockets = new Set();
365
577
  const sendToSockets = (payload) => {
366
578
  const line = `${JSON.stringify(payload)}\n`;
@@ -374,11 +586,38 @@ function startDaemon({ projectRoot, provider, model }) {
374
586
  }
375
587
  };
376
588
 
377
- const busBridge = startBusBridge(projectRoot, (evt) => {
589
+ const busBridge = startBusBridge(projectRoot, provider, (evt) => {
378
590
  sendToSockets({ type: "bus", data: evt });
379
591
  }, (status) => {
380
592
  sendToSockets({ type: "status", data: status });
381
- });
593
+ }, () => sockets.size > 0);
594
+
595
+ // 定期检测状态变化并推送(仅当有变化时)
596
+ let lastActiveJson = "";
597
+ const statusSyncInterval = setInterval(() => {
598
+ if (sockets.size === 0) return; // 没有客户端连接时跳过
599
+ try {
600
+ // 先清理不活跃的订阅者,确保状态准确
601
+ const syncBus = new EventBus(projectRoot);
602
+ syncBus.ensureBus();
603
+ syncBus.loadBusData();
604
+ syncBus.subscriberManager.cleanupInactive();
605
+ syncBus.saveBusData();
606
+ } catch {
607
+ // ignore cleanup errors
608
+ }
609
+ try {
610
+ const status = buildStatus(projectRoot);
611
+ const currentActiveJson = JSON.stringify(status.active);
612
+ if (currentActiveJson !== lastActiveJson) {
613
+ lastActiveJson = currentActiveJson;
614
+ sendToSockets({ type: "status", data: status });
615
+ log(`status sync: active agents changed to ${status.active.length}`);
616
+ }
617
+ } catch {
618
+ // ignore status check errors
619
+ }
620
+ }, 3000); // 每3秒检测一次
382
621
 
383
622
  const server = net.createServer((socket) => {
384
623
  sockets.add(socket);
@@ -394,6 +633,16 @@ function startDaemon({ projectRoot, provider, model }) {
394
633
  for (const req of items) {
395
634
  if (!req || typeof req !== "object") continue;
396
635
  if (req.type === "status") {
636
+ // 先清理不活跃的订阅者,确保状态准确
637
+ try {
638
+ const eventBus = new EventBus(projectRoot);
639
+ eventBus.ensureBus();
640
+ eventBus.loadBusData();
641
+ eventBus.subscriberManager.cleanupInactive();
642
+ eventBus.saveBusData();
643
+ } catch {
644
+ // ignore cleanup errors, proceed with status
645
+ }
397
646
  const status = buildStatus(projectRoot);
398
647
  socket.write(`${JSON.stringify({ type: "status", data: status })}\n`);
399
648
  continue;
@@ -430,8 +679,8 @@ function startDaemon({ projectRoot, provider, model }) {
430
679
  busBridge.markPending(item.target);
431
680
  }
432
681
  }
433
- dispatchMessages(projectRoot, result.payload.dispatch || [], busBridge.getSubscriber());
434
- const opsResults = await handleOps(projectRoot, result.payload.ops || []);
682
+ await dispatchMessages(projectRoot, result.payload.dispatch || []);
683
+ const opsResults = await handleOps(projectRoot, result.payload.ops || [], processManager);
435
684
  log(`ok reply=${Boolean(result.payload.reply)} dispatch=${(result.payload.dispatch || []).length} ops=${(result.payload.ops || []).length}`);
436
685
  socket.write(
437
686
  `${JSON.stringify({
@@ -440,6 +689,279 @@ function startDaemon({ projectRoot, provider, model }) {
440
689
  opsResults,
441
690
  })}\n`,
442
691
  );
692
+ continue;
693
+ }
694
+ if (req.type === "bus_send") {
695
+ // Direct bus send request from chat UI
696
+ const { target, message } = req;
697
+ if (!target || !message) {
698
+ socket.write(
699
+ `${JSON.stringify({
700
+ type: "error",
701
+ error: "bus_send requires target and message",
702
+ })}\n`,
703
+ );
704
+ continue;
705
+ }
706
+ try {
707
+ const publisher = busBridge.getSubscriber() || "ufoo-agent";
708
+ const eventBus = new EventBus(projectRoot);
709
+ await eventBus.send(target, message, publisher);
710
+ log(`bus_send target=${target} publisher=${publisher}`);
711
+ socket.write(
712
+ `${JSON.stringify({
713
+ type: "bus_send_ok",
714
+ })}\n`,
715
+ );
716
+ } catch (err) {
717
+ log(`bus_send failed: ${err.message}`);
718
+ socket.write(
719
+ `${JSON.stringify({
720
+ type: "error",
721
+ error: err.message || "bus_send failed",
722
+ })}\n`,
723
+ );
724
+ }
725
+ continue;
726
+ }
727
+ if (req.type === "launch_agent") {
728
+ const { agent, count, nickname } = req;
729
+ if (!agent || (agent !== "codex" && agent !== "claude")) {
730
+ socket.write(
731
+ `${JSON.stringify({
732
+ type: "error",
733
+ error: "launch_agent requires agent=codex|claude",
734
+ })}\n`,
735
+ );
736
+ continue;
737
+ }
738
+ const parsedCount = parseInt(count, 10);
739
+ const finalCount = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 1;
740
+ const op = {
741
+ action: "launch",
742
+ agent,
743
+ count: finalCount,
744
+ nickname: nickname || "",
745
+ };
746
+ try {
747
+ const opsResults = await handleOps(projectRoot, [op], processManager);
748
+ const launchResult = opsResults.find((r) => r.action === "launch");
749
+ const ok = launchResult ? launchResult.ok !== false : true;
750
+ const reply = ok
751
+ ? `Launched ${op.count} ${agent} agent(s)`
752
+ : `Launch failed: ${launchResult?.error || "unknown error"}`;
753
+ socket.write(
754
+ `${JSON.stringify({
755
+ type: "response",
756
+ data: {
757
+ reply,
758
+ dispatch: [],
759
+ ops: [op],
760
+ },
761
+ opsResults,
762
+ })}\n`,
763
+ );
764
+ } catch (err) {
765
+ socket.write(
766
+ `${JSON.stringify({
767
+ type: "error",
768
+ error: err.message || "launch_agent failed",
769
+ })}\n`,
770
+ );
771
+ }
772
+ continue;
773
+ }
774
+ if (req.type === "close_agent") {
775
+ const { agentId } = req;
776
+ if (!agentId) {
777
+ socket.write(
778
+ `${JSON.stringify({
779
+ type: "error",
780
+ error: "close_agent requires agentId",
781
+ })}\n`,
782
+ );
783
+ continue;
784
+ }
785
+ try {
786
+ const ok = await closeAgent(projectRoot, agentId);
787
+ // Always cleanup inactive and broadcast — removes dead agents from list
788
+ try {
789
+ const cleanupBus = new EventBus(projectRoot);
790
+ cleanupBus.ensureBus();
791
+ cleanupBus.loadBusData();
792
+ cleanupBus.subscriberManager.cleanupInactive();
793
+ cleanupBus.saveBusData();
794
+ } catch { /* ignore */ }
795
+ log(`close_agent id=${agentId} ok=${ok}`);
796
+ const status = buildStatus(projectRoot);
797
+ socket.write(
798
+ `${JSON.stringify({ type: "close_agent_ok", ok: true, agentId })}\n`,
799
+ );
800
+ sendToSockets({ type: "status", data: status });
801
+ } catch (err) {
802
+ socket.write(
803
+ `${JSON.stringify({
804
+ type: "error",
805
+ error: err.message || "close_agent failed",
806
+ })}\n`,
807
+ );
808
+ }
809
+ continue;
810
+ }
811
+ if (req.type === "resume_agents") {
812
+ const target = req.target || "";
813
+ try {
814
+ const result = await resumeAgents(projectRoot, target, processManager);
815
+ const resumedCount = result.resumed.length;
816
+ const skippedCount = result.skipped.length;
817
+ const reply = resumedCount > 0
818
+ ? `Resumed ${resumedCount} agent(s)` + (skippedCount ? `, skipped ${skippedCount}` : "")
819
+ : (skippedCount ? `No agents resumed (skipped ${skippedCount})` : "No agents resumed");
820
+ socket.write(
821
+ `${JSON.stringify({
822
+ type: "response",
823
+ data: {
824
+ reply,
825
+ resume: result,
826
+ },
827
+ })}\n`,
828
+ );
829
+ } catch (err) {
830
+ socket.write(
831
+ `${JSON.stringify({
832
+ type: "error",
833
+ error: err.message || "resume_agents failed",
834
+ })}\n`,
835
+ );
836
+ }
837
+ continue;
838
+ }
839
+ if (req.type === "register_agent") {
840
+ // Manual agent launch requests daemon to register it
841
+ const { agentType, nickname, parentPid, launchMode, tmuxPane, tty, skipProbe, reuseSession } = req;
842
+ if (!agentType) {
843
+ socket.write(
844
+ `${JSON.stringify({
845
+ type: "error",
846
+ error: "register_agent requires agentType",
847
+ })}\n`,
848
+ );
849
+ continue;
850
+ }
851
+ try {
852
+ const crypto = require("crypto");
853
+
854
+ // 检查是否复用旧 session
855
+ let sessionId;
856
+ let subscriberId;
857
+ let isReusing = false;
858
+
859
+ if (reuseSession && reuseSession.sessionId && reuseSession.subscriberId) {
860
+ // 验证旧 session 是否可以复用
861
+ sessionId = reuseSession.sessionId;
862
+ subscriberId = reuseSession.subscriberId;
863
+ isReusing = true;
864
+ log(`register_agent reusing session: ${subscriberId}`);
865
+ } else {
866
+ // 生成新的 session
867
+ sessionId = crypto.randomBytes(4).toString("hex");
868
+ subscriberId = `${agentType}:${sessionId}`;
869
+ }
870
+
871
+ // Daemon registers the agent in bus
872
+ const eventBus = new EventBus(projectRoot);
873
+ await eventBus.init();
874
+ eventBus.loadBusData();
875
+ const parsedParentPid = Number.parseInt(parentPid, 10);
876
+ if (!Number.isFinite(parsedParentPid) || parsedParentPid <= 0) {
877
+ throw new Error("register_agent requires valid parentPid");
878
+ }
879
+ const joinOptions = {
880
+ parentPid: Number.isFinite(parsedParentPid) ? parsedParentPid : undefined,
881
+ launchMode: launchMode || "",
882
+ tmuxPane: tmuxPane || "",
883
+ // 如果复用旧 session,保留 provider session ID
884
+ providerSessionId: isReusing ? reuseSession.providerSessionId : undefined,
885
+ };
886
+ if (Object.prototype.hasOwnProperty.call(req, "tty")) {
887
+ const ttyValue = typeof tty === "string" ? tty.trim() : "";
888
+ joinOptions.tty = ttyValue;
889
+ if (!ttyValue) {
890
+ log(`register_agent warning: missing tty for ${subscriberId}`);
891
+ }
892
+ }
893
+ await eventBus.subscriberManager.join(sessionId, agentType, nickname || "", joinOptions);
894
+ eventBus.saveBusData();
895
+
896
+ const finalNickname = eventBus.busData?.agents?.[subscriberId]?.nickname || "";
897
+ const reusedLabel = isReusing ? " (reused)" : "";
898
+ log(`register_agent type=${agentType} nickname=${finalNickname || "(none)"} id=${subscriberId}${reusedLabel}`);
899
+
900
+ // 如果复用 session 且已有 provider session ID,跳过 probe
901
+ const hasProviderSession = isReusing && reuseSession.providerSessionId;
902
+ if (!skipProbe && !hasProviderSession && finalNickname) {
903
+ const probeHandle = scheduleProviderSessionProbe({
904
+ projectRoot,
905
+ subscriberId,
906
+ agentType,
907
+ nickname: finalNickname,
908
+ onResolved: (id, resolved) => {
909
+ providerSessions.set(id, {
910
+ sessionId: resolved.sessionId,
911
+ source: resolved.source || "",
912
+ updated_at: new Date().toISOString(),
913
+ });
914
+ // 清理handle
915
+ probeHandles.delete(id);
916
+ },
917
+ });
918
+ // 保存handle,用于agent_ready时提前触发
919
+ if (probeHandle) {
920
+ probeHandles.set(subscriberId, probeHandle);
921
+ }
922
+ }
923
+ socket.write(
924
+ `${JSON.stringify({
925
+ type: "register_ok",
926
+ subscriberId,
927
+ nickname: finalNickname || "",
928
+ })}\n`,
929
+ );
930
+ // 广播状态更新给所有连接的客户端
931
+ const status = buildStatus(projectRoot);
932
+ sendToSockets({ type: "status", data: status });
933
+ } catch (err) {
934
+ log(`register_agent failed: ${err.message}`);
935
+ socket.write(
936
+ `${JSON.stringify({
937
+ type: "error",
938
+ error: err.message || "register_agent failed",
939
+ })}\n`,
940
+ );
941
+ }
942
+ continue;
943
+ }
944
+ if (req.type === "agent_ready") {
945
+ // Agent has completed initialization and is ready to receive commands
946
+ const { subscriberId } = req;
947
+ if (!subscriberId) {
948
+ continue; // Silently ignore invalid requests
949
+ }
950
+
951
+ log(`agent_ready id=${subscriberId} - triggering probe immediately`);
952
+
953
+ // 提前触发probe(不再等待8秒延迟)
954
+ const probeHandle = probeHandles.get(subscriberId);
955
+ if (probeHandle && typeof probeHandle.triggerNow === "function") {
956
+ // 异步触发,不阻塞消息处理
957
+ probeHandle.triggerNow().catch((err) => {
958
+ log(`agent_ready probe trigger failed for ${subscriberId}: ${err.message}`);
959
+ });
960
+ } else {
961
+ log(`agent_ready no probe handle found for ${subscriberId}`);
962
+ }
963
+
964
+ continue;
443
965
  }
444
966
  }
445
967
  }
@@ -449,13 +971,135 @@ function startDaemon({ projectRoot, provider, model }) {
449
971
  server.listen(socketPath(projectRoot));
450
972
  log(`Started pid=${process.pid}`);
451
973
 
452
- process.on("exit", () => {
974
+ // 清理旧 daemon 留下的孤儿 internal agent 进程
975
+ const EventBus = require("../bus");
976
+ const eventBus = new EventBus(projectRoot);
977
+ try {
978
+ eventBus.ensureBus();
979
+ eventBus.loadBusData();
980
+ const agents = eventBus.busData.agents || {};
981
+
982
+ // 查找所有 agent-runner 进程
983
+ const psResult = spawnSync("ps", ["aux"], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
984
+ const lines = psResult.stdout ? psResult.stdout.split("\n") : [];
985
+ const runnerProcesses = [];
986
+
987
+ for (const line of lines) {
988
+ if (line.includes("agent-pty-runner") || line.includes("agent-runner")) {
989
+ const parts = line.trim().split(/\s+/);
990
+ if (parts.length >= 2) {
991
+ const pid = parseInt(parts[1], 10);
992
+ if (Number.isFinite(pid)) {
993
+ runnerProcesses.push({ pid, line });
994
+ }
995
+ }
996
+ }
997
+ }
998
+
999
+ // 检查每个 runner 的父进程
1000
+ for (const runner of runnerProcesses) {
1001
+ try {
1002
+ const ppidResult = spawnSync("ps", ["-p", String(runner.pid), "-o", "ppid="], { encoding: "utf8" });
1003
+ const ppid = parseInt(ppidResult.stdout.trim(), 10);
1004
+
1005
+ if (Number.isFinite(ppid)) {
1006
+ // 检查父进程是否存在
1007
+ try {
1008
+ process.kill(ppid, 0);
1009
+ // 父进程还活着,检查是否是 daemon
1010
+ const ppidCmd = spawnSync("ps", ["-p", String(ppid), "-o", "command="], { encoding: "utf8" });
1011
+ const cmd = ppidCmd.stdout.trim();
1012
+
1013
+ if (!cmd.includes("daemon start")) {
1014
+ // 父进程不是 daemon,这是孤儿进程
1015
+ log(`Found orphan agent-runner process ${runner.pid} (parent ${ppid} is not a daemon)`);
1016
+ try {
1017
+ process.kill(runner.pid, "SIGTERM");
1018
+ log(`Killed orphan agent-runner ${runner.pid}`);
1019
+ } catch {
1020
+ // ignore
1021
+ }
1022
+ }
1023
+ } catch {
1024
+ // 父进程已死,杀掉孤儿进程
1025
+ log(`Found orphan agent-runner process ${runner.pid} (parent ${ppid} is dead)`);
1026
+ try {
1027
+ process.kill(runner.pid, "SIGTERM");
1028
+ log(`Killed orphan agent-runner ${runner.pid}`);
1029
+ } catch {
1030
+ // ignore
1031
+ }
1032
+ }
1033
+ }
1034
+ } catch {
1035
+ // ignore
1036
+ }
1037
+ }
1038
+
1039
+ // 标记对应的 agents 为 inactive
1040
+ for (const [subscriberId, meta] of Object.entries(agents)) {
1041
+ if (meta.launch_mode && meta.launch_mode.startsWith("internal")) {
1042
+ if (meta.pid) {
1043
+ try {
1044
+ process.kill(meta.pid, 0);
1045
+ // 父 daemon 还活着,跳过
1046
+ } catch {
1047
+ // 父 daemon 已死,标记为 inactive
1048
+ // 设置 last_seen 为很久以前,强制立即超时
1049
+ meta.status = "inactive";
1050
+ meta.last_seen = "2020-01-01T00:00:00.000Z";
1051
+ log(`Marked orphan internal agent ${subscriberId} as inactive (parent daemon ${meta.pid} is dead)`);
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ eventBus.saveBusData();
1057
+ } catch (err) {
1058
+ log(`Failed to cleanup orphan agents: ${err.message}`);
1059
+ }
1060
+
1061
+ const config = loadConfig(projectRoot);
1062
+ const autoResumeEnabled = config.autoResume !== false;
1063
+ const shouldResume = resumeMode === "force" || (resumeMode === "auto" && autoResumeEnabled);
1064
+ if (shouldResume) {
1065
+ setTimeout(() => {
1066
+ resumeAgents(projectRoot, "", processManager).catch((err) => {
1067
+ log(`auto resume failed: ${err.message || String(err)}`);
1068
+ });
1069
+ }, 1500);
1070
+ }
1071
+
1072
+ const cleanup = () => {
1073
+ log(`Shutting down daemon (managed agents: ${processManager.count()})`);
1074
+
1075
+ // 清理所有子进程
1076
+ processManager.cleanup();
1077
+
1078
+ clearInterval(statusSyncInterval);
453
1079
  busBridge.stop();
454
1080
  removeSocket(projectRoot);
455
- });
1081
+
1082
+ // 释放锁文件
1083
+ try {
1084
+ if (lockFd !== undefined) {
1085
+ fs.closeSync(lockFd);
1086
+ }
1087
+ const lockFile = path.join(getUfooPaths(projectRoot).runDir, "daemon.lock");
1088
+ if (fs.existsSync(lockFile)) {
1089
+ fs.unlinkSync(lockFile);
1090
+ }
1091
+ } catch {
1092
+ // ignore cleanup errors
1093
+ }
1094
+ };
1095
+
1096
+ process.on("exit", cleanup);
456
1097
  process.on("SIGTERM", () => {
457
- busBridge.stop();
458
- removeSocket(projectRoot);
1098
+ cleanup();
1099
+ process.exit(0);
1100
+ });
1101
+ process.on("SIGINT", () => {
1102
+ cleanup();
459
1103
  process.exit(0);
460
1104
  });
461
1105
  }
@@ -495,6 +1139,17 @@ function stopDaemon(projectRoot) {
495
1139
  // ignore
496
1140
  }
497
1141
  removeSocket(projectRoot);
1142
+
1143
+ // 清理锁文件
1144
+ try {
1145
+ const lockFile = path.join(getUfooPaths(projectRoot).runDir, "daemon.lock");
1146
+ if (fs.existsSync(lockFile)) {
1147
+ fs.unlinkSync(lockFile);
1148
+ }
1149
+ } catch {
1150
+ // ignore
1151
+ }
1152
+
498
1153
  return killed;
499
1154
  }
500
1155