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 +1 -1
- package/src/runtime/shared/turn-snapshot.mjs +184 -0
- package/src/session-runtime/runtime-core.mjs +4 -0
- package/src/session-runtime/session-turn-api.mjs +7 -0
- package/src/tui/App.jsx +1 -1
- package/src/tui/dist/index.mjs +124 -2
- package/src/tui/engine/live-share.mjs +100 -0
- package/src/tui/engine/session-api-ext.mjs +3 -0
- package/src/tui/engine.mjs +27 -0
package/package.json
CHANGED
|
@@ -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}
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -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
|
},
|
package/src/tui/engine.mjs
CHANGED
|
@@ -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
|