u-foo 2.3.23 → 2.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.3.23",
3
+ "version": "2.3.25",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
package/src/bus/inject.js CHANGED
@@ -1,4 +1,4 @@
1
- const { spawn } = require("child_process");
1
+ const { spawn, spawnSync } = require("child_process");
2
2
  const fs = require("fs");
3
3
  const net = require("net");
4
4
  const path = require("path");
@@ -12,12 +12,46 @@ const logInject = (message) => {
12
12
  }
13
13
  };
14
14
 
15
+ function escapeAppleScriptString(value) {
16
+ return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
17
+ }
18
+
19
+ function appleScriptStringLiteral(value) {
20
+ const lines = String(value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
21
+ if (lines.length === 0) return '""';
22
+ return lines.map((line) => `"${escapeAppleScriptString(line)}"`).join(" & linefeed & ");
23
+ }
24
+
25
+ function runAppleScript(lines = []) {
26
+ return new Promise((resolve, reject) => {
27
+ const proc = spawn("osascript", lines.flatMap((line) => ["-e", line]));
28
+ let stderr = "";
29
+ let stdout = "";
30
+
31
+ proc.stdout.on("data", (data) => {
32
+ stdout += data.toString("utf8");
33
+ });
34
+ proc.stderr.on("data", (data) => {
35
+ stderr += data.toString("utf8");
36
+ });
37
+ proc.on("close", (code) => {
38
+ if (code === 0) {
39
+ resolve(stdout.trim());
40
+ } else {
41
+ reject(new Error(stderr.trim() || "AppleScript failed"));
42
+ }
43
+ });
44
+ proc.on("error", reject);
45
+ });
46
+ }
47
+
15
48
  /**
16
49
  * 命令注入器 - 将命令注入到终端
17
50
  *
18
51
  * 支持的方式:
19
52
  * 1. PTY socket(直接写入,无需macOS权限)
20
53
  * 2. tmux send-keys(无需权限)
54
+ * 3. Terminal.app/iTerm2 tty lookup(macOS terminal mode fallback)
21
55
  */
22
56
  class Injector {
23
57
  constructor(busDir, agentsFile) {
@@ -175,6 +209,128 @@ class Injector {
175
209
  textProc.on("error", reject);
176
210
  }
177
211
 
212
+ /**
213
+ * Use Terminal.app's tty metadata to locate the target tab and paste input.
214
+ */
215
+ async injectTerminal(tty, command) {
216
+ const ttyLiteral = appleScriptStringLiteral(tty);
217
+ const commandLiteral = appleScriptStringLiteral(command);
218
+ const lines = [
219
+ 'tell application "Terminal"',
220
+ " set targetWindow to missing value",
221
+ " set targetTab to missing value",
222
+ " repeat with w in windows",
223
+ " repeat with t in tabs of w",
224
+ " try",
225
+ ` if tty of t is ${ttyLiteral} then`,
226
+ " set targetWindow to w",
227
+ " set targetTab to t",
228
+ " exit repeat",
229
+ " end if",
230
+ " end try",
231
+ " end repeat",
232
+ " if targetTab is not missing value then exit repeat",
233
+ " end repeat",
234
+ " if targetTab is missing value then",
235
+ ` error "No Terminal tab found with tty: " & ${ttyLiteral}`,
236
+ " end if",
237
+ " activate",
238
+ " set selected tab of targetWindow to targetTab",
239
+ " set index of targetWindow to 1",
240
+ "end tell",
241
+ "set oldClipboard to the clipboard",
242
+ "try",
243
+ ` set the clipboard to ${commandLiteral}`,
244
+ " delay 0.1",
245
+ ' tell application "System Events"',
246
+ ' tell process "Terminal"',
247
+ " key code 53",
248
+ " delay 0.1",
249
+ ' keystroke "v" using command down',
250
+ " delay 0.2",
251
+ " keystroke return",
252
+ " end tell",
253
+ " end tell",
254
+ "on error errMsg number errNo",
255
+ " set the clipboard to oldClipboard",
256
+ " error errMsg number errNo",
257
+ "end try",
258
+ "delay 0.2",
259
+ "set the clipboard to oldClipboard",
260
+ ];
261
+
262
+ return runAppleScript(lines);
263
+ }
264
+
265
+ /**
266
+ * Check if iTerm2 is available before trying its direct write API.
267
+ */
268
+ isItermRunning() {
269
+ try {
270
+ const res = spawnSync("osascript", ["-e", 'application "iTerm2" is running'], {
271
+ encoding: "utf8",
272
+ stdio: ["ignore", "pipe", "ignore"],
273
+ });
274
+ return res.status === 0 && String(res.stdout || "").trim() === "true";
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Use iTerm2's tty metadata to locate the target session and write input.
282
+ */
283
+ async injectIterm(tty, command) {
284
+ const ttyLiteral = appleScriptStringLiteral(tty);
285
+ const commandLiteral = appleScriptStringLiteral(command);
286
+ const lines = [
287
+ 'tell application "iTerm2"',
288
+ " activate",
289
+ " repeat with w in windows",
290
+ " repeat with t in tabs of w",
291
+ " repeat with s in sessions of t",
292
+ " try",
293
+ ` if tty of s is ${ttyLiteral} then`,
294
+ " select s",
295
+ ` write text ${commandLiteral} to s`,
296
+ " return",
297
+ " end if",
298
+ " end try",
299
+ " end repeat",
300
+ " end repeat",
301
+ " end repeat",
302
+ ` error "No iTerm2 session found with tty: " & ${ttyLiteral}`,
303
+ "end tell",
304
+ ];
305
+
306
+ return runAppleScript(lines);
307
+ }
308
+
309
+ async injectMacTerminal(tty, command, meta = {}) {
310
+ if (process.platform !== "darwin") {
311
+ throw new Error("Terminal.app injection is only supported on macOS");
312
+ }
313
+
314
+ const terminalApp = String(meta.terminal_app || meta.terminalApp || "").trim().toLowerCase();
315
+ const shouldTryIterm = terminalApp === "iterm2" || (!terminalApp && this.isItermRunning());
316
+
317
+ if (shouldTryIterm) {
318
+ try {
319
+ logInject(`[inject] Using iTerm2 AppleScript for tty: ${tty}`);
320
+ await this.injectIterm(tty, command);
321
+ return;
322
+ } catch (err) {
323
+ if (terminalApp === "iterm2") {
324
+ throw err;
325
+ }
326
+ logInject(`[inject] iTerm2 failed: ${err.message}, trying Terminal.app`);
327
+ }
328
+ }
329
+
330
+ logInject(`[inject] Using Terminal.app AppleScript for tty: ${tty}`);
331
+ await this.injectTerminal(tty, command);
332
+ }
333
+
178
334
  /**
179
335
  * 获取订阅者的 inject socket 路径
180
336
  */
@@ -252,6 +408,7 @@ class Injector {
252
408
  * 优先级:
253
409
  * 1. PTY socket(直接写入,无需macOS权限)
254
410
  * 2. tmux send-keys(无需权限)
411
+ * 3. Terminal.app/iTerm2 tty lookup(terminal mode fallback)
255
412
  */
256
413
  async inject(subscriber, commandOverride = "") {
257
414
  if (String(subscriber || "").startsWith("ufoo-code:")) {
@@ -300,8 +457,10 @@ class Injector {
300
457
  }
301
458
  }
302
459
 
303
- // 读取 tty(tmux 需要)
304
- const tty = this.readTty(subscriber);
460
+ // 读取 tty(tmux/Terminal.app fallback 需要)
461
+ const recordedTty = this.readTty(subscriber);
462
+ const metaTty = isValidTty(meta.tty) ? meta.tty : "";
463
+ const tty = recordedTty || metaTty;
305
464
 
306
465
  // 2. 尝试 tmux(无需权限)
307
466
  // Launch mode may be temporarily missing/stale (e.g. rejoin from non-interactive context).
@@ -329,8 +488,17 @@ class Injector {
329
488
  }
330
489
  }
331
490
 
491
+ // 3. Plain Terminal.app/iTerm2 fallback: locate the window/tab by tty and paste input.
492
+ const allowMacTerminalFallback = (launchMode === "terminal" || (!launchMode && tty && isValidTty(tty)))
493
+ && tty
494
+ && isValidTty(tty);
495
+ if (allowMacTerminalFallback) {
496
+ await this.injectMacTerminal(tty, command, meta);
497
+ return;
498
+ }
499
+
332
500
  // 没有可用的注入方式
333
- throw new Error(`No inject method available for ${subscriber}. PTY socket or tmux required.`);
501
+ throw new Error(`No inject method available for ${subscriber}. PTY socket, tmux, or Terminal.app injection required.`);
334
502
  }
335
503
  }
336
504
 
@@ -78,6 +78,14 @@ function collectHostLaunchRequestContext(env = process.env) {
78
78
  return context;
79
79
  }
80
80
 
81
+ function collectTerminalLaunchRequestContext(resolveTerminalApp = defaultResolveTerminalApp) {
82
+ const terminalApp = String(resolveTerminalApp() || "").trim().toLowerCase();
83
+ if (terminalApp === "terminal" || terminalApp === "iterm2") {
84
+ return { terminal_app: terminalApp };
85
+ }
86
+ return {};
87
+ }
88
+
81
89
  async function withCapturedConsole(capture, fn) {
82
90
  const originalLog = console.log;
83
91
  const originalError = console.error;
@@ -551,11 +559,8 @@ function createCommandExecutor(options = {}) {
551
559
  prompt_profile: promptProfile,
552
560
  launch_scope: launchScope,
553
561
  ...collectHostLaunchRequestContext(),
562
+ ...collectTerminalLaunchRequestContext(resolveTerminalApp),
554
563
  };
555
- const terminalApp = String(resolveTerminalApp() || "").trim().toLowerCase();
556
- if (terminalApp === "terminal" || terminalApp === "iterm2") {
557
- request.terminal_app = terminalApp;
558
- }
559
564
  send(request);
560
565
  schedule(requestStatus, 1000);
561
566
  } catch (err) {
@@ -696,6 +701,7 @@ function createCommandExecutor(options = {}) {
696
701
  prompt_profile: profile,
697
702
  launch_scope: launchScope,
698
703
  ...collectHostLaunchRequestContext(),
704
+ ...collectTerminalLaunchRequestContext(resolveTerminalApp),
699
705
  });
700
706
  schedule(requestStatus, 1000);
701
707
  } catch (err) {
@@ -1106,6 +1112,7 @@ function createCommandExecutor(options = {}) {
1106
1112
  instance,
1107
1113
  dry_run: dryRun,
1108
1114
  ...collectHostLaunchRequestContext(),
1115
+ ...collectTerminalLaunchRequestContext(resolveTerminalApp),
1109
1116
  });
1110
1117
  schedule(requestStatus, 1000);
1111
1118
  return;
@@ -1638,4 +1645,5 @@ function createCommandExecutor(options = {}) {
1638
1645
  module.exports = {
1639
1646
  createCommandExecutor,
1640
1647
  collectHostLaunchRequestContext,
1648
+ collectTerminalLaunchRequestContext,
1641
1649
  };
@@ -242,11 +242,13 @@ function buildLaunchHostContext(params = {}) {
242
242
  const hostDaemonSock = asTrimmedString(params.host_daemon_sock || params.hostDaemonSock);
243
243
  const hostName = asTrimmedString(params.host_name || params.hostName);
244
244
  const hostSessionId = asTrimmedString(params.host_session_id || params.hostSessionId);
245
+ const terminalApp = asTrimmedString(params.terminal_app || params.terminalApp);
245
246
  const context = {};
246
247
  if (hostInjectSock) context.host_inject_sock = hostInjectSock;
247
248
  if (hostDaemonSock) context.host_daemon_sock = hostDaemonSock;
248
249
  if (hostName) context.host_name = hostName;
249
250
  if (hostSessionId) context.host_session_id = hostSessionId;
251
+ if (terminalApp) context.terminal_app = terminalApp;
250
252
  if (params.host_capabilities && typeof params.host_capabilities === "object") {
251
253
  context.host_capabilities = { ...params.host_capabilities };
252
254
  } else if (params.hostCapabilities && typeof params.hostCapabilities === "object") {
@@ -1753,6 +1753,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1753
1753
  const hostDaemonSock = req.host_daemon_sock || req.hostDaemonSock || "";
1754
1754
  const hostName = req.host_name || req.hostName || "";
1755
1755
  const hostSessionId = req.host_session_id || req.hostSessionId || "";
1756
+ const terminalApp = req.terminal_app || req.terminalApp || "";
1756
1757
  const hostCapabilities =
1757
1758
  req.host_capabilities && typeof req.host_capabilities === "object"
1758
1759
  ? req.host_capabilities
@@ -1768,6 +1769,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1768
1769
  host_daemon_sock: hostDaemonSock,
1769
1770
  host_name: hostName,
1770
1771
  host_session_id: hostSessionId,
1772
+ terminal_app: terminalApp,
1771
1773
  host_capabilities: hostCapabilities,
1772
1774
  });
1773
1775
  const ok = result && result.ok !== false;
package/src/daemon/ops.js CHANGED
@@ -152,14 +152,25 @@ function resolveHostLaunchContext(options = {}) {
152
152
  };
153
153
  }
154
154
 
155
+ function resolveNativeTerminalApp(options = {}) {
156
+ const explicit = normalizeTerminalAppPreference(options.terminalApp);
157
+ if (explicit) return explicit;
158
+
159
+ const termProgram = normalizeTerminalAppPreference(process.env.TERM_PROGRAM || "");
160
+ if (termProgram) return termProgram;
161
+ if (process.env.ITERM_SESSION_ID) return "iterm2";
162
+ return "";
163
+ }
164
+
155
165
  function resolveConfiguredLaunchMode(configuredMode = "", options = {}) {
156
166
  const mode = normalizeOptionalString(configuredMode);
157
167
  if (mode === "internal" || mode === "internal-pty" || mode === "tmux" || mode === "terminal" || mode === "host") {
158
168
  return mode;
159
169
  }
160
- const hostContext = resolveHostLaunchContext(options);
161
- if (hostContext.hostDaemonSock) return "host";
162
170
  if (process.env.TMUX_PANE) return "tmux";
171
+ const hostContext = resolveHostLaunchContext(options);
172
+ const nativeTerminalApp = resolveNativeTerminalApp(options);
173
+ if (hostContext.hostDaemonSock && !nativeTerminalApp) return "host";
163
174
  return "terminal";
164
175
  }
165
176
 
@@ -1303,4 +1314,14 @@ async function closeAgent(projectRoot, agentId) {
1303
1314
  };
1304
1315
  }
1305
1316
 
1306
- module.exports = { launchAgent, closeAgent, getRecoverableAgents, resumeAgents };
1317
+ module.exports = {
1318
+ launchAgent,
1319
+ closeAgent,
1320
+ getRecoverableAgents,
1321
+ resumeAgents,
1322
+ __private: {
1323
+ resolveConfiguredLaunchMode,
1324
+ resolveHostLaunchContext,
1325
+ resolveNativeTerminalApp,
1326
+ },
1327
+ };