mixdog 0.9.64 → 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 (36) 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/runtime/shared/turn-snapshot.mjs +184 -0
  13. package/src/session-runtime/context-status.mjs +2 -1
  14. package/src/session-runtime/provider-request-tools.mjs +6 -0
  15. package/src/session-runtime/runtime-core.mjs +4 -0
  16. package/src/session-runtime/session-turn-api.mjs +7 -0
  17. package/src/session-runtime/tool-catalog-schema.mjs +5 -1
  18. package/src/tui/App.jsx +1 -1
  19. package/src/tui/app/transcript-window.mjs +16 -0
  20. package/src/tui/app/use-transcript-window.mjs +36 -1
  21. package/src/tui/components/Markdown.jsx +15 -1
  22. package/src/tui/components/Message.jsx +1 -1
  23. package/src/tui/components/PromptInput.jsx +7 -0
  24. package/src/tui/dist/index.mjs +431 -81
  25. package/src/tui/engine/frame-batched-store.mjs +5 -3
  26. package/src/tui/engine/live-share.mjs +100 -0
  27. package/src/tui/engine/render-timing.mjs +28 -0
  28. package/src/tui/engine/session-api-ext.mjs +10 -0
  29. package/src/tui/engine/tui-steering-persist.mjs +25 -4
  30. package/src/tui/engine.mjs +36 -1
  31. package/src/tui/index.jsx +11 -3
  32. package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
  33. package/src/tui/markdown/render-ansi.mjs +34 -1
  34. package/src/tui/markdown/stream-fence.mjs +44 -7
  35. package/src/tui/markdown/streaming-markdown.mjs +95 -27
  36. package/src/ui/stream-finalize.mjs +45 -0
@@ -36,25 +36,67 @@ function _isPreviewNoise(text) {
36
36
  return isSessionPreviewNoise(text);
37
37
  }
38
38
 
