mixdog 0.9.19 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * src/tui/engine/turn.mjs - lead TUI turn engine (createRunTurn). Extracted from engine.mjs.
3
+ */
4
+ import { aggregateToolCategoryEntry, classifyToolCategory, formatAggregateDetail, summarizeToolResult } from '../../runtime/shared/tool-surface.mjs';
5
+ import { applyUsageDelta } from './session-stats.mjs';
6
+ import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from './labels.mjs';
7
+ import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
8
+ import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
+ import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
10
+ import { promptDisplayText } from './queue-helpers.mjs';
11
+ import { yieldToRenderer } from './render-timing.mjs';
12
+ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
13
+
14
+ export function createRunTurn(bag) {
15
+ const {
16
+ runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, pushItem, patchItem, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
17
+ } = bag;
18
+
19
+ async function runTurn(userText, options = {}) {
20
+ const turnIndex = getState().stats.turns || 0;
21
+ const startedAt = Date.now();
22
+ // Per-turn epoch. Force-release (watchdog grace) bumps the shared counter so
23
+ // this turn's own eventual `finally` — which may run LONG after force-release
24
+ // already started a new turn that reuses the per-session mutex — can detect
25
+ // it is stale and skip all shared-getState() writes (busy, flags.activePromptRestore,
26
+ // turndone, drain kick). Neutralizes the stale unwind without touching the mutex.
27
+ const turnEpoch = ++flags.leadTurnEpoch;
28
+ const inputBaseline = getState().stats.inputTokens;
29
+ const outputBaseline = getState().stats.outputTokens;
30
+ const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
31
+ const displayText = promptDisplayText(userText, options);
32
+ let promptCommittedCallbackCalled = false;
33
+ flags.activePromptRestore = {
34
+ text: String(displayText || '').trim(),
35
+ pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
36
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
37
+ onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
38
+ restorable: options.restorable !== false,
39
+ submittedIds,
40
+ reclaimed: false,
41
+ committed: false,
42
+ requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
43
+ };
44
+ set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: 'requesting' } });
45
+
46
+ let assistantText = '';
47
+ let currentAssistantId = null;
48
+
49
+ tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
50
+ // ── Wall-clock watchdog ───────────────────────────────────────────────
51
+ // If this turn never unwinds (provider call stuck), trip after the cap:
52
+ // abort the in-flight run via the existing interrupt path (runtime.abort,
53
+ // the same one Esc uses), which rejects the pending runtime.ask() with a
54
+ // SessionClosedError so the normal cancelled-turn teardown runs. If even
55
+ // that unwind is starved, forceReleaseTurn() in the timer hard-clears busy
56
+ // and kicks the drain so input is never permanently dead.
57
+ let watchdogTripped = false;
58
+ let watchdogTimer = null;
59
+ let watchdogGraceTimer = null;
60
+ const clearWatchdog = () => {
61
+ if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
62
+ if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
63
+ };
64
+ watchdogTimer = setTimeout(() => {
65
+ if (flags.disposed) return;
66
+ watchdogTripped = true;
67
+ const elapsed = Date.now() - startedAt;
68
+ tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} — aborting stuck turn`);
69
+ pushNotice(`Turn timed out after ${Math.round(elapsed / 1000)}s — aborting stuck request. Input is available again.`, 'warn', { transcript: true });
70
+ try { runtime.abort('cli-react-abort-watchdog'); } catch {}
71
+ // Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
72
+ // (unwind starved), hard-release the turn after a short grace so the
73
+ // React store is never left with busy=true and no drain in flight.
74
+ watchdogGraceTimer = setTimeout(() => {
75
+ if (flags.disposed) return;
76
+ if (!getState().busy) return;
77
+ if (flags.leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
78
+ tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
79
+ // Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
80
+ // a no-op for shared getState() and cannot corrupt the turn we hand off to.
81
+ flags.leadTurnEpoch++;
82
+ set({ busy: false, spinner: null, thinking: null, lastTurn: null });
83
+ flags.activePromptRestore = null;
84
+ if (flags.draining) flags.draining = false;
85
+ if (pending.length > 0) void drain();
86
+ }, 5_000);
87
+ watchdogGraceTimer.unref?.();
88
+ }, LEAD_TURN_TIMEOUT_MS);
89
+ watchdogTimer.unref?.();
90
+ let currentAssistantText = '';
91
+ let thinkingText = '';
92
+ let thinkingStartedAt = 0;
93
+ let thinkingSegmentStartedAt = 0;
94
+ let accumulatedThinkingMs = 0;
95
+ let cancelled = false;
96
+ let askResult = null;
97
+ let turnFinishedNormally = false;
98
+ const itemsAtTurnStart = getState().items.length;
99
+ const cardByCallId = new Map();
100
+ const toolCards = [];
101
+ const toolGroups = new Map();
102
+ const resultsDone = new Set();
103
+ // Streaming providers can deliver eager onToolResult before onToolCall registers
104
+ // cards (send() still in flight). Hold those by callId until the batch lands.
105
+ const earlyResultBuffer = new Map();
106
+ const aggregateCards = []; // active aggregate cards in the current consecutive tool block
107
+ let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
108
+
109
+ // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
110
+ // A tool card used to enter the transcript the instant onToolCall fired,
111
+ // reserving its estimated height (margin+header+detail) while ToolExecution
112
+ // only painted blank placeholder rows for TOOL_PENDING_SHOW_DELAY_MS. With a
113
+ // bottom-fixed viewport that shoved the body up BEFORE any glyph appeared, so
114
+ // the scroll ran ahead of the text. We now hold each card off-screen for the
115
+ // same delay and push it only when its real header/detail will paint (delay
116
+ // elapsed) OR a result lands first (fast tool → completed card, no pending
117
+ // flicker). Either way the pushed spec is stamped `deferredDisplayReady` so
118
+ // ToolExecution renders the real header + 'Running' detail immediately
119
+ // instead of the blank pre-delay placeholder — this matters for the
120
+ // result-forced chain push (flushDeferredUpTo), where earlier-seq sibling
121
+ // cards are pushed alongside the result-bearing one before their own delay
122
+ // elapses and would otherwise paint an empty reserved band.
123
+ // Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
124
+ // [jitter fix] Reserve the tool-card row immediately instead of after a
125
+ // 1000ms delay. Delayed insertion made rows appear late and shove the body
126
+ // vertically; pushing now (with the real 'Running' header height via
127
+ // deferredDisplayReady) keeps the layout stable. The timer path is kept as a
128
+ // 0ms microtask fallback for entries that are registered but not explicitly
129
+ // surfaced, preserving call-order flush semantics.
130
+ const TOOL_CARD_PUSH_DELAY_MS = 0;
131
+ let deferredSeqCounter = 0;
132
+ const deferredEntries = []; // creation-order list; each is pushed at most once
133
+ // Push this entry AND every earlier-created still-deferred entry, in order,
134
+ // so transcript order always matches call order even when a later card's
135
+ // result/timer fires before an earlier one's.
136
+ const flushDeferredUpTo = (entry) => {
137
+ if (!entry) return;
138
+ for (const e of deferredEntries) {
139
+ if (e.seq > entry.seq) break;
140
+ if (e.pushed) continue;
141
+ e.pushed = true;
142
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
143
+ try { e.push(); } catch {}
144
+ }
145
+ };
146
+ flags.flushDeferredBeforeImmediatePush = () => {
147
+ if (!deferredEntries.length) return;
148
+ const last = deferredEntries[deferredEntries.length - 1];
149
+ if (last) flushDeferredUpTo(last);
150
+ };
151
+ const registerDeferredCard = (card) => {
152
+ const entry = {
153
+ seq: deferredSeqCounter++,
154
+ pushed: false,
155
+ timer: null,
156
+ // Mark the card visible and return its spec WITHOUT emitting, so a
157
+ // batched turn-close flush can commit many specs in one set().
158
+ materialize: () => {
159
+ card.pushed = true;
160
+ if (!card.spec) return null;
161
+ card.spec.deferredDisplayReady = true;
162
+ return card.spec;
163
+ },
164
+ push: () => {
165
+ const spec = entry.materialize();
166
+ if (!spec) return;
167
+ flags.pushingFromDeferredEntry = true;
168
+ try { pushItem(spec); } finally { flags.pushingFromDeferredEntry = false; }
169
+ },
170
+ };
171
+ card.deferred = entry;
172
+ card.ensureVisible = () => flushDeferredUpTo(entry);
173
+ deferredEntries.push(entry);
174
+ entry.timer = setTimeout(() => {
175
+ entry.timer = null;
176
+ if (flags.disposed) return;
177
+ flushDeferredUpTo(entry);
178
+ }, TOOL_CARD_PUSH_DELAY_MS);
179
+ entry.timer.unref?.();
180
+ };
181
+ const registerDeferredAggregate = (aggregate) => {
182
+ const entry = {
183
+ seq: deferredSeqCounter++,
184
+ pushed: false,
185
+ timer: null,
186
+ materialize: () => {
187
+ aggregate.pushed = true;
188
+ if (!aggregate.pendingSpec) return null;
189
+ aggregate.pendingSpec.deferredDisplayReady = true;
190
+ return aggregate.pendingSpec;
191
+ },
192
+ push: () => {
193
+ const spec = entry.materialize();
194
+ if (!spec) return;
195
+ flags.pushingFromDeferredEntry = true;
196
+ try { pushItem(spec); } finally { flags.pushingFromDeferredEntry = false; }
197
+ },
198
+ };
199
+ aggregate.deferred = entry;
200
+ aggregate.ensureVisible = () => flushDeferredUpTo(entry);
201
+ deferredEntries.push(entry);
202
+ entry.timer = setTimeout(() => {
203
+ entry.timer = null;
204
+ if (flags.disposed) return;
205
+ flushDeferredUpTo(entry);
206
+ }, TOOL_CARD_PUSH_DELAY_MS);
207
+ entry.timer.unref?.();
208
+ };
209
+ const clearDeferredTimers = () => {
210
+ for (const e of deferredEntries) {
211
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
212
+ }
213
+ };
214
+ // Collect (mark pushed + cancel timers) every still-deferred entry up to
215
+ // `entry` in creation order, returning their specs WITHOUT emitting — the
216
+ // caller commits them (optionally alongside a trailing turndone item) in a
217
+ // single set() so turn-close writes land as ONE visible commit.
218
+ const collectDeferredUpTo = (entry) => {
219
+ const specs = [];
220
+ if (!entry) return specs;
221
+ for (const e of deferredEntries) {
222
+ if (e.seq > entry.seq) break;
223
+ if (e.pushed) continue;
224
+ e.pushed = true;
225
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
226
+ const spec = e.materialize?.();
227
+ if (spec) specs.push(spec);
228
+ }
229
+ return specs;
230
+ };
231
+ // Append pre-built items (deferred cards + turndone) in ONE set(). None are
232
+ // 'user' kind, so no promptHistory rebuild is needed.
233
+ const appendItemsBatch = (newItems, extra = {}) => {
234
+ if (!newItems || !newItems.length) { set(extra); return; }
235
+ const base = getState().items.length;
236
+ const items = [...getState().items, ...newItems];
237
+ for (let i = 0; i < newItems.length; i++) {
238
+ const it = newItems[i];
239
+ if (it?.id != null) itemIndexById.set(it.id, base + i);
240
+ }
241
+ set({ items, ...extra });
242
+ };
243
+
244
+ const markPromptCommitted = () => {
245
+ if (flags.activePromptRestore) {
246
+ if (!promptCommittedCallbackCalled && typeof flags.activePromptRestore.onCommitted === 'function') {
247
+ promptCommittedCallbackCalled = true;
248
+ try { flags.activePromptRestore.onCommitted(); } catch {}
249
+ }
250
+ flags.activePromptRestore.restorable = false;
251
+ flags.activePromptRestore.committed = true;
252
+ flags.activePromptRestore.requeueEntries = [];
253
+ flags.activePromptRestore.pastedImages = null;
254
+ flags.activePromptRestore.pastedTexts = null;
255
+ }
256
+ };
257
+
258
+ const finalizeToolHeaders = () => {
259
+ const ids = new Set();
260
+ for (const card of toolCards || []) {
261
+ if (card?.itemId) ids.add(card.itemId);
262
+ // Seal not-yet-pushed specs too, so a card that pushes later (timer)
263
+ // enters already-finalized instead of flashing the active header form.
264
+ if (card && card.pushed === false && card.spec) card.spec.headerFinalized = true;
265
+ }
266
+ for (const aggregate of aggregateCards || []) {
267
+ if (aggregate?.itemId) ids.add(aggregate.itemId);
268
+ if (aggregate && aggregate.pushed === false && aggregate.pendingSpec) aggregate.pendingSpec.headerFinalized = true;
269
+ }
270
+ if (ids.size === 0) return false;
271
+ let changed = false;
272
+ const items = getState().items.map((item) => {
273
+ if (!ids.has(item?.id) || item.kind !== 'tool' || item.headerFinalized !== false) return item;
274
+ changed = true;
275
+ return { ...item, headerFinalized: true };
276
+ });
277
+ if (changed) set({ items });
278
+ return changed;
279
+ };
280
+
281
+ const completeAggregateVisual = () => {
282
+ for (const aggregate of aggregateCards) {
283
+ const allCalls = [...aggregate.calls.values()];
284
+ if (allCalls.length === 0) continue;
285
+ aggregate.ensureVisible?.();
286
+ const errors = allCalls.filter((r) => r.isError).length;
287
+ const completed = allCalls.filter((r) => r.resolved).length;
288
+ const succeeded = completed - errors;
289
+ const rawResult = aggregateRawResult(allCalls);
290
+ // Merged count summary (see patchToolCardResult); failures keep
291
+ // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
292
+ const displayDetail = errors > 0
293
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
294
+ : formatAggregateDetail(aggregateSummaries(aggregate));
295
+ patchItem(aggregate.itemId, {
296
+ result: displayDetail,
297
+ text: displayDetail,
298
+ rawResult: rawResult || null,
299
+ isError: errors > 0,
300
+ count: allCalls.length,
301
+ completedCount: allCalls.length,
302
+ completedAt: Date.now(),
303
+ });
304
+ }
305
+ };
306
+
307
+ const clearAggregateContinuation = () => {
308
+ completeAggregateVisual();
309
+ finalizeToolHeaders();
310
+ aggregateCards.length = 0;
311
+ // Seal the block: same-bucket calls after this point must open a fresh
312
+ // card, never continue one from before the seal (assistant text/turn
313
+ // end boundary).
314
+ tailAggregate = null;
315
+ };
316
+
317
+ const rememberActiveAggregate = (aggregate) => {
318
+ if (!aggregate) return;
319
+ if (!aggregateCards.includes(aggregate)) aggregateCards.push(aggregate);
320
+ tailAggregate = aggregate;
321
+ };
322
+
323
+ const ensureAggregateCard = (bucket) => {
324
+ // Only the TAIL aggregate (most recent card) may absorb the next call,
325
+ // and only when the bucket matches. Any different-bucket aggregate or
326
+ // standalone card in between breaks the run, so Search, Memory, Search
327
+ // renders as three cards in call order — a new call never merges into
328
+ // an earlier card above the current tail (which read as out-of-order
329
+ // count changes in the transcript). clearAggregateContinuation seals
330
+ // the block at assistant-text/turn boundaries.
331
+ const cached = tailAggregate && tailAggregate.bucket === bucket ? tailAggregate : null;
332
+ if (cached) {
333
+ rememberActiveAggregate(cached);
334
+ return cached;
335
+ }
336
+ const itemId = nextId();
337
+ const aggregate = {
338
+ itemId,
339
+ bucket,
340
+ categories: new Map(),
341
+ categoryOrder: [],
342
+ calls: new Map(),
343
+ nextSummarySeq: 0,
344
+ pushed: false,
345
+ startedAt: Date.now(),
346
+ };
347
+ // Arm the deferred push once at creation; syncAggregateHeader only keeps
348
+ // pendingSpec current until the timer/result flushes it in call order.
349
+ registerDeferredAggregate(aggregate);
350
+ rememberActiveAggregate(aggregate);
351
+ return aggregate;
352
+ };
353
+
354
+ const syncAggregateHeader = (aggregate) => {
355
+ if (!aggregate?.itemId) return;
356
+ const patch = {
357
+ args: { categoryOrder: aggregate.categoryOrder.slice() },
358
+ count: aggregate.calls.size,
359
+ completedCount: [...aggregate.calls.values()].filter((r) => r.resolved || r.completedEarly).length,
360
+ categories: Object.fromEntries(aggregate.categories),
361
+ };
362
+ if (aggregate.pushed) {
363
+ patchItem(aggregate.itemId, patch);
364
+ return;
365
+ }
366
+ // Not yet visible: keep the latest header spec current. The deferred entry
367
+ // (armed at creation) pushes pendingSpec when its timer fires or a result
368
+ // forces it visible, preserving call order via flushDeferredUpTo.
369
+ aggregate.pendingSpec = {
370
+ kind: 'tool',
371
+ id: aggregate.itemId,
372
+ name: '__aggregate__',
373
+ ...patch,
374
+ aggregate: true,
375
+ result: null,
376
+ rawResult: null,
377
+ isError: false,
378
+ expanded: false,
379
+ headerFinalized: false,
380
+ startedAt: aggregate.startedAt || Date.now(),
381
+ };
382
+ };
383
+
384
+ const ensureAssistant = (initialText = '') => {
385
+ if (!currentAssistantId) {
386
+ currentAssistantId = nextId();
387
+ // Do NOT reset currentAssistantText here. The first onTextDelta has
388
+ // already accumulated the opening chunk before this batched flush runs;
389
+ // wiping it dropped the leading characters and forced a later set() to
390
+ // re-add them. Segment resets are owned by closeAssistantSegment().
391
+ // Seed the new row with the already-visible text so the ● gutter and the
392
+ // first body line appear in the SAME set()/emit() — no empty "●-only"
393
+ // row that scrolls once on its own and again when the body lands.
394
+ pushItem({ kind: 'assistant', id: currentAssistantId, text: String(initialText || ''), streaming: true });
395
+ }
396
+ return currentAssistantId;
397
+ };
398
+
399
+ const closeAssistantSegment = () => {
400
+ currentAssistantId = null;
401
+ currentAssistantText = '';
402
+ // Reset incremental-flush getState() so the next segment rescans from scratch.
403
+ _streamScanLen = 0;
404
+ _lastNewlineIdx = -1;
405
+ _emittedNewlineIdx = -2;
406
+ _emittedVisibleText = '';
407
+ _cachedAssistantIndex = -1;
408
+ };
409
+
410
+ const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
411
+ const text = currentAssistantText || '';
412
+ if (!text.trim()) {
413
+ closeAssistantSegment();
414
+ return false;
415
+ }
416
+ if (sealToolBlock) clearAggregateContinuation();
417
+ const id = currentAssistantId || ensureAssistant(text);
418
+ patchItem(id, { text, streaming: false });
419
+ closeAssistantSegment();
420
+ return true;
421
+ };
422
+
423
+ const startThinkingSegment = () => {
424
+ const now = Date.now();
425
+ if (!thinkingStartedAt) thinkingStartedAt = now;
426
+ if (!thinkingSegmentStartedAt) thinkingSegmentStartedAt = now;
427
+ return now;
428
+ };
429
+
430
+ const closeThinkingSegment = () => {
431
+ if (!thinkingSegmentStartedAt) return;
432
+ const now = Date.now();
433
+ accumulatedThinkingMs += Math.max(0, now - thinkingSegmentStartedAt);
434
+ thinkingSegmentStartedAt = 0;
435
+ return now;
436
+ };
437
+
438
+ // --- Streaming-delta batcher ---
439
+ // onTextDelta and onReasoningDelta fire on every tiny chunk (often <10 chars).
440
+ // Each call previously called set() → emit() → full React reconcile. We
441
+ // batch accumulated text and flush at most once per STREAM_BATCH_INTERVAL_MS
442
+ // (≈16ms / 60fps cap). A forced flush happens before any tool call,
443
+ // finalization, or error so those code paths see the correct text getState().
444
+ // Flush cadence for streamed text/thinking. 8ms (~120fps) matches the Ink
445
+ // render maxFps (index.jsx render({ maxFps: 120 })), so a queued batch is
446
+ // never held back waiting for the next Ink frame. 16ms (~60fps) left every
447
+ // other Ink frame idle, which made fast provider streams visibly land in
448
+ // coarse chunks ("10 chars at a time").
449
+ const STREAM_BATCH_INTERVAL_MS = 16;
450
+ let _batchTimer = null;
451
+ let _pendingTextFlush = false; // true when a text/spinner update is queued
452
+ let _pendingThinkFlush = false; // true when a thinking update is queued
453
+ let _pendingThinkingLastEndedAt = 0;
454
+ let compactingActive = false;
455
+ // Incremental streaming-flush getState(): avoids rescanning the full accumulated
456
+ // assistant text (lastIndexOf) and re-finding the row index on every flush.
457
+ let _streamScanLen = 0; // chars of currentAssistantText already scanned for '\n'
458
+ let _lastNewlineIdx = -1; // offset of the last completed-line '\n' found so far
459
+ let _emittedNewlineIdx = -2; // newline offset backing _emittedVisibleText (-2 forces first compute)
460
+ let _emittedVisibleText = ''; // cached visible slice for the current newline offset
461
+ let _cachedAssistantIndex = -1;// cached getState().items index of the streaming assistant row
462
+ // Engine-local streaming scalars. Neither responseLength nor thinkingText is
463
+ // rendered per-token by any consumer: App reads getState().thinking only as a
464
+ // boolean (App.jsx `!!(getState().thinking || liveSpinner?.thinking)`) and the
465
+ // Spinner takes outputTokens, not responseLength. So we keep these growing
466
+ // values in engine-local vars and publish to the store only on a visible
467
+ // transition (thinking on↔off), a completed visible text line, tool/usage
468
+ // updates, or finalization — not on every 8ms streaming flush.
469
+ let _publishedThinkingActive = false; // last thinking boolean pushed to store
470
+ // responseLength is only consumed at finalize as an outputTokens fallback
471
+ // (Math.round(responseLength/4)); we refresh getState().spinner.responseLength on
472
+ // visible-line flush and finalize so that fallback stays valid.
473
+
474
+ const flushStreamBatch = () => {
475
+ if (_batchTimer !== null) {
476
+ clearTimeout(_batchTimer);
477
+ _batchTimer = null;
478
+ }
479
+ if (_pendingTextFlush) {
480
+ _pendingTextFlush = false;
481
+ // Show only COMPLETED lines while streaming. The in-progress trailing
482
+ // line stays hidden until its '\n' arrives, so the visible text never
483
+ // grows a glyph at a time (no "Wh"→pause→"What happened…" partial reveal, no
484
+ // CJK-width reflow jitter). The final non-streaming patch
485
+ // (streaming:false) always carries the full text, so the tail line that
486
+ // never got a newline still lands once at finalize.
487
+ // Incrementally track the last completed-line '\n' offset instead of
488
+ // rescanning the whole accumulated text every flush. Each char is
489
+ // examined once across the stream (amortized O(n) total, not O(n) per
490
+ // flush); when the newline offset hasn't advanced the visible text is
491
+ // byte-identical to the last flush, so the slice below is skipped and
492
+ // reused. Reveal semantics are unchanged: still only completed lines.
493
+ const textLen = currentAssistantText.length;
494
+ if (textLen < _streamScanLen) { _streamScanLen = 0; _lastNewlineIdx = -1; }
495
+ for (let i = _streamScanLen; i < textLen; i++) {
496
+ if (currentAssistantText.charCodeAt(i) === 10) _lastNewlineIdx = i;
497
+ }
498
+ _streamScanLen = textLen;
499
+ let streamingVisibleText;
500
+ if (_lastNewlineIdx === _emittedNewlineIdx) {
501
+ streamingVisibleText = _emittedVisibleText;
502
+ } else {
503
+ streamingVisibleText = _lastNewlineIdx >= 0
504
+ ? currentAssistantText.slice(0, _lastNewlineIdx + 1)
505
+ : '';
506
+ _emittedNewlineIdx = _lastNewlineIdx;
507
+ _emittedVisibleText = streamingVisibleText;
508
+ }
509
+ const patch = {};
510
+ // Do NOT create the assistant row (and scroll the transcript) before
511
+ // there is a completed line with VISIBLE content to show. Until the
512
+ // first '\n' the only pending getState() is the spinner; the row appears
513
+ // together with its first visible line, so no empty "●-only" row
514
+ // flashes/scrolls ahead of text. `.trim()` also guards the
515
+ // whitespace-only case: a response that opens with leading newlines
516
+ // ("\n\n# …") completes a blank line first, whose estimated height
517
+ // still reserves rows and scrolls the transcript, but Markdown trims
518
+ // the body to nothing — so the scroll advances onto an empty band for
519
+ // a few seconds until a non-blank line lands. Don't create the row
520
+ // until there is real content to paint.
521
+ if (currentAssistantId || streamingVisibleText.trim()) {
522
+ const id = ensureAssistant(streamingVisibleText);
523
+ // Emit the accumulated assistant text and spinner update together so a
524
+ // streaming batch costs one set() → one emit() → one React reconcile.
525
+ // Cache the streaming row's index; re-find only on a cache miss (row
526
+ // added/removed) instead of a findIndex scan on every flush.
527
+ let index = _cachedAssistantIndex;
528
+ if (index < 0 || getState().items[index]?.id !== id) {
529
+ index = getState().items.findIndex((it) => it.id === id);
530
+ _cachedAssistantIndex = index;
531
+ }
532
+ if (index >= 0) {
533
+ const current = getState().items[index];
534
+ if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
535
+ const items = getState().items.slice();
536
+ items[index] = { ...current, text: streamingVisibleText, streaming: true };
537
+ patch.items = items;
538
+ }
539
+ }
540
+ }
541
+ // Only touch the spinner when there is a real reason: a visible-line
542
+ // change (patch.items set above), a thinking→responding transition, or a
543
+ // pending thinking end timestamp. Refresh responseLength here so the
544
+ // finalize outputTokens fallback stays valid without a per-token push.
545
+ const responseLengthVal = assistantText.length + thinkingText.length;
546
+ const visibleLineChanged = patch.items !== undefined;
547
+ const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
548
+ if (getState().spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
549
+ patch.spinner = { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || getState().spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
550
+ _publishedThinkingActive = false;
551
+ }
552
+ if (Object.keys(patch).length > 0) set(patch);
553
+ _pendingThinkingLastEndedAt = 0;
554
+ }
555
+ if (_pendingThinkFlush) {
556
+ _pendingThinkFlush = false;
557
+ // App only consumes getState().thinking as a boolean and the Spinner only
558
+ // reads the thinking flag + timing anchors — none of them render the
559
+ // growing thinkingText. So publish the thinking boolean only on the
560
+ // OFF→ON transition (or when compacting toggles the flag), not on every
561
+ // 8ms reasoning chunk. The full thinkingText stays engine-local and is
562
+ // emitted at finalize via the normal spinner/thinking teardown.
563
+ const nextThinkingActive = !compactingActive;
564
+ // Skip the push when the published thinking boolean is unchanged: neither
565
+ // the growing thinkingText nor responseLength is rendered per-token, and
566
+ // the Spinner derives its live elapsed from the (already-published)
567
+ // thinkingSegmentStartedAt anchor. Applies to both thinking and
568
+ // compacting steady getState().
569
+ if (nextThinkingActive === _publishedThinkingActive) {
570
+ // no-op: boolean unchanged
571
+ } else {
572
+ const responseLengthVal = assistantText.length + thinkingText.length;
573
+ const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
574
+ // getState().thinking stays a truthy sentinel while active; consumers read it
575
+ // as a boolean. Keep the value stable (thinkingText) so a late consumer
576
+ // still sees real text, but only push on transition.
577
+ const patch = { thinking: compactingActive ? null : thinkingText };
578
+ if (getState().spinner) {
579
+ patch.spinner = compactingActive
580
+ ? { ...getState().spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: getState().spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
581
+ : { ...getState().spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
582
+ }
583
+ set(patch);
584
+ _publishedThinkingActive = nextThinkingActive;
585
+ }
586
+ }
587
+ };
588
+
589
+ const scheduleStreamFlush = () => {
590
+ if (_batchTimer !== null) return; // already scheduled; do not re-arm
591
+ _batchTimer = setTimeout(flushStreamBatch, STREAM_BATCH_INTERVAL_MS);
592
+ if (_batchTimer?.unref) _batchTimer.unref(); // don't prevent process exit
593
+ };
594
+
595
+ // __earlyNotify: show 1-line summary + completedCount immediately; defer
596
+ // rawResult/expand and resultsDone to the history flush.
597
+ const markToolCardCompletedState = (callId, message) => {
598
+ const card = cardByCallId.get(callId);
599
+ if (!card) return;
600
+ // Early completion also clears the active-summary entry.
601
+ markToolCallDone(card.callId);
602
+ const aggregate = card.aggregate;
603
+ if (aggregate && card.itemId === aggregate.itemId) {
604
+ const callRec = aggregate.calls.get(callId);
605
+ if (!callRec || callRec.resolved || callRec.completedEarly) return;
606
+ aggregate.ensureVisible?.();
607
+ const rawText = toolResultText(message?.content);
608
+ const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
609
+ const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
610
+ callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
611
+ assignAggregateSummaryOrder(aggregate, callRec);
612
+ callRec.isError = isError;
613
+ callRec.resultText = text;
614
+ callRec.completedEarly = true;
615
+ const allCalls = [...aggregate.calls.values()];
616
+ const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
617
+ const errors = allCalls.filter((r) => r.isError).length;
618
+ const succeeded = completedCount - errors;
619
+ const rawResult = aggregateRawResult(allCalls);
620
+ // Collapsed detail carries the merged per-call count summary even on
621
+ // the early-notify path; patching '' here flipped the detail row back
622
+ // to the 'Running' placeholder between count updates (the visible
623
+ // jitter). Failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
624
+ const displayDetail = errors > 0
625
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
626
+ : formatAggregateDetail(aggregateSummaries(aggregate));
627
+ const currentItem = getState().items.find((it) => it.id === card.itemId);
628
+ const visualCompleted = Math.max(
629
+ completedCount,
630
+ Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
631
+ );
632
+ const patch = {
633
+ result: displayDetail,
634
+ text: displayDetail,
635
+ isError: errors > 0,
636
+ errorCount: errors,
637
+ count: allCalls.length,
638
+ completedCount: visualCompleted,
639
+ };
640
+ if (visualCompleted >= allCalls.length) {
641
+ patch.completedAt = Number(currentItem?.completedAt) || Date.now();
642
+ }
643
+ patchItem(card.itemId, patch);
644
+ return;
645
+ }
646
+ // Non-aggregate eager tools are rare; flipping completedCount without
647
+ // result changes pending/detail rendering and risks row jitter — wait for
648
+ // the real history flush (unchanged behavior).
649
+ };
650
+
651
+ const deliverToolResultMessage = (message) => {
652
+ if (message?.__earlyNotify === true) {
653
+ const earlyCallId = toolResultCallId(message);
654
+ if (earlyCallId) markToolCardCompletedState(earlyCallId, message);
655
+ return;
656
+ }
657
+ flushToolResults([message], toolCards, cardByCallId, toolGroups, resultsDone);
658
+ };
659
+
660
+ try {
661
+ const { result, session } = await runtime.ask(userText, {
662
+ drainSteering: () => drainPendingSteering(),
663
+ onSteerMessage: (text) => {
664
+ // Steering can be injected after a terminal no-tool response has
665
+ // already streamed but before runTurn finalizes. Seal the current
666
+ // assistant segment first so the steered user turn and the next
667
+ // assistant response do not get visually merged into one bubble.
668
+ flushStreamBatch();
669
+ if (currentAssistantId) {
670
+ patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
671
+ closeAssistantSegment();
672
+ }
673
+ assistantText = '';
674
+ const value = String(text || '').trim();
675
+ if (value) {
676
+ // Any non-tool transcript item is a block boundary: seal the
677
+ // aggregate continuation (not just finalize headers) so a later
678
+ // same-category tool call opens a fresh card instead of reusing
679
+ // one whose count would then change ABOVE this steered user item.
680
+ clearAggregateContinuation();
681
+ pushUserOrSyntheticItem(value, undefined, 'injected');
682
+ }
683
+ },
684
+ onToolCall: async (_iter, calls) => {
685
+ markPromptCommitted();
686
+ // Always flush any buffered mid-turn assistant text before the tool
687
+ // card appears. Without this, when neither a thinking panel nor a
688
+ // spinner is active the buffered text was dropped by the following
689
+ // closeAssistantSegment(), so the message above the tool card vanished.
690
+ flushStreamBatch();
691
+ if (thinkingText && getState().thinking) {
692
+ const thinkingLastEndedAt = closeThinkingSegment();
693
+ set({ thinking: null, spinner: getState().spinner ? { ...getState().spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : getState().spinner });
694
+ _publishedThinkingActive = false;
695
+ } else if (getState().spinner) {
696
+ set({ spinner: { ...getState().spinner, mode: 'tool-use' } });
697
+ }
698
+ const batchCalls = (calls || []).filter(Boolean);
699
+ if (batchCalls.length === 0) return;
700
+ const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
701
+ if (committedAssistantSegment) {
702
+ // Let the pre-tool assistant preamble paint as its own frame before
703
+ // the tool card reserves/pushes rows. When both enter the transcript
704
+ // in the same render, the bottom-pinned viewport can appear to jump
705
+ // upward by the combined height ("preamble + tool card" at once).
706
+ await yieldToRenderer();
707
+ }
708
+
709
+ const touchedAggregates = new Set();
710
+ // [jitter fix] Last standalone (Agent) card in this batch to reserve a
711
+ // row for. Flushed AFTER the syncAggregateHeader loop so any earlier-seq
712
+ // aggregate it would flush-through already has its pendingSpec built.
713
+ let standaloneReserve = null;
714
+ for (let i = 0; i < batchCalls.length; i++) {
715
+ const c = batchCalls[i];
716
+ const name = toolCallName(c);
717
+ const args = toolCallArgs(c);
718
+ // Category drives the aggregate bucket so only same-category calls
719
+ // merge into one card; classify first, then bucket by it.
720
+ const category = classifyToolCategory(name, args);
721
+ // Agent/bridge calls always get their own standalone card so
722
+ // ToolExecution's isAgentTool/agentActionTitle path renders a
723
+ // "Spawn <Agent> (model, tag)" header instead of being folded
724
+ // into the "Called N agent(s)" category-aggregate card.
725
+ const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
726
+ const callId = toolCallId(c);
727
+ const callKey = callId || `__tool_${toolCards.length}_${i}`;
728
+ // The old App scan counted multi-pattern calls via
729
+ // aggregateToolCategoryEntry(...).count, not a flat 1. Derive the same
730
+ // count here so the incremental Explore/Search summary matches.
731
+ const activeCount = Number(aggregateToolCategoryEntry(name, args, category)?.count || 1);
732
+ // Track Explore/Search calls as active for the incremental prompt-
733
+ // line summary; cleared when their result lands or the turn ends.
734
+ markToolCallActive(callKey, category, activeCount, Date.now());
735
+
736
+ if (!bucket) {
737
+ const itemId = nextId();
738
+ // Defer the visible push: hold the spec and only enter the
739
+ // transcript when the real header/detail will paint (delay
740
+ // elapsed) or its result lands first. Avoids reserving blank
741
+ // placeholder height that scrolls the body ahead of the glyphs.
742
+ const card = {
743
+ itemId,
744
+ callId: callKey,
745
+ done: false,
746
+ pushed: false,
747
+ spec: {
748
+ kind: 'tool',
749
+ id: itemId,
750
+ name,
751
+ args,
752
+ result: null,
753
+ isError: false,
754
+ expanded: false,
755
+ headerFinalized: false,
756
+ count: 1,
757
+ completedCount: 0,
758
+ startedAt: Date.now(),
759
+ },
760
+ };
761
+ registerDeferredCard(card);
762
+ if (callId) {
763
+ cardByCallId.set(callId, card);
764
+ }
765
+ toolCards.push(card);
766
+ // [jitter fix] Immediate row-reserve is deferred to after the
767
+ // syncAggregateHeader loop below (see standaloneReserve): calling
768
+ // ensureVisible() here would flushDeferredUpTo() every earlier-seq
769
+ // entry — including an aggregate whose pendingSpec syncAggregateHeader
770
+ // hasn't built yet — marking it pushed without inserting (lost/
771
+ // out-of-order card). Record it and flush once headers exist.
772
+ standaloneReserve = card;
773
+ // A standalone card (Agent) breaks the consecutive run too: a
774
+ // later same-bucket call must open a fresh card BELOW it, not
775
+ // merge into an aggregate above it.
776
+ tailAggregate = null;
777
+ continue;
778
+ }
779
+
780
+ const categoryEntry = aggregateToolCategoryEntry(name, args, category);
781
+ const aggregateCard = ensureAggregateCard(bucket);
782
+ if (!aggregateCard.categories.has(categoryEntry.key)) aggregateCard.categoryOrder.push(categoryEntry.key);
783
+ const prevCategory = aggregateCard.categories.get(categoryEntry.key);
784
+ aggregateCard.categories.set(categoryEntry.key, {
785
+ ...categoryEntry,
786
+ count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
787
+ });
788
+ aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
789
+ touchedAggregates.add(aggregateCard);
790
+ const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
791
+ if (callId) {
792
+ cardByCallId.set(callId, card);
793
+ }
794
+ toolCards.push(card);
795
+ }
796
+
797
+ for (const aggregateCard of touchedAggregates) {
798
+ syncAggregateHeader(aggregateCard);
799
+ }
800
+ // [jitter fix] Now that every touched aggregate has its pendingSpec,
801
+ // reserve the standalone (Agent) card's row immediately. Its
802
+ // ensureVisible() flushes earlier-seq entries too, but those aggregates
803
+ // are now push-ready so none is marked pushed without inserting.
804
+ standaloneReserve?.ensureVisible?.();
805
+ if (committedAssistantSegment) {
806
+ // A pre-tool assistant preamble has already had one render frame to
807
+ // settle. Do not let the first grouped tool card sit off-screen until
808
+ // the normal 1s deferred timer: when it later inserts its real 3 rows,
809
+ // the already-wrapped preamble visibly jumps. Surface the first card
810
+ // now via the existing deferredDisplayReady path, so the post-
811
+ // preamble frame contains the intended Running tool card immediately
812
+ // (no blank placeholder, no delayed row insertion).
813
+ const firstTouchedAggregate = [...touchedAggregates][0] || null;
814
+ firstTouchedAggregate?.ensureVisible?.();
815
+ }
816
+ for (const [bufferedCallId, bufferedMessage] of earlyResultBuffer) {
817
+ if (!cardByCallId.has(bufferedCallId)) continue;
818
+ deliverToolResultMessage(bufferedMessage);
819
+ earlyResultBuffer.delete(bufferedCallId);
820
+ }
821
+ await yieldToRenderer();
822
+ },
823
+ onToolResult: (message) => {
824
+ const callId = toolResultCallId(message);
825
+ if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
826
+ earlyResultBuffer.set(callId, message);
827
+ return;
828
+ }
829
+ deliverToolResultMessage(message);
830
+ },
831
+ onToolApproval: async (request) => {
832
+ markPromptCommitted();
833
+ flushStreamBatch();
834
+ if (getState().spinner) set({ spinner: { ...getState().spinner, mode: 'tool-approval' } });
835
+ return await requestToolApproval(request);
836
+ },
837
+ onCompactEvent: (event) => {
838
+ flushStreamBatch();
839
+ // Non-tool transcript item — same block-boundary rule as the
840
+ // steered user item above: seal any live aggregate first so a
841
+ // later same-category tool call doesn't reuse a card whose count
842
+ // would then change above this statusdone item.
843
+ clearAggregateContinuation();
844
+ pushItem({
845
+ kind: 'statusdone',
846
+ id: nextId(),
847
+ label: compactEventLabel(event),
848
+ detail: compactEventDetail(event),
849
+ });
850
+ },
851
+ onStageChange: async (stage) => {
852
+ if (!getState().spinner) return;
853
+ const value = String(stage || '');
854
+ if (value === 'compacting') {
855
+ compactingActive = true;
856
+ const thinkingLastEndedAt = closeThinkingSegment();
857
+ _pendingThinkFlush = false;
858
+ _publishedThinkingActive = false; // compacting cleared the thinking flag
859
+ set({
860
+ thinking: null,
861
+ spinner: {
862
+ ...getState().spinner,
863
+ thinking: false,
864
+ thinkingSegmentStartedAt: 0,
865
+ thinkingAccumulatedMs: accumulatedThinkingMs,
866
+ thinkingLastEndedAt: thinkingLastEndedAt || getState().spinner.thinkingLastEndedAt || 0,
867
+ mode: 'compacting',
868
+ },
869
+ });
870
+ await yieldToRenderer();
871
+ return;
872
+ }
873
+ if (value === 'requesting' || value === 'streaming') compactingActive = false;
874
+ const mode = value === 'requesting'
875
+ ? 'requesting'
876
+ : value === 'streaming'
877
+ ? (getState().spinner.thinking ? 'thinking' : 'responding')
878
+ : null;
879
+ if (!mode || getState().spinner.mode === mode) return;
880
+ set({ spinner: { ...getState().spinner, mode } });
881
+ },
882
+ onTextDelta: (chunk) => {
883
+ const textChunk = String(chunk ?? '');
884
+ if (!textChunk) return;
885
+ markPromptCommitted();
886
+ const thinkingLastEndedAt = closeThinkingSegment();
887
+ // Drop any queued think-flush too: it would otherwise re-publish
888
+ // spinner.thinking:true from flushStreamBatch and resurrect the
889
+ // indicator after we cleared it here.
890
+ _pendingThinkFlush = false;
891
+ if (getState().thinking) { set({ thinking: null }); _publishedThinkingActive = false; } // collapse thinking panel immediately, no batch delay
892
+ assistantText += textChunk;
893
+ currentAssistantText += textChunk;
894
+ // Accumulate text and schedule a batched flush (≤1 render per
895
+ // STREAM_BATCH_INTERVAL_MS). Without scheduling, mid-turn text only
896
+ // surfaced via the tool-call/finalize flush, so a text→tool segment
897
+ // with no spinner/thinking dropped the message above the tool card.
898
+ _pendingTextFlush = true;
899
+ if (thinkingLastEndedAt) _pendingThinkingLastEndedAt = thinkingLastEndedAt;
900
+ scheduleStreamFlush();
901
+ },
902
+ onAssistantText: (text) => {
903
+ // Mid-turn assistant text that precedes a tool call. Providers that
904
+ // stream via onTextDelta already accumulated it into assistantText;
905
+ // providers that only return the final response.content (no deltas)
906
+ // never fired onTextDelta, so without this the preamble shows nothing
907
+ // before the tool card. De-dup against already-streamed text so the
908
+ // streaming path is unaffected.
909
+ const full = String(text ?? '');
910
+ if (!full.trim()) return;
911
+ // If the streaming path already produced text for THIS segment,
912
+ // onTextDelta owns the render — content is the same accumulated text
913
+ // (or a superset), so skip to avoid double-printing the preamble.
914
+ // Do not check turn-global assistantText: earlier closed preambles stay
915
+ // there across tool calls, and would suppress later non-streaming
916
+ // preambles even though currentAssistantText has been reset.
917
+ if (currentAssistantText.trim()) return;
918
+ markPromptCommitted();
919
+ closeThinkingSegment();
920
+ _pendingThinkFlush = false; // see onTextDelta: prevent a stale think flush resurrecting the indicator
921
+ if (getState().thinking) { set({ thinking: null }); _publishedThinkingActive = false; }
922
+ assistantText += full;
923
+ currentAssistantText += full;
924
+ _pendingTextFlush = true;
925
+ flushStreamBatch();
926
+ },
927
+ onReasoningDelta: (chunk) => {
928
+ if (String(chunk ?? '')) markPromptCommitted();
929
+ startThinkingSegment();
930
+ thinkingText += String(chunk ?? '');
931
+ // Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
932
+ _pendingThinkFlush = true;
933
+ scheduleStreamFlush();
934
+ },
935
+ onUsageDelta: (delta) => {
936
+ applyUsageDelta(getState().stats, delta);
937
+ syncContextStats({ allowEstimated: true });
938
+ const currentTurnInput = Math.max(0, getState().stats.inputTokens - inputBaseline);
939
+ const currentTurnOutput = Math.max(0, getState().stats.outputTokens - outputBaseline);
940
+ if (getState().spinner) {
941
+ set({ stats: { ...getState().stats }, spinner: { ...getState().spinner, inputTokens: currentTurnInput, outputTokens: currentTurnOutput } });
942
+ } else {
943
+ set({ stats: { ...getState().stats } });
944
+ }
945
+ },
946
+ });
947
+ askResult = result;
948
+ markPromptCommitted();
949
+
950
+ flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
951
+ finalizeToolHeaders();
952
+ flushStreamBatch(); // force-flush any batched streaming text before finalization writes
953
+ syncContextStats({ allowEstimated: true });
954
+
955
+ const finalText = result?.content != null ? String(result.content) : '';
956
+ if (finalText.trim()) {
957
+ // The persisted transcript is written from the provider's final content,
958
+ // while the live TUI row is fed by streaming deltas. If a provider/parser
959
+ // misses or suppresses an early delta, keeping the streamed buffer here
960
+ // leaves the final on-screen assistant row missing leading characters even
961
+ // though the transcript is correct. Always reconcile the active segment to
962
+ // the final provider text when it is available.
963
+ const id = currentAssistantId || ensureAssistant(finalText);
964
+ currentAssistantText = finalText;
965
+ patchItem(id, { text: finalText, streaming: false });
966
+ } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
967
+ const streamedText = currentAssistantText || assistantText;
968
+ patchItem(currentAssistantId, { text: streamedText, streaming: false });
969
+ }
970
+ turnFinishedNormally = true;
971
+ } catch (error) {
972
+ flushStreamBatch(); // ensure any batched text lands before the error notice
973
+ if (error?.name === 'SessionClosedError') {
974
+ cancelled = true;
975
+ if (assistantText.trim() && currentAssistantId) {
976
+ patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
977
+ }
978
+ // Finalize pending tool cards so they don't stay "Running..." forever
979
+ // after cancellation. Without this, the spinner vanishes and TurnDone
980
+ // shows "cancelled", but in-flight tool cards remain in a perpetual
981
+ // pending/blinking getState() because the normal finalize path (line 992)
982
+ // was skipped when the error interrupted the try block.
983
+ flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
984
+ finalizeToolHeaders();
985
+ } else {
986
+ finalizeToolHeaders();
987
+ pushNotice(toolErrorDisplay(error, 'turn'), 'error');
988
+ }
989
+ } finally {
990
+ denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
991
+ // Turn is unwinding normally (or via abort) — cancel the wall-clock
992
+ // watchdog + its force-release grace so they never fire on a live turn.
993
+ clearWatchdog();
994
+ // If the watchdog force-release already fired, a NEWER turn now owns the
995
+ // shared store (busy, flags.activePromptRestore, turndone, drain). This stale
996
+ // unwind must NOT write shared getState() or it corrupts that turn. It still
997
+ // runs its own turn-local teardown (approvals, deferred cards) below.
998
+ const isStaleUnwind = flags.leadTurnEpoch !== turnEpoch;
999
+ // Flush any still-deferred tool cards into the transcript and cancel their
1000
+ // pending push timers so nothing fires (or leaks) after the turn ends. The
1001
+ // finalize path above already patches results onto visible cards; this just
1002
+ // guarantees every registered card is materialized before the turn closes.
1003
+ // Collect (don't emit) the still-deferred cards so the turn-close flush and
1004
+ // the turndone item append in ONE set() below instead of one render bounce
1005
+ // per row. Order/ids are preserved (creation order, then turndone last).
1006
+ let closingItems = [];
1007
+ if (deferredEntries.length) {
1008
+ const last = deferredEntries[deferredEntries.length - 1];
1009
+ closingItems = collectDeferredUpTo(last);
1010
+ clearDeferredTimers();
1011
+ }
1012
+ flags.flushDeferredBeforeImmediatePush = null;
1013
+ const producedTranscriptItem =
1014
+ getState().items.length + closingItems.length > itemsAtTurnStart;
1015
+ const reclaimed = cancelled && flags.activePromptRestore?.reclaimed === true;
1016
+ if (!isStaleUnwind) flags.activePromptRestore = null;
1017
+ closeThinkingSegment();
1018
+ const elapsedMs = Date.now() - startedAt;
1019
+ const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
1020
+ // responseLength is engine-local now (not pushed per-token), so compute the
1021
+ // fallback from the live accumulator instead of the possibly-stale
1022
+ // getState().spinner.responseLength. Final-only / non-streaming turns never
1023
+ // accumulate `assistantText` (only currentAssistantText is set at the
1024
+ // finalize reconcile above), so take the larger of the two text sources so
1025
+ // a no-usage turn still estimates tokens from the final content.
1026
+ const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1027
+ const finalResponseLength = finalAssistantLen + thinkingText.length;
1028
+ const finalOutputTokens = Math.max(0, Number(getState().spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
1029
+ const turnStatus = cancelled ? 'cancelled' : 'done';
1030
+ const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
1031
+ const assistantOutput = (currentAssistantText || assistantText || '').trim();
1032
+ // Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
1033
+ const isNoOpTurn = turnFinishedNormally
1034
+ && !cancelled
1035
+ && toolCards.length === 0
1036
+ && !resultContent
1037
+ && !assistantOutput
1038
+ && !producedTranscriptItem;
1039
+ if (isStaleUnwind) {
1040
+ // Preserve prior behavior: a stale unwind still materializes its own
1041
+ // deferred cards (turn-local teardown) but writes no other shared getState().
1042
+ if (closingItems.length) appendItemsBatch(closingItems);
1043
+ tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-getState() writes`);
1044
+ } else {
1045
+ if (!isNoOpTurn) {
1046
+ getState().stats.turns = (getState().stats.turns || 0) + 1;
1047
+ }
1048
+ // Pin the post-think summary into the transcript right after this turn's
1049
+ // output so it scrolls up with the answer and stays in the scrollback,
1050
+ // in scrollback. (Previously TurnDone rendered only in the
1051
+ // bottom-fixed live-status slot and vanished on the next turn.)
1052
+ if (!reclaimed && !isNoOpTurn) {
1053
+ closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1054
+ }
1055
+ // Deferred cards + turndone + status all land in ONE set() (one commit).
1056
+ appendItemsBatch(closingItems, {
1057
+ busy: false,
1058
+ spinner: null,
1059
+ thinking: null,
1060
+ lastTurn: null,
1061
+ stats: { ...getState().stats },
1062
+ ...routeState(),
1063
+ toolMode: runtime.toolMode,
1064
+ ...agentStatusState({ force: true }),
1065
+ });
1066
+ flushDeferredExecutionPendingResumeKick();
1067
+ }
1068
+ }
1069
+ // Shared UI getState(): a stale unwind must not wipe a newer turn's live
1070
+ // tool-summary line (same epoch rule as the shared-getState() block above).
1071
+ if (flags.leadTurnEpoch === turnEpoch) clearActiveToolSummary();
1072
+ _publishedThinkingActive = false; // turn teardown cleared getState().thinking
1073
+ tuiDebug(`runTurn end turn=${turnIndex} status=${cancelled ? 'cancelled' : 'done'} elapsedMs=${Date.now() - startedAt}${watchdogTripped ? ' watchdogTripped=1' : ''} pending=${pending.length}`);
1074
+ return cancelled ? 'cancelled' : 'done';
1075
+ }
1076
+
1077
+ return runTurn;
1078
+ }