mixdog 0.9.55 → 0.9.59

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.
Files changed (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -24,6 +24,13 @@ export function createContextState({ runtime, getState, updateState, getPendingS
24
24
  };
25
25
  const baseRouteState = () => ({
26
26
  sessionId: runtime.id,
27
+ // Fork-on-resume marker: when the engine opened a live session as a fork
28
+ // (fresh id, copied transcript), hosts validating "resume returned the
29
+ // requested session" must accept the fork by its origin id.
30
+ sessionForkedFrom: runtime.session?.forkedFrom || null,
31
+ // Remote-attach marker: this surface is a live viewer on a session owned
32
+ // by another process; submits are injected, transcript follows disk.
33
+ sessionRemoteAttached: runtime.session?.remoteAttached === true,
27
34
  clientHostPid: runtime.clientHostPid || null,
28
35
  model: runtime.model,
29
36
  provider: runtime.provider,
@@ -94,7 +101,9 @@ export function createContextState({ runtime, getState, updateState, getPendingS
94
101
  const hasTurnActivity = state.busy === true
95
102
  || state.spinner != null
96
103
  || state.thinking != null;
97
- const isFreshSession = !hasProviderUsage && !hasApiContextUsage && !hasTurnActivity;
104
+ const hasConversationMessages = Number(ctx?.messages?.count || 0) > 0;
105
+ const isFreshSession = !hasProviderUsage && !hasApiContextUsage && !hasTurnActivity
106
+ && !hasConversationMessages;
98
107
  if (isFreshSession) {
99
108
  stats.currentEstimatedContextTokens = 0;
100
109
  stats.currentContextTokens = 0;
@@ -0,0 +1,390 @@
1
+ /**
2
+ * src/tui/engine/live-share.mjs — real-time cross-surface session mirroring.
3
+ *
4
+ * OWNER leg: hosts a local named-pipe (win32) / unix-socket server for the
5
+ * session it owns and pushes frame-batched transcript deltas (items /
6
+ * streaming tail / spinner) to attached viewers. Push-only: zero polling,
7
+ * idle cost is one open listener.
8
+ * VIEWER leg: connects to the owner's pipe and mirrors those deltas into the
9
+ * local engine store, so streaming output appears live on every co-open
10
+ * surface (desktop <-> terminal). Viewer submits travel over the same pipe
11
+ * into the owner's normal submit queue (concurrent input from both sides is
12
+ * serialized there); the durable pending spool remains the fallback when the
13
+ * pipe is down. Ownership/promotion semantics are unchanged — the pipe is a
14
+ * transport, never a second writer.
15
+ */
16
+ import { createServer, connect } from 'node:net';
17
+ import { unlinkSync } from 'node:fs';
18
+
19
+ // Above this many patched rows per frame the delta degenerates to a full
20
+ // items push (bulk rewrites: compaction, clear, history restore).
21
+ const MAX_FRAME_PATCHES = 48;
22
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
23
+ // Desync-recovery `sync` requests are throttled so a corrupt stream cannot
24
+ // make the owner serialize full transcripts every frame.
25
+ const SYNC_REQUEST_MIN_INTERVAL_MS = 500;
26
+
27
+ export function liveSharePipePath(sessionId, sessionFilePath) {
28
+ return process.platform === 'win32'
29
+ ? `\\\\.\\pipe\\mixdog-live-${sessionId}`
30
+ : `${sessionFilePath}.live.sock`;
31
+ }
32
+
33
+ function frameLine(frame) {
34
+ return `${JSON.stringify(frame)}\n`;
35
+ }
36
+
37
+ function attachLineReader(socket, onFrame, onOverflow) {
38
+ let buffer = '';
39
+ socket.on('data', (chunk) => {
40
+ buffer += chunk;
41
+ if (buffer.length > MAX_BUFFER_BYTES) {
42
+ buffer = '';
43
+ onOverflow?.();
44
+ return;
45
+ }
46
+ let index;
47
+ while ((index = buffer.indexOf('\n')) >= 0) {
48
+ const line = buffer.slice(0, index);
49
+ buffer = buffer.slice(index + 1);
50
+ if (!line.trim()) continue;
51
+ let frame = null;
52
+ try { frame = JSON.parse(line); } catch { continue; }
53
+ if (frame && typeof frame === 'object') onFrame(frame);
54
+ }
55
+ });
56
+ }
57
+
58
+ export function createLiveShare({
59
+ ownerSessionId,
60
+ viewerSessionId,
61
+ socketPathFor,
62
+ getPublishedState,
63
+ listeners,
64
+ onRemoteSubmit,
65
+ onOwnerClosed,
66
+ viewerApply,
67
+ }) {
68
+ // ---- owner: pipe server + delta publisher ----
69
+ let server = null;
70
+ let serverId = '';
71
+ let serverPath = '';
72
+ const sockets = new Set();
73
+ let lastItems = null;
74
+ let lastTail = null;
75
+ let lastSpinner = null;
76
+
77
+ const broadcast = (frame) => {
78
+ if (sockets.size === 0) return;
79
+ const line = frameLine(frame);
80
+ for (const socket of sockets) {
81
+ try { socket.write(line); } catch { /* per-socket close handles it */ }
82
+ }
83
+ };
84
+
85
+ const fullFrame = (st) => ({
86
+ t: 'full',
87
+ sessionId: serverId,
88
+ items: Array.isArray(st.items) ? st.items : [],
89
+ tail: st.streamingTail || null,
90
+ spinner: st.spinner || null,
91
+ });
92
+
93
+ // Runs on every frame-batched publish. With no viewers it only re-baselines
94
+ // references (pure pointer assignments) — effectively free.
95
+ const onPublish = () => {
96
+ const st = getPublishedState();
97
+ if (!server || sockets.size === 0) {
98
+ lastItems = st.items;
99
+ lastTail = st.streamingTail;
100
+ lastSpinner = st.spinner;
101
+ return;
102
+ }
103
+ const frame = { t: 'delta' };
104
+ let dirty = false;
105
+ if (st.items !== lastItems) {
106
+ const prev = Array.isArray(lastItems) ? lastItems : [];
107
+ const next = Array.isArray(st.items) ? st.items : [];
108
+ let structural = next.length < prev.length;
109
+ const changed = [];
110
+ if (!structural) {
111
+ for (let i = 0; i < prev.length; i++) {
112
+ if (next[i] === prev[i]) continue;
113
+ if (!next[i]?.id || next[i].id !== prev[i]?.id) { structural = true; break; }
114
+ changed.push(next[i]);
115
+ if (changed.length > MAX_FRAME_PATCHES) { structural = true; break; }
116
+ }
117
+ }
118
+ if (structural) {
119
+ frame.items = next;
120
+ } else {
121
+ if (changed.length) frame.changed = changed;
122
+ if (next.length > prev.length) frame.appended = next.slice(prev.length);
123
+ }
124
+ lastItems = st.items;
125
+ dirty = true;
126
+ }
127
+ if (st.streamingTail !== lastTail) {
128
+ const nextTail = st.streamingTail || null;
129
+ const prevTail = lastTail || null;
130
+ // Streaming text grows append-only frame to frame: ship just the new
131
+ // suffix so long responses stay a few bytes per frame, not O(text).
132
+ if (nextTail && prevTail && nextTail.id === prevTail.id
133
+ && typeof nextTail.text === 'string' && typeof prevTail.text === 'string'
134
+ && nextTail.text.length >= prevTail.text.length
135
+ && nextTail.text.startsWith(prevTail.text)) {
136
+ const meta = {};
137
+ for (const [key, value] of Object.entries(nextTail)) {
138
+ if (key === 'text') continue;
139
+ if (!Object.is(prevTail[key], value)) meta[key] = value;
140
+ }
141
+ frame.tailAppend = {
142
+ id: nextTail.id,
143
+ base: prevTail.text.length,
144
+ text: nextTail.text.slice(prevTail.text.length),
145
+ ...(Object.keys(meta).length ? { meta } : {}),
146
+ };
147
+ } else {
148
+ frame.tail = nextTail;
149
+ }
150
+ lastTail = st.streamingTail;
151
+ dirty = true;
152
+ }
153
+ if (st.spinner !== lastSpinner) {
154
+ frame.spinner = st.spinner || null;
155
+ lastSpinner = st.spinner;
156
+ dirty = true;
157
+ }
158
+ if (dirty) broadcast(frame);
159
+ };
160
+ listeners.add(onPublish);
161
+
162
+ const stopServer = () => {
163
+ if (!server) return;
164
+ try { broadcast({ t: 'close' }); } catch { /* sockets closing anyway */ }
165
+ for (const socket of sockets) {
166
+ try { socket.destroy(); } catch { /* already gone */ }
167
+ }
168
+ sockets.clear();
169
+ const closing = server;
170
+ server = null;
171
+ try { closing.close(); } catch { /* already closed */ }
172
+ if (process.platform !== 'win32' && serverPath) {
173
+ try { unlinkSync(serverPath); } catch { /* never created / already gone */ }
174
+ }
175
+ serverId = '';
176
+ serverPath = '';
177
+ };
178
+
179
+ const startServer = (id) => {
180
+ const path = socketPathFor(id);
181
+ const next = createServer((socket) => {
182
+ socket.setNoDelay?.(true);
183
+ sockets.add(socket);
184
+ const cleanup = () => sockets.delete(socket);
185
+ socket.on('close', cleanup);
186
+ socket.on('error', () => {
187
+ cleanup();
188
+ try { socket.destroy(); } catch { /* already gone */ }
189
+ });
190
+ attachLineReader(socket, (frame) => {
191
+ if (frame.t === 'submit' && typeof frame.text === 'string' && frame.text.trim()) {
192
+ onRemoteSubmit(frame.text);
193
+ } else if (frame.t === 'sync') {
194
+ try { socket.write(frameLine(fullFrame(getPublishedState()))); } catch { /* close handles */ }
195
+ }
196
+ }, () => { try { socket.destroy(); } catch { /* already gone */ } });
197
+ try {
198
+ const st = getPublishedState();
199
+ lastItems = st.items;
200
+ lastTail = st.streamingTail;
201
+ lastSpinner = st.spinner;
202
+ socket.write(frameLine(fullFrame(st)));
203
+ } catch {
204
+ try { socket.destroy(); } catch { /* already gone */ }
205
+ }
206
+ });
207
+ next.on('error', () => {
208
+ // EADDRINUSE (another live owner) or a transient listen failure: give
209
+ // up quietly; the next ensure() tick retries.
210
+ if (server === next) { server = null; serverId = ''; serverPath = ''; }
211
+ try { next.close(); } catch { /* already closed */ }
212
+ });
213
+ if (process.platform !== 'win32') {
214
+ try { unlinkSync(path); } catch { /* no stale socket */ }
215
+ }
216
+ server = next;
217
+ serverId = id;
218
+ serverPath = path;
219
+ try { next.listen(path); } catch {
220
+ server = null;
221
+ serverId = '';
222
+ serverPath = '';
223
+ }
224
+ };
225
+
226
+ // ---- viewer: pipe client mirroring owner deltas ----
227
+ let client = null;
228
+ let clientId = '';
229
+ let clientUp = false;
230
+ let clientSyncedId = '';
231
+ let lastSyncRequestAt = 0;
232
+ const viewerSyncWaiters = new Set();
233
+
234
+ const settleViewerSync = (id, synced) => {
235
+ for (const waiter of [...viewerSyncWaiters]) {
236
+ if (waiter.id === id) waiter.finish(synced);
237
+ }
238
+ };
239
+
240
+ const waitForViewerSync = (id, timeoutMs = 750) => {
241
+ const target = String(id || '');
242
+ if (!target) return Promise.resolve(false);
243
+ if (clientUp && clientId === target && clientSyncedId === target) {
244
+ return Promise.resolve(true);
245
+ }
246
+ return new Promise((resolve) => {
247
+ const waiter = {
248
+ id: target,
249
+ timer: null,
250
+ finish: null,
251
+ };
252
+ waiter.finish = (synced) => {
253
+ if (!viewerSyncWaiters.delete(waiter)) return;
254
+ if (waiter.timer) clearTimeout(waiter.timer);
255
+ resolve(synced === true);
256
+ };
257
+ viewerSyncWaiters.add(waiter);
258
+ waiter.timer = setTimeout(() => waiter.finish(false), Math.max(1, Number(timeoutMs) || 750));
259
+ waiter.timer.unref?.();
260
+ });
261
+ };
262
+
263
+ const requestSync = (socket) => {
264
+ const now = Date.now();
265
+ if (now - lastSyncRequestAt < SYNC_REQUEST_MIN_INTERVAL_MS) return;
266
+ lastSyncRequestAt = now;
267
+ try { socket.write(frameLine({ t: 'sync' })); } catch { /* close handles */ }
268
+ };
269
+
270
+ const upsertItem = (item) => {
271
+ if (!item || item.id == null) return;
272
+ const items = viewerApply.getState().items;
273
+ const exists = Array.isArray(items) && items.some((it) => it?.id === item.id);
274
+ if (exists) viewerApply.patchItem(item.id, item);
275
+ else viewerApply.appendItems([item]);
276
+ };
277
+
278
+ const applyViewerFrame = (frame, socket) => {
279
+ if (frame.t === 'full') {
280
+ viewerApply.replaceItems(Array.isArray(frame.items) ? frame.items : []);
281
+ if (frame.tail) viewerApply.updateStreamingTail(frame.tail.id, frame.tail);
282
+ viewerApply.set({ spinner: frame.spinner || null });
283
+ return;
284
+ }
285
+ if (frame.t !== 'delta') return;
286
+ if (Array.isArray(frame.items)) {
287
+ viewerApply.replaceItems(frame.items, { preserveStreamingTail: true });
288
+ } else {
289
+ for (const item of frame.changed || []) upsertItem(item);
290
+ for (const item of frame.appended || []) upsertItem(item);
291
+ }
292
+ if (frame.tailAppend) {
293
+ const current = viewerApply.getState().streamingTail;
294
+ const base = Number(frame.tailAppend.base) || 0;
295
+ if (current && current.id === frame.tailAppend.id
296
+ && typeof current.text === 'string' && current.text.length === base) {
297
+ viewerApply.updateStreamingTail(frame.tailAppend.id, {
298
+ ...(frame.tailAppend.meta || {}),
299
+ text: current.text + String(frame.tailAppend.text || ''),
300
+ });
301
+ } else {
302
+ // Suffix base mismatch — this mirror lost a frame; resync fully.
303
+ requestSync(socket);
304
+ }
305
+ } else if ('tail' in frame) {
306
+ if (frame.tail) viewerApply.updateStreamingTail(frame.tail.id, frame.tail);
307
+ else viewerApply.clearStreamingTail();
308
+ }
309
+ if ('spinner' in frame) viewerApply.set({ spinner: frame.spinner || null });
310
+ };
311
+
312
+ const stopClient = () => {
313
+ const closing = client;
314
+ const closingId = clientId;
315
+ client = null;
316
+ clientId = '';
317
+ clientUp = false;
318
+ clientSyncedId = '';
319
+ if (closingId) settleViewerSync(closingId, false);
320
+ if (closing) {
321
+ try { closing.destroy(); } catch { /* already gone */ }
322
+ }
323
+ };
324
+
325
+ const startClient = (id) => {
326
+ let socket;
327
+ try { socket = connect(socketPathFor(id)); } catch { return; }
328
+ client = socket;
329
+ clientId = id;
330
+ clientUp = false;
331
+ clientSyncedId = '';
332
+ socket.setNoDelay?.(true);
333
+ socket.on('connect', () => { if (client === socket) clientUp = true; });
334
+ const down = (ownerClosed) => {
335
+ const wasUp = clientUp && client === socket;
336
+ if (client === socket) { client = null; clientId = ''; clientUp = false; }
337
+ try { socket.destroy(); } catch { /* already gone */ }
338
+ // A live link that dropped means the owner ended or crashed: nudge the
339
+ // promotion path instead of waiting for the next store-mtime change.
340
+ if (wasUp) onOwnerClosed?.(id, ownerClosed);
341
+ };
342
+ socket.on('error', () => down(false));
343
+ socket.on('close', () => down(false));
344
+ attachLineReader(socket, (frame) => {
345
+ if (client !== socket) return;
346
+ if (frame.t === 'close') { down(true); return; }
347
+ if (viewerSessionId() !== id) return;
348
+ try {
349
+ applyViewerFrame(frame, socket);
350
+ // A connected socket is not an entry boundary: the viewer must wait
351
+ // until the owner's atomic full frame has replaced the stale disk
352
+ // restore. Desktop resume holds its renderer publication on this
353
+ // barrier, preventing "last user message first, whole turn later".
354
+ if (frame.t === 'full') {
355
+ clientSyncedId = id;
356
+ settleViewerSync(id, true);
357
+ }
358
+ } catch { requestSync(socket); }
359
+ }, () => down(false));
360
+ };
361
+
362
+ return {
363
+ // Reconciles both legs against the current session role; called from the
364
+ // engine share tick (also serves as the reconnect/retry cadence).
365
+ ensure() {
366
+ const ownerId = String(ownerSessionId() || '');
367
+ const attachId = ownerId ? '' : String(viewerSessionId() || '');
368
+ if (serverId && serverId !== ownerId) stopServer();
369
+ if (ownerId && !server) startServer(ownerId);
370
+ if (clientId && clientId !== attachId) stopClient();
371
+ if (attachId && !client) startClient(attachId);
372
+ },
373
+ viewerConnected: () => clientUp,
374
+ waitForViewerSync,
375
+ sendSubmit(text) {
376
+ if (!clientUp || !client) return false;
377
+ try {
378
+ client.write(frameLine({ t: 'submit', text: String(text) }));
379
+ return true;
380
+ } catch {
381
+ return false;
382
+ }
383
+ },
384
+ dispose() {
385
+ listeners.delete(onPublish);
386
+ stopServer();
387
+ stopClient();
388
+ },
389
+ };
390
+ }
@@ -81,6 +81,7 @@ export function promptContentText(content) {
81
81
  if (typeof part === 'string') return part;
82
82
  if (part?.type === 'text') return part.text || '';
83
83
  if (part?.type === 'image') return '[Image]';
84
+ if (part?.type === 'file') return '[File]';
84
85
  return part?.text || '';
85
86
  }).filter(Boolean).join('\n');
86
87
  }
@@ -150,6 +151,28 @@ export function mergePastedImages(entries) {
150
151
  return Object.keys(out).length > 0 ? out : null;
151
152
  }
152
153
 
154
+ // Byte-free image metadata for the user transcript item. The prompt content
155
+ // keeps the full base64 payload for the provider send; transcript items (and
156
+ // therefore desktop snapshots published on every update) carry only
157
+ // name/mime/size so previews can render without bloating state.
158
+ export function promptContentImageMeta(content, pastedImages) {
159
+ const parts = Array.isArray(content) ? content.filter((part) => part?.type === 'image') : [];
160
+ if (parts.length === 0) return null;
161
+ const named = pastedImages && typeof pastedImages === 'object' ? Object.values(pastedImages) : [];
162
+ return parts.map((part, index) => {
163
+ const meta = named[index] && typeof named[index] === 'object' ? named[index] : null;
164
+ const data = typeof part.data === 'string'
165
+ ? part.data
166
+ : (typeof part.content === 'string' ? part.content : '');
167
+ return {
168
+ id: meta?.id ?? null,
169
+ name: String(meta?.filename || `Image ${index + 1}`),
170
+ mimeType: String(part.mimeType || part.mediaType || meta?.mediaType || 'image/png'),
171
+ bytes: data.length,
172
+ };
173
+ });
174
+ }
175
+
153
176
  export function mergePastedTexts(entries) {
154
177
  const out = {};
155
178
  for (const entry of entries || []) {