39
- function _sessionPreview(session) {
40
- const messages = Array.isArray(session?.messages) ? session.messages : [];
41
- for (const m of messages) {
42
- if (m?.role !== 'user') continue;
43
- const raw = _messageText(m.content);
44
- if (_isPreviewNoise(raw)) continue;
45
- const text = _cleanPreview(raw);
46
- if (text) return text;
47
- }
48
- return '';
39
+ const sessionMessageProjectionMemo = new WeakMap();
40
+
41
+ function _previewFromMessage(message) {
42
+ if (message?.role !== 'user') return '';
43
+ const raw = _messageText(message.content);
44
+ if (_isPreviewNoise(raw)) return '';
45
+ return _cleanPreview(raw);
49
46
  }
50
47
 
51
- function _sessionMessageCount(session) {
48
+ function _sessionMessageProjection(session) {
52
49
  const messages = Array.isArray(session?.messages) ? session.messages : [];
50
+ const cached = session && typeof session === 'object'
51
+ ? sessionMessageProjectionMemo.get(session)
52
+ : null;
53
+ let start = 0;
53
54
  let count = 0;
54
- for (const m of messages) {
55
- if (m && (m.role === 'user' || m.role === 'assistant')) count++;
55
+ let preview = '';
56
+ let previewMessage = null;
57
+ const canAppend = cached
58
+ && messages.length >= cached.length
59
+ && (cached.length === 0 || messages[cached.length - 1] === cached.lastMessage);
60
+ if (canAppend) {
61
+ start = cached.length;
62
+ count = cached.count;
63
+ preview = cached.preview;
64
+ previewMessage = cached.previewMessage;
65
+ // The first real user message is stable in normal append-only session
66
+ // flow, but refresh it cheaply so an in-place content scrub still
67
+ // invalidates the cached title source without rescanning the transcript.
68
+ if (previewMessage) {
69
+ const refreshed = _previewFromMessage(previewMessage);
70
+ if (refreshed) preview = refreshed;
71
+ else {
72
+ start = 0;
73
+ count = 0;
74
+ preview = '';
75
+ previewMessage = null;
76
+ }
77
+ }
56
78
  }
57
- return count;
79
+ for (let index = start; index < messages.length; index += 1) {
80
+ const message = messages[index];
81
+ if (message && (message.role === 'user' || message.role === 'assistant')) count += 1;
82
+ if (!preview) {
83
+ const candidate = _previewFromMessage(message);
84
+ if (candidate) {
85
+ preview = candidate;
86
+ previewMessage = message;
87
+ }
88
+ }
89
+ }
90
+ if (session && typeof session === 'object') {
91
+ sessionMessageProjectionMemo.set(session, {
92
+ length: messages.length,
93
+ lastMessage: messages[messages.length - 1] || null,
94
+ count,
95
+ preview,
96
+ previewMessage,
97
+ });
98
+ }
99
+ return { count, preview };
58
100
  }
59
101
 
60
102
  function _positiveNumber(value, fallback = 0) {
@@ -79,9 +121,14 @@ function _desktopSessionSummary(value, cwd = null) {
79
121
 
80
122
  export function _sessionSummary(session) {
81
123
  if (!session?.id) return null;
124
+ const messageProjection = _sessionMessageProjection(session);
82
125
  return {
83
126
  id: String(session.id),
84
127
  updatedAt: _positiveNumber(session.updatedAt, Date.now()),
128
+ // Conversation activity is intentionally separate from lifecycle
129
+ // bookkeeping. Resume/detach saves can advance updatedAt without a
130
+ // user-visible turn and must not reshuffle Recent session lists.
131
+ lastUsedAt: _positiveNumber(session.lastUsedAt, 0),
85
132
  createdAt: _positiveNumber(session.createdAt, 0),
86
133
  lastHeartbeatAt: _positiveNumber(session.lastHeartbeatAt, 0),
87
134
  closed: session.closed === true,
@@ -101,8 +148,8 @@ export function _sessionSummary(session) {
101
148
  task_id: session.task_id || session.taskId || null,
102
149
  permission: session.permission || null,
103
150
  toolPermission: session.toolPermission || null,
104
- messageCount: _sessionMessageCount(session),
105
- preview: _sessionPreview(session),
151
+ messageCount: messageProjection.count,
152
+ preview: messageProjection.preview,
106
153
  generation: typeof session.generation === 'number' ? session.generation : 0,
107
154
  implicitBashSessionId: session.implicitBashSessionId || null,
108
155
  };
@@ -113,6 +160,7 @@ function _normalizeSummaryRow(row) {
113
160
  return {
114
161
  id: row.id,
115
162
  updatedAt: _positiveNumber(row.updatedAt, 0),
163
+ lastUsedAt: _positiveNumber(row.lastUsedAt, 0),
116
164
  createdAt: _positiveNumber(row.createdAt, 0),
117
165
  lastHeartbeatAt: _positiveNumber(row.lastHeartbeatAt, 0),
118
166
  closed: row.closed === true,
@@ -173,6 +221,12 @@ const _pendingUpserts = new Map(); // id → summary row (latest wins)
173
221
  const _pendingRemovals = new Set(); // ids to drop (upsert/removal are mutually exclusive per id)
174
222
  let _summaryRetryTimer = null;
175
223
  let _summaryFlushScheduled = false;
224
+ let _summaryFlushInflight = 0;
225
+
226
+ export function _hasUnsettledSummaryOps() {
227
+ return _pendingUpserts.size > 0 || _pendingRemovals.size > 0
228
+ || _summaryFlushScheduled || _summaryFlushInflight > 0;
229
+ }
176
230
 
177
231
  function _scheduleSummaryRetry() {
178
232
  if (_summaryRetryTimer) return;
@@ -206,6 +260,7 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
206
260
  _pendingRemovals.clear();
207
261
  const mutate = (cur) => {
208
262
  const index = _normalizeSummaryIndex(cur);
263
+ const existingById = new Map(index.rows.map((row) => [row.id, row]));
209
264
  let changed = false;
210
265
  const rows = index.rows.filter((r) => {
211
266
  if (removals.has(r.id) || upserts.has(r.id)) { changed = true; return false; }
@@ -214,7 +269,7 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
214
269
  for (const row of upserts.values()) {
215
270
  const cleanRow = _normalizeSummaryRow(row);
216
271
  if (!cleanRow) continue;
217
- const existing = index.rows.find((r) => r.id === cleanRow.id) || null;
272
+ const existing = existingById.get(cleanRow.id) || null;
218
273
  if (existing && JSON.stringify(existing) === JSON.stringify(cleanRow)) {
219
274
  rows.push(existing);
220
275
  continue;
@@ -248,12 +303,18 @@ export function _flushPendingSummaryOps({ sync = false } = {}) {
248
303
  } catch { requeue(); }
249
304
  return;
250
305
  }
306
+ _summaryFlushInflight++;
251
307
  updateJsonAtomic(summaryIndexPath(), mutate, { compact: true, lock: true, timeoutMs: SUMMARY_LOCK_TIMEOUT_MS })
252
- .catch(() => { requeue(); });
308
+ .catch(() => { requeue(); })
309
+ .finally(() => { _summaryFlushInflight--; });
253
310
  }
254
311
 
255
312
  export function _upsertSessionSummary(session) {
256
313
  const row = _sessionSummary(session);
314
+ _upsertSessionSummaryRow(row);
315
+ }
316
+
317
+ export function _upsertSessionSummaryRow(row) {
257
318
  if (!row) return;
258
319
  _pendingRemovals.delete(row.id);
259
320
  _pendingUpserts.set(row.id, row);
@@ -20,6 +20,10 @@ const LEAD_OWNERS = new Set(['cli', 'user', 'mixdog', 'legacy']);
20
20
  function isLeadVisibleRow(row) {
21
21
  const owner = String(row.owner || 'user').trim().toLowerCase();
22
22
  if (owner && !LEAD_OWNERS.has(owner)) return false;
23
+ // Mirror listLeadSessions: a previewless zero-message row is an unusable
24
+ // scratch (desktop boot leftovers, crashed first turns) — resuming it
25
+ // shows an empty conversation, so the catalog hides it.
26
+ if (!row.preview && row.messageCount === 0) return false;
23
27
  const sourceType = String(row.sourceType || '').trim().toLowerCase();
24
28
  const sourceName = String(row.sourceName || '').trim().toLowerCase();
25
29
  const agent = String(row.agent || '').trim().toLowerCase();
@@ -34,6 +34,7 @@ import {
34
34
  _removeSessionSummary,
35
35
  _pruneSummaryIndexIds,
36
36
  _flushPendingSummaryOps,
37
+ _hasUnsettledSummaryOps,
37
38
  } from './store-summary-index.mjs';
38
39
  // Facade re-export: summary-index API moved to store-summary-index.mjs; keep
39
40
  // prior importers of store.mjs unchanged.
@@ -68,6 +69,9 @@ export { saveSessionAsync, saveSessionAsyncDeferred } from './store/save-worker.
68
69
 
69
70
  /** Module-level map tracking in-flight saves per session ID to prevent concurrent write corruption. */
70
71
  const _savePending = new Map();
72
+ // Disk mtime of the summary index at the last time it was read into (or
73
+ // refreshed as) the in-memory cache base — cross-process staleness detector.
74
+ let _summaryIndexMtimeSeen = 0;
71
75
 
72
76
 
73
77
  /**
@@ -836,7 +840,12 @@ function _overlayUnpersistedSummaryRows(rows, invalidStorageIds = new Set()) {
836
840
  function rebuildSessionSummaryIndex() {
837
841
  _ensureSummaryCacheDataDir();
838
842
  const { rows } = _scanStoredSessionSummaryRows();
839
- return _setSummaryRowsCache(_writeSummaryIndex(rows));
843
+ const indexedRows = _writeSummaryIndex(rows);
844
+ // This process just authored the new cache base. Remember its mtime so the
845
+ // next local save cannot mistake the rebuild for a cross-process update
846
+ // and replace a newer in-memory row with the just-obsoleted sidecar.
847
+ try { _summaryIndexMtimeSeen = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* stat only */ }
848
+ return _setSummaryRowsCache(indexedRows);
840
849
  }
841
850
 
842
851
  export function listStoredSessionSummaries(options = {}) {
@@ -867,7 +876,30 @@ export function listStoredSessionSummaries(options = {}) {
867
876
  return [];
868
877
  }
869
878
  }
870
- if (_summaryRowsCache !== null) return _cachedSummaryRows().slice();
879
+ if (_summaryRowsCache !== null) {
880
+ // A local session save has already updated the in-memory cache but its
881
+ // non-blocking sidecar merge may still be queued/in flight. Re-reading
882
+ // the older sidecar in that window would temporarily erase the new row.
883
+ if (_hasUnsettledSummaryOps()) return _cachedSummaryRows().slice();
884
+ // Cross-process freshness: another live process (terminal CLI owning a
885
+ // session this surface only views) advances messageCount/updatedAt by
886
+ // rewriting the summary index FILE — an in-memory cache that never
887
+ // looks back at disk serves frozen rows forever (user: the unread dot
888
+ // never fired for terminal-owned growth). One stat per call; when the
889
+ // index advanced, re-read the cheap index JSON as the new cache base
890
+ // (local optimistic overlays stay applied on top).
891
+ let diskMtime = 0;
892
+ try { diskMtime = statSync(summaryIndexPath()).mtimeMs || 0; } catch { /* no index yet */ }
893
+ if (diskMtime <= _summaryIndexMtimeSeen) return _cachedSummaryRows().slice();
894
+ try {
895
+ const raw = JSON.parse(readFileSync(summaryIndexPath(), 'utf-8'));
896
+ if (Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION) {
897
+ _summaryIndexMtimeSeen = diskMtime;
898
+ return _setSummaryRowsCache(_normalizeSummaryIndex(raw).rows).slice();
899
+ }
900
+ } catch { /* torn concurrent write — keep serving the cache; retry next call */ }
901
+ return _cachedSummaryRows().slice();
902
+ }
871
903
 
872
904
  let indexedRows = [];
873
905
  let p;
@@ -879,6 +911,9 @@ export function listStoredSessionSummaries(options = {}) {
879
911
  const raw = JSON.parse(readFileSync(p, 'utf-8'));
880
912
  hasIndex = Number(raw?.version) === SESSION_SUMMARY_INDEX_VERSION;
881
913
  if (hasIndex) indexedRows = _normalizeSummaryIndex(raw).rows;
914
+ if (hasIndex) {
915
+ try { _summaryIndexMtimeSeen = statSync(p).mtimeMs || 0; } catch { /* stat only */ }
916
+ }
882
917
  }
883
918
  } catch { /* unreadable/malformed sidecar falls through to rebuild */ }
884
919
 
@@ -0,0 +1,184 @@
1
+ // Turn-scoped worktree snapshots over a SHADOW git repository.
2
+ //
3
+ // A per-worktree bare repo lives under <data>/turn-snapshots/<hash>; `git
4
+ // --git-dir <shadow> --work-tree <project>` add/write-tree captures the whole
5
+ // worktree as a tree object WITHOUT touching the project's own git state (or
6
+ // requiring the project to be a git repo at all). Diffing the turn-start tree
7
+ // against the current tree therefore reports EVERYTHING a turn changed —
8
+ // lead edits, subagent edits, background shell jobs, even external editors —
9
+ // which transcript-parsed patches structurally cannot see.
10
+ //
11
+ // Costs: the first track() of a project hashes the worktree once (mitigated
12
+ // by reusing the project's own .git objects via alternates); every later
13
+ // track() is an index stat-scan. All entry points are best-effort and never
14
+ // throw into the turn path. MIXDOG_TURN_SNAPSHOT=0 disables the feature.
15
+ import { execFile } from 'child_process';
16
+ import { createHash } from 'crypto';
17
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
18
+ import { join, resolve } from 'path';
19
+ import { resolvePluginData } from './plugin-paths.mjs';
20
+
21
+ const DISABLED = /^(0|false|off)$/i.test(String(process.env.MIXDOG_TURN_SNAPSHOT || ''));
22
+ const GIT_TIMEOUT_MS = 120_000;
23
+ const BEGIN_WAIT_CAP_MS = 1_500;
24
+ const MAX_PATCH_BYTES = 2_000_000;
25
+ const BASE_CACHE_MAX = 32;
26
+ const FAILURE_BACKOFF_MS = 5 * 60_000;
27
+
28
+ const _queues = new Map(); // gitdir → tail promise (serialize per repo)
29
+ const _baseBySession = new Map(); // sessionId → { worktree, tree, at }
30
+ const _failedUntil = new Map(); // gitdir → timestamp
31
+
32
+ function _worktreeKey(worktree) {
33
+ const raw = String(worktree || '').trim();
34
+ if (!raw) return '';
35
+ const full = resolve(raw);
36
+ return process.platform === 'win32' ? full.toLowerCase() : full;
37
+ }
38
+
39
+ function _gitDirFor(worktreeKey) {
40
+ const hash = createHash('sha1').update(worktreeKey).digest('hex').slice(0, 20);
41
+ return join(resolvePluginData(), 'turn-snapshots', hash);
42
+ }
43
+
44
+ function _git(args, { cwd } = {}) {
45
+ return new Promise((resolveExec) => {
46
+ execFile('git', args, {
47
+ cwd: cwd || undefined,
48
+ windowsHide: true,
49
+ timeout: GIT_TIMEOUT_MS,
50
+ maxBuffer: 64 * 1024 * 1024,
51
+ }, (error, stdout, stderr) => {
52
+ resolveExec({ code: error ? (error.code ?? 1) : 0, stdout: String(stdout || ''), stderr: String(stderr || '') });
53
+ });
54
+ });
55
+ }
56
+
57
+ // Serialize all git operations per shadow repo: concurrent index writes would
58
+ // corrupt each other, and turn-start/track/diff can overlap freely otherwise.
59
+ function _enqueue(gitdir, task) {
60
+ const tail = (_queues.get(gitdir) || Promise.resolve()).then(task, task);
61
+ _queues.set(gitdir, tail.catch(() => {}));
62
+ return tail;
63
+ }
64
+
65
+ async function _ensureRepo(worktree, gitdir) {
66
+ if (existsSync(join(gitdir, 'HEAD'))) return true;
67
+ mkdirSync(gitdir, { recursive: true });
68
+ const init = await _git(['init', '--bare', '--quiet', gitdir]);
69
+ if (init.code !== 0) return false;
70
+ // The shadow repo indexes the project via --work-tree; never let it descend
71
+ // into the project's real .git, and keep bytes stable/gc quiet.
72
+ await _git(['--git-dir', gitdir, 'config', 'core.bare', 'false']);
73
+ await _git(['--git-dir', gitdir, 'config', 'core.autocrlf', 'false']);
74
+ await _git(['--git-dir', gitdir, 'config', 'gc.auto', '0']);
75
+ try {
76
+ mkdirSync(join(gitdir, 'info'), { recursive: true });
77
+ writeFileSync(join(gitdir, 'info', 'exclude'), '.git/\n');
78
+ // First-snapshot cost: reuse the project's own git objects so unchanged
79
+ // blobs need no re-hash/store (the opencode chromium lesson).
80
+ const projectObjects = join(worktree, '.git', 'objects');
81
+ if (existsSync(projectObjects)) {
82
+ mkdirSync(join(gitdir, 'objects', 'info'), { recursive: true });
83
+ writeFileSync(join(gitdir, 'objects', 'info', 'alternates'), `${projectObjects}\n`);
84
+ }
85
+ } catch { /* exclusions/alternates are optimizations, not requirements */ }
86
+ return true;
87
+ }
88
+
89
+ // Capture the current worktree as a tree object; null on any failure.
90
+ async function _trackTree(worktree) {
91
+ const key = _worktreeKey(worktree);
92
+ if (!key || !existsSync(key)) return null;
93
+ const gitdir = _gitDirFor(key);
94
+ const failedUntil = _failedUntil.get(gitdir) || 0;
95
+ if (Date.now() < failedUntil) return null;
96
+ return _enqueue(gitdir, async () => {
97
+ if (!(await _ensureRepo(key, gitdir))) {
98
+ _failedUntil.set(gitdir, Date.now() + FAILURE_BACKOFF_MS);
99
+ return null;
100
+ }
101
+ const base = ['--git-dir', gitdir, '--work-tree', key];
102
+ const add = await _git([...base, 'add', '-A', '--', '.'], { cwd: key });
103
+ if (add.code !== 0) {
104
+ _failedUntil.set(gitdir, Date.now() + FAILURE_BACKOFF_MS);
105
+ return null;
106
+ }
107
+ const tree = await _git([...base, 'write-tree'], { cwd: key });
108
+ if (tree.code !== 0) return null;
109
+ const hash = tree.stdout.trim();
110
+ return /^[0-9a-f]{40,64}$/.test(hash) ? hash : null;
111
+ });
112
+ }
113
+
114
+ /** Fire-and-forget shadow-repo warmup so a project's first turn never pays
115
+ * the initial full snapshot inline. */
116
+ export function prewarmTurnSnapshot(worktree) {
117
+ if (DISABLED || !worktree) return;
118
+ void _trackTree(worktree).catch(() => {});
119
+ }
120
+
121
+ /** Capture the turn base tree for a session. Waits at most BEGIN_WAIT_CAP_MS
122
+ * so a cold first snapshot cannot stall the turn; a slower capture still
123
+ * lands through the shared promise and applies to this turn retroactively. */
124
+ export async function beginTurnSnapshot(worktree, sessionId) {
125
+ if (DISABLED || !worktree || !sessionId) return;
126
+ const key = _worktreeKey(worktree);
127
+ const capture = _trackTree(worktree).then((tree) => {
128
+ if (!tree) return;
129
+ _baseBySession.delete(sessionId);
130
+ _baseBySession.set(sessionId, { worktree: key, tree, at: Date.now() });
131
+ while (_baseBySession.size > BASE_CACHE_MAX) {
132
+ const oldest = _baseBySession.keys().next().value;
133
+ if (oldest === undefined) break;
134
+ _baseBySession.delete(oldest);
135
+ }
136
+ }).catch(() => {});
137
+ await Promise.race([capture, new Promise((r) => { const t = setTimeout(r, BEGIN_WAIT_CAP_MS); t.unref?.(); })]);
138
+ }
139
+
140
+ /** Diff the session's turn-start tree against the CURRENT worktree state.
141
+ * Returns { supported, files:[{name, additions, deletions}], patch }. */
142
+ export async function getTurnReviewDiff(worktree, sessionId) {
143
+ if (DISABLED) return { supported: false, files: [], patch: '' };
144
+ const key = _worktreeKey(worktree);
145
+ const base = sessionId ? _baseBySession.get(sessionId) : null;
146
+ if (!key) return { supported: false, files: [], patch: '' };
147
+ if (!base || base.worktree !== key) {
148
+ return { supported: true, files: [], patch: '', reason: 'no-base' };
149
+ }
150
+ const head = await _trackTree(worktree);
151
+ if (!head) return { supported: false, files: [], patch: '' };
152
+ if (head === base.tree) return { supported: true, files: [], patch: '', baseTree: base.tree, headTree: head };
153
+ const gitdir = _gitDirFor(key);
154
+ const argsBase = ['--git-dir', gitdir];
155
+ const [numstat, patch] = await Promise.all([
156
+ _git([...argsBase, 'diff-tree', '-r', '--numstat', '-z', base.tree, head]),
157
+ _git([...argsBase, 'diff-tree', '-r', '-p', '--no-color', base.tree, head]),
158
+ ]);
159
+ if (numstat.code !== 0) return { supported: false, files: [], patch: '' };
160
+ const files = [];
161
+ const fields = numstat.stdout.split('\0').filter(Boolean);
162
+ for (const row of fields) {
163
+ const match = row.match(/^(\d+|-)\t(\d+|-)\t([\s\S]+)$/);
164
+ if (!match) continue;
165
+ files.push({
166
+ name: match[3],
167
+ additions: match[1] === '-' ? 0 : Number(match[1]),
168
+ deletions: match[2] === '-' ? 0 : Number(match[2]),
169
+ });
170
+ }
171
+ let patchText = patch.code === 0 ? patch.stdout : '';
172
+ if (patchText.length > MAX_PATCH_BYTES) {
173
+ patchText = patchText.slice(0, MAX_PATCH_BYTES);
174
+ const cut = patchText.lastIndexOf('\ndiff --git ');
175
+ if (cut > 0) patchText = patchText.slice(0, cut + 1);
176
+ }
177
+ return { supported: true, files, patch: patchText, baseTree: base.tree, headTree: head };
178
+ }
179
+
180
+ export function _resetTurnSnapshotForTest() {
181
+ _queues.clear();
182
+ _baseBySession.clear();
183
+ _failedUntil.clear();
184
+ }
@@ -5,6 +5,7 @@ import {
5
5
  providerTokenCalibration,
6
6
  resolveSessionCompactPolicy,
7
7
  summarizeContextMessages,
8
+ summarizeContextMessagesAtRevision,
8
9
  toolSchemaSignature,
9
10
  } from '../runtime/agent/orchestrator/session/context-utils.mjs';
10
11
  import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
@@ -234,7 +235,7 @@ export function createContextStatus({
234
235
  return contextStatusCacheValue;
235
236
  }
236
237
 
237
- const messageSummary = summarizeContextMessages(messages);
238
+ const messageSummary = summarizeContextMessagesAtRevision(messages, messagesRevision);
238
239
  const toolSchemaTokens = estimateToolSchemaTokens(requestTools);
239
240
  const toolSchemaBreakdown = estimateToolSchemaBreakdown(requestTools);
240
241
  const requestReserveTokens = estimateRequestReserveTokens(requestTools);
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
 
3
3
  const requestToolScope = new AsyncLocalStorage();
4
4
  const NATIVE_PREFIX_COUNT = Symbol('mixdog.providerNativeToolPrefixCount');
5
+ const finalizedProviderRequestTools = new WeakSet();
5
6
  let requestToolScopeGeneration = 0;
6
7
 
7
8
  function normalizedProvider(provider) {
@@ -15,6 +16,10 @@ export function providerNativeToolPrefixCount(requestTools, fallback = 0) {
15
16
  return Math.max(0, Math.min(Array.isArray(requestTools) ? requestTools.length : 0, Number(raw) || 0));
16
17
  }
17
18
 
19
+ export function isFinalizedProviderRequestTools(requestTools) {
20
+ return Array.isArray(requestTools) && finalizedProviderRequestTools.has(requestTools);
21
+ }
22
+
18
23
  export function finalizeProviderRequestTools(requestTools, nativePrefixCount = 0) {
19
24
  Object.defineProperty(requestTools, NATIVE_PREFIX_COUNT, {
20
25
  value: Math.max(0, Math.min(requestTools.length, Number(nativePrefixCount) || 0)),
@@ -22,6 +27,7 @@ export function finalizeProviderRequestTools(requestTools, nativePrefixCount = 0
22
27
  configurable: false,
23
28
  writable: false,
24
29
  });
30
+ finalizedProviderRequestTools.add(requestTools);
25
31
  return Object.freeze(requestTools);
26
32
  }
27
33
 
@@ -231,6 +231,7 @@ import { attachSessionHooks } from './session-hooks.mjs';
231
231
  import { createQuickModelRows } from './quick-model-rows.mjs';
232
232
  import { createWarmupSchedulers } from './warmup-schedulers.mjs';
233
233
  import { createPrewarmSchedulers } from './prewarm.mjs';
234
+ import { getTurnReviewDiff as getTurnSnapshotReviewDiff } from '../runtime/shared/turn-snapshot.mjs';
234
235
  import { createMcpGlue } from './mcp-glue.mjs';
235
236
  import { createCwdPlugins } from './cwd-plugins.mjs';
236
237
  import { createSettingsApi } from './settings-api.mjs';
@@ -2303,6 +2304,9 @@ export async function createMixdogSessionRuntime({
2303
2304
  ...settingsApi,
2304
2305
  ...channelConfigApi,
2305
2306
  ...providerAuthApi,
2307
+ // Turn-scoped worktree diff (shadow snapshot): everything changed since
2308
+ // the current turn's base tree, regardless of which agent/process wrote it.
2309
+ getTurnReviewDiff: () => getTurnSnapshotReviewDiff(currentCwd, session?.id),
2306
2310
  get id() {
2307
2311
  return session?.id || null;
2308
2312
  },
@@ -10,6 +10,7 @@ import {
10
10
  refreshInitialDeferredMcpSurface,
11
11
  } from './tool-catalog.mjs';
12
12
  import { getMcpTools } from '../runtime/agent/orchestrator/mcp/client.mjs';
13
+ import { beginTurnSnapshot } from '../runtime/shared/turn-snapshot.mjs';
13
14
 
14
15
  export function splitToolStatusCounts(rows) {
15
16
  const list = Array.isArray(rows) ? rows : [];
@@ -107,6 +108,12 @@ export function createSessionTurnApi(deps) {
107
108
  }
108
109
  }
109
110
  const session0 = getSession();
111
+ // Turn-review base: capture the pre-turn worktree tree in the shadow
112
+ // snapshot repo so the desktop review bar can diff EVERYTHING this
113
+ // turn changes — subagent and background-job edits included. The wait
114
+ // is capped so a cold first snapshot never stalls the turn; a late
115
+ // base still lands through the shared promise.
116
+ try { await beginTurnSnapshot(getCurrentCwd(), session0?.id); } catch { /* never blocks a turn */ }
110
117
  if (session0.deferredInitialRefreshPending) {
111
118
  // FIRST TURN of a FRESH session (session-local gate, NOT the
112
119
  // process-wide firstTurnCompleted): an MCP server may have finished its
@@ -13,6 +13,7 @@ import {
13
13
  } from '../runtime/agent/orchestrator/providers/custom-tool-wire.mjs';
14
14
  import {
15
15
  finalizeProviderRequestTools,
16
+ isFinalizedProviderRequestTools,
16
17
  providerNativeToolPrefixCount,
17
18
  } from './provider-request-tools.mjs';
18
19
  import {
@@ -101,7 +102,10 @@ export function toolSchemaBucket(tool) {
101
102
  export function estimateToolSchemaBreakdown(tools) {
102
103
  if (Array.isArray(tools)) {
103
104
  const cached = toolSchemaBreakdownMemo.get(tools);
104
- if (sameToolSchemaEntries(cached, tools)) return cached.value;
105
+ if (cached && (
106
+ isFinalizedProviderRequestTools(tools)
107
+ || sameToolSchemaEntries(cached, tools)
108
+ )) return cached.value;
105
109
  }
106
110
  const out = {};
107
111
  const list = Array.isArray(tools) ? tools : [];
package/src/tui/App.jsx CHANGED
@@ -3508,7 +3508,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
3508
3508
  ) : null}
3509
3509
  <StatusLine
3510
3510
  sessionId={state.sessionId}
3511
- clientHostPid={state.clientHostPid}
3511
+ clientHostPid={state.ownerClientHostPid || state.clientHostPid}
3512
3512
  provider={state.provider}
3513
3513
  model={state.model}
3514
3514
  effort={state.effort}
@@ -303,6 +303,22 @@ export function hasStreamingRowStateToPrune() {
303
303
  || streamingTailEstimateById.size > 0;
304
304
  }
305
305
 
306
+ export function transcriptHarvestInputsEqual(left, right) {
307
+ return !!left && !!right
308
+ && left.revision === right.revision
309
+ && left.settledItems === right.settledItems
310
+ && left.streamingTailItem === right.streamingTailItem
311
+ && left.startIndex === right.startIndex
312
+ && left.endIndex === right.endIndex
313
+ && left.frameColumns === right.frameColumns
314
+ && left.toolOutputExpanded === right.toolOutputExpanded
315
+ && left.transcriptContentHeight === right.transcriptContentHeight
316
+ && left.floatingPanelRows === right.floatingPanelRows
317
+ && left.overlayHintRequested === right.overlayHintRequested
318
+ && left.transcriptGuardRows === right.transcriptGuardRows
319
+ && left.themeEpoch === right.themeEpoch;
320
+ }
321
+
306
322
  /** Drop streamingMeasuredRowsById entries for ids no longer mounted, so the
307
323
  * store does not grow unbounded over a long session (mirrors the id→item /
308
324
  * id→callback map pruning already done for the mounted set). */
@@ -29,6 +29,7 @@ import {
29
29
  TRANSCRIPT_WINDOW_OVERSCAN_ROWS,
30
30
  upperBound,
31
31
  shiftSelectionRectY,
32
+ transcriptHarvestInputsEqual,
32
33
  } from './transcript-window.mjs';
33
34
  import { shouldSuppressFullyFailedToolItem } from '../transcript-tool-failures.mjs';
34
35
 
@@ -125,6 +126,11 @@ export function useTranscriptWindow({
125
126
  // overscan band).
126
127
  const transcriptItemElsRef = useRef(new Map());
127
128
  const transcriptMeasureRefCache = useRef(new Map());
129
+ const harvestGateRef = useRef({
130
+ inputs: null,
131
+ skippedForDrag: false,
132
+ forceNext: false,
133
+ });
128
134
  // id → latest item object for this render. The callback ref reads from here so
129
135
  // a reused (stable) callback never captures a stale item across patches.
130
136
  const transcriptMeasureItemsRef = useRef(new Map());
@@ -516,6 +522,20 @@ export function useTranscriptWindow({
516
522
  && floatingPanelRows <= 0
517
523
  && transcriptGuardRows > 0
518
524
  && !overlayHintOnLastItem;
525
+ const harvestInputs = {
526
+ revision,
527
+ settledItems,
528
+ streamingTailItem,
529
+ startIndex: transcriptWindow.startIndex,
530
+ endIndex: transcriptWindow.endIndex,
531
+ frameColumns,
532
+ toolOutputExpanded,
533
+ transcriptContentHeight,
534
+ floatingPanelRows,
535
+ overlayHintRequested,
536
+ transcriptGuardRows,
537
+ themeEpoch,
538
+ };
519
539
  // ── App-level measured height harvest ───────────────────────────────────
520
540
  // Runs after EVERY commit (no deps): Yoga has just laid out the mounted rows,
521
541
  // so each tracked item Box's getComputedHeight() is its REAL terminal height.
@@ -528,6 +548,7 @@ export function useTranscriptWindow({
528
548
  // follow path already keeps them visually stable.
529
549
  useLayoutEffect(() => {
530
550
  if (!TRANSCRIPT_MEASURED_ROWS) return;
551
+ const gate = harvestGateRef.current;
531
552
  // Skip the per-row Yoga harvest while a drag is in progress. Edge auto-
532
553
  // scroll commits setScrollOffset on every pointer motion, but the mounted
533
554
  // rows' real heights do not change during a drag — only their scroll
@@ -535,9 +556,18 @@ export function useTranscriptWindow({
535
556
  // variantKey check per row) for no height change, which is pure drag
536
557
  // overhead on a tall transcript. The cached measurements stay authoritative
537
558
  // for the row-index math; a single re-measure is forced on release below.
538
- if (dragRef.current.active) return;
559
+ if (dragRef.current.active) {
560
+ gate.skippedForDrag = true;
561
+ return;
562
+ }
563
+ if (!gate.skippedForDrag
564
+ && !gate.forceNext
565
+ && transcriptHarvestInputsEqual(gate.inputs, harvestInputs)) return;
539
566
  const els = transcriptItemElsRef.current;
540
567
  if (!els || els.size === 0) return;
568
+ gate.inputs = harvestInputs;
569
+ gate.skippedForDrag = false;
570
+ gate.forceNext = false;
541
571
  const liveItems = transcriptMeasureItemsRef.current;
542
572
  const toolExpandedFlag = toolOutputExpanded ? 1 : 0;
543
573
  let changed = false;
@@ -655,6 +685,11 @@ export function useTranscriptWindow({
655
685
  }, 0);
656
686
  if (typeof streak.timer?.unref === 'function') streak.timer.unref();
657
687
  }
688
+ // Preserve the original convergence contract: a measurement-driven
689
+ // render gets one confirmation harvest even when no external layout
690
+ // input changed. Stable rows stop there; a real oscillation continues
691
+ // until the existing circuit breaker trips.
692
+ gate.forceNext = true;
658
693
  setMeasuredRowsVersion((v) => (v + 1) % 1000000);
659
694
  }
660
695
  }