mixdog 0.9.16 → 0.9.17

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 (44) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/bench/cache-probe-tasks.json +1 -1
  4. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  5. package/scripts/bench/r4-mixed-tasks.json +1 -1
  6. package/scripts/bench/review-tasks.json +1 -1
  7. package/scripts/build-runtime-windows.ps1 +242 -242
  8. package/scripts/provider-toolcall-test.mjs +79 -2
  9. package/scripts/recall-usecase-cases.json +1 -1
  10. package/scripts/smoke-runtime-negative.ps1 +106 -106
  11. package/scripts/tool-efficiency-diag.mjs +1 -1
  12. package/src/mixdog-session-runtime.mjs +12 -0
  13. package/src/rules/lead/02-channels.md +3 -3
  14. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +9 -0
  16. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -0
  18. package/src/runtime/agent/orchestrator/session/context-utils.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -0
  20. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +32 -18
  21. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  22. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +108 -30
  23. package/src/runtime/agent/orchestrator/session/store.mjs +5 -0
  24. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  25. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +3 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +15 -15
  27. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  28. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  29. package/src/runtime/channels/lib/runtime-paths.mjs +8 -19
  30. package/src/runtime/memory/index.mjs +37 -0
  31. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  32. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  33. package/src/runtime/shared/atomic-file.mjs +110 -0
  34. package/src/runtime/shared/transcript-writer.mjs +46 -4
  35. package/src/session-runtime/provider-models.mjs +47 -8
  36. package/src/tui/app/transcript-window.mjs +115 -6
  37. package/src/tui/app/use-transcript-window.mjs +58 -7
  38. package/src/tui/components/StatusLine.jsx +1 -1
  39. package/src/tui/dist/index.mjs +373 -81
  40. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  41. package/src/tui/engine.mjs +6 -4
  42. package/src/tui/index.jsx +97 -6
  43. package/src/ui/statusline-segments.mjs +54 -36
  44. package/src/ui/statusline.mjs +141 -95
@@ -573,20 +573,31 @@ function streamingEstimateRows(item, columns, toolOutputExpanded) {
573
573
  return Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
574
574
  }
575
575
 
576
- function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
576
+ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpanded) {
577
577
  if (!item) return Math.max(1, Math.ceil(estimateTranscriptItemRows(item, columns, toolOutputExpanded)));
578
578
  if (shouldSuppressFullyFailedToolItem(item)) return 0;
579
579
  if (item.kind === 'assistant' && item.streaming) {
580
- const estimate = streamingEstimateRows(item, columns, toolOutputExpanded);
581
580
  const toolExpanded = toolOutputExpanded ? 1 : 0;
582
581
  const idEntry = streamingMeasuredRowsById.get(item.id);
583
582
  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);
583
+ // Defer-growth: while a confirmed (post-commit Yoga) measurement exists
584
+ // for this streaming id, THIS render keeps that value instead of
585
+ // folding in the freshly-grown estimate. Combining
586
+ // max(measuredFloor, liveEstimate) let per-flush row growth reach
587
+ // totalRows/scrollOffset a full frame BEFORE Yoga confirmed the real
588
+ // wrap — the estimate could over/undercount vs the real layout, and
589
+ // the next commit's harvest then corrected it, bouncing an anchored or
590
+ // bottom-pinned offset by the mismatch. Freezing at the last confirmed
591
+ // height means growth only reaches the row index on the SAME frame the
592
+ // harvest (use-transcript-window.mjs) writes the new measured value and
593
+ // bumps measuredRowsVersion — the "measured frame" consumes it, not the
594
+ // estimate frame.
595
+ return idEntry.rows;
587
596
  }
588
597
  if (idEntry) streamingMeasuredRowsById.delete(item.id);
589
- return estimate;
598
+ // No confirmed measurement yet for this id (item just started streaming):
599
+ // nothing to defer against, so the first frame falls back to the estimate.
600
+ return streamingEstimateRows(item, columns, toolOutputExpanded);
590
601
  }
591
602
  if (item.kind === 'assistant' && streamingMeasuredRowsById.has(item.id)) {
592
603
  // Item settled (no longer streaming): the id-keyed floor is no longer
@@ -632,6 +643,104 @@ export function buildTranscriptRowIndex(items, {
632
643
  return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
633
644
  }
634
645
 
646
+ // ── Incremental streaming-tail row-index cache ─────────────────────────────
647
+ // On a streaming flush the engine swaps `items` for a fresh array in which
648
+ // ONLY the trailing assistant item's text has grown; every settled item ahead
649
+ // of it is byte-identical geometry. buildTranscriptRowIndex re-walks all N
650
+ // items each flush (O(n) per ~16ms frame) even though only the last row's
651
+ // height can change. This cache holds the SETTLED-PREFIX row-index (all items
652
+ // except the trailing streaming assistant item) keyed on a stable signature of
653
+ // that prefix + columns + expanded + suppress + measuredRowsVersion. When the
654
+ // only difference since last flush is the trailing streaming item, we recompute
655
+ // just that one tail row and append it to the cached prefixRows. Any structural
656
+ // change (item count, non-tail change, columns, expanded, suppress, version)
657
+ // invalidates the cache and falls back to a full buildTranscriptRowIndex.
658
+ // The cache is per-hook-instance now (passed in as `cacheRef`); there is no
659
+ // module-level mutable state to leak across hook instances.
660
+
661
+ /** True when `items` ends with a streaming assistant item (the growing tail). */
662
+ function trailingStreamingItem(allItems) {
663
+ const last = allItems.length > 0 ? allItems[allItems.length - 1] : null;
664
+ return last && last.kind === 'assistant' && last.streaming ? last : null;
665
+ }
666
+
667
+ export function buildTranscriptRowIndexIncremental(items, {
668
+ columns = 80,
669
+ toolOutputExpanded = false,
670
+ suppressMeasuredRowHeights = false,
671
+ measuredRowsVersion = 0,
672
+ cacheRef = null,
673
+ prefixSig = null,
674
+ } = {}) {
675
+ const allItems = Array.isArray(items) ? items : [];
676
+ const tail = trailingStreamingItem(allItems);
677
+ // Per-hook-instance cache holder (useRef object). Fall back to a throwaway
678
+ // holder if none supplied so the builder still works in isolation.
679
+ const holder = cacheRef || { current: null };
680
+ // No streaming tail → nothing incremental to exploit; drop any stale cache and
681
+ // do the normal full build. (The memo layer already skips recompute when the
682
+ // structure signature is unchanged, so a settled transcript pays this once.)
683
+ if (!tail) {
684
+ holder.current = null;
685
+ return buildTranscriptRowIndex(allItems, {
686
+ columns, toolOutputExpanded, suppressMeasuredRowHeights,
687
+ });
688
+ }
689
+ const prefixLen = allItems.length - 1;
690
+ const cache = holder.current;
691
+ // Prefix identity is carried by `prefixSig` (a hash of every prefix item's
692
+ // WeakMap sigPart, computed once in the hook). String equality catches a
693
+ // same-length MIDDLE-item replacement (tool-card patch swaps a middle object →
694
+ // fresh fragment → different sig), which a last-item ref check would miss.
695
+ // Fast path: same prefix length + tail id + prefixSig, and columns/expanded/
696
+ // suppress/version all match. Only the tail row can differ → recompute + append.
697
+ if (cache
698
+ && cache.columns === columns
699
+ && cache.toolExpanded === (toolOutputExpanded ? 1 : 0)
700
+ && cache.suppress === suppressMeasuredRowHeights
701
+ && cache.version === measuredRowsVersion
702
+ && cache.prefixLen === prefixLen
703
+ && prefixSig != null
704
+ && cache.prefixSig === prefixSig
705
+ && cache.tailId === tail.id) {
706
+ // prefixSig matches → prefix rows provably unchanged; NO prefix walk / no
707
+ // re-estimation. Only the tail row can differ, recompute it.
708
+ {
709
+ const tailMeasured = suppressMeasuredRowHeights
710
+ ? null
711
+ : measuredTranscriptRows(tail, columns, toolOutputExpanded);
712
+ const tailRows = tailMeasured != null
713
+ ? tailMeasured
714
+ : estimateTranscriptItemRowsCached(tail, columns, toolOutputExpanded);
715
+ const rows = cache.prefixRowsArr.slice();
716
+ rows.push(tailRows);
717
+ const prefixRows = cache.prefixPrefixRows.slice();
718
+ prefixRows.push(cache.prefixTotal + tailRows);
719
+ return { rows, prefixRows, totalRows: prefixRows[allItems.length] || 0 };
720
+ }
721
+ }
722
+ // Cache miss: full build, then repopulate the settled-prefix cache so the NEXT
723
+ // flush (only the tail grown) takes the fast path. The prefix arrays are the
724
+ // full-build outputs truncated before the tail row — byte-identical to a
725
+ // full rebuild's prefix by construction.
726
+ const full = buildTranscriptRowIndex(allItems, {
727
+ columns, toolOutputExpanded, suppressMeasuredRowHeights,
728
+ });
729
+ holder.current = {
730
+ columns,
731
+ toolExpanded: toolOutputExpanded ? 1 : 0,
732
+ suppress: suppressMeasuredRowHeights,
733
+ version: measuredRowsVersion,
734
+ prefixLen,
735
+ prefixSig,
736
+ tailId: tail.id,
737
+ prefixRowsArr: full.rows.slice(0, prefixLen),
738
+ prefixPrefixRows: full.prefixRows.slice(0, prefixLen + 1),
739
+ prefixTotal: full.prefixRows[prefixLen] || 0,
740
+ };
741
+ return full;
742
+ }
743
+
635
744
  // Stable signature for the transcript row-index / window memos. Changes only
636
745
  // when transcript STRUCTURE changes or the streaming item's estimated height
637
746
  // changes — not on every character. Per-item sigParts are identity-memoized.
@@ -16,8 +16,9 @@ import {
16
16
  streamingMeasuredRowsById,
17
17
  pruneStreamingMeasuredRowsById,
18
18
  transcriptItemVariantKey,
19
- buildTranscriptRowIndex,
19
+ buildTranscriptRowIndexIncremental,
20
20
  transcriptStructureSignature,
21
+ estimateTranscriptItemRowsCached,
21
22
  transcriptRenderWindow,
22
23
  resolveAnchorScrollOffset,
23
24
  upperBound,
@@ -55,6 +56,10 @@ export function useTranscriptWindow({
55
56
  }) {
56
57
  const transcriptTotalRowsRef = useRef(0);
57
58
  const preservedScrollDeltaRef = useRef(0);
59
+ // Per-hook-instance settled-prefix row-index cache for the incremental
60
+ // builder. Was module-level (leaked across hook instances); now local so
61
+ // each transcript window owns its own tail-flush cache.
62
+ const incrementalRowIndexCacheRef = useRef(null);
58
63
  // Previous frame's viewport-only geometry (content height + floating-panel
59
64
  // reservation). A floating panel / view (picker/context/usage/text-entry)
60
65
  // open-close changes transcriptContentHeight WITHOUT changing `items`, so the
@@ -120,10 +125,33 @@ export function useTranscriptWindow({
120
125
  // input typing — skip the O(n) signature walk entirely. During streaming the
121
126
  // engine hands us a fresh `items` array each flush, so this memo
122
127
  // recomputes and still tracks the streaming item's height correctly.
123
- const transcriptStructureSig = useMemo(
124
- () => transcriptStructureSignature(items, frameColumns, toolOutputExpanded),
125
- [items, frameColumns, toolOutputExpanded],
128
+ // Split the structure signature into prefix (all items except the trailing
129
+ // streaming item) + tail. On a streaming flush only the tail item object is
130
+ // replaced; every prefix item keeps identity, so the prefix sig is O(1)-
131
+ const streamingTailItem = useMemo(() => {
132
+ const last = (items || []).length > 0 ? items[items.length - 1] : null;
133
+ return last && last.kind === 'assistant' && last.streaming ? last : null;
134
+ }, [items]);
135
+ const prefixLen = streamingTailItem ? (items || []).length - 1 : (items || []).length;
136
+ // Key on `items` identity (engine swaps the array every flush — INCLUDING a
137
+ // tool-card patch that replaces a MIDDLE item object), so a same-length
138
+ // middle-item replacement is caught (new object → fresh WeakMap fragment →
139
+ // different prefixSig string). The walk itself is cheap: transcript-
140
+ // StructureSignature reads a WeakMap sigPart per item (no re-estimation for
141
+ // unchanged objects) and joins — O(n) map lookups, not O(n) measurement. The
142
+ // row-index memo stays keyed on the prefixSig STRING, so a tail-only flush
143
+ // (identical prefix objects → identical string) still skips the row rebuild.
144
+ const prefixSig = useMemo(
145
+ () => transcriptStructureSignature(
146
+ streamingTailItem ? (items || []).slice(0, prefixLen) : (items || []),
147
+ frameColumns, toolOutputExpanded,
148
+ ),
149
+ [items, streamingTailItem, prefixLen, frameColumns, toolOutputExpanded],
126
150
  );
151
+ const tailSig = streamingTailItem
152
+ ? `a${streamingTailItem.id}:${estimateTranscriptItemRowsCached(streamingTailItem, frameColumns, toolOutputExpanded)}`
153
+ : '_';
154
+ const transcriptStructureSig = `${prefixSig}#${tailSig}`;
127
155
  const transcriptStreamingActive = (items || []).some(
128
156
  (item) => item?.kind === 'assistant' && item?.streaming,
129
157
  );
@@ -140,12 +168,24 @@ export function useTranscriptWindow({
140
168
  (scrolledUpForStreamingMeasure && hasStreamingReadingAnchor)
141
169
  || (scrolledUpForStreamingMeasure && prevEstimateGeometry && !transcriptPinnedForStreaming)
142
170
  ));
143
- const transcriptRowIndex = useMemo(() => buildTranscriptRowIndex(items, {
171
+ // Incremental builder: on a streaming flush where only the trailing assistant
172
+ // item's text grew, it recomputes just the tail row and appends to a cached
173
+ // settled-prefix row-index (O(1) prefix) instead of re-walking all N items.
174
+ // Any structural change (item count, non-tail change, columns, expanded,
175
+ // suppress, measuredRowsVersion) misses the cache and falls back to a full
176
+ // buildTranscriptRowIndex — so the prefix table is byte-identical to a full
177
+ // rebuild for the settled prefix. All those invalidators are folded into the
178
+ // memo deps below (sig captures item/column/expanded structure; the rest are
179
+ // listed explicitly), so the memo only recomputes when one of them changes.
180
+ const transcriptRowIndex = useMemo(() => buildTranscriptRowIndexIncremental(items, {
144
181
  columns: frameColumns,
145
182
  toolOutputExpanded,
146
183
  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]);
184
+ measuredRowsVersion,
185
+ cacheRef: incrementalRowIndexCacheRef,
186
+ prefixSig,
187
+ // 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
188
+ }), [prefixSig, tailSig, measuredRowsVersion, suppressMeasuredRowHeights]);
149
189
  // ── Same-frame anchor lock ───────────────────────────────────────────────
150
190
  // While the user reads older transcript (anchor captured, not dirty), resolve
151
191
  // the scroll offset that keeps the anchored viewport-top row fixed for THIS
@@ -412,6 +452,17 @@ export function useTranscriptWindow({
412
452
  const idPrev = streamingMeasuredRowsById.get(item.id);
413
453
  if (!idPrev || idPrev.rows !== measured || idPrev.columns !== frameColumns || idPrev.toolExpanded !== toolExpandedFlag) {
414
454
  streamingMeasuredRowsById.set(item.id, { rows: measured, columns: frameColumns, toolExpanded: toolExpandedFlag });
455
+ // ALWAYS bump on a real id-store change, bottom-pinned or not.
456
+ // estimateTranscriptItemRowsCached (transcript-window.mjs) now
457
+ // returns ONLY this id-store floor for a streaming item (defer-
458
+ // growth: no live-estimate blend), so it is the SOLE path that ever
459
+ // feeds this item's grown height into transcriptStructureSig /
460
+ // buildTranscriptRowIndex. Skipping the bump while bottom-pinned
461
+ // used to be safe because the blended estimate kept the signature
462
+ // moving on its own each flush; with the blend gone, skipping it
463
+ // here would freeze totalRows/prefixRows for this item until the
464
+ // stream settles — invisible growth for anything reading rowIndex
465
+ // (windowing, maxScrollRows, an anchor captured mid-stream).
415
466
  changed = true;
416
467
  }
417
468
  }
@@ -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) {