mixdog 0.9.82 → 0.9.84

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.82",
3
+ "version": "0.9.84",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+
8
+ import { embeddingRuntimeTarget } from './prune-embedding-runtime.mjs'
9
+
10
+ export const RUNTIME_DEPENDENCY_CACHE_SCHEMA = 1
11
+
12
+ function sha256(value) {
13
+ return createHash('sha256').update(value).digest('hex')
14
+ }
15
+
16
+ export function normalizeRuntimeLockfile(lockfile) {
17
+ const normalized = structuredClone(
18
+ typeof lockfile === 'string' || Buffer.isBuffer(lockfile)
19
+ ? JSON.parse(String(lockfile))
20
+ : lockfile,
21
+ )
22
+ // Deploy updates the package identity before every release, but that does
23
+ // not change the production dependency tree cached by native packagers.
24
+ delete normalized.name
25
+ delete normalized.version
26
+ if (normalized.packages?.['']) {
27
+ delete normalized.packages[''].name
28
+ delete normalized.packages[''].version
29
+ }
30
+ return normalized
31
+ }
32
+
33
+ export function runtimeDependencyFingerprint({
34
+ lockfile,
35
+ prunerSource,
36
+ target,
37
+ host = `${process.platform}-${process.arch}`,
38
+ nodeAbi = process.versions.modules,
39
+ }) {
40
+ return sha256(JSON.stringify({
41
+ schemaVersion: RUNTIME_DEPENDENCY_CACHE_SCHEMA,
42
+ target,
43
+ host,
44
+ nodeAbi,
45
+ lockfile: normalizeRuntimeLockfile(lockfile),
46
+ prunerSha256: sha256(prunerSource),
47
+ }))
48
+ }
49
+
50
+ export async function runtimeDependencyCacheIdentity(rootDir, target) {
51
+ const [lockfile, prunerSource] = await Promise.all([
52
+ readFile(join(rootDir, 'package-lock.json')),
53
+ readFile(join(rootDir, 'scripts', 'prune-embedding-runtime.mjs')),
54
+ ])
55
+ const host = `${process.platform}-${process.arch}`
56
+ const nodeAbi = process.versions.modules
57
+ return {
58
+ schemaVersion: RUNTIME_DEPENDENCY_CACHE_SCHEMA,
59
+ target: target.key,
60
+ host,
61
+ nodeAbi,
62
+ fingerprint: runtimeDependencyFingerprint({
63
+ lockfile,
64
+ prunerSource,
65
+ target: target.key,
66
+ host,
67
+ nodeAbi,
68
+ }),
69
+ }
70
+ }
71
+
72
+ export function runtimeDependencyCacheKey(identity) {
73
+ return `runtime-deps-v${identity.schemaVersion}-${identity.target}-${identity.fingerprint}`
74
+ }
75
+
76
+ const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''
77
+ if (invokedPath === import.meta.url) {
78
+ const optionValue = (name) => {
79
+ const prefix = `--${name}=`
80
+ const argument = process.argv.find((value) => value.startsWith(prefix))
81
+ return argument ? argument.slice(prefix.length) : ''
82
+ }
83
+ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
84
+ const target = embeddingRuntimeTarget({
85
+ platform: optionValue('platform') || undefined,
86
+ arch: optionValue('arch') || undefined,
87
+ })
88
+ runtimeDependencyCacheIdentity(rootDir, target)
89
+ .then((identity) => process.stdout.write(`${runtimeDependencyCacheKey(identity)}\n`))
90
+ .catch((error) => {
91
+ process.stderr.write(`Runtime dependency cache key failed: ${error?.message || error}\n`)
92
+ process.exitCode = 1
93
+ })
94
+ }
@@ -203,8 +203,11 @@ export function contentToText(content, fallback = '') {
203
203
 
204
204
  function storedHistoryImagePlaceholder(part) {
205
205
  const info = imageInfo(part) || geminiInlineInfo(part);
206
- const url = imageUrlFromPart(part);
207
- const fileUri = imageFileUriFromPart(part);
206
+ // Inline base64 already gives us its MIME type. Do not call
207
+ // imageUrlFromPart in that case: it would manufacture a second
208
+ // `data:...;base64,<entire payload>` string merely to discard it.
209
+ const url = info ? null : imageUrlFromPart(part);
210
+ const fileUri = info ? null : imageFileUriFromPart(part);
208
211
  const mimeType = info?.mimeType || imageMimeFromDataUrl(url) || fileUri?.mimeType || (part?.type === 'image' ? DEFAULT_IMAGE_MIME : '');
209
212
  return `[Image omitted from stored history${mimeType ? `: ${mimeType}` : ''}]`;
210
213
  }
@@ -2,7 +2,7 @@
2
2
  // Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
3
3
  // Drives sweepStaleSessions on an unref'd interval; closeSession is imported
4
4
  // from session-close.mjs (one-way dependency, no cycle).
5
- import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
5
+ import { sweepStaleSessions, sweepStaleSessionsCooperative, evictIdleLiveSessions } from '../store.mjs';
6
6
  import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
7
7
  import {
8
8
  _getRuntimeEntry,
@@ -23,6 +23,7 @@ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_M
23
23
  const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
24
24
  let _cleanupTimer = null;
25
25
  let _cleanupInitialTimer = null;
26
+ let _cleanupRun = null;
26
27
 
27
28
  // A session is "live" when it still owns a non-closed runtime entry. Passed to
28
29
  // the retention cap so the active/current and any in-flight session is never
@@ -65,10 +66,10 @@ const _sweepLog = (line) => {
65
66
  if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
66
67
  };
67
68
 
68
- function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
69
+ async function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
69
70
  const startedAt = Date.now();
70
71
  try {
71
- const result = sweepStaleSessions({
72
+ const result = await sweepStaleSessionsCooperative({
72
73
  sweepIdle,
73
74
  tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
74
75
  isSessionLive: _isSessionLive,
@@ -144,33 +145,43 @@ export function sweepTombstones() {
144
145
  }
145
146
 
146
147
  export function _runCleanupCycle() {
147
- // Drain every settled runtime entry on each pass, not just the one or two
148
- // sessions whose on-disk idle TTL happened to expire in this interval.
149
- _sweepTerminalSessionRuntimes();
150
- sweepOrphanedPendingMessages();
151
- sweepIdleSessions({ includeTombstones: true });
152
- // Reclaim same-process session snapshots whose state is durable on disk
153
- // (memory-leak guard: _liveSessions used to grow for process lifetime).
154
- try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
148
+ if (_cleanupRun) return _cleanupRun;
149
+ const run = (async () => {
150
+ // Drain every settled runtime entry on each pass, not just the one or two
151
+ // sessions whose on-disk idle TTL happened to expire in this interval.
152
+ _sweepTerminalSessionRuntimes();
153
+ sweepOrphanedPendingMessages();
154
+ await sweepIdleSessions({ includeTombstones: true });
155
+ // Reclaim same-process session snapshots whose state is durable on disk
156
+ // (memory-leak guard: _liveSessions used to grow for process lifetime).
157
+ try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
158
+ })().catch((error) => {
159
+ try { process.stderr.write(`[agent-session] cleanup cycle failed: ${error?.message || error}\n`); } catch {}
160
+ });
161
+ const tracked = run.finally(() => {
162
+ if (_cleanupRun === tracked) _cleanupRun = null;
163
+ });
164
+ _cleanupRun = tracked;
165
+ return tracked;
155
166
  }
156
167
 
157
168
  function _startCleanupInterval() {
158
169
  if (_cleanupTimer) return;
159
170
  if (CLEANUP_INTERVAL_MS <= 0) return;
160
- _cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
171
+ _cleanupTimer = setInterval(() => { void _runCleanupCycle(); }, CLEANUP_INTERVAL_MS);
161
172
  if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
162
173
  }
163
174
 
164
175
  export function startIdleCleanup() {
165
176
  if (_cleanupTimer || _cleanupInitialTimer) return;
166
177
  if (CLEANUP_INITIAL_DELAY_MS <= 0) {
167
- _runCleanupCycle();
178
+ void _runCleanupCycle();
168
179
  _startCleanupInterval();
169
180
  return;
170
181
  }
171
182
  _cleanupInitialTimer = setTimeout(() => {
172
183
  _cleanupInitialTimer = null;
173
- _runCleanupCycle();
184
+ void _runCleanupCycle();
174
185
  _startCleanupInterval();
175
186
  }, CLEANUP_INITIAL_DELAY_MS);
176
187
  if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
@@ -244,7 +244,7 @@ export function getStoredSessionsRaw() {
244
244
  * Background sweep: delete session files idle longer than ttlMs.
245
245
  * Returns { cleaned, remaining, details } for logging.
246
246
  */
247
- export function sweepStaleSessions(ttlMs, options = {}) {
247
+ function* sweepStaleSessionSteps(ttlMs, options = {}) {
248
248
  if (ttlMs && typeof ttlMs === 'object') {
249
249
  options = ttlMs;
250
250
  ttlMs = options.ttlMs;
@@ -302,6 +302,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
302
302
  let openPruned = 0;
303
303
  const openPrunedDetails = [];
304
304
  for (const row of summaries) {
305
+ // Cooperative callers pause between records so large stores never hold
306
+ // an interactive host's event loop for the full directory scan.
307
+ yield undefined;
305
308
  try {
306
309
  if (!row?.id) continue;
307
310
  const jsonPath = sessionPath(row.id);
@@ -593,6 +596,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
593
596
  // session mid-create whose .json write has not landed yet.
594
597
  try {
595
598
  for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
599
+ yield undefined;
596
600
  if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
597
601
  let hbMtime = 0;
598
602
  try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
@@ -613,3 +617,36 @@ export function sweepStaleSessions(ttlMs, options = {}) {
613
617
  }
614
618
  return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
615
619
  }
620
+
621
+ /** Synchronous compatibility surface for explicit maintenance commands/tests. */
622
+ export function sweepStaleSessions(ttlMs, options = {}) {
623
+ const steps = sweepStaleSessionSteps(ttlMs, options);
624
+ let next = steps.next();
625
+ while (!next.done) next = steps.next();
626
+ return next.value;
627
+ }
628
+
629
+ /**
630
+ * Interactive-host sweep: preserve the exact synchronous lifecycle decisions
631
+ * while yielding between records. A single large session remains atomic, but a
632
+ * directory worth of reads/parses can no longer become one multi-second task.
633
+ */
634
+ export async function sweepStaleSessionsCooperative(ttlMs, options = {}) {
635
+ const cooperativeOptions = ttlMs && typeof ttlMs === 'object' ? ttlMs : options;
636
+ const configuredSliceMs = Number(cooperativeOptions?.cooperativeSliceMs);
637
+ const sliceMs = Number.isFinite(configuredSliceMs)
638
+ ? Math.min(50, Math.max(0, configuredSliceMs))
639
+ : 8;
640
+ const steps = sweepStaleSessionSteps(ttlMs, options);
641
+ let next = steps.next();
642
+ while (!next.done) {
643
+ const sliceStartedAt = performance.now();
644
+ do {
645
+ next = steps.next();
646
+ } while (!next.done && performance.now() - sliceStartedAt < sliceMs);
647
+ if (!next.done) {
648
+ await new Promise((resolve) => setImmediate(resolve));
649
+ }
650
+ }
651
+ return next.value;
652
+ }
@@ -1,6 +1,6 @@
1
1
  import { Worker } from 'worker_threads';
2
2
  import { guardedSaveOptions as _guardedSaveOptions } from './write-guards.mjs';
3
- import { _ensureLifecycleFields } from './serialize.mjs';
3
+ import { _ensureLifecycleFields, _sessionForDisk } from './serialize.mjs';
4
4
  import { setLiveSession, _droppedSaveIds, clearSessionSaveError } from './live-state.mjs';
5
5
  import { _cacheSessionSummary, _rollbackCachedSessionSummary, _queueSessionSummaryUpsert } from './summary-cache.mjs';
6
6
 
@@ -23,6 +23,17 @@ let _saveWorkerRefCount = 0;
23
23
  let _deferredSaveReqId = 0;
24
24
  export const _deferredSessionSaves = new Map();
25
25
 
26
+ /**
27
+ * Build the exact payload the worker will persist BEFORE Worker.postMessage
28
+ * structured-clones it on the caller's thread. Inline image/document bytes and
29
+ * transient live-turn aliases are disk-ineligible already; removing them here
30
+ * prevents a multi-megabyte duplicate allocation and long clone pause while
31
+ * preserving canonical text/tool history byte-for-byte.
32
+ */
33
+ export function _sessionPayloadForSaveWorker(session) {
34
+ return _sessionForDisk(session);
35
+ }
36
+
26
37
  function _getOrSpawnWorker() {
27
38
  if (_saveWorker) return _saveWorker;
28
39
  _saveWorker = new Worker(new URL('../save-session-worker.mjs', import.meta.url), {
@@ -179,19 +190,10 @@ export function saveSessionAsync(session, opts) {
179
190
  const id = session.id;
180
191
  const summaryVersion = _cacheSessionSummary(session);
181
192
  const safeOpts = opts?._sessionWriteGuard ? opts : _guardedSaveOptions(id, opts);
182
- // The Worker `postMessage` below structured-clones the whole session on the
183
- // main thread. `session.liveTurnMessages` (live working transcript) and
184
- // `session.toolApprovalHook` (askOpts.onToolApproval callback) are transient
185
- // in-flight aliases askSession sets for the turn duration; both carry
186
- // non-cloneable values (a function, and raw messages that can hold functions),
187
- // which makes structuredClone throw "could not be cloned" for every mid-turn
188
- // iteration save. The worker strips both via _sessionForDisk anyway, so drop
189
- // them from the cloned payload here WITHOUT mutating the live session object.
190
- const clonePayload = (session && typeof session === 'object'
191
- && (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
192
- || Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook')))
193
- ? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
194
- : session;
193
+ // Worker.postMessage clones on THIS thread. Project to the canonical disk
194
+ // shape first so media bytes and duplicate live-turn aliases never enter
195
+ // that clone. setLiveSession above deliberately retains the rich original.
196
+ const clonePayload = _sessionPayloadForSaveWorker(session);
195
197
  return new Promise((resolve, reject) => {
196
198
  const waiter = { resolve, reject };
197
199
  if (_saveAsyncInflight.has(id)) {
@@ -757,5 +757,6 @@ export {
757
757
  listStoredSessionSummaries,
758
758
  getStoredSessionsRaw,
759
759
  sweepStaleSessions,
760
+ sweepStaleSessionsCooperative,
760
761
  } from './store/listing.mjs';
761
762
  export { _savePending };
@@ -1,184 +1,121 @@
1
- // Turn-scoped worktree snapshots over a SHADOW git repository.
1
+ // Turn-scoped review registry.
2
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.
3
+ // The public function names retain the original shadow-snapshot API so desktop
4
+ // capability callers stay compatible. The tracked data now follows Codex's
5
+ // attribution rule instead: only successful apply_patch UI diffs are recorded,
6
+ // each worker keeps its own review, and a Lead turn may read its child reviews
7
+ // without reassigning them to the Lead.
10
8
  //
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';
9
+ // Lead patches already live in the Lead transcript. Worker patches are
10
+ // delivered through agentLoop.onToolResult and frozen here against the owning
11
+ // Lead turn generation. Shell/background/external-editor writes are excluded.
20
12
 
21
13
  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;
14
+ const TURN_CACHE_MAX = 32;
24
15
  const MAX_PATCH_BYTES = 2_000_000;
25
- const BASE_CACHE_MAX = 32;
26
- const FAILURE_BACKOFF_MS = 5 * 60_000;
27
16
 
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
17
+ const _turnsBySession = new Map();
18
+ let _agentTurnSeq = 0;
31
19
 
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;
20
+ function clean(value) {
21
+ return typeof value === 'string' ? value.trim() : '';
37
22
  }
38
23
 
39
- function _gitDirFor(worktreeKey) {
40
- const hash = createHash('sha1').update(worktreeKey).digest('hex').slice(0, 20);
41
- return join(resolvePluginData(), 'turn-snapshots', hash);
24
+ function trimTurnCache() {
25
+ while (_turnsBySession.size > TURN_CACHE_MAX) {
26
+ const oldest = _turnsBySession.keys().next().value;
27
+ if (oldest === undefined) break;
28
+ _turnsBySession.delete(oldest);
29
+ }
42
30
  }
43
31
 
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
- });
32
+ function mergePatches(values) {
33
+ let merged = '';
34
+ for (const value of Array.isArray(values) ? values : [values]) {
35
+ const patch = typeof value === 'string' ? value.trim() : '';
36
+ if (!patch) continue;
37
+ const next = merged ? `${merged}\n${patch}` : patch;
38
+ if (next.length > MAX_PATCH_BYTES) break;
39
+ merged = next;
40
+ }
41
+ return merged;
55
42
  }
56
43
 
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;
44
+ function publicAgentReviews(sessionId) {
45
+ const turn = _turnsBySession.get(clean(sessionId));
46
+ if (!turn) return [];
47
+ return [...turn.agents.values()].map((review) => ({ ...review }));
63
48
  }
64
49
 
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
- }
50
+ /** Compatibility no-op: apply_patch tracking has no project warmup cost. */
51
+ export function prewarmTurnSnapshot() {}
88
52
 
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;
53
+ /** Start a new user turn and invalidate the prior turn's child review group. */
54
+ export async function beginTurnSnapshot(_worktree, sessionId) {
55
+ const ownerSessionId = clean(sessionId);
56
+ if (DISABLED || !ownerSessionId) return;
57
+ const generation = (_turnsBySession.get(ownerSessionId)?.generation || 0) + 1;
58
+ _turnsBySession.delete(ownerSessionId);
59
+ _turnsBySession.set(ownerSessionId, {
60
+ generation,
61
+ agents: new Map(),
111
62
  });
63
+ trimTurnCache();
112
64
  }
113
65
 
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(() => {});
66
+ /** Bind one worker execution to the Lead turn that launched it. */
67
+ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {}) {
68
+ if (DISABLED) return null;
69
+ const owner = clean(ownerSessionId);
70
+ const child = clean(childSessionId);
71
+ const turn = _turnsBySession.get(owner);
72
+ if (!owner || !child || !turn) return null;
73
+ _agentTurnSeq += 1;
74
+ return {
75
+ id: `agent-review-${_agentTurnSeq}`,
76
+ ownerSessionId: owner,
77
+ generation: turn.generation,
78
+ sessionId: child,
79
+ agent: clean(meta.agent) || null,
80
+ tag: clean(meta.tag) || null,
81
+ completed: false,
82
+ };
119
83
  }
120
84
 
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?.(); })]);
85
+ /** Freeze successful worker apply_patch diffs into their owning Lead turn. */
86
+ export function completeAgentTurnReview(handle, patches = []) {
87
+ if (!handle || handle.completed === true) return false;
88
+ handle.completed = true;
89
+ const turn = _turnsBySession.get(clean(handle.ownerSessionId));
90
+ if (!turn || turn.generation !== handle.generation) return false;
91
+ const patch = mergePatches(patches);
92
+ if (!patch) return false;
93
+ const key = clean(handle.sessionId) || handle.id;
94
+ const prior = turn.agents.get(key);
95
+ turn.agents.set(key, {
96
+ sessionId: key,
97
+ agent: handle.agent || prior?.agent || null,
98
+ tag: handle.tag || prior?.tag || null,
99
+ patch: mergePatches([prior?.patch, patch]),
100
+ });
101
+ return true;
138
102
  }
139
103
 
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 };
104
+ /** Return only attributed child reviews; Lead review is transcript-derived. */
105
+ export async function getTurnReviewDiff(_worktree, sessionId) {
106
+ if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
107
+ const ownerSessionId = clean(sessionId);
108
+ const turn = _turnsBySession.get(ownerSessionId);
109
+ return {
110
+ supported: true,
111
+ files: [],
112
+ patch: '',
113
+ agents: publicAgentReviews(ownerSessionId),
114
+ ...(turn ? { generation: turn.generation } : { reason: 'no-turn' }),
115
+ };
178
116
  }
179
117
 
180
118
  export function _resetTurnSnapshotForTest() {
181
- _queues.clear();
182
- _baseBySession.clear();
183
- _failedUntil.clear();
119
+ _turnsBySession.clear();
120
+ _agentTurnSeq = 0;
184
121
  }
@@ -1227,18 +1227,21 @@ export async function createMixdogSessionRuntime({
1227
1227
  const startedAt = performance.now();
1228
1228
  bootProfile('session:create:start', { mode, reason });
1229
1229
  const promise = (async () => {
1230
+ // Demand-only: this starts only after the user submits (unless an
1231
+ // explicitly enabled prewarm caller asks for a session). Core-memory
1232
+ // startup does not depend on keychain/provider readiness, so overlap the
1233
+ // two cold paths instead of paying their bounded waits serially.
1234
+ const coreMemoryContextPromise = loadCoreMemoryContext();
1230
1235
  await awaitKeychainPrewarm();
1231
1236
  ensureConfigForRouteProvider();
1232
1237
  await resolveMissingRouteModelForFirstTurn();
1233
1238
  requireModelRoute();
1234
1239
  bootProfile('session:create:route-ready', { ms: (performance.now() - startedAt).toFixed(1) });
1235
- // refreshRouteEffort (effort/model-meta) and loadCoreMemoryContext (memory
1236
- // files) are independent — refreshRouteEffort only touches route effort/
1237
- // display fields, never provider/model that the memory load reads — so run
1238
- // them concurrently instead of serially on the boot path.
1240
+ // Route effort waits on provider readiness while the already-started
1241
+ // memory load continues independently.
1239
1242
  const [, coreMemoryContext] = await Promise.all([
1240
1243
  refreshRouteEffort(),
1241
- loadCoreMemoryContext(),
1244
+ coreMemoryContextPromise,
1242
1245
  ]);
1243
1246
  bootProfile('session:create:effort-ready', { ms: (performance.now() - startedAt).toFixed(1) });
1244
1247
  const providerImpl = reg.getProvider(route.provider);
@@ -1846,8 +1849,8 @@ export async function createMixdogSessionRuntime({
1846
1849
  ...channelConfigApi,
1847
1850
  ...providerAuthApi,
1848
1851
  ...mediaApi,
1849
- // Turn-scoped worktree diff (shadow snapshot): everything changed since
1850
- // the current turn's base tree, regardless of which agent/process wrote it.
1852
+ // Turn-scoped attributed review: child apply_patch diffs for this Lead turn.
1853
+ // Lead patches are read from its transcript by the renderer.
1851
1854
  getTurnReviewDiff: () => getTurnSnapshotReviewDiff(currentCwd, session?.id),
1852
1855
  get id() {
1853
1856
  return session?.id || null;