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
@@ -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
  });
@@ -0,0 +1,119 @@
1
+ function normalizeIndex(value, length) {
2
+ const parsed = Number(value);
3
+ const nextIndex = Number.isFinite(parsed) ? Math.trunc(parsed) : Number.NaN;
4
+ if (!Number.isFinite(nextIndex) || nextIndex < 0 || nextIndex >= length) {
5
+ return -1;
6
+ }
7
+ return nextIndex;
8
+ }
9
+
10
+ function defaultResolveProjectRoot(row = {}) {
11
+ return String((row && row.project_root) || "");
12
+ }
13
+
14
+ function createProjectCloseController(options = {}) {
15
+ const {
16
+ getProjects = () => [],
17
+ getActiveProjectRoot = () => "",
18
+ resolveProjectRoot = defaultResolveProjectRoot,
19
+ isRunning = () => false,
20
+ stopDaemon = () => false,
21
+ switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
22
+ refreshProjects = () => {},
23
+ renderDashboard = () => {},
24
+ renderScreen = () => {},
25
+ logMessage = () => {},
26
+ escapeBlessed = (value) => String(value || ""),
27
+ } = options;
28
+
29
+ let closingProject = false;
30
+
31
+ function pickFallbackProjectRoot(targetProjectRoot) {
32
+ const rows = Array.isArray(getProjects()) ? getProjects() : [];
33
+ for (const row of rows) {
34
+ const root = resolveProjectRoot(row);
35
+ if (!root || root === targetProjectRoot) continue;
36
+ return root;
37
+ }
38
+ return "";
39
+ }
40
+
41
+ async function requestCloseProject(index) {
42
+ if (closingProject) {
43
+ return { ok: false, error: "project close already in progress" };
44
+ }
45
+
46
+ const rows = Array.isArray(getProjects()) ? getProjects() : [];
47
+ const nextIndex = normalizeIndex(index, rows.length);
48
+ if (nextIndex < 0) {
49
+ return { ok: false, error: "project index out of range" };
50
+ }
51
+
52
+ const target = rows[nextIndex] || {};
53
+ const projectRoot = resolveProjectRoot(target);
54
+ if (!projectRoot) {
55
+ return { ok: false, error: "project root unavailable" };
56
+ }
57
+
58
+ const projectName = String(target.project_name || projectRoot);
59
+ const escapedName = escapeBlessed(projectName);
60
+ const activeProjectRoot = String(getActiveProjectRoot() || "");
61
+
62
+ closingProject = true;
63
+ try {
64
+ logMessage("status", `{white-fg}⚙{/white-fg} Closing project ${escapedName} daemon and agents...`);
65
+
66
+ let switchedTo = "";
67
+ if (activeProjectRoot === projectRoot) {
68
+ const fallbackRoot = pickFallbackProjectRoot(projectRoot);
69
+ if (!fallbackRoot) {
70
+ const error = "Cannot close current project; switch to another project first";
71
+ logMessage("error", `{white-fg}✗{/white-fg} ${escapeBlessed(error)}`);
72
+ return { ok: false, error };
73
+ }
74
+
75
+ const switched = await Promise.resolve(switchProject(fallbackRoot));
76
+ if (!switched || switched.ok !== true) {
77
+ const reason = String((switched && switched.error) || "switch failed");
78
+ logMessage("error", `{white-fg}✗{/white-fg} Failed to switch project before close: ${escapeBlessed(reason)}`);
79
+ return { ok: false, error: reason };
80
+ }
81
+ switchedTo = fallbackRoot;
82
+ }
83
+
84
+ const wasRunning = Boolean(isRunning(projectRoot));
85
+ stopDaemon(projectRoot);
86
+
87
+ refreshProjects();
88
+ renderDashboard();
89
+ renderScreen();
90
+
91
+ if (wasRunning) {
92
+ logMessage("status", `{white-fg}✓{/white-fg} Closed project ${escapedName} daemon and agents`);
93
+ } else {
94
+ logMessage("status", `{white-fg}✓{/white-fg} Project ${escapedName} daemon already stopped`);
95
+ }
96
+
97
+ return {
98
+ ok: true,
99
+ project_root: projectRoot,
100
+ switched_to: switchedTo || undefined,
101
+ };
102
+ } catch (err) {
103
+ const message = err && err.message ? err.message : String(err || "project close failed");
104
+ logMessage("error", `{white-fg}✗{/white-fg} Failed to close project: ${escapeBlessed(message)}`);
105
+ return { ok: false, error: message };
106
+ } finally {
107
+ closingProject = false;
108
+ }
109
+ }
110
+
111
+ return {
112
+ requestCloseProject,
113
+ pickFallbackProjectRoot,
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ createProjectCloseController,
119
+ };
@@ -0,0 +1,55 @@
1
+ function parseTimestampMs(value) {
2
+ const parsed = Date.parse(String(value || ""));
3
+ return Number.isFinite(parsed) ? parsed : 0;
4
+ }
5
+
6
+ function projectLabel(row = {}) {
7
+ return String(row.project_name || row.project_root || "");
8
+ }
9
+
10
+ function normalizeInteractionMs(value) {
11
+ const num = Number(value);
12
+ if (!Number.isFinite(num) || num < 0) return 0;
13
+ return num;
14
+ }
15
+
16
+ function filterVisibleProjectRuntimes(rows = []) {
17
+ const sourceRows = Array.isArray(rows) ? rows : [];
18
+ return sourceRows.filter((row) => {
19
+ const status = String((row && row.status) || "").trim().toLowerCase();
20
+ return status !== "stopped";
21
+ });
22
+ }
23
+
24
+ function sortProjectRuntimes(options = {}) {
25
+ const {
26
+ rows = [],
27
+ activeProjectRoot = "",
28
+ resolveProjectRoot = (row) => String((row && row.project_root) || ""),
29
+ getInteractionMs = () => 0,
30
+ } = options;
31
+ const sourceRows = Array.isArray(rows) ? rows.slice() : [];
32
+ // Keep arg usage for backward compatibility with existing callers/tests.
33
+ void activeProjectRoot;
34
+ void resolveProjectRoot;
35
+
36
+ sourceRows.sort((a, b) => {
37
+ const bInteraction = normalizeInteractionMs(getInteractionMs(b));
38
+ const aInteraction = normalizeInteractionMs(getInteractionMs(a));
39
+ if (bInteraction !== aInteraction) return bInteraction - aInteraction;
40
+
41
+ const bSeen = parseTimestampMs(b && b.last_seen);
42
+ const aSeen = parseTimestampMs(a && a.last_seen);
43
+ if (bSeen !== aSeen) return bSeen - aSeen;
44
+
45
+ return projectLabel(a).localeCompare(projectLabel(b), "en", { sensitivity: "base" });
46
+ });
47
+
48
+ return sourceRows;
49
+ }
50
+
51
+ module.exports = {
52
+ sortProjectRuntimes,
53
+ parseTimestampMs,
54
+ filterVisibleProjectRuntimes,
55
+ };
@@ -107,20 +107,66 @@ function createStatusLineController(options = {}) {
107
107
  renderStatusLine();
108
108
  }
109
109
 
110
- function queueStatusLine(text) {
111
- pendingStatusLines.push(text || "");
110
+ function normalizePendingItem(text, options = {}) {
111
+ const key = options && typeof options.key === "string"
112
+ ? options.key.trim()
113
+ : "";
114
+ return {
115
+ text: text || "",
116
+ key,
117
+ };
118
+ }
119
+
120
+ function headPendingText() {
121
+ if (pendingStatusLines.length === 0) return "";
122
+ const item = pendingStatusLines[0];
123
+ return item && typeof item.text === "string" ? item.text : "";
124
+ }
125
+
126
+ function queueStatusLine(text, options = {}) {
127
+ const item = normalizePendingItem(text, options);
128
+ if (item.key) {
129
+ const existingIndex = pendingStatusLines.findIndex((entry) => entry.key === item.key);
130
+ if (existingIndex >= 0) {
131
+ pendingStatusLines[existingIndex] = item;
132
+ if (existingIndex === 0) {
133
+ setPrimaryStatus(item.text, { pending: true });
134
+ renderScreen();
135
+ }
136
+ return;
137
+ }
138
+ }
139
+
140
+ pendingStatusLines.push(item);
112
141
  if (pendingStatusLines.length === 1) {
113
- setPrimaryStatus(pendingStatusLines[0], { pending: true });
142
+ setPrimaryStatus(item.text, { pending: true });
114
143
  renderScreen();
115
144
  }
116
145
  }
117
146
 
118
- function resolveStatusLine(text) {
147
+ function resolveStatusLine(text, options = {}) {
148
+ const key = options && typeof options.key === "string"
149
+ ? options.key.trim()
150
+ : "";
151
+ let removedHead = false;
152
+
119
153
  if (pendingStatusLines.length > 0) {
120
- pendingStatusLines.shift();
154
+ if (key) {
155
+ const index = pendingStatusLines.findIndex((entry) => entry.key === key);
156
+ if (index >= 0) {
157
+ pendingStatusLines.splice(index, 1);
158
+ removedHead = index === 0;
159
+ }
160
+ } else {
161
+ pendingStatusLines.shift();
162
+ removedHead = true;
163
+ }
121
164
  }
165
+
122
166
  if (pendingStatusLines.length > 0) {
123
- setPrimaryStatus(pendingStatusLines[0], { pending: true });
167
+ if (removedHead || !primaryStatusPending) {
168
+ setPrimaryStatus(headPendingText(), { pending: true });
169
+ }
124
170
  } else {
125
171
  setPrimaryStatus(text || "", { pending: false });
126
172
  }
@@ -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,
@@ -3,10 +3,46 @@ const path = require("path");
3
3
  const fs = require("fs");
4
4
  const { spawn, spawnSync } = require("child_process");
5
5
 
6
- function connectSocket(sockPath) {
6
+ function connectSocket(sockPath, options = {}) {
7
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
8
+ ? Math.trunc(options.timeoutMs)
9
+ : 0;
7
10
  return new Promise((resolve, reject) => {
8
- const client = net.createConnection(sockPath, () => resolve(client));
9
- client.on("error", reject);
11
+ let timeoutHandle = null;
12
+ const client = net.createConnection(sockPath, () => {
13
+ if (timeoutHandle) {
14
+ clearTimeout(timeoutHandle);
15
+ }
16
+ resolve(client);
17
+ });
18
+
19
+ const cleanup = () => {
20
+ if (timeoutHandle) {
21
+ clearTimeout(timeoutHandle);
22
+ timeoutHandle = null;
23
+ }
24
+ };
25
+
26
+ client.on("error", (err) => {
27
+ cleanup();
28
+ reject(err);
29
+ });
30
+
31
+ if (timeoutMs > 0) {
32
+ timeoutHandle = setTimeout(() => {
33
+ const err = new Error(`connect timeout after ${timeoutMs}ms`);
34
+ err.code = "ETIMEDOUT";
35
+ try {
36
+ client.destroy(err);
37
+ } catch {
38
+ // ignore
39
+ }
40
+ reject(err);
41
+ }, timeoutMs);
42
+ if (typeof timeoutHandle.unref === "function") {
43
+ timeoutHandle.unref();
44
+ }
45
+ }
10
46
  });
11
47
  }
12
48
 
@@ -38,11 +74,11 @@ function stopDaemon(projectRoot) {
38
74
  });
39
75
  }
40
76
 
41
- async function connectWithRetry(sockPath, retries, delayMs) {
77
+ async function connectWithRetry(sockPath, retries, delayMs, options = {}) {
42
78
  for (let i = 0; i < retries; i += 1) {
43
79
  try {
44
80
  // eslint-disable-next-line no-await-in-loop
45
- const client = await connectSocket(sockPath);
81
+ const client = await connectSocket(sockPath, options);
46
82
  return client;
47
83
  } catch {
48
84
  // eslint-disable-next-line no-await-in-loop