u-foo 1.0.6 → 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 (149) hide show
  1. package/README.md +44 -4
  2. package/SKILLS/ufoo/SKILL.md +17 -2
  3. package/SKILLS/uinit/SKILL.md +8 -3
  4. package/bin/ucode-core.js +15 -0
  5. package/bin/ucode.js +125 -0
  6. package/bin/ufoo-assistant-agent.js +5 -0
  7. package/bin/ufoo-engine.js +25 -0
  8. package/bin/ufoo.js +4 -0
  9. package/modules/AGENTS.template.md +14 -4
  10. package/modules/bus/README.md +8 -5
  11. package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
  12. package/modules/context/SKILLS/uctx/SKILL.md +3 -1
  13. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  14. package/package.json +12 -3
  15. package/scripts/import-pi-mono.js +124 -0
  16. package/scripts/postinstall.js +20 -49
  17. package/scripts/sync-claude-skills.sh +21 -0
  18. package/src/agent/cliRunner.js +524 -31
  19. package/src/agent/internalRunner.js +76 -9
  20. package/src/agent/launcher.js +97 -45
  21. package/src/agent/normalizeOutput.js +1 -1
  22. package/src/agent/notifier.js +144 -4
  23. package/src/agent/ptyRunner.js +480 -10
  24. package/src/agent/ptyWrapper.js +28 -3
  25. package/src/agent/readyDetector.js +16 -0
  26. package/src/agent/ucode.js +443 -0
  27. package/src/agent/ucodeBootstrap.js +113 -0
  28. package/src/agent/ucodeBuild.js +67 -0
  29. package/src/agent/ucodeDoctor.js +184 -0
  30. package/src/agent/ucodeRuntimeConfig.js +129 -0
  31. package/src/agent/ufooAgent.js +11 -2
  32. package/src/assistant/agent.js +260 -0
  33. package/src/assistant/bridge.js +172 -0
  34. package/src/assistant/engine.js +252 -0
  35. package/src/assistant/stdio.js +58 -0
  36. package/src/assistant/ufooEngineCli.js +306 -0
  37. package/src/bus/activate.js +27 -11
  38. package/src/bus/daemon.js +133 -5
  39. package/src/bus/index.js +137 -80
  40. package/src/bus/inject.js +47 -17
  41. package/src/bus/message.js +145 -17
  42. package/src/bus/nickname.js +3 -1
  43. package/src/bus/queue.js +6 -1
  44. package/src/bus/store.js +189 -0
  45. package/src/bus/subscriber.js +20 -4
  46. package/src/bus/utils.js +9 -3
  47. package/src/chat/agentBar.js +117 -0
  48. package/src/chat/agentDirectory.js +88 -0
  49. package/src/chat/agentSockets.js +225 -0
  50. package/src/chat/agentViewController.js +298 -0
  51. package/src/chat/chatLogController.js +115 -0
  52. package/src/chat/commandExecutor.js +700 -0
  53. package/src/chat/commands.js +132 -0
  54. package/src/chat/completionController.js +414 -0
  55. package/src/chat/cronScheduler.js +160 -0
  56. package/src/chat/daemonConnection.js +166 -0
  57. package/src/chat/daemonCoordinator.js +64 -0
  58. package/src/chat/daemonMessageRouter.js +257 -0
  59. package/src/chat/daemonReconnect.js +41 -0
  60. package/src/chat/daemonTransport.js +36 -0
  61. package/src/chat/daemonTransportDefaults.js +10 -0
  62. package/src/chat/dashboardKeyController.js +480 -0
  63. package/src/chat/dashboardView.js +154 -0
  64. package/src/chat/index.js +935 -2909
  65. package/src/chat/inputHistoryController.js +105 -0
  66. package/src/chat/inputListenerController.js +304 -0
  67. package/src/chat/inputMath.js +104 -0
  68. package/src/chat/inputSubmitHandler.js +171 -0
  69. package/src/chat/layout.js +165 -0
  70. package/src/chat/pasteController.js +81 -0
  71. package/src/chat/rawKeyMap.js +42 -0
  72. package/src/chat/settingsController.js +132 -0
  73. package/src/chat/statusLineController.js +177 -0
  74. package/src/chat/streamTracker.js +138 -0
  75. package/src/chat/text.js +70 -0
  76. package/src/chat/transport.js +61 -0
  77. package/src/cli/busCoreCommands.js +59 -0
  78. package/src/cli/ctxCoreCommands.js +199 -0
  79. package/src/cli/onlineCoreCommands.js +379 -0
  80. package/src/cli.js +741 -238
  81. package/src/code/README.md +29 -0
  82. package/src/code/UCODE_PROMPT.md +32 -0
  83. package/src/code/agent.js +1651 -0
  84. package/src/code/cli.js +158 -0
  85. package/src/code/config +0 -0
  86. package/src/code/dispatch.js +42 -0
  87. package/src/code/index.js +70 -0
  88. package/src/code/nativeRunner.js +1213 -0
  89. package/src/code/runtime.js +154 -0
  90. package/src/code/sessionStore.js +162 -0
  91. package/src/code/taskDecomposer.js +269 -0
  92. package/src/code/tools/bash.js +53 -0
  93. package/src/code/tools/common.js +42 -0
  94. package/src/code/tools/edit.js +70 -0
  95. package/src/code/tools/read.js +44 -0
  96. package/src/code/tools/write.js +35 -0
  97. package/src/code/tui.js +1580 -0
  98. package/src/config.js +47 -1
  99. package/src/context/decisions.js +12 -2
  100. package/src/context/index.js +18 -1
  101. package/src/context/sync.js +127 -0
  102. package/src/daemon/agentProcessManager.js +74 -0
  103. package/src/daemon/cronOps.js +241 -0
  104. package/src/daemon/index.js +661 -488
  105. package/src/daemon/ipcServer.js +99 -0
  106. package/src/daemon/ops.js +417 -179
  107. package/src/daemon/promptLoop.js +319 -0
  108. package/src/daemon/promptRequest.js +101 -0
  109. package/src/daemon/providerSessions.js +32 -17
  110. package/src/daemon/reporting.js +90 -0
  111. package/src/daemon/run.js +2 -5
  112. package/src/daemon/status.js +24 -1
  113. package/src/init/index.js +68 -14
  114. package/src/online/bridge.js +663 -0
  115. package/src/online/client.js +245 -0
  116. package/src/online/runner.js +253 -0
  117. package/src/online/server.js +992 -0
  118. package/src/online/tokens.js +103 -0
  119. package/src/report/store.js +331 -0
  120. package/src/shared/eventContract.js +35 -0
  121. package/src/shared/ptySocketContract.js +21 -0
  122. package/src/status/index.js +50 -17
  123. package/src/terminal/adapterContract.js +87 -0
  124. package/src/terminal/adapterRouter.js +84 -0
  125. package/src/terminal/adapters/externalAdapter.js +14 -0
  126. package/src/terminal/adapters/internalAdapter.js +13 -0
  127. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  128. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  129. package/src/terminal/adapters/terminalAdapter.js +31 -0
  130. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  131. package/src/ufoo/agentsStore.js +69 -3
  132. package/src/utils/banner.js +5 -2
  133. package/scripts/.archived/bash-to-js-migration/README.md +0 -46
  134. package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
  135. package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
  136. package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
  137. package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
  138. package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
  139. package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
  140. package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
  141. package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
  142. package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
  143. package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
  144. package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
  145. package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
  146. package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
  147. package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
  148. package/scripts/banner.sh +0 -2
  149. package/src/bus/API_DESIGN.md +0 -204
