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.
- package/package.json +1 -1
- package/src/app.mjs +2 -2
- package/src/cli.mjs +1 -1
- package/src/repl.mjs +72 -6
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +76 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +45 -4
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +36 -13
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +79 -18
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +37 -2
- package/src/runtime/shared/turn-snapshot.mjs +184 -0
- package/src/session-runtime/context-status.mjs +2 -1
- package/src/session-runtime/provider-request-tools.mjs +6 -0
- package/src/session-runtime/runtime-core.mjs +4 -0
- package/src/session-runtime/session-turn-api.mjs +7 -0
- package/src/session-runtime/tool-catalog-schema.mjs +5 -1
- package/src/tui/App.jsx +1 -1
- package/src/tui/app/transcript-window.mjs +16 -0
- package/src/tui/app/use-transcript-window.mjs +36 -1
- package/src/tui/components/Markdown.jsx +15 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +7 -0
- package/src/tui/dist/index.mjs +431 -81
- package/src/tui/engine/frame-batched-store.mjs +5 -3
- package/src/tui/engine/live-share.mjs +100 -0
- package/src/tui/engine/render-timing.mjs +28 -0
- package/src/tui/engine/session-api-ext.mjs +10 -0
- package/src/tui/engine/tui-steering-persist.mjs +25 -4
- package/src/tui/engine.mjs +36 -1
- package/src/tui/index.jsx +11 -3
- package/src/tui/markdown/measure-rendered-rows.mjs +28 -16
- package/src/tui/markdown/render-ansi.mjs +34 -1
- package/src/tui/markdown/stream-fence.mjs +44 -7
- package/src/tui/markdown/streaming-markdown.mjs +95 -27
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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:
|
|
105
|
-
preview:
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 (
|
|
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)
|
|
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
|
}
|