u-foo 1.4.0 → 1.5.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.
@@ -2,16 +2,18 @@ const fs = require("fs");
2
2
 
3
3
  function createInputHistoryController(options = {}) {
4
4
  const {
5
- inputHistoryFile,
6
- historyDir,
5
+ inputHistoryFile: inputHistoryFileOption,
6
+ historyDir: historyDirOption,
7
7
  setInputValue = () => {},
8
8
  getInputValue = () => "",
9
9
  fsMod = fs,
10
10
  } = options;
11
11
 
12
- if (!inputHistoryFile || !historyDir) {
12
+ if (!inputHistoryFileOption || !historyDirOption) {
13
13
  throw new Error("createInputHistoryController requires inputHistoryFile and historyDir");
14
14
  }
15
+ let inputHistoryFile = inputHistoryFileOption;
16
+ let historyDir = historyDirOption;
15
17
 
16
18
  const inputHistory = [];
17
19
  let historyIndex = 0;
@@ -24,6 +26,9 @@ function createInputHistoryController(options = {}) {
24
26
  }
25
27
 
26
28
  function loadInputHistory(limit = 2000) {
29
+ inputHistory.length = 0;
30
+ historyIndex = 0;
31
+ historyDraft = "";
27
32
  try {
28
33
  const raw = fsMod.readFileSync(inputHistoryFile, "utf8");
29
34
  const lines = String(raw || "").trim().split(/\r?\n/).filter(Boolean);
@@ -85,6 +90,28 @@ function createInputHistoryController(options = {}) {
85
90
  setIndexToEnd();
86
91
  }
87
92
 
93
+ function setHistoryTarget(next = {}) {
94
+ if (!next.inputHistoryFile || !next.historyDir) {
95
+ throw new Error("setHistoryTarget requires inputHistoryFile and historyDir");
96
+ }
97
+ inputHistoryFile = next.inputHistoryFile;
98
+ historyDir = next.historyDir;
99
+ }
100
+
101
+ function restoreDraft(draft = "") {
102
+ const nextDraft = String(draft || "");
103
+ setInputValue(nextDraft);
104
+ historyIndex = inputHistory.length;
105
+ historyDraft = nextDraft;
106
+ }
107
+
108
+ function getDraftForPersistence() {
109
+ if (historyIndex === inputHistory.length) {
110
+ return String(getInputValue() || "");
111
+ }
112
+ return String(historyDraft || "");
113
+ }
114
+
88
115
  return {
89
116
  loadInputHistory,
90
117
  updateDraftFromInput,
@@ -92,6 +119,9 @@ function createInputHistoryController(options = {}) {
92
119
  historyDown,
93
120
  commitSubmittedText,
94
121
  setIndexToEnd,
122
+ setHistoryTarget,
123
+ restoreDraft,
124
+ getDraftForPersistence,
95
125
  getState: () => ({
96
126
  history: [...inputHistory],
97
127
  historyIndex,
@@ -204,23 +204,33 @@ function createInputListenerController(options = {}) {
204
204
 
205
205
  if (keyName === "up" || keyName === "down") {
206
206
  const innerWidth = getWrapWidth();
207
- if (innerWidth > 0) {
208
- const cursorPos = getCursorPos();
209
- const value = (textarea && textarea.value) || "";
210
- const { row, col } = getCursorRowCol(value, cursorPos, innerWidth);
211
- if (getPreferredCol() === null) setPreferredCol(col);
212
- const totalRows = countLines(value, innerWidth);
213
-
214
- if (keyName === "down" && row >= totalRows - 1) {
207
+ if (innerWidth <= 0) {
208
+ if (keyName === "down") {
215
209
  enterDashboardMode();
216
210
  return;
217
211
  }
212
+ ensureInputCursorVisible();
213
+ updateCursor(textarea);
214
+ render(textarea);
215
+ return;
216
+ }
218
217
 
219
- const targetRow = keyName === "up"
220
- ? Math.max(0, row - 1)
221
- : Math.min(totalRows - 1, row + 1);
222
- setCursorPos(getCursorPosForRowCol(value, targetRow, getPreferredCol(), innerWidth));
218
+ const cursorPos = getCursorPos();
219
+ const value = (textarea && textarea.value) || "";
220
+ const { row, col } = getCursorRowCol(value, cursorPos, innerWidth);
221
+ if (getPreferredCol() === null) setPreferredCol(col);
222
+ const totalRows = countLines(value, innerWidth);
223
+
224
+ if (keyName === "down" && row >= totalRows - 1) {
225
+ enterDashboardMode();
226
+ return;
223
227
  }
228
+
229
+ const targetRow = keyName === "up"
230
+ ? Math.max(0, row - 1)
231
+ : Math.min(totalRows - 1, row + 1);
232
+ setCursorPos(getCursorPosForRowCol(value, targetRow, getPreferredCol(), innerWidth));
233
+
224
234
  ensureInputCursorVisible();
225
235
  updateCursor(textarea);
226
236
  render(textarea);
@@ -2,8 +2,13 @@ function createChatLayout(options = {}) {
2
2
  const {
3
3
  blessed,
4
4
  currentInputHeight = 4,
5
+ dashboardHeight = 1,
5
6
  version = "unknown",
6
7
  } = options;
8
+ const normalizedDashboardHeight = Number.isFinite(dashboardHeight) && dashboardHeight > 0
9
+ ? Math.floor(dashboardHeight)
10
+ : 1;
11
+ const reservedBottomLines = Math.max(2, currentInputHeight + 1);
7
12
 
8
13
  const screen = blessed.screen({
9
14
  smartCSR: true,
@@ -33,7 +38,7 @@ function createChatLayout(options = {}) {
33
38
  top: 0,
34
39
  left: 0,
35
40
  width: "100%",
36
- height: "100%-5", // Will be adjusted dynamically
41
+ height: `100%-${reservedBottomLines}`, // Will be adjusted dynamically
37
42
  tags: true,
38
43
  scrollable: true,
39
44
  alwaysScroll: true,
@@ -97,7 +102,7 @@ function createChatLayout(options = {}) {
97
102
  bottom: 0,
98
103
  left: 0,
99
104
  width: "100%",
100
- height: 1,
105
+ height: normalizedDashboardHeight,
101
106
  style: { fg: "gray" },
102
107
  tags: true,
103
108
  });
@@ -105,7 +110,7 @@ function createChatLayout(options = {}) {
105
110
  // Bottom border line for input area (above dashboard)
106
111
  const inputBottomLine = blessed.line({
107
112
  parent: screen,
108
- bottom: 1,
113
+ bottom: normalizedDashboardHeight,
109
114
  left: 1,
110
115
  width: "100%-2",
111
116
  orientation: "horizontal",
@@ -115,10 +120,10 @@ function createChatLayout(options = {}) {
115
120
  // Prompt indicator
116
121
  const promptBox = blessed.box({
117
122
  parent: screen,
118
- bottom: 2,
123
+ bottom: normalizedDashboardHeight + 1,
119
124
  left: 0,
120
125
  width: 2,
121
- height: currentInputHeight - 3,
126
+ height: Math.max(1, currentInputHeight - normalizedDashboardHeight - 2),
122
127
  content: ">",
123
128
  style: { fg: "cyan" },
124
129
  });
@@ -126,10 +131,10 @@ function createChatLayout(options = {}) {
126
131
  // Input area without left/right border
127
132
  const input = blessed.textarea({
128
133
  parent: screen,
129
- bottom: 2,
134
+ bottom: normalizedDashboardHeight + 1,
130
135
  left: 2,
131
136
  width: "100%-2",
132
- height: currentInputHeight - 3,
137
+ height: Math.max(1, currentInputHeight - normalizedDashboardHeight - 2),
133
138
  inputOnFocus: true,
134
139
  keys: true,
135
140
  });
@@ -136,11 +136,17 @@ function createStreamTracker(options = {}) {
136
136
  return true;
137
137
  }
138
138
 
139
+ function discardAll() {
140
+ streamStates.clear();
141
+ pendingDeliveries.clear();
142
+ }
143
+
139
144
  return {
140
145
  beginStream,
141
146
  appendStreamDelta,
142
147
  finalizeStream,
143
148
  hasStream,
149
+ discardAll,
144
150
  markPendingDelivery,
145
151
  getPendingState,
146
152
  consumePendingDelivery,
@@ -201,6 +201,8 @@ function runOnlineInbox(nickname, opts = {}) {
201
201
  checkInbox(nickname, {
202
202
  clear: !!opts.clear,
203
203
  unread: !!opts.unread,
204
+ room: opts.room || "",
205
+ channel: opts.channel || "",
204
206
  });
205
207
  }
206
208
 
@@ -280,6 +282,8 @@ async function runOnlineCommand(subcmd, payload = {}, options = {}) {
280
282
  return runOnlineInbox(payload.nickname, {
281
283
  clear: opts.clear,
282
284
  unread: opts.unread,
285
+ room: opts.room || "",
286
+ channel: opts.channel || "",
283
287
  });
284
288
  default:
285
289
  throw createUnknownOnlineError(subcmd);
@@ -374,6 +378,8 @@ async function runOnlineCommand(subcmd, payload = {}, options = {}) {
374
378
  return runOnlineInbox(argv[1], {
375
379
  clear: hasFallbackFlag(argv, "--clear"),
376
380
  unread: hasFallbackFlag(argv, "--unread"),
381
+ room: getFallbackOpt(argv, "--room"),
382
+ channel: getFallbackOpt(argv, "--channel"),
377
383
  });
378
384
  }
379
385
  default:
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) {
@@ -669,6 +675,19 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
669
675
  const log = (msg) => {
670
676
  logFile.write(`[daemon] ${new Date().toISOString()} ${msg}\n`);
671
677
  };
678
+ const publishProjectRuntime = (status = "running") => {
679
+ try {
680
+ upsertProjectRuntime({
681
+ projectRoot,
682
+ daemonPid: process.pid,
683
+ socketPath: socketPath(projectRoot),
684
+ status,
685
+ lastSeen: new Date().toISOString(),
686
+ });
687
+ } catch (err) {
688
+ log(`project runtime update failed (${status}): ${err.message || err}`);
689
+ }
690
+ };
672
691
 
673
692
  // 创建进程管理器 - daemon 作为父进程监控所有 internal agents
674
693
  const processManager = new AgentProcessManager(projectRoot);
@@ -952,7 +971,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
952
971
  }
953
972
  if (req.type === IPC_REQUEST_TYPES.LAUNCH_AGENT) {
954
973
  log(`launch_agent received: agent=${req.agent} count=${req.count}`);
955
- const { agent, count, nickname } = req;
974
+ const { agent, count, nickname, launch_scope, terminal_app } = req;
956
975
  const normalizedAgent = normalizeLaunchAgent(agent);
957
976
  if (!normalizedAgent) {
958
977
  socket.write(
@@ -971,6 +990,8 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
971
990
  agent: normalizedAgent,
972
991
  count: finalCount,
973
992
  nickname: nickname || "",
993
+ launch_scope: launch_scope || "",
994
+ terminal_app: terminal_app || "",
974
995
  };
975
996
  try {
976
997
  const opsResults = await handleOps(projectRoot, [op], processManager);
@@ -1515,6 +1536,10 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1515
1536
  };
1516
1537
 
1517
1538
  ipcServer.listen(socketPath(projectRoot));
1539
+ publishProjectRuntime("running");
1540
+ const runtimeHeartbeat = setInterval(() => {
1541
+ publishProjectRuntime("running");
1542
+ }, PROJECT_RUNTIME_HEARTBEAT_MS);
1518
1543
 
1519
1544
  log(`Started pid=${process.pid}`);
1520
1545
 
@@ -1619,8 +1644,17 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1619
1644
  }, 1500);
1620
1645
  }
1621
1646
 
1647
+ let cleanedUp = false;
1622
1648
  const cleanup = () => {
1649
+ if (cleanedUp) return;
1650
+ cleanedUp = true;
1623
1651
  log(`Shutting down daemon (managed agents: ${processManager.count()})`);
1652
+ clearInterval(runtimeHeartbeat);
1653
+ try {
1654
+ markProjectStopped(projectRoot);
1655
+ } catch {
1656
+ // ignore cleanup errors
1657
+ }
1624
1658
 
1625
1659
  if (daemonCronController) {
1626
1660
  daemonCronController.stopAll();
@@ -1706,6 +1740,12 @@ function stopDaemon(projectRoot) {
1706
1740
  // ignore
1707
1741
  }
1708
1742
 
1743
+ try {
1744
+ markProjectStopped(projectRoot);
1745
+ } catch {
1746
+ // ignore
1747
+ }
1748
+
1709
1749
  return killed;
1710
1750
  }
1711
1751