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/cli.js CHANGED
@@ -7,6 +7,9 @@ const { runBusCoreCommand } = require("./cli/busCoreCommands");
7
7
  const { runCtxCommand } = require("./cli/ctxCoreCommands");
8
8
  const { runOnlineCommand } = require("./cli/onlineCoreCommands");
9
9
  const { runGroupCoreCommand } = require("./cli/groupCoreCommands");
10
+ const { listProjectRuntimes, getCurrentProjectRuntime } = require("./projects/registry");
11
+ const { canonicalProjectRoot, buildProjectId } = require("./projects/projectId");
12
+ const { getUfooPaths } = require("./ufoo/paths");
10
13
 
11
14
  function getPackageRoot() {
12
15
  return path.resolve(__dirname, "..");
@@ -161,6 +164,111 @@ function parseJsonObject(text, fallback = {}) {
161
164
  return parsed;
162
165
  }
163
166
 
167
+ function formatProjectRuntimeLine(row, index = 0) {
168
+ const idx = String(index + 1).padStart(2, " ");
169
+ const name = String(row.project_name || "-");
170
+ const status = String(row.status || "-");
171
+ const seen = row.last_seen || "-";
172
+ const projectRoot = String(row.project_root || "-");
173
+ return `${idx}. ${name} [${status}] last_seen=${seen} ${projectRoot}`;
174
+ }
175
+
176
+ function printProjectList(rows = [], write = (line) => console.log(line)) {
177
+ if (!Array.isArray(rows) || rows.length === 0) {
178
+ write("No projects found.");
179
+ return;
180
+ }
181
+ write("=== Projects ===");
182
+ rows.forEach((row, index) => {
183
+ write(formatProjectRuntimeLine(row, index));
184
+ });
185
+ }
186
+
187
+ function buildCurrentProjectFallback(projectRoot) {
188
+ let canonical = "";
189
+ let projectId = "";
190
+ try {
191
+ canonical = canonicalProjectRoot(projectRoot);
192
+ } catch {
193
+ canonical = path.resolve(projectRoot || process.cwd());
194
+ }
195
+ try {
196
+ projectId = buildProjectId(canonical);
197
+ } catch {
198
+ projectId = "";
199
+ }
200
+ const paths = getUfooPaths(canonical);
201
+ return {
202
+ version: 1,
203
+ project_id: projectId || null,
204
+ project_root: canonical,
205
+ project_name: path.basename(canonical) || canonical,
206
+ daemon_pid: null,
207
+ socket_path: paths.ufooSock,
208
+ status: "untracked",
209
+ last_seen: null,
210
+ };
211
+ }
212
+
213
+ function printCurrentProject(runtime, write = (line) => console.log(line)) {
214
+ const row = runtime || {};
215
+ write("=== Current Project ===");
216
+ write(`name: ${row.project_name || "-"}`);
217
+ write(`status: ${row.status || "-"}`);
218
+ write(`path: ${row.project_root || "-"}`);
219
+ write(`daemon_pid: ${row.daemon_pid || "-"}`);
220
+ write(`socket: ${row.socket_path || "-"}`);
221
+ write(`last_seen: ${row.last_seen || "-"}`);
222
+ }
223
+
224
+ function projectSwitchV1Error() {
225
+ const err = new Error("project switch is chat-only in v1");
226
+ err.code = "UFOO_PROJECT_SWITCH_CHAT_ONLY";
227
+ err.exitCode = 2;
228
+ return err;
229
+ }
230
+
231
+ function runProjectCommand({
232
+ subcommand = "list",
233
+ outputJson = false,
234
+ cwd = process.cwd(),
235
+ write = (line) => console.log(line),
236
+ writeError = (line) => console.error(line),
237
+ } = {}) {
238
+ const sub = String(subcommand || "list").trim().toLowerCase();
239
+ try {
240
+ if (sub === "list") {
241
+ const rows = listProjectRuntimes({ validate: true, cleanupTmp: true });
242
+ if (outputJson) {
243
+ write(JSON.stringify(rows, null, 2));
244
+ return 0;
245
+ }
246
+ printProjectList(rows, write);
247
+ return 0;
248
+ }
249
+ if (sub === "current") {
250
+ const current = getCurrentProjectRuntime(cwd, { validate: true })
251
+ || buildCurrentProjectFallback(cwd);
252
+ if (outputJson) {
253
+ write(JSON.stringify(current, null, 2));
254
+ return 0;
255
+ }
256
+ printCurrentProject(current, write);
257
+ return 0;
258
+ }
259
+ if (sub === "switch") {
260
+ const err = projectSwitchV1Error();
261
+ writeError(err.message);
262
+ return err.exitCode || 2;
263
+ }
264
+ writeError("project requires list|current|switch subcommand");
265
+ return 1;
266
+ } catch (err) {
267
+ writeError(err.message || String(err));
268
+ return 1;
269
+ }
270
+ }
271
+
164
272
  function normalizeReportPhase(action = "") {
165
273
  const value = String(action || "").trim().toLowerCase();
166
274
  if (value === "start") return "start";
@@ -244,9 +352,46 @@ async function runCli(argv) {
244
352
  program
245
353
  .command("chat")
246
354
  .description("Launch ufoo chat UI")
247
- .action(() => {
355
+ .option("-g, --global", "Launch in global multi-project mode")
356
+ .action((opts) => {
248
357
  const repoRoot = getPackageRoot();
249
- run(process.execPath, [path.join(repoRoot, "bin", "ufoo.js"), "chat"]);
358
+ const args = ["chat"];
359
+ if (opts.global === true) args.push("-g");
360
+ run(process.execPath, [path.join(repoRoot, "bin", "ufoo.js"), ...args]);
361
+ });
362
+ const project = program.command("project").description("Project runtime commands");
363
+ project
364
+ .command("list")
365
+ .description("List runtime projects discovered from global registry")
366
+ .option("--json", "Output as JSON")
367
+ .action((opts) => {
368
+ process.exitCode = runProjectCommand({
369
+ subcommand: "list",
370
+ outputJson: opts.json === true,
371
+ cwd: process.cwd(),
372
+ });
373
+ });
374
+ project
375
+ .command("current")
376
+ .description("Show current project runtime context")
377
+ .option("--json", "Output as JSON")
378
+ .action((opts) => {
379
+ process.exitCode = runProjectCommand({
380
+ subcommand: "current",
381
+ outputJson: opts.json === true,
382
+ cwd: process.cwd(),
383
+ });
384
+ });
385
+ project
386
+ .command("switch")
387
+ .description("Switch active project (chat-only in v1)")
388
+ .argument("<indexOrPath>", "Project index from list output or absolute path")
389
+ .action(() => {
390
+ process.exitCode = runProjectCommand({
391
+ subcommand: "switch",
392
+ outputJson: false,
393
+ cwd: process.cwd(),
394
+ });
250
395
  });
251
396
  program
252
397
  .command("launch")
@@ -1112,7 +1257,11 @@ async function runCli(argv) {
1112
1257
  console.log(" ufoo doctor");
1113
1258
  console.log(" ufoo status");
1114
1259
  console.log(" ufoo daemon --start|--stop|--status");
1115
- console.log(" ufoo chat");
1260
+ console.log(" ufoo -g");
1261
+ console.log(" ufoo chat [-g]");
1262
+ console.log(" ufoo project list [--json]");
1263
+ console.log(" ufoo project current [--json]");
1264
+ console.log(" ufoo project switch <index|path>");
1116
1265
  console.log(" ufoo resume [nickname]");
1117
1266
  console.log(" ufoo recover [list [target] | run <target>] [--json]");
1118
1267
  console.log(" ufoo report <start|progress|done|error|list> [message] [--task <id>] [--agent <id>]");
@@ -1175,7 +1324,21 @@ async function runCli(argv) {
1175
1324
  return;
1176
1325
  }
1177
1326
  if (cmd === "chat") {
1178
- run(process.execPath, [path.join(repoRoot, "bin", "ufoo.js"), "chat"]);
1327
+ const chatArgs = ["chat"];
1328
+ if (rest.includes("-g") || rest.includes("--global")) {
1329
+ chatArgs.push("-g");
1330
+ }
1331
+ run(process.execPath, [path.join(repoRoot, "bin", "ufoo.js"), ...chatArgs]);
1332
+ return;
1333
+ }
1334
+ if (cmd === "project") {
1335
+ const sub = String(rest[0] || "list").trim().toLowerCase();
1336
+ const outputJson = rest.includes("--json");
1337
+ process.exitCode = runProjectCommand({
1338
+ subcommand: sub,
1339
+ outputJson,
1340
+ cwd: process.cwd(),
1341
+ });
1179
1342
  return;
1180
1343
  }
1181
1344
  if (cmd === "resume") {
@@ -11,6 +11,7 @@ const { generateInstanceId, subscriberToSafeName } = require("../bus/utils");
11
11
  const { createDaemonIpcServer } = require("./ipcServer");
12
12
  const { IPC_REQUEST_TYPES, IPC_RESPONSE_TYPES, BUS_STATUS_PHASES } = require("../shared/eventContract");
13
13
  const { getUfooPaths } = require("../ufoo/paths");
14
+ const { upsertProjectRuntime, markProjectStopped } = require("../projects/registry");
14
15
  const { scheduleProviderSessionProbe, loadProviderSessionCache } = require("./providerSessions");
15
16
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
16
17
  const { createDaemonCronController } = require("./cronOps");
@@ -25,6 +26,7 @@ let providerSessions = null;
25
26
  let probeHandles = new Map();
26
27
  let daemonCronController = null;
27
28
  let daemonGroupOrchestrator = null;
29
+ const PROJECT_RUNTIME_HEARTBEAT_MS = 10 * 1000;
28
30
 
29
31
  function sleep(ms) {
30
32
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -323,7 +325,10 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
323
325
  continue;
324
326
  }
325
327
  // eslint-disable-next-line no-await-in-loop
326
- const launchResult = await launchAgent(projectRoot, agent, count, nickname, processManager);
328
+ const launchResult = await launchAgent(projectRoot, agent, count, nickname, processManager, {
329
+ launchScope: op.launch_scope || "",
330
+ terminalApp: op.terminal_app || "",
331
+ });
327
332
  if (launchResult.mode === "internal" && launchResult.subscriberIds && launchResult.subscriberIds.length > 0) {
328
333
  const probeAgentType = agent === "codex"
329
334
  ? "codex"
@@ -359,6 +364,7 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
359
364
  agent,
360
365
  count,
361
366
  nickname: nickname || undefined,
367
+ launch_scope: launchResult.launchScope || undefined,
362
368
  subscriber_ids: Array.isArray(launchResult.subscriberIds) ? launchResult.subscriberIds.slice() : [],
363
369
  });
364
370
  if (nickname) {
@@ -372,8 +378,15 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
372
378
  results.push({ action: "launch", ok: false, agent, count, error: err.message });
373
379
  }
374
380
  } else if (op.action === "close") {
375
- const ok = await closeAgent(projectRoot, op.agent_id);
376
- results.push({ action: "close", ok, agent_id: op.agent_id });
381
+ const closeResult = await closeAgent(projectRoot, op.agent_id);
382
+ const normalizedClose = closeResult && typeof closeResult === "object"
383
+ ? closeResult
384
+ : { ok: Boolean(closeResult) };
385
+ results.push({
386
+ action: "close",
387
+ agent_id: op.agent_id,
388
+ ...normalizedClose,
389
+ });
377
390
  } else if (op.action === "rename") {
378
391
  const agentId = op.agent_id || "";
379
392
  const nickname = op.nickname || "";
@@ -669,6 +682,19 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
669
682
  const log = (msg) => {
670
683
  logFile.write(`[daemon] ${new Date().toISOString()} ${msg}\n`);
671
684
  };
685
+ const publishProjectRuntime = (status = "running") => {
686
+ try {
687
+ upsertProjectRuntime({
688
+ projectRoot,
689
+ daemonPid: process.pid,
690
+ socketPath: socketPath(projectRoot),
691
+ status,
692
+ lastSeen: new Date().toISOString(),
693
+ });
694
+ } catch (err) {
695
+ log(`project runtime update failed (${status}): ${err.message || err}`);
696
+ }
697
+ };
672
698
 
673
699
  // 创建进程管理器 - daemon 作为父进程监控所有 internal agents
674
700
  const processManager = new AgentProcessManager(projectRoot);
@@ -924,7 +950,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
924
950
  const closeResult = opsResults.find((r) => r.action === "close");
925
951
  const ok = closeResult ? closeResult.ok !== false : true;
926
952
  const reply = ok
927
- ? `Closed ${agent_id}`
953
+ ? (closeResult && closeResult.already_stopped
954
+ ? `Closed ${agent_id} (already stopped)`
955
+ : `Closed ${agent_id}`)
928
956
  : `Close failed: ${closeResult?.error || "unknown error"}`;
929
957
  socket.write(
930
958
  `${JSON.stringify({
@@ -952,7 +980,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
952
980
  }
953
981
  if (req.type === IPC_REQUEST_TYPES.LAUNCH_AGENT) {
954
982
  log(`launch_agent received: agent=${req.agent} count=${req.count}`);
955
- const { agent, count, nickname } = req;
983
+ const { agent, count, nickname, launch_scope, terminal_app } = req;
956
984
  const normalizedAgent = normalizeLaunchAgent(agent);
957
985
  if (!normalizedAgent) {
958
986
  socket.write(
@@ -971,6 +999,8 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
971
999
  agent: normalizedAgent,
972
1000
  count: finalCount,
973
1001
  nickname: nickname || "",
1002
+ launch_scope: launch_scope || "",
1003
+ terminal_app: terminal_app || "",
974
1004
  };
975
1005
  try {
976
1006
  const opsResults = await handleOps(projectRoot, [op], processManager);
@@ -1515,6 +1545,10 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1515
1545
  };
1516
1546
 
1517
1547
  ipcServer.listen(socketPath(projectRoot));
1548
+ publishProjectRuntime("running");
1549
+ const runtimeHeartbeat = setInterval(() => {
1550
+ publishProjectRuntime("running");
1551
+ }, PROJECT_RUNTIME_HEARTBEAT_MS);
1518
1552
 
1519
1553
  log(`Started pid=${process.pid}`);
1520
1554
 
@@ -1619,8 +1653,17 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1619
1653
  }, 1500);
1620
1654
  }
1621
1655
 
1656
+ let cleanedUp = false;
1622
1657
  const cleanup = () => {
1658
+ if (cleanedUp) return;
1659
+ cleanedUp = true;
1623
1660
  log(`Shutting down daemon (managed agents: ${processManager.count()})`);
1661
+ clearInterval(runtimeHeartbeat);
1662
+ try {
1663
+ markProjectStopped(projectRoot);
1664
+ } catch {
1665
+ // ignore cleanup errors
1666
+ }
1624
1667
 
1625
1668
  if (daemonCronController) {
1626
1669
  daemonCronController.stopAll();
@@ -1706,6 +1749,12 @@ function stopDaemon(projectRoot) {
1706
1749
  // ignore
1707
1750
  }
1708
1751
 
1752
+ try {
1753
+ markProjectStopped(projectRoot);
1754
+ } catch {
1755
+ // ignore
1756
+ }
1757
+
1709
1758
  return killed;
1710
1759
  }
1711
1760
 
@@ -28,6 +28,7 @@ function createDaemonIpcServer(options = {}) {
28
28
  };
29
29
 
30
30
  let lastActiveJson = "";
31
+ let lastMetaJson = "";
31
32
  const statusSyncInterval = setInterval(() => {
32
33
  if (sockets.size === 0) return;
33
34
  try {
@@ -38,8 +39,12 @@ function createDaemonIpcServer(options = {}) {
38
39
  try {
39
40
  const status = buildStatus(projectRoot);
40
41
  const currentActiveJson = JSON.stringify(status.active);
41
- if (currentActiveJson !== lastActiveJson) {
42
+ const currentMetaJson = JSON.stringify(
43
+ (status.active_meta || []).map((m) => `${m.id}:${m.activity_state || ""}`)
44
+ );
45
+ if (currentActiveJson !== lastActiveJson || currentMetaJson !== lastMetaJson) {
42
46
  lastActiveJson = currentActiveJson;
47
+ lastMetaJson = currentMetaJson;
43
48
  sendToSockets({ type: IPC_RESPONSE_TYPES.STATUS, data: status });
44
49
  log(`status sync: active agents changed to ${status.active.length}`);
45
50
  }