mixdog 0.9.16 → 0.9.18

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 (43) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/provider-toolcall-test.mjs +79 -2
  4. package/src/mixdog-session-runtime.mjs +12 -0
  5. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  6. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
  7. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  8. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  9. package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
  10. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
  11. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
  12. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
  14. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  15. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  16. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
  18. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  19. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  20. package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
  21. package/src/runtime/channels/backends/discord-gateway.mjs +12 -2
  22. package/src/runtime/channels/backends/discord.mjs +97 -7
  23. package/src/runtime/channels/index.mjs +150 -23
  24. package/src/runtime/channels/lib/crash-log.mjs +21 -3
  25. package/src/runtime/channels/lib/output-forwarder.mjs +118 -7
  26. package/src/runtime/channels/lib/runtime-paths.mjs +21 -19
  27. package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
  28. package/src/runtime/memory/index.mjs +37 -0
  29. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  30. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  31. package/src/runtime/shared/atomic-file.mjs +110 -0
  32. package/src/runtime/shared/transcript-writer.mjs +46 -4
  33. package/src/session-runtime/provider-models.mjs +47 -8
  34. package/src/standalone/channel-worker.mjs +14 -2
  35. package/src/tui/app/transcript-window.mjs +137 -6
  36. package/src/tui/app/use-transcript-window.mjs +67 -10
  37. package/src/tui/components/StatusLine.jsx +1 -1
  38. package/src/tui/dist/index.mjs +436 -93
  39. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  40. package/src/tui/engine.mjs +66 -14
  41. package/src/tui/index.jsx +97 -6
  42. package/src/ui/statusline-segments.mjs +54 -36
  43. package/src/ui/statusline.mjs +141 -95
@@ -11,6 +11,10 @@ import { fastCapableFor, fastPreferenceFor } from './model-capabilities.mjs';
11
11
  import { modelSettingsFor } from './config-helpers.mjs';
12
12
  import { isSelectableLlmModel } from './model-recency.mjs';
13
13
 
