mixdog 0.9.82 → 0.9.83

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.83",
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
  }
@@ -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)) {
@@ -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;
@@ -45,7 +45,17 @@ export function createSessionTurnApi(deps) {
45
45
  activeToolSurface, applyResolvedCwd, resolveCwdPath, agentStatusState, notificationListeners,
46
46
  awaitInitialMcpConnect,
47
47
  } = deps;
48
+ const enqueueRemoteAttachedPrompt = (prompt) => {
49
+ const attachedSession = getSession();
50
+ if (!attachedSession?.remoteAttached || !attachedSession.id) return false;
51
+ try {
52
+ return Number(mgr.enqueueRemotePendingMessage?.(attachedSession.id, prompt)) > 0;
53
+ } catch {
54
+ return false;
55
+ }
56
+ };
48
57
  return {
58
+ enqueueRemoteAttachedPrompt,
49
59
  getTurnLiveness() {
50
60
  const sessionId = getSession()?.id;
51
61
  if (!sessionId || typeof mgr.getSessionProgressSnapshot !== 'function') return null;
@@ -65,9 +75,12 @@ export function createSessionTurnApi(deps) {
65
75
  // a normal user turn and this surface refreshes from disk.
66
76
  const attachedSession = getSession();
67
77
  if (attachedSession?.remoteAttached) {
68
- try { mgr.enqueueRemotePendingMessage?.(attachedSession.id, prompt); } catch { /* best-effort */ }
78
+ const delivered = enqueueRemoteAttachedPrompt(prompt);
69
79
  return {
70
- result: { content: 'Delivered to the live owner of this session — the reply will appear here shortly.' },
80
+ // This branch is only a race-safe fallback for callers that reached
81
+ // ask() before the live pipe was ready. Never manufacture an
82
+ // assistant response: the owner's mirrored transcript is authoritative.
83
+ result: { content: '', remoteAttached: true, delivered },
71
84
  session: attachedSession,
72
85
  };
73
86
  }
@@ -108,11 +121,9 @@ export function createSessionTurnApi(deps) {
108
121
  }
109
122
  }
110
123
  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.
124
+ // Turn-review boundary: start a fresh session+turn generation. Lead
125
+ // patches remain transcript-derived; worker apply_patch diffs bind to
126
+ // this generation and cannot leak into the next turn/session.
116
127
  try { await beginTurnSnapshot(getCurrentCwd(), session0?.id); } catch { /* never blocks a turn */ }
117
128
  if (session0.deferredInitialRefreshPending) {
118
129
  // FIRST TURN of a FRESH session (session-local gate, NOT the
@@ -59,6 +59,10 @@ import {
59
59
  } from './agent-tool/worker-rows.mjs';
60
60
  import { resolveAgentTerminalReapMs } from '../session-runtime/config-helpers.mjs';
61
61
  import { createWorkerIndex } from './agent-tool/worker-index.mjs';
62
+ import {
63
+ beginAgentTurnReview,
64
+ completeAgentTurnReview,
65
+ } from '../runtime/shared/turn-snapshot.mjs';
62
66
  // Re-export the static tool descriptor so importers of this facade keep the
63
67
  // identical public surface (`import { AGENT_TOOL } from './agent-tool.mjs'`).
64
68
  export { AGENT_TOOL };
@@ -91,6 +95,26 @@ export function createStandaloneAgent({
91
95
  if (typeof onSubagentEvent !== 'function') return;
92
96
  try { onSubagentEvent(phase, { agent_type: agent || null, ...extra }); } catch { /* best-effort */ }
93
97
  }
98
+ function createTurnReviewCollector(session, tag, agent, notifyContext = {}) {
99
+ const ownerSessionId = clean(
100
+ notifyContext?.callerSessionId
101
+ || notifyContext?.sessionId
102
+ || notifyContext?.routingSessionId
103
+ || session?.ownerSessionId,
104
+ );
105
+ const handle = beginAgentTurnReview(ownerSessionId, session?.id, { tag, agent });
106
+ const patches = [];
107
+ return {
108
+ onToolResult(message) {
109
+ if (typeof message?.uiDiff === 'string' && message.uiDiff.trim()) {
110
+ patches.push(message.uiDiff);
111
+ }
112
+ },
113
+ complete() {
114
+ completeAgentTurnReview(handle, patches);
115
+ },
116
+ };
117
+ }
94
118
  const tags = new Map();
95
119
  const tagAgents = new Map();
96
120
  const tagCwds = new Map();
@@ -869,6 +893,7 @@ export function createStandaloneAgent({
869
893
  async function runSpawn(prepared, notifyContext = null, job = null) {
870
894
  const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
871
895
  const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy, agent);
896
+ const turnReview = createTurnReviewCollector(session, tag, agent, notifyContext || {});
872
897
  let finalStatus = 'idle';
873
898
  // SubagentStart: a worker session is about to run its first turn.
874
899
  emitSubagentEvent('start', agent, { session_id: session.id, tag });
@@ -907,8 +932,10 @@ export function createStandaloneAgent({
907
932
  handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(session.id));
908
933
  const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
909
934
  notifyFn: workerNotifyFn(session.id, notifyContext || {}),
935
+ onToolResult: (message) => turnReview.onToolResult(message),
910
936
  ...(job ? {
911
937
  onTerminalResult: (terminalResult) => {
938
+ turnReview.complete();
912
939
  const value = completionValue(terminalResult);
913
940
  if (job) job._terminalResultValue = value;
914
941
  notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
@@ -999,6 +1026,7 @@ export function createStandaloneAgent({
999
1026
  }
1000
1027
  throw error;
1001
1028
  } finally {
1029
+ turnReview.complete();
1002
1030
  watchdog?.stop?.();
1003
1031
  upsertWorkerSessionDeferred(session, tag, {
1004
1032
  agent,
@@ -1056,6 +1084,7 @@ export function createStandaloneAgent({
1056
1084
  const sendAgent = session.agent || normalizeAgentName(args.agent);
1057
1085
  const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent), sendAgent);
1058
1086
  const tag = tagForSession(sessionId);
1087
+ const turnReview = createTurnReviewCollector(session, tag, sendAgent, notifyContext || {});
1059
1088
  let finalStatus = 'idle';
1060
1089
  upsertWorkerSessionDeferred(session, tag, { status: 'running', stage: 'running' });
1061
1090
  let handoffMsgStart = 0;
@@ -1078,8 +1107,10 @@ export function createStandaloneAgent({
1078
1107
  handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(sessionId));
1079
1108
  const result = await mgr.askSession(sessionId, prompt, args.context || null, null, session.cwd || defaultCwd, null, {
1080
1109
  notifyFn: workerNotifyFn(sessionId, notifyContext || {}),
1110
+ onToolResult: (message) => turnReview.onToolResult(message),
1081
1111
  ...(job ? {
1082
1112
  onTerminalResult: (terminalResult) => {
1113
+ turnReview.complete();
1083
1114
  const value = completionValue(terminalResult);
1084
1115
  if (job) job._terminalResultValue = value;
1085
1116
  notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
@@ -1153,6 +1184,7 @@ export function createStandaloneAgent({
1153
1184
  }
1154
1185
  throw error;
1155
1186
  } finally {
1187
+ turnReview.complete();
1156
1188
  watchdog?.stop?.();
1157
1189
  upsertWorkerSessionDeferred(session, tag, {
1158
1190
  status: finalStatus,
@@ -29555,6 +29555,8 @@ import { unlinkSync as unlinkSync4 } from "node:fs";
29555
29555
  var MAX_FRAME_PATCHES = 48;
29556
29556
  var MAX_BUFFER_BYTES = 8 * 1024 * 1024;
29557
29557
  var SYNC_REQUEST_MIN_INTERVAL_MS = 500;
29558
+ var LIVE_CONNECT_RETRY_MIN_MS = 10;
29559
+ var LIVE_CONNECT_RETRY_MAX_MS = 160;
29558
29560
  function liveSharePipePath(sessionId, sessionFilePath) {
29559
29561
  return process.platform === "win32" ? `\\\\.\\pipe\\mixdog-live-${sessionId}` : `${sessionFilePath}.live.sock`;
29560
29562
  }
@@ -29597,9 +29599,13 @@ function createLiveShare({
29597
29599
  onOwnerClosed,
29598
29600
  viewerApply
29599
29601
  }) {
29602
+ let disposed = false;
29600
29603
  let server = null;
29601
29604
  let serverId = "";
29602
29605
  let serverPath = "";
29606
+ let serverRetryTimer = null;
29607
+ let serverRetryId = "";
29608
+ let serverRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
29603
29609
  const sockets = /* @__PURE__ */ new Set();
29604
29610
  let lastItems = null;
29605
29611
  let lastTail = null;
@@ -29735,8 +29741,36 @@ function createLiveShare({
29735
29741
  if (dirty) broadcast(frame);
29736
29742
  };
29737
29743
  listeners.add(onPublish);
29744
+ const clearServerRetry = () => {
29745
+ if (serverRetryTimer) clearTimeout(serverRetryTimer);
29746
+ serverRetryTimer = null;
29747
+ serverRetryId = "";
29748
+ };
29749
+ const scheduleServerRetry = (id) => {
29750
+ const target = String(id || "");
29751
+ if (disposed || !target || server || serverRetryTimer || String(ownerSessionId() || "") !== target) return;
29752
+ const delay2 = serverRetryDelayMs;
29753
+ serverRetryDelayMs = Math.min(LIVE_CONNECT_RETRY_MAX_MS, Math.max(
29754
+ LIVE_CONNECT_RETRY_MIN_MS,
29755
+ serverRetryDelayMs * 2
29756
+ ));
29757
+ serverRetryId = target;
29758
+ serverRetryTimer = setTimeout(() => {
29759
+ serverRetryTimer = null;
29760
+ serverRetryId = "";
29761
+ if (disposed || server || String(ownerSessionId() || "") !== target) return;
29762
+ startServer2(target);
29763
+ }, delay2);
29764
+ serverRetryTimer.unref?.();
29765
+ };
29738
29766
  const stopServer = () => {
29739
- if (!server) return;
29767
+ clearServerRetry();
29768
+ serverRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
29769
+ if (!server) {
29770
+ serverId = "";
29771
+ serverPath = "";
29772
+ return;
29773
+ }
29740
29774
  try {
29741
29775
  broadcast({ t: "close" });
29742
29776
  } catch {
@@ -29764,6 +29798,8 @@ function createLiveShare({
29764
29798
  serverPath = "";
29765
29799
  };
29766
29800
  const startServer2 = (id) => {
29801
+ if (disposed || server || !id) return;
29802
+ clearServerRetry();
29767
29803
  const path4 = socketPathFor(id);
29768
29804
  const next = createServer((socket) => {
29769
29805
  socket.setNoDelay?.(true);
@@ -29813,12 +29849,18 @@ function createLiveShare({
29813
29849
  server = null;
29814
29850
  serverId = "";
29815
29851
  serverPath = "";
29852
+ scheduleServerRetry(id);
29816
29853
  }
29817
29854
  try {
29818
29855
  next.close();
29819
29856
  } catch {
29820
29857
  }
29821
29858
  });
29859
+ next.on("listening", () => {
29860
+ if (server !== next) return;
29861
+ clearServerRetry();
29862
+ serverRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
29863
+ });
29822
29864
  if (process.platform !== "win32") {
29823
29865
  try {
29824
29866
  unlinkSync4(path4);
@@ -29834,6 +29876,7 @@ function createLiveShare({
29834
29876
  server = null;
29835
29877
  serverId = "";
29836
29878
  serverPath = "";
29879
+ scheduleServerRetry(id);
29837
29880
  }
29838
29881
  };
29839
29882
  let client = null;
@@ -29841,15 +29884,19 @@ function createLiveShare({
29841
29884
  let clientUp = false;
29842
29885
  let clientSyncedId = "";
29843
29886
  let lastSyncRequestAt = 0;
29887
+ let clientRetryTimer = null;
29888
+ let clientRetryId = "";
29889
+ let clientRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
29844
29890
  const viewerSyncWaiters = /* @__PURE__ */ new Set();
29845
29891
  const settleViewerSync = (id, synced) => {
29846
29892
  for (const waiter of [...viewerSyncWaiters]) {
29847
29893
  if (waiter.id === id) waiter.finish(synced);
29848
29894
  }
29849
29895
  };
29850
- const waitForViewerSync = (id, timeoutMs = 750) => {
29896
+ const waitForViewerSync = (id, timeoutMs = 1500) => {
29851
29897
  const target = String(id || "");
29852
29898
  if (!target) return Promise.resolve(false);
29899
+ ensureShare();
29853
29900
  if (clientUp && clientId === target && clientSyncedId === target) {
29854
29901
  return Promise.resolve(true);
29855
29902
  }
@@ -29920,6 +29967,28 @@ function createLiveShare({
29920
29967
  } catch {
29921
29968
  }
29922
29969
  };
29970
+ const clearClientRetry = () => {
29971
+ if (clientRetryTimer) clearTimeout(clientRetryTimer);
29972
+ clientRetryTimer = null;
29973
+ clientRetryId = "";
29974
+ };
29975
+ const scheduleClientRetry = (id) => {
29976
+ const target = String(id || "");
29977
+ if (disposed || !target || client || clientRetryTimer || String(viewerSessionId() || "") !== target) return;
29978
+ const delay2 = clientRetryDelayMs;
29979
+ clientRetryDelayMs = Math.min(LIVE_CONNECT_RETRY_MAX_MS, Math.max(
29980
+ LIVE_CONNECT_RETRY_MIN_MS,
29981
+ clientRetryDelayMs * 2
29982
+ ));
29983
+ clientRetryId = target;
29984
+ clientRetryTimer = setTimeout(() => {
29985
+ clientRetryTimer = null;
29986
+ clientRetryId = "";
29987
+ if (disposed || client || String(viewerSessionId() || "") !== target) return;
29988
+ startClient(target);
29989
+ }, delay2);
29990
+ clientRetryTimer.unref?.();
29991
+ };
29923
29992
  const applyViewerFrame = (frame, socket) => {
29924
29993
  if (frame.t === "full") {
29925
29994
  viewerApply.replaceItems(Array.isArray(frame.items) ? frame.items : []);
@@ -29954,6 +30023,8 @@ function createLiveShare({
29954
30023
  if ("live" in frame) applyLiveState(frame.live);
29955
30024
  };
29956
30025
  const stopClient = () => {
30026
+ clearClientRetry();
30027
+ clientRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
29957
30028
  const closing = client;
29958
30029
  const closingId = clientId;
29959
30030
  const wasUp = clientUp;
@@ -29971,10 +30042,13 @@ function createLiveShare({
29971
30042
  if (wasUp) clearMirroredLiveState();
29972
30043
  };
29973
30044
  const startClient = (id) => {
30045
+ if (disposed || client || !id) return;
30046
+ clearClientRetry();
29974
30047
  let socket;
29975
30048
  try {
29976
30049
  socket = connect(socketPathFor(id));
29977
30050
  } catch {
30051
+ scheduleClientRetry(id);
29978
30052
  return;
29979
30053
  }
29980
30054
  client = socket;
@@ -29983,14 +30057,19 @@ function createLiveShare({
29983
30057
  clientSyncedId = "";
29984
30058
  socket.setNoDelay?.(true);
29985
30059
  socket.on("connect", () => {
29986
- if (client === socket) clientUp = true;
30060
+ if (client !== socket) return;
30061
+ clientUp = true;
30062
+ clearClientRetry();
30063
+ clientRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
29987
30064
  });
29988
30065
  const down = (ownerClosed) => {
29989
30066
  const wasUp = clientUp && client === socket;
29990
- if (client === socket) {
30067
+ const wasCurrent = client === socket;
30068
+ if (wasCurrent) {
29991
30069
  client = null;
29992
30070
  clientId = "";
29993
30071
  clientUp = false;
30072
+ clientSyncedId = "";
29994
30073
  }
29995
30074
  try {
29996
30075
  socket.destroy();
@@ -29998,6 +30077,7 @@ function createLiveShare({
29998
30077
  }
29999
30078
  if (wasUp) clearMirroredLiveState();
30000
30079
  if (wasUp) onOwnerClosed?.(id, ownerClosed);
30080
+ if (wasCurrent) scheduleClientRetry(id);
30001
30081
  };
30002
30082
  socket.on("error", () => down(false));
30003
30083
  socket.on("close", () => down(false));
@@ -30019,17 +30099,21 @@ function createLiveShare({
30019
30099
  }
30020
30100
  }, () => down(false));
30021
30101
  };
30102
+ const ensureShare = () => {
30103
+ if (disposed) return;
30104
+ const ownerId = String(ownerSessionId() || "");
30105
+ const attachId = ownerId ? "" : String(viewerSessionId() || "");
30106
+ if (serverRetryId && serverRetryId !== ownerId) clearServerRetry();
30107
+ if (!ownerId && serverRetryTimer) clearServerRetry();
30108
+ if (serverId && serverId !== ownerId) stopServer();
30109
+ if (ownerId && !server && !serverRetryTimer) startServer2(ownerId);
30110
+ if (clientRetryId && clientRetryId !== attachId) clearClientRetry();
30111
+ if (!attachId && clientRetryTimer) clearClientRetry();
30112
+ if (clientId && clientId !== attachId) stopClient();
30113
+ if (attachId && !client && !clientRetryTimer) startClient(attachId);
30114
+ };
30022
30115
  return {
30023
- // Reconciles both legs against the current session role; called from the
30024
- // engine share tick (also serves as the reconnect/retry cadence).
30025
- ensure() {
30026
- const ownerId = String(ownerSessionId() || "");
30027
- const attachId = ownerId ? "" : String(viewerSessionId() || "");
30028
- if (serverId && serverId !== ownerId) stopServer();
30029
- if (ownerId && !server) startServer2(ownerId);
30030
- if (clientId && clientId !== attachId) stopClient();
30031
- if (attachId && !client) startClient(attachId);
30032
- },
30116
+ ensure: ensureShare,
30033
30117
  viewerConnected: () => clientUp,
30034
30118
  waitForViewerSync,
30035
30119
  sendSubmit(text) {
@@ -30051,9 +30135,11 @@ function createLiveShare({
30051
30135
  }
30052
30136
  },
30053
30137
  dispose() {
30138
+ disposed = true;
30054
30139
  listeners.delete(onPublish);
30055
30140
  stopServer();
30056
30141
  stopClient();
30142
+ for (const waiter of [...viewerSyncWaiters]) waiter.finish(false);
30057
30143
  }
30058
30144
  };
30059
30145
  }
@@ -31198,6 +31284,7 @@ async function createEngineSession({
31198
31284
  const timer2 = setTimeout(() => {
31199
31285
  if (flags.disposed || !state.sessionRemoteAttached) return;
31200
31286
  if (String(state.sessionId || "") !== id) return;
31287
+ if (liveShare.viewerConnected()) return;
31201
31288
  void Promise.resolve(api.resume(id, { quiet: true })).catch(() => {
31202
31289
  });
31203
31290
  }, 1500);
@@ -31223,9 +31310,15 @@ async function createEngineSession({
31223
31310
  if (typeof api.submit === "function") {
31224
31311
  const baseSubmit = api.submit;
31225
31312
  api.submit = (prompt, options = {}) => {
31226
- if (state.sessionRemoteAttached && liveShare.viewerConnected()) {
31313
+ if (state.sessionRemoteAttached) {
31227
31314
  const text = String(promptDisplayText(prompt, options) || "").trim();
31315
+ if (!text) return false;
31316
+ try {
31317
+ liveShare.ensure();
31318
+ } catch {
31319
+ }
31228
31320
  if (text && liveShare.sendSubmit(text)) return true;
31321
+ return runtime.enqueueRemoteAttachedPrompt?.(prompt) === true;
31229
31322
  }
31230
31323
  return baseSubmit(prompt, options);
31231
31324
  };
@@ -31258,6 +31351,7 @@ async function createEngineSession({
31258
31351
  return result;
31259
31352
  };
31260
31353
  }
31354
+ reconcileLiveShareNow();
31261
31355
  let spoolWatcher = null;
31262
31356
  let spoolDebounce = null;
31263
31357
  try {
@@ -23,6 +23,11 @@ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
23
23
  // Desync-recovery `sync` requests are throttled so a corrupt stream cannot
24
24
  // make the owner serialize full transcripts every frame.
25
25
  const SYNC_REQUEST_MIN_INTERVAL_MS = 500;
26
+ // Session entry is latency-sensitive: the owner pipe can be a few event-loop
27
+ // turns behind the viewer resume. Retry locally instead of waiting for the
28
+ // coarse 3s engine safety tick.
29
+ const LIVE_CONNECT_RETRY_MIN_MS = 10;
30
+ const LIVE_CONNECT_RETRY_MAX_MS = 160;
26
31
 
27
32
  export function liveSharePipePath(sessionId, sessionFilePath) {
28
33
  return process.platform === 'win32'
@@ -66,10 +71,14 @@ export function createLiveShare({
66
71
  onOwnerClosed,
67
72
  viewerApply,
68
73
  }) {
74
+ let disposed = false;
69
75
  // ---- owner: pipe server + delta publisher ----
70
76
  let server = null;
71
77
  let serverId = '';
72
78
  let serverPath = '';
79
+ let serverRetryTimer = null;
80
+ let serverRetryId = '';
81
+ let serverRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
73
82
  const sockets = new Set();
74
83
  let lastItems = null;
75
84
  let lastTail = null;
@@ -206,8 +215,38 @@ export function createLiveShare({
206
215
  };
207
216
  listeners.add(onPublish);
208
217
 
218
+ const clearServerRetry = () => {
219
+ if (serverRetryTimer) clearTimeout(serverRetryTimer);
220
+ serverRetryTimer = null;
221
+ serverRetryId = '';
222
+ };
223
+
224
+ const scheduleServerRetry = (id) => {
225
+ const target = String(id || '');
226
+ if (disposed || !target || server || serverRetryTimer || String(ownerSessionId() || '') !== target) return;
227
+ const delay = serverRetryDelayMs;
228
+ serverRetryDelayMs = Math.min(LIVE_CONNECT_RETRY_MAX_MS, Math.max(
229
+ LIVE_CONNECT_RETRY_MIN_MS,
230
+ serverRetryDelayMs * 2,
231
+ ));
232
+ serverRetryId = target;
233
+ serverRetryTimer = setTimeout(() => {
234
+ serverRetryTimer = null;
235
+ serverRetryId = '';
236
+ if (disposed || server || String(ownerSessionId() || '') !== target) return;
237
+ startServer(target);
238
+ }, delay);
239
+ serverRetryTimer.unref?.();
240
+ };
241
+
209
242
  const stopServer = () => {
210
- if (!server) return;
243
+ clearServerRetry();
244
+ serverRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
245
+ if (!server) {
246
+ serverId = '';
247
+ serverPath = '';
248
+ return;
249
+ }
211
250
  try { broadcast({ t: 'close' }); } catch { /* sockets closing anyway */ }
212
251
  for (const socket of sockets) {
213
252
  try { socket.destroy(); } catch { /* already gone */ }
@@ -224,6 +263,8 @@ export function createLiveShare({
224
263
  };
225
264
 
226
265
  const startServer = (id) => {
266
+ if (disposed || server || !id) return;
267
+ clearServerRetry();
227
268
  const path = socketPathFor(id);
228
269
  const next = createServer((socket) => {
229
270
  socket.setNoDelay?.(true);
@@ -257,11 +298,21 @@ export function createLiveShare({
257
298
  }
258
299
  });
259
300
  next.on('error', () => {
260
- // EADDRINUSE (another live owner) or a transient listen failure: give
261
- // up quietly; the next ensure() tick retries.
262
- if (server === next) { server = null; serverId = ''; serverPath = ''; }
301
+ // EADDRINUSE (another live owner) or a transient listen failure: retry
302
+ // with a short bounded backoff while this surface still owns the id.
303
+ if (server === next) {
304
+ server = null;
305
+ serverId = '';
306
+ serverPath = '';
307
+ scheduleServerRetry(id);
308
+ }
263
309
  try { next.close(); } catch { /* already closed */ }
264
310
  });
311
+ next.on('listening', () => {
312
+ if (server !== next) return;
313
+ clearServerRetry();
314
+ serverRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
315
+ });
265
316
  if (process.platform !== 'win32') {
266
317
  try { unlinkSync(path); } catch { /* no stale socket */ }
267
318
  }
@@ -272,6 +323,7 @@ export function createLiveShare({
272
323
  server = null;
273
324
  serverId = '';
274
325
  serverPath = '';
326
+ scheduleServerRetry(id);
275
327
  }
276
328
  };
277
329
 
@@ -281,6 +333,9 @@ export function createLiveShare({
281
333
  let clientUp = false;
282
334
  let clientSyncedId = '';
283
335
  let lastSyncRequestAt = 0;
336
+ let clientRetryTimer = null;
337
+ let clientRetryId = '';
338
+ let clientRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
284
339
  const viewerSyncWaiters = new Set();
285
340
 
286
341
  const settleViewerSync = (id, synced) => {
@@ -289,9 +344,10 @@ export function createLiveShare({
289
344
  }
290
345
  };
291
346
 
292
- const waitForViewerSync = (id, timeoutMs = 750) => {
347
+ const waitForViewerSync = (id, timeoutMs = 1500) => {
293
348
  const target = String(id || '');
294
349
  if (!target) return Promise.resolve(false);
350
+ ensureShare();
295
351
  if (clientUp && clientId === target && clientSyncedId === target) {
296
352
  return Promise.resolve(true);
297
353
  }
@@ -363,6 +419,30 @@ export function createLiveShare({
363
419
  } catch { /* viewer store already disposed */ }
364
420
  };
365
421
 
422
+ const clearClientRetry = () => {
423
+ if (clientRetryTimer) clearTimeout(clientRetryTimer);
424
+ clientRetryTimer = null;
425
+ clientRetryId = '';
426
+ };
427
+
428
+ const scheduleClientRetry = (id) => {
429
+ const target = String(id || '');
430
+ if (disposed || !target || client || clientRetryTimer || String(viewerSessionId() || '') !== target) return;
431
+ const delay = clientRetryDelayMs;
432
+ clientRetryDelayMs = Math.min(LIVE_CONNECT_RETRY_MAX_MS, Math.max(
433
+ LIVE_CONNECT_RETRY_MIN_MS,
434
+ clientRetryDelayMs * 2,
435
+ ));
436
+ clientRetryId = target;
437
+ clientRetryTimer = setTimeout(() => {
438
+ clientRetryTimer = null;
439
+ clientRetryId = '';
440
+ if (disposed || client || String(viewerSessionId() || '') !== target) return;
441
+ startClient(target);
442
+ }, delay);
443
+ clientRetryTimer.unref?.();
444
+ };
445
+
366
446
  const applyViewerFrame = (frame, socket) => {
367
447
  if (frame.t === 'full') {
368
448
  viewerApply.replaceItems(Array.isArray(frame.items) ? frame.items : []);
@@ -400,6 +480,8 @@ export function createLiveShare({
400
480
  };
401
481
 
402
482
  const stopClient = () => {
483
+ clearClientRetry();
484
+ clientRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
403
485
  const closing = client;
404
486
  const closingId = clientId;
405
487
  const wasUp = clientUp;
@@ -422,22 +504,39 @@ export function createLiveShare({
422
504
  };
423
505
 
424
506
  const startClient = (id) => {
507
+ if (disposed || client || !id) return;
508
+ clearClientRetry();
425
509
  let socket;
426
- try { socket = connect(socketPathFor(id)); } catch { return; }
510
+ try { socket = connect(socketPathFor(id)); } catch {
511
+ scheduleClientRetry(id);
512
+ return;
513
+ }
427
514
  client = socket;
428
515
  clientId = id;
429
516
  clientUp = false;
430
517
  clientSyncedId = '';
431
518
  socket.setNoDelay?.(true);
432
- socket.on('connect', () => { if (client === socket) clientUp = true; });
519
+ socket.on('connect', () => {
520
+ if (client !== socket) return;
521
+ clientUp = true;
522
+ clearClientRetry();
523
+ clientRetryDelayMs = LIVE_CONNECT_RETRY_MIN_MS;
524
+ });
433
525
  const down = (ownerClosed) => {
434
526
  const wasUp = clientUp && client === socket;
435
- if (client === socket) { client = null; clientId = ''; clientUp = false; }
527
+ const wasCurrent = client === socket;
528
+ if (wasCurrent) {
529
+ client = null;
530
+ clientId = '';
531
+ clientUp = false;
532
+ clientSyncedId = '';
533
+ }
436
534
  try { socket.destroy(); } catch { /* already gone */ }
437
535
  if (wasUp) clearMirroredLiveState();
438
536
  // A live link that dropped means the owner ended or crashed: nudge the
439
537
  // promotion path instead of waiting for the next store-mtime change.
440
538
  if (wasUp) onOwnerClosed?.(id, ownerClosed);
539
+ if (wasCurrent) scheduleClientRetry(id);
441
540
  };
442
541
  socket.on('error', () => down(false));
443
542
  socket.on('close', () => down(false));
@@ -459,17 +558,25 @@ export function createLiveShare({
459
558
  }, () => down(false));
460
559
  };
461
560
 
561
+ // Reconciles both legs against the current session role. Failed pipe opens
562
+ // continue through the local retry timers; the engine's 3s call is only a
563
+ // safety net, never the normal connection cadence.
564
+ const ensureShare = () => {
565
+ if (disposed) return;
566
+ const ownerId = String(ownerSessionId() || '');
567
+ const attachId = ownerId ? '' : String(viewerSessionId() || '');
568
+ if (serverRetryId && serverRetryId !== ownerId) clearServerRetry();
569
+ if (!ownerId && serverRetryTimer) clearServerRetry();
570
+ if (serverId && serverId !== ownerId) stopServer();
571
+ if (ownerId && !server && !serverRetryTimer) startServer(ownerId);
572
+ if (clientRetryId && clientRetryId !== attachId) clearClientRetry();
573
+ if (!attachId && clientRetryTimer) clearClientRetry();
574
+ if (clientId && clientId !== attachId) stopClient();
575
+ if (attachId && !client && !clientRetryTimer) startClient(attachId);
576
+ };
577
+
462
578
  return {
463
- // Reconciles both legs against the current session role; called from the
464
- // engine share tick (also serves as the reconnect/retry cadence).
465
- ensure() {
466
- const ownerId = String(ownerSessionId() || '');
467
- const attachId = ownerId ? '' : String(viewerSessionId() || '');
468
- if (serverId && serverId !== ownerId) stopServer();
469
- if (ownerId && !server) startServer(ownerId);
470
- if (clientId && clientId !== attachId) stopClient();
471
- if (attachId && !client) startClient(attachId);
472
- },
579
+ ensure: ensureShare,
473
580
  viewerConnected: () => clientUp,
474
581
  waitForViewerSync,
475
582
  sendSubmit(text) {
@@ -491,9 +598,11 @@ export function createLiveShare({
491
598
  }
492
599
  },
493
600
  dispose() {
601
+ disposed = true;
494
602
  listeners.delete(onPublish);
495
603
  stopServer();
496
604
  stopClient();
605
+ for (const waiter of [...viewerSyncWaiters]) waiter.finish(false);
497
606
  },
498
607
  };
499
608
  }
@@ -899,6 +899,7 @@ export async function createEngineSession({
899
899
  const timer = setTimeout(() => {
900
900
  if (flags.disposed || !state.sessionRemoteAttached) return;
901
901
  if (String(state.sessionId || '') !== id) return;
902
+ if (liveShare.viewerConnected()) return;
902
903
  void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* tick retries */ });
903
904
  }, 1500);
904
905
  timer.unref?.();
@@ -928,9 +929,16 @@ export async function createEngineSession({
928
929
  if (typeof api.submit === 'function') {
929
930
  const baseSubmit = api.submit;
930
931
  api.submit = (prompt, options = {}) => {
931
- if (state.sessionRemoteAttached && liveShare.viewerConnected()) {
932
+ if (state.sessionRemoteAttached) {
932
933
  const text = String(promptDisplayText(prompt, options) || '').trim();
934
+ if (!text) return false;
935
+ // Reconcile first so a session that became attachable this event-loop
936
+ // turn uses the instant pipe path. If the pipe is still opening, write
937
+ // directly to the durable owner spool instead of starting a fake local
938
+ // turn that renders an error/synthetic assistant message.
939
+ try { liveShare.ensure(); } catch { /* durable fallback below */ }
933
940
  if (text && liveShare.sendSubmit(text)) return true;
941
+ return runtime.enqueueRemoteAttachedPrompt?.(prompt) === true;
934
942
  }
935
943
  return baseSubmit(prompt, options);
936
944
  };
@@ -973,6 +981,9 @@ export async function createEngineSession({
973
981
  return result;
974
982
  };
975
983
  }
984
+ // Cover engines whose runtime already has a session at construction time;
985
+ // do not wait for a lifecycle method or the 3s safety pulse to open the pipe.
986
+ reconcileLiveShareNow();
976
987
  // Instant input pickup: watch the shared pending spool so an attached
977
988
  // surface's fallback submit reaches this owner immediately instead of on
978
989
  // the 3s tick. Best-effort — the tick below remains the safety net.