u-foo 1.0.3 → 1.1.9

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 (179) hide show
  1. package/README.md +110 -11
  2. package/README.zh-CN.md +9 -7
  3. package/SKILLS/ufoo/SKILL.md +132 -0
  4. package/SKILLS/uinit/SKILL.md +78 -0
  5. package/SKILLS/ustatus/SKILL.md +36 -0
  6. package/bin/uclaude.js +13 -0
  7. package/bin/ucode-core.js +15 -0
  8. package/bin/ucode.js +125 -0
  9. package/bin/ucodex.js +13 -0
  10. package/bin/ufoo +9 -31
  11. package/bin/ufoo-assistant-agent.js +5 -0
  12. package/bin/ufoo-engine.js +25 -0
  13. package/bin/ufoo.js +17 -0
  14. package/modules/AGENTS.template.md +29 -11
  15. package/modules/bus/README.md +33 -25
  16. package/modules/bus/SKILLS/ubus/SKILL.md +19 -8
  17. package/modules/context/README.md +18 -40
  18. package/modules/context/SKILLS/uctx/SKILL.md +63 -1
  19. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  20. package/package.json +25 -4
  21. package/scripts/import-pi-mono.js +124 -0
  22. package/scripts/postinstall.js +30 -0
  23. package/scripts/sync-claude-skills.sh +21 -0
  24. package/src/agent/cliRunner.js +554 -33
  25. package/src/agent/internalRunner.js +150 -56
  26. package/src/agent/launcher.js +754 -0
  27. package/src/agent/normalizeOutput.js +1 -1
  28. package/src/agent/notifier.js +340 -0
  29. package/src/agent/ptyRunner.js +847 -0
  30. package/src/agent/ptyWrapper.js +379 -0
  31. package/src/agent/readyDetector.js +175 -0
  32. package/src/agent/ucode.js +443 -0
  33. package/src/agent/ucodeBootstrap.js +113 -0
  34. package/src/agent/ucodeBuild.js +67 -0
  35. package/src/agent/ucodeDoctor.js +184 -0
  36. package/src/agent/ucodeRuntimeConfig.js +129 -0
  37. package/src/agent/ufooAgent.js +46 -42
  38. package/src/assistant/agent.js +260 -0
  39. package/src/assistant/bridge.js +172 -0
  40. package/src/assistant/engine.js +252 -0
  41. package/src/assistant/stdio.js +58 -0
  42. package/src/assistant/ufooEngineCli.js +306 -0
  43. package/src/bus/activate.js +172 -0
  44. package/src/bus/daemon.js +436 -0
  45. package/src/bus/index.js +842 -0
  46. package/src/bus/inject.js +315 -0
  47. package/src/bus/message.js +430 -0
  48. package/src/bus/nickname.js +88 -0
  49. package/src/bus/queue.js +136 -0
  50. package/src/bus/shake.js +26 -0
  51. package/src/bus/store.js +189 -0
  52. package/src/bus/subscriber.js +312 -0
  53. package/src/bus/utils.js +363 -0
  54. package/src/chat/agentBar.js +117 -0
  55. package/src/chat/agentDirectory.js +88 -0
  56. package/src/chat/agentSockets.js +225 -0
  57. package/src/chat/agentViewController.js +298 -0
  58. package/src/chat/chatLogController.js +115 -0
  59. package/src/chat/commandExecutor.js +700 -0
  60. package/src/chat/commands.js +132 -0
  61. package/src/chat/completionController.js +414 -0
  62. package/src/chat/cronScheduler.js +160 -0
  63. package/src/chat/daemonConnection.js +166 -0
  64. package/src/chat/daemonCoordinator.js +64 -0
  65. package/src/chat/daemonMessageRouter.js +257 -0
  66. package/src/chat/daemonReconnect.js +41 -0
  67. package/src/chat/daemonTransport.js +36 -0
  68. package/src/chat/daemonTransportDefaults.js +10 -0
  69. package/src/chat/dashboardKeyController.js +480 -0
  70. package/src/chat/dashboardView.js +154 -0
  71. package/src/chat/index.js +1011 -1392
  72. package/src/chat/inputHistoryController.js +105 -0
  73. package/src/chat/inputListenerController.js +304 -0
  74. package/src/chat/inputMath.js +104 -0
  75. package/src/chat/inputSubmitHandler.js +171 -0
  76. package/src/chat/layout.js +165 -0
  77. package/src/chat/pasteController.js +81 -0
  78. package/src/chat/rawKeyMap.js +42 -0
  79. package/src/chat/settingsController.js +132 -0
  80. package/src/chat/statusLineController.js +177 -0
  81. package/src/chat/streamTracker.js +138 -0
  82. package/src/chat/text.js +70 -0
  83. package/src/chat/transport.js +61 -0
  84. package/src/cli/busCoreCommands.js +59 -0
  85. package/src/cli/ctxCoreCommands.js +199 -0
  86. package/src/cli/onlineCoreCommands.js +379 -0
  87. package/src/cli.js +1162 -96
  88. package/src/code/README.md +29 -0
  89. package/src/code/UCODE_PROMPT.md +32 -0
  90. package/src/code/agent.js +1651 -0
  91. package/src/code/cli.js +158 -0
  92. package/src/code/config +0 -0
  93. package/src/code/dispatch.js +42 -0
  94. package/src/code/index.js +70 -0
  95. package/src/code/nativeRunner.js +1213 -0
  96. package/src/code/runtime.js +154 -0
  97. package/src/code/sessionStore.js +162 -0
  98. package/src/code/taskDecomposer.js +269 -0
  99. package/src/code/tools/bash.js +53 -0
  100. package/src/code/tools/common.js +42 -0
  101. package/src/code/tools/edit.js +70 -0
  102. package/src/code/tools/read.js +44 -0
  103. package/src/code/tools/write.js +35 -0
  104. package/src/code/tui.js +1580 -0
  105. package/src/config.js +56 -3
  106. package/src/context/decisions.js +324 -0
  107. package/src/context/doctor.js +183 -0
  108. package/src/context/index.js +55 -0
  109. package/src/context/sync.js +127 -0
  110. package/src/daemon/agentProcessManager.js +74 -0
  111. package/src/daemon/cronOps.js +241 -0
  112. package/src/daemon/index.js +998 -170
  113. package/src/daemon/ipcServer.js +99 -0
  114. package/src/daemon/ops.js +630 -48
  115. package/src/daemon/promptLoop.js +319 -0
  116. package/src/daemon/promptRequest.js +101 -0
  117. package/src/daemon/providerSessions.js +306 -0
  118. package/src/daemon/reporting.js +90 -0
  119. package/src/daemon/run.js +31 -1
  120. package/src/daemon/status.js +48 -8
  121. package/src/doctor/index.js +50 -0
  122. package/src/init/index.js +318 -0
  123. package/src/online/bridge.js +663 -0
  124. package/src/online/client.js +245 -0
  125. package/src/online/runner.js +253 -0
  126. package/src/online/server.js +992 -0
  127. package/src/online/tokens.js +103 -0
  128. package/src/report/store.js +331 -0
  129. package/src/shared/eventContract.js +35 -0
  130. package/src/shared/ptySocketContract.js +21 -0
  131. package/src/skills/index.js +159 -0
  132. package/src/status/index.js +285 -0
  133. package/src/terminal/adapterContract.js +87 -0
  134. package/src/terminal/adapterRouter.js +84 -0
  135. package/src/terminal/adapters/externalAdapter.js +14 -0
  136. package/src/terminal/adapters/internalAdapter.js +13 -0
  137. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  138. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  139. package/src/terminal/adapters/terminalAdapter.js +31 -0
  140. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  141. package/src/terminal/detect.js +64 -0
  142. package/src/terminal/index.js +8 -0
  143. package/src/terminal/iterm2.js +126 -0
  144. package/src/ufoo/agentsStore.js +107 -0
  145. package/src/ufoo/paths.js +46 -0
  146. package/src/utils/banner.js +76 -0
  147. package/bin/uclaude +0 -65
  148. package/bin/ucodex +0 -65
  149. package/modules/bus/scripts/bus-alert.sh +0 -185
  150. package/modules/bus/scripts/bus-listen.sh +0 -117
  151. package/modules/context/ASSUMPTIONS.md +0 -7
  152. package/modules/context/CONSTRAINTS.md +0 -7
  153. package/modules/context/CONTEXT-STRUCTURE.md +0 -49
  154. package/modules/context/DECISION-PROTOCOL.md +0 -62
  155. package/modules/context/HANDOFF.md +0 -33
  156. package/modules/context/RULES.md +0 -15
  157. package/modules/context/SKILLS/README.md +0 -14
  158. package/modules/context/SYSTEM.md +0 -18
  159. package/modules/context/TEMPLATES/assumptions.md +0 -4
  160. package/modules/context/TEMPLATES/constraints.md +0 -4
  161. package/modules/context/TEMPLATES/decision.md +0 -16
  162. package/modules/context/TEMPLATES/project-context-readme.md +0 -6
  163. package/modules/context/TEMPLATES/system.md +0 -3
  164. package/modules/context/TEMPLATES/terminology.md +0 -4
  165. package/modules/context/TERMINOLOGY.md +0 -10
  166. package/scripts/banner.sh +0 -89
  167. package/scripts/bus-alert.sh +0 -6
  168. package/scripts/bus-autotrigger.sh +0 -6
  169. package/scripts/bus-daemon.sh +0 -231
  170. package/scripts/bus-inject.sh +0 -144
  171. package/scripts/bus-listen.sh +0 -6
  172. package/scripts/bus.sh +0 -984
  173. package/scripts/context-decisions.sh +0 -167
  174. package/scripts/context-doctor.sh +0 -72
  175. package/scripts/context-lint.sh +0 -110
  176. package/scripts/doctor.sh +0 -22
  177. package/scripts/init.sh +0 -247
  178. package/scripts/skills.sh +0 -113
  179. package/scripts/status.sh +0 -125
