mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -10,7 +10,6 @@ import { SPINNER_VERBS } from './spinner-verbs.mjs';
10
10
  import {
11
11
  aggregateToolCategoryEntry,
12
12
  classifyToolCategory,
13
- formatAggregateDetail,
14
13
  summarizeToolResult,
15
14
  } from '../runtime/shared/tool-surface.mjs';
16
15
  import { isBackgroundErrorOnlyBody, presentErrorText } from '../runtime/shared/err-text.mjs';
@@ -19,6 +18,7 @@ import {
19
18
  modelVisibleToolCompletionMessage,
20
19
  } from '../runtime/shared/tool-execution-contract.mjs';
21
20
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
21
+ import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
22
22
 
23
23
  const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
24
24
  const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
@@ -289,6 +289,10 @@ function toolResultText(content) {
289
289
 
290
290
  const TOOL_RESULT_PART_MAX_DEPTH = 12;
291
291
  const TOOL_RESULT_JSON_FALLBACK_MAX = 480;
292
+ // Absolute cap for a collapsed tool detail line (the second row under the ⎿
293
+ // gutter). Terminal-width independent so a wide terminal never lets a long line
294
+ // stretch the row; lockstep with ToolExecution RESULT_LINE_HARD_MAX (80).
295
+ const TOOL_DETAIL_LINE_MAX = 80;
292
296
 
293
297
  function compactToolResultObjectFallback(obj) {
294
298
  if (obj?.type === 'tool_result') return '';
@@ -357,7 +361,7 @@ function toolAggregateDetailFallback(detailText, rawResult) {
357
361
  if (!raw) return detailText;
358
362
  const line = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
359
363
  if (!line) return detailText;
360
- return line.length > 160 ? `${line.slice(0, 157)}…` : line;
364
+ return line.length > TOOL_DETAIL_LINE_MAX ? `${line.slice(0, TOOL_DETAIL_LINE_MAX - 3)}…` : line;
361
365
  }
362
366
 
363
367
  function toolGroupedDisplayFallback(resultText, text, rawText) {
@@ -456,7 +460,7 @@ function parseAgentResultEnvelope(text, fallback = {}) {
456
460
  attrs[match[1].toLowerCase()] = String(match[2] || '').replace(/^["']|["']$/g, '');
457
461
  }
458
462
  const providerModel = /\s([a-zA-Z0-9_.-]+)\/([^\s]+)\s*$/i.exec(head);
459
- const role = attrs.agent || attrs.role || fallback.role || fallback.agent || '';
463
+ const agent = attrs.agent || fallback.agent || '';
460
464
  return {
461
465
  name: 'agent',
462
466
  label: String(fallback.status || attrs.status || 'completed').toLowerCase(),
@@ -465,8 +469,7 @@ function parseAgentResultEnvelope(text, fallback = {}) {
465
469
  status: fallback.status || attrs.status || 'completed',
466
470
  task_id: fallback.taskId || attrs.task_id || attrs.taskid || undefined,
467
471
  tag: fallback.tag || attrs.tag || undefined,
468
- agent: role || undefined,
469
- role: role || undefined,
472
+ agent: agent || undefined,
470
473
  provider: fallback.provider || attrs.provider || providerModel?.[1] || undefined,
471
474
  model: fallback.model || attrs.model || providerModel?.[2] || undefined,
472
475
  preset: fallback.preset || attrs.preset || undefined,
@@ -502,7 +505,6 @@ export function parseBackgroundTaskEnvelope(text) {
502
505
  status,
503
506
  taskId,
504
507
  tag: fields.tag || fields.label || '',
505
- role: fields.role || fields.agent || '',
506
508
  agent: fields.agent || '',
507
509
  provider: fields.provider || '',
508
510
  model: fields.model || '',
@@ -524,8 +526,7 @@ export function parseBackgroundTaskEnvelope(text) {
524
526
  operation: fields.operation || undefined,
525
527
  label: fields.label || undefined,
526
528
  tag: fields.tag || undefined,
527
- agent: fields.agent || fields.role || undefined,
528
- role: fields.role || fields.agent || undefined,
529
+ agent: fields.agent || undefined,
529
530
  provider: fields.provider || undefined,
530
531
  model: fields.model || undefined,
531
532
  preset: fields.preset || undefined,
@@ -657,7 +658,16 @@ function parseToolArgs(args) {
657
658
  return typeof args === 'object' ? args : {};
658
659
  }
659
660
 
660
- const yieldToRenderer = () => new Promise((resolve) => setImmediate(resolve));
661
+ // Ink renders through a maxFps throttle (120fps in index.jsx, ≈8.3ms). A plain
662
+ // setImmediate only yields to the event loop; if Ink already painted within the
663
+ // current throttle window, the next paint may still be queued and our following
664
+ // transcript mutation can coalesce into the same visible frame. Wait just past
665
+ // one render window when we intentionally split transcript commits for visual
666
+ // stability (preamble frame → tool-card frame).
667
+ const RENDER_THROTTLE_FLUSH_MS = 12;
668
+ const yieldToRenderer = () => new Promise((resolve) => {
669
+ setTimeout(resolve, RENDER_THROTTLE_FLUSH_MS);
670
+ });
661
671
 
662
672
  function parseAgentJob(text) {
663
673
  const value = String(text || '');
@@ -666,7 +676,7 @@ function parseAgentJob(text) {
666
676
  const statusMatch = /^status:\s*([^\s(]+)/m.exec(value);
667
677
  const typeMatch = /^type:\s*(.+)$/m.exec(value);
668
678
  const targetMatch = /^target:\s*(.+)$/m.exec(value);
669
- const roleMatch = /^(?:agent|role):\s*(.+)$/m.exec(value);
679
+ const agentMatch = /^agent:\s*(.+)$/m.exec(value);
670
680
  const presetMatch = /^preset:\s*(.+)$/m.exec(value);
671
681
  const modelMatch = /^model:\s*([^/\s]+)\/(.+)$/m.exec(value);
672
682
  const effortMatch = /^effort:\s*(.+)$/m.exec(value);
@@ -676,7 +686,7 @@ function parseAgentJob(text) {
676
686
  status: (statusMatch?.[1] || '').toLowerCase(),
677
687
  type: (typeMatch?.[1] || '').trim(),
678
688
  target: (targetMatch?.[1] || '').trim(),
679
- role: (roleMatch?.[1] || '').trim(),
689
+ agent: (agentMatch?.[1] || '').trim(),
680
690
  preset: (presetMatch?.[1] || '').trim(),
681
691
  provider: (modelMatch?.[1] || '').trim(),
682
692
  model: (modelMatch?.[2] || '').trim(),
@@ -837,8 +847,8 @@ function notificationQueueKey(event, text, parsed) {
837
847
  : tag
838
848
  ? `tag:${tag}:${shortTextFingerprint(synthetic.result || text)}`
839
849
  : '';
840
- const role = String(synthetic.args?.agent || synthetic.args?.role || '').trim();
841
- if (resultId || role) return ['agent-result', resultId, role].filter(Boolean).join(':');
850
+ const agent = String(synthetic.args?.agent || '').trim();
851
+ if (resultId || agent) return ['agent-result', resultId, agent].filter(Boolean).join(':');
842
852
  }
843
853
  const id = String(meta.execution_id || parsed?.taskId || '').trim();
844
854
  if (!id) return '';
@@ -897,7 +907,7 @@ function agentArgsWithResultMetadata(args, parsed) {
897
907
  }
898
908
  if (parsed.status) next.status = parsed.status;
899
909
  if (parsed.taskId) next.task_id = parsed.taskId;
900
- if (parsed.role) next.role = parsed.role;
910
+ if (parsed.agent) next.agent = parsed.agent;
901
911
  if (parsed.preset) next.preset = parsed.preset;
902
912
  if (parsed.provider) next.provider = parsed.provider;
903
913
  if (parsed.model) next.model = parsed.model;
@@ -914,9 +924,10 @@ export async function createEngineSession({
914
924
  provider: providerName,
915
925
  model,
916
926
  toolMode = 'full',
927
+ remote = false,
917
928
  } = {}) {
918
929
  const startedAt = performance.now();
919
- bootProfile('engine:create:start', { provider: providerName, model, toolMode });
930
+ bootProfile('engine:create:start', { provider: providerName, model, toolMode, remote });
920
931
  // Silence provider/session diagnostics so they cannot tear through the
921
932
  // alternate-screen React/ink render.
922
933
  process.env.MIXDOG_QUIET_PROVIDER_LOG = '1';
@@ -928,7 +939,7 @@ export async function createEngineSession({
928
939
  const importStartedAt = performance.now();
929
940
  const { createMixdogSessionRuntime } = await import(SESSION_RUNTIME_MODULE);
930
941
  bootProfile('session-runtime:imported', { ms: (performance.now() - importStartedAt).toFixed(1) });
931
- const runtime = await createMixdogSessionRuntime({ provider: providerName, model, toolMode });
942
+ const runtime = await createMixdogSessionRuntime({ provider: providerName, model, toolMode, remote });
932
943
  bootProfile('engine:create:runtime-ready', { ms: (performance.now() - startedAt).toFixed(1) });
933
944
  const cwd = runtime.cwd || process.cwd();
934
945
  const stateStartedAt = performance.now();
@@ -965,6 +976,7 @@ export async function createEngineSession({
965
976
  searchRoute: runtime.getSearchRoute?.() || runtime.searchRoute || null,
966
977
  autoClear: autoClearState(),
967
978
  workflow: runtime.workflow || null,
979
+ remoteEnabled: runtime.isRemoteEnabled?.() === true,
968
980
  });
969
981
 
970
982
  const routeState = () => ({
@@ -1121,7 +1133,12 @@ export async function createEngineSession({
1121
1133
  }
1122
1134
  return nextItems;
1123
1135
  };
1136
+ let flushDeferredBeforeImmediatePush = null;
1137
+ let pushingFromDeferredEntry = false;
1124
1138
  const pushItem = (item) => {
1139
+ if (!pushingFromDeferredEntry && flushDeferredBeforeImmediatePush) {
1140
+ flushDeferredBeforeImmediatePush();
1141
+ }
1125
1142
  const index = state.items.length;
1126
1143
  const items = [...state.items, item];
1127
1144
  if (item?.id != null) itemIndexById.set(item.id, index);
@@ -1384,6 +1401,51 @@ export async function createEngineSession({
1384
1401
  });
1385
1402
  }
1386
1403
 
1404
+ const CANCELLED_RESULT_STATUS_LINE = '[status: cancelled]';
1405
+
1406
+ function normalizedResultStatusToken(value) {
1407
+ const raw = String(value || '').trim().toLowerCase();
1408
+ if (!raw) return '';
1409
+ if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
1410
+ if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
1411
+ if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
1412
+ if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
1413
+ return '';
1414
+ }
1415
+
1416
+ function resultTextTerminalStatus(text) {
1417
+ const body = String(text || '');
1418
+ const tagged = body.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
1419
+ if (tagged) return normalizedResultStatusToken(tagged);
1420
+ const bracketed = body.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
1421
+ if (bracketed) return normalizedResultStatusToken(bracketed);
1422
+ const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
1423
+ return normalizedResultStatusToken(inline);
1424
+ }
1425
+
1426
+ function itemHasKnownTerminalStatus(item, texts = []) {
1427
+ const settled = (token) => token === 'completed' || token === 'failed' || token === 'cancelled';
1428
+ if (settled(normalizedResultStatusToken(item?.args?.status))) return true;
1429
+ for (const text of texts) {
1430
+ if (settled(resultTextTerminalStatus(text))) return true;
1431
+ }
1432
+ return false;
1433
+ }
1434
+
1435
+ function withCancelledResultMarker(text, item) {
1436
+ const body = String(text || '');
1437
+ // Do NOT inspect item.rawResult here: aggregate rawResult is child tool
1438
+ // output (`1. grep\n<result>…`) that can incidentally contain a `status:`
1439
+ // line, which would false-positive as an already-terminal status and skip
1440
+ // the cancelled marker. Only result/text/body are engine-controlled
1441
+ // collapsed detail (empty / status word / an existing marker), so they are
1442
+ // the trustworthy terminal-status sources.
1443
+ const sources = [item?.result, item?.text, body];
1444
+ if (itemHasKnownTerminalStatus(item, sources)) return body;
1445
+ if (!body.trim()) return `${CANCELLED_RESULT_STATUS_LINE}\n`;
1446
+ return `${CANCELLED_RESULT_STATUS_LINE}\n${body}`;
1447
+ }
1448
+
1387
1449
  function groupedToolResultText(group) {
1388
1450
  const completed = Math.min(group.count, group.completed);
1389
1451
  if (group.count <= 1) return group.results.at(-1)?.text ?? '';
@@ -1424,7 +1486,7 @@ export async function createEngineSession({
1424
1486
  const chunks = [];
1425
1487
  for (const rec of calls || []) {
1426
1488
  if (rec?.resolved !== true) continue;
1427
- const text = String(rec?.resultText || '').replace(/\s+$/, '');
1489
+ let text = String(rec?.resultText || '').replace(/\s+$/, '');
1428
1490
  if (!text.trim()) continue;
1429
1491
  const label = String(rec?.name || rec?.category || 'tool').trim() || 'tool';
1430
1492
  chunks.push(`${chunks.length + 1}. ${label}\n${text}`);
@@ -1432,37 +1494,15 @@ export async function createEngineSession({
1432
1494
  return chunks.join('\n\n');
1433
1495
  }
1434
1496
 
1435
- function aggregateDisplayDetail(summaries, allCalls) {
1436
- const fromSummaries = formatAggregateDetail(summaries);
1437
- if (String(fromSummaries || '').trim()) return fromSummaries;
1438
- let line = '';
1439
- for (const rec of allCalls || []) {
1440
- const text = String(rec?.resultText || '').replace(/\s+$/, '');
1441
- line = text.split('\n').map((l) => l.trim()).find(Boolean) || '';
1442
- if (line) break;
1443
- }
1444
- if (!line) return '';
1445
- return line.length > 160 ? `${line.slice(0, 157)}…` : line;
1446
- }
1447
-
1448
1497
  function aggregateBucketForCategory(category) {
1449
- switch (category) {
1450
- case 'Read':
1451
- case 'Search':
1452
- return 'local-discovery';
1453
- case 'Web Research':
1454
- return 'web-research';
1455
- case 'Memory':
1456
- return 'memory';
1457
- case 'Explore':
1458
- return 'explore';
1459
- case 'Patch':
1460
- return 'patch';
1461
- default:
1462
- // Shell/Agent/Channel/Setup/Other stay as their own cards so risky or
1463
- // semantically distinct actions do not disappear inside a discovery log.
1464
- return null;
1465
- }
1498
+ // Merge consecutive tool calls of the SAME category into one aggregate card;
1499
+ // a different category opens a fresh card (no cross-category merge). The
1500
+ // bucket key is the category itself, so a run of Search calls collapses into
1501
+ // one Search card while an adjacent Read/Patch stays separate. Falls back to
1502
+ // 'default' when a call has no resolved category. Hook/approval denials keep
1503
+ // their dedicated ToolHookDenialCard path in App.jsx.
1504
+ const key = String(category || '').trim();
1505
+ return key ? `category:${key}` : 'default';
1466
1506
  }
1467
1507
 
1468
1508
  function aggregateSummaries(aggregate) {
@@ -1511,22 +1551,20 @@ export async function createEngineSession({
1511
1551
  const allCalls = [...aggregate.calls.values()];
1512
1552
  const completed = allCalls.filter((r) => r.resolved).length;
1513
1553
  const errors = allCalls.filter((r) => r.isError).length;
1514
- const summaries = aggregateSummaries(aggregate);
1515
- let detailText;
1516
- if (errors > 0 && summaries.length === 0) {
1517
- const succeeded = completed - errors;
1518
- detailText = succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`;
1519
- } else {
1520
- detailText = aggregateDisplayDetail(summaries, allCalls);
1521
- if (errors > 0) {
1522
- detailText = detailText ? `${detailText} · ${errors} Failed` : `${errors} Failed`;
1523
- }
1524
- }
1554
+ // Collapsed detail is status-only (no per-result summary). Failures keep a
1555
+ // bare 'N Failed' status so an error stays visible while collapsed.
1556
+ const succeeded = completed - errors;
1557
+ const detailText = errors > 0
1558
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1559
+ : '';
1525
1560
  const currentItem = state.items.find((it) => it.id === card.itemId);
1526
1561
  const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
1527
1562
  const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
1528
1563
  const rawResult = aggregateRawResult(allCalls);
1529
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1564
+ // Collapsed aggregate detail carries no per-result summary — the card body
1565
+ // shows only a status word ('Finished') or, on failure, 'N Failed'. The
1566
+ // numbered+labelled raw (rawResult) is preserved for ctrl+o expansion.
1567
+ const displayDetail = detailText;
1530
1568
  patchItem(card.itemId, {
1531
1569
  result: displayDetail,
1532
1570
  text: displayDetail,
@@ -1575,7 +1613,7 @@ export async function createEngineSession({
1575
1613
  return true;
1576
1614
  }
1577
1615
 
1578
- const flushToolResults = (messages, toolCards, cardByCallId, toolGroups, done, { finalize = false } = {}) => {
1616
+ const flushToolResults = (messages, toolCards, cardByCallId, toolGroups, done, { finalize = false, cancelled = false } = {}) => {
1579
1617
  const results = [];
1580
1618
  for (const m of messages || []) {
1581
1619
  if (!m || m.role !== 'tool') continue;
@@ -1613,14 +1651,33 @@ export async function createEngineSession({
1613
1651
  const aggregate = card.aggregate;
1614
1652
  if (aggregate && card.itemId === aggregate.itemId) {
1615
1653
  const allCalls = [...aggregate.calls.values()];
1654
+ // Never let a call that truly never resolved be presented as a real
1655
+ // completion. Stamp it resolved with an empty, non-error result so
1656
+ // completedCount reflects an honest (if degenerate) accounting instead
1657
+ // of manufacturing success out of a call that never came back.
1658
+ for (const rec of allCalls) {
1659
+ if (rec.resolved) continue;
1660
+ rec.resolved = true;
1661
+ rec.isError = false;
1662
+ rec.resultText = rec.resultText || '';
1663
+ }
1616
1664
  const completed = allCalls.filter((r) => r.resolved).length;
1617
- const remaining = allCalls.length - completed;
1618
- const totalCompleted = remaining > 0 ? completed + remaining : completed;
1665
+ const totalCompleted = completed;
1619
1666
  const errors = allCalls.filter((r) => r.isError).length;
1620
- const summaries = aggregateSummaries(aggregate);
1621
- const detailText = aggregateDisplayDetail(summaries, allCalls);
1667
+ const succeeded = completed - errors;
1622
1668
  const rawResult = aggregateRawResult(allCalls);
1623
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1669
+ // Collapsed detail is status-only: no per-result summary. Failures keep a
1670
+ // bare 'N Failed' status. The numbered+labelled raw is kept for ctrl+o.
1671
+ let displayDetail = errors > 0
1672
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1673
+ : '';
1674
+ if (cancelled) {
1675
+ // Cancelled aggregates MUST keep the [status: cancelled] marker on the
1676
+ // result so terminalStatus parsing resolves to 'cancelled'. Only normal
1677
+ // completions drop the summary; cancelled ones prepend the marker.
1678
+ const currentItem = state.items.find((it) => it.id === card.itemId);
1679
+ displayDetail = withCancelledResultMarker(displayDetail, currentItem);
1680
+ }
1624
1681
  patchItem(card.itemId, {
1625
1682
  result: displayDetail,
1626
1683
  text: displayDetail,
@@ -1642,7 +1699,11 @@ export async function createEngineSession({
1642
1699
  const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
1643
1700
  group.completed = Math.min(group.count, group.completed + 1);
1644
1701
  toolGroups.set(card.itemId, group);
1645
- const resultText = groupedToolResultText(group);
1702
+ let resultText = groupedToolResultText(group);
1703
+ if (cancelled) {
1704
+ const currentItem = state.items.find((it) => it.id === card.itemId);
1705
+ resultText = withCancelledResultMarker(resultText, currentItem);
1706
+ }
1646
1707
  patchItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, count: group.count, completedCount: group.completed, completedAt: Date.now() });
1647
1708
  card.done = true;
1648
1709
  if (card.callId) done.add(card.callId);
@@ -1688,8 +1749,7 @@ export async function createEngineSession({
1688
1749
  // cards (send() still in flight). Hold those by callId until the batch lands.
1689
1750
  const earlyResultBuffer = new Map();
1690
1751
  const aggregateCards = []; // active aggregate cards in the current consecutive tool block
1691
- const aggregateByBucket = new Map(); // tail-continuation cache; append only while still the last visible item
1692
- let openAggregateCard = null;
1752
+ const aggregateByBucket = new Map(); // per-bucket reusable card for the current consecutive tool block; cleared on block seal
1693
1753
 
1694
1754
  // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
1695
1755
  // A tool card used to enter the transcript the instant onToolCall fired,
@@ -1722,12 +1782,23 @@ export async function createEngineSession({
1722
1782
  try { e.push(); } catch {}
1723
1783
  }
1724
1784
  };
1785
+ flushDeferredBeforeImmediatePush = () => {
1786
+ if (!deferredEntries.length) return;
1787
+ const last = deferredEntries[deferredEntries.length - 1];
1788
+ if (last) flushDeferredUpTo(last);
1789
+ };
1725
1790
  const registerDeferredCard = (card) => {
1726
1791
  const entry = {
1727
1792
  seq: deferredSeqCounter++,
1728
1793
  pushed: false,
1729
1794
  timer: null,
1730
- push: () => { card.pushed = true; if (card.spec) { card.spec.deferredDisplayReady = true; pushItem(card.spec); } },
1795
+ push: () => {
1796
+ card.pushed = true;
1797
+ if (!card.spec) return;
1798
+ card.spec.deferredDisplayReady = true;
1799
+ pushingFromDeferredEntry = true;
1800
+ try { pushItem(card.spec); } finally { pushingFromDeferredEntry = false; }
1801
+ },
1731
1802
  };
1732
1803
  card.deferred = entry;
1733
1804
  card.ensureVisible = () => flushDeferredUpTo(entry);
@@ -1744,7 +1815,13 @@ export async function createEngineSession({
1744
1815
  seq: deferredSeqCounter++,
1745
1816
  pushed: false,
1746
1817
  timer: null,
1747
- push: () => { aggregate.pushed = true; if (aggregate.pendingSpec) { aggregate.pendingSpec.deferredDisplayReady = true; pushItem(aggregate.pendingSpec); } },
1818
+ push: () => {
1819
+ aggregate.pushed = true;
1820
+ if (!aggregate.pendingSpec) return;
1821
+ aggregate.pendingSpec.deferredDisplayReady = true;
1822
+ pushingFromDeferredEntry = true;
1823
+ try { pushItem(aggregate.pendingSpec); } finally { pushingFromDeferredEntry = false; }
1824
+ },
1748
1825
  };
1749
1826
  aggregate.deferred = entry;
1750
1827
  aggregate.ensureVisible = () => flushDeferredUpTo(entry);
@@ -1804,10 +1881,14 @@ export async function createEngineSession({
1804
1881
  if (allCalls.length === 0) continue;
1805
1882
  aggregate.ensureVisible?.();
1806
1883
  const errors = allCalls.filter((r) => r.isError).length;
1807
- const summaries = aggregateSummaries(aggregate);
1808
- const detailText = aggregateDisplayDetail(summaries, allCalls);
1884
+ const completed = allCalls.filter((r) => r.resolved).length;
1885
+ const succeeded = completed - errors;
1809
1886
  const rawResult = aggregateRawResult(allCalls);
1810
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1887
+ // Status-only collapsed detail (see patchToolCardResult): no per-result
1888
+ // summary; failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
1889
+ const displayDetail = errors > 0
1890
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1891
+ : '';
1811
1892
  patchItem(aggregate.itemId, {
1812
1893
  result: displayDetail,
1813
1894
  text: displayDetail,
@@ -1824,13 +1905,11 @@ export async function createEngineSession({
1824
1905
  completeAggregateVisual();
1825
1906
  finalizeToolHeaders();
1826
1907
  aggregateCards.length = 0;
1827
- openAggregateCard = null;
1828
- };
1829
-
1830
- const isAggregateTail = (aggregate) => {
1831
- if (!aggregate?.itemId) return false;
1832
- const last = state.items[state.items.length - 1];
1833
- return last?.kind === 'tool' && last.aggregate === true && last.id === aggregate.itemId;
1908
+ // Seal the block: same-bucket calls after this point must open a fresh
1909
+ // card, never continue one from before the seal (assistant text/turn
1910
+ // end boundary). Cross-block behavior is unchanged by the in-block
1911
+ // reuse relaxation below.
1912
+ aggregateByBucket.clear();
1834
1913
  };
1835
1914
 
1836
1915
  const rememberActiveAggregate = (aggregate) => {
@@ -1840,24 +1919,19 @@ export async function createEngineSession({
1840
1919
  };
1841
1920
 
1842
1921
  const ensureAggregateCard = (bucket) => {
1843
- // Reuse the open aggregate when it is either still the transcript tail OR
1844
- // has not been pushed yet. The not-yet-pushed case matters for parallel
1845
- // tool batches: every call in one onToolCall batch is collected first and
1846
- // the card is pushed once afterward (syncAggregateHeader), so the 2nd+
1847
- // same-bucket call in the SAME batch must merge even though the card is
1848
- // not yet visible in state.items (isAggregateTail would be false).
1849
- if (openAggregateCard?.bucket === bucket
1850
- && (!openAggregateCard.pushed || isAggregateTail(openAggregateCard))) return openAggregateCard;
1851
- // If the previous aggregate was finalized/closed but is still the tail of
1852
- // the transcript (or was never pushed), continue that exact card instead
1853
- // of pushing a duplicate directly below it. Never reach past a visible
1854
- // assistant/tool/status item: that would make an older card's count change
1855
- // "in the middle" of history.
1856
- const tailAggregate = aggregateByBucket.get(bucket);
1857
- if (tailAggregate && (!tailAggregate.pushed || isAggregateTail(tailAggregate))) {
1858
- openAggregateCard = tailAggregate;
1859
- rememberActiveAggregate(tailAggregate);
1860
- return tailAggregate;
1922
+ // Reuse any same-bucket aggregate created earlier in the SAME consecutive
1923
+ // tool block, even when a different-category card was interleaved after
1924
+ // it (Read, Search, Read the two Reads merge into one card; the Search
1925
+ // stays separate). The block is only sealed forcing a fresh card per
1926
+ // bucket by clearAggregateContinuation (assistant text lands or the
1927
+ // turn ends), which clears aggregateByBucket. Reusing a pushed, non-tail
1928
+ // card is safe: the aggregate card body is a fixed header+1-detail-row
1929
+ // height, so patching its count/completedCount later never reflows the
1930
+ // cards that were pushed after it.
1931
+ const cached = aggregateByBucket.get(bucket);
1932
+ if (cached) {
1933
+ rememberActiveAggregate(cached);
1934
+ return cached;
1861
1935
  }
1862
1936
  const itemId = nextId();
1863
1937
  const aggregate = {
@@ -1874,7 +1948,6 @@ export async function createEngineSession({
1874
1948
  // pendingSpec current until the timer/result flushes it in call order.
1875
1949
  registerDeferredAggregate(aggregate);
1876
1950
  rememberActiveAggregate(aggregate);
1877
- openAggregateCard = aggregate;
1878
1951
  return aggregate;
1879
1952
  };
1880
1953
 
@@ -2065,19 +2138,13 @@ export async function createEngineSession({
2065
2138
  const allCalls = [...aggregate.calls.values()];
2066
2139
  const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
2067
2140
  const errors = allCalls.filter((r) => r.isError).length;
2068
- const summaries = aggregateSummaries(aggregate);
2069
- let detailText;
2070
- if (errors > 0 && summaries.length === 0) {
2071
- const succeeded = completedCount - errors;
2072
- detailText = succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`;
2073
- } else {
2074
- detailText = aggregateDisplayDetail(summaries, allCalls);
2075
- if (errors > 0) {
2076
- detailText = detailText ? `${detailText} · ${errors} Failed` : `${errors} Failed`;
2077
- }
2078
- }
2141
+ const succeeded = completedCount - errors;
2079
2142
  const rawResult = aggregateRawResult(allCalls);
2080
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
2143
+ // Status-only collapsed detail (no per-result summary); failures keep
2144
+ // 'N Failed'. Raw preserved for ctrl+o expansion.
2145
+ const displayDetail = errors > 0
2146
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
2147
+ : '';
2081
2148
  const currentItem = state.items.find((it) => it.id === card.itemId);
2082
2149
  const visualCompleted = Math.max(
2083
2150
  completedCount,
@@ -2146,21 +2213,28 @@ export async function createEngineSession({
2146
2213
  }
2147
2214
  const batchCalls = (calls || []).filter(Boolean);
2148
2215
  if (batchCalls.length === 0) return;
2149
- commitAssistantSegment({ sealToolBlock: true });
2216
+ const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
2217
+ if (committedAssistantSegment) {
2218
+ // Let the pre-tool assistant preamble paint as its own frame before
2219
+ // the tool card reserves/pushes rows. When both enter the transcript
2220
+ // in the same render, the bottom-pinned viewport can appear to jump
2221
+ // upward by the combined height ("preamble + tool card" at once).
2222
+ await yieldToRenderer();
2223
+ }
2150
2224
 
2151
2225
  const touchedAggregates = new Set();
2152
2226
  for (let i = 0; i < batchCalls.length; i++) {
2153
2227
  const c = batchCalls[i];
2154
2228
  const name = toolCallName(c);
2155
2229
  const args = toolCallArgs(c);
2230
+ // Category drives the aggregate bucket so only same-category calls
2231
+ // merge into one card; classify first, then bucket by it.
2156
2232
  const category = classifyToolCategory(name, args);
2157
2233
  const bucket = aggregateBucketForCategory(category);
2158
- const categoryEntry = aggregateToolCategoryEntry(name, args, category);
2159
2234
  const callId = toolCallId(c);
2160
2235
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
2161
2236
 
2162
2237
  if (!bucket) {
2163
- openAggregateCard = null;
2164
2238
  const itemId = nextId();
2165
2239
  // Defer the visible push: hold the spec and only enter the
2166
2240
  // transcript when the real header/detail will paint (delay
@@ -2193,6 +2267,7 @@ export async function createEngineSession({
2193
2267
  continue;
2194
2268
  }
2195
2269
 
2270
+ const categoryEntry = aggregateToolCategoryEntry(name, args, category);
2196
2271
  const aggregateCard = ensureAggregateCard(bucket);
2197
2272
  if (!aggregateCard.categories.has(categoryEntry.key)) aggregateCard.categoryOrder.push(categoryEntry.key);
2198
2273
  const prevCategory = aggregateCard.categories.get(categoryEntry.key);
@@ -2212,6 +2287,17 @@ export async function createEngineSession({
2212
2287
  for (const aggregateCard of touchedAggregates) {
2213
2288
  syncAggregateHeader(aggregateCard);
2214
2289
  }
2290
+ if (committedAssistantSegment) {
2291
+ // A pre-tool assistant preamble has already had one render frame to
2292
+ // settle. Do not let the first grouped tool card sit off-screen until
2293
+ // the normal 1s deferred timer: when it later inserts its real 3 rows,
2294
+ // the already-wrapped preamble visibly jumps. Surface the first card
2295
+ // now via the existing deferredDisplayReady path, so the post-
2296
+ // preamble frame contains the intended Running tool card immediately
2297
+ // (no blank placeholder, no delayed row insertion).
2298
+ const firstTouchedAggregate = [...touchedAggregates][0] || null;
2299
+ firstTouchedAggregate?.ensureVisible?.();
2300
+ }
2215
2301
  for (const [bufferedCallId, bufferedMessage] of earlyResultBuffer) {
2216
2302
  if (!cardByCallId.has(bufferedCallId)) continue;
2217
2303
  deliverToolResultMessage(bufferedMessage);
@@ -2367,7 +2453,7 @@ export async function createEngineSession({
2367
2453
  // shows "cancelled", but in-flight tool cards remain in a perpetual
2368
2454
  // pending/blinking state because the normal finalize path (line 992)
2369
2455
  // was skipped when the error interrupted the try block.
2370
- flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
2456
+ flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
2371
2457
  finalizeToolHeaders();
2372
2458
  } else {
2373
2459
  finalizeToolHeaders();
@@ -2384,6 +2470,7 @@ export async function createEngineSession({
2384
2470
  if (last) flushDeferredUpTo(last);
2385
2471
  clearDeferredTimers();
2386
2472
  }
2473
+ flushDeferredBeforeImmediatePush = null;
2387
2474
  const producedTranscriptItem = state.items.length > itemsAtTurnStart;
2388
2475
  const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
2389
2476
  activePromptRestore = null;
@@ -2481,6 +2568,7 @@ export async function createEngineSession({
2481
2568
  if (pending.length === 0) return [];
2482
2569
  const max = queuePriorityValue(maxPriority);
2483
2570
  const predicate = typeof options.predicate === 'function' ? options.predicate : () => true;
2571
+ const limit = Math.max(1, Number(options.limit) || Infinity);
2484
2572
  let bestPriority = Infinity;
2485
2573
  let targetMode = null;
2486
2574
  for (const entry of pending) {
@@ -2499,6 +2587,7 @@ export async function createEngineSession({
2499
2587
  if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
2500
2588
  batch.push(entry);
2501
2589
  pending.splice(i, 1);
2590
+ if (batch.length >= limit) break;
2502
2591
  } else {
2503
2592
  i += 1;
2504
2593
  }
@@ -2509,13 +2598,16 @@ export async function createEngineSession({
2509
2598
 
2510
2599
  async function drain() {
2511
2600
  if (draining) return;
2601
+ if (autoClearRunning) return;
2512
2602
  draining = true;
2603
+ let firstBatch = true;
2513
2604
  try {
2514
2605
  while (pending.length > 0) {
2515
2606
  // Drain one priority/mode bucket at a time (unified command queue):
2516
2607
  // unified command queue semantics: prompt steering stays editable and
2517
2608
  // task notifications stay non-editable but model-visible.
2518
- const batch = dequeueQueueBatch('later');
2609
+ const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
2610
+ firstBatch = false;
2519
2611
  if (batch.length === 0) break;
2520
2612
  const ids = new Set(batch.map((e) => e.id));
2521
2613
  const merged = mergePromptContents(batch);
@@ -2559,9 +2651,12 @@ export async function createEngineSession({
2559
2651
 
2560
2652
  function drainPendingSteering() {
2561
2653
  // Mid-turn steering drain:
2562
- // getCommandsByMaxPriority('next') and exclude slash commands. Slash
2563
- // commands must run through the normal command processor after the turn,
2564
- // not be sent to the model as plain text.
2654
+ // Injects queued user prompts (steering) plus non-editable internal entries
2655
+ // into the CURRENT provider pre-send window so the user can redirect a turn
2656
+ // that is already running. Slash commands are still excluded: they must run
2657
+ // through the normal command processor after the turn, not be sent as plain
2658
+ // text. Consumed entries are spliced out of `pending` here, so the post-turn
2659
+ // drain() loop will not re-execute them.
2565
2660
  const batch = dequeueQueueBatch('next', { predicate: (entry) => !isSlashQueuedEntry(entry) });
2566
2661
  if (batch.length === 0) return [];
2567
2662
  const out = batch
@@ -2592,7 +2687,14 @@ export async function createEngineSession({
2592
2687
  const startedAt = Date.now();
2593
2688
  set({ commandStatus: { active: true, verb: 'Auto-clearing idle conversation', startedAt, mode: 'auto-clear' } });
2594
2689
  try {
2595
- await runtime.clear({ compactType: cfg.compactType || null, requireCompactSuccess: !!cfg.compactType });
2690
+ // Give Ink one event-loop turn to paint the auto-clear status before the
2691
+ // clear/compact path starts doing synchronous session/transcript work.
2692
+ // Without this, long idle clears can look like a frozen prompt followed by
2693
+ // an already-complete status row.
2694
+ await new Promise((resolve) => setTimeout(resolve, 0));
2695
+ const compaction = runtime.getCompactionSettings();
2696
+ const compactType = compaction.compactType || compaction.type;
2697
+ await runtime.clear({ compactType, requireCompactSuccess: !!compactType });
2596
2698
  resetStats();
2597
2699
  clearUiActivityBeforeContextSync();
2598
2700
  syncContextStats({ allowEstimated: true });
@@ -2627,6 +2729,7 @@ export async function createEngineSession({
2627
2729
  lastUserActivityAt = Date.now();
2628
2730
  autoClearRunning = false;
2629
2731
  set({ commandStatus: null });
2732
+ void drain();
2630
2733
  }
2631
2734
  }
2632
2735
 
@@ -2739,6 +2842,10 @@ export async function createEngineSession({
2739
2842
  enqueue(text, queueOptions);
2740
2843
  return true;
2741
2844
  }
2845
+ if (autoClearRunning) {
2846
+ enqueue(text, queueOptions);
2847
+ return true;
2848
+ }
2742
2849
  void autoClearBeforeSubmit().then(() => enqueue(text, queueOptions));
2743
2850
  return true;
2744
2851
  },
@@ -2747,8 +2854,10 @@ export async function createEngineSession({
2747
2854
  if (state.commandBusy) return false;
2748
2855
  set({ commandBusy: true });
2749
2856
  try {
2750
- await runtime.setRoute({ model: m }, { applyToCurrentSession: true });
2751
- syncContextStats({ allowEstimated: true });
2857
+ // Model changes apply to the NEXT session only (default setRoute
2858
+ // behavior) never rewrite the live session's provider/model, which
2859
+ // would force a full prompt-cache rewrite mid-conversation.
2860
+ await runtime.setRoute({ model: m });
2752
2861
  set({ ...routeState(), stats: { ...state.stats } });
2753
2862
  return true;
2754
2863
  } finally {
@@ -2802,6 +2911,11 @@ export async function createEngineSession({
2802
2911
  set({ autoClear: next });
2803
2912
  return next;
2804
2913
  },
2914
+ getUpdateSettings: () => runtime.getUpdateSettings?.() || null,
2915
+ setAutoUpdate: (enabled) => runtime.setAutoUpdate?.(enabled),
2916
+ checkForUpdate: (input = {}) => runtime.checkForUpdate?.(input),
2917
+ runUpdateNow: () => runtime.runUpdateNow?.(),
2918
+ getUpdateStatus: () => runtime.getUpdateStatus?.() || { phase: 'idle' },
2805
2919
  getProfile: () => runtime.getProfile?.() || { title: '', language: 'system', languages: [] },
2806
2920
  setProfile: (input = {}) => {
2807
2921
  const next = runtime.setProfile?.(input) || runtime.getProfile?.() || null;
@@ -3157,8 +3271,20 @@ export async function createEngineSession({
3157
3271
  if (!state.busy) return false;
3158
3272
  denyAllToolApprovals('interrupted by user');
3159
3273
  const restoreState = activePromptRestore;
3160
- const restoreText = restoreState?.restorable ? restoreState.text : '';
3161
- const restorePastedImages = restoreState?.restorable && restoreState?.pastedImages ? restoreState.pastedImages : null;
3274
+ // A queued steering prompt means the user already redirected the turn:
3275
+ // interrupting should just cancel the running turn and let the steering
3276
+ // prompt run next, NOT resurrect the in-flight prompt back into the draft.
3277
+ const hasPendingSteering = pending.some((entry) => isQueuedEntryEditable(entry));
3278
+ const canRestore = restoreState?.restorable && !hasPendingSteering;
3279
+ const restoreText = canRestore ? restoreState.text : '';
3280
+ const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
3281
+ // When steering suppresses the restore, the interrupted prompt's pasted
3282
+ // images never get committed (onCommitted won't fire) nor re-installed into
3283
+ // the draft, so hand them back for cleanup to avoid a stale `[Image #id]`
3284
+ // lingering in the paste snapshot.
3285
+ const discardPastedImages = restoreState?.restorable && hasPendingSteering && restoreState?.pastedImages
3286
+ ? restoreState.pastedImages
3287
+ : null;
3162
3288
  const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
3163
3289
  ? restoreState.requeueEntries.slice()
3164
3290
  : [];
@@ -3180,7 +3306,7 @@ export async function createEngineSession({
3180
3306
  restoreState.restorable = false;
3181
3307
  restoreState.requeueEntries = [];
3182
3308
  }
3183
- return { aborted, restoreText, pastedImages: restorePastedImages };
3309
+ return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages };
3184
3310
  },
3185
3311
  resolveToolApproval: (id, decision = {}) => {
3186
3312
  const approved = decision === true || decision?.approved === true;
@@ -3260,6 +3386,18 @@ export async function createEngineSession({
3260
3386
  set({ commandBusy: false });
3261
3387
  }
3262
3388
  },
3389
+ // Toggle Discord remote mode for this session. Flips the runtime's
3390
+ // remoteEnabled flag (booting/stopping the channel worker) and returns the
3391
+ // NEW enabled state so the caller can render an ON/OFF notice.
3392
+ toggleRemote: () => {
3393
+ const enabled = runtime.isRemoteEnabled?.() === true;
3394
+ if (enabled) runtime.stopRemote?.();
3395
+ else runtime.startRemote?.();
3396
+ const next = runtime.isRemoteEnabled?.() === true;
3397
+ set({ remoteEnabled: next });
3398
+ return next;
3399
+ },
3400
+ isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
3263
3401
  // Theme is a TUI-local concern (no runtime round-trip). listThemes returns
3264
3402
  // picker metadata; getTheme reports the active id; setTheme applies the
3265
3403
  // palette in-place + persists ui.theme and bumps a themeEpoch so the React
@@ -3274,6 +3412,9 @@ export async function createEngineSession({
3274
3412
  setAgentRoute: async (agentId, opts) => {
3275
3413
  return await runtime.setAgentRoute?.(agentId, opts);
3276
3414
  },
3415
+ setDefaultProvider: async (provider) => {
3416
+ return await runtime.setDefaultProvider?.(provider);
3417
+ },
3277
3418
  listProviders: () => {
3278
3419
  return runtime.listProviders();
3279
3420
  },
@@ -3286,6 +3427,10 @@ export async function createEngineSession({
3286
3427
  getOnboardingStatus: () => {
3287
3428
  return runtime.getOnboardingStatus?.() || { completed: true, workflowRoutes: {} };
3288
3429
  },
3430
+ skipOnboarding: () => {
3431
+ // Completed-marking only; no route/agent/provider writes.
3432
+ return runtime.skipOnboarding?.() || null;
3433
+ },
3289
3434
  completeOnboarding: async (payload = {}) => {
3290
3435
  if (state.commandBusy) return null;
3291
3436
  set({ commandBusy: true });
@@ -3363,6 +3508,8 @@ export async function createEngineSession({
3363
3508
  getChannelSetup: () => {
3364
3509
  return runtime.getChannelSetup();
3365
3510
  },
3511
+ getChannelWorkerStatus: () => runtime.getChannelWorkerStatus?.(),
3512
+ setBackend: (name) => runtime.setBackend?.(name),
3366
3513
  saveDiscordToken: (token) => {
3367
3514
  const result = runtime.saveDiscordToken(token);
3368
3515
  pushNotice('discord token saved', 'info');
@@ -3373,6 +3520,16 @@ export async function createEngineSession({
3373
3520
  pushNotice('discord token forgotten', 'info');
3374
3521
  return result;
3375
3522
  },
3523
+ saveTelegramToken: (token) => {
3524
+ const result = runtime.saveTelegramToken?.(token);
3525
+ pushNotice('telegram token saved', 'info');
3526
+ return result;
3527
+ },
3528
+ forgetTelegramToken: () => {
3529
+ const result = runtime.forgetTelegramToken?.();
3530
+ pushNotice('telegram token forgotten', 'info');
3531
+ return result;
3532
+ },
3376
3533
  saveWebhookAuthtoken: (token) => {
3377
3534
  const result = runtime.saveWebhookAuthtoken(token);
3378
3535
  pushNotice('webhook/ngrok authtoken saved', 'info');
@@ -3433,7 +3590,9 @@ export async function createEngineSession({
3433
3590
  set({ commandBusy: true });
3434
3591
  try {
3435
3592
  const routeOpts = opts && typeof opts === 'object' ? opts : {};
3436
- const applyToCurrentSession = routeOpts.applyToCurrentSession === false ? false : true;
3593
+ // Default: apply to the NEXT session only. Only an explicit
3594
+ // `applyToCurrentSession: true` rewrites the live session in place.
3595
+ const applyToCurrentSession = routeOpts.applyToCurrentSession === true;
3437
3596
  const { applyToCurrentSession: _drop, ...nextRoute } = routeOpts;
3438
3597
  await runtime.setRoute(nextRoute, { applyToCurrentSession });
3439
3598
  if (applyToCurrentSession) syncContextStats({ allowEstimated: true });
@@ -3448,6 +3607,7 @@ export async function createEngineSession({
3448
3607
  if (state.commandBusy) return false;
3449
3608
  set({ commandBusy: true });
3450
3609
  clearToastTimers();
3610
+ resetAllStreamingMarkdownStablePrefixes();
3451
3611
  const rollbackSnapshot = snapshotTuiBeforeSessionReset();
3452
3612
  resetTuiForPendingSessionReset();
3453
3613
  set({
@@ -3483,6 +3643,7 @@ export async function createEngineSession({
3483
3643
  if (state.commandBusy) return false;
3484
3644
  set({ commandBusy: true });
3485
3645
  clearToastTimers();
3646
+ resetAllStreamingMarkdownStablePrefixes();
3486
3647
  const rollbackSnapshot = snapshotTuiBeforeSessionReset();
3487
3648
  resetTuiForPendingSessionReset();
3488
3649
  set({