mixdog 0.9.65 → 0.9.66

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/app.mjs +2 -2
  3. package/src/cli.mjs +1 -1
  4. package/src/repl.mjs +72 -6
  5. package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
  6. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
  7. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
  8. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
  9. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +79 -18
  10. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +4 -0
  11. package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
  12. package/src/session-runtime/context-status.mjs +2 -1
  13. package/src/session-runtime/provider-request-tools.mjs +6 -0
  14. package/src/session-runtime/tool-catalog-schema.mjs +5 -1
  15. package/src/tui/app/transcript-window.mjs +16 -0
  16. package/src/tui/app/use-transcript-window.mjs +36 -1
  17. package/src/tui/components/Markdown.jsx +15 -1
  18. package/src/tui/components/Message.jsx +1 -1
  19. package/src/tui/components/PromptInput.jsx +7 -0
  20. package/src/tui/dist/index.mjs +307 -79
  21. package/src/tui/engine/frame-batched-store.mjs +5 -3
  22. package/src/tui/engine/render-timing.mjs +28 -0
  23. package/src/tui/engine/session-api-ext.mjs +7 -0
  24. package/src/tui/engine/tui-steering-persist.mjs +25 -4
  25. package/src/tui/engine.mjs +9 -1
  26. package/src/tui/index.jsx +11 -3
  27. package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
  28. package/src/tui/markdown/render-ansi.mjs +34 -1
  29. package/src/tui/markdown/stream-fence.mjs +44 -7
  30. package/src/tui/markdown/streaming-markdown.mjs +95 -27
  31. package/src/ui/stream-finalize.mjs +45 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.65",
3
+ "version": "0.9.66",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
package/src/app.mjs CHANGED
@@ -39,8 +39,8 @@ export { parseHeadlessRoleCommand };
39
39
  * tools, and the canonical Ink TUI. Vendored reference code is not executable
40
40
  * from this entry point.
41
41
  */
