u-foo 2.4.1 → 2.4.3

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.4.1",
3
+ "version": "2.4.3",
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",
@@ -2,6 +2,14 @@
2
2
 
3
3
  const crypto = require("crypto");
4
4
 
5
+ const SILENT_BOOTSTRAP_INSTRUCTION = [
6
+ "Bootstrap silence:",
7
+ "- This message is setup only, not a task.",
8
+ "- Apply these instructions silently, then wait for the next user, bus, or controller task.",
9
+ "- Do not reply, summarize, acknowledge, report, hand off, or call tools in response to this bootstrap message.",
10
+ "- Do not send `ufoo report` or `ufoo bus` until real work arrives after this bootstrap.",
11
+ ].join("\n");
12
+
5
13
  const SHARED_UFOO_PROTOCOL = [
6
14
  "Session harness: ufoo",
7
15
  "",
@@ -42,11 +50,15 @@ const SHARED_UFOO_PROTOCOL = [
42
50
  "dedup, so don't worry about report loops.",
43
51
  "",
44
52
  "`ufoo report start|progress|done|error \"<short summary>\"`",
53
+ "Do not emulate report failures with `ufoo bus send ufoo-agent ...`.",
54
+ "If `ufoo report` fails, continue without a fallback bus report.",
45
55
  "",
46
56
  "Then continue the active task.",
47
57
  ].join("\n");
48
58
 
49
59
  const SHARED_GROUP_PREFIX = [
60
+ SILENT_BOOTSTRAP_INSTRUCTION,
61
+ "",
50
62
  "You are part of a ufoo multi-agent group.",
51
63
  "",
52
64
  "Shared rules:",
@@ -193,6 +205,7 @@ function computeBootstrapFingerprint({
193
205
  }
194
206
 
195
207
  module.exports = {
208
+ SILENT_BOOTSTRAP_INSTRUCTION,
196
209
  SHARED_UFOO_PROTOCOL,
197
210
  SHARED_GROUP_PREFIX,
198
211
  SOLO_AGENT_PREFIX,
@@ -129,6 +129,41 @@ async function sendDaemonRequest(projectRoot, payload) {
129
129
  });
130
130
  }
131
131
 
132
+ async function sendAgentReportRequest(projectRoot, report) {
133
+ const { normalizeReportInput } = require("../../coordination/report/store");
134
+ const { enqueueAgentReport } = require("../../runtime/daemon/reportControlBus");
135
+ const entry = normalizeReportInput(report);
136
+ const queued = await enqueueAgentReport(projectRoot, entry);
137
+ return {
138
+ type: "response",
139
+ data: {
140
+ reply: `Report queued (${entry.phase})`,
141
+ report: entry,
142
+ queued,
143
+ },
144
+ };
145
+ }
146
+
147
+ function getReportDetail(out = {}) {
148
+ if (out.phase === "error") {
149
+ return out.error || out.summary || out.message || out.task_id;
150
+ }
151
+ return out.summary || out.message || out.task_id;
152
+ }
153
+
154
+ function printReportOutput(out, json = false, queued = null) {
155
+ if (json) {
156
+ console.log(JSON.stringify({
157
+ status: "queued",
158
+ report: out,
159
+ queued,
160
+ }, null, 2));
161
+ return;
162
+ }
163
+ const detail = getReportDetail(out);
164
+ console.log(`[report] queued ${out.phase} ${out.agent_id} ${out.task_id} ${detail}`);
165
+ }
166
+
132
167
  function requireOptional(name) {
133
168
  try {
134
169
  // eslint-disable-next-line global-require, import/no-dynamic-require
@@ -978,20 +1013,9 @@ async function runCli(argv) {
978
1013
  };
979
1014
 
980
1015
  try {
981
- await ensureDaemonRunning(projectRoot);
982
- const resp = await sendDaemonRequest(projectRoot, {
983
- type: "agent_report",
984
- report,
985
- });
1016
+ const resp = await sendAgentReportRequest(projectRoot, report);
986
1017
  const out = resp?.data?.report || report;
987
- if (opts.json) {
988
- console.log(JSON.stringify(out, null, 2));
989
- return;
990
- }
991
- const detail = out.phase === "error"
992
- ? (out.error || out.summary || out.message || out.task_id)
993
- : (out.summary || out.message || out.task_id);
994
- console.log(`[report] ${out.phase} ${out.agent_id} ${out.task_id} ${detail}`);
1018
+ printReportOutput(out, opts.json, resp?.data?.queued || null);
995
1019
  } catch (err) {
996
1020
  console.error(err.message || String(err));
997
1021
  process.exitCode = 1;
@@ -1926,20 +1950,9 @@ async function runCli(argv) {
1926
1950
 
1927
1951
  (async () => {
1928
1952
  try {
1929
- await ensureDaemonRunning(process.cwd());
1930
- const resp = await sendDaemonRequest(process.cwd(), {
1931
- type: "agent_report",
1932
- report,
1933
- });
1953
+ const resp = await sendAgentReportRequest(process.cwd(), report);
1934
1954
  const out = resp?.data?.report || report;
1935
- if (rest.includes("--json")) {
1936
- console.log(JSON.stringify(out, null, 2));
1937
- return;
1938
- }
1939
- const detail = out.phase === "error"
1940
- ? (out.error || out.summary || out.message || out.task_id)
1941
- : (out.summary || out.message || out.task_id);
1942
- console.log(`[report] ${out.phase} ${out.agent_id} ${out.task_id} ${detail}`);
1955
+ printReportOutput(out, rest.includes("--json"), resp?.data?.queued || null);
1943
1956
  } catch (err) {
1944
1957
  console.error(err.message || String(err));
1945
1958
  process.exitCode = 1;
@@ -330,7 +330,7 @@ class EventBus {
330
330
  injectionMode: options.injectionMode,
331
331
  source: options.source,
332
332
  })
333
- : await this.messageManager.emit(target, eventName, data, publisher);
333
+ : await this.messageManager.emit(target, eventName, data, publisher, options.type);
334
334
  const silent = options.silent === true;
335
335
  if (!silent && eventName === "message") {
336
336
  logOk(
@@ -21,6 +21,11 @@ const { normalizeFormat, renderGroupDiagramFromTemplate, renderGroupDiagramFromR
21
21
  const { runPromptWithAssistant } = require("./promptLoop");
22
22
  const { handlePromptRequest } = require("./promptRequest");
23
23
  const { recordAgentReport } = require("./reporting");
24
+ const {
25
+ isAgentReportControlEvent,
26
+ extractAgentReportControl,
27
+ takeReportControlEvents,
28
+ } = require("./reportControlBus");
24
29
  const { isGlobalControllerProjectRoot } = require("../projects");
25
30
  const {
26
31
  assignSoloRoleToExistingAgent,
@@ -855,7 +860,7 @@ async function dispatchMessages(projectRoot, dispatch = []) {
855
860
  }
856
861
  }
857
862
 
858
- function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
863
+ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain, onReport) {
859
864
  const state = {
860
865
  subscriber: null,
861
866
  queueFile: null,
@@ -867,6 +872,7 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
867
872
  };
868
873
  const eventBus = new EventBus(projectRoot);
869
874
  let joinInProgress = false;
875
+ let polling = false;
870
876
 
871
877
  function getAgentNickname(agentId) {
872
878
  if (!agentId) return agentId;
@@ -1057,6 +1063,27 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
1057
1063
  })();
1058
1064
  }
1059
1065
 
1066
+ async function handleReportControlEvent(evt) {
1067
+ if (!isAgentReportControlEvent(evt)) return false;
1068
+ if (typeof onReport !== "function") return false;
1069
+ const control = extractAgentReportControl(evt);
1070
+ if (!control) return false;
1071
+ await onReport(control.report, {
1072
+ event: evt,
1073
+ requestId: control.request_id,
1074
+ queuedAt: control.queued_at,
1075
+ });
1076
+ return true;
1077
+ }
1078
+
1079
+ async function pollReportControlQueue() {
1080
+ const events = takeReportControlEvents(projectRoot);
1081
+ if (!events.length) return;
1082
+ for (const evt of events) {
1083
+ if (await handleReportControlEvent(evt)) continue;
1084
+ }
1085
+ }
1086
+
1060
1087
  function pollQueue() {
1061
1088
  if (!state.queueFile) return;
1062
1089
  if (!fs.existsSync(state.queueFile)) return;
@@ -1109,14 +1136,23 @@ function startBusBridge(projectRoot, provider, onEvent, onStatus, shouldDrain) {
1109
1136
  }
1110
1137
  }
1111
1138
 
1112
- function poll() {
1113
- ensureSubscriber();
1114
- if (typeof shouldDrain === "function" && !shouldDrain()) return;
1115
- pollQueue();
1116
- pollWatchedEvents();
1139
+ async function poll() {
1140
+ if (polling) return;
1141
+ polling = true;
1142
+ try {
1143
+ ensureSubscriber();
1144
+ await pollReportControlQueue();
1145
+ if (typeof shouldDrain === "function" && !shouldDrain()) return;
1146
+ pollQueue();
1147
+ pollWatchedEvents();
1148
+ } finally {
1149
+ polling = false;
1150
+ }
1117
1151
  }
1118
1152
 
1119
- const interval = setInterval(poll, 1000);
1153
+ const interval = setInterval(() => {
1154
+ poll().catch(() => {});
1155
+ }, 1000);
1120
1156
  return {
1121
1157
  markPending(target) {
1122
1158
  if (!target) return;
@@ -1300,11 +1336,53 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1300
1336
  log,
1301
1337
  });
1302
1338
 
1339
+ const publishAgentReportResult = (entry) => {
1340
+ ipcServer.sendToSockets({
1341
+ type: IPC_RESPONSE_TYPES.BUS,
1342
+ data: {
1343
+ event: "controller_report",
1344
+ publisher: entry.agent_id,
1345
+ message: entry.summary || entry.message || entry.task_id,
1346
+ report: entry,
1347
+ },
1348
+ });
1349
+ ipcServer.sendToSockets({
1350
+ type: IPC_RESPONSE_TYPES.STATUS,
1351
+ data: buildRuntimeStatus(),
1352
+ });
1353
+ };
1354
+
1355
+ const handleAgentReport = async (report, options = {}) => {
1356
+ const source = options.source || report.source || "cli";
1357
+ const { entry } = await recordAgentReport({
1358
+ projectRoot,
1359
+ report: {
1360
+ ...report,
1361
+ source,
1362
+ },
1363
+ onStatus: (status) => {
1364
+ ipcServer.sendToSockets({
1365
+ type: IPC_RESPONSE_TYPES.STATUS,
1366
+ data: status,
1367
+ });
1368
+ },
1369
+ log,
1370
+ });
1371
+ publishAgentReportResult(entry);
1372
+ return entry;
1373
+ };
1374
+
1303
1375
  const busBridge = startBusBridge(projectRoot, provider, (evt) => {
1304
1376
  ipcServer.sendToSockets({ type: IPC_RESPONSE_TYPES.BUS, data: evt });
1305
1377
  }, (status) => {
1306
1378
  ipcServer.sendToSockets({ type: IPC_RESPONSE_TYPES.STATUS, data: status });
1307
- }, () => ipcServer.hasClients());
1379
+ }, () => ipcServer.hasClients(), async (report, meta = {}) => {
1380
+ try {
1381
+ await handleAgentReport(report || {}, { source: (report && report.source) || "bus" });
1382
+ } catch (err) {
1383
+ log(`report bus event failed request=${meta.requestId || ""} error=${err.message || String(err)}`);
1384
+ }
1385
+ });
1308
1386
 
1309
1387
  handleIpcRequest = async (req, socket) => {
1310
1388
  if (!req || typeof req !== "object") return;
@@ -1399,20 +1477,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1399
1477
  if (req.type === IPC_REQUEST_TYPES.AGENT_REPORT) {
1400
1478
  try {
1401
1479
  const report = req.report && typeof req.report === "object" ? req.report : {};
1402
- const { entry } = await recordAgentReport({
1403
- projectRoot,
1404
- report: {
1405
- ...report,
1406
- source: report.source || "cli",
1407
- },
1408
- onStatus: (status) => {
1409
- ipcServer.sendToSockets({
1410
- type: IPC_RESPONSE_TYPES.STATUS,
1411
- data: status,
1412
- });
1413
- },
1414
- log,
1415
- });
1480
+ const entry = await handleAgentReport(report, { source: report.source || "cli" });
1416
1481
  socket.write(
1417
1482
  `${JSON.stringify({
1418
1483
  type: IPC_RESPONSE_TYPES.RESPONSE,
@@ -1423,19 +1488,6 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1423
1488
  })}
1424
1489
  `,
1425
1490
  );
1426
- ipcServer.sendToSockets({
1427
- type: IPC_RESPONSE_TYPES.BUS,
1428
- data: {
1429
- event: "controller_report",
1430
- publisher: entry.agent_id,
1431
- message: entry.summary || entry.message || entry.task_id,
1432
- report: entry,
1433
- },
1434
- });
1435
- ipcServer.sendToSockets({
1436
- type: IPC_RESPONSE_TYPES.STATUS,
1437
- data: buildRuntimeStatus(),
1438
- });
1439
1491
  } catch (err) {
1440
1492
  socket.write(
1441
1493
  `${JSON.stringify({
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { getUfooPaths } = require("../../coordination/state/paths");
6
+ const {
7
+ appendJSONL,
8
+ ensureDir,
9
+ generateInstanceId,
10
+ } = require("../../coordination/bus/utils");
11
+
12
+ const REPORT_CONTROL_TARGET = "ufoo-agent";
13
+ const REPORT_CONTROL_EVENT = "agent_report";
14
+ const REPORT_CONTROL_TYPE = "control/report";
15
+
16
+ function getReportControlQueueDir(projectRoot) {
17
+ const paths = getUfooPaths(projectRoot);
18
+ return path.join(paths.busDir, "control", "report");
19
+ }
20
+
21
+ function getReportControlQueueFile(projectRoot) {
22
+ return path.join(getReportControlQueueDir(projectRoot), "pending.jsonl");
23
+ }
24
+
25
+ function ensureReportControlQueue(projectRoot) {
26
+ const queueDir = getReportControlQueueDir(projectRoot);
27
+ ensureDir(queueDir);
28
+ return queueDir;
29
+ }
30
+
31
+ function resolveReportPublisher(report = {}) {
32
+ const fromReport = String(report.agent_id || report.agentId || "").trim();
33
+ if (fromReport) return fromReport;
34
+ const fromEnv = String(process.env.UFOO_SUBSCRIBER_ID || "").trim();
35
+ return fromEnv || "unknown-agent";
36
+ }
37
+
38
+ function buildReportControlData(report = {}, options = {}) {
39
+ return {
40
+ request_id: options.requestId || `report-${Date.now().toString(36)}-${generateInstanceId()}`,
41
+ queued_at: options.queuedAt || new Date().toISOString(),
42
+ report,
43
+ };
44
+ }
45
+
46
+ function buildReportControlEvent(report = {}, options = {}) {
47
+ const data = buildReportControlData(report, options);
48
+ return {
49
+ timestamp: data.queued_at,
50
+ type: REPORT_CONTROL_TYPE,
51
+ event: REPORT_CONTROL_EVENT,
52
+ publisher: options.publisher || resolveReportPublisher(report),
53
+ target: REPORT_CONTROL_TARGET,
54
+ data,
55
+ };
56
+ }
57
+
58
+ async function enqueueAgentReport(projectRoot, report, options = {}) {
59
+ ensureReportControlQueue(projectRoot);
60
+
61
+ const event = buildReportControlEvent(report, options);
62
+ appendJSONL(getReportControlQueueFile(projectRoot), event);
63
+
64
+ return {
65
+ queued: true,
66
+ request_id: event.data.request_id,
67
+ target: REPORT_CONTROL_TARGET,
68
+ targets: [REPORT_CONTROL_TARGET],
69
+ report,
70
+ };
71
+ }
72
+
73
+ function takeReportControlEvents(projectRoot) {
74
+ const queueFile = getReportControlQueueFile(projectRoot);
75
+ if (!fs.existsSync(queueFile)) return [];
76
+
77
+ const processingFile = `${queueFile}.processing.${process.pid}.${Date.now()}.${generateInstanceId()}`;
78
+ let content = "";
79
+ let readOk = false;
80
+ try {
81
+ fs.renameSync(queueFile, processingFile);
82
+ content = fs.readFileSync(processingFile, "utf8");
83
+ readOk = true;
84
+ } catch {
85
+ try {
86
+ if (fs.existsSync(processingFile)) {
87
+ fs.renameSync(processingFile, queueFile);
88
+ }
89
+ } catch {
90
+ // ignore rollback errors
91
+ }
92
+ return [];
93
+ } finally {
94
+ if (readOk) {
95
+ try {
96
+ if (fs.existsSync(processingFile)) {
97
+ fs.rmSync(processingFile, { force: true });
98
+ }
99
+ } catch {
100
+ // ignore cleanup errors
101
+ }
102
+ }
103
+ }
104
+
105
+ return content.split(/\r?\n/)
106
+ .filter(Boolean)
107
+ .map((line) => {
108
+ try {
109
+ return JSON.parse(line);
110
+ } catch {
111
+ return null;
112
+ }
113
+ })
114
+ .filter(Boolean);
115
+ }
116
+
117
+ function isAgentReportControlEvent(evt) {
118
+ if (!evt || typeof evt !== "object") return false;
119
+ if (evt.target !== REPORT_CONTROL_TARGET) return false;
120
+ if (evt.type !== REPORT_CONTROL_TYPE) return false;
121
+ if (evt.event !== REPORT_CONTROL_EVENT) return false;
122
+ const data = evt.data && typeof evt.data === "object" ? evt.data : {};
123
+ return Boolean(data.report && typeof data.report === "object");
124
+ }
125
+
126
+ function extractAgentReportControl(evt) {
127
+ if (!isAgentReportControlEvent(evt)) return null;
128
+ const data = evt.data && typeof evt.data === "object" ? evt.data : {};
129
+ return {
130
+ report: data.report,
131
+ request_id: data.request_id || "",
132
+ queued_at: data.queued_at || evt.timestamp || evt.ts || "",
133
+ };
134
+ }
135
+
136
+ module.exports = {
137
+ REPORT_CONTROL_TARGET,
138
+ REPORT_CONTROL_EVENT,
139
+ REPORT_CONTROL_TYPE,
140
+ getReportControlQueueDir,
141
+ getReportControlQueueFile,
142
+ ensureReportControlQueue,
143
+ resolveReportPublisher,
144
+ buildReportControlData,
145
+ buildReportControlEvent,
146
+ enqueueAgentReport,
147
+ takeReportControlEvents,
148
+ isAgentReportControlEvent,
149
+ extractAgentReportControl,
150
+ };
@@ -1010,8 +1010,14 @@ function inferStatusType(text = "", requestedType = "") {
1010
1010
  const type = String(requestedType || "").trim().toLowerCase();
1011
1011
  if (type === "done" || type === "success" || type === "error" || type === "idle") return type;
1012
1012
  const clean = stripBlessedTags(String(text || "")).trim();
1013
- if (/^[✓✔]/.test(clean) || /\bdone\b/i.test(clean) || /\bprocessed\b/i.test(clean)) return "done";
1014
- if (/^[✗!]/.test(clean) || /\berror\b/i.test(clean) || /\bfailed\b/i.test(clean)) return "error";
1013
+ if (/^[✗!]/.test(clean) || /\b(error|failed|failure|offline)\b/i.test(clean) || /失败|错误/.test(clean)) return "error";
1014
+ if (
1015
+ /^[✓✔]/.test(clean) ||
1016
+ /^(done|closed|complete|completed|finished|success|succeeded|ready)\b/i.test(clean) ||
1017
+ /\b(processed|reconnected|switched|saved)\b/i.test(clean) ||
1018
+ /\bdone\s*$/i.test(clean) ||
1019
+ /完成|成功|已处理|已保存|已切换|已连接/.test(clean)
1020
+ ) return "done";
1015
1021
  return type || "typing";
1016
1022
  }
1017
1023
 
@@ -1123,12 +1129,13 @@ function createChatApp({ React, ink, props, interactive = true }) {
1123
1129
  dispatch({ type: "status/idle" });
1124
1130
  return;
1125
1131
  }
1132
+ const type = inferStatusType(clean, options.type || "typing");
1126
1133
  dispatch({
1127
1134
  type: "status/set",
1128
1135
  payload: {
1129
1136
  message: clean,
1130
- type: inferStatusType(clean, options.type || "typing"),
1131
- showTimer: options.showTimer === true,
1137
+ type,
1138
+ showTimer: options.showTimer === true && isAnimatedStatusType(type),
1132
1139
  startedAt: options.startedAt || Date.now(),
1133
1140
  },
1134
1141
  });
@@ -1312,8 +1319,10 @@ function createChatApp({ React, ink, props, interactive = true }) {
1312
1319
 
1313
1320
  useEffect(() => {
1314
1321
  if (!stdout) return undefined;
1315
- const update = () =>
1316
- setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
1322
+ const update = () => {
1323
+ const next = { cols: stdout.columns || 0, rows: stdout.rows || 0 };
1324
+ setSize((prev) => (prev.cols === next.cols && prev.rows === next.rows ? prev : next));
1325
+ };
1317
1326
  update();
1318
1327
  stdout.on("resize", update);
1319
1328
  return () => stdout.off("resize", update);
@@ -1881,7 +1890,8 @@ function createChatApp({ React, ink, props, interactive = true }) {
1881
1890
  useEffect(() => {
1882
1891
  const internalStatus = state.viewingAgentId ? internalStatusLabel(internalAgentView.status) : "ready";
1883
1892
  const internalActive = internalStatus !== "ready";
1884
- const statusAnimated = state.status.message && isAnimatedStatusType(state.status.type);
1893
+ const statusType = inferStatusType(state.status.message, state.status.type);
1894
+ const statusAnimated = state.status.message && isAnimatedStatusType(statusType);
1885
1895
  if ((!statusAnimated) && !internalActive) return undefined;
1886
1896
  const timer = setInterval(() => setSpinnerTick((t) => t + 1), 100);
1887
1897
  return () => clearInterval(timer);
@@ -1922,13 +1932,10 @@ function createChatApp({ React, ink, props, interactive = true }) {
1922
1932
  dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1923
1933
  }
1924
1934
  if (statusText) {
1925
- dispatch({
1926
- type: "status/set",
1927
- payload: { message: statusText, type: "typing", showTimer: false, startedAt: Date.now() },
1928
- });
1935
+ setStatusText(statusText, { type: "typing", showTimer: false });
1929
1936
  }
1930
1937
  if (restart) restartDaemonBestEffort();
1931
- }, [restartDaemonBestEffort]);
1938
+ }, [restartDaemonBestEffort, setStatusText]);
1932
1939
 
1933
1940
  const clearUfooAgentIdentity = useCallback(() => {
1934
1941
  try {
@@ -3501,7 +3508,7 @@ function buildDashHints(state, targetAgentLabel) {
3501
3508
  function computeStatusText(status, spinnerTick) {
3502
3509
  const message = String((status && status.message) || "");
3503
3510
  if (!message) return "CHAT · Ready";
3504
- const type = String((status && status.type) || "thinking");
3511
+ const type = inferStatusType(message, status && status.type);
3505
3512
  if (type === "done" || type === "success") {
3506
3513
  const clean = stripBlessedTags(message).trim();
3507
3514
  return /^[✓✔]/.test(clean) ? clean : `✓ ${clean}`;
@@ -482,11 +482,19 @@ function createMultilineInput({ React, ink }) {
482
482
  return undefined;
483
483
  }
484
484
  patchStdoutForIME(out);
485
+ const targetRowsUp = __imeCursor.lastFrameHadNewline
486
+ ? rowsBelowCursor
487
+ : Math.max(0, rowsBelowCursor - 1);
488
+ const alreadyParked = __imeCursor.active === true
489
+ && __imeCursor.parkRowsUp === rowsBelowCursor
490
+ && __imeCursor.parkCol === cursorTermCol
491
+ && __imeCursor.movedUpRows === targetRowsUp;
485
492
  // Publish the desired park target so the stdout monkey-patch can
486
493
  // re-park after every throttled ink frame write.
487
494
  __imeCursor.active = true;
488
495
  __imeCursor.parkRowsUp = rowsBelowCursor;
489
496
  __imeCursor.parkCol = cursorTermCol;
497
+ if (alreadyParked) return undefined;
490
498
  // Park immediately — covers cases where ink has nothing to render
491
499
  // (output unchanged) and won't fire a frame write at all, and keeps
492
500
  // the caret visible between frames. Combine hide + restore + park +
@@ -496,7 +504,7 @@ function createMultilineInput({ React, ink }) {
496
504
  // CRITICAL: the move-up amount must match the anchor that movedUpRows
497
505
  // was measured against. If the last frame ended without '\n' (the
498
506
  // full-screen path), the anchor is one row higher than the log-update
499
- // case, so we use rowsUpFromAnchor() rather than parkRowsUp directly.
507
+ // case, so we use targetRowsUp rather than parkRowsUp directly.
500
508
  // Otherwise restoring down by movedUpRows then moving up parkRowsUp
501
509
  // overshoots by one and leaves the hardware cursor one row above the
502
510
  // inverse caret — the residual "ghost cursor" symptom.
@@ -505,7 +513,7 @@ function createMultilineInput({ React, ink }) {
505
513
  combined += `\x1b[${__imeCursor.movedUpRows}B`;
506
514
  __imeCursor.movedUpRows = 0;
507
515
  }
508
- combined += applyParkSequence(rowsUpFromAnchor());
516
+ combined += applyParkSequence(targetRowsUp);
509
517
  out.write(combined);
510
518
  return undefined;
511
519
  });
@@ -643,8 +643,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
643
643
 
644
644
  useEffect(() => {
645
645
  if (!stdout) return undefined;
646
- const update = () =>
647
- setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
646
+ const update = () => {
647
+ const next = { cols: stdout.columns || 0, rows: stdout.rows || 0 };
648
+ setSize((prev) => (prev.cols === next.cols && prev.rows === next.rows ? prev : next));
649
+ };
648
650
  update();
649
651
  stdout.on("resize", update);
650
652
  return () => stdout.off("resize", update);
@@ -652,7 +654,11 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
652
654
 
653
655
  // Drive the spinner + elapsed-timer redraws while a task is in flight.
654
656
  useEffect(() => {
655
- if (!status.message || status.type === "none") return undefined;
657
+ const statusType = inferStatusType(status.message, status.type);
658
+ if (!status.message || statusType === "none" || statusType === "idle" ||
659
+ statusType === "done" || statusType === "success" || statusType === "error") {
660
+ return undefined;
661
+ }
656
662
  const timer = setInterval(() => {
657
663
  setSpinnerTick((t) => t + 1);
658
664
  if (status.showTimer) setNowTick((t) => t + 1);
@@ -792,6 +798,22 @@ function runUcodeInkTui(props = {}) {
792
798
 
793
799
  module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText };
794
800
 
801
+ function inferStatusType(text = "", requestedType = "") {
802
+ const type = String(requestedType || "").trim().toLowerCase();
803
+ if (type === "done" || type === "success" || type === "error" || type === "idle" || type === "none") {
804
+ return type;
805
+ }
806
+ const clean = String(text || "").trim();
807
+ if (/^[✗!]/.test(clean) || /\b(error|failed|failure)\b/i.test(clean) || /失败|错误/.test(clean)) return "error";
808
+ if (
809
+ /^[✓✔]/.test(clean) ||
810
+ /^(done|complete|completed|finished|success|succeeded|ready)\b/i.test(clean) ||
811
+ /\bdone\s*$/i.test(clean) ||
812
+ /完成|成功/.test(clean)
813
+ ) return "done";
814
+ return type || "thinking";
815
+ }
816
+
795
817
  /**
796
818
  * Pure status-line text builder used by the React component (and unit
797
819
  * tests). Returns "UCODE · Ready" while idle and a spinner+message+timer
@@ -802,7 +824,16 @@ function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
802
824
  const message = String((status && status.message) || "");
803
825
  const suffix = String(backgroundSuffix || "");
804
826
  if (!message) return `UCODE · Ready${suffix}`;
805
- const type = String((status && status.type) || "thinking");
827
+ const type = inferStatusType(message, status && status.type);
828
+ if (type === "done" || type === "success") {
829
+ const clean = message.trim();
830
+ return `${/^[✓✔]/.test(clean) ? clean : `✓ ${clean}`}${suffix}`;
831
+ }
832
+ if (type === "error") {
833
+ const clean = message.trim();
834
+ return `${/^[✗!]/.test(clean) ? clean : `✗ ${clean}`}${suffix}`;
835
+ }
836
+ if (type === "idle" || type === "none") return `${message.trim() || "UCODE · Ready"}${suffix}`;
806
837
  const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
807
838
  const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
808
839
  const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
@@ -48,6 +48,53 @@ function projectRootOf(row = {}) {
48
48
  return String((row && (row.root || row.project_root || row.projectRoot)) || "");
49
49
  }
50
50
 
51
+ function stableJson(value) {
52
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
53
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
54
+ const keys = Object.keys(value).sort();
55
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
56
+ }
57
+
58
+ function shallowArrayEqual(left = [], right = []) {
59
+ if (left === right) return true;
60
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
61
+ if (left.length !== right.length) return false;
62
+ for (let i = 0; i < left.length; i += 1) {
63
+ if (left[i] !== right[i]) return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ function listPayloadEqual(left = [], right = []) {
69
+ if (left === right) return true;
70
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
71
+ if (left.length !== right.length) return false;
72
+ for (let i = 0; i < left.length; i += 1) {
73
+ if (stableJson(left[i]) !== stableJson(right[i])) return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ function mapPayloadEqual(left, right) {
79
+ if (left === right) return true;
80
+ if (!(left instanceof Map) || !(right instanceof Map)) return false;
81
+ if (left.size !== right.size) return false;
82
+ for (const [key, value] of left.entries()) {
83
+ if (!right.has(key)) return false;
84
+ if (stableJson(value) !== stableJson(right.get(key))) return false;
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function statusPayloadEqual(left, right) {
90
+ const normalize = (value = {}) => {
91
+ const normalized = { ...value };
92
+ if (normalized.showTimer !== true) normalized.startedAt = 0;
93
+ return normalized;
94
+ };
95
+ return stableJson(normalize(left)) === stableJson(normalize(right));
96
+ }
97
+
51
98
  function createInitialState({ banner = [], globalMode = false, globalScope = "controller", settings = {} } = {}) {
52
99
  const initialLaunchMode = settings.launchMode || "auto";
53
100
  const initialAgentProvider = settings.agentProvider || "codex-cli";
@@ -174,6 +221,14 @@ function reducer(state, action) {
174
221
  } else if (nextIdx >= ids.length) {
175
222
  nextIdx = ids.length - 1;
176
223
  }
224
+ if (
225
+ shallowArrayEqual(state.agents, ids) &&
226
+ mapPayloadEqual(state.activeAgentMeta, meta) &&
227
+ state.selectedAgentIndex === nextIdx &&
228
+ state.agentSelectionMode === nextMode
229
+ ) {
230
+ return state;
231
+ }
177
232
  return {
178
233
  ...state,
179
234
  agents: ids,
@@ -216,12 +271,22 @@ function reducer(state, action) {
216
271
  const selectedIndex = selectedRoot
217
272
  ? list.findIndex((row) => projectRootOf(row) === selectedRoot)
218
273
  : -1;
274
+ const nextActiveRoot = action.activeProjectRoot || state.activeProjectRoot;
275
+ if (
276
+ listPayloadEqual(state.projects, list) &&
277
+ state.selectedProjectRoot === (selectedIndex >= 0 ? selectedRoot : "") &&
278
+ state.selectedProjectIndex === selectedIndex &&
279
+ state.activeProjectRoot === nextActiveRoot &&
280
+ state.emptyProjectsDownArmed === (list.length === 0 ? state.emptyProjectsDownArmed : false)
281
+ ) {
282
+ return state;
283
+ }
219
284
  return {
220
285
  ...state,
221
286
  projects: list,
222
287
  selectedProjectRoot: selectedIndex >= 0 ? selectedRoot : "",
223
288
  selectedProjectIndex: selectedIndex,
224
- activeProjectRoot: action.activeProjectRoot || state.activeProjectRoot,
289
+ activeProjectRoot: nextActiveRoot,
225
290
  emptyProjectsDownArmed: list.length === 0 ? state.emptyProjectsDownArmed : false,
226
291
  };
227
292
  }
@@ -240,8 +305,11 @@ function reducer(state, action) {
240
305
  return { ...state, projectListWindowStart: Math.max(0, action.windowStart | 0) };
241
306
  case "scope/set":
242
307
  return { ...state, globalScope: action.scope === "project" ? "project" : "controller" };
243
- case "status/set":
244
- return { ...state, status: { ...state.status, ...action.payload } };
308
+ case "status/set": {
309
+ const nextStatus = { ...state.status, ...action.payload };
310
+ if (statusPayloadEqual(state.status, nextStatus)) return state;
311
+ return { ...state, status: nextStatus };
312
+ }
245
313
  case "status/idle":
246
314
  return { ...state, status: { message: "", type: "thinking", showTimer: false, startedAt: 0 } };
247
315
  case "history/push": {
@@ -311,10 +379,16 @@ function reducer(state, action) {
311
379
  return { ...state, selectedProviderIndex: Math.max(0, action.index | 0) };
312
380
  case "cronIndex/set":
313
381
  return { ...state, selectedCronIndex: Math.max(-1, action.index | 0) };
314
- case "cron/set":
315
- return { ...state, cronTasks: Array.isArray(action.list) ? action.list : [] };
316
- case "loop/set":
317
- return { ...state, loopSummary: action.summary && typeof action.summary === "object" ? action.summary : null };
382
+ case "cron/set": {
383
+ const list = Array.isArray(action.list) ? action.list : [];
384
+ if (listPayloadEqual(state.cronTasks, list)) return state;
385
+ return { ...state, cronTasks: list };
386
+ }
387
+ case "loop/set": {
388
+ const summary = action.summary && typeof action.summary === "object" ? action.summary : null;
389
+ if (stableJson(state.loopSummary) === stableJson(summary)) return state;
390
+ return { ...state, loopSummary: summary };
391
+ }
318
392
  case "stream/begin":
319
393
  return {
320
394
  ...state,