u-foo 3.0.6 โ†’ 3.0.7

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.
@@ -33,9 +33,13 @@ const {
33
33
  const {
34
34
  drainAgentMailboxForTurn,
35
35
  shouldAutoContinueForTaskWake,
36
- buildTaskRunWakeReminder,
37
36
  listTaskRunsAwaitingModel,
38
37
  } = require("./runtime/agentWakeup");
38
+ const {
39
+ shouldIsolateTaskFocusTurn,
40
+ buildIsolatedTaskFocusTurn,
41
+ sanitizeToolResultForModel,
42
+ } = require("./runtime/taskFocusContext");
39
43
  const {
40
44
  runAskUserTool,
41
45
  syncInteractionFromPlanGraph,
@@ -185,6 +189,7 @@ function createUsageTotals() {
185
189
  output: 0,
186
190
  cacheRead: 0,
187
191
  cacheCreation: 0,
192
+ lastContextTokens: 0,
188
193
  };
189
194
  }
190
195
 
@@ -889,6 +894,8 @@ function runCoreTool({
889
894
  return {
890
895
  ...result.modelPayload,
891
896
  ok: result.status === "accepted",
897
+ // Keep executionState for the runner only; sanitizeToolResultForModel
898
+ // strips it before the payload enters provider messages.
892
899
  executionState: result.executionState || state,
893
900
  };
894
901
  }
@@ -1694,7 +1701,7 @@ function shadowResolvePending(ledger, pending, toolResult) {
1694
1701
  const callId = pendingToolCallId(pending);
1695
1702
  if (!callId) return;
1696
1703
  resolveCall(ledger, callId, {
1697
- result: toolResult,
1704
+ result: sanitizeToolResultForModel(toolResult),
1698
1705
  isError: Boolean(!toolResult || toolResult.ok === false),
1699
1706
  });
1700
1707
  }
@@ -1733,6 +1740,7 @@ async function runNativeLoop({
1733
1740
  onPhase = null,
1734
1741
  onToolEvent = null,
1735
1742
  onArtifactPersisted = null,
1743
+ onContextUsage = null,
1736
1744
  sessionId = "",
1737
1745
  signal = null,
1738
1746
  guards,
@@ -1743,12 +1751,31 @@ async function runNativeLoop({
1743
1751
  if (!requestModel) {
1744
1752
  throw new Error("ucode model is not configured");
1745
1753
  }
1754
+ const {
1755
+ contextTokensFromUsage,
1756
+ buildContextMeter,
1757
+ } = require("./contextWindow");
1758
+ function emitContextUsage(turnUsage = null) {
1759
+ const contextTokens = contextTokensFromUsage(turnUsage);
1760
+ usage.lastContextTokens = contextTokens;
1761
+ if (typeof onContextUsage !== "function") return;
1762
+ try {
1763
+ onContextUsage(buildContextMeter({
1764
+ usedTokens: contextTokens,
1765
+ model: requestModel,
1766
+ }));
1767
+ } catch {
1768
+ // ignore UI callback failures
1769
+ }
1770
+ }
1746
1771
 
1747
1772
  const requestUrl = transport.resolveUrl(baseUrl);
1748
1773
  if (!requestUrl) {
1749
1774
  throw new Error("ucode baseUrl is not configured");
1750
1775
  }
1751
1776
 
1777
+ // Durable Agent Loop transcript (returned + synced to session). Provider
1778
+ // turns that serve TaskFocus use a fresh providerMessages list instead.
1752
1779
  const messages = sanitizeModelMessages(cloneMessageList(historyMessages));
1753
1780
  if (!resume) {
1754
1781
  transport.prepareMessages({ messages, systemPrompt, prompt });
@@ -1772,6 +1799,9 @@ async function runNativeLoop({
1772
1799
  let planAutoContinues = 0;
1773
1800
  let lastAutoContinueWaitingId = "";
1774
1801
  let consecutiveEmptyAutoContinues = 0;
1802
+ let providerMessages = messages;
1803
+ let isolatedActive = false;
1804
+ let isolatedPrefixLength = 0;
1775
1805
 
1776
1806
  if (resume) {
1777
1807
  await withFaultPoint("before_provider_resume", () => {});
@@ -1779,20 +1809,55 @@ async function runNativeLoop({
1779
1809
 
1780
1810
  function injectPendingUserReminders() {
1781
1811
  const nudges = drainUserPrompts(executionState);
1782
- if (nudges.length === 0) return;
1812
+ if (nudges.length === 0) return "";
1783
1813
  const waiting = executionState.planGraph && executionState.planGraph.waitingFor
1784
1814
  ? executionState.planGraph.waitingFor
1785
1815
  : null;
1786
- const content = formatUserReminderMessage(nudges, { waitingFor: waiting });
1787
- if (!content) return;
1788
- messages.push({ role: "user", content });
1816
+ return formatUserReminderMessage(nudges, { waitingFor: waiting }) || "";
1789
1817
  }
1790
1818
 
1791
1819
  /** Deliver mid-loop TaskRun runtime events before the next model call. */
1792
- function injectRuntimeMailboxEvents() {
1820
+ function injectRuntimeMailboxEvents(targetMessages) {
1793
1821
  const drained = drainAgentMailboxForTurn(executionState);
1794
1822
  if (!drained.text) return false;
1795
- messages.push({ role: "user", content: drained.text });
1823
+ targetMessages.push({ role: "user", content: drained.text });
1824
+ return true;
1825
+ }
1826
+
1827
+ /**
1828
+ * Mirror provider-only TaskFocus deltas onto the durable transcript so
1829
+ * session sync / ask_user resume still see tool_use pairs, without feeding
1830
+ * the full Agent Loop history back into the next isolated provider turn.
1831
+ */
1832
+ function mirrorIsolatedDeltaToDurable() {
1833
+ if (!isolatedActive || providerMessages === messages) return;
1834
+ const delta = providerMessages.slice(isolatedPrefixLength);
1835
+ for (const entry of delta) {
1836
+ messages.push(cloneMessageList([entry])[0]);
1837
+ }
1838
+ isolatedPrefixLength = providerMessages.length;
1839
+ }
1840
+
1841
+ /**
1842
+ * Serve waiting TaskRun / plan-task turns on a fresh one-message context.
1843
+ * Durable `messages` is left intact for transcript sync.
1844
+ */
1845
+ function applyIsolatedTaskFocusContext() {
1846
+ if (!shouldIsolateTaskFocusTurn(executionState)) {
1847
+ isolatedActive = false;
1848
+ providerMessages = messages;
1849
+ isolatedPrefixLength = 0;
1850
+ return false;
1851
+ }
1852
+ const drained = drainAgentMailboxForTurn(executionState);
1853
+ const userNudge = injectPendingUserReminders();
1854
+ const isolated = buildIsolatedTaskFocusTurn(executionState, {
1855
+ mailboxEvents: drained.events || [],
1856
+ userNudge,
1857
+ });
1858
+ providerMessages = isolated.messages;
1859
+ isolatedActive = true;
1860
+ isolatedPrefixLength = providerMessages.length;
1796
1861
  return true;
1797
1862
  }
1798
1863
 
@@ -1820,26 +1885,24 @@ async function runNativeLoop({
1820
1885
  return false;
1821
1886
  }
1822
1887
 
1823
- // Prefer draining fresh runtime mail (task_started, etc.) before nudges.
1824
- if (injectRuntimeMailboxEvents()) {
1888
+ // TaskFocus isolation rebuilds providerMessages at the top of the next
1889
+ // loop iteration โ€” just signal continue when a task/plan still needs service.
1890
+ if (shouldIsolateTaskFocusTurn(executionState) || shouldAutoContinueForTaskWake(executionState)) {
1825
1891
  planAutoContinues += 1;
1826
1892
  lastAutoContinueWaitingId = continueKey;
1827
1893
  consecutiveEmptyAutoContinues += 1;
1828
1894
  return true;
1829
1895
  }
1830
1896
 
1831
- if (shouldAutoContinuePlan(executionState)) {
1832
- const reminder = buildPlanAutoContinueReminder(executionState);
1833
- if (!reminder) return false;
1834
- messages.push({ role: "user", content: reminder });
1897
+ if (injectRuntimeMailboxEvents(messages)) {
1835
1898
  planAutoContinues += 1;
1836
1899
  lastAutoContinueWaitingId = continueKey;
1837
1900
  consecutiveEmptyAutoContinues += 1;
1838
1901
  return true;
1839
1902
  }
1840
1903
 
1841
- if (shouldAutoContinueForTaskWake(executionState)) {
1842
- const reminder = buildTaskRunWakeReminder(executionState);
1904
+ if (shouldAutoContinuePlan(executionState)) {
1905
+ const reminder = buildPlanAutoContinueReminder(executionState);
1843
1906
  if (!reminder) return false;
1844
1907
  messages.push({ role: "user", content: reminder });
1845
1908
  planAutoContinues += 1;
@@ -1854,8 +1917,11 @@ async function runNativeLoop({
1854
1917
  while (true) {
1855
1918
  guards.ensureActive();
1856
1919
 
1857
- injectPendingUserReminders();
1858
- injectRuntimeMailboxEvents();
1920
+ if (!applyIsolatedTaskFocusContext()) {
1921
+ const nudge = injectPendingUserReminders();
1922
+ if (nudge) messages.push({ role: "user", content: nudge });
1923
+ injectRuntimeMailboxEvents(messages);
1924
+ }
1859
1925
 
1860
1926
  if (activeLedger) {
1861
1927
  runProviderTurnGate(activeLedger);
@@ -1868,7 +1934,7 @@ async function runNativeLoop({
1868
1934
  provider,
1869
1935
  systemPrompt,
1870
1936
  systemBlocks,
1871
- messages,
1937
+ messages: providerMessages,
1872
1938
  signal,
1873
1939
  timeoutMs,
1874
1940
  onPhase,
@@ -1886,6 +1952,7 @@ async function runNativeLoop({
1886
1952
 
1887
1953
  usage.turns += 1;
1888
1954
  addUsageTotals(usage, turnResult && turnResult.usage);
1955
+ emitContextUsage(turnResult && turnResult.usage);
1889
1956
 
1890
1957
  const toolCalls = transport.getToolCalls(turnResult);
1891
1958
 
@@ -1894,7 +1961,7 @@ async function runNativeLoop({
1894
1961
  const sideEffects = parseStructuredSideEffects(text);
1895
1962
  const planCommand = sideEffects ? normalizePlanGraphCommand(sideEffects) : null;
1896
1963
  if (planCommand) {
1897
- transport.appendFinalAssistantMessage({ messages, turnResult });
1964
+ transport.appendFinalAssistantMessage({ messages: providerMessages, turnResult });
1898
1965
  const planResult = runCoreTool({
1899
1966
  tool: "plan_graph",
1900
1967
  args: planCommand,
@@ -1908,18 +1975,22 @@ async function runNativeLoop({
1908
1975
  if (planResult && planResult.executionState) {
1909
1976
  executionState = planResult.executionState;
1910
1977
  }
1911
- messages.push({
1978
+ providerMessages.push({
1912
1979
  role: "user",
1913
1980
  content: JSON.stringify({
1914
1981
  type: "plan_graph_result",
1915
- ...((planResult && planResult.status)
1916
- ? planResult
1917
- : { status: "rejected", ok: false, error: "plan_graph failed" }),
1982
+ ...sanitizeToolResultForModel(
1983
+ (planResult && planResult.status)
1984
+ ? planResult
1985
+ : { status: "rejected", ok: false, error: "plan_graph failed" },
1986
+ ),
1918
1987
  }),
1919
1988
  });
1989
+ mirrorIsolatedDeltaToDurable();
1920
1990
  continue;
1921
1991
  }
1922
- transport.appendFinalAssistantMessage({ messages, turnResult });
1992
+ transport.appendFinalAssistantMessage({ messages: providerMessages, turnResult });
1993
+ mirrorIsolatedDeltaToDurable();
1923
1994
  if (!aggregated.trim() && text) {
1924
1995
  aggregated = text;
1925
1996
  }
@@ -1940,8 +2011,13 @@ async function runNativeLoop({
1940
2011
  // A tool-using turn resets the empty auto-continue streak (progress possible).
1941
2012
  consecutiveEmptyAutoContinues = 0;
1942
2013
 
1943
- const pendingCalls = transport.prepareToolCalls({ messages, turnResult, toolCalls });
2014
+ const pendingCalls = transport.prepareToolCalls({
2015
+ messages: providerMessages,
2016
+ turnResult,
2017
+ toolCalls,
2018
+ });
1944
2019
  if (!pendingCalls) {
2020
+ mirrorIsolatedDeltaToDurable();
1945
2021
  return {
1946
2022
  text: aggregated,
1947
2023
  streamed,
@@ -1977,7 +2053,8 @@ async function runNativeLoop({
1977
2053
  toolCallsExecuted += 1;
1978
2054
  toolErrors += 1;
1979
2055
  }
1980
- flushLedgerToolResults(activeLedger, transport, messages, pendingCalls);
2056
+ flushLedgerToolResults(activeLedger, transport, providerMessages, pendingCalls);
2057
+ mirrorIsolatedDeltaToDurable();
1981
2058
  lastProtocolLedger = snapshotLedger(activeLedger);
1982
2059
  continue;
1983
2060
  }
@@ -1993,7 +2070,8 @@ async function runNativeLoop({
1993
2070
  toolCallsExecuted += 1;
1994
2071
  toolErrors += 1;
1995
2072
  }
1996
- flushLedgerToolResults(activeLedger, transport, messages, pendingCalls);
2073
+ flushLedgerToolResults(activeLedger, transport, providerMessages, pendingCalls);
2074
+ mirrorIsolatedDeltaToDurable();
1997
2075
  lastProtocolLedger = snapshotLedger(activeLedger);
1998
2076
  continue;
1999
2077
  }
@@ -2114,7 +2192,8 @@ async function runNativeLoop({
2114
2192
  shadowResolvePending(activeLedger, pending, toolResult);
2115
2193
  }
2116
2194
 
2117
- flushLedgerToolResults(activeLedger, transport, messages, pendingCalls);
2195
+ flushLedgerToolResults(activeLedger, transport, providerMessages, pendingCalls);
2196
+ mirrorIsolatedDeltaToDurable();
2118
2197
  lastProtocolLedger = snapshotLedger(activeLedger);
2119
2198
 
2120
2199
  if (deferredAskUser) {
@@ -2184,6 +2263,7 @@ async function runNativeAgentTask({
2184
2263
  onPhase = null,
2185
2264
  onToolEvent = null,
2186
2265
  onArtifactPersisted = null,
2266
+ onContextUsage = null,
2187
2267
  signal = null,
2188
2268
  executionState = null,
2189
2269
  resume = false,
@@ -2258,6 +2338,7 @@ async function runNativeAgentTask({
2258
2338
  onPhase,
2259
2339
  onToolEvent,
2260
2340
  onArtifactPersisted,
2341
+ onContextUsage,
2261
2342
  sessionId: nextSessionId,
2262
2343
  signal,
2263
2344
  guards,
@@ -2274,6 +2355,11 @@ async function runNativeAgentTask({
2274
2355
  const usage = runResult.usage && typeof runResult.usage === "object"
2275
2356
  ? runResult.usage
2276
2357
  : createUsageTotals();
2358
+ const { buildContextMeter } = require("./contextWindow");
2359
+ const contextMeter = buildContextMeter({
2360
+ usedTokens: usage.lastContextTokens,
2361
+ model: runtime.model,
2362
+ });
2277
2363
  appendUsageRecord(workspaceRoot, {
2278
2364
  sessionId: nextSessionId,
2279
2365
  model: runtime.model,
@@ -2292,6 +2378,7 @@ async function runNativeAgentTask({
2292
2378
  messages: cloneMessageList(runResult.messages),
2293
2379
  sessionId: nextSessionId,
2294
2380
  usage,
2381
+ contextMeter,
2295
2382
  executionState: runResult.executionState || executionState || null,
2296
2383
  // The loop marks streamed=true whenever it receives a stream callback;
2297
2384
  // only report it when the caller actually registered one.
@@ -12,4 +12,5 @@ module.exports = {
12
12
  ...require("./toolProvenance"),
13
13
  ...require("./graphYieldRouter"),
14
14
  ...require("./agentWakeup"),
15
+ ...require("./taskFocusContext"),
15
16
  };
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Isolated TaskFocus turns for Agent Loop.
5
+ *
6
+ * TaskLoop steps must not inherit the full Agent Loop transcript. When a
7
+ * TaskRun is waiting_model (or the parent plan is waiting on a task node),
8
+ * rebuild a fresh one-message turn from TaskFocus + compact graph hints.
9
+ */
10
+
11
+ const {
12
+ listTaskRunsAwaitingModel,
13
+ buildTaskRunWakeReminder,
14
+ formatAgentRuntimeEvents,
15
+ } = require("./agentWakeup");
16
+ const { buildTaskFocus, renderTaskFocusText, getExecutionKind } = require("./taskFocus");
17
+ const { shouldAutoContinuePlan, buildPlanAutoContinueReminder } = require("../context/userNudge");
18
+
19
+ function getChildGraph(executionState = null, graphId = "") {
20
+ const id = String(graphId || "").trim();
21
+ if (!id || !executionState || typeof executionState !== "object") return null;
22
+ if (executionState.graphs && executionState.graphs[id]) return executionState.graphs[id];
23
+ if (executionState.planGraph && executionState.planGraph.graphId === id) {
24
+ return executionState.planGraph;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function waitingPlanNode(executionState = null) {
30
+ const pg = executionState && executionState.planGraph;
31
+ const waiting = pg && pg.waitingFor;
32
+ if (!waiting || !waiting.id) return null;
33
+ if (String(waiting.type || "").trim().toLowerCase() !== "task") return null;
34
+ const nodes = Array.isArray(pg.nodes) ? pg.nodes : [];
35
+ return nodes.find((node) => node && node.id === waiting.id) || null;
36
+ }
37
+
38
+ /**
39
+ * True when the next model turn should use TaskFocus isolation instead of the
40
+ * accumulating Agent Loop transcript.
41
+ */
42
+ function shouldIsolateTaskFocusTurn(executionState = null) {
43
+ if (!executionState || typeof executionState !== "object") return false;
44
+ if (executionState.pendingUserInteraction) return false;
45
+ // Active TaskRun waiting_model always gets a fresh TaskFocus turn.
46
+ if (listTaskRunsAwaitingModel(executionState).length > 0) return true;
47
+ // Parent-plan waits without a TaskRun only isolate when the Agent Loop
48
+ // would also auto-continue (skip approval_required / terminal yields).
49
+ if (!shouldAutoContinuePlan(executionState)) return false;
50
+ const node = waitingPlanNode(executionState);
51
+ if (!node) return false;
52
+ const kind = getExecutionKind(node);
53
+ return kind === "task_loop" || kind === "expand" || kind === "inline_llm" || kind === "llm";
54
+ }
55
+
56
+ function pickPrimaryAwaitingRun(executionState = null) {
57
+ const runs = listTaskRunsAwaitingModel(executionState);
58
+ return runs[0] || null;
59
+ }
60
+
61
+ function rebuildFocusText(executionState = null, run = null) {
62
+ if (run && String(run.lastFocusText || "").trim()) {
63
+ return String(run.lastFocusText).trim();
64
+ }
65
+ const parent = executionState && executionState.planGraph;
66
+ const nodes = parent && Array.isArray(parent.nodes) ? parent.nodes : [];
67
+ const nodeId = (run && run.parentNodeId)
68
+ || (waitingPlanNode(executionState) && waitingPlanNode(executionState).id)
69
+ || "";
70
+ const focus = buildTaskFocus({
71
+ nodes,
72
+ currentNodeId: nodeId,
73
+ taskRunsById: (executionState.taskRuns && executionState.taskRuns.byId) || {},
74
+ recentlyChangedFiles: executionState.modifiedFiles || [],
75
+ standaloneTask: run && !run.parentNodeId
76
+ ? {
77
+ id: run.id,
78
+ objective: run.objective || run.title || "",
79
+ title: run.title || run.objective || run.id,
80
+ status: run.status,
81
+ }
82
+ : null,
83
+ });
84
+ return renderTaskFocusText(focus);
85
+ }
86
+
87
+ function compactChildGraphHint(executionState = null, run = null) {
88
+ if (!run || !run.childGraphId) return "";
89
+ const child = getChildGraph(executionState, run.childGraphId);
90
+ if (!child) return `Child graph ${run.childGraphId}: (missing)`;
91
+ const waiting = child.waitingFor;
92
+ const nodes = Array.isArray(child.nodes) ? child.nodes : [];
93
+ const lines = [
94
+ `Child graph ${child.graphId}:`,
95
+ ` objective: ${String(child.objective || "").slice(0, 200)}`,
96
+ ` nodes: ${nodes.length}`,
97
+ ];
98
+ if (waiting && waiting.id) {
99
+ lines.push(` waitingFor: ${waiting.type || "node"}:${waiting.id}`);
100
+ }
101
+ const pending = nodes
102
+ .filter((n) => n && (n.status === "pending" || n.status === "ready" || n.status === "waiting_llm"))
103
+ .slice(0, 6)
104
+ .map((n) => `${n.id}[${n.status || "pending"}]`);
105
+ if (pending.length) lines.push(` open: ${pending.join(", ")}`);
106
+ return lines.join("\n");
107
+ }
108
+
109
+ function buildIsolationWakeText(executionState = null, mailboxEvents = []) {
110
+ const parts = [];
111
+ const mailText = formatAgentRuntimeEvents(mailboxEvents);
112
+ if (mailText) parts.push(mailText);
113
+ const taskWake = buildTaskRunWakeReminder(executionState);
114
+ if (taskWake) parts.push(taskWake);
115
+ else if (shouldAutoContinuePlan(executionState)) {
116
+ const planWake = buildPlanAutoContinueReminder(executionState);
117
+ if (planWake) parts.push(planWake);
118
+ }
119
+ return parts.join("\n\n").trim();
120
+ }
121
+
122
+ /**
123
+ * Build a fresh Agent Loop message list for one TaskFocus-scoped model turn.
124
+ */
125
+ function buildIsolatedTaskFocusTurn(executionState = null, options = {}) {
126
+ const run = pickPrimaryAwaitingRun(executionState);
127
+ const focusText = rebuildFocusText(executionState, run);
128
+ const childHint = compactChildGraphHint(executionState, run);
129
+ const wakeText = String(options.wakeText || "").trim()
130
+ || buildIsolationWakeText(executionState, options.mailboxEvents || []);
131
+ const userNudge = String(options.userNudge || "").trim();
132
+
133
+ const content = [
134
+ "Isolated TaskFocus turn (no prior Agent Loop transcript).",
135
+ "Serve only the current task / child graph. Do not re-litigate finished plan nodes.",
136
+ focusText,
137
+ childHint,
138
+ wakeText,
139
+ userNudge,
140
+ ].filter(Boolean).join("\n\n");
141
+
142
+ return {
143
+ isolated: true,
144
+ taskRunId: run && run.id ? String(run.id) : "",
145
+ childGraphId: run && run.childGraphId ? String(run.childGraphId) : "",
146
+ parentNodeId: run && run.parentNodeId ? String(run.parentNodeId) : "",
147
+ messages: [{ role: "user", content }],
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Drop runner-only fields before a tool result is serialized into model messages.
153
+ * executionState alone can be hundreds of KB and must never enter the prompt.
154
+ */
155
+ function sanitizeToolResultForModel(toolResult = null) {
156
+ if (!toolResult || typeof toolResult !== "object" || Array.isArray(toolResult)) {
157
+ return toolResult;
158
+ }
159
+ const {
160
+ executionState,
161
+ compile,
162
+ taskLoopResume,
163
+ ...rest
164
+ } = toolResult;
165
+ if (rest.modelPayload && typeof rest.modelPayload === "object") {
166
+ const {
167
+ executionState: nestedState,
168
+ compile: nestedCompile,
169
+ ...payloadRest
170
+ } = rest.modelPayload;
171
+ rest.modelPayload = payloadRest;
172
+ void nestedState;
173
+ void nestedCompile;
174
+ }
175
+ void executionState;
176
+ void compile;
177
+ void taskLoopResume;
178
+ return rest;
179
+ }
180
+
181
+ module.exports = {
182
+ shouldIsolateTaskFocusTurn,
183
+ pickPrimaryAwaitingRun,
184
+ rebuildFocusText,
185
+ compactChildGraphHint,
186
+ buildIsolationWakeText,
187
+ buildIsolatedTaskFocusTurn,
188
+ sanitizeToolResultForModel,
189
+ waitingPlanNode,
190
+ };
@@ -113,6 +113,15 @@ function buildSessionSnapshot(input = {}) {
113
113
  : 0,
114
114
  // In-memory compatibility for callers still reading nlMessages
115
115
  nlMessages: cloneMessages(source.nlMessages),
116
+ contextMeter: source.contextMeter && typeof source.contextMeter === "object"
117
+ ? {
118
+ usedTokens: Number(source.contextMeter.usedTokens) || 0,
119
+ limitTokens: Number(source.contextMeter.limitTokens) || 0,
120
+ model: String(source.contextMeter.model || source.model || "").trim(),
121
+ label: String(source.contextMeter.label || "").trim(),
122
+ updatedAt: String(source.contextMeter.updatedAt || "").trim(),
123
+ }
124
+ : null,
116
125
  };
117
126
  }
118
127
 
@@ -16,22 +16,16 @@ const UCODE_BANNER_LINES = [
16
16
 
17
17
  const UCODE_VERSION = String((pkg && pkg.version) || "dev");
18
18
 
19
- // Flying-saucer patrol for busy/loading states: the ๐Ÿ›ธ drifts left-right
20
- // inside a fixed 6-cell field (three emoji slots), so the status text after
21
- // it stays anchored instead of shifting with the saucer.
22
- const UFO_FIELD_CELLS = 6;
23
- const UFO_FRAMES = [];
24
- for (let i = 0; i <= UFO_FIELD_CELLS - 2; i += 1) {
25
- UFO_FRAMES.push(`${" ".repeat(i)}๐Ÿ›ธ${" ".repeat(UFO_FIELD_CELLS - 2 - i)}`);
26
- }
27
- for (let i = UFO_FIELD_CELLS - 3; i > 0; i -= 1) {
28
- UFO_FRAMES.push(`${" ".repeat(i)}๐Ÿ›ธ${" ".repeat(UFO_FIELD_CELLS - 2 - i)}`);
29
- }
19
+ // Fixed-width ASCII spinner. The old ๐Ÿ›ธ patrol used emoji + padding that many
20
+ // terminals measure differently from Ink/Yoga, so long status lines left a
21
+ // staircase of ghost glyphs (and dragged vN.N.N into the log area).
22
+ const SPINNER_FRAMES = ["-", "\\", "|", "/"];
23
+ const WAITING_FRAMES = [". ", ".. ", "...", ".. ", ". "];
30
24
 
31
25
  const STATUS_INDICATORS = {
32
- thinking: UFO_FRAMES,
33
- typing: UFO_FRAMES,
34
- waiting: ["โˆ™", "โˆ™โˆ™", "โˆ™โˆ™โˆ™", "โˆ™โˆ™", "โˆ™"],
26
+ thinking: SPINNER_FRAMES,
27
+ typing: SPINNER_FRAMES,
28
+ waiting: WAITING_FRAMES,
35
29
  };
36
30
 
37
31
  // Friendly labels for the tool-call events surfaced in the status line.
@@ -1187,7 +1187,7 @@ function createChatStatusLine({ React, ink }) {
1187
1187
  const { useEffect, useState } = React;
1188
1188
  const { Box, Text } = ink;
1189
1189
  const h = React.createElement;
1190
- return function ChatStatusLine({ status, version }) {
1190
+ return function ChatStatusLine({ status, version, cols = 80 }) {
1191
1191
  const message = String((status && status.message) || "");
1192
1192
  const animated = Boolean(message)
1193
1193
  && isAnimatedStatusType(inferStatusType(message, status && status.type));
@@ -1197,10 +1197,16 @@ function createChatStatusLine({ React, ink }) {
1197
1197
  const timer = setInterval(() => setTick((t) => t + 1), 100);
1198
1198
  return () => clearInterval(timer);
1199
1199
  }, [animated]);
1200
+ const versionText = `v${version || ""}`;
1201
+ const raw = computeStatusText(status, animated ? tick : 0);
1202
+ // Keep the version pinned on the right; truncate the live message so a
1203
+ // long bus status cannot collide with it or leave redraw trails.
1204
+ const leftBudget = Math.max(12, (Number(cols) || 80) - fmt.displayCellWidth(versionText) - 2);
1205
+ const left = fitPlainLine(raw, leftBudget);
1200
1206
  return h(Box, { marginTop: 1, width: "100%" },
1201
- h(Text, { color: "gray" }, computeStatusText(status, animated ? tick : 0)),
1207
+ h(Text, { color: "gray", wrap: "truncate" }, left),
1202
1208
  h(Box, { flexGrow: 1 }),
1203
- h(Text, { color: "gray" }, `v${version}`),
1209
+ h(Text, { color: "gray" }, versionText),
1204
1210
  );
1205
1211
  };
1206
1212
  }
@@ -3605,7 +3611,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3605
3611
  }
3606
3612
  if (row.kind === "user") {
3607
3613
  const userBody = renderUserLogBody(row.bodyText);
3608
- return h(Box, { key, width: "100%", marginBottom: 1 },
3614
+ return h(Box, { key, width: "100%", marginBottom: 1, alignItems: "flex-start" },
3609
3615
  h(Text, { color: "green", bold: true }, row.markerText || "โ€บ "),
3610
3616
  userBody.at
3611
3617
  ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
@@ -3621,7 +3627,9 @@ function createChatApp({ React, ink, props, interactive = true }) {
3621
3627
  wrap: "wrap",
3622
3628
  };
3623
3629
  if (colors.dim) bodyProps.dimColor = true;
3624
- return h(Box, { key, width: "100%" },
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" },
3625
3633
  h(Text, {
3626
3634
  color: colors.marker,
3627
3635
  bold: row.kind === "error" || row.kind === "assistant",
@@ -3682,7 +3690,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3682
3690
  }
3683
3691
  if (row.kind === "user") {
3684
3692
  const userBody = renderUserLogBody(row.bodyText);
3685
- return h(Box, { key, width: "100%", marginTop, marginBottom: 1 },
3693
+ return h(Box, { key, width: "100%", marginTop, marginBottom: 1, alignItems: "flex-start" },
3686
3694
  h(Text, { color: "green", bold: true }, row.markerText || "โ€บ "),
3687
3695
  userBody.at
3688
3696
  ? h(Text, { color: "magenta", bold: true }, `@${userBody.at} `)
@@ -3698,7 +3706,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3698
3706
  wrap: "wrap",
3699
3707
  };
3700
3708
  if (colors.dim) bodyProps.dimColor = true;
3701
- return h(Box, { key, width: "100%", marginTop },
3709
+ return h(Box, { key, width: "100%", marginTop, alignItems: "flex-start" },
3702
3710
  h(Text, {
3703
3711
  color: colors.marker,
3704
3712
  bold: row.kind === "error" || row.kind === "assistant",
@@ -3809,7 +3817,11 @@ function createChatApp({ React, ink, props, interactive = true }) {
3809
3817
  activeStreamGroups ? h(Box, { flexDirection: "column" },
3810
3818
  ...activeStreamGroups.map(renderChatLogGroup).filter(Boolean),
3811
3819
  ) : null,
3812
- h(ChatStatusLine, { status: state.status, version: fmt.UCODE_VERSION }),
3820
+ h(ChatStatusLine, {
3821
+ status: state.status,
3822
+ version: fmt.UCODE_VERSION,
3823
+ cols: size.cols || 80,
3824
+ }),
3813
3825
  completionsOpen ? (() => {
3814
3826
  const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
3815
3827
  const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);