package/src/daemon/ops.js CHANGED
@@ -1,11 +1,41 @@
1
- const { spawn, spawnSync } = require("child_process");
1
+ const { spawn } = require("child_process");
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
4
  const { loadConfig } = require("../config");
5
5
  const { getUfooPaths } = require("../ufoo/paths");
6
6
  const { loadAgentsData, saveAgentsData } = require("../ufoo/agentsStore");
7
- const { isAgentPidAlive } = require("../bus/utils");
7
+ const { isAgentPidAlive, getTtyProcessInfo } = require("../bus/utils");
8
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
+ }
9
39
 
10
40
  function resolveAgentId(projectRoot, agentId) {
11
41
  if (!agentId) return agentId;
@@ -16,7 +46,8 @@ function resolveAgentId(projectRoot, agentId) {
16
46
  const entries = Object.entries(bus.agents || {});
17
47
  const match = entries.find(([, meta]) => meta?.nickname === agentId);
18
48
  if (match) return match[0];
19
- const targetType = agentId === "claude" ? "claude-code" : agentId;
49
+ const normalized = normalizeLaunchAgent(agentId);
50
+ const targetType = toBusAgentType(normalized) || agentId;
20
51
  const candidates = entries
21
52
  .filter(([, meta]) => meta?.agent_type === targetType && meta?.status === "active")
22
53
  .map(([id]) => id);
@@ -27,6 +58,51 @@ function resolveAgentId(projectRoot, agentId) {
27
58
  return agentId;
28
59
  }
29
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
+
30
106
  function shellEscape(value) {
31
107
  const str = String(value);
32
108
  return `'${str.replace(/'/g, `'\\''`)}'`;
@@ -36,75 +112,181 @@ function escapeAppleScriptString(str) {
36
112
  return String(str).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
37
113
  }
38
114
 
39
- /**
40
- * Terminal.app 中打开新窗口运行 agent
41
- * 使用简单的 AppleScript,只负责打开窗口执行命令
42
- * agent 进程的监控由 uclaude/ucodex 内部的 PTY wrapper 处理
43
- */
44
- async function spawnTerminalAgent(projectRoot, agent, nickname = "") {
115
+ function runAppleScript(lines) {
116
+ return new Promise((resolve, reject) => {
117
+ const proc = spawn("osascript", lines.flatMap((l) => ["-e", l]));
118
+ let stderr = "";
119
+ let stdout = "";
120
+ proc.stderr.on("data", (d) => {
121
+ stderr += d.toString("utf8");
122
+ });
123
+ proc.stdout.on("data", (d) => {
124
+ stdout += d.toString("utf8");
125
+ });
126
+ proc.on("close", (code) => {
127
+ if (code === 0) resolve(stdout.trim());
128
+ else reject(new Error(stderr || "osascript failed"));
129
+ });
130
+ });
131
+ }
132
+
133
+ async function openTerminalWindow(runCmd) {
45
134
  if (process.platform !== "darwin") {
46
135
  throw new Error("Terminal mode is only supported on macOS");
47
136
  }
48
137
 
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}`;
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
+ }
53
156
 
54
157
  const script = [
55
158
  'tell application "Terminal"',
56
- `do script "${escapeAppleScriptString(runCmd)}"`,
159
+ `do script "${escaped}"`,
57
160
  "activate",
58
161
  "end tell",
59
162
  ];
163
+ await runAppleScript(script);
164
+ }
60
165
 
61
- return new Promise((resolve, reject) => {
62
- const proc = spawn("osascript", script.flatMap((l) => ["-e", l]));
63
- let stderr = "";
64
- proc.stderr.on("data", (d) => {
65
- stderr += d.toString("utf8");
66
- });
67
- proc.on("close", (code) => {
68
- if (code === 0) resolve();
69
- else reject(new Error(stderr || "Failed to open Terminal.app"));
70
- });
71
- });
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
+ }
72
260
  }