package/src/daemon/ops.js CHANGED
@@ -2,17 +2,52 @@ const { spawn } = 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, getTtyProcessInfo } = require("../bus/utils");
8
+ const { isITerm2 } = require("../terminal/detect");
9
+ const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
10
+
11
+ function normalizeLaunchAgent(agent = "") {
12
+ const value = String(agent || "").trim().toLowerCase();
13
+ if (value === "codex") return "codex";
14
+ if (value === "claude" || value === "claude-code") return "claude";
15
+ if (value === "ufoo" || value === "ucode" || value === "ufoo-code") return "ufoo";
16
+ return "";
17
+ }
18
+
19
+ function toBusAgentType(agent = "") {
20
+ if (agent === "codex") return "codex";
21
+ if (agent === "claude") return "claude-code";
22
+ if (agent === "ufoo") return "ufoo-code";
23
+ return "";
24
+ }
25
+
26
+ function toTerminalBinary(agent = "") {
27
+ if (agent === "codex") return "./bin/ucodex.js";
28
+ if (agent === "claude") return "./bin/uclaude.js";
29
+ if (agent === "ufoo") return "./bin/ucode.js";
30
+ return "";
31
+ }
32
+
33
+ function toTmuxBinary(agent = "") {
34
+ if (agent === "codex") return "ucodex";
35
+ if (agent === "claude") return "uclaude";
36
+ if (agent === "ufoo") return "ucode";
37
+ return "";
38
+ }
5
39
 
6
40
  function resolveAgentId(projectRoot, agentId) {
7
41
  if (!agentId) return agentId;
8
42
  if (agentId.includes(":")) return agentId;
9
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
43
+ const busPath = getUfooPaths(projectRoot).agentsFile;
10
44
  try {
11
45
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
12
- const entries = Object.entries(bus.subscribers || {});
46
+ const entries = Object.entries(bus.agents || {});
13
47
  const match = entries.find(([, meta]) => meta?.nickname === agentId);
14
48
  if (match) return match[0];
15
- const targetType = agentId === "claude" ? "claude-code" : agentId;
49
+ const normalized = normalizeLaunchAgent(agentId);
50
+ const targetType = toBusAgentType(normalized) || agentId;
16
51
  const candidates = entries
17
52
  .filter(([, meta]) => meta?.agent_type === targetType && meta?.status === "active")
18
53
  .map(([id]) => id);
@@ -23,75 +58,596 @@ function resolveAgentId(projectRoot, agentId) {
23
58
  return agentId;
24
59
  }
25
60
 
61
+ function markAgentInactive(projectRoot, agentId) {
62
+ if (!agentId) return false;
63
+ const filePath = getUfooPaths(projectRoot).agentsFile;
64
+ const data = loadAgentsData(filePath);
65
+ const meta = data.agents?.[agentId];
66
+ if (!meta) return false;
67
+ data.agents[agentId] = {
68
+ ...meta,
69
+ status: "inactive",
70
+ last_seen: new Date().toISOString(),
71
+ };
72
+ saveAgentsData(filePath, data);
73
+ return true;
74
+ }
75
+
76
+
77
+ function listSubscribers(projectRoot, agentType) {
78
+ const busPath = getUfooPaths(projectRoot).agentsFile;
79
+ try {
80
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
81
+ return Object.entries(bus.agents || {})
82
+ .filter(([, meta]) => meta && meta.agent_type === agentType && meta.status === "active")
83
+ .map(([id]) => id);
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ async function waitForNewSubscriber(projectRoot, agentType, existing, timeoutMs = 15000) {
90
+ const start = Date.now();
91
+ const seen = new Set(existing || []);
92
+ while (Date.now() - start < timeoutMs) {
93
+ const current = listSubscribers(projectRoot, agentType);
94
+ const diff = current.find((id) => !seen.has(id));
95
+ if (diff) return diff;
96
+ // eslint-disable-next-line no-await-in-loop
97
+ await new Promise((r) => setTimeout(r, 200));
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function escapeCommand(cmd) {
103
+ return cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
104
+ }
105
+
106
+ function shellEscape(value) {
107
+ const str = String(value);
108
+ return `'${str.replace(/'/g, `'\\''`)}'`;
109
+ }
110
+
111
+ function escapeAppleScriptString(str) {
112
+ return String(str).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
113
+ }
114
+
26
115
  function runAppleScript(lines) {
27
116
  return new Promise((resolve, reject) => {
28
117
  const proc = spawn("osascript", lines.flatMap((l) => ["-e", l]));
29
118
  let stderr = "";
119
+ let stdout = "";
30
120
  proc.stderr.on("data", (d) => {
31
121
  stderr += d.toString("utf8");
32
122
  });
123
+ proc.stdout.on("data", (d) => {
124
+ stdout += d.toString("utf8");
125
+ });
33
126
  proc.on("close", (code) => {
34
- if (code === 0) resolve();
127
+ if (code === 0) resolve(stdout.trim());
35
128
  else reject(new Error(stderr || "osascript failed"));
36
129
  });
37
130
  });
38
131
  }
39
132
 
40
- function escapeCommand(cmd) {
41
- return cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
133
+ async function openTerminalWindow(runCmd) {
134
+ if (process.platform !== "darwin") {
135
+ throw new Error("Terminal mode is only supported on macOS");
136
+ }
137
+
138
+ const escaped = escapeAppleScriptString(runCmd);
139
+
140
+ if (isITerm2()) {
141
+ try {
142
+ const script = [
143
+ 'tell application "iTerm2"',
144
+ " tell current window",
145
+ ` create tab with default profile command "${escaped}"`,
146
+ " end tell",
147
+ " activate",
148
+ "end tell",
149
+ ];
150
+ await runAppleScript(script);
151
+ return;
152
+ } catch {
153
+ // fall back to Terminal.app
154
+ }
155
+ }
156
+
157
+ const script = [
158
+ 'tell application "Terminal"',
159
+ `do script "${escaped}"`,
160
+ "activate",
161
+ "end tell",
162
+ ];
163
+ await runAppleScript(script);
42
164
  }
43
165
 
44
- function shellEscape(value) {
45
- const str = String(value);
46
- return `'${str.replace(/'/g, `'\\''`)}'`;
166
+ async function closeTerminalWindowByTty(ttyPath, preferApp = "") {
167
+ if (process.platform !== "darwin") return false;
168
+ if (!ttyPath) return false;
169
+
170
+ const escaped = escapeAppleScriptString(ttyPath);
171
+
172
+ const tryITerm = async () => {
173
+ const script = [
174
+ 'tell application "iTerm2"',
175
+ " repeat with w in windows",
176
+ " repeat with t in tabs of w",
177
+ " repeat with s in sessions of t",
178
+ ` if tty of s is \"${escaped}\" then`,
179
+ " close t",
180
+ ' return "ok"',
181
+ " end if",
182
+ " end repeat",
183
+ " end repeat",
184
+ " end repeat",
185
+ "end tell",
186
+ 'return "not found"',
187
+ ];
188
+ const res = await runAppleScript(script);
189
+ return res === "ok";
190
+ };
191
+
192
+ const tryTerminal = async () => {
193
+ const script = [
194
+ 'tell application "Terminal"',
195
+ " repeat with w in windows",
196
+ " repeat with t in tabs of w",
197
+ ` if tty of t is \"${escaped}\" then`,
198
+ " close t",
199
+ " if (count of tabs of w) is 0 then close w",
200
+ ' return "ok"',
201
+ " end if",
202
+ " end repeat",
203
+ " end repeat",
204
+ "end tell",
205
+ 'return "not found"',
206
+ ];
207
+ const res = await runAppleScript(script);
208
+ return res === "ok";
209
+ };
210
+
211
+ const prefer = (preferApp || "").toLowerCase();
212
+ const order = prefer === "terminal"
213
+ ? [tryTerminal, tryITerm]
214
+ : prefer === "iterm2"
215
+ ? [tryITerm, tryTerminal]
216
+ : [tryITerm, tryTerminal];
217
+
218
+ for (const attempt of order) {
219
+ try {
220
+ if (await attempt()) return true;
221
+ } catch {
222
+ // ignore and try next
223
+ }
224
+ }
225
+ return false;
226
+ }
227
+
228
+ function buildTitleCmd(title) {
229
+ if (!title) return "";
230
+ return `printf '\\033]0;%s\\007' ${shellEscape(title)}`;
231
+ }
232
+
233
+ function buildResumeCommand(projectRoot, agent, sessionId) {
234
+ const binary = toTerminalBinary(agent);
235
+ if (!binary) {
236
+ throw new Error(`unsupported agent for resume: ${agent}`);
237
+ }
238
+ const args = buildResumeArgs(agent, sessionId);
239
+ const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
240
+ const skipProbeEnv = "UFOO_SKIP_SESSION_PROBE=1 ";
241
+ return `cd ${shellEscape(projectRoot)} && ${skipProbeEnv}${binary}${argText}`;
242
+ }
243
+
244
+ async function tryReuseTerminal(projectRoot, subscriberId, meta, agent, sessionId) {
245
+ if (!meta || !meta.tty) return false;
246
+ const info = getTtyProcessInfo(meta.tty);
247
+ if (!info.alive || info.hasAgent || !info.idle) return false;
248
+ const titleCmd = buildTitleCmd(meta.nickname || "");
249
+ const baseCmd = buildResumeCommand(projectRoot, agent, sessionId);
250
+ const command = titleCmd ? `${titleCmd} && ${baseCmd}` : baseCmd;
251
+ try {
252
+ const EventBus = require("../bus");
253
+ const bus = new EventBus(projectRoot);
254
+ bus.ensureBus();
255
+ await bus.inject(subscriberId, command);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Spawn managed terminal agent - open a real Terminal session to run the agent
264
+ */
265
+ async function spawnManagedTerminalAgent(projectRoot, agent, nickname = "", processManager = null, extraArgs = [], extraEnv = "") {
266
+ const normalizedAgent = normalizeLaunchAgent(agent);
267
+ const binary = toTerminalBinary(normalizedAgent);
268
+ const agentType = toBusAgentType(normalizedAgent);
269
+ if (!binary || !agentType) {
270
+ throw new Error(`unsupported agent type: ${agent}`);
271
+ }
272
+ const existing = listSubscribers(projectRoot, agentType);
273
+ const runDir = getUfooPaths(projectRoot).runDir;
274
+ fs.mkdirSync(runDir, { recursive: true });
275
+
276
+ const args = Array.isArray(extraArgs) ? extraArgs : [];
277
+ const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
278
+ const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
279
+ const modeEnv = "UFOO_LAUNCH_MODE=terminal ";
280
+ const envPrefix = extraEnv ? `${String(extraEnv).trim()} ` : "";
281
+ const titleCmd = buildTitleCmd(nickname);
282
+ const prefix = titleCmd ? `${titleCmd} && ` : "";
283
+
284
+ const runCmd = `cd ${shellEscape(projectRoot)} && ${prefix}${modeEnv}${nickEnv}${envPrefix}${binary}${argText}`;
285
+
286
+ await openTerminalWindow(runCmd);
287
+
288
+ const subscriberId = await waitForNewSubscriber(projectRoot, agentType, existing, 15000);
289
+ return { child: null, subscriberId: subscriberId || null };
47
290
  }
48
291
 
49
- async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "") {
292
+ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
50
293
  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");
294
+ const logDir = getUfooPaths(projectRoot).runDir;
295
+ fs.mkdirSync(logDir, { recursive: true });
296
+
297
+ const crypto = require("crypto");
298
+ const EventBus = require("../bus");
299
+ const children = [];
300
+ const subscriberIds = [];
301
+
302
+ // 初始化 bus
303
+ const bus = new EventBus(projectRoot);
304
+ await bus.init();
305
+
306
+ const originalPid = process.pid;
307
+
54
308
  for (let i = 0; i < count; i += 1) {
55
- const child = spawn(process.execPath, [runner, "agent-runner", agent], {
56
- detached: true,
309
+ const logFile = path.join(logDir, `agent-${agent}-${Date.now()}-${i}.log`);
310
+ const errLog = fs.openSync(logFile, "a");
311
+
312
+ // 预生成 session ID
313
+ const sessionId = crypto.randomBytes(4).toString("hex");
314
+ const normalizedAgent = normalizeLaunchAgent(agent);
315
+ const agentType = toBusAgentType(normalizedAgent);
316
+ if (!agentType) {
317
+ throw new Error(`unsupported agent type: ${agent}`);
318
+ }
319
+ const subscriberId = `${agentType}:${sessionId}`;
320
+ subscriberIds.push(subscriberId);
321
+
322
+ // Daemon 预先在 bus 中注册
323
+ bus.loadBusData();
324
+ process.env.UFOO_PARENT_PID = String(originalPid);
325
+
326
+ // For ucode/ufoo agents, default nickname to "ucode" if not specified
327
+ const defaultNickname = agentType === "ufoo-code" ? "ucode" : agent;
328
+ const finalNickname = count > 1 ? `${nickname || defaultNickname}-${i + 1}` : (nickname || defaultNickname);
329
+ const usePty = process.env.UFOO_INTERNAL_PTY !== "0";
330
+ const launchMode = usePty ? "internal-pty" : "internal";
331
+
332
+ // 传递 launch_mode 和 parent PID 到 join
333
+ await bus.subscriberManager.join(sessionId, agentType, finalNickname, {
334
+ launchMode,
335
+ parentPid: originalPid,
336
+ });
337
+ bus.saveBusData();
338
+
339
+ const runnerCmd = usePty ? "agent-pty-runner" : "agent-runner";
340
+ const child = spawn(process.execPath, [runner, runnerCmd, agent], {
341
+ // 关键改动:不使用 detached,daemon 作为父进程
342
+ detached: false,
57
343
  stdio: ["ignore", errLog, errLog],
58
344
  cwd: projectRoot,
59
- env: { ...process.env, UFOO_INTERNAL_AGENT: "1", UFOO_NICKNAME: nickname || "" },
345
+ env: {
346
+ ...process.env,
347
+ UFOO_INTERNAL_AGENT: "1",
348
+ UFOO_INTERNAL_PTY: usePty ? "1" : "0",
349
+ UFOO_SUBSCRIBER_ID: subscriberId, // 直接传递 subscriber ID
350
+ UFOO_NICKNAME: finalNickname,
351
+ UFOO_LAUNCH_MODE: usePty ? "internal-pty" : "internal",
352
+ UFOO_PARENT_PID: String(originalPid),
353
+ },
60
354
  });
61
- child.unref();
62
- }
63
- setTimeout(() => {
355
+
356
+ // Update bus data with the actual child PID so isMetaActive
357
+ // can detect when the ptyRunner process dies.
64
358
  try {
65
- fs.closeSync(errLog);
359
+ bus.loadBusData();
360
+ if (bus.busData.agents && bus.busData.agents[subscriberId]) {
361
+ bus.busData.agents[subscriberId].pid = child.pid;
362
+ }
363
+ bus.saveBusData();
66
364
  } catch {
67
- // ignore
365
+ // ignore pid update errors
68
366
  }
69
- }, 1000);
367
+
368
+ // 本地日志记录
369
+ child.on("exit", (code, signal) => {
370
+ try {
371
+ fs.closeSync(errLog);
372
+ } catch {
373
+ // ignore
374
+ }
375
+
376
+ // Mark agent as inactive when its process exits
377
+ try {
378
+ bus.loadBusData();
379
+ if (bus.busData.agents && bus.busData.agents[subscriberId]) {
380
+ bus.busData.agents[subscriberId].status = "inactive";
381
+ bus.busData.agents[subscriberId].last_seen = new Date().toISOString();
382
+ }
383
+ bus.saveBusData();
384
+ } catch {
385
+ // ignore
386
+ }
387
+
388
+ if (signal) {
389
+ fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} killed by signal ${signal}\n`);
390
+ } else {
391
+ fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} exited with code ${code}\n`);
392
+ }
393
+ });
394
+
395
+ child.on("error", (err) => {
396
+ fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} spawn failed: ${err.message}\n`);
397
+ try {
398
+ fs.closeSync(errLog);
399
+ } catch {
400
+ // ignore
401
+ }
402
+ });
403
+
404
+ // 注册到进程管理器(父子进程监控)
405
+ if (processManager) {
406
+ processManager.register(subscriberId, child);
407
+ }
408
+
409
+ children.push(child);
410
+ }
411
+
412
+ return { children, subscriberIds };
413
+ }
414
+
415
+ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extraEnv = "") {
416
+ return new Promise((resolve, reject) => {
417
+ const normalizedAgent = normalizeLaunchAgent(agent);
418
+ const binary = toTmuxBinary(normalizedAgent);
419
+ if (!binary) {
420
+ reject(new Error(`unsupported agent type: ${agent}`));
421
+ return;
422
+ }
423
+ const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
424
+ const modeEnv = "UFOO_LAUNCH_MODE=tmux ";
425
+ const ttyEnv = "UFOO_TTY_OVERRIDE=$(tty) ";
426
+ const args = Array.isArray(extraArgs) ? extraArgs : [];
427
+ const envPrefix = extraEnv ? `${String(extraEnv).trim()} ` : "";
428
+ const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
429
+
430
+ // IMPORTANT: Set TMUX_PANE inside the new window using tmux display-message
431
+ // This ensures the agent gets the correct pane ID for command injection
432
+ const setPaneEnv = `export TMUX_PANE=$(tmux display-message -p '#{pane_id}'); `;
433
+ const runCmd = `cd ${shellEscape(projectRoot)} && ${setPaneEnv}${modeEnv}${nickEnv}${ttyEnv}${envPrefix}${binary}${argText}`;
434
+ const windowName = nickname || `${agent}-${Date.now()}`;
435
+
436
+ // Use detached mode (-d) to avoid stealing focus
437
+ // Use -a flag to insert after current window, avoiding index conflicts
438
+ // Use target session from env or current session
439
+ const targetSession = process.env.UFOO_TMUX_SESSION || "";
440
+ const tmuxArgs = ["new-window", "-a", "-d", "-n", windowName];
441
+ if (targetSession) {
442
+ tmuxArgs.push("-t", targetSession);
443
+ }
444
+ tmuxArgs.push(runCmd);
445
+
446
+ const proc = spawn("tmux", tmuxArgs);
447
+ let stderr = "";
448
+ proc.stderr.on("data", (d) => {
449
+ stderr += d.toString("utf8");
450
+ });
451
+ proc.on("close", (code) => {
452
+ if (code === 0) resolve();
453
+ else reject(new Error(stderr || "tmux new-window failed"));
454
+ });
455
+ });
70
456
  }
71
457
 
72
- async function spawnAgent(projectRoot, agent, count = 1, nickname = "") {
458
+ async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
73
459
  const config = loadConfig(projectRoot);
74
- if (config.launchMode === "internal") {
75
- await spawnInternalAgent(projectRoot, agent, count, nickname);
76
- return;
460
+ const mode = config.launchMode || "terminal";
461
+ const normalizedAgent = normalizeLaunchAgent(agent);
462
+ if (!normalizedAgent) {
463
+ throw new Error(`unsupported agent type: ${agent}`);
464
+ }
465
+
466
+ if (mode === "internal") {
467
+ const result = await spawnInternalAgent(projectRoot, normalizedAgent, count, nickname, processManager);
468
+ return { mode: "internal", subscriberIds: result.subscriberIds };
469
+ }
470
+ if (mode === "tmux") {
471
+ // Check if tmux is available
472
+ const tmuxCheck = spawn("tmux", ["list-sessions"], { stdio: "pipe" });
473
+ let stdout = "";
474
+ tmuxCheck.stdout.on("data", (d) => {
475
+ stdout += d.toString("utf8");
476
+ });
477
+ const tmuxAvailable = await new Promise((resolve) => {
478
+ tmuxCheck.on("close", (code) => resolve(code === 0));
479
+ tmuxCheck.on("error", () => resolve(false));
480
+ });
481
+ if (!tmuxAvailable) {
482
+ throw new Error("tmux is not available or no tmux session is running");
483
+ }
484
+ // If UFOO_TMUX_SESSION not set, use first available session
485
+ if (!process.env.UFOO_TMUX_SESSION && stdout) {
486
+ const sessions = stdout.trim().split("\n");
487
+ if (sessions.length > 0) {
488
+ const firstSession = sessions[0].split(":")[0];
489
+ process.env.UFOO_TMUX_SESSION = firstSession;
490
+ }
491
+ }
492
+ for (let i = 0; i < count; i += 1) {
493
+ // Use "ucode" as default nickname for ufoo/ucode agents
494
+ const defaultNick = normalizedAgent === "ufoo" ? "ucode" : normalizedAgent;
495
+ const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
496
+ // eslint-disable-next-line no-await-in-loop
497
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
498
+ }
499
+ return { mode: "tmux" };
77
500
  }
501
+ // terminal mode - daemon 作为父进程,输出到终端窗口
78
502
  if (process.platform !== "darwin") {
79
- throw new Error("spawnAgent is only supported on macOS Terminal.app");
503
+ throw new Error("launchAgent with terminal mode is only supported on macOS Terminal.app");
80
504
  }
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
- ];
505
+
506
+ const subscriberIds = [];
91
507
  for (let i = 0; i < count; i += 1) {
508
+ // Use "ucode" as default nickname for ufoo/ucode agents
509
+ const defaultNick = normalizedAgent === "ufoo" ? "ucode" : normalizedAgent;
510
+ const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
92
511
  // eslint-disable-next-line no-await-in-loop
93
- await runAppleScript(script);
512
+ const result = await spawnManagedTerminalAgent(projectRoot, normalizedAgent, nick, processManager);
513
+ if (result.subscriberId) subscriberIds.push(result.subscriberId);
94
514
  }
515
+
516
+ return { mode: "terminal", subscriberIds };
517
+ }
518
+
519
+ function normalizeAgentType(agentType) {
520
+ if (agentType === "claude-code") return "claude";
521
+ if (agentType === "codex") return "codex";
522
+ if (agentType === "ufoo-code") return "ufoo";
523
+ return agentType;
524
+ }
525
+
526
+ function buildResumeArgs(agent, sessionId) {
527
+ if (!sessionId) return [];
528
+ if (agent === "codex") return ["resume", sessionId];
529
+ if (agent === "claude") return ["--session-id", sessionId];
530
+ return [];
531
+ }
532
+
533
+ function isActiveAgent(meta) {
534
+ if (!meta || meta.status !== "active") return false;
535
+ if (meta.pid && !isAgentPidAlive(meta.pid)) return false;
536
+ return true;
537
+ }
538
+
539
+ function collectRecoverableAgents(projectRoot, target = "") {
540
+ const config = loadConfig(projectRoot);
541
+ const mode = config.launchMode || "terminal";
542
+ const filePath = getUfooPaths(projectRoot).agentsFile;
543
+ const data = loadAgentsData(filePath);
544
+ const entries = Object.entries(data.agents || {});
545
+
546
+ let targets = entries;
547
+ if (target) {
548
+ if (target.includes(":")) {
549
+ targets = entries.filter(([id]) => id === target);
550
+ } else {
551
+ targets = entries.filter(([id, meta]) => id === target || (meta && meta.nickname === target));
552
+ }
553
+ }
554
+
555
+ const recoverableEntries = [];
556
+ const skipped = [];
557
+
558
+ if (target && targets.length === 0) {
559
+ return {
560
+ mode,
561
+ data,
562
+ recoverableEntries,
563
+ skipped: [{ id: target, reason: "target not found" }],
564
+ };
565
+ }
566
+
567
+ for (const [id, meta] of targets) {
568
+ if (!meta || !meta.provider_session_id) {
569
+ skipped.push({ id, reason: "no provider session" });
570
+ continue;
571
+ }
572
+ if (isActiveAgent(meta)) {
573
+ skipped.push({ id, reason: "already active" });
574
+ continue;
575
+ }
576
+ const agent = normalizeAgentType(meta.agent_type);
577
+ if (agent !== "codex" && agent !== "claude") {
578
+ skipped.push({ id, reason: "unsupported agent type" });
579
+ continue;
580
+ }
581
+
582
+ if (mode === "internal") {
583
+ skipped.push({ id, reason: "internal mode not supported for resume" });
584
+ continue;
585
+ }
586
+
587
+ recoverableEntries.push({ id, meta, agent });
588
+ }
589
+
590
+ return {
591
+ mode,
592
+ data,
593
+ recoverableEntries,
594
+ skipped,
595
+ };
596
+ }
597
+
598
+ function getRecoverableAgents(projectRoot, target = "") {
599
+ const { mode, recoverableEntries, skipped } = collectRecoverableAgents(projectRoot, target);
600
+ const recoverable = recoverableEntries.map((item) => ({
601
+ id: item.id,
602
+ nickname: item.meta.nickname || "",
603
+ agent: item.agent,
604
+ sessionId: item.meta.provider_session_id || "",
605
+ launchMode: item.meta.launch_mode || "",
606
+ lastSeen: item.meta.last_seen || "",
607
+ }));
608
+ return { ok: true, mode, recoverable, skipped };
609
+ }
610
+
611
+ async function resumeAgents(projectRoot, target = "", processManager = null) {
612
+ const filePath = getUfooPaths(projectRoot).agentsFile;
613
+ const { mode, data, recoverableEntries, skipped } = collectRecoverableAgents(projectRoot, target);
614
+
615
+ if (recoverableEntries.length === 0) {
616
+ return { ok: true, resumed: [], skipped };
617
+ }
618
+
619
+ // Clear old nicknames to allow reuse.
620
+ let updated = false;
621
+ for (const item of recoverableEntries) {
622
+ if (item.meta && item.meta.nickname) {
623
+ data.agents[item.id] = { ...item.meta, nickname: "" };
624
+ updated = true;
625
+ }
626
+ }
627
+ if (updated) {
628
+ saveAgentsData(filePath, data);
629
+ }
630
+
631
+ const resumed = [];
632
+ for (const item of recoverableEntries) {
633
+ const nickname = item.meta.nickname || "";
634
+ const sessionId = item.meta.provider_session_id;
635
+ const reused = await tryReuseTerminal(projectRoot, item.id, item.meta, item.agent, sessionId);
636
+ if (!reused) {
637
+ const args = buildResumeArgs(item.agent, sessionId);
638
+ const envPrefix = "UFOO_SKIP_SESSION_PROBE=1";
639
+ if (mode === "tmux") {
640
+ // eslint-disable-next-line no-await-in-loop
641
+ await spawnTmuxWindow(projectRoot, item.agent, nickname, args, envPrefix);
642
+ } else {
643
+ // eslint-disable-next-line no-await-in-loop
644
+ await spawnManagedTerminalAgent(projectRoot, item.agent, nickname, processManager, args, envPrefix);
645
+ }
646
+ }
647
+ resumed.push({ id: item.id, nickname, agent: item.agent, sessionId, reused });
648
+ }
649
+
650
+ return { ok: true, resumed, skipped };
95
651
  }
96
652
 
97
653
  async function closeAgent(projectRoot, agentId) {
@@ -99,22 +655,48 @@ async function closeAgent(projectRoot, agentId) {
99
655
  return false;
100
656
  }
101
657
  const resolvedId = resolveAgentId(projectRoot, agentId);
102
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
658
+ const busPath = getUfooPaths(projectRoot).agentsFile;
103
659
  let pid = null;
660
+ let launchMode = "";
661
+ let tty = "";
662
+ let terminalApp = "";
104
663
  try {
105
664
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
106
- const entry = bus.subscribers?.[resolvedId];
107
- if (entry && entry.pid) pid = entry.pid;
665
+ const entry = bus.agents?.[resolvedId];
666
+ if (entry) {
667
+ if (entry.pid) pid = entry.pid;
668
+ launchMode = entry.launch_mode || "";
669
+ tty = entry.tty || "";
670
+ terminalApp = entry.terminal_app || "";
671
+ }
108
672
  } catch {
109
673
  pid = null;
110
674
  }
111
- if (!pid) return false;
112
- try {
113
- process.kill(pid, "SIGTERM");
114
- return true;
115
- } catch {
116
- return false;
675
+ const adapterRouter = createTerminalAdapterRouter();
676
+ const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId });
677
+ const canCloseWindow = adapter.capabilities.supportsWindowClose && tty;
678
+
679
+ // Close process first for faster state transition in chat.
680
+ let sentSignal = false;
681
+ if (pid) {
682
+ try {
683
+ process.kill(pid, "SIGTERM");
684
+ sentSignal = true;
685
+ } catch {
686
+ sentSignal = false;
687
+ }
117
688
  }
689
+
690
+ if (sentSignal || (!pid && canCloseWindow)) {
691
+ markAgentInactive(projectRoot, resolvedId);
692
+ }
693
+
694
+ if (canCloseWindow) {
695
+ // Non-blocking: don't hold close response on AppleScript window operations.
696
+ void closeTerminalWindowByTty(tty, terminalApp).catch(() => false);
697
+ }
698
+
699
+ return sentSignal || (!pid && canCloseWindow);
118
700
  }
119
701
 
120
- module.exports = { spawnAgent, closeAgent };
702
+ module.exports = { launchAgent, closeAgent, getRecoverableAgents, resumeAgents };