mixdog 0.9.64 → 0.9.65

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.64",
3
+ "version": "0.9.65",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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
+ }
@@ -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
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}
@@ -23604,7 +23604,7 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
23604
23604
  StatusLine,
23605
23605
  {
23606
23606
  sessionId: state.sessionId,
23607
- clientHostPid: state.clientHostPid,
23607
+ clientHostPid: state.ownerClientHostPid || state.clientHostPid,
23608
23608
  provider: state.provider,
23609
23609
  model: state.model,
23610
23610
  effort: state.effort,
@@ -28049,6 +28049,9 @@ function createEngineApiB(bag) {
28049
28049
  getUsageDashboard: async (options = {}) => {
28050
28050
  return await runtime.getUsageDashboard?.(options);
28051
28051
  },
28052
+ getTurnReviewDiff: async () => {
28053
+ return await runtime.getTurnReviewDiff?.() ?? { supported: false, files: [], patch: "" };
28054
+ },
28052
28055
  getOnboardingStatus: () => {
28053
28056
  return runtime.getOnboardingStatus?.() || { completed: true, workflowRoutes: {} };
28054
28057
  },
@@ -29341,6 +29344,7 @@ function createLiveShare({
29341
29344
  getPublishedState,
29342
29345
  listeners,
29343
29346
  onRemoteSubmit,
29347
+ onRemoteAbort,
29344
29348
  onOwnerClosed,
29345
29349
  viewerApply
29346
29350
  }) {
@@ -29351,6 +29355,7 @@ function createLiveShare({
29351
29355
  let lastItems = null;
29352
29356
  let lastTail = null;
29353
29357
  let lastSpinner = null;
29358
+ let lastLiveSig = "";
29354
29359
  const broadcast = (frame) => {
29355
29360
  if (sockets.size === 0) return;
29356
29361
  const line = frameLine(frame);
@@ -29361,12 +29366,50 @@ function createLiveShare({
29361
29366
  }
29362
29367
  }
29363
29368
  };
29369
+ const LIVE_STATS_KEYS = [
29370
+ "currentContextSource",
29371
+ "currentContextTokens",
29372
+ "currentEstimatedContextTokens",
29373
+ "currentContextUpdatedAt",
29374
+ "costUsd",
29375
+ "turns",
29376
+ "inputTokens",
29377
+ "outputTokens",
29378
+ "latestInputTokens",
29379
+ "latestPromptTokens",
29380
+ "contextTokens"
29381
+ ];
29382
+ const liveStateOf = (st) => {
29383
+ const stats = st.stats && typeof st.stats === "object" ? st.stats : {};
29384
+ const statsSubset = {};
29385
+ for (const key of LIVE_STATS_KEYS) {
29386
+ if (stats[key] !== void 0) statsSubset[key] = stats[key];
29387
+ }
29388
+ return {
29389
+ busy: st.busy === true,
29390
+ commandBusy: st.commandBusy === true,
29391
+ queued: (Array.isArray(st.queued) ? st.queued : []).map((entry) => ({
29392
+ id: entry?.id,
29393
+ text: String(entry?.displayText ?? entry?.text ?? entry?.message ?? "").slice(0, 2e3),
29394
+ ...entry?.enqueuedAt ? { enqueuedAt: entry.enqueuedAt } : {}
29395
+ })),
29396
+ activeToolSummary: st.activeToolSummary || null,
29397
+ agentWorkers: Array.isArray(st.agentWorkers) ? st.agentWorkers : [],
29398
+ agentJobs: Array.isArray(st.agentJobs) ? st.agentJobs : [],
29399
+ ownerClientHostPid: Number(st.clientHostPid) || process.pid,
29400
+ displayContextWindow: Number(st.displayContextWindow) || 0,
29401
+ compactBoundaryTokens: Number(st.compactBoundaryTokens) || 0,
29402
+ autoCompactTokenLimit: Number(st.autoCompactTokenLimit) || 0,
29403
+ stats: statsSubset
29404
+ };
29405
+ };
29364
29406
  const fullFrame = (st) => ({
29365
29407
  t: "full",
29366
29408
  sessionId: serverId,
29367
29409
  items: Array.isArray(st.items) ? st.items : [],
29368
29410
  tail: st.streamingTail || null,
29369
- spinner: st.spinner || null
29411
+ spinner: st.spinner || null,
29412
+ live: liveStateOf(st)
29370
29413
  });
29371
29414
  const onPublish = () => {
29372
29415
  const st = getPublishedState();
@@ -29374,6 +29417,7 @@ function createLiveShare({
29374
29417
  lastItems = st.items;
29375
29418
  lastTail = st.streamingTail;
29376
29419
  lastSpinner = st.spinner;
29420
+ lastLiveSig = "";
29377
29421
  return;
29378
29422
  }
29379
29423
  const frame = { t: "delta" };
@@ -29432,6 +29476,13 @@ function createLiveShare({
29432
29476
  lastSpinner = st.spinner;
29433
29477
  dirty = true;
29434
29478
  }
29479
+ const live = liveStateOf(st);
29480
+ const liveSig = JSON.stringify(live);
29481
+ if (liveSig !== lastLiveSig) {
29482
+ frame.live = live;
29483
+ lastLiveSig = liveSig;
29484
+ dirty = true;
29485
+ }
29435
29486
  if (dirty) broadcast(frame);
29436
29487
  };
29437
29488
  listeners.add(onPublish);
@@ -29480,6 +29531,8 @@ function createLiveShare({
29480
29531
  attachLineReader(socket, (frame) => {
29481
29532
  if (frame.t === "submit" && typeof frame.text === "string" && frame.text.trim()) {
29482
29533
  onRemoteSubmit(frame.text);
29534
+ } else if (frame.t === "abort") {
29535
+ onRemoteAbort?.();
29483
29536
  } else if (frame.t === "sync") {
29484
29537
  try {
29485
29538
  socket.write(frameLine(fullFrame(getPublishedState())));
@@ -29497,6 +29550,7 @@ function createLiveShare({
29497
29550
  lastItems = st.items;
29498
29551
  lastTail = st.streamingTail;
29499
29552
  lastSpinner = st.spinner;
29553
+ lastLiveSig = JSON.stringify(liveStateOf(st));
29500
29554
  socket.write(frameLine(fullFrame(st)));
29501
29555
  } catch {
29502
29556
  try {
@@ -29582,11 +29636,47 @@ function createLiveShare({
29582
29636
  if (exists) viewerApply.patchItem(item.id, item);
29583
29637
  else viewerApply.appendItems([item]);
29584
29638
  };
29639
+ const applyLiveState = (live) => {
29640
+ if (!live || typeof live !== "object") return;
29641
+ const patch = {
29642
+ busy: live.busy === true,
29643
+ commandBusy: live.commandBusy === true,
29644
+ queued: Array.isArray(live.queued) ? live.queued : [],
29645
+ activeToolSummary: live.activeToolSummary || null,
29646
+ agentWorkers: Array.isArray(live.agentWorkers) ? live.agentWorkers : [],
29647
+ agentJobs: Array.isArray(live.agentJobs) ? live.agentJobs : [],
29648
+ ownerClientHostPid: Number(live.ownerClientHostPid) || 0
29649
+ };
29650
+ for (const key of ["displayContextWindow", "compactBoundaryTokens", "autoCompactTokenLimit"]) {
29651
+ if (Number(live[key]) > 0) patch[key] = Number(live[key]);
29652
+ }
29653
+ if (live.stats && typeof live.stats === "object" && Object.keys(live.stats).length > 0) {
29654
+ const current = viewerApply.getState().stats;
29655
+ patch.stats = { ...current && typeof current === "object" ? current : {}, ...live.stats };
29656
+ }
29657
+ viewerApply.set(patch);
29658
+ };
29659
+ const clearMirroredLiveState = () => {
29660
+ try {
29661
+ viewerApply?.set?.({
29662
+ busy: false,
29663
+ commandBusy: false,
29664
+ spinner: null,
29665
+ queued: [],
29666
+ activeToolSummary: null,
29667
+ agentWorkers: [],
29668
+ agentJobs: [],
29669
+ ownerClientHostPid: 0
29670
+ });
29671
+ } catch {
29672
+ }
29673
+ };
29585
29674
  const applyViewerFrame = (frame, socket) => {
29586
29675
  if (frame.t === "full") {
29587
29676
  viewerApply.replaceItems(Array.isArray(frame.items) ? frame.items : []);
29588
29677
  if (frame.tail) viewerApply.updateStreamingTail(frame.tail.id, frame.tail);
29589
29678
  viewerApply.set({ spinner: frame.spinner || null });
29679
+ applyLiveState(frame.live);
29590
29680
  return;
29591
29681
  }
29592
29682
  if (frame.t !== "delta") return;
@@ -29612,6 +29702,7 @@ function createLiveShare({
29612
29702
  else viewerApply.clearStreamingTail();
29613
29703
  }
29614
29704
  if ("spinner" in frame) viewerApply.set({ spinner: frame.spinner || null });
29705
+ if ("live" in frame) applyLiveState(frame.live);
29615
29706
  };
29616
29707
  const stopClient = () => {
29617
29708
  const closing = client;
@@ -29654,6 +29745,7 @@ function createLiveShare({
29654
29745
  socket.destroy();
29655
29746
  } catch {
29656
29747
  }
29748
+ if (wasUp) clearMirroredLiveState();
29657
29749
  if (wasUp) onOwnerClosed?.(id, ownerClosed);
29658
29750
  };
29659
29751
  socket.on("error", () => down(false));
@@ -29698,6 +29790,15 @@ function createLiveShare({
29698
29790
  return false;
29699
29791
  }
29700
29792
  },
29793
+ sendAbort() {
29794
+ if (!clientUp || !client) return false;
29795
+ try {
29796
+ client.write(frameLine({ t: "abort" }));
29797
+ return true;
29798
+ } catch {
29799
+ return false;
29800
+ }
29801
+ },
29701
29802
  dispose() {
29702
29803
  listeners.delete(onPublish);
29703
29804
  stopServer();
@@ -30667,6 +30768,10 @@ async function createEngineSession({
30667
30768
  lifecycle.runtimePulseTimer = setInterval(() => {
30668
30769
  if (flags.disposed) return;
30669
30770
  if (flags.pendingSessionReset) return;
30771
+ if (bag.liveShareMirroring?.()) {
30772
+ set({ ...routeState() });
30773
+ return;
30774
+ }
30670
30775
  syncContextStats({ allowEstimated: true });
30671
30776
  set({
30672
30777
  ...routeState(),
@@ -30812,6 +30917,13 @@ async function createEngineSession({
30812
30917
  bag.enqueue(text);
30813
30918
  void bag.drain();
30814
30919
  },
30920
+ onRemoteAbort: () => {
30921
+ if (flags.disposed || state.sessionRemoteAttached) return;
30922
+ try {
30923
+ api.abort?.();
30924
+ } catch {
30925
+ }
30926
+ },
30815
30927
  onOwnerClosed: (id) => {
30816
30928
  const timer2 = setTimeout(() => {
30817
30929
  if (flags.disposed || !state.sessionRemoteAttached) return;
@@ -30837,6 +30949,7 @@ async function createEngineSession({
30837
30949
  } catch {
30838
30950
  }
30839
30951
  };
30952
+ bag.liveShareMirroring = () => state.sessionRemoteAttached && liveShare.viewerConnected();
30840
30953
  if (typeof api.submit === "function") {
30841
30954
  const baseSubmit = api.submit;
30842
30955
  api.submit = (prompt, options = {}) => {
@@ -30847,6 +30960,15 @@ async function createEngineSession({
30847
30960
  return baseSubmit(prompt, options);
30848
30961
  };
30849
30962
  }
30963
+ if (typeof api.abort === "function") {
30964
+ const baseAbort = api.abort;
30965
+ api.abort = (...args) => {
30966
+ if (state.sessionRemoteAttached && liveShare.viewerConnected() && liveShare.sendAbort()) {
30967
+ return true;
30968
+ }
30969
+ return baseAbort(...args);
30970
+ };
30971
+ }
30850
30972
  const reconcileLiveShareNow = () => {
30851
30973
  try {
30852
30974
  liveShare.ensure();
@@ -62,6 +62,7 @@ export function createLiveShare({
62
62
  getPublishedState,
63
63
  listeners,
64
64
  onRemoteSubmit,
65
+ onRemoteAbort,
65
66
  onOwnerClosed,
66
67
  viewerApply,
67
68
  }) {
@@ -73,6 +74,7 @@ export function createLiveShare({
73
74
  let lastItems = null;
74
75
  let lastTail = null;
75
76
  let lastSpinner = null;
77
+ let lastLiveSig = '';
76
78
 
77
79
  const broadcast = (frame) => {
78
80
  if (sockets.size === 0) return;
@@ -82,12 +84,49 @@ export function createLiveShare({
82
84
  }
83
85
  };
84
86
 
87
+ // Live-state mirror (attach parity): the transcript alone left an attached
88
+ // viewer blind to the owner's activity — busy/stop state, the queued
89
+ // follow-up list, the Explore/Search summary, agent workers/jobs, and the
90
+ // context gauge stats all live in owner process state. Mirror a compact
91
+ // subset so viewer surfaces render them natively. queued entries are
92
+ // projected to display fields only (content parts may carry images).
93
+ const LIVE_STATS_KEYS = [
94
+ 'currentContextSource', 'currentContextTokens', 'currentEstimatedContextTokens',
95
+ 'currentContextUpdatedAt', 'costUsd', 'turns', 'inputTokens', 'outputTokens',
96
+ 'latestInputTokens', 'latestPromptTokens', 'contextTokens',
97
+ ];
98
+ const liveStateOf = (st) => {
99
+ const stats = st.stats && typeof st.stats === 'object' ? st.stats : {};
100
+ const statsSubset = {};
101
+ for (const key of LIVE_STATS_KEYS) {
102
+ if (stats[key] !== undefined) statsSubset[key] = stats[key];
103
+ }
104
+ return {
105
+ busy: st.busy === true,
106
+ commandBusy: st.commandBusy === true,
107
+ queued: (Array.isArray(st.queued) ? st.queued : []).map((entry) => ({
108
+ id: entry?.id,
109
+ text: String(entry?.displayText ?? entry?.text ?? entry?.message ?? '').slice(0, 2000),
110
+ ...(entry?.enqueuedAt ? { enqueuedAt: entry.enqueuedAt } : {}),
111
+ })),
112
+ activeToolSummary: st.activeToolSummary || null,
113
+ agentWorkers: Array.isArray(st.agentWorkers) ? st.agentWorkers : [],
114
+ agentJobs: Array.isArray(st.agentJobs) ? st.agentJobs : [],
115
+ ownerClientHostPid: Number(st.clientHostPid) || process.pid,
116
+ displayContextWindow: Number(st.displayContextWindow) || 0,
117
+ compactBoundaryTokens: Number(st.compactBoundaryTokens) || 0,
118
+ autoCompactTokenLimit: Number(st.autoCompactTokenLimit) || 0,
119
+ stats: statsSubset,
120
+ };
121
+ };
122
+
85
123
  const fullFrame = (st) => ({
86
124
  t: 'full',
87
125
  sessionId: serverId,
88
126
  items: Array.isArray(st.items) ? st.items : [],
89
127
  tail: st.streamingTail || null,
90
128
  spinner: st.spinner || null,
129
+ live: liveStateOf(st),
91
130
  });
92
131
 
93
132
  // Runs on every frame-batched publish. With no viewers it only re-baselines
@@ -98,6 +137,7 @@ export function createLiveShare({
98
137
  lastItems = st.items;
99
138
  lastTail = st.streamingTail;
100
139
  lastSpinner = st.spinner;
140
+ lastLiveSig = '';
101
141
  return;
102
142
  }
103
143
  const frame = { t: 'delta' };
@@ -155,6 +195,13 @@ export function createLiveShare({
155
195
  lastSpinner = st.spinner;
156
196
  dirty = true;
157
197
  }
198
+ const live = liveStateOf(st);
199
+ const liveSig = JSON.stringify(live);
200
+ if (liveSig !== lastLiveSig) {
201
+ frame.live = live;
202
+ lastLiveSig = liveSig;
203
+ dirty = true;
204
+ }
158
205
  if (dirty) broadcast(frame);
159
206
  };
160
207
  listeners.add(onPublish);
@@ -190,6 +237,10 @@ export function createLiveShare({
190
237
  attachLineReader(socket, (frame) => {
191
238
  if (frame.t === 'submit' && typeof frame.text === 'string' && frame.text.trim()) {
192
239
  onRemoteSubmit(frame.text);
240
+ } else if (frame.t === 'abort') {
241
+ // Viewer stop button: interrupt the owner's active turn here — the
242
+ // viewer process has no turn of its own to cancel.
243
+ onRemoteAbort?.();
193
244
  } else if (frame.t === 'sync') {
194
245
  try { socket.write(frameLine(fullFrame(getPublishedState()))); } catch { /* close handles */ }
195
246
  }
@@ -199,6 +250,7 @@ export function createLiveShare({
199
250
  lastItems = st.items;
200
251
  lastTail = st.streamingTail;
201
252
  lastSpinner = st.spinner;
253
+ lastLiveSig = JSON.stringify(liveStateOf(st));
202
254
  socket.write(frameLine(fullFrame(st)));
203
255
  } catch {
204
256
  try { socket.destroy(); } catch { /* already gone */ }
@@ -275,11 +327,48 @@ export function createLiveShare({
275
327
  else viewerApply.appendItems([item]);
276
328
  };
277
329
 
330
+ // Mirror the owner's live-state subset into the viewer store. Context stats
331
+ // merge over the local stats object so unmirrored accumulator fields keep
332
+ // their last local values instead of vanishing.
333
+ const applyLiveState = (live) => {
334
+ if (!live || typeof live !== 'object') return;
335
+ const patch = {
336
+ busy: live.busy === true,
337
+ commandBusy: live.commandBusy === true,
338
+ queued: Array.isArray(live.queued) ? live.queued : [],
339
+ activeToolSummary: live.activeToolSummary || null,
340
+ agentWorkers: Array.isArray(live.agentWorkers) ? live.agentWorkers : [],
341
+ agentJobs: Array.isArray(live.agentJobs) ? live.agentJobs : [],
342
+ ownerClientHostPid: Number(live.ownerClientHostPid) || 0,
343
+ };
344
+ for (const key of ['displayContextWindow', 'compactBoundaryTokens', 'autoCompactTokenLimit']) {
345
+ if (Number(live[key]) > 0) patch[key] = Number(live[key]);
346
+ }
347
+ if (live.stats && typeof live.stats === 'object' && Object.keys(live.stats).length > 0) {
348
+ const current = viewerApply.getState().stats;
349
+ patch.stats = { ...(current && typeof current === 'object' ? current : {}), ...live.stats };
350
+ }
351
+ viewerApply.set(patch);
352
+ };
353
+
354
+ // Owner gone (clean close, crash, or pipe drop): the mirrored activity is
355
+ // no longer authoritative — clear it so the viewer never shows a frozen
356
+ // spinner/queue while the promotion path takes over.
357
+ const clearMirroredLiveState = () => {
358
+ try {
359
+ viewerApply?.set?.({
360
+ busy: false, commandBusy: false, spinner: null, queued: [],
361
+ activeToolSummary: null, agentWorkers: [], agentJobs: [], ownerClientHostPid: 0,
362
+ });
363
+ } catch { /* viewer store already disposed */ }
364
+ };
365
+
278
366
  const applyViewerFrame = (frame, socket) => {
279
367
  if (frame.t === 'full') {
280
368
  viewerApply.replaceItems(Array.isArray(frame.items) ? frame.items : []);
281
369
  if (frame.tail) viewerApply.updateStreamingTail(frame.tail.id, frame.tail);
282
370
  viewerApply.set({ spinner: frame.spinner || null });
371
+ applyLiveState(frame.live);
283
372
  return;
284
373
  }
285
374
  if (frame.t !== 'delta') return;
@@ -307,6 +396,7 @@ export function createLiveShare({
307
396
  else viewerApply.clearStreamingTail();
308
397
  }
309
398
  if ('spinner' in frame) viewerApply.set({ spinner: frame.spinner || null });
399
+ if ('live' in frame) applyLiveState(frame.live);
310
400
  };
311
401
 
312
402
  const stopClient = () => {
@@ -335,6 +425,7 @@ export function createLiveShare({
335
425
  const wasUp = clientUp && client === socket;
336
426
  if (client === socket) { client = null; clientId = ''; clientUp = false; }
337
427
  try { socket.destroy(); } catch { /* already gone */ }
428
+ if (wasUp) clearMirroredLiveState();
338
429
  // A live link that dropped means the owner ended or crashed: nudge the
339
430
  // promotion path instead of waiting for the next store-mtime change.
340
431
  if (wasUp) onOwnerClosed?.(id, ownerClosed);
@@ -381,6 +472,15 @@ export function createLiveShare({
381
472
  return false;
382
473
  }
383
474
  },
475
+ sendAbort() {
476
+ if (!clientUp || !client) return false;
477
+ try {
478
+ client.write(frameLine({ t: 'abort' }));
479
+ return true;
480
+ } catch {
481
+ return false;
482
+ }
483
+ },
384
484
  dispose() {
385
485
  listeners.delete(onPublish);
386
486
  stopServer();
@@ -515,6 +515,9 @@ export function createEngineApiB(bag) {
515
515
  getUsageDashboard: async (options = {}) => {
516
516
  return await runtime.getUsageDashboard?.(options);
517
517
  },
518
+ getTurnReviewDiff: async () => {
519
+ return (await runtime.getTurnReviewDiff?.()) ?? { supported: false, files: [], patch: '' };
520
+ },
518
521
  getOnboardingStatus: () => {
519
522
  return runtime.getOnboardingStatus?.() || { completed: true, workflowRoutes: {} };
520
523
  },
@@ -695,6 +695,13 @@ export async function createEngineSession({
695
695
  lifecycle.runtimePulseTimer = setInterval(() => {
696
696
  if (flags.disposed) return;
697
697
  if (flags.pendingSessionReset) return;
698
+ // Attached viewer with a live pipe: the owner's frames are authoritative
699
+ // for stats/agent/tool state. Recomputing them locally here would stomp
700
+ // the mirror with this process's empty registries every 2s.
701
+ if (bag.liveShareMirroring?.()) {
702
+ set({ ...routeState() });
703
+ return;
704
+ }
698
705
  syncContextStats({ allowEstimated: true });
699
706
  set({
700
707
  ...routeState(),
@@ -822,6 +829,11 @@ export async function createEngineSession({
822
829
  bag.enqueue(text);
823
830
  void bag.drain();
824
831
  },
832
+ onRemoteAbort: () => {
833
+ // Forwarded viewer stop: interrupt OUR active turn (we are the owner).
834
+ if (flags.disposed || state.sessionRemoteAttached) return;
835
+ try { api.abort?.(); } catch { /* abort is best-effort */ }
836
+ },
825
837
  onOwnerClosed: (id) => {
826
838
  // Owner left (clean close or crash): promote via the normal quiet
827
839
  // re-resume once its final save/presence-clear has landed.
@@ -849,6 +861,9 @@ export async function createEngineSession({
849
861
  // resume() calls this right after installing the restored items so the
850
862
  // owner's full frame lands at the entry boundary instead of seconds later.
851
863
  bag.ensureLiveShare = () => { try { liveShare.ensure(); } catch { /* share tick retries */ } };
864
+ // Pulse guard: while this surface is an attached viewer with a live pipe,
865
+ // owner frames own stats/agent/tool state (see runtimePulseTimer above).
866
+ bag.liveShareMirroring = () => state.sessionRemoteAttached && liveShare.viewerConnected();
852
867
  // Live viewer submits ride the owner's pipe (instant user bubble + shared
853
868
  // streaming); the durable spool path below remains the fallback.
854
869
  if (typeof api.submit === 'function') {
@@ -861,6 +876,18 @@ export async function createEngineSession({
861
876
  return baseSubmit(prompt, options);
862
877
  };
863
878
  }
879
+ // Viewer stop button: the local engine has no in-flight turn to cancel —
880
+ // forward the interrupt to the owner over the pipe. Falls back to the local
881
+ // abort (no-op safe) when the pipe is down.
882
+ if (typeof api.abort === 'function') {
883
+ const baseAbort = api.abort;
884
+ api.abort = (...args) => {
885
+ if (state.sessionRemoteAttached && liveShare.viewerConnected() && liveShare.sendAbort()) {
886
+ return true;
887
+ }
888
+ return baseAbort(...args);
889
+ };
890
+ }
864
891
  // Attach-time pipe fast-path: session entry (resume) reconciles the live
865
892
  // pipe IMMEDIATELY instead of waiting for the 3s share tick. The attach
866
893
  // render comes from the last disk save WITHOUT the in-flight turn, so that