42
- export async function run(argv = []) {
43
- const invocation = classifyCliInvocation(argv);
42
+ export async function run(argv = [], classifiedInvocation = null) {
43
+ const invocation = classifiedInvocation ?? classifyCliInvocation(argv);
44
44
  // Headless commands establish their own audited environment after route
45
45
  // validation; do not let an inherited MIXDOG_BOOT_PROFILE affect that path.
46
46
  if (invocation.kind !== 'headless' && invocation.skipHostPrelude !== true) {
package/src/cli.mjs CHANGED
@@ -57,7 +57,7 @@ async function main() {
57
57
  } catch { /* fall through to in-place run */ }
58
58
  }
59
59
  const { run } = await import('./app.mjs');
60
- return await run(argv);
60
+ return await run(argv, invocation);
61
61
  }
62
62
 
63
63
  main().then(async (code) => {
package/src/repl.mjs CHANGED
@@ -14,7 +14,8 @@
14
14
  * Live token streaming via onTextDelta conflicts with post-hoc markdown
15
15
  * rendering (you can't style a heading until you've seen the whole line).
16
16
  * We choose: stream raw tokens live so the turn FEELS alive, then on turn
17
- * end clear the streamed raw block and re-print the markdown-rendered text.
17
+ * end replace only changed rows with markdown-rendered text, falling back to
18
+ * a full block redraw when wrapping or row structure makes patching unsafe.
18
19
  * Rationale: the alternative (buffer silently, render once) loses all live
19
20
  * feedback on slow turns, which is the worse UX. The re-render is cheap and
20
21
  * only runs when stdout is a TTY (so piped/non-TTY output stays clean and is
@@ -27,12 +28,14 @@ import { basename } from 'node:path';
27
28
  import { bold, dim, cyan, green, red, yellow, colorEnabled } from './ui/ansi.mjs';
28
29
  import { printHelp } from './help.mjs';
29
30
  import { createSessionStats, applyUsageDelta } from './ui/session-stats.mjs';
31
+ import { buildStreamFinalPatch } from './ui/stream-finalize.mjs';
30
32
 
31
33
  let runtimeModulePromise = null;
32
34
  let markdownModulePromise = null;
33
35
  let toolCardModulePromise = null;
34
36
  let statuslineModulePromise = null;
35
37
  let shutdownModulePromise = null;
38
+ const STREAM_WRITE_BATCH_MS = 8;
36
39
 
37
40
  function loadRuntimeModule() {
38
41
  runtimeModulePromise ??= import('./mixdog-session-runtime.mjs');
@@ -159,6 +162,15 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
159
162
  stdout.write('\n');
160
163
 
161
164
  let streamedText = '';
165
+ let streamedChunks = [];
166
+ let streamedTextDirty = false;
167
+ const currentStreamedText = () => {
168
+ if (!streamedTextDirty) return streamedText;
169
+ streamedText = streamedChunks.join('');
170
+ streamedChunks = streamedText ? [streamedText] : [];
171
+ streamedTextDirty = false;
172
+ return streamedText;
173
+ };
162
174
  let printedAny = false;
163
175
  let printedToolCard = false;
164
176
  // After the blank line emitted above, the cursor sits at a fresh line
@@ -166,6 +178,38 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
166
178
  // a leading newline is only needed to break away from un-terminated
167
179
  // streamed text, never between consecutive cards.
168
180
  let atLineStart = true;
181
+ let pendingStreamChunks = [];
182
+ let streamFlushTimer = null;
183
+ let streamWriteError = null;
184
+ const flushStream = () => {
185
+ if (streamFlushTimer) {
186
+ clearTimeout(streamFlushTimer);
187
+ streamFlushTimer = null;
188
+ }
189
+ if (streamWriteError) {
190
+ const error = streamWriteError;
191
+ streamWriteError = null;
192
+ throw error;
193
+ }
194
+ if (pendingStreamChunks.length === 0) return;
195
+ const text = pendingStreamChunks.join('');
196
+ pendingStreamChunks = [];
197
+ stdout.write(text);
198
+ };
199
+ const queueStreamChunk = (chunk) => {
200
+ if (streamWriteError) throw streamWriteError;
201
+ pendingStreamChunks.push(chunk);
202
+ if (streamFlushTimer) return;
203
+ streamFlushTimer = setTimeout(() => {
204
+ streamFlushTimer = null;
205
+ try {
206
+ flushStream();
207
+ } catch (error) {
208
+ streamWriteError = error;
209
+ }
210
+ }, STREAM_WRITE_BATCH_MS);
211
+ streamFlushTimer.unref?.();
212
+ };
169
213
  try {
170
214
  const runtime = await ensureRuntime();
171
215
  const { result } = await runtime.ask(
@@ -173,6 +217,7 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
173
217
  {
174
218
  onToolCall: async (_iter, calls) => {
175
219
  for (const c of calls || []) {
220
+ flushStream();
176
221
  printedToolCard = true;
177
222
  const lead = atLineStart ? '' : '\n';
178
223
  stdout.write(lead + (await renderToolCardLazy(c)) + '\n');
@@ -181,16 +226,21 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
181
226
  },
182
227
  onTextDelta: (chunk) => {
183
228
  printedAny = true;
184
- streamedText += chunk;
185
- stdout.write(chunk);
229
+ streamedChunks.push(chunk);
230
+ streamedTextDirty = true;
231
+ queueStreamChunk(chunk);
186
232
  atLineStart = chunk.endsWith('\n');
187
233
  },
188
234
  onTextReset: ({ chars } = {}) => {
189
235
  const count = Math.max(0, Number(chars) || 0);
190
236
  if (!count || !colorEnabled() || printedToolCard) return false;
237
+ flushStream();
238
+ streamedText = currentStreamedText();
191
239
  const remaining = streamedText.slice(0, Math.max(0, streamedText.length - count));
192
240
  eraseStreamedBlock(streamedText);
193
241
  streamedText = remaining;
242
+ streamedChunks = remaining ? [remaining] : [];
243
+ streamedTextDirty = false;
194
244
  if (remaining) stdout.write(remaining);
195
245
  printedAny = !!remaining;
196
246
  atLineStart = !remaining || remaining.endsWith('\n');
@@ -199,7 +249,9 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
199
249
  onUsageDelta: (delta) => applyUsageDelta(stats, delta),
200
250
  },
201
251
  );
252
+ flushStream();
202
253
 
254
+ streamedText = currentStreamedText();
203
255
  const finalText = (result?.content != null && String(result.content)) || streamedText;
204
256
 
205
257
  // Approach (a): clear the raw streamed block and re-print as markdown.
@@ -208,8 +260,16 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
208
260
  // into a pipe).
209
261
  if (finalText) {
210
262
  if (printedAny && colorEnabled() && !printedToolCard) {
211
- eraseStreamedBlock(streamedText);
212
- stdout.write(await renderMarkdownLazy(finalText) + '\n');
263
+ const rendered = await renderMarkdownLazy(finalText);
264
+ const patch = buildStreamFinalPatch(streamedText, rendered, {
265
+ columns: stdout.columns || 80,
266
+ });
267
+ if (patch) stdout.write(patch.output);
268
+ else {
269
+ eraseStreamedBlock(streamedText);
270
+ stdout.write(rendered);
271
+ }
272
+ stdout.write('\n');
213
273
  } else if (!printedAny) {
214
274
  // Nothing streamed live (provider without onTextDelta) — render once.
215
275
  stdout.write(await renderMarkdownLazy(finalText) + '\n');
@@ -235,7 +295,13 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
235
295
  rawContextWindow: runtime.rawContextWindow,
236
296
  })) + '\n');
237
297
  } catch (error) {
238
- stdout.write('\n' + red(`[error] ${error?.message || error}`) + '\n');
298
+ let displayError = error;
299
+ try {
300
+ flushStream();
301
+ } catch (flushError) {
302
+ displayError = flushError;
303
+ }
304
+ stdout.write('\n' + red(`[error] ${displayError?.message || displayError}`) + '\n');
239
305
  }
240
306
 
241
307
  stdout.write('\n');
@@ -1,7 +1,10 @@
1
1
  import { isOffloadedToolResultText } from './tool-result-offload.mjs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { createRequire } from 'node:module';
4
- import { providerNativeToolPrefixCount } from '../../../../session-runtime/provider-request-tools.mjs';
4
+ import {
5
+ isFinalizedProviderRequestTools,
6
+ providerNativeToolPrefixCount,
7
+ } from '../../../../session-runtime/provider-request-tools.mjs';
5
8
  import { contentFileDescriptors, contentImageDescriptors, contentToText } from '../providers/media-normalization.mjs';
6
9
  export {
7
10
  DEFAULT_COMPACTION_BUFFER_TOKENS,
@@ -591,9 +594,10 @@ export function summarizeContextMessages(messages) {
591
594
  if (!Array.isArray(messages)) return contextSummaryResult(emptyContextSummaryState(), 0);
592
595
  let cached = contextTranscriptMemo.get(messages);
593
596
  if (!cached || messages.length < cached.count) {
594
- cached = { count: 0, contributions: [], state: emptyContextSummaryState(), revision: 0 };
597
+ cached = { count: 0, contributions: [], state: emptyContextSummaryState(), revision: 0, result: null };
595
598
  contextTranscriptMemo.set(messages, cached);
596
599
  }
600
+ let changed = cached.result === null;
597
601
  for (let index = 0; index < messages.length; index += 1) {
598
602
  const previous = cached.contributions[index];
599
603
  const contribution = contextMessageContribution(messages[index]);
@@ -602,10 +606,13 @@ export function summarizeContextMessages(messages) {
602
606
  cached.contributions[index] = contribution;
603
607
  applyContextMessageContribution(cached.state, contribution, 1);
604
608
  cached.revision += 1;
609
+ changed = true;
605
610
  }
606
611
  cached.contributions.length = messages.length;
607
612
  cached.count = messages.length;
608
- return contextSummaryResult(cached.state, messages.length);
613
+ if (!changed && cached.result) return cached.result;
614
+ cached.result = contextSummaryResult(cached.state, messages.length);
615
+ return cached.result;
609
616
  }
610
617
 
611
618
  // A stable warm-cache generation for consumers that cache a derived view of
@@ -617,11 +624,47 @@ export function contextMessagesRevision(messages) {
617
624
  return contextTranscriptMemo.get(messages)?.revision || 0;
618
625
  }
619
626
 
627
+ export function summarizeContextMessagesAtRevision(messages, revision) {
628
+ if (Array.isArray(messages)) {
629
+ const cached = contextTranscriptMemo.get(messages);
630
+ if (cached
631
+ && cached.count === messages.length
632
+ && cached.revision === revision
633
+ && cached.result) return cached.result;
634
+ }
635
+ return summarizeContextMessages(messages);
636
+ }
637
+
620
638
  // Hash only estimator/provider-visible projections. In particular, images
621
639
  // contribute their visual count but never their raw data-url/base64 bytes.
640
+ const contextMessagesSignatureMemo = new WeakMap();
641
+ const CONTEXT_SIGNATURE_COUNTS_MAX = 4;
642
+
622
643
  export function contextMessagesSignature(messages, count = messages?.length) {
623
644
  const list = Array.isArray(messages) ? messages : [];
624
645
  const end = Math.max(0, Math.min(list.length, Number.isInteger(count) ? count : list.length));
646
+ let contributions = null;
647
+ let signatures = null;
648
+ if (Array.isArray(messages)) {
649
+ summarizeContextMessages(messages);
650
+ contributions = contextTranscriptMemo.get(messages)?.contributions.slice(0, end) || [];
651
+ signatures = contextMessagesSignatureMemo.get(messages);
652
+ const previous = signatures?.get(end);
653
+ if (previous && previous.contributions.length === contributions.length) {
654
+ let unchanged = true;
655
+ for (let index = 0; index < contributions.length; index += 1) {
656
+ if (previous.contributions[index] !== contributions[index]) {
657
+ unchanged = false;
658
+ break;
659
+ }
660
+ }
661
+ if (unchanged) {
662
+ signatures.delete(end);
663
+ signatures.set(end, previous);
664
+ return previous.signature;
665
+ }
666
+ }
667
+ }
625
668
  const hash = createHash('sha256');
626
669
  for (let index = 0; index < end; index += 1) {
627
670
  const message = list[index];
@@ -634,11 +677,22 @@ export function contextMessagesSignature(messages, count = messages?.length) {
634
677
  ]));
635
678
  hash.update('\0');
636
679
  }
637
- return hash.digest('hex');
680
+ const signature = hash.digest('hex');
681
+ if (Array.isArray(messages)) {
682
+ signatures ||= new Map();
683
+ if (signatures.has(end)) signatures.delete(end);
684
+ signatures.set(end, { contributions, signature });
685
+ while (signatures.size > CONTEXT_SIGNATURE_COUNTS_MAX) {
686
+ const oldest = signatures.keys().next().value;
687
+ if (oldest === undefined) break;
688
+ signatures.delete(oldest);
689
+ }
690
+ contextMessagesSignatureMemo.set(messages, signatures);
691
+ }
692
+ return signature;
638
693
  }
639
694
 
640
- const toolSchemaTokenMemo = new WeakMap();
641
- const requestReserveTokenMemo = new WeakMap();
695
+ const toolSchemaAnalysisMemo = new WeakMap();
642
696
 
643
697
  function serializeToolSchemas(tools) {
644
698
  const list = Array.isArray(tools) ? tools : [];
@@ -661,7 +715,19 @@ function serializeToolSchemas(tools) {
661
715
  }
662
716
 
663
717
  export function toolSchemaSignature(tools) {
664
- return createHash('sha256').update(serializeToolSchemas(tools)).digest('hex');
718
+ return analyzeToolSchemas(tools).signature;
719
+ }
720
+
721
+ function analyzeToolSchemas(tools) {
722
+ const list = Array.isArray(tools) ? tools : [];
723
+ const cached = Array.isArray(tools) ? toolSchemaAnalysisMemo.get(tools) : null;
724
+ if (cached && isFinalizedProviderRequestTools(tools)) return cached;
725
+ const text = serializeToolSchemas(list);
726
+ const signature = createHash('sha256').update(text).digest('hex');
727
+ if (cached?.signature === signature) return cached;
728
+ const analysis = { signature, tokens: estimateTokens(text) };
729
+ if (Array.isArray(tools)) toolSchemaAnalysisMemo.set(tools, analysis);
730
+ return analysis;
665
731
  }
666
732
 
667
733
  /**
@@ -674,13 +740,7 @@ export function toolSchemaSignature(tools) {
674
740
  */
675
741
  export function estimateToolSchemaTokens(tools) {
676
742
  if (!Array.isArray(tools) || tools.length === 0) return 0;
677
- const signature = toolSchemaSignature(tools);
678
- const cached = toolSchemaTokenMemo.get(tools);
679
- if (cached?.signature === signature) return cached.value;
680
- const text = serializeToolSchemas(tools);
681
- const tokens = estimateTokens(text);
682
- toolSchemaTokenMemo.set(tools, { signature, value: tokens });
683
- return tokens;
743
+ return analyzeToolSchemas(tools).tokens;
684
744
  }
685
745
 
686
746
  /**
@@ -689,13 +749,8 @@ export function estimateToolSchemaTokens(tools) {
689
749
  * not expose a stable framing cost, so no synthetic fixed allowance is added.
690
750
  */
691
751
  export function estimateRequestReserveTokens(tools) {
692
- if (!Array.isArray(tools)) return estimateToolSchemaTokens(tools);
693
- const signature = toolSchemaSignature(tools);
694
- const cached = requestReserveTokenMemo.get(tools);
695
- if (cached?.signature === signature) return cached.value;
696
- const reserve = estimateToolSchemaTokens(tools);
697
- requestReserveTokenMemo.set(tools, { signature, value: reserve });
698
- return reserve;
752
+ if (!Array.isArray(tools) || tools.length === 0) return estimateToolSchemaTokens(tools);
753
+ return analyzeToolSchemas(tools).tokens;
699
754
  }
700
755
 
701
756
  /**
@@ -18,6 +18,18 @@ const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
18
18
  const PENDING_MESSAGES_MODE = 0o600;
19
19
  const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
20
  const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
21
+ // Replay window for genuine user/steering entries. A cross-surface submit is
22
+ // meant for a LIVE owner; if nothing picked it up within this window, firing
23
+ // it into a session resumed hours or days later reads as a surprise
24
+ // self-injection (user report: stale spool messages replayed on re-entry).
25
+ // Crash+restart recovery within the window still replays normally.
26
+ const STALE_USER_INJECTION_TTL_MS = 30 * 60 * 1000;
27
+
28
+ function isStaleUserInjection(entry, now = Date.now()) {
29
+ if (isCompletionNotificationEntry(entry)) return false;
30
+ const enqueuedAt = Number(entry?.enqueuedAt) || 0;
31
+ return enqueuedAt > 0 && (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
32
+ }
21
33
  // Marker for deferred agent/tool *completion* notifications. Such entries must
22
34
  // never be replayed into a later turn on session resume (out-of-order delivery
23
35
  // is worse than loss — owner decision), so drain discards them. Genuine
@@ -155,11 +167,17 @@ function normalizePendingStore(raw) {
155
167
  const out = { version: 1, updatedAt: storeUpdatedAt, sessions: {}, sessionTouchedAt: {} };
156
168
  for (const [sid, value] of Object.entries(sessions)) {
157
169
  if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
170
+ // Legacy string entries carry no timestamp of their own. Age them from
171
+ // the session's last real enqueue (sessionTouchedAt), NOT the store's
172
+ // updatedAt — the store refreshes on every unrelated write, which made
173
+ // days-old legacy entries look permanently fresh to the stale gate.
174
+ const touchedAt = Number(touchedRaw[sid]);
175
+ const legacyEnqueuedAt = Number.isFinite(touchedAt) && touchedAt > 0 ? touchedAt : storeUpdatedAt;
158
176
  const q = isTuiSteeringPendingKey(sid)
159
177
  ? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
160
178
  : value.map((entry, index) => normalizePersistedEntry(entry, {
161
179
  legacyId: legacyPendingMessageId(sid, index, typeof entry === 'string' ? entry : JSON.stringify(entry)),
162
- fallbackEnqueuedAt: storeUpdatedAt + index,
180
+ fallbackEnqueuedAt: legacyEnqueuedAt + index,
163
181
  })).filter(Boolean);
164
182
  if (q.length > 0) {
165
183
  out.sessions[sid] = q;
@@ -469,6 +487,7 @@ export function hydratePendingMessages(sessionId, options = {}) {
469
487
  let hydrated = [];
470
488
  let alreadyDelivered = [];
471
489
  let staleLedgerEntries = [];
490
+ const staleUserEntries = [];
472
491
  const ledgerSession = loadSession(sessionId);
473
492
  try {
474
493
  const deliveredLedger = new Set(ledgerSession?.deliveredPendingMessageIds || []);
@@ -490,7 +509,16 @@ export function hydratePendingMessages(sessionId, options = {}) {
490
509
  alreadyDelivered.push(entry);
491
510
  return false;
492
511
  }
493
- return id && !inDelivery.has(id) && !acked.has(id);
512
+ if (!id || inDelivery.has(id) || acked.has(id)) return false;
513
+ // Stale genuine user/steering entries must not surprise-inject
514
+ // into a session resumed long after they were queued (user:
515
+ // "re-entering the session suddenly injected old messages").
516
+ // Completion entries keep their own resume-drop policy.
517
+ if (isStaleUserInjection(entry)) {
518
+ staleUserEntries.push(entry);
519
+ return false;
520
+ }
521
+ return true;
494
522
  });
495
523
  // Read-only claim: durable data remains until successful delivery
496
524
  // acknowledges these exact ids. A crash here therefore redelivers.
@@ -506,6 +534,11 @@ export function hydratePendingMessages(sessionId, options = {}) {
506
534
  const cleaned = await acknowledgePendingMessages(sessionId, alreadyDelivered);
507
535
  if (cleaned) cleanupConfirmed.push(...alreadyDelivered);
508
536
  }
537
+ if (staleUserEntries.length > 0) {
538
+ try { process.stderr.write(`[session] dropped ${staleUserEntries.length} stale queued message(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
539
+ const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries);
540
+ if (cleaned) cleanupConfirmed.push(...staleUserEntries);
541
+ }
509
542
  if (cleanupConfirmed.length > 0) {
510
543
  try {
511
544
  // One session save prunes both IDs whose replay spool was removed
@@ -710,6 +743,7 @@ export function drainForeignUserInjections(sessionId) {
710
743
  }
711
744
  } catch { /* ledger unavailable — in-memory sets still guard */ }
712
745
  const taken = [];
746
+ let droppedStale = 0;
713
747
  try {
714
748
  updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
715
749
  const next = normalizePendingStore(raw);
@@ -723,10 +757,14 @@ export function drainForeignUserInjections(sessionId) {
723
757
  && !isCompletionNotificationEntry(entry)
724
758
  && !isLegacyUnmarkedCompletionNotification(text)
725
759
  && text && !isInternalRuntimeNotificationText(text);
726
- if (foreignUser) taken.push(text);
760
+ // Stale foreign submits are removed WITHOUT injecting: they
761
+ // were aimed at a live owner that no longer exists (user:
762
+ // stale spool messages fired on session re-entry days later).
763
+ if (foreignUser && isStaleUserInjection(entry)) droppedStale += 1;
764
+ else if (foreignUser) taken.push(text);
727
765
  else kept.push(entry);
728
766
  }
729
- if (taken.length === 0) return undefined;
767
+ if (taken.length === 0 && droppedStale === 0) return undefined;
730
768
  if (kept.length > 0) next.sessions[sessionId] = kept;
731
769
  else {
732
770
  delete next.sessions[sessionId];
@@ -739,6 +777,9 @@ export function drainForeignUserInjections(sessionId) {
739
777
  try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
740
778
  return [];
741
779
  }
780
+ if (droppedStale > 0) {
781
+ try { process.stderr.write(`[session] dropped ${droppedStale} stale foreign submit(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
782
+ }
742
783
  return taken;
743
784
  }
744
785
 
@@ -136,7 +136,17 @@ export async function clearSessionMessages(sessionId, options = {}) {
136
136
  // fresh id. Skipped for scratch sessions with no real user turn — nothing
137
137
  // worth resuming. Best-effort: any failure here must never block the
138
138
  // clear itself (mirrors the pre-compact failure handling above).
139
- if (hasUserConversationMessage(messages)) {
139
+ // ALSO skipped when the clear carries a compact summary forward
140
+ // (compact_clear/auto-clear): the outgoing transcript is just the compact
141
+ // product whose content the live session retains via the summary, so the
142
+ // fork duplicated it as a confusing extra Recent row ("Re-attached after
143
+ // compaction…" — user report). Plain /clear (no summary kept) still forks.
144
+ const summaryCarriedForward = keep.some((m) => (
145
+ m?.role === 'user'
146
+ && typeof m.content === 'string'
147
+ && m.content.startsWith(SUMMARY_PREFIX)
148
+ ));
149
+ if (hasUserConversationMessage(messages) && !summaryCarriedForward) {
140
150
  try {
141
151
  const forkId = mintSessionId();
142
152
  const fork = {
@@ -3,7 +3,7 @@ import { join } from 'path';
3
3
  import { getPluginData } from '../../config.mjs';
4
4
  import { getStoreDir } from './paths-heartbeat.mjs';
5
5
  import { _storedSessionFromFile } from './serialize.mjs';
6
- import { _sessionSummary, _normalizeSummaryIndex, _upsertSessionSummary, _removeSessionSummary, _pruneSummaryIndexIds } from '../store-summary-index.mjs';
6
+ import { _sessionSummary, _normalizeSummaryIndex, _upsertSessionSummaryRow, _removeSessionSummary, _pruneSummaryIndexIds } from '../store-summary-index.mjs';
7
7
 
8
8
  // Listing is much hotter than writing, especially while the desktop session
9
9
  // browser is open. Keep the compact sidecar in memory after the first read;
@@ -12,27 +12,31 @@ import { _sessionSummary, _normalizeSummaryIndex, _upsertSessionSummary, _remove
12
12
  // path. Pending overlays cover a write that lands before the first listing.
13
13
  export let _summaryRowsCache = null;
14
14
  const _summaryCacheUpserts = new Map();
15
+ const _summaryCacheLatestRows = new Map();
15
16
  export const _summaryCacheRemovals = new Set();
16
17
  export const _summaryCacheVersions = new Map();
17
18
  let _summaryCacheDataDir = null;
19
+ let _summaryRowsViewCache = null;
20
+
21
+ function _invalidateSummaryRowsView() {
22
+ _summaryRowsViewCache = null;
23
+ }
18
24
 
19
25
  export function _ensureSummaryCacheDataDir() {
20
26
  const dataDir = getPluginData();
21
27
  if (_summaryCacheDataDir === dataDir) return;
22
28
  _summaryCacheDataDir = dataDir;
23
29
  _summaryRowsCache = null;
30
+ _summaryRowsViewCache = null;
24
31
  _summaryScanCache.clear();
25
32
  _summaryCacheUpserts.clear();
33
+ _summaryCacheLatestRows.clear();
26
34
  _summaryCacheRemovals.clear();
27
35
  _summaryCacheVersions.clear();
28
36
  }
29
37
 
30
- function _summaryRowsWithLocalMutations(rows, { discardLocalMutations = false } = {}) {
31
- if (discardLocalMutations) {
32
- _summaryCacheUpserts.clear();
33
- _summaryCacheRemovals.clear();
34
- }
35
- const byId = new Map(_normalizeSummaryIndex({ rows }).rows.map((row) => [row.id, row]));
38
+ function _summaryRowsWithLocalMutations(rows) {
39
+ const byId = new Map(rows.map((row) => [row.id, row]));
36
40
  for (const id of _summaryCacheRemovals) byId.delete(id);
37
41
  for (const [id, row] of _summaryCacheUpserts) byId.set(id, row);
38
42
  return [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
@@ -41,14 +45,19 @@ function _summaryRowsWithLocalMutations(rows, { discardLocalMutations = false }
41
45
  export function _setSummaryRowsCache(rows, options) {
42
46
  if (options?.discardLocalMutations === true) {
43
47
  _summaryCacheUpserts.clear();
48
+ _summaryCacheLatestRows.clear();
44
49
  _summaryCacheRemovals.clear();
45
50
  }
46
51
  _summaryRowsCache = _normalizeSummaryIndex({ rows }).rows;
47
- return _summaryRowsWithLocalMutations(_summaryRowsCache);
52
+ _invalidateSummaryRowsView();
53
+ return _cachedSummaryRows();
48
54
  }
49
55
 
50
56
  export function _cachedSummaryRows() {
51
- return _summaryRowsCache === null ? null : _summaryRowsWithLocalMutations(_summaryRowsCache);
57
+ if (_summaryRowsCache === null) return null;
58
+ if (_summaryRowsViewCache) return _summaryRowsViewCache;
59
+ _summaryRowsViewCache = _summaryRowsWithLocalMutations(_summaryRowsCache);
60
+ return _summaryRowsViewCache;
52
61
  }
53
62
 
54
63
  function _setCachedBaseSummary(row) {
@@ -56,21 +65,26 @@ function _setCachedBaseSummary(row) {
56
65
  const byId = new Map(_summaryRowsCache.map((existing) => [existing.id, existing]));
57
66
  byId.set(row.id, row);
58
67
  _summaryRowsCache = [...byId.values()].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
68
+ _invalidateSummaryRowsView();
59
69
  }
60
70
 
61
71
  function _removeCachedBaseSummary(id) {
62
72
  if (_summaryRowsCache === null) return;
63
73
  _summaryRowsCache = _summaryRowsCache.filter((row) => row.id !== id);
74
+ _invalidateSummaryRowsView();
64
75
  }
65
76
 
66
77
  export function _cacheSessionSummary(session) {
67
78
  _ensureSummaryCacheDataDir();
68
79
  const row = _sessionSummary(session);
69
80
  if (!row) return;
70
- _summaryCacheVersions.set(row.id, (_summaryCacheVersions.get(row.id) || 0) + 1);
81
+ const version = (_summaryCacheVersions.get(row.id) || 0) + 1;
82
+ _summaryCacheVersions.set(row.id, version);
71
83
  _summaryCacheRemovals.delete(row.id);
72
84
  _summaryCacheUpserts.set(row.id, row);
73
- return _summaryCacheVersions.get(row.id);
85
+ _summaryCacheLatestRows.set(row.id, { version, row });
86
+ _invalidateSummaryRowsView();
87
+ return version;
74
88
  }
75
89
 
76
90
  export function _uncacheSessionSummary(id) {
@@ -78,24 +92,33 @@ export function _uncacheSessionSummary(id) {
78
92
  if (!id) return;
79
93
  _summaryCacheVersions.set(id, (_summaryCacheVersions.get(id) || 0) + 1);
80
94
  _summaryCacheUpserts.delete(id);
95
+ _summaryCacheLatestRows.delete(id);
81
96
  _summaryCacheRemovals.add(id);
97
+ _invalidateSummaryRowsView();
82
98
  _removeCachedBaseSummary(id);
83
99
  }
84
100
 
85
101
  export function _rollbackCachedSessionSummary(id, version) {
86
102
  if ((_summaryCacheVersions.get(id) || 0) !== version) return;
87
103
  _summaryCacheUpserts.delete(id);
104
+ _summaryCacheLatestRows.delete(id);
105
+ _invalidateSummaryRowsView();
88
106
  }
89
107
 
90
108
  export function _queueSessionSummaryUpsert(session, version = null) {
91
- const row = _sessionSummary(session);
109
+ const latest = version === null ? null : _summaryCacheLatestRows.get(session?.id);
110
+ const row = latest?.version === version ? latest.row : _sessionSummary(session);
92
111
  if (!row) return;
93
112
  _setCachedBaseSummary(row);
94
113
  if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
95
114
  _summaryCacheUpserts.delete(row.id);
115
+ if (_summaryCacheLatestRows.get(row.id)?.version === version) {
116
+ _summaryCacheLatestRows.delete(row.id);
117
+ }
96
118
  _summaryCacheRemovals.delete(row.id);
119
+ _invalidateSummaryRowsView();
97
120
  }
98
- _upsertSessionSummary(session);
121
+ _upsertSessionSummaryRow(row);
99
122
  }
100
123
 
101
124
  export function _queueSessionSummaryRemoval(id) {