u-foo 3.0.8 → 3.0.9

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": "3.0.8",
3
+ "version": "3.0.9",
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",
@@ -315,7 +315,6 @@ class AgentNotifier {
315
315
  if (this.stopped) return;
316
316
 
317
317
  const currentCount = this.getMessageCount();
318
- const nowMs = Date.now();
319
318
 
320
319
  // 有新消息
321
320
  if (currentCount > this.lastCount) {
@@ -330,14 +329,22 @@ class AgentNotifier {
330
329
  }
331
330
 
332
331
  this.lastCount = this.getMessageCount();
333
- if (this._launcherReady && (!this.lastWorkingAt || nowMs - this.lastWorkingAt >= this.workingHoldMs)) {
332
+ // Delivery moved to the daemon scheduler. ActivityDetector owns
333
+ // working → idle / waiting_input. Never force-idle over working here:
334
+ // lastWorkingAt is only set by the legacy deliverPending path, so the
335
+ // old hold-window check stayed permanently true and stomped real
336
+ // working states every poll (~2s) — Codex injections then slipped mid-turn.
337
+ if (this._launcherReady) {
334
338
  const currentActivityState = this.getCurrentActivityState();
335
- if (currentActivityState !== "waiting_input" && currentActivityState !== "blocked") {
336
- if (currentActivityState === "working") {
337
- this.updateActivityState("idle", { force: true });
338
- } else {
339
- this.updateActivityState("idle");
340
- }
339
+ if (
340
+ currentActivityState
341
+ && currentActivityState !== "working"
342
+ && currentActivityState !== "waiting_input"
343
+ && currentActivityState !== "blocked"
344
+ ) {
345
+ // Soft fallback only (no force): ready/starting → idle for delivery,
346
+ // without overriding detector-owned busy states.
347
+ this.updateActivityState("idle");
341
348
  }
342
349
  }
343
350
  this.refreshTitle();
@@ -58,6 +58,14 @@ function hasPendingUserPrompts(executionState = null) {
58
58
  return state.pendingUserPrompts.length > 0;
59
59
  }
60
60
 
61
+ /** Peek pending nudge texts without draining (for TUI queue banner). */
62
+ function listPendingUserPrompts(executionState = null) {
63
+ const state = ensurePendingUserPrompts(executionState);
64
+ return state.pendingUserPrompts
65
+ .map((entry) => String(entry && entry.text || "").trim())
66
+ .filter(Boolean);
67
+ }
68
+
61
69
  function shouldFrameAsUserReminder(executionState = null) {
62
70
  if (!executionState || typeof executionState !== "object") return false;
63
71
  if (executionState.planMode === true) return true;
@@ -160,6 +168,7 @@ module.exports = {
160
168
  drainUserPrompts,
161
169
  clearUserPrompts,
162
170
  hasPendingUserPrompts,
171
+ listPendingUserPrompts,
163
172
  shouldFrameAsUserReminder,
164
173
  formatUserReminderMessage,
165
174
  buildContinuationUserPrompt,
@@ -5,6 +5,7 @@ const Injector = require("../../coordination/bus/inject");
5
5
  const { buildPromptInjectionText } = require("../../coordination/bus/promptEnvelope");
6
6
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
7
7
  const { normalizeQueueEnvelope } = require("../../coordination/bus/deliveryQueue");
8
+ const { writeActivityState } = require("../../agents/activity/activityStateWriter");
8
9
 
9
10
  function asState(value = "") {
10
11
  return String(value || "").trim().toLowerCase();
@@ -58,6 +59,14 @@ class DeliveryScheduler {
58
59
  this.emitDelivery = typeof options.emitDelivery === "function"
59
60
  ? options.emitDelivery
60
61
  : async () => {};
62
+ this.markWorking = typeof options.markWorking === "function"
63
+ ? options.markWorking
64
+ : (subscriber) => {
65
+ writeActivityState(this.paths.agentsFile, subscriber, "working", {
66
+ force: true,
67
+ detail: "inject",
68
+ });
69
+ };
61
70
  this.log = typeof options.log === "function" ? options.log : () => {};
62
71
  this.now = typeof options.now === "function" ? options.now : () => Date.now();
63
72
  this.deferWarnAfterMs = positiveMs(options.deferWarnAfterMs, DEFAULT_DEFER_WARN_AFTER_MS);
@@ -224,6 +233,15 @@ class DeliveryScheduler {
224
233
  try {
225
234
  await this.injector.inject(subscriber, injectionText);
226
235
  queue.completeClaim(claim);
236
+ // Close the idle gate immediately. PTY ActivityDetector will refresh
237
+ // working from output, then quiet-window back to idle; without this
238
+ // stamp a second pending message can slip through on the next tick
239
+ // before any Codex stdout arrives.
240
+ try {
241
+ this.markWorking(subscriber);
242
+ } catch {
243
+ // activity stamp must never undo a successful inject
244
+ }
227
245
  await this.emitDelivery({
228
246
  subscriber,
229
247
  event: envelope,
@@ -732,6 +732,86 @@ function wrapInternalPlainLine(text = "", width = 80) {
732
732
  return rows;
733
733
  }
734
734
 
735
+ // Ink's wrap:"wrap" + <Static> under-counts CJK row height on live appends,
736
+ // so later log writes overpaint the previous line. Pre-wrap by display cells
737
+ // and paint with wrap:"truncate" so each Static item occupies a known height.
738
+ function expandChatLogPhysicalLines(text = "", width = 80) {
739
+ const limit = Math.max(1, Math.floor(Number(width) || 80));
740
+ const source = String(text || "").replace(/\r/g, "");
741
+ if (!source) return [""];
742
+ const out = [];
743
+ for (const line of source.split("\n")) {
744
+ out.push(...wrapInternalPlainLine(line, limit));
745
+ }
746
+ return out.length > 0 ? out : [""];
747
+ }
748
+
749
+ function padDisplayCells(cells = 0) {
750
+ return " ".repeat(Math.max(0, Math.floor(Number(cells) || 0)));
751
+ }
752
+
753
+ function splitUserLogAtMention(bodyText = "") {
754
+ const body = String(bodyText || "");
755
+ const atMatch = body.match(/^@([^\s]+)\s+(.*)$/);
756
+ if (atMatch) {
757
+ return { at: atMatch[1], rest: atMatch[2] || "" };
758
+ }
759
+ return { at: "", rest: body };
760
+ }
761
+
762
+ /**
763
+ * Flatten one chat log row into terminal-width physical lines. Each returned
764
+ * line is a single string (marker/speaker/body already merged) so Ink never
765
+ * has to wrap it — critical for <Static> append-only CJK safety.
766
+ */
767
+ function buildChatLogDisplayLines(row = {}, options = {}) {
768
+ const cols = Math.max(8, Math.floor(Number(options.cols) || 80));
769
+ const continuation = Boolean(options.continuation);
770
+ const groupKind = options.groupKind || row.kind || "plain";
771
+ const kind = row.kind || "plain";
772
+
773
+ if (kind === "spacer") return [" "];
774
+ if (kind === "divider") {
775
+ return [fitPlainLine(` ${compactDividerLabel(row.body || row.bodyText || "")}`, cols)];
776
+ }
777
+ if (kind === "banner") {
778
+ return expandChatLogPhysicalLines(stripInternalLogMarkup(row.bodyText || row.body || ""), cols)
779
+ .map((line) => fitPlainLine(line, cols));
780
+ }
781
+
782
+ const markerText = continuation
783
+ ? (groupKind === "assistant" || groupKind === "agent" || groupKind === "report" ? " " : " ")
784
+ : String(row.markerText != null ? row.markerText : "");
785
+
786
+ if (kind === "user") {
787
+ const userBody = splitUserLogAtMention(row.bodyText || row.body || "");
788
+ const atPrefix = userBody.at ? `@${userBody.at} ` : "";
789
+ const firstPrefix = `${markerText || "› "}${atPrefix}`;
790
+ const prefixCells = fmt.displayCellWidth(firstPrefix);
791
+ const budget = Math.max(1, cols - prefixCells);
792
+ const chunks = expandChatLogPhysicalLines(userBody.rest, budget);
793
+ const contPad = padDisplayCells(prefixCells);
794
+ return chunks.map((chunk, idx) => {
795
+ const line = idx === 0 ? `${firstPrefix}${chunk}` : `${contPad}${chunk}`;
796
+ return fitPlainLine(line, cols);
797
+ });
798
+ }
799
+
800
+ const speakerPrefix = (!continuation && row.speaker)
801
+ ? `${row.speaker} · `
802
+ : "";
803
+ const head = `${markerText}${speakerPrefix}`;
804
+ const headCells = fmt.displayCellWidth(head);
805
+ const budget = Math.max(1, cols - headCells);
806
+ const bodyPlain = stripInternalLogMarkup(row.bodyText != null ? row.bodyText : (row.body || ""));
807
+ const chunks = expandChatLogPhysicalLines(bodyPlain, budget);
808
+ const contPad = padDisplayCells(headCells);
809
+ return chunks.map((chunk, idx) => {
810
+ const line = idx === 0 ? `${head}${chunk}` : `${contPad}${chunk}`;
811
+ return fitPlainLine(line, cols);
812
+ });
813
+ }
814
+
735
815
  function classifyInternalLogLine(line = "") {
736
816
  const raw = stripInternalLogMarkup(line).replace(/\r/g, "");
737
817
  if (!raw) return { kind: "spacer", text: "", markdown: false, bold: false };
@@ -3580,16 +3660,44 @@ function createChatApp({ React, ink, props, interactive = true }) {
3580
3660
  return null;
3581
3661
  }
3582
3662
 
3583
- const renderUserLogBody = (bodyText = "") => {
3584
- const body = String(bodyText || "");
3585
- const atMatch = body.match(/^@([^\s]+)\s+(.*)$/);
3586
- if (atMatch) {
3587
- return {
3588
- at: atMatch[1],
3589
- rest: atMatch[2] || "",
3590
- };
3663
+ const renderChatLogLines = (row, { key, continuation = false, groupKind = "", marginTop = 0, marginBottom = 0 } = {}) => {
3664
+ const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3665
+ const cols = Math.max(20, size.cols || 80);
3666
+ const lines = buildChatLogDisplayLines(row, {
3667
+ continuation,
3668
+ groupKind: groupKind || row.kind,
3669
+ cols,
3670
+ });
3671
+ const textProps = {
3672
+ color: row.kind === "user" ? "green" : colors.body,
3673
+ bold: Boolean(
3674
+ row.kind === "user"
3675
+ || colors.bold
3676
+ || row.kind === "error"
3677
+ || row.kind === "assistant"
3678
+ || row.kind === "banner"
3679
+ ),
3680
+ wrap: "truncate",
3681
+ };
3682
+ if (colors.dim) textProps.dimColor = true;
3683
+ if (row.kind === "agent" || row.kind === "report") {
3684
+ textProps.color = colors.speaker;
3591
3685
  }
3592
- return { at: "", rest: body };
3686
+ if (lines.length <= 1) {
3687
+ return h(Box, { key, width: "100%", marginTop, marginBottom },
3688
+ h(Text, textProps, (lines[0] != null ? lines[0] : " ") || " "));
3689
+ }
3690
+ return h(Box, {
3691
+ key,
3692
+ flexDirection: "column",
3693
+ width: "100%",
3694
+ marginTop,
3695
+ marginBottom,
3696
+ },
3697
+ ...lines.map((line, idx) => h(Text, {
3698
+ key: `${key}-r${idx}`,
3699
+ ...textProps,
3700
+ }, line || " ")));
3593
3701
  };
3594
3702
 
3595
3703
  const renderChatLogEntry = (entry, group) => {
@@ -3598,53 +3706,12 @@ function createChatApp({ React, ink, props, interactive = true }) {
3598
3706
  if (row.kind === "spacer") {
3599
3707
  return h(Text, { key, color: "gray" }, " ");
3600
3708
  }
3601
- const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3602
- if (row.kind === "divider") {
3603
- return h(Box, { key, marginBottom: 1 },
3604
- h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
3605
- );
3606
- }
3607
- if (row.kind === "banner") {
3608
- return h(Box, { key },
3609
- h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3610
- );
3611
- }
3612
- if (row.kind === "user") {
3613
- const userBody = renderUserLogBody(row.bodyText);
3614
- return h(Box, { key, width: "100%", marginBottom: 1, alignItems: "flex-start" },
3615
- h(Text, { color: "green", bold: true }, row.markerText || "› "),
3616
- userBody.at
3617
- ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
3618
- : null,
3619
- h(Text, { color: "green", bold: true, wrap: "wrap" }, userBody.rest),
3620
- );
3621
- }
3622
- const markerText = entry && entry.continuation
3623
- ? (group && (group.kind === "assistant" || group.kind === "agent" || group.kind === "report") ? " " : " ")
3624
- : row.markerText;
3625
- const bodyProps = {
3626
- color: colors.body,
3627
- wrap: "wrap",
3628
- };
3629
- if (colors.dim) bodyProps.dimColor = true;
3630
- // Pin the gutter glyph to the first text line; default Yoga stretch/center
3631
- // floats markers above wrapped speaker · body rows.
3632
- return h(Box, { key, width: "100%", alignItems: "flex-start" },
3633
- h(Text, {
3634
- color: colors.marker,
3635
- bold: row.kind === "error" || row.kind === "assistant",
3636
- dimColor: Boolean(colors.dim),
3637
- }, markerText),
3638
- h(Text, bodyProps,
3639
- row.speaker && !(entry && entry.continuation)
3640
- ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3641
- : null,
3642
- row.speaker && !(entry && entry.continuation)
3643
- ? h(Text, { color: "gray" }, " · ")
3644
- : null,
3645
- row.bodyText,
3646
- ),
3647
- );
3709
+ return renderChatLogLines(row, {
3710
+ key,
3711
+ continuation: Boolean(entry && entry.continuation),
3712
+ groupKind: group && group.kind ? group.kind : row.kind,
3713
+ marginBottom: row.kind === "user" || row.kind === "divider" ? 1 : 0,
3714
+ });
3648
3715
  };
3649
3716
 
3650
3717
  const renderChatLogGroup = (group) => {
@@ -3664,64 +3731,23 @@ function createChatApp({ React, ink, props, interactive = true }) {
3664
3731
  ...entries.map((entry) => renderChatLogEntry(entry, group)));
3665
3732
  };
3666
3733
 
3667
- // Renderer for one finalized (append-only) <Static> log item. Mirrors
3668
- // renderChatLogEntry visually; spacing differs because per-item
3669
- // rendering can't wrap a group in one margin-bottom Box — instead the
3670
- // decoration pass flags `marginBefore` on whatever entry follows a
3671
- // transcript group.
3734
+ // Renderer for one finalized (append-only) <Static> log item. Spacing
3735
+ // uses decorateStaticLogEntry's marginBefore; body text is pre-wrapped to
3736
+ // the terminal width so Ink never wrap:"wrap"s CJK inside Static.
3672
3737
  const renderStaticChatLogItem = (item) => {
3673
3738
  const { row, groupKind, continuation, marginBefore } = item;
3674
3739
  const key = item.entry && item.entry.id ? item.entry.id : `log-${row.body}`;
3675
- const marginTop = marginBefore ? 1 : 0;
3676
3740
  if (row.kind === "spacer") {
3677
- return h(Box, { key, marginTop },
3741
+ return h(Box, { key, marginTop: marginBefore ? 1 : 0 },
3678
3742
  h(Text, { color: "gray" }, " "));
3679
3743
  }
3680
- const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
3681
- if (row.kind === "divider") {
3682
- return h(Box, { key, marginTop, marginBottom: 1 },
3683
- h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
3684
- );
3685
- }
3686
- if (row.kind === "banner") {
3687
- return h(Box, { key, marginTop },
3688
- h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3689
- );
3690
- }
3691
- if (row.kind === "user") {
3692
- const userBody = renderUserLogBody(row.bodyText);
3693
- return h(Box, { key, width: "100%", marginTop, marginBottom: 1, alignItems: "flex-start" },
3694
- h(Text, { color: "green", bold: true }, row.markerText || "› "),
3695
- userBody.at
3696
- ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
3697
- : null,
3698
- h(Text, { color: "green", bold: true, wrap: "wrap" }, userBody.rest),
3699
- );
3700
- }
3701
- const markerText = continuation
3702
- ? (groupKind === "assistant" || groupKind === "agent" || groupKind === "report" ? " " : " ")
3703
- : row.markerText;
3704
- const bodyProps = {
3705
- color: colors.body,
3706
- wrap: "wrap",
3707
- };
3708
- if (colors.dim) bodyProps.dimColor = true;
3709
- return h(Box, { key, width: "100%", marginTop, alignItems: "flex-start" },
3710
- h(Text, {
3711
- color: colors.marker,
3712
- bold: row.kind === "error" || row.kind === "assistant",
3713
- dimColor: Boolean(colors.dim),
3714
- }, markerText),
3715
- h(Text, bodyProps,
3716
- row.speaker && !continuation
3717
- ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3718
- : null,
3719
- row.speaker && !continuation
3720
- ? h(Text, { color: "gray" }, " · ")
3721
- : null,
3722
- row.bodyText,
3723
- ),
3724
- );
3744
+ return renderChatLogLines(row, {
3745
+ key,
3746
+ continuation,
3747
+ groupKind,
3748
+ marginTop: marginBefore ? 1 : 0,
3749
+ marginBottom: row.kind === "user" || row.kind === "divider" ? 1 : 0,
3750
+ });
3725
3751
  };
3726
3752
 
3727
3753
  if (state.viewingAgentId) {
@@ -4099,6 +4125,8 @@ module.exports = {
4099
4125
  createInkStreamState,
4100
4126
  createThrottledSender,
4101
4127
  decorateStaticLogEntry,
4128
+ buildChatLogDisplayLines,
4129
+ expandChatLogPhysicalLines,
4102
4130
  bootstrapEnvironment,
4103
4131
  buildDirectBusSendRequest,
4104
4132
  buildPromptIpcRequest,
@@ -96,6 +96,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
96
96
  hash: "",
97
97
  }));
98
98
  const [interactionLines, setInteractionLines] = useState([]);
99
+ // Bumps when pendingUserPrompts change so the near-input queue banner
100
+ // re-renders without dumping a chat-log system line.
101
+ const [queueTick, setQueueTick] = useState(0);
102
+ const bumpQueue = useCallback(() => setQueueTick((n) => n + 1), []);
99
103
  const [spinnerTick, setSpinnerTick] = useState(0);
100
104
  const [size, setSize] = useState({ cols: 0, rows: 0 });
101
105
  const [contextMeter, setContextMeter] = useState(() => {
@@ -903,6 +907,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
903
907
  cancelThinkingFlush();
904
908
  thinkingTailRef.current = "";
905
909
  refreshPlanUi();
910
+ bumpQueue();
906
911
  setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
907
912
  }
908
913
  if (streamBuf) {
@@ -942,7 +947,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
942
947
  default:
943
948
  if (result.output) appendLogText(result.output);
944
949
  }
945
- }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge, flushTableBuffer, refreshPlanUi]);
950
+ }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge, flushTableBuffer, refreshPlanUi, bumpQueue]);
946
951
  // ^ `props` is captured by the createUcodeApp closure on a single mount,
947
952
  // so its reference is stable across renders even though it looks like a
948
953
  // changing dep to React's exhaustive-deps lint.
@@ -1153,14 +1158,8 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1153
1158
  if (!props.state.executionState || typeof props.state.executionState !== "object") {
1154
1159
  props.state.executionState = emptyExecutionState();
1155
1160
  }
1156
- const queued = enqueueUserPrompt(props.state.executionState, modelText);
1157
- const reminderPreview = logText.slice(0, 120) + (logText.length > 120 ? "…" : "");
1158
- appendLogText(
1159
- queued.enqueued
1160
- ? `Queued user reminder for next model turn: ${reminderPreview}`
1161
- : "Could not queue user reminder (empty).",
1162
- "system",
1163
- );
1161
+ enqueueUserPrompt(props.state.executionState, modelText);
1162
+ bumpQueue();
1164
1163
  return;
1165
1164
  }
1166
1165
 
@@ -1180,6 +1179,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1180
1179
  appendLogLine,
1181
1180
  flushActiveMerge,
1182
1181
  flushTableBuffer,
1182
+ bumpQueue,
1183
1183
  props.state,
1184
1184
  props.submitUserInteractionAnswer,
1185
1185
  refreshPlanUi,
@@ -1410,6 +1410,25 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1410
1410
  ),
1411
1411
  )
1412
1412
  : null,
1413
+ (() => {
1414
+ void queueTick;
1415
+ let pending = [];
1416
+ try {
1417
+ const { listPendingUserPrompts } = require("../../code/context/userNudge");
1418
+ pending = listPendingUserPrompts(props.state && props.state.executionState);
1419
+ } catch {
1420
+ pending = [];
1421
+ }
1422
+ if (pending.length === 0) return null;
1423
+ const latest = String(pending[pending.length - 1] || "");
1424
+ const more = pending.length > 1 ? ` · +${pending.length - 1}` : "";
1425
+ const preview = latest.length > 72 ? `${latest.slice(0, 72)}…` : latest;
1426
+ return h(Box, { width: "100%" },
1427
+ h(Text, { color: "yellow", wrap: "truncate" },
1428
+ `排队中 · 未发出 · ${preview}${more}`,
1429
+ ),
1430
+ );
1431
+ })(),
1413
1432
  h(Box, { width: "100%" },
1414
1433
  h(MultilineInput, {
1415
1434
  value: draft,
@@ -1469,6 +1488,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
1469
1488
  if (props.state && props.state.executionState) {
1470
1489
  clearUserPrompts(props.state.executionState);
1471
1490
  }
1491
+ bumpQueue();
1472
1492
  } catch { /* ignore */ }
1473
1493
  appendLogLine("⚙ Cancellation requested. Stopping the current task...", "system");
1474
1494
  setStatus({