73
261
 
74
262
  /**
75
- * iTerm2 中打开新 tab 运行 agent
76
- * 使用 AppleScript 控制 iTerm2,比 Terminal.app 更丰富的功能
263
+ * Spawn managed terminal agent - open a real Terminal session to run the agent
77
264
  */
78
- async function spawnITerm2Agent(projectRoot, agent, nickname = "") {
79
- if (process.platform !== "darwin") {
80
- throw new Error("iTerm2 mode is only supported on macOS");
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}`);
81
271
  }
272
+ const existing = listSubscribers(projectRoot, agentType);
273
+ const runDir = getUfooPaths(projectRoot).runDir;
274
+ fs.mkdirSync(runDir, { recursive: true });
82
275
 
83
- const binary = agent === "codex" ? "ucodex" : "uclaude";
276
+ const args = Array.isArray(extraArgs) ? extraArgs : [];
277
+ const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
84
278
  const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
85
279
  const modeEnv = "UFOO_LAUNCH_MODE=terminal ";
86
- const runCmd = `cd ${shellEscape(projectRoot)} && ${modeEnv}${nickEnv}${binary}`;
280
+ const envPrefix = extraEnv ? `${String(extraEnv).trim()} ` : "";
281
+ const titleCmd = buildTitleCmd(nickname);
282
+ const prefix = titleCmd ? `${titleCmd} && ` : "";
87
283
 
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
- ];
284
+ const runCmd = `cd ${shellEscape(projectRoot)} && ${prefix}${modeEnv}${nickEnv}${envPrefix}${binary}${argText}`;
96
285
 
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
- });
286
+ await openTerminalWindow(runCmd);
287
+
288
+ const subscriberId = await waitForNewSubscriber(projectRoot, agentType, existing, 15000);
289
+ return { child: null, subscriberId: subscriberId || null };
108
290
  }
109
291
 
110
292
  async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
@@ -129,7 +311,11 @@ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "",
129
311
 
130
312
  // 预生成 session ID
131
313
  const sessionId = crypto.randomBytes(4).toString("hex");
132
- const agentType = agent === "codex" ? "codex" : "claude-code";
314
+ const normalizedAgent = normalizeLaunchAgent(agent);
315
+ const agentType = toBusAgentType(normalizedAgent);
316
+ if (!agentType) {
317
+ throw new Error(`unsupported agent type: ${agent}`);
318
+ }
133
319
  const subscriberId = `${agentType}:${sessionId}`;
134
320
  subscriberIds.push(subscriberId);
135
321
 
@@ -137,7 +323,9 @@ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "",
137
323
  bus.loadBusData();
138
324
  process.env.UFOO_PARENT_PID = String(originalPid);
139
325
 
140
- const finalNickname = count > 1 ? `${nickname || agent}-${i + 1}` : (nickname || "");
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);
141
329
  const usePty = process.env.UFOO_INTERNAL_PTY !== "0";
142
330
  const launchMode = usePty ? "internal-pty" : "internal";
143
331
 
@@ -165,6 +353,18 @@ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "",
165
353
  },
166
354
  });
167
355
 
356
+ // Update bus data with the actual child PID so isMetaActive
357
+ // can detect when the ptyRunner process dies.
358
+ try {
359
+ bus.loadBusData();
360
+ if (bus.busData.agents && bus.busData.agents[subscriberId]) {
361
+ bus.busData.agents[subscriberId].pid = child.pid;
362
+ }
363
+ bus.saveBusData();
364
+ } catch {
365
+ // ignore pid update errors
366
+ }
367
+
168
368
  // 本地日志记录
169
369
  child.on("exit", (code, signal) => {
170
370
  try {
@@ -173,6 +373,18 @@ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "",
173
373
  // ignore
174
374
  }
175
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
+
176
388
  if (signal) {
177
389
  fs.appendFileSync(logFile, `\n[internal-agent] ${subscriberId} killed by signal ${signal}\n`);
178
390
  } else {
@@ -200,41 +412,14 @@ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "",
200
412
  return { children, subscriberIds };
201
413
  }
202
414
 
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
415
  function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extraEnv = "") {
236
416
  return new Promise((resolve, reject) => {
237
- const binary = agent === "codex" ? "ucodex" : "uclaude";
417
+ const normalizedAgent = normalizeLaunchAgent(agent);
418
+ const binary = toTmuxBinary(normalizedAgent);
419
+ if (!binary) {
420
+ reject(new Error(`unsupported agent type: ${agent}`));
421
+ return;
422
+ }
238
423
  const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
239
424
  const modeEnv = "UFOO_LAUNCH_MODE=tmux ";
240
425
  const ttyEnv = "UFOO_TTY_OVERRIDE=$(tty) ";
@@ -242,61 +427,46 @@ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extr
242
427
  const envPrefix = extraEnv ? `${String(extraEnv).trim()} ` : "";
243
428
  const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
244
429
 
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}`;
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}`;
247
434
  const windowName = nickname || `${agent}-${Date.now()}`;
248
- const targetSession = process.env.UFOO_TMUX_SESSION || "";
249
435
 
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
- });
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);
277
443
  }
278
- });
279
- }
444
+ tmuxArgs.push(runCmd);
280
445
 
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";
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
+ });
291
456
  }
292
457
 
293
458
  async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
294
459
  const config = loadConfig(projectRoot);
295
- let mode = config.launchMode || "auto";
296
- if (mode === "auto") {
297
- mode = detectLaunchMode();
460
+ const mode = config.launchMode || "terminal";
461
+ const normalizedAgent = normalizeLaunchAgent(agent);
462
+ if (!normalizedAgent) {
463
+ throw new Error(`unsupported agent type: ${agent}`);
298
464
  }
299
465
 
466
+ if (mode === "internal") {
467
+ const result = await spawnInternalAgent(projectRoot, normalizedAgent, count, nickname, processManager);
468
+ return { mode: "internal", subscriberIds: result.subscriberIds };
469
+ }
300
470
  if (mode === "tmux") {
301
471
  // Check if tmux is available
302
472
  const tmuxCheck = spawn("tmux", ["list-sessions"], { stdio: "pipe" });
@@ -320,36 +490,36 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
320
490
  }
321
491
  }
322
492
  for (let i = 0; i < count; i += 1) {
323
- const nick = count > 1 ? `${nickname || agent}-${i + 1}` : (nickname || "");
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 || "");
324
496
  // eslint-disable-next-line no-await-in-loop
325
- await spawnTmuxWindow(projectRoot, agent, nick);
497
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
326
498
  }
327
499
  return { mode: "tmux" };
328
500
  }
501
+ // terminal mode - daemon 作为父进程,输出到终端窗口
502
+ if (process.platform !== "darwin") {
503
+ throw new Error("launchAgent with terminal mode is only supported on macOS Terminal.app");
504
+ }
329
505
 
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" };
506
+ const subscriberIds = [];
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 || "");
511
+ // eslint-disable-next-line no-await-in-loop
512
+ const result = await spawnManagedTerminalAgent(projectRoot, normalizedAgent, nick, processManager);
513
+ if (result.subscriberId) subscriberIds.push(result.subscriberId);
343
514
  }
344
515
 
345
- // internal mode - 使用 PTY 方式启动
346
- const result = await spawnInternalAgent(projectRoot, agent, count, nickname, processManager);
347
- return { mode: "internal", subscriberIds: result.subscriberIds };
516
+ return { mode: "terminal", subscriberIds };
348
517
  }
349
518
 
350
519
  function normalizeAgentType(agentType) {
351
520
  if (agentType === "claude-code") return "claude";
352
521
  if (agentType === "codex") return "codex";
522
+ if (agentType === "ufoo-code") return "ufoo";
353
523
  return agentType;
354
524
  }
355
525
 
@@ -366,9 +536,9 @@ function isActiveAgent(meta) {
366
536
  return true;
367
537
  }
368
538
 
369
- async function resumeAgents(projectRoot, target = "", processManager = null) {
539
+ function collectRecoverableAgents(projectRoot, target = "") {
370
540
  const config = loadConfig(projectRoot);
371
- const mode = config.launchMode || "internal";
541
+ const mode = config.launchMode || "terminal";
372
542
  const filePath = getUfooPaths(projectRoot).agentsFile;
373
543
  const data = loadAgentsData(filePath);
374
544
  const entries = Object.entries(data.agents || {});
@@ -378,13 +548,22 @@ async function resumeAgents(projectRoot, target = "", processManager = null) {
378
548
  if (target.includes(":")) {
379
549
  targets = entries.filter(([id]) => id === target);
380
550
  } else {
381
- targets = entries.filter(([, meta]) => meta && meta.nickname === target);
551
+ targets = entries.filter(([id, meta]) => id === target || (meta && meta.nickname === target));
382
552
  }
383
553
  }
384
554
 
385
- const resumable = [];
555
+ const recoverableEntries = [];
386
556
  const skipped = [];
387
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
+
388
567
  for (const [id, meta] of targets) {
389
568
  if (!meta || !meta.provider_session_id) {
390
569
  skipped.push({ id, reason: "no provider session" });
@@ -399,16 +578,47 @@ async function resumeAgents(projectRoot, target = "", processManager = null) {
399
578
  skipped.push({ id, reason: "unsupported agent type" });
400
579
  continue;
401
580
  }
402
- resumable.push({ id, meta, agent });
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 });
403
588
  }
404
589
 
405
- if (resumable.length === 0) {
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) {
406
616
  return { ok: true, resumed: [], skipped };
407
617
  }
408
618
 
409
- // Clear old nicknames to allow reuse
619
+ // Clear old nicknames to allow reuse.
410
620
  let updated = false;
411
- for (const item of resumable) {
621
+ for (const item of recoverableEntries) {
412
622
  if (item.meta && item.meta.nickname) {
413
623
  data.agents[item.id] = { ...item.meta, nickname: "" };
414
624
  updated = true;
@@ -419,46 +629,74 @@ async function resumeAgents(projectRoot, target = "", processManager = null) {
419
629
  }
420
630
 
421
631
  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;
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) {
428
637
  const args = buildResumeArgs(item.agent, sessionId);
429
638
  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 });
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
+ }
433
646
  }
434
- return { ok: true, resumed, skipped };
647
+ resumed.push({ id: item.id, nickname, agent: item.agent, sessionId, reused });
435
648
  }
436
649
 
437
- // internal 模式暂不支持 resume(需要用户手动启动)
438
- for (const item of resumable) {
439
- skipped.push({ id: item.id, reason: "internal mode requires manual restart" });
440
- }
441
650
  return { ok: true, resumed, skipped };
442
651
  }
443
652
 
444
653
  async function closeAgent(projectRoot, agentId) {
654
+ if (process.platform !== "darwin") {
655
+ return false;
656
+ }
445
657
  const resolvedId = resolveAgentId(projectRoot, agentId);
446
658
  const busPath = getUfooPaths(projectRoot).agentsFile;
447
659
  let pid = null;
660
+ let launchMode = "";
661
+ let tty = "";
662
+ let terminalApp = "";
448
663
  try {
449
664
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
450
665
  const entry = bus.agents?.[resolvedId];
451
- if (entry && entry.pid) pid = entry.pid;
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
+ }
452
672
  } catch {
453
673
  pid = null;
454
674
  }
455
- if (!pid) return false;
456
- try {
457
- process.kill(pid, "SIGTERM");
458
- return true;
459
- } catch {
460
- 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
+ }
461
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);
462
700
  }
463
701
 
464
- module.exports = { launchAgent, closeAgent, resumeAgents };
702
+ module.exports = { launchAgent, closeAgent, getRecoverableAgents, resumeAgents };