14
+ const PROVIDER_MODELS_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(
15
+ process.env.MIXDOG_PROVIDER_MODELS_PROFILE || process.env.MIXDOG_BOOT_PROFILE || '',
16
+ ));
17
+
14
18
  export function createProviderModels({
15
19
  caches,
16
20
  modelMetaByRoute,
@@ -32,6 +36,10 @@ export function createProviderModels({
32
36
  const config = () => getConfig();
33
37
  const route = () => getRoute();
34
38
  const reg = () => getReg();
39
+ function profile(event, fields = {}) {
40
+ if (!PROVIDER_MODELS_PROFILE_ENABLED) return;
41
+ bootProfile(`provider-models:${event}`, fields);
42
+ }
35
43
 
36
44
  function modelMetaKey(providerId, modelId) {
37
45
  return `${clean(providerId)}\n${clean(modelId)}`;
@@ -152,12 +160,17 @@ export function createProviderModels({
152
160
  return results;
153
161
  }
154
162
 
155
- async function loadProviderModelsFresh({ forceRefresh = false } = {}) {
156
- ensureFullConfig();
163
+ async function loadProviderModelsFresh({ forceRefresh = false, loadSecrets = true } = {}) {
164
+ const startedAt = performance.now();
165
+ profile('load:start', { forceRefresh, loadSecrets });
166
+ if (loadSecrets) ensureFullConfig();
167
+ const providersStartedAt = performance.now();
157
168
  await ensureProvidersReady(config().providers || {});
169
+ profile('providers-ready', { ms: (performance.now() - providersStartedAt).toFixed(1) });
158
170
  const allProviders = [...reg().getAllProviders()];
159
171
  const providerResults = await Promise.all(allProviders.map(async ([name, provider]) => {
160
172
  if (typeof provider?.listModels !== 'function') return [];
173
+ const providerStartedAt = performance.now();
161
174
  try {
162
175
  let models = null;
163
176
  if (forceRefresh && typeof provider._refreshModelCache === 'function') {
@@ -173,8 +186,19 @@ export function createProviderModels({
173
186
  if (!isSelectableLlmModel(m)) continue;
174
187
  rows.push(providerModelCacheRow(name, m));
175
188
  }
189
+ profile('provider:done', {
190
+ provider: name,
191
+ ms: (performance.now() - providerStartedAt).toFixed(1),
192
+ models: models.length,
193
+ rows: rows.length,
194
+ });
176
195
  return rows;
177
- } catch {
196
+ } catch (error) {
197
+ profile('provider:failed', {
198
+ provider: name,
199
+ ms: (performance.now() - providerStartedAt).toFixed(1),
200
+ error: error?.message || String(error),
201
+ });
178
202
  // Ignore per-provider catalog failures so one bad credential or
179
203
  // transient /models error does not hide other authenticated models.
180
204
  return [];
@@ -189,9 +213,21 @@ export function createProviderModels({
189
213
  results.push(row);
190
214
  modelMetaByRoute.set(modelMetaKey(row.provider, row.id), row);
191
215
  }
216
+ profile('load:done', { ms: (performance.now() - startedAt).toFixed(1), providers: allProviders.length, rows: results.length });
192
217
  return results;
193
218
  }
194
219
 
220
+ function shouldAdoptProviderModelCache(models, { loadSecrets = true } = {}) {
221
+ // Background warmup deliberately avoids ensureFullConfig() so it cannot
222
+ // block the TUI on keychain/config reload. That no-secrets path is only a
223
+ // best-effort provider-internal prefetch. Its result may be a partial
224
+ // catalog (for example local/env providers listed while keychain-backed
225
+ // providers failed), so never let it become the authoritative picker cache.
226
+ // Foreground/forced loads still adopt empty lists because they loaded the
227
+ // authoritative config.
228
+ return loadSecrets;
229
+ }
230
+
195
231
  async function collectSearchProviderModels({ force = false } = {}) {
196
232
  if (!force && Array.isArray(caches.searchProviderModelsCache.models)) {
197
233
  return providerModelsFromCacheRows(quickHelpers.searchRowsWithDefault(caches.searchProviderModelsCache.models));
@@ -223,15 +259,15 @@ export function createProviderModels({
223
259
  }
224
260
  if (force) {
225
261
  const seq = ++caches.providerModelsLoadSeq;
226
- const models = await loadProviderModelsFresh({ forceRefresh: true });
262
+ const models = await loadProviderModelsFresh({ forceRefresh: true, loadSecrets: true });
227
263
  if (seq === caches.providerModelsLoadSeq) caches.providerModelsCache = { models, at: Date.now() };
228
264
  return providerModelsFromCacheRows(models);
229
265
  }
230
266
  if (!caches.providerModelsPromise) {
231
267
  const seq = ++caches.providerModelsLoadSeq;
232
- caches.providerModelsPromise = loadProviderModelsFresh()
268
+ caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets: true })
233
269
  .then((models) => {
234
- if (seq === caches.providerModelsLoadSeq) caches.providerModelsCache = { models, at: Date.now() };
270
+ if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets: true })) caches.providerModelsCache = { models, at: Date.now() };
235
271
  return models;
236
272
  })
237
273
  .finally(() => {
@@ -243,10 +279,13 @@ export function createProviderModels({
243
279
 
244
280
  function warmProviderModelCache() {
245
281
  if (Array.isArray(caches.providerModelsCache.models) || caches.providerModelsPromise) return caches.providerModelsPromise;
282
+ profile('warm:start');
246
283
  const seq = ++caches.providerModelsLoadSeq;
247
- caches.providerModelsPromise = loadProviderModelsFresh()
284
+ caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets: false })
248
285
  .then((models) => {
249
- if (seq === caches.providerModelsLoadSeq) caches.providerModelsCache = { models, at: Date.now() };
286
+ if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets: false })) {
287
+ caches.providerModelsCache = { models, at: Date.now() };
288
+ }
250
289
  bootProfile('provider-models:warm-ready', { count: models.length });
251
290
  return models;
252
291
  })
@@ -75,6 +75,8 @@ export function createStandaloneChannelWorker({
75
75
  let readyResolve = null;
76
76
  let readyReject = null;
77
77
  let stopPromise = null;
78
+ let stopRequested = false;
79
+ let bootGeneration = 0;
78
80
  let inProcessMod = null;
79
81
  let inProcessStartPromise = null;
80
82
  let nextCallId = 1;
@@ -153,6 +155,7 @@ export function createStandaloneChannelWorker({
153
155
  return stopPromise.then(() => start());
154
156
  }
155
157
  if (child && child.exitCode == null && !child.killed) return readyPromise || Promise.resolve(status());
158
+ stopRequested = false;
156
159
  readyPromise = new Promise((resolve, reject) => {
157
160
  readyResolve = resolve;
158
161
  readyReject = reject;
@@ -181,6 +184,9 @@ export function createStandaloneChannelWorker({
181
184
  windowsHide: true,
182
185
  });
183
186
  const spawnedPid = child.pid;
187
+ // Per-boot generation: a later start()/respawn bumps this, so a stale old
188
+ // child's late exit is ignored instead of respawning/rejecting the current one.
189
+ const myGeneration = ++bootGeneration;
184
190
  startChildGuardian({ childPid: spawnedPid, label: 'channel-worker', orphanGraceMs: 8000, forceGraceMs: 3000 });
185
191
  if (spawnedPid) ownedChildPids.add(spawnedPid);
186
192
  installParentExitHook();
@@ -215,13 +221,17 @@ export function createStandaloneChannelWorker({
215
221
  });
216
222
 
217
223
  child.on('exit', (code, signal) => {
224
+ if (myGeneration !== bootGeneration) return;
218
225
  if (spawnedPid) ownedChildPids.delete(spawnedPid);
219
226
  const error = new Error(`channels runtime exited (${signal || (code ?? 'unknown')})`);
220
- if (!becameReady && readyReject && !stopPromise && attempt < WORKER_BOOT_MAX_ATTEMPTS) {
227
+ // Exit code 2 = terminal (non-transient) worker start failure: reject, never respawn.
228
+ // A requested stop (stopRequested) also suppresses respawn even if stopPromise's
229
+ // settle timer already cleared it before the child exited.
230
+ if (!becameReady && readyReject && !stopPromise && !stopRequested && code !== 2 && attempt < WORKER_BOOT_MAX_ATTEMPTS) {
221
231
  logLine(logPath, `worker exited before ready (${signal || (code ?? 'unknown')}), attempt ${attempt}/${WORKER_BOOT_MAX_ATTEMPTS}; retrying in ${WORKER_BOOT_RETRY_DELAY_MS}ms`);
222
232
  child = null;
223
233
  setTimeout(() => {
224
- if (stopPromise || !readyReject) return;
234
+ if (stopPromise || stopRequested || !readyReject) return;
225
235
  spawnWorkerChild(attempt + 1);
226
236
  }, WORKER_BOOT_RETRY_DELAY_MS);
227
237
  return;
@@ -235,6 +245,7 @@ export function createStandaloneChannelWorker({
235
245
  });
236
246
 
237
247
  child.on('error', (error) => {
248
+ if (myGeneration !== bootGeneration) return;
238
249
  logLine(logPath, `runtime error: ${error?.message || error}`);
239
250
  if (readyReject) readyReject(error);
240
251
  readyResolve = null;
@@ -347,6 +358,7 @@ export function createStandaloneChannelWorker({
347
358
 
348
359
  function stop(reason = 'standalone shutdown', options = {}) {
349
360
  const waitForExit = options?.waitForExit !== false;
361
+ stopRequested = true;
350
362
  stopClientHeartbeat();
351
363
  if (stopPromise) return stopPromise;
352
364
  if (!useProcessWorker) {
@@ -562,6 +562,17 @@ export function measuredTranscriptRows(item, columns, toolOutputExpanded) {
562
562
 
563
563
  const STREAMING_ROW_QUANTUM = 1;
564
564
 
565
+ // Bottom-pinned flag for the streaming-tail row estimate. Set once per hook
566
+ // render (use-transcript-window.mjs) BEFORE any tail resolution so every path
567
+ // that resolves the streaming tail height (tailSig, buildTranscriptRowIndex,
568
+ // incremental tail, transcriptStructureSignature) reads the same value and
569
+ // cannot diverge. Bottom-pinned → fold the live estimate in (geometry right on
570
+ // the first commit); away from bottom → d19cad1e defer-growth.
571
+ let streamingBottomPinned = false;
572
+ export function setStreamingBottomPinned(pinned) {
573
+ streamingBottomPinned = !!pinned;
574
+ }
575
+
565
576
  function assistantTextForStreamingRowEstimate(text) {
566
577
  return streamingLayoutText(text);
567
578
  }
@@ -573,20 +584,42 @@ function streamingEstimateRows(item, columns, toolOutputExpanded) {
573
584
  return Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
574
585
  }
575
586
 
576
- function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
587
+ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
577
588
  if (!item) return Math.max(1, Math.ceil(estimateTranscriptItemRows(item, columns, toolOutputExpanded)));
578
589
  if (shouldSuppressFullyFailedToolItem(item)) return 0;
579
590
  if (item.kind === 'assistant' && item.streaming) {
580
- const estimate = streamingEstimateRows(item, columns, toolOutputExpanded);
581
591
  const toolExpanded = toolOutputExpanded ? 1 : 0;
582
592
  const idEntry = streamingMeasuredRowsById.get(item.id);
583
593
  if (idEntry && idEntry.rows > 0 && idEntry.columns === columns && idEntry.toolExpanded === toolExpanded) {
584
- // Streaming text only ever grows, so the last real measured height is a
585
- // valid floor even after the item object (and its text) has moved on.
586
- return Math.max(idEntry.rows, estimate);
594
+ if (streamingBottomPinned) {
595
+ // Bottom-pinned: the view follows the tail, so its geometry must be
596
+ // correct on the FIRST commit. Fold the freshly-grown live estimate in
597
+ // — max(measuredFloor, liveEstimate) — so totalRows/scrollOffset are
598
+ // right this frame instead of committing the stale measured floor and
599
+ // growing a frame later when the harvest bumps measuredRowsVersion
600
+ // (the judder). The measured floor still guards against an estimate
601
+ // that undercounts the real wrap. Away from bottom the defer-growth
602
+ // path below is kept (its anchor-stability / perf intent).
603
+ return Math.max(idEntry.rows, streamingEstimateRows(item, columns, toolOutputExpanded));
604
+ }
605
+ // Defer-growth: while a confirmed (post-commit Yoga) measurement exists
606
+ // for this streaming id, THIS render keeps that value instead of
607
+ // folding in the freshly-grown estimate. Combining
608
+ // max(measuredFloor, liveEstimate) let per-flush row growth reach
609
+ // totalRows/scrollOffset a full frame BEFORE Yoga confirmed the real
610
+ // wrap — the estimate could over/undercount vs the real layout, and
611
+ // the next commit's harvest then corrected it, bouncing an anchored or
612
+ // bottom-pinned offset by the mismatch. Freezing at the last confirmed
613
+ // height means growth only reaches the row index on the SAME frame the
614
+ // harvest (use-transcript-window.mjs) writes the new measured value and
615
+ // bumps measuredRowsVersion — the "measured frame" consumes it, not the
616
+ // estimate frame.
617
+ return idEntry.rows;
587
618
  }
588
619
  if (idEntry) streamingMeasuredRowsById.delete(item.id);
589
- return estimate;
620
+ // No confirmed measurement yet for this id (item just started streaming):
621
+ // nothing to defer against, so the first frame falls back to the estimate.
622
+ return streamingEstimateRows(item, columns, toolOutputExpanded);
590
623
  }
591
624
  if (item.kind === 'assistant' && streamingMeasuredRowsById.has(item.id)) {
592
625
  // Item settled (no longer streaming): the id-keyed floor is no longer
@@ -632,6 +665,104 @@ export function buildTranscriptRowIndex(items, {
632
665
  return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
633
666
  }
634
667
 
668
+ // ── Incremental streaming-tail row-index cache ─────────────────────────────
669
+ // On a streaming flush the engine swaps `items` for a fresh array in which
670
+ // ONLY the trailing assistant item's text has grown; every settled item ahead
671
+ // of it is byte-identical geometry. buildTranscriptRowIndex re-walks all N
672
+ // items each flush (O(n) per ~16ms frame) even though only the last row's
673
+ // height can change. This cache holds the SETTLED-PREFIX row-index (all items
674
+ // except the trailing streaming assistant item) keyed on a stable signature of
675
+ // that prefix + columns + expanded + suppress + measuredRowsVersion. When the
676
+ // only difference since last flush is the trailing streaming item, we recompute
677
+ // just that one tail row and append it to the cached prefixRows. Any structural
678
+ // change (item count, non-tail change, columns, expanded, suppress, version)
679
+ // invalidates the cache and falls back to a full buildTranscriptRowIndex.
680
+ // The cache is per-hook-instance now (passed in as `cacheRef`); there is no
681
+ // module-level mutable state to leak across hook instances.
682
+
683
+ /** True when `items` ends with a streaming assistant item (the growing tail). */
684
+ function trailingStreamingItem(allItems) {
685
+ const last = allItems.length > 0 ? allItems[allItems.length - 1] : null;
686
+ return last && last.kind === 'assistant' && last.streaming ? last : null;
687
+ }
688
+
689
+ export function buildTranscriptRowIndexIncremental(items, {
690
+ columns = 80,
691
+ toolOutputExpanded = false,
692
+ suppressMeasuredRowHeights = false,
693
+ measuredRowsVersion = 0,
694
+ cacheRef = null,
695
+ prefixSig = null,
696
+ } = {}) {
697
+ const allItems = Array.isArray(items) ? items : [];
698
+ const tail = trailingStreamingItem(allItems);
699
+ // Per-hook-instance cache holder (useRef object). Fall back to a throwaway
700
+ // holder if none supplied so the builder still works in isolation.
701
+ const holder = cacheRef || { current: null };
702
+ // No streaming tail → nothing incremental to exploit; drop any stale cache and
703
+ // do the normal full build. (The memo layer already skips recompute when the
704
+ // structure signature is unchanged, so a settled transcript pays this once.)
705
+ if (!tail) {
706
+ holder.current = null;
707
+ return buildTranscriptRowIndex(allItems, {
708
+ columns, toolOutputExpanded, suppressMeasuredRowHeights,
709
+ });
710
+ }
711
+ const prefixLen = allItems.length - 1;
712
+ const cache = holder.current;
713
+ // Prefix identity is carried by `prefixSig` (a hash of every prefix item's
714
+ // WeakMap sigPart, computed once in the hook). String equality catches a
715
+ // same-length MIDDLE-item replacement (tool-card patch swaps a middle object →
716
+ // fresh fragment → different sig), which a last-item ref check would miss.
717
+ // Fast path: same prefix length + tail id + prefixSig, and columns/expanded/
718
+ // suppress/version all match. Only the tail row can differ → recompute + append.
719
+ if (cache
720
+ && cache.columns === columns
721
+ && cache.toolExpanded === (toolOutputExpanded ? 1 : 0)
722
+ && cache.suppress === suppressMeasuredRowHeights
723
+ && cache.version === measuredRowsVersion
724
+ && cache.prefixLen === prefixLen
725
+ && prefixSig != null
726
+ && cache.prefixSig === prefixSig
727
+ && cache.tailId === tail.id) {
728
+ // prefixSig matches → prefix rows provably unchanged; NO prefix walk / no
729
+ // re-estimation. Only the tail row can differ, recompute it.
730
+ {
731
+ const tailMeasured = suppressMeasuredRowHeights
732
+ ? null
733
+ : measuredTranscriptRows(tail, columns, toolOutputExpanded);
734
+ const tailRows = tailMeasured != null
735
+ ? tailMeasured
736
+ : estimateTranscriptItemRowsCached(tail, columns, toolOutputExpanded);
737
+ const rows = cache.prefixRowsArr.slice();
738
+ rows.push(tailRows);
739
+ const prefixRows = cache.prefixPrefixRows.slice();
740
+ prefixRows.push(cache.prefixTotal + tailRows);
741
+ return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
742
+ }
743
+ }
744
+ // Cache miss: full build, then repopulate the settled-prefix cache so the NEXT
745
+ // flush (only the tail grown) takes the fast path. The prefix arrays are the
746
+ // full-build outputs truncated before the tail row — byte-identical to a
747
+ // full rebuild's prefix by construction.
748
+ const full = buildTranscriptRowIndex(allItems, {
749
+ columns, toolOutputExpanded, suppressMeasuredRowHeights,
750
+ });
751
+ holder.current = {
752
+ columns,
753
+ toolExpanded: toolOutputExpanded ? 1 : 0,
754
+ suppress: suppressMeasuredRowHeights,
755
+ version: measuredRowsVersion,
756
+ prefixLen,
757
+ prefixSig,
758
+ tailId: tail.id,
759
+ prefixRowsArr: full.rows.slice(0, prefixLen),
760
+ prefixPrefixRows: full.prefixRows.slice(0, prefixLen + 1),
761
+ prefixTotal: full.prefixRows[prefixLen] || 0,
762
+ };
763
+ return full;
764
+ }
765
+
635
766
  // Stable signature for the transcript row-index / window memos. Changes only
636
767
  // when transcript STRUCTURE changes or the streaming item's estimated height
637
768
  // changes — not on every character. Per-item sigParts are identity-memoized.
@@ -16,8 +16,10 @@ import {
16
16
  streamingMeasuredRowsById,
17
17
  pruneStreamingMeasuredRowsById,
18
18
  transcriptItemVariantKey,
19
- buildTranscriptRowIndex,
19
+ buildTranscriptRowIndexIncremental,
20
20
  transcriptStructureSignature,
21
+ estimateTranscriptItemRowsCached,
22
+ setStreamingBottomPinned,
21
23
  transcriptRenderWindow,
22
24
  resolveAnchorScrollOffset,
23
25
  upperBound,
@@ -55,6 +57,10 @@ export function useTranscriptWindow({
55
57
  }) {
56
58
  const transcriptTotalRowsRef = useRef(0);
57
59
  const preservedScrollDeltaRef = useRef(0);
60
+ // Per-hook-instance settled-prefix row-index cache for the incremental
61
+ // builder. Was module-level (leaked across hook instances); now local so
62
+ // each transcript window owns its own tail-flush cache.
63
+ const incrementalRowIndexCacheRef = useRef(null);
58
64
  // Previous frame's viewport-only geometry (content height + floating-panel
59
65
  // reservation). A floating panel / view (picker/context/usage/text-entry)
60
66
  // open-close changes transcriptContentHeight WITHOUT changing `items`, so the
@@ -120,16 +126,44 @@ export function useTranscriptWindow({
120
126
  // input typing — skip the O(n) signature walk entirely. During streaming the
121
127
  // engine hands us a fresh `items` array each flush, so this memo
122
128
  // recomputes and still tracks the streaming item's height correctly.
123
- const transcriptStructureSig = useMemo(
124
- () => transcriptStructureSignature(items, frameColumns, toolOutputExpanded),
125
- [items, frameColumns, toolOutputExpanded],
126
- );
127
- const transcriptStreamingActive = (items || []).some(
128
- (item) => item?.kind === 'assistant' && item?.streaming,
129
+ // Split the structure signature into prefix (all items except the trailing
130
+ // streaming item) + tail. On a streaming flush only the tail item object is
131
+ // replaced; every prefix item keeps identity, so the prefix sig is O(1)-
132
+ const streamingTailItem = useMemo(() => {
133
+ const last = (items || []).length > 0 ? items[items.length - 1] : null;
134
+ return last && last.kind === 'assistant' && last.streaming ? last : null;
135
+ }, [items]);
136
+ const prefixLen = streamingTailItem ? (items || []).length - 1 : (items || []).length;
137
+ // Key on `items` identity (engine swaps the array every flush — INCLUDING a
138
+ // tool-card patch that replaces a MIDDLE item object), so a same-length
139
+ // middle-item replacement is caught (new object → fresh WeakMap fragment →
140
+ // different prefixSig string). The walk itself is cheap: transcript-
141
+ // StructureSignature reads a WeakMap sigPart per item (no re-estimation for
142
+ // unchanged objects) and joins — O(n) map lookups, not O(n) measurement. The
143
+ // row-index memo stays keyed on the prefixSig STRING, so a tail-only flush
144
+ // (identical prefix objects → identical string) still skips the row rebuild.
145
+ const prefixSig = useMemo(
146
+ () => transcriptStructureSignature(
147
+ streamingTailItem ? (items || []).slice(0, prefixLen) : (items || []),
148
+ frameColumns, toolOutputExpanded,
149
+ ),
150
+ [items, streamingTailItem, prefixLen, frameColumns, toolOutputExpanded],
129
151
  );
130
152
  const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
131
153
  const transcriptPinnedForStreaming = followingRef.current
132
154
  || scrolledUpRowsForPin <= transcriptBottomSlackRows;
155
+ // Publish the pinned state to the streaming-tail estimator BEFORE resolving
156
+ // tailSig / the row-index memo this render, so max(measuredFloor, liveEstimate)
157
+ // applies while bottom-pinned (right geometry on first commit, no judder) and
158
+ // every tail-resolving path reads the same height (no sig/geometry divergence).
159
+ setStreamingBottomPinned(transcriptPinnedForStreaming);
160
+ const tailSig = streamingTailItem
161
+ ? `a${streamingTailItem.id}:${estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)}`
162
+ : '_';
163
+ const transcriptStructureSig = `${prefixSig}#${tailSig}`;
164
+ const transcriptStreamingActive = (items || []).some(
165
+ (item) => item?.kind === 'assistant' && item?.streaming,
166
+ );
133
167
  const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > transcriptBottomSlackRows;
134
168
  const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
135
169
  const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
@@ -140,12 +174,24 @@ export function useTranscriptWindow({
140
174
  (scrolledUpForStreamingMeasure && hasStreamingReadingAnchor)
141
175
  || (scrolledUpForStreamingMeasure && prevEstimateGeometry && !transcriptPinnedForStreaming)
142
176
  ));
143
- const transcriptRowIndex = useMemo(() => buildTranscriptRowIndex(items, {
177
+ // Incremental builder: on a streaming flush where only the trailing assistant
178
+ // item's text grew, it recomputes just the tail row and appends to a cached
179
+ // settled-prefix row-index (O(1) prefix) instead of re-walking all N items.
180
+ // Any structural change (item count, non-tail change, columns, expanded,
181
+ // suppress, measuredRowsVersion) misses the cache and falls back to a full
182
+ // buildTranscriptRowIndex — so the prefix table is byte-identical to a full
183
+ // rebuild for the settled prefix. All those invalidators are folded into the
184
+ // memo deps below (sig captures item/column/expanded structure; the rest are
185
+ // listed explicitly), so the memo only recomputes when one of them changes.
186
+ const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(items, {
144
187
  columns: frameColumns,
145
188
  toolOutputExpanded,
146
189
  suppressMeasuredRowHeights,
147
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: sig captures the relevant item changes; measuredRowsVersion folds in app-level measured height corrections
148
- }), [transcriptStructureSig, measuredRowsVersion, suppressMeasuredRowHeights]);
190
+ measuredRowsVersion,
191
+ cacheRef: incrementalRowIndexCacheRef,
192
+ prefixSig,
193
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: prefixSig/tailSig capture the relevant item changes (raw `items` dropped so a tail-only flush never re-enters the builder via array identity); measuredRowsVersion folds in app-level measured height corrections
194
+ }), [prefixSig, tailSig, measuredRowsVersion, suppressMeasuredRowHeights]);
149
195
  // ── Same-frame anchor lock ───────────────────────────────────────────────
150
196
  // While the user reads older transcript (anchor captured, not dirty), resolve
151
197
  // the scroll offset that keeps the anchored viewport-top row fixed for THIS
@@ -412,6 +458,17 @@ export function useTranscriptWindow({
412
458
  const idPrev = streamingMeasuredRowsById.get(item.id);
413
459
  if (!idPrev || idPrev.rows !== measured || idPrev.columns !== frameColumns || idPrev.toolExpanded !== toolExpandedFlag) {
414
460
  streamingMeasuredRowsById.set(item.id, { rows: measured, columns: frameColumns, toolExpanded: toolExpandedFlag });
461
+ // ALWAYS bump on a real id-store change, bottom-pinned or not.
462
+ // estimateTranscriptItemRowsCached (transcript-window.mjs) now
463
+ // returns ONLY this id-store floor for a streaming item (defer-
464
+ // growth: no live-estimate blend), so it is the SOLE path that ever
465
+ // feeds this item's grown height into transcriptStructureSig /
466
+ // buildTranscriptRowIndex. Skipping the bump while bottom-pinned
467
+ // used to be safe because the blended estimate kept the signature
468
+ // moving on its own each flush; with the blend gone, skipping it
469
+ // here would freeze totalRows/prefixRows for this item until the
470
+ // stream settles — invisible growth for anything reading rowIndex
471
+ // (windowing, maxScrollRows, an anchor captured mid-stream).
415
472
  changed = true;
416
473
  }
417
474
  }
@@ -84,7 +84,7 @@ function hasActiveStatuslineWork(line, agentWorkers = [], agentJobs = [], active
84
84
  return hasRunningStatuslineWorkers(agentWorkers, agentJobs)
85
85
  || hasActiveStatuslineTools(activeTools)
86
86
  || /\bRunning \d+ (?:Agents?|Shells?)\b/.test(stripAnsi(line))
87
- || /\b(?:Exploring|Searching)\b/.test(stripAnsi(line));
87
+ || /\b(?:Exploring|Searching|Memory)\b/.test(stripAnsi(line));
88
88
  }
89
89
 
90
90
  function bootFullRenderEligible(mountAtMs, line, agentWorkers = [], agentJobs = [], activeTools = null) {