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
@@ -7,10 +7,313 @@ import { toolResultText } from './tool-result-text.mjs';
7
7
  import { parseSyntheticAgentMessage } from './agent-envelope.mjs';
8
8
  import { flushTuiSteeringPersist } from './tui-steering-persist.mjs';
9
9
  import { getVoiceStatus, toggleVoice } from '../lib/voice-setup.mjs';
10
+ import { aggregateToolCategoryEntry, aggregateDoneCategories, classifyToolCategory, formatAggregateDetail, summarizeToolResult } from '../../runtime/shared/tool-surface.mjs';
11
+ import { aggregateBucketForCategory, aggregateRawResult, failureDetailText, shellCommandExitCode } from './tool-result-status.mjs';
12
+
13
+ export function restoredTranscriptMetadata(message) {
14
+ const value = message?.meta?.transcript;
15
+ if (!value || typeof value !== 'object') return {};
16
+ const completionValue = value.completion && typeof value.completion === 'object'
17
+ ? value.completion
18
+ : null;
19
+ const completionStatus = typeof completionValue?.status === 'string'
20
+ ? completionValue.status
21
+ : '';
22
+ const completionElapsedMs = Number(completionValue?.elapsedMs);
23
+ const completion = completionValue && completionStatus && Number.isFinite(completionElapsedMs)
24
+ ? {
25
+ status: completionStatus,
26
+ elapsedMs: Math.max(0, completionElapsedMs),
27
+ ...(typeof completionValue.verb === 'string' && completionValue.verb
28
+ ? { verb: completionValue.verb }
29
+ : {}),
30
+ }
31
+ : null;
32
+ return {
33
+ ...(Number.isFinite(Number(value.at)) ? { at: Number(value.at) } : {}),
34
+ ...(typeof value.model === 'string' && value.model ? { model: value.model } : {}),
35
+ ...(typeof value.provider === 'string' && value.provider ? { provider: value.provider } : {}),
36
+ ...(typeof value.agent === 'string' && value.agent ? { agent: value.agent } : {}),
37
+ ...(completion ? { completion } : {}),
38
+ };
39
+ }
40
+
41
+ export function restoredAssistantTranscriptItems(message, nextId) {
42
+ const text = (typeof message?.content === 'string' ? message.content : toolResultText(message?.content)).trim();
43
+ if (!text) return [];
44
+ const { completion, ...metadata } = restoredTranscriptMetadata(message);
45
+ const items = [{ kind: 'assistant', id: nextId(), text, ...metadata }];
46
+ if (completion) {
47
+ items.push({
48
+ kind: 'turndone',
49
+ id: nextId(),
50
+ ...completion,
51
+ ...(metadata.at ? { at: metadata.at } : {}),
52
+ });
53
+ }
54
+ return items;
55
+ }
56
+
57
+ // Restored tool cards: stored assistant messages keep their (compacted)
58
+ // tool_calls and the follow-up role:'tool' results, but resume used to drop
59
+ // both — a reopened session lost every tool marker (user bug). Rebuild one
60
+ // transcript tool item per call and attach its result by tool_call_id.
61
+ export function restoredToolCallItems(message, nextId, pendingByCallId) {
62
+ const calls = Array.isArray(message?.tool_calls) ? message.tool_calls
63
+ : Array.isArray(message?.toolCalls) ? message.toolCalls : [];
64
+ const at = Number(message?.meta?.transcript?.at);
65
+ const items = [];
66
+ for (const call of calls) {
67
+ const name = String(call?.function?.name || call?.name || 'tool').trim() || 'tool';
68
+ let args = call?.function?.arguments ?? call?.arguments;
69
+ if (typeof args === 'string') {
70
+ try { args = JSON.parse(args); } catch { /* keep the raw string args */ }
71
+ }
72
+ const item = {
73
+ kind: 'tool',
74
+ id: nextId(),
75
+ name,
76
+ ...(args !== undefined && args !== '' ? { args } : {}),
77
+ expanded: false,
78
+ count: 1,
79
+ completedCount: 1,
80
+ ...(Number.isFinite(at) ? { at, startedAt: at, completedAt: at } : {}),
81
+ };
82
+ const callId = typeof call?.id === 'string' ? call.id : '';
83
+ if (callId) pendingByCallId.set(callId, item);
84
+ items.push(item);
85
+ }
86
+ return items;
87
+ }
88
+
89
+ export function attachRestoredToolResult(message, pendingByCallId) {
90
+ const callId = typeof message?.tool_call_id === 'string' && message.tool_call_id
91
+ ? message.tool_call_id
92
+ : typeof message?.toolCallId === 'string' ? message.toolCallId : '';
93
+ const target = callId ? pendingByCallId.get(callId) : null;
94
+ if (!target) return;
95
+ pendingByCallId.delete(callId);
96
+ const text = (typeof message?.content === 'string' ? message.content : toolResultText(message?.content)) || '';
97
+ target.result = text;
98
+ if (/^\s*(?:error|\[error|failed\b)/i.test(text)) target.isError = true;
99
+ }
100
+
101
+ // Collapse a consecutive run (≥2) of restored per-call tool items into ONE
102
+ // done aggregate item shaped exactly like the live turn's '__aggregate__'
103
+ // card (turn.mjs completeAggregateVisual): merged category header counts,
104
+ // per-call result summaries as the collapsed detail, raw bodies preserved
105
+ // for expansion, failures/exits surfaced as 'N Ok · N Failed'/'Exit N'.
106
+ function buildRestoredAggregateItem(members) {
107
+ const categories = new Map();
108
+ const categoryOrder = [];
109
+ const calls = [];
110
+ for (const { item, category } of members) {
111
+ const entry = aggregateToolCategoryEntry(item.name, item.args, category);
112
+ if (!categories.has(entry.key)) categoryOrder.push(entry.key);
113
+ const prev = categories.get(entry.key);
114
+ categories.set(entry.key, { ...entry, count: Number(prev?.count || 0) + Number(entry.count || 1) });
115
+ const resultText = String(item.result ?? '');
116
+ // Mirror live outcome semantics: a plain non-zero shell exit is the
117
+ // neutral 'Exit N' state, not a red failure; only real call errors count.
118
+ const exitCode = shellCommandExitCode(resultText);
119
+ const isExitError = exitCode != null;
120
+ const isCallError = !isExitError && item.isError === true;
121
+ calls.push({
122
+ name: item.name,
123
+ args: item.args,
124
+ category,
125
+ isError: isCallError,
126
+ isCallError,
127
+ isExitError,
128
+ exitCode,
129
+ resultText,
130
+ resolved: true,
131
+ summary: !isCallError && resultText.trim()
132
+ ? summarizeToolResult(item.name, item.args, resultText, false)
133
+ : null,
134
+ });
135
+ }
136
+ const errors = calls.filter((r) => r.isError).length;
137
+ const callErrors = calls.filter((r) => r.isCallError).length;
138
+ const exitErrors = calls.filter((r) => r.isExitError).length;
139
+ const succeeded = Math.max(0, calls.length - errors - exitErrors);
140
+ const displayDetail = errors > 0 || exitErrors > 0
141
+ ? failureDetailText({ succeeded, realErrors: callErrors, exitErrors, exitCode: calls.find((r) => r.isExitError)?.exitCode })
142
+ : formatAggregateDetail(calls.filter((r) => r.summary).map((r) => r.summary));
143
+ const rawResult = aggregateRawResult(calls);
144
+ const first = members[0].item;
145
+ const last = members[members.length - 1].item;
146
+ return {
147
+ kind: 'tool',
148
+ id: first.id,
149
+ name: '__aggregate__',
150
+ args: { categoryOrder },
151
+ aggregate: true,
152
+ categories: Object.fromEntries(categories),
153
+ doneCategories: aggregateDoneCategories(calls),
154
+ count: calls.length,
155
+ completedCount: calls.length,
156
+ isError: errors > 0,
157
+ errorCount: errors,
158
+ callErrorCount: callErrors,
159
+ exitErrorCount: exitErrors,
160
+ result: displayDetail,
161
+ text: displayDetail,
162
+ rawResult: rawResult || null,
163
+ expanded: false,
164
+ headerFinalized: true,
165
+ ...(first.at != null ? { at: first.at } : {}),
166
+ ...(first.startedAt != null ? { startedAt: first.startedAt } : {}),
167
+ ...(last.completedAt != null ? { completedAt: last.completedAt } : {}),
168
+ };
169
+ }
170
+
171
+ // Restored transcripts must mirror the live turn's category merging: the live
172
+ // engine (turn.mjs) collapses consecutive same-bucket tool calls into one
173
+ // aggregate card, but resume rebuilt one card per call, so a reopened session
174
+ // un-merged every run (user bug). Walk the restored items and merge adjacent
175
+ // tool cards whose aggregateBucketForCategory matches; any non-tool item
176
+ // (user/assistant/turndone) is a block boundary, same as the live seal rule.
177
+ // Runs of 1 keep the plain per-call card (its argument summary stays visible).
178
+ // Agent cards never merge on restore: the live rule scopes Agent grouping to
179
+ // a single provider batch, a boundary the stored history no longer carries.
180
+ export function mergeRestoredToolItems(items) {
181
+ const merged = [];
182
+ let run = null; // { bucket, members: [{ item, category }] }
183
+ const flushRun = () => {
184
+ if (!run) return;
185
+ if (run.members.length >= 2) merged.push(buildRestoredAggregateItem(run.members));
186
+ else for (const member of run.members) merged.push(member.item);
187
+ run = null;
188
+ };
189
+ for (const item of items || []) {
190
+ const mergeable = item?.kind === 'tool' && item.aggregate !== true;
191
+ if (!mergeable) { flushRun(); merged.push(item); continue; }
192
+ const category = classifyToolCategory(item.name, item.args);
193
+ const bucket = category === 'Agent' ? '' : aggregateBucketForCategory(category);
194
+ if (!bucket) { flushRun(); merged.push(item); continue; }
195
+ if (run && run.bucket === bucket) { run.members.push({ item, category }); continue; }
196
+ flushRun();
197
+ run = { bucket, members: [{ item, category }] };
198
+ }
199
+ flushRun();
200
+ return merged;
201
+ }
202
+
203
+ function restoredMessageItemUpperBound(message) {
204
+ if (message?.role === 'user') return 1;
205
+ if (message?.role !== 'assistant') return 0;
206
+ const calls = Array.isArray(message?.tool_calls) ? message.tool_calls
207
+ : Array.isArray(message?.toolCalls) ? message.toolCalls : [];
208
+ const content = message?.content;
209
+ const hasContent = typeof content === 'string'
210
+ ? content.length > 0
211
+ : Array.isArray(content) && content.length > 0;
212
+ const hasCompletion = Boolean(message?.meta?.transcript?.completion);
213
+ return Number(hasContent) + Number(hasCompletion) + calls.length;
214
+ }
215
+
216
+ function restoredUserTranscriptItems(message, nextId) {
217
+ // Injected model-context payloads are model-visible but never user-authored:
218
+ // skill bodies (meta:'skill'), hook/system reminders, and tag-wrapped context
219
+ // blocks must not restore as user bubbles in any client.
220
+ if (message?.meta === 'skill' || message?.meta === 'hook') return [];
221
+ const text = (typeof message?.content === 'string'
222
+ ? message.content
223
+ : toolResultText(message?.content)).trim();
224
+ if (/^<(?:system-reminder|skill|memory-context|mcp-instructions|available-deferred-tools|event)\b/i.test(text)) {
225
+ return [];
226
+ }
227
+ if (!text) return [];
228
+ const synthetic = parseSyntheticAgentMessage(text);
229
+ if (!synthetic) {
230
+ return [{ kind: 'user', id: nextId(), text, ...restoredTranscriptMetadata(message) }];
231
+ }
232
+ const label = synthetic.label || 'notification';
233
+ const syntheticAt = Number(message?.meta?.transcript?.at);
234
+ return [{
235
+ kind: 'tool',
236
+ id: nextId(),
237
+ name: synthetic.name || 'agent',
238
+ args: synthetic.args || {
239
+ type: label,
240
+ task_id: synthetic.taskId || undefined,
241
+ description: synthetic.summary || 'agent notification',
242
+ },
243
+ result: synthetic.result,
244
+ rawResult: synthetic.rawResult ?? text,
245
+ isError: synthetic.isError ?? /^(failed|error|killed|cancelled)$/i.test(label),
246
+ expanded: false,
247
+ count: 1,
248
+ completedCount: 1,
249
+ ...(Number.isFinite(syntheticAt)
250
+ ? { at: syntheticAt, startedAt: syntheticAt, completedAt: syntheticAt }
251
+ : {}),
252
+ }];
253
+ }
254
+
255
+ function restoreTranscriptRange(messages, start, sessionId) {
256
+ const items = [];
257
+ const pendingToolCalls = new Map();
258
+ for (let index = start; index < messages.length; index += 1) {
259
+ const message = messages[index];
260
+ let part = 0;
261
+ // Message-position ids remain stable when newer messages are appended and
262
+ // let a tail-only restore skip the prefix without first counting every old
263
+ // projected item.
264
+ const restoredId = () => `hist_${sessionId}_${index}_${++part}`;
265
+ if (message?.role === 'user') {
266
+ items.push(...restoredUserTranscriptItems(message, restoredId));
267
+ } else if (message?.role === 'assistant') {
268
+ items.push(...restoredAssistantTranscriptItems(message, restoredId));
269
+ items.push(...restoredToolCallItems(message, restoredId, pendingToolCalls));
270
+ } else if (message?.role === 'tool') {
271
+ attachRestoredToolResult(message, pendingToolCalls);
272
+ }
273
+ }
274
+ return mergeRestoredToolItems(items);
275
+ }
276
+
277
+ export function restoreTranscriptItems(messages, {
278
+ sessionId = 'session',
279
+ itemLimit = Number.POSITIVE_INFINITY,
280
+ } = {}) {
281
+ const source = Array.isArray(messages) ? messages : [];
282
+ const numericLimit = Number(itemLimit);
283
+ const limited = Number.isFinite(numericLimit) && numericLimit > 0;
284
+ if (!limited) return restoreTranscriptRange(source, 0, sessionId);
285
+
286
+ const limit = Math.max(1, Math.floor(numericLimit));
287
+ // Restore beyond the visible cap so a boundary tool run can merge exactly
288
+ // as it did in the full transcript. Selection is incremental from the tail:
289
+ // large cold sessions never read or project their old message bodies.
290
+ const target = limit + Math.min(128, limit);
291
+ let start = source.length;
292
+ let upperBound = 0;
293
+ while (start > 0 && upperBound < target) {
294
+ start -= 1;
295
+ upperBound += restoredMessageItemUpperBound(source[start]);
296
+ }
297
+
298
+ let restored = restoreTranscriptRange(source, start, sessionId);
299
+ // Hidden/context messages and aggregate tool runs can make the cheap upper
300
+ // bound optimistic. Expand backward exponentially only when necessary.
301
+ let expansionTarget = Math.max(64, limit - restored.length);
302
+ while (start > 0 && restored.length < limit) {
303
+ let expansion = 0;
304
+ while (start > 0 && expansion < expansionTarget) {
305
+ start -= 1;
306
+ expansion += restoredMessageItemUpperBound(source[start]);
307
+ }
308
+ restored = restoreTranscriptRange(source, start, sessionId);
309
+ expansionTarget *= 2;
310
+ }
311
+ return restored.length > limit ? restored.slice(-limit) : restored;
312
+ }
10
313
 
11
314
  export function createEngineApiB(bag) {
12
315
  const {
13
- runtime, nextId, flags, lifecycle, listeners, getState, set, disposeEmit, replaceItems, pushNotice, removeNotice, setProgressHint, clearToastTimers, disposeTranscriptSpill, routeState, syncContextStats, finishToolApproval, denyAllToolApprovals, restoreLeadSteeringFromDisk, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, commitTuiSessionReset, resetStatsAndSyncContext,
316
+ runtime, nextId, flags, lifecycle, listeners, getState, set, flushEmitImmediate, disposeEmit, replaceItems, pushNotice, removeNotice, setProgressHint, clearToastTimers, disposeTranscriptSpill, routeState, syncContextStats, finishToolApproval, denyAllToolApprovals, restoreLeadSteeringFromDisk, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, commitTuiSessionReset, resetStatsAndSyncContext,
14
317
  } = bag;
15
318
  return {
16
319
  resolveToolApproval: (id, decision = {}) => {
@@ -23,6 +326,9 @@ export function createEngineApiB(bag) {
23
326
  listProviderModels: (options = {}) => {
24
327
  return runtime.listProviderModels(options);
25
328
  },
329
+ prefetchSession: (id) => {
330
+ return runtime.prefetchSession?.(id) === true;
331
+ },
26
332
  getSearchRoute: () => {
27
333
  return runtime.getSearchRoute?.() || runtime.searchRoute || null;
28
334
  },
@@ -120,6 +426,60 @@ export function createEngineApiB(bag) {
120
426
  },
121
427
  isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
122
428
  getVoiceStatus: () => getVoiceStatus(),
429
+ // Desktop push-to-talk dictation: accept a recorded audio payload
430
+ // (base64), stage it as a temp file, and run it through the SAME managed
431
+ // whisper.cpp pipeline the channels use (ffmpeg convert -> whisper server,
432
+ // model selected per voice.model/system language). Returns the transcript
433
+ // text or throws a user-actionable error (e.g. runtime not installed).
434
+ transcribeAudio: async ({ data, mimeType = 'audio/webm' } = {}) => {
435
+ const base64 = String(data || '');
436
+ if (!base64) throw new Error('transcribeAudio: audio payload is required');
437
+ if (base64.length > 40_000_000) throw new Error('transcribeAudio: recording too large');
438
+ const [{ createVoiceTranscription }, { resolvePluginData }, { readSection }, os, path, fsp, crypto] = await Promise.all([
439
+ import('../../runtime/channels/lib/voice-transcription.mjs'),
440
+ import('../../runtime/shared/plugin-paths.mjs'),
441
+ import('../../runtime/shared/config.mjs'),
442
+ import('node:os'),
443
+ import('node:path'),
444
+ import('node:fs/promises'),
445
+ import('node:crypto'),
446
+ ]);
447
+ const extension = /ogg/i.test(mimeType) ? 'ogg' : /wav/i.test(mimeType) ? 'wav' : /mp4|m4a/i.test(mimeType) ? 'm4a' : 'webm';
448
+ const audioPath = path.join(os.tmpdir(), `mixdog-dictation-${process.pid}-${Date.now()}.${extension}`);
449
+ await fsp.writeFile(audioPath, Buffer.from(base64, 'base64'));
450
+ try {
451
+ const { transcribeVoice } = createVoiceTranscription({
452
+ getConfig: () => ({ voice: readSection('voice') || {} }),
453
+ dataDir: resolvePluginData(),
454
+ });
455
+ const text = await transcribeVoice(audioPath, {
456
+ attachmentId: `dictation-${crypto.randomUUID()}`,
457
+ });
458
+ return typeof text === 'string' ? text : '';
459
+ } finally {
460
+ fsp.rm(audioPath, { force: true }).catch(() => undefined);
461
+ }
462
+ },
463
+ // Desktop composer image attach: run the SAME optional-sharp resize
464
+ // pipeline the TUI paste path uses (<=2000x2000, 5MB-base64 token budget,
465
+ // identical no-sharp size errors) so both clients send identical image
466
+ // payloads to the provider.
467
+ resizeImage: async ({ data, mimeType = 'image/png', filename = '' } = {}) => {
468
+ const base64 = String(data || '');
469
+ if (!base64) throw new Error('resizeImage: image payload is required');
470
+ if (base64.length > 40_000_000) throw new Error('resizeImage: image too large');
471
+ const { imageAttachmentFromBuffer } = await import('../paste-attachments.mjs');
472
+ const attachment = await imageAttachmentFromBuffer(
473
+ Buffer.from(base64, 'base64'),
474
+ String(mimeType || 'image/png'),
475
+ { filename: String(filename || 'Pasted image') },
476
+ );
477
+ return {
478
+ data: attachment.content,
479
+ mimeType: attachment.mediaType,
480
+ metadataText: attachment.metadataText || '',
481
+ };
482
+ },
123
483
  toggleVoice: async () => {
124
484
  const result = await toggleVoice({ pushNotice, setProgressHint });
125
485
  return {
@@ -305,6 +665,11 @@ export function createEngineApiB(bag) {
305
665
  pushNotice(`schedule ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
306
666
  return result;
307
667
  },
668
+ runScheduleNow: async (name) => {
669
+ const result = await runtime.runScheduleNow(name);
670
+ pushNotice(`schedule ran: ${name}`, 'info');
671
+ return result;
672
+ },
308
673
  saveWebhook: async (entry) => {
309
674
  const result = await runtime.saveWebhook(entry);
310
675
  pushNotice(`webhook saved: ${result.name}`, 'info');
@@ -377,6 +742,46 @@ export function createEngineApiB(bag) {
377
742
  listSessions: (options) => {
378
743
  return runtime.listSessions(options);
379
744
  },
745
+ deleteSession: async (id) => {
746
+ if (getState().commandBusy) return false;
747
+ const deletingCurrent = String(runtime.session?.id || getState().sessionId || '') === String(id || '');
748
+ set({ commandBusy: true });
749
+ clearToastTimers();
750
+ resetAllStreamingMarkdownStablePrefixes();
751
+ const rollbackSnapshot = deletingCurrent ? snapshotTuiBeforeSessionReset() : null;
752
+ if (deletingCurrent) resetTuiForPendingSessionReset();
753
+ try {
754
+ if (await runtime.deleteSession(id) !== true) {
755
+ if (rollbackSnapshot) restoreTuiAfterFailedSessionReset(rollbackSnapshot);
756
+ return false;
757
+ }
758
+ if (deletingCurrent) {
759
+ clearUiActivityBeforeContextSync();
760
+ flags.pendingSessionReset = false;
761
+ resetStatsAndSyncContext();
762
+ set({
763
+ items: replaceItems([]),
764
+ toasts: [],
765
+ queued: [],
766
+ thinking: null,
767
+ spinner: null,
768
+ lastTurn: null,
769
+ sessionId: null,
770
+ cwd: runtime.cwd,
771
+ ...routeState(),
772
+ stats: { ...getState().stats },
773
+ });
774
+ commitTuiSessionReset(rollbackSnapshot);
775
+ }
776
+ return true;
777
+ } catch (error) {
778
+ if (rollbackSnapshot) restoreTuiAfterFailedSessionReset(rollbackSnapshot);
779
+ throw error;
780
+ } finally {
781
+ flags.pendingSessionReset = false;
782
+ set({ commandBusy: false });
783
+ }
784
+ },
380
785
  switchContext: async (options) => {
381
786
  if (getState().commandBusy) return false;
382
787
  set({ commandBusy: true });
@@ -444,52 +849,32 @@ export function createEngineApiB(bag) {
444
849
  set({ commandBusy: false });
445
850
  }
446
851
  },
447
- resume: async (id) => {
852
+ resume: async (id, options = {}) => {
448
853
  if (getState().commandBusy) return false;
449
- set({ commandBusy: true, commandStatus: { active: true, verb: 'Resuming conversation', startedAt: Date.now(), mode: 'resuming' } });
854
+ // quiet: viewer-follow refreshes (engine share tick) re-resume on every
855
+ // owner turn — they must not flash the "Resuming conversation" status.
856
+ set({
857
+ commandBusy: true,
858
+ ...(options.quiet === true
859
+ ? {}
860
+ : { commandStatus: { active: true, verb: 'Resuming conversation', startedAt: Date.now(), mode: 'resuming' } }),
861
+ });
450
862
  clearToastTimers();
451
863
  try {
452
864
  const r = await runtime.resume(id);
453
865
  if (!r) return false;
454
866
  resetStatsAndSyncContext();
455
- const items = [];
456
- for (const m of r.messages || []) {
457
- if (m.role === 'user') {
458
- // content may be a string OR an array of parts (text/tool-call
459
- // interleaving) — toolResultText coerces both to readable text so
460
- // array-content messages aren't silently dropped.
461
- const text = (typeof m.content === 'string' ? m.content : toolResultText(m.content)).trim();
462
- if (text) {
463
- const synthetic = parseSyntheticAgentMessage(text);
464
- if (synthetic) {
465
- const label = synthetic.label || 'notification';
466
- items.push({
467
- kind: 'tool',
468
- id: nextId(),
469
- name: synthetic.name || 'agent',
470
- args: synthetic.args || {
471
- type: label,
472
- task_id: synthetic.taskId || undefined,
473
- description: synthetic.summary || 'agent notification',
474
- },
475
- result: synthetic.result,
476
- rawResult: synthetic.rawResult ?? text,
477
- isError: synthetic.isError ?? /^(failed|error|killed|cancelled)$/i.test(label),
478
- expanded: false,
479
- count: 1,
480
- completedCount: 1,
481
- startedAt: Date.now(),
482
- completedAt: Date.now(),
483
- });
484
- } else {
485
- items.push({ kind: 'user', id: nextId(), text });
486
- }
487
- }
488
- } else if (m.role === 'assistant') {
489
- const text = (typeof m.content === 'string' ? m.content : toolResultText(m.content)).trim();
490
- if (text) items.push({ kind: 'assistant', id: nextId(), text });
491
- }
867
+ const requestedLimit = Number(options.transcriptItemLimit);
868
+ if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
869
+ flags.resumeTranscriptItemLimit = Math.max(1, Math.floor(requestedLimit));
492
870
  }
871
+ const itemLimit = Number(flags.resumeTranscriptItemLimit);
872
+ const items = restoreTranscriptItems(r.messages, {
873
+ sessionId: String(r.id || id),
874
+ itemLimit: Number.isFinite(itemLimit) && itemLimit > 0
875
+ ? itemLimit
876
+ : Number.POSITIVE_INFINITY,
877
+ });
493
878
  set({
494
879
  items: replaceItems(items),
495
880
  toasts: [],
@@ -500,10 +885,20 @@ export function createEngineApiB(bag) {
500
885
  ...routeState(),
501
886
  stats: { ...getState().stats },
502
887
  });
888
+ // Reconcile the live-share legs NOW (viewer pipe attach / owner pipe
889
+ // start). The 3s share tick otherwise leaves a live-owned session on
890
+ // the stale disk snapshot and then full-swaps it seconds after entry —
891
+ // the visible transcript lurch. Connecting here makes the owner's
892
+ // full frame land at the resume boundary, so entry paints settled.
893
+ bag.ensureLiveShare?.();
503
894
  void restoreLeadSteeringFromDisk();
504
895
  return true;
505
896
  } finally {
506
897
  set({ commandBusy: false, commandStatus: null });
898
+ // Desktop resume returns a snapshot immediately after this promise.
899
+ // Publish the completed route/transcript boundary now so callers never
900
+ // observe the previous frame's session id and title.
901
+ flushEmitImmediate();
507
902
  }
508
903
  },
509
904
 
@@ -511,6 +906,10 @@ export function createEngineApiB(bag) {
511
906
  if (flags.disposed) return;
512
907
  disposeEmit?.();
513
908
  flags.disposed = true;
909
+ // Release the interactive-presence beacon so a cross-open after this
910
+ // surface exits takes normal ownership instead of viewer-attaching to a
911
+ // dead owner (crash paths fall back to the 2min staleness window).
912
+ try { runtime.clearSessionPresence?.(); } catch { /* best-effort */ }
514
913
  clearToastTimers();
515
914
  disposeTranscriptSpill?.();
516
915
  try { clearInterval(lifecycle.runtimePulseTimer); } catch {}
@@ -206,7 +206,13 @@ export function createEngineApiA(bag) {
206
206
  set({ commandBusy: false });
207
207
  }
208
208
  },
209
- agentControl: async (args = {}) => {
209
+ agentControl: async (args = {}, options = {}) => {
210
+ // Silent reads (desktop dock agent viewer) go straight to the runtime:
211
+ // no transcript card, no commandBusy — mirrors memoryControl's silent
212
+ // read path.
213
+ if (options.silent === true) {
214
+ return runtime.agentControl(args);
215
+ }
210
216
  if (getState().commandBusy) return null;
211
217
  set({ commandBusy: true });
212
218
  try {
@@ -4,7 +4,7 @@
4
4
  import { presentErrorText } from '../../runtime/shared/err-text.mjs';
5
5
  import { resetAllStreamingMarkdownStablePrefixes } from '../markdown/streaming-markdown.mjs';
6
6
  import { createSessionStats } from './session-stats.mjs';
7
- import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
7
+ import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, promptContentImageMeta, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
8
8
  import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersist } from './tui-steering-persist.mjs';
9
9
 
10
10
  export function createSessionFlow(bag) {
@@ -49,6 +49,7 @@ export function createSessionFlow(bag) {
49
49
  content: text,
50
50
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
51
51
  pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
52
+ images: promptContentImageMeta(text, options.pastedImages),
52
53
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
53
54
  mode,
54
55
  priority,
@@ -200,7 +201,12 @@ export function createSessionFlow(bag) {
200
201
  // at delivery time; the queued twin is model-visible only and must
201
202
  // NOT render a second transcript card here (no fall-back to content).
202
203
  if (entry.suppressDisplay) continue;
203
- pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
204
+ pushUserOrSyntheticItem(
205
+ entry.text,
206
+ entry.id,
207
+ isQueuedEntryEditable(entry) ? 'user' : 'injected',
208
+ Array.isArray(entry.images) && entry.images.length ? { images: entry.images } : null,
209
+ );
204
210
  }
205
211
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
206
212
  // A completion resume is owned by the completion that woke it. Esc
@@ -357,15 +363,22 @@ export function createSessionFlow(bag) {
357
363
  const minContextPercent = Number(cfg.minContextPercent ?? 10);
358
364
  if (minContextPercent > 0) {
359
365
  const status = runtime.contextStatus?.() || null;
360
- const estimatedTokens = Math.max(0, Number(status?.currentEstimatedTokens ?? status?.usedTokens ?? 0));
361
- const usedTokens = Math.max(0, Number(status?.usedTokens ?? estimatedTokens ?? 0));
366
+ // A zero is often an unavailable/stale meter field, not an authoritative
367
+ // measurement. Do not let it mask the sibling live estimate.
368
+ const usedTokens = Math.max(
369
+ 0,
370
+ Number(status?.usedTokens) || 0,
371
+ Number(status?.currentEstimatedTokens) || 0,
372
+ Number(status?.compaction?.currentEstimatedTokens) || 0,
373
+ );
362
374
  const triggerTokens = Number(
363
375
  status?.compaction?.triggerTokens
364
376
  || status?.compaction?.autoCompactTokenLimit
365
377
  || runtime.session?.autoCompactTokenLimit
366
378
  || 0,
367
379
  );
368
- if (!(usedTokens > 0 && triggerTokens > 0)) {
380
+ if (!Number.isFinite(usedTokens) || !Number.isFinite(triggerTokens)
381
+ || !(usedTokens > 0 && triggerTokens > 0)) {
369
382
  if (!activityAt) flags.lastUserActivityAt = now;
370
383
  return false;
371
384
  }
@@ -481,11 +494,12 @@ export function createSessionFlow(bag) {
481
494
  }
482
495
  }
483
496
 
484
- function restoreQueued(currentText = '') {
497
+ function restoreQueued(currentText = '', selectedId = '') {
498
+ const targetId = String(selectedId || '').trim();
485
499
  const queued = [];
486
500
  for (let i = 0; i < pending.length;) {
487
501
  const entry = pending[i];
488
- if (isQueuedEntryEditable(entry)) {
502
+ if (isQueuedEntryEditable(entry) && (!targetId || String(entry.id) === targetId)) {
489
503
  queued.push(entry);
490
504
  pending.splice(i, 1);
491
505
  } else {
@@ -159,6 +159,7 @@ export function createToolCardResults({
159
159
  count: group.count,
160
160
  completedCount: group.completed,
161
161
  completedAt: Date.now(),
162
+ liveOutput: null,
162
163
  };
163
164
  if (group.count <= 1) {
164
165
  const body = String(text || rawText || '').trim();
@@ -292,7 +293,8 @@ export function createToolCardResults({
292
293
  const currentItem = itemById(card.itemId);
293
294
  resultText = withCancelledResultMarker(resultText, currentItem);
294
295
  }
295
- patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, exitErrorCount: group.exitErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
296
+ // liveOutput: null the settled result supersedes any streamed tail.
297
+ patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, exitErrorCount: group.exitErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now(), liveOutput: null });
296
298
  card.done = true;
297
299
  if (card.callId) done.add(card.callId);
298
300
  }