u-foo 2.3.0 → 2.3.2

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.0",
3
+ "version": "2.3.2",
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",
@@ -12,6 +12,7 @@ const { writeActivityState } = require("./activityStateWriter");
12
12
  * @param {string} options.subscriber - Subscriber ID (e.g. "claude-code:abc123")
13
13
  * @param {string} options.projectRoot - Project root (unused, kept for API compat)
14
14
  * @param {boolean} [options.force=true] - Force overwrite priority-protected states
15
+ * publish(state, extra, { force }) can override this default for one transition.
15
16
  */
16
17
  function createActivityStatePublisher(options = {}) {
17
18
  const {
@@ -22,10 +23,13 @@ function createActivityStatePublisher(options = {}) {
22
23
 
23
24
  let lastState = "";
24
25
 
25
- function publish(state, extra = {}) {
26
+ function publish(state, extra = {}, publishOptions = {}) {
26
27
  if (state === lastState) return false;
27
28
  const since = extra.since || undefined;
28
- const changed = writeActivityState(agentsFile, subscriber, state, { since, force });
29
+ const effectiveForce = typeof publishOptions.force === "boolean"
30
+ ? publishOptions.force
31
+ : force;
32
+ const changed = writeActivityState(agentsFile, subscriber, state, { since, force: effectiveForce });
29
33
  if (!changed) return false;
30
34
  lastState = state;
31
35
  // Write to bus events directory for daemon bridge to pick up.
@@ -123,8 +123,10 @@ class AgentNotifier {
123
123
  * 更新 activity_state(terminal/tmux agent 基础支持)
124
124
  * 基于消息投递推断 WORKING,无 pending 时推断 IDLE
125
125
  */
126
- updateActivityState(state) {
127
- return this.activityPublisher.publish(state);
126
+ updateActivityState(state, options = {}) {
127
+ return this.activityPublisher.publish(state, {}, {
128
+ force: typeof options.force === "boolean" ? options.force : undefined,
129
+ });
128
130
  }
129
131
 
130
132
  getCurrentActivityState() {
@@ -375,7 +377,14 @@ class AgentNotifier {
375
377
 
376
378
  this.lastCount = this.getMessageCount();
377
379
  if (this._launcherReady && (!this.lastWorkingAt || nowMs - this.lastWorkingAt >= this.workingHoldMs)) {
378
- this.updateActivityState("idle");
380
+ const currentActivityState = this.getCurrentActivityState();
381
+ if (currentActivityState !== "waiting_input" && currentActivityState !== "blocked") {
382
+ if (currentActivityState === "working") {
383
+ this.updateActivityState("idle", { force: true });
384
+ } else {
385
+ this.updateActivityState("idle");
386
+ }
387
+ }
379
388
  }
380
389
  this.refreshTitle();
381
390
  this.updateHeartbeat();
@@ -122,10 +122,11 @@ class SubscriberManager {
122
122
  }
123
123
  }
124
124
 
125
- async cleanupDuplicateTty(currentSubscriber, ttyPath) {
125
+ async cleanupDuplicateTty(currentSubscriber, ttyPath, options = {}) {
126
126
  if (!ttyPath) return null;
127
127
  if (!this.busData.agents) return null;
128
128
 
129
+ const currentAgentType = String(options.agentType || "").trim();
129
130
  let inheritedNickname = null;
130
131
  const entries = Object.entries(this.busData.agents);
131
132
  for (const [id, meta] of entries) {
@@ -136,8 +137,9 @@ class SubscriberManager {
136
137
  : (await this.queueManager.readTty(id));
137
138
  if (!metaTty) continue;
138
139
  if (metaTty === ttyPath) {
139
- // Inherit user-set nickname from the displaced entry
140
- if (meta.nickname && !inheritedNickname) {
140
+ const sameAgentType = !currentAgentType || meta?.agent_type === currentAgentType;
141
+ // Inherit user-set nickname only when replacing the same agent type.
142
+ if (sameAgentType && meta.nickname && !inheritedNickname) {
141
143
  inheritedNickname = meta.nickname;
142
144
  }
143
145
  // Remove stale subscriber using same tty
@@ -227,7 +229,7 @@ class SubscriberManager {
227
229
  // 清理同一 tty 的旧订阅者(避免重复启动污染)
228
230
  // Inherit nickname from displaced entry when this is a new subscriber
229
231
  // with no explicit nickname (e.g. session restart on same TTY)
230
- const inheritedNickname = await this.cleanupDuplicateTty(subscriber, finalTty);
232
+ const inheritedNickname = await this.cleanupDuplicateTty(subscriber, finalTty, { agentType });
231
233
  if (inheritedNickname && !nickname && !existingMeta) {
232
234
  finalNickname = inheritedNickname;
233
235
  if (!finalScopedNickname) finalScopedNickname = inheritedNickname;
@@ -117,6 +117,7 @@ function createCommandExecutor(options = {}) {
117
117
  parseCommand = () => null,
118
118
  escapeBlessed = (value) => String(value || ""),
119
119
  logMessage = () => {},
120
+ resolveStatusLine = null,
120
121
  renderScreen = () => {},
121
122
  getActiveAgents = () => [],
122
123
  getActiveAgentMetaMap = () => new Map(),
@@ -157,6 +158,10 @@ function createCommandExecutor(options = {}) {
157
158
  throw new Error("createCommandExecutor requires projectRoot");
158
159
  }
159
160
 
161
+ const statusMsg = typeof resolveStatusLine === "function"
162
+ ? resolveStatusLine
163
+ : (text) => logMessage("status", text);
164
+
160
165
  async function handleDoctorCommand() {
161
166
  logMessage("system", "{white-fg}⚙{/white-fg} Running health check...");
162
167
 
@@ -221,51 +226,51 @@ function createCommandExecutor(options = {}) {
221
226
 
222
227
  if (subcommand === "start") {
223
228
  if (isDaemonRunning(targetRoot)) {
224
- logMessage("system", "{white-fg}⚠{/white-fg} Daemon already running");
229
+ statusMsg("{gray-fg}⚠{/gray-fg} Daemon already running");
225
230
  } else {
226
- logMessage("system", "{white-fg}⚙{/white-fg} Starting daemon...");
231
+ statusMsg("{gray-fg}⚙{/gray-fg} Starting daemon...");
227
232
  startDaemon(targetRoot);
228
233
  await sleep(1000);
229
234
  if (isDaemonRunning(targetRoot)) {
230
- logMessage("system", "{white-fg}✓{/white-fg} Daemon started");
235
+ statusMsg("{gray-fg}✓{/gray-fg} Daemon started");
231
236
  } else {
232
- logMessage("error", "{white-fg}✗{/white-fg} Failed to start daemon");
237
+ statusMsg("{gray-fg}✗{/gray-fg} Failed to start daemon");
233
238
  }
234
239
  }
235
240
  return;
236
241
  }
237
242
 
238
243
  if (subcommand === "stop") {
239
- logMessage("system", "{white-fg}⚙{/white-fg} Stopping daemon...");
244
+ statusMsg("{gray-fg}⚙{/gray-fg} Stopping daemon...");
240
245
  stopDaemon(targetRoot);
241
246
  await sleep(1000);
242
247
  if (!isDaemonRunning(targetRoot)) {
243
- logMessage("system", "{white-fg}✓{/white-fg} Daemon stopped");
248
+ statusMsg("{gray-fg}✓{/gray-fg} Daemon stopped");
244
249
  } else {
245
- logMessage("error", "{white-fg}✗{/white-fg} Failed to stop daemon");
250
+ statusMsg("{gray-fg}✗{/gray-fg} Failed to stop daemon");
246
251
  }
247
252
  return;
248
253
  }
249
254
 
250
255
  if (subcommand === "restart") {
251
- logMessage("system", "{white-fg}⚙{/white-fg} Restarting daemon...");
256
+ statusMsg("{gray-fg}⚙{/gray-fg} Restarting daemon...");
252
257
  stopDaemon(targetRoot);
253
258
  await sleep(500);
254
259
  startDaemon(targetRoot);
255
260
  await sleep(1000);
256
261
  if (isDaemonRunning(targetRoot)) {
257
- logMessage("system", "{white-fg}✓{/white-fg} Daemon restarted");
262
+ statusMsg("{gray-fg}✓{/gray-fg} Daemon restarted");
258
263
  } else {
259
- logMessage("error", "{white-fg}✗{/white-fg} Failed to restart daemon");
264
+ statusMsg("{gray-fg}✗{/gray-fg} Failed to restart daemon");
260
265
  }
261
266
  return;
262
267
  }
263
268
 
264
269
  if (subcommand === "status") {
265
270
  if (isDaemonRunning(targetRoot)) {
266
- logMessage("system", "{white-fg}✓{/white-fg} Daemon is running");
271
+ statusMsg("{gray-fg}✓{/gray-fg} Daemon is running");
267
272
  } else {
268
- logMessage("system", "{white-fg}✗{/white-fg} Daemon is not running");
273
+ statusMsg("{gray-fg}✗{/gray-fg} Daemon is not running");
269
274
  }
270
275
  return;
271
276
  }
@@ -37,6 +37,14 @@ function createDaemonMessageRouter(options = {}) {
37
37
  return text.includes(":") && !text.includes(" ");
38
38
  }
39
39
 
40
+ function speakerPrefix(label, color = "cyan") {
41
+ const escapedLabel = escapeBlessed(label);
42
+ if (color === "white") {
43
+ return `{white-fg}${escapedLabel}{/white-fg} {gray-fg}·{/gray-fg} `;
44
+ }
45
+ return `{cyan-fg}${escapedLabel}{/cyan-fg} {gray-fg}·{/gray-fg} `;
46
+ }
47
+
40
48
  function normalizeDisplayMessage(raw) {
41
49
  let displayMessage = raw || "";
42
50
  let streamPayload = null;
@@ -77,9 +85,9 @@ function createDaemonMessageRouter(options = {}) {
77
85
  resolveBusStatus(item);
78
86
  if (text) {
79
87
  const prefix = data.phase === BUS_STATUS_PHASES.ERROR
80
- ? "{white-fg}✗{/white-fg}"
81
- : "{white-fg}✓{/white-fg}";
82
- logMessage("status", `${prefix} ${escapeBlessed(text)}`, data);
88
+ ? "{gray-fg}✗{/gray-fg}"
89
+ : "{gray-fg}✓{/gray-fg}";
90
+ resolveStatusLine(`${prefix} ${escapeBlessed(text)}`, data);
83
91
  }
84
92
  } else {
85
93
  enqueueBusStatus(item);
@@ -214,7 +222,7 @@ function createDaemonMessageRouter(options = {}) {
214
222
  );
215
223
  // Suppress lifecycle confirmations from chat history — status line plus structured payload is enough.
216
224
  if (!isLifecycleStatusOnly && !isGroupStartedConfirmation) {
217
- logMessage("reply", `{white-fg}←{/white-fg} ${escapeBlessed(replyText)}`);
225
+ logMessage("reply", `${speakerPrefix("ufoo", "white")}${escapeBlessed(replyText)}`);
218
226
  }
219
227
  }
220
228
 
@@ -352,15 +360,11 @@ function createDaemonMessageRouter(options = {}) {
352
360
  const publisher = report.agent_id || data.publisher || "ufoo-agent";
353
361
  const displayName = resolveAgentDisplayName(publisher);
354
362
  const detail = report.summary || report.message || data.message || report.task_id || "report";
355
- logMessage(
356
- "system",
357
- `{gray-fg}↥{/gray-fg} {cyan-fg}${escapeBlessed(displayName)}{/cyan-fg} {gray-fg}→ ufoo-agent{/gray-fg} ${escapeBlessed(detail)}`
358
- );
363
+ logMessage("bus", `${speakerPrefix(displayName)}${escapeBlessed(detail)}`);
359
364
  requestStatus();
360
365
  renderScreen();
361
366
  return true;
362
367
  }
363
- const prefix = data.event === "broadcast" ? "{gray-fg}⇢{/gray-fg}" : "{gray-fg}↔{/gray-fg}";
364
368
  const publisher = data.publisher && data.publisher !== "unknown"
365
369
  ? data.publisher
366
370
  : (data.event === "broadcast" ? "broadcast" : "bus");
@@ -399,7 +403,7 @@ function createDaemonMessageRouter(options = {}) {
399
403
  }
400
404
 
401
405
  const pendingBeforeMessage = getPendingState(publisher, displayName);
402
- const prefixLabel = `${prefix} {gray-fg}${escapeBlessed(displayName)}{/gray-fg}: `;
406
+ const prefixLabel = speakerPrefix(displayName);
403
407
  const continuationPrefix = " ".repeat(stripBlessedTags(prefixLabel).length);
404
408
 
405
409
  if (streamPayload) {
package/src/chat/index.js CHANGED
@@ -1905,6 +1905,7 @@ async function runChat(projectRoot, options = {}) {
1905
1905
  renderDashboard,
1906
1906
  renderScreen: () => screen.render(),
1907
1907
  logMessage,
1908
+ resolveStatusLine,
1908
1909
  escapeBlessed,
1909
1910
  });
1910
1911
 
@@ -1919,6 +1920,7 @@ async function runChat(projectRoot, options = {}) {
1919
1920
  parseCommand,
1920
1921
  escapeBlessed,
1921
1922
  logMessage,
1923
+ resolveStatusLine,
1922
1924
  renderScreen: () => screen.render(),
1923
1925
  getActiveAgents: () => activeAgents,
1924
1926
  getActiveAgentMetaMap: () => activeAgentMetaMap,
@@ -30,6 +30,10 @@ function createInputSubmitHandler(options = {}) {
30
30
  throw new Error("createInputSubmitHandler requires a mutable state object");
31
31
  }
32
32
 
33
+ function userPrefix() {
34
+ return "{white-fg}you{/white-fg} {gray-fg}·{/gray-fg}";
35
+ }
36
+
33
37
  async function tryActivateTargetAgent(agentId) {
34
38
  const adapter = getAgentAdapter(agentId);
35
39
  const capabilities = adapter && adapter.capabilities ? adapter.capabilities : null;
@@ -88,7 +92,7 @@ function createInputSubmitHandler(options = {}) {
88
92
  const label = getAgentLabel(state.targetAgent);
89
93
  logMessage(
90
94
  "user",
91
- `{cyan-fg}→{/cyan-fg} {magenta-fg}@${escapeBlessed(label)}{/magenta-fg} ${escapeBlessed(text)}`
95
+ `${userPrefix()} {magenta-fg}@${escapeBlessed(label)}{/magenta-fg} ${escapeBlessed(text)}`
92
96
  );
93
97
  renderScreen(); // Immediately render the user message
94
98
  markPendingDelivery(state.targetAgent);
@@ -114,10 +118,7 @@ function createInputSubmitHandler(options = {}) {
114
118
  return;
115
119
  }
116
120
  setTargetAgent(resolvedTarget);
117
- logMessage(
118
- "status",
119
- `{white-fg}⚙{/white-fg} Target selected: @${escapeBlessed(atTarget.target)}`
120
- );
121
+ queueStatusLine(`Target selected: @${escapeBlessed(atTarget.target)}`);
121
122
  focusInput();
122
123
  return;
123
124
  }
@@ -125,7 +126,7 @@ function createInputSubmitHandler(options = {}) {
125
126
  const message = atTarget.message.trim();
126
127
  logMessage(
127
128
  "user",
128
- `{cyan-fg}→{/cyan-fg} {magenta-fg}@${escapeBlessed(atTarget.target)}{/magenta-fg} ${escapeBlessed(message)}`
129
+ `${userPrefix()} {magenta-fg}@${escapeBlessed(atTarget.target)}{/magenta-fg} ${escapeBlessed(message)}`
129
130
  );
130
131
  renderScreen(); // Immediately render the user message
131
132
  markPendingDelivery(resolvedTarget);
@@ -142,7 +143,7 @@ function createInputSubmitHandler(options = {}) {
142
143
 
143
144
  if (text.startsWith("/")) {
144
145
  if (shouldEchoCommandInChat(text)) {
145
- logMessage("user", `{white-fg}→{/white-fg} ${escapeBlessed(text)}`);
146
+ logMessage("user", `${userPrefix()} ${escapeBlessed(text)}`);
146
147
  renderScreen(); // Render slash command immediately
147
148
  }
148
149
  try {
@@ -188,7 +189,7 @@ function createInputSubmitHandler(options = {}) {
188
189
  allow_relevance_queue: true,
189
190
  },
190
191
  });
191
- logMessage("user", `{white-fg}→{/white-fg} ${escapeBlessed(text)}`);
192
+ logMessage("user", `${userPrefix()} ${escapeBlessed(text)}`);
192
193
  renderScreen(); // Render plain text message immediately
193
194
  }
194
195
 
@@ -23,10 +23,14 @@ function createProjectCloseController(options = {}) {
23
23
  renderDashboard = () => {},
24
24
  renderScreen = () => {},
25
25
  logMessage = () => {},
26
+ resolveStatusLine = null,
26
27
  escapeBlessed = (value) => String(value || ""),
27
28
  } = options;
28
29
 
29
30
  let closingProject = false;
31
+ const statusMsg = typeof resolveStatusLine === "function"
32
+ ? resolveStatusLine
33
+ : (text) => logMessage("status", text);
30
34
 
31
35
  function pickFallbackProjectRoot(targetProjectRoot) {
32
36
  const rows = Array.isArray(getProjects()) ? getProjects() : [];
@@ -61,7 +65,7 @@ function createProjectCloseController(options = {}) {
61
65
 
62
66
  closingProject = true;
63
67
  try {
64
- logMessage("status", `{white-fg}⚙{/white-fg} Closing project ${escapedName} daemon and agents...`);
68
+ statusMsg(`{gray-fg}⚙{/gray-fg} Closing project ${escapedName} daemon and agents...`);
65
69
 
66
70
  let switchedTo = "";
67
71
  if (activeProjectRoot === projectRoot) {
@@ -89,9 +93,9 @@ function createProjectCloseController(options = {}) {
89
93
  renderScreen();
90
94
 
91
95
  if (wasRunning) {
92
- logMessage("status", `{white-fg}✓{/white-fg} Closed project ${escapedName} daemon and agents`);
96
+ statusMsg(`{gray-fg}✓{/gray-fg} Closed project ${escapedName} daemon and agents`);
93
97
  } else {
94
- logMessage("status", `{white-fg}✓{/white-fg} Project ${escapedName} daemon already stopped`);
98
+ statusMsg(`{gray-fg}✓{/gray-fg} Project ${escapedName} daemon already stopped`);
95
99
  }
96
100
 
97
101
  return {
@@ -75,7 +75,8 @@ function createSettingsController(options = {}) {
75
75
  setSelectedProviderIndex(Math.max(0, providerOptions.findIndex((opt) => opt.value === next)));
76
76
  saveConfig(projectRoot, { agentProvider: next });
77
77
  clearUfooAgentIdentity();
78
- logMessage("status", `{white-fg}⚙{/white-fg} ufoo-agent: ${providerLabel(next)}`);
78
+ const statusMsg = resolveStatusLine || ((text) => logMessage("status", text));
79
+ statusMsg(`{gray-fg}⚙{/gray-fg} ufoo-agent: ${providerLabel(next)}`);
79
80
  renderDashboard();
80
81
  renderScreen();
81
82
  void restartDaemon();
@@ -89,7 +90,8 @@ function createSettingsController(options = {}) {
89
90
  setSelectedResumeIndex(next ? 0 : 1);
90
91
  saveConfig(projectRoot, { autoResume: next });
91
92
  const label = next ? "Resume previous session" : "Start new session";
92
- logMessage("status", `{white-fg}⚙{/white-fg} Resume mode: ${label}`);
93
+ const statusMsg = resolveStatusLine || ((text) => logMessage("status", text));
94
+ statusMsg(`{gray-fg}⚙{/gray-fg} Resume mode: ${label}`);
93
95
  renderDashboard();
94
96
  renderScreen();
95
97
  return true;