u-foo 1.4.1 → 1.6.0

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 (47) hide show
  1. package/README.md +21 -0
  2. package/README.zh-CN.md +21 -0
  3. package/bin/ufoo.js +15 -7
  4. package/modules/AGENTS.template.md +4 -102
  5. package/package.json +3 -2
  6. package/scripts/global-chat-switch-benchmark.js +406 -0
  7. package/src/agent/activityDetector.js +328 -0
  8. package/src/agent/activityStatePublisher.js +67 -0
  9. package/src/agent/activityStateWriter.js +40 -0
  10. package/src/agent/internalRunner.js +13 -0
  11. package/src/agent/launcher.js +47 -7
  12. package/src/agent/notifier.js +73 -4
  13. package/src/agent/ptyRunner.js +81 -34
  14. package/src/agent/ufooAgent.js +192 -6
  15. package/src/bus/message.js +1 -9
  16. package/src/bus/subscriber.js +2 -0
  17. package/src/bus/utils.js +10 -0
  18. package/src/chat/agentBar.js +21 -3
  19. package/src/chat/agentViewController.js +2 -0
  20. package/src/chat/chatLogController.js +28 -5
  21. package/src/chat/commandExecutor.js +127 -3
  22. package/src/chat/commands.js +8 -0
  23. package/src/chat/daemonConnection.js +77 -4
  24. package/src/chat/daemonCoordinator.js +36 -0
  25. package/src/chat/daemonMessageRouter.js +22 -0
  26. package/src/chat/daemonTransport.js +47 -5
  27. package/src/chat/daemonTransportDefaults.js +1 -0
  28. package/src/chat/dashboardKeyController.js +89 -1
  29. package/src/chat/dashboardView.js +312 -93
  30. package/src/chat/index.js +683 -41
  31. package/src/chat/inputHistoryController.js +33 -3
  32. package/src/chat/inputListenerController.js +22 -12
  33. package/src/chat/layout.js +12 -7
  34. package/src/chat/projectCloseController.js +119 -0
  35. package/src/chat/projectRuntimes.js +55 -0
  36. package/src/chat/statusLineController.js +52 -6
  37. package/src/chat/streamTracker.js +6 -0
  38. package/src/chat/transport.js +41 -5
  39. package/src/cli.js +167 -4
  40. package/src/daemon/index.js +54 -5
  41. package/src/daemon/ipcServer.js +6 -1
  42. package/src/daemon/ops.js +245 -35
  43. package/src/daemon/status.js +3 -1
  44. package/src/init/index.js +32 -3
  45. package/src/projects/projectId.js +29 -0
  46. package/src/projects/registry.js +279 -0
  47. package/src/ufoo/agentsStore.js +44 -0
package/src/daemon/ops.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 path = require("path");
4
4
  const { loadConfig } = require("../config");
@@ -37,6 +37,44 @@ function toTmuxBinary(agent = "") {
37
37
  return "";
38
38
  }
39
39
 
40
+ function normalizeLaunchScope(value, fallback = "inplace") {
41
+ const raw = String(value || "").trim().toLowerCase();
42
+ if (!raw) return fallback;
43
+ if (raw === "inplace" || raw === "same" || raw === "current" || raw === "tab" || raw === "pane") {
44
+ return "inplace";
45
+ }
46
+ if (
47
+ raw === "window"
48
+ || raw === "separate"
49
+ || raw === "new"
50
+ || raw === "new-window"
51
+ || raw === "external"
52
+ || raw === "1"
53
+ || raw === "true"
54
+ || raw === "yes"
55
+ || raw === "y"
56
+ || raw === "on"
57
+ ) {
58
+ return "window";
59
+ }
60
+ if (raw === "0" || raw === "false" || raw === "no" || raw === "n" || raw === "off") {
61
+ return "inplace";
62
+ }
63
+ return fallback;
64
+ }
65
+
66
+ function normalizeTerminalAppPreference(value = "") {
67
+ const raw = String(value || "").trim().toLowerCase();
68
+ if (!raw) return "";
69
+ if (raw === "terminal" || raw === "apple_terminal" || raw === "apple-terminal") {
70
+ return "terminal";
71
+ }
72
+ if (raw === "iterm2" || raw === "iterm" || raw === "iterm.app") {
73
+ return "iterm2";
74
+ }
75
+ return "";
76
+ }
77
+
40
78
  function resolveAgentId(projectRoot, agentId) {
41
79
  if (!agentId) return agentId;
42
80
  if (agentId.includes(":")) return agentId;
@@ -130,23 +168,40 @@ function runAppleScript(lines) {
130
168
  });
131
169
  }
132
170
 
133
- async function openTerminalWindow(runCmd) {
171
+ async function openTerminalWindow(runCmd, options = {}) {
134
172
  if (process.platform !== "darwin") {
135
173
  throw new Error("Terminal mode is only supported on macOS");
136
174
  }
137
175
 
176
+ const launchScope = normalizeLaunchScope(options.launchScope, "inplace");
177
+ const terminalApp = normalizeTerminalAppPreference(options.terminalApp);
178
+ const preferSeparateWindow = launchScope === "window";
138
179
  const escaped = escapeAppleScriptString(runCmd);
180
+ const shouldTryITerm2 = terminalApp
181
+ ? terminalApp === "iterm2"
182
+ : isITerm2();
139
183
 
140
- if (isITerm2()) {
184
+ if (shouldTryITerm2) {
141
185
  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
- ];
186
+ const script = preferSeparateWindow
187
+ ? [
188
+ 'tell application "iTerm2"',
189
+ ` create window with default profile command "${escaped}"`,
190
+ " activate",
191
+ "end tell",
192
+ ]
193
+ : [
194
+ 'tell application "iTerm2"',
195
+ " if (count of windows) is 0 then",
196
+ ` create window with default profile command "${escaped}"`,
197
+ " else",
198
+ " tell current window",
199
+ ` create tab with default profile command "${escaped}"`,
200
+ " end tell",
201
+ " end if",
202
+ " activate",
203
+ "end tell",
204
+ ];
150
205
  await runAppleScript(script);
151
206
  return;
152
207
  } catch {
@@ -154,13 +209,55 @@ async function openTerminalWindow(runCmd) {
154
209
  }
155
210
  }
156
211
 
157
- const script = [
212
+ if (preferSeparateWindow) {
213
+ const script = [
214
+ 'tell application "Terminal"',
215
+ ` do script "${escaped}"`,
216
+ " activate",
217
+ "end tell",
218
+ ];
219
+ await runAppleScript(script);
220
+ return;
221
+ }
222
+
223
+ const preferredScript = [
158
224
  'tell application "Terminal"',
159
- `do script "${escaped}"`,
160
- "activate",
225
+ " activate",
226
+ " if (count of windows) is 0 then",
227
+ ` do script "${escaped}"`,
228
+ " else",
229
+ ' tell application "System Events"',
230
+ ' tell process "Terminal"',
231
+ ' keystroke "t" using command down',
232
+ " end tell",
233
+ " end tell",
234
+ " delay 0.08",
235
+ ` do script "${escaped}" in selected tab of front window`,
236
+ " end if",
237
+ " activate",
161
238
  "end tell",
162
239
  ];
163
- await runAppleScript(script);
240
+
241
+ try {
242
+ await runAppleScript(preferredScript);
243
+ return;
244
+ } catch {
245
+ // Accessibility can block System Events key events; fall back to pure Terminal AppleScript.
246
+ }
247
+
248
+ const fallbackScript = [
249
+ 'tell application "Terminal"',
250
+ " activate",
251
+ " if (count of windows) is 0 then",
252
+ ` do script "${escaped}"`,
253
+ " else",
254
+ " set newTab to (do script \"\" in front window)",
255
+ ` do script "${escaped}" in newTab`,
256
+ " end if",
257
+ " activate",
258
+ "end tell",
259
+ ];
260
+ await runAppleScript(fallbackScript);
164
261
  }
165
262
 
166
263
  async function closeTerminalWindowByTty(ttyPath, preferApp = "") {
@@ -262,7 +359,16 @@ async function tryReuseTerminal(projectRoot, subscriberId, meta, agent, sessionI
262
359
  /**
263
360
  * Spawn managed terminal agent - open a real Terminal session to run the agent
264
361
  */
265
- async function spawnManagedTerminalAgent(projectRoot, agent, nickname = "", processManager = null, extraArgs = [], extraEnv = "") {
362
+ async function spawnManagedTerminalAgent(
363
+ projectRoot,
364
+ agent,
365
+ nickname = "",
366
+ processManager = null,
367
+ extraArgs = [],
368
+ extraEnv = "",
369
+ launchScope = "window",
370
+ terminalApp = ""
371
+ ) {
266
372
  const normalizedAgent = normalizeLaunchAgent(agent);
267
373
  const binary = toTerminalBinary(normalizedAgent);
268
374
  const agentType = toBusAgentType(normalizedAgent);
@@ -283,7 +389,7 @@ async function spawnManagedTerminalAgent(projectRoot, agent, nickname = "", proc
283
389
 
284
390
  const runCmd = `cd ${shellEscape(projectRoot)} && ${prefix}${modeEnv}${nickEnv}${envPrefix}${binary}${argText}`;
285
391
 
286
- await openTerminalWindow(runCmd);
392
+ await openTerminalWindow(runCmd, { launchScope, terminalApp });
287
393
 
288
394
  const subscriberId = await waitForNewSubscriber(projectRoot, agentType, existing, 15000);
289
395
  return { child: null, subscriberId: subscriberId || null };
@@ -455,9 +561,57 @@ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extr
455
561
  });
456
562
  }
457
563
 
458
- async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
564
+ function resolveTmuxPaneTarget() {
565
+ const explicit = String(process.env.UFOO_TMUX_TARGET || "").trim();
566
+ if (explicit) return explicit;
567
+ const preferredPane = String(process.env.UFOO_TMUX_PANE || "").trim();
568
+ if (preferredPane) return preferredPane;
569
+ const currentPane = String(process.env.TMUX_PANE || "").trim();
570
+ if (currentPane) return currentPane;
571
+ return "";
572
+ }
573
+
574
+ function spawnTmuxPane(projectRoot, agent, nickname = "", extraArgs = [], extraEnv = "", target = "") {
575
+ return new Promise((resolve, reject) => {
576
+ const normalizedAgent = normalizeLaunchAgent(agent);
577
+ const binary = toTmuxBinary(normalizedAgent);
578
+ if (!binary) {
579
+ reject(new Error(`unsupported agent type: ${agent}`));
580
+ return;
581
+ }
582
+ const nickEnv = nickname ? `UFOO_NICKNAME=${shellEscape(nickname)} ` : "";
583
+ const modeEnv = "UFOO_LAUNCH_MODE=tmux ";
584
+ const ttyEnv = "UFOO_TTY_OVERRIDE=$(tty) ";
585
+ const args = Array.isArray(extraArgs) ? extraArgs : [];
586
+ const envPrefix = extraEnv ? `${String(extraEnv).trim()} ` : "";
587
+ const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
588
+ const setPaneEnv = `export TMUX_PANE=$(tmux display-message -p '#{pane_id}'); `;
589
+ const runCmd = `cd ${shellEscape(projectRoot)} && ${setPaneEnv}${modeEnv}${nickEnv}${ttyEnv}${envPrefix}${binary}${argText}`;
590
+
591
+ const tmuxArgs = ["split-window", "-d"];
592
+ const normalizedTarget = String(target || "").trim();
593
+ if (normalizedTarget) {
594
+ tmuxArgs.push("-t", normalizedTarget);
595
+ }
596
+ tmuxArgs.push(runCmd);
597
+
598
+ const proc = spawn("tmux", tmuxArgs);
599
+ let stderr = "";
600
+ proc.stderr.on("data", (d) => {
601
+ stderr += d.toString("utf8");
602
+ });
603
+ proc.on("close", (code) => {
604
+ if (code === 0) resolve();
605
+ else reject(new Error(stderr || "tmux split-window failed"));
606
+ });
607
+ });
608
+ }
609
+
610
+ async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null, options = {}) {
459
611
  const config = loadConfig(projectRoot);
460
612
  const mode = config.launchMode || "terminal";
613
+ const launchScope = normalizeLaunchScope(options.launchScope, "inplace");
614
+ const terminalApp = normalizeTerminalAppPreference(options.terminalApp);
461
615
  const normalizedAgent = normalizeLaunchAgent(agent);
462
616
  if (!normalizedAgent) {
463
617
  throw new Error(`unsupported agent type: ${agent}`);
@@ -465,7 +619,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
465
619
 
466
620
  if (mode === "internal") {
467
621
  const result = await spawnInternalAgent(projectRoot, normalizedAgent, count, nickname, processManager);
468
- return { mode: "internal", subscriberIds: result.subscriberIds };
622
+ return { mode: "internal", launchScope, subscriberIds: result.subscriberIds };
469
623
  }
470
624
  if (mode === "tmux") {
471
625
  // Check if tmux is available
@@ -489,14 +643,27 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
489
643
  process.env.UFOO_TMUX_SESSION = firstSession;
490
644
  }
491
645
  }
646
+ const paneTarget = resolveTmuxPaneTarget();
647
+ const useSeparateWindow = launchScope === "window";
492
648
  for (let i = 0; i < count; i += 1) {
493
649
  // Use "ucode" as default nickname for ufoo/ucode agents
494
650
  const defaultNick = normalizedAgent === "ufoo" ? "ucode" : normalizedAgent;
495
651
  const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
496
- // eslint-disable-next-line no-await-in-loop
497
- await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
652
+ if (useSeparateWindow) {
653
+ // eslint-disable-next-line no-await-in-loop
654
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
655
+ } else {
656
+ try {
657
+ // eslint-disable-next-line no-await-in-loop
658
+ await spawnTmuxPane(projectRoot, normalizedAgent, nick, [], "", paneTarget);
659
+ } catch {
660
+ // Fallback to new window when current pane target cannot be resolved.
661
+ // eslint-disable-next-line no-await-in-loop
662
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
663
+ }
664
+ }
498
665
  }
499
- return { mode: "tmux" };
666
+ return { mode: "tmux", launchScope, subscriberIds: [] };
500
667
  }
501
668
  // terminal mode - daemon 作为父进程,输出到终端窗口
502
669
  if (process.platform !== "darwin") {
@@ -509,11 +676,20 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
509
676
  const defaultNick = normalizedAgent === "ufoo" ? "ucode" : normalizedAgent;
510
677
  const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
511
678
  // eslint-disable-next-line no-await-in-loop
512
- const result = await spawnManagedTerminalAgent(projectRoot, normalizedAgent, nick, processManager);
679
+ const result = await spawnManagedTerminalAgent(
680
+ projectRoot,
681
+ normalizedAgent,
682
+ nick,
683
+ processManager,
684
+ [],
685
+ "",
686
+ launchScope,
687
+ terminalApp
688
+ );
513
689
  if (result.subscriberId) subscriberIds.push(result.subscriberId);
514
690
  }
515
691
 
516
- return { mode: "terminal", subscriberIds };
692
+ return { mode: "terminal", launchScope, subscriberIds };
517
693
  }
518
694
 
519
695
  function normalizeAgentType(agentType) {
@@ -651,43 +827,55 @@ async function resumeAgents(projectRoot, target = "", processManager = null) {
651
827
  }
652
828
 
653
829
  async function closeAgent(projectRoot, agentId) {
654
- if (process.platform !== "darwin") {
655
- return false;
656
- }
657
830
  const resolvedId = resolveAgentId(projectRoot, agentId);
658
831
  const busPath = getUfooPaths(projectRoot).agentsFile;
659
- let pid = null;
832
+ let pid = 0;
660
833
  let launchMode = "";
661
834
  let tty = "";
662
835
  let terminalApp = "";
836
+ let tmuxPane = "";
837
+ let found = false;
663
838
  try {
664
839
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
665
840
  const entry = bus.agents?.[resolvedId];
666
841
  if (entry) {
667
- if (entry.pid) pid = entry.pid;
842
+ found = true;
843
+ const parsedPid = Number.parseInt(entry.pid, 10);
844
+ pid = Number.isFinite(parsedPid) && parsedPid > 0 ? parsedPid : 0;
668
845
  launchMode = entry.launch_mode || "";
669
846
  tty = entry.tty || "";
670
847
  terminalApp = entry.terminal_app || "";
848
+ tmuxPane = entry.tmux_pane || "";
671
849
  }
672
850
  } catch {
673
- pid = null;
851
+ found = false;
852
+ }
853
+
854
+ if (!found) {
855
+ return { ok: true, already_stopped: true, resolved_agent_id: resolvedId };
674
856
  }
857
+
675
858
  const adapterRouter = createTerminalAdapterRouter();
676
859
  const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId });
677
- const canCloseWindow = adapter.capabilities.supportsWindowClose && tty;
860
+ const canCloseWindow = process.platform === "darwin"
861
+ && Boolean(adapter.capabilities.supportsWindowClose)
862
+ && Boolean(tty);
678
863
 
679
864
  // Close process first for faster state transition in chat.
680
865
  let sentSignal = false;
681
- if (pid) {
866
+ let killErr = null;
867
+ if (pid > 0) {
682
868
  try {
683
869
  process.kill(pid, "SIGTERM");
684
870
  sentSignal = true;
685
- } catch {
871
+ } catch (err) {
872
+ killErr = err || null;
686
873
  sentSignal = false;
687
874
  }
688
875
  }
689
876
 
690
- if (sentSignal || (!pid && canCloseWindow)) {
877
+ const pidGone = pid > 0 && !sentSignal && !isAgentPidAlive(pid);
878
+ if (sentSignal || pid === 0 || pidGone) {
691
879
  markAgentInactive(projectRoot, resolvedId);
692
880
  }
693
881
 
@@ -696,7 +884,29 @@ async function closeAgent(projectRoot, agentId) {
696
884
  void closeTerminalWindowByTty(tty, terminalApp).catch(() => false);
697
885
  }
698
886
 
699
- return sentSignal || (!pid && canCloseWindow);
887
+ // Tmux pane cleanup: kill the pane after sending SIGTERM to the process.
888
+ if (launchMode === "tmux" && tmuxPane) {
889
+ try {
890
+ spawnSync("tmux", ["kill-pane", "-t", tmuxPane], { stdio: "ignore", timeout: 3000 });
891
+ } catch {
892
+ // ignore - pane may already be gone
893
+ }
894
+ }
895
+
896
+ if (sentSignal) {
897
+ return { ok: true, resolved_agent_id: resolvedId };
898
+ }
899
+ if (pid === 0 || pidGone) {
900
+ return { ok: true, already_stopped: true, resolved_agent_id: resolvedId };
901
+ }
902
+ const reason = killErr && killErr.message
903
+ ? killErr.message
904
+ : "failed to stop process";
905
+ return {
906
+ ok: false,
907
+ error: reason,
908
+ resolved_agent_id: resolvedId,
909
+ };
700
910
  }
701
911
 
702
912
  module.exports = { launchAgent, closeAgent, getRecoverableAgents, resumeAgents };
@@ -147,7 +147,9 @@ function buildStatus(projectRoot, options = {}) {
147
147
  const launch_mode = meta?.launch_mode || "unknown";
148
148
  const tmux_pane = meta?.tmux_pane || "";
149
149
  const tty = meta?.tty || "";
150
- return { id, nickname, display, launch_mode, tmux_pane, tty };
150
+ const activity_state = meta?.activity_state || "";
151
+ const activity_since = meta?.activity_since || "";
152
+ return { id, nickname, display, launch_mode, tmux_pane, tty, activity_state, activity_since };
151
153
  });
152
154
 
153
155
  return {
package/src/init/index.js CHANGED
@@ -180,23 +180,52 @@ class UfooInit {
180
180
 
181
181
  let content = fs.readFileSync(filePath, "utf8");
182
182
  const marker = "<!-- ufoo-template -->";
183
+ const block = `${marker}\n${template}\n${marker}`;
184
+
183
185
  if (content.includes(marker)) {
186
+ // Replace existing marker block in-place
184
187
  const startIdx = content.indexOf(marker);
185
188
  const endIdx = content.indexOf(marker, startIdx + marker.length);
186
189
  if (endIdx !== -1) {
187
190
  content =
188
191
  content.slice(0, startIdx) +
189
- `${marker}\n${template}\n${marker}` +
192
+ block +
190
193
  content.slice(endIdx + marker.length);
191
194
  } else {
192
- content += `\n${marker}\n${template}\n${marker}\n`;
195
+ content =
196
+ content.slice(0, startIdx) + block + content.slice(startIdx + marker.length);
193
197
  }
194
198
  } else {
195
- content += `\n${marker}\n${template}\n${marker}\n`;
199
+ // Insert after first heading line for visibility (not buried at end)
200
+ const headingEnd = this.findFirstHeadingEnd(content);
201
+ if (headingEnd !== -1) {
202
+ content =
203
+ content.slice(0, headingEnd) +
204
+ `\n${block}\n\n` +
205
+ content.slice(headingEnd);
206
+ } else {
207
+ content = `${block}\n\n${content}`;
208
+ }
196
209
  }
197
210
  fs.writeFileSync(filePath, content, "utf8");
198
211
  }
199
212
 
213
+ findFirstHeadingEnd(content) {
214
+ // ATX heading: # ... (allow leading indentation and EOF without trailing newline)
215
+ const atxHeading = content.match(/^(?:[ \t]{0,3})#{1,6}[ \t]*[^\n]*(?:\n|$)/m);
216
+ // Setext heading: text line + underline (=== or ---)
217
+ const setextHeading = content.match(/^[^\n]+\n(?:=+|-+)[ \t]*(?:\n|$)/m);
218
+
219
+ let bestMatch = null;
220
+ if (atxHeading && setextHeading) {
221
+ bestMatch = atxHeading.index <= setextHeading.index ? atxHeading : setextHeading;
222
+ } else {
223
+ bestMatch = atxHeading || setextHeading;
224
+ }
225
+ if (!bestMatch) return -1;
226
+ return bestMatch.index + bestMatch[0].length;
227
+ }
228
+
200
229
  /**
201
230
  * 初始化 context 模块
202
231
  */
@@ -0,0 +1,29 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ function trimTrailingSlashes(value) {
6
+ if (!value) return value;
7
+ return value.replace(/\/+$/, "") || "/";
8
+ }
9
+
10
+ function canonicalProjectRoot(projectRoot) {
11
+ const input = String(projectRoot || "").trim();
12
+ if (!input) {
13
+ throw new Error("projectRoot is required");
14
+ }
15
+ const resolved = path.resolve(input);
16
+ const canonical = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved);
17
+ return trimTrailingSlashes(canonical);
18
+ }
19
+
20
+ function buildProjectId(projectRoot) {
21
+ const canonical = canonicalProjectRoot(projectRoot);
22
+ return crypto.createHash("sha1").update(canonical).digest("hex").slice(0, 12);
23
+ }
24
+
25
+ module.exports = {
26
+ trimTrailingSlashes,
27
+ canonicalProjectRoot,
28
+ buildProjectId,
29
+ };