mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -1
  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/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -4,76 +4,75 @@
4
4
  * Runs mixdog's session manager outside React and exposes a tiny subscribable
5
5
  * store. The React/ink layer consumes it via useSyncExternalStore
6
6
  * (see hooks/useEngine.mjs).
7
+ *
8
+ * Pure/stateless helpers live in ./engine/* (boot-profile, session-stats,
9
+ * labels, notice-text, tool-result-text, tool-call-fields, agent-envelope,
10
+ * queue-helpers) and are re-exported here so the public surface is unchanged.
11
+ * This file keeps the stateful createEngineSession store + notification plan.
7
12
  */
8
13
  import { performance } from 'node:perf_hooks';
9
- import { SPINNER_VERBS } from './spinner-verbs.mjs';
10
14
  import {
11
15
  aggregateToolCategoryEntry,
12
16
  classifyToolCategory,
13
17
  formatAggregateDetail,
14
18
  summarizeToolResult,
15
19
  } from '../runtime/shared/tool-surface.mjs';
16
- import { isBackgroundErrorOnlyBody, presentErrorText } from '../runtime/shared/err-text.mjs';
17
20
  import {
18
21
  isModelVisibleToolCompletionWrapper,
19
22
  modelVisibleToolCompletionMessage,
20
23
  } from '../runtime/shared/tool-execution-contract.mjs';
24
+ import { presentErrorText } from '../runtime/shared/err-text.mjs';
21
25
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
22
-
23
- const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
24
- const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
25
-
26
- function bootProfile(event, fields = {}) {
27
- if (!BOOT_PROFILE_ENABLED) return;
28
- const elapsedMs = performance.now() - BOOT_PROFILE_START;
29
- const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `tui:${event}`];
30
- for (const [key, value] of Object.entries(fields || {})) {
31
- if (value === undefined || value === null || value === '') continue;
32
- parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
33
- }
34
- try { process.stderr.write(`${parts.join(' ')}\n`); } catch {}
35
- }
36
-
37
- // Session-usage accumulator - inlined (not imported from ui/statusline.mjs) so
38
- // engine.mjs has no static dependency on the vendored statusline closure.
39
- function createSessionStats() {
40
- return {
41
- inputTokens: 0,
42
- outputTokens: 0,
43
- cachedTokens: 0,
44
- cacheWriteTokens: 0,
45
- promptTokens: 0,
46
- latestInputTokens: 0,
47
- latestOutputTokens: 0,
48
- latestCachedTokens: 0,
49
- latestCacheWriteTokens: 0,
50
- latestPromptTokens: 0,
51
- currentContextTokens: 0,
52
- costUsd: 0,
53
- turns: 0,
54
- };
55
- }
56
- function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
57
- function applyUsageDelta(stats, delta = {}) {
58
- if (!stats || !delta) return stats;
59
- const inputTokens = num(delta.deltaInput);
60
- const outputTokens = num(delta.deltaOutput);
61
- const cachedTokens = num(delta.deltaCachedRead);
62
- const cacheWriteTokens = num(delta.deltaCacheWrite);
63
- const promptTokens = num(delta.deltaPrompt);
64
- stats.inputTokens += inputTokens;
65
- stats.outputTokens += outputTokens;
66
- stats.cachedTokens += cachedTokens;
67
- stats.cacheWriteTokens += cacheWriteTokens;
68
- stats.promptTokens += promptTokens;
69
- stats.latestInputTokens = inputTokens;
70
- stats.latestOutputTokens = outputTokens;
71
- stats.latestCachedTokens = cachedTokens;
72
- stats.latestCacheWriteTokens = cacheWriteTokens;
73
- stats.latestPromptTokens = promptTokens;
74
- stats.costUsd += num(delta.costUsd);
75
- return stats;
76
- }
26
+ import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
27
+ import { bootProfile } from './engine/boot-profile.mjs';
28
+ import { createSessionStats, applyUsageDelta } from './engine/session-stats.mjs';
29
+ import {
30
+ pickVerb,
31
+ pickDoneVerb,
32
+ formatElapsedSeconds,
33
+ compactEventLabel,
34
+ compactEventDetail,
35
+ projectNameFromPath,
36
+ } from './engine/labels.mjs';
37
+ import { polishNoticeText } from './engine/notice-text.mjs';
38
+ import {
39
+ toolResultText,
40
+ toolAggregateDetailFallback,
41
+ toolGroupedDisplayFallback,
42
+ toolErrorDisplay,
43
+ } from './engine/tool-result-text.mjs';
44
+ import {
45
+ toolCallId,
46
+ toolResultCallId,
47
+ toolCallName,
48
+ toolCallArgs,
49
+ } from './engine/tool-call-fields.mjs';
50
+ import {
51
+ parseAgentJob,
52
+ parseAgentResultEnvelope,
53
+ parseBackgroundTaskEnvelope,
54
+ parseSyntheticAgentMessage,
55
+ isStatusOnlyAgentCompletionNotification,
56
+ agentJobResultText,
57
+ agentArgsWithResultMetadata,
58
+ toolResultStatus,
59
+ isErrorToolStatus,
60
+ } from './engine/agent-envelope.mjs';
61
+ import {
62
+ queuePriorityValue,
63
+ defaultQueuePriority,
64
+ isQueuedEntryEditable,
65
+ isQueuedEntryVisible,
66
+ isSlashQueuedEntry,
67
+ shortTextFingerprint,
68
+ notificationDisplayText,
69
+ sessionActivityTimestamp,
70
+ promptDisplayText,
71
+ mergePromptContents,
72
+ mergePastedImages,
73
+ mergePastedTexts,
74
+ callCommitCallbacks,
75
+ } from './engine/queue-helpers.mjs';
77
76
 
78
77
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
79
78
  // src/tui/dist/index.mjs.
@@ -89,739 +88,21 @@ const TOOL_APPROVAL_TIMEOUT_MS = (() => {
89
88
  let _idSeq = 0;
90
89
  const nextId = () => `it_${++_idSeq}`;
91
90
 
92
- function pickVerb(turn) {
93
- return SPINNER_VERBS[(turn * 7 + 3) % SPINNER_VERBS.length];
94
- }
95
-
96
- const TURN_DONE_VERBS = [
97
- 'Thought',
98
- 'Reasoned',
99
- 'Mapped',
100
- 'Checked',
101
- 'Solved',
102
- 'Composed',
103
- 'Synthesized',
104
- 'Wrapped',
105
- ];
106
-
107
- function pickDoneVerb(turn) {
108
- return TURN_DONE_VERBS[(turn * 5 + 2) % TURN_DONE_VERBS.length];
109
- }
110
-
111
- function formatElapsedSeconds(ms) {
112
- const value = Math.max(0, Number(ms) || 0);
113
- if (value <= 0) return '0s';
114
- return `${Math.max(1, Math.ceil(value / 1000))}s`;
115
- }
116
-
117
- function compactEventLabel(event = {}) {
118
- const status = String(event.status || '').toLowerCase();
119
- const reactive = String(event.trigger || '').toLowerCase() === 'reactive';
120
- if (status === 'failed') return reactive ? 'Compact failed (overflow retry)' : 'Compact failed';
121
- if (status === 'skipped') return 'Compact skipped';
122
- if (status === 'no_change') return 'Compact checked';
123
- return reactive ? 'Compact complete (overflow recovery)' : 'Compact complete';
124
- }
125
-
126
- function compactEventDetail(event = {}) {
127
- // Keep the elapsed time as the lead detail, but no longer discard the rest of
128
- // the compact metadata. Surface type/trigger and the boundary/pressure so the
129
- // statusdone marker reflects what actually fired.
130
- const parts = [];
131
- const elapsed = formatElapsedSeconds(Number(event.durationMs ?? event.elapsedMs ?? 0));
132
- if (elapsed) parts.push(elapsed);
133
- const type = String(event.compactType || event.type || '').trim();
134
- if (type && type !== 'semantic') parts.push(type);
135
- const trigger = String(event.trigger || '').toLowerCase();
136
- if (trigger === 'reactive') parts.push('reactive');
137
- else if (trigger === 'manual') parts.push('manual');
138
- const before = Number(event.beforeTokens ?? event.pressureTokens ?? 0);
139
- const after = Number(event.afterTokens ?? 0);
140
- const fmtTok = (n) => {
141
- const v = Number(n) || 0;
142
- if (v >= 1000) return `${(v / 1000).toFixed(v >= 10_000 ? 0 : 1)}k`;
143
- return `${Math.round(v)}`;
144
- };
145
- if (before > 0 && after > 0 && after !== before) parts.push(`${fmtTok(before)}→${fmtTok(after)}`);
146
- return parts.join(' · ');
147
- }
148
-
149
- function projectNameFromPath(value) {
150
- const text = String(value || '').replace(/[\\/]+$/, '');
151
- return text.split(/[\\/]/).pop() || text || '(current)';
152
- }
153
-
154
- const FAILED_NOTICE_ACTIONS = new Map([
155
- ['api key save', 'save API key'],
156
- ['auth-forget', 'forget auth'],
157
- ['auto-clear', 'update auto-clear'],
158
- ['autoclear', 'update auto-clear'],
159
- ['agent', 'run agent command'],
160
- ['channels', 'load channels'],
161
- ['channels update', 'update channels'],
162
- ['clear', 'clear chat'],
163
- ['compact', 'compact context'],
164
- ['copy', 'copy'],
165
- ['core memory', 'load core memory'],
166
- ['cwd', 'update working directory'],
167
- ['effort switch', 'switch effort'],
168
- ['fast', 'update fast mode'],
169
- ['hook rule update', 'update hook rule'],
170
- ['hook toggle', 'toggle hook'],
171
- ['hook update', 'update hook'],
172
- ['hooks status', 'load hooks'],
173
- ['local provider update', 'update local provider'],
174
- ['mcp add', 'add MCP server'],
175
- ['mcp reconnect', 'reconnect MCP server'],
176
- ['mcp status', 'load MCP status'],
177
- ['mcp toggle', 'toggle MCP server'],
178
- ['memory', 'run memory command'],
179
- ['memory status', 'load memory status'],
180
- ['model save', 'save model'],
181
- ['model switch', 'switch model'],
182
- ['oauth code', 'finish OAuth login'],
183
- ['oauth login', 'start OAuth login'],
184
- ['output style switch', 'switch output style'],
185
- ['OpenAI usage auth save', 'save OpenAI usage auth'],
186
- ['OpenCode Go usage auth save', 'save OpenCode Go usage auth'],
187
- ['plugin add', 'add plugin'],
188
- ['plugin MCP enable', 'enable plugin MCP'],
189
- ['plugin uninstall', 'uninstall plugin'],
190
- ['plugin update', 'update plugin'],
191
- ['plugins status', 'load plugins'],
192
- ['providers', 'load providers'],
193
- ['recall', 'run recall'],
194
- ['resume', 'resume chat'],
195
- ['schedule toggle', 'toggle schedule'],
196
- ['setup save', 'save setup'],
197
- ['settings update', 'update settings'],
198
- ['skill add', 'add skill'],
199
- ['skills status', 'load skills'],
200
- ['tools status', 'load tool status'],
201
- ['usage', 'load usage'],
202
- ['webhook toggle', 'toggle webhook'],
203
- ['workflow switch', 'switch workflow'],
204
- ]);
205
-
206
- function polishNoticeAction(action) {
207
- const value = String(action || '').trim();
208
- if (!value) return 'finish';
209
- const key = value.toLowerCase();
210
- for (const [candidate, replacement] of FAILED_NOTICE_ACTIONS.entries()) {
211
- if (candidate.toLowerCase() === key) return replacement;
212
- }
213
- const suffixes = [
214
- [' save', 'save'],
215
- [' switch', 'switch'],
216
- [' update', 'update'],
217
- [' toggle', 'toggle'],
218
- [' reconnect', 'reconnect'],
219
- [' enable', 'enable'],
220
- [' uninstall', 'uninstall'],
221
- [' add', 'add'],
222
- ];
223
- for (const [suffix, verb] of suffixes) {
224
- if (!key.endsWith(suffix)) continue;
225
- const subject = value.slice(0, -suffix.length).trim();
226
- return subject ? `${verb} ${subject}` : verb;
227
- }
228
- return value;
229
- }
230
-
231
- function sentenceStart(text) {
232
- const value = String(text || '').trim();
233
- return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
234
- }
235
-
236
- function polishNoticeText(text) {
237
- let value = String(text ?? '').trim().replace(/^✓\s*/, '');
238
- if (!value) return '';
239
- const error = /^error\s*:\s*(.+)$/i.exec(value);
240
- if (error?.[1]) value = error[1].trim();
241
- const couldNot = /^could not\s+(.+?)(?::\s*(.+))?$/i.exec(value);
242
- if (couldNot) {
243
- return couldNot[2]
244
- ? `Couldn’t ${couldNot[1]}: ${couldNot[2]}`
245
- : `Couldn’t ${couldNot[1]}.`;
246
- }
247
- const failed = /^(.+?)\s+failed(?::\s*(.+))?$/i.exec(value);
248
- if (failed) {
249
- const action = polishNoticeAction(failed[1]);
250
- return failed[2] ? `Couldn’t ${action}: ${failed[2]}` : `Couldn’t ${action}.`;
251
- }
252
- const busy = /^(.+?)\s+already in progress\.?$/i.exec(value);
253
- if (busy) return `${sentenceStart(polishNoticeAction(busy[1]))} is already running.`;
254
- const required = /^(.+?)\s+is required(?:\s+for\s+(.+))?\.?$/i.exec(value);
255
- if (required) {
256
- const subject = required[1].trim();
257
- const target = required[2]?.trim();
258
- return `${subject}${target ? ` required for ${target}` : ' required'}.`;
259
- }
260
- return value;
261
- }
262
-
263
- function toolResultText(content) {
264
- if (content == null) return '';
265
- if (typeof content === 'string') return content;
266
- if (Array.isArray(content)) {
267
- return content.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
268
- }
269
- if (typeof content === 'object') {
270
- if (Array.isArray(content.content)) {
271
- const nested = content.content.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
272
- if (nested) return nested;
273
- } else if (content.content != null && typeof content.content === 'object') {
274
- const nested = toolResultPartText(content.content);
275
- if (nested) return nested;
276
- }
277
- if (Array.isArray(content.parts)) {
278
- const nested = content.parts.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
279
- if (nested) return nested;
280
- }
281
- const fromPart = toolResultPartText(content);
282
- if (fromPart) return fromPart;
283
- if (content?.type === 'tool_result') return '';
284
- if (typeof content.text === 'string') return content.text;
285
- if (typeof content.content === 'string') return content.content;
286
- }
287
- try { return JSON.stringify(content); } catch { return String(content); }
288
- }
289
-
290
- const TOOL_RESULT_PART_MAX_DEPTH = 12;
291
- const TOOL_RESULT_JSON_FALLBACK_MAX = 480;
292
-
293
- function compactToolResultObjectFallback(obj) {
294
- if (obj?.type === 'tool_result') return '';
295
- try {
296
- const json = JSON.stringify(obj);
297
- if (!json || json === '{}') return '';
298
- if (json.length <= TOOL_RESULT_JSON_FALLBACK_MAX) return json;
299
- return `${json.slice(0, TOOL_RESULT_JSON_FALLBACK_MAX - 1)}…`;
300
- } catch {
301
- return String(obj);
302
- }
303
- }
304
-
305
- function toolResultPartText(part, depth = 0) {
306
- if (part == null) return '';
307
- if (depth > TOOL_RESULT_PART_MAX_DEPTH) return '';
308
- if (typeof part === 'string') return part;
309
- if (part?.type === 'image' || part?.type === 'input_image') {
310
- return `[image: ${part.mimeType || part.mediaType || part.source?.media_type || 'image'}]`;
311
- }
312
- if (part?.type === 'tool_result') {
313
- const inner = part.content;
314
- if (typeof inner === 'string') return inner;
315
- if (Array.isArray(inner)) {
316
- return inner.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
317
- }
318
- if (inner != null && typeof inner === 'object') {
319
- return toolResultPartText(inner, depth + 1);
320
- }
321
- return '';
322
- }
323
- if (part?.type === 'text' || part?.type === 'output_text' || part?.type === 'input_text') {
324
- return part.text ?? '';
325
- }
326
- if (Array.isArray(part)) {
327
- return part.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
328
- }
329
- if (typeof part === 'object') {
330
- if (Array.isArray(part.content)) {
331
- const nested = part.content.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
332
- if (nested) return nested;
333
- }
334
- if (part.content != null && typeof part.content === 'object') {
335
- const nested = toolResultPartText(part.content, depth + 1);
336
- if (nested) return nested;
337
- }
338
- if (Array.isArray(part.parts)) {
339
- const nested = part.parts.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
340
- if (nested) return nested;
341
- }
342
- if (typeof part.text === 'string' && part.text) return part.text;
343
- if (typeof part.output === 'string' && part.output) return part.output;
344
- if (typeof part.message === 'string' && part.message) return part.message;
345
- if (typeof part.content === 'string') return part.content;
346
- if (part.source?.type === 'base64' && part.source?.data) {
347
- return `[image: ${part.source.media_type || part.source.mediaType || 'base64'}]`;
348
- }
349
- return compactToolResultObjectFallback(part);
350
- }
351
- return '';
352
- }
353
-
354
- function toolAggregateDetailFallback(detailText, rawResult) {
355
- if (String(detailText || '').trim()) return detailText;
356
- const raw = String(rawResult || '').replace(/\s+$/, '').trim();
357
- if (!raw) return detailText;
358
- const line = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
359
- if (!line) return detailText;
360
- return line.length > 160 ? `${line.slice(0, 157)}…` : line;
361
- }
362
-
363
- function toolGroupedDisplayFallback(resultText, text, rawText) {
364
- if (String(resultText || '').trim()) return resultText;
365
- const body = String(text || rawText || '').trim();
366
- if (body) return text || rawText;
367
- return resultText;
368
- }
369
-
370
- function toolErrorDisplay(value, surface = 'tool') {
371
- const text = presentErrorText(value, { surface });
372
- if (/^(?:Search failed|Fetch failed|No first response|The .+ went stale|(?:Web search agent|Agent|Tool) (?:stopped|was cancelled))/i.test(text)) {
373
- return text;
374
- }
375
- return /^error\s*:/i.test(text) ? text : `Error: ${text}`;
376
- }
377
-
378
- function toolCallId(call) {
379
- return call?.id ?? call?.toolCallId ?? call?.tool_call_id ?? call?.call_id;
380
- }
381
-
382
- function toolResultCallId(message) {
383
- return message?.toolCallId
384
- ?? message?.tool_call_id
385
- ?? message?.tool_use_id
386
- ?? message?.call_id
387
- ?? message?.id;
388
- }
389
-
390
- function toolCallName(call) {
391
- return call?.name ?? call?.function?.name ?? call?.toolName ?? call?.tool_name ?? 'tool';
392
- }
393
-
394
- function toolCallArgs(call) {
395
- return call?.arguments ?? call?.args ?? call?.input ?? call?.function?.arguments;
396
- }
397
-
398
- function textBetweenTag(text, tag) {
399
- const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
400
- const match = re.exec(String(text ?? ''));
401
- return match ? match[1].trim() : '';
402
- }
403
-
404
- function stripSyntheticAgentTags(text) {
405
- const value = String(text ?? '').trim();
406
- const finalAnswer = textBetweenTag(value, 'final-answer');
407
- if (finalAnswer) return finalAnswer;
408
- const taskResult = textBetweenTag(value, 'result');
409
- if (taskResult) return taskResult;
410
- return value
411
- .replace(/^agent result[^\n]*(?:\n|$)/i, '')
412
- .replace(/<\/?(?:final-answer|task-notification|task-id|tool-use-id|output-file|result|status|summary|usage|total_tokens|tool_uses|duration_ms|worktree|worktreePath|worktreeBranch)[^>]*>/gi, '')
413
- .trim();
414
- }
415
-
416
- function splitBridgeEnvelope(text) {
417
- const value = String(text ?? '').trim();
418
- if (!value) return { head: '', body: '' };
419
- const match = /\n\s*\n/.exec(value);
420
- if (!match) return { head: value, body: '' };
421
- return {
422
- head: value.slice(0, match.index).trim(),
423
- body: value.slice(match.index + match[0].length).trim(),
424
- };
425
- }
426
-
427
- function agentJobStatusText(parsed) {
428
- if (!parsed) return '';
429
- const parts = [];
430
- if (parsed.status) parts.push(`status: ${parsed.status}`);
431
- if (parsed.taskId) parts.push(`task_id: ${parsed.taskId}`);
432
- return parts.join(' · ');
433
- }
434
-
435
- function agentJobResultText(text, parsed = parseAgentJob(text)) {
436
- const value = String(text ?? '').trim();
437
- if (!value) return '';
438
- if (parsed?.taskId) {
439
- const { body } = splitBridgeEnvelope(value);
440
- const cleanBody = stripSyntheticAgentTags(body);
441
- if (cleanBody) return cleanBody;
442
- return agentJobStatusText(parsed);
443
- }
444
- return stripSyntheticAgentTags(value) || value;
445
- }
446
-
447
- function parseAgentResultEnvelope(text, fallback = {}) {
448
- const value = String(text ?? '').trim();
449
- if (!/^agent result\b/i.test(value)) return null;
450
- const [head = '', ...restLines] = value.split('\n');
451
- const body = stripSyntheticAgentTags(restLines.join('\n'));
452
- const attrs = {};
453
- const attrRe = /([a-zA-Z][\w-]*)=("[^"]*"|'[^']*'|\S+)/g;
454
- let match;
455
- while ((match = attrRe.exec(head))) {
456
- attrs[match[1].toLowerCase()] = String(match[2] || '').replace(/^["']|["']$/g, '');
457
- }
458
- const providerModel = /\s([a-zA-Z0-9_.-]+)\/([^\s]+)\s*$/i.exec(head);
459
- const role = attrs.agent || attrs.role || fallback.role || fallback.agent || '';
460
- return {
461
- name: 'agent',
462
- label: String(fallback.status || attrs.status || 'completed').toLowerCase(),
463
- args: {
464
- type: 'result',
465
- status: fallback.status || attrs.status || 'completed',
466
- task_id: fallback.taskId || attrs.task_id || attrs.taskid || undefined,
467
- tag: fallback.tag || attrs.tag || undefined,
468
- agent: role || undefined,
469
- role: role || undefined,
470
- provider: fallback.provider || attrs.provider || providerModel?.[1] || undefined,
471
- model: fallback.model || attrs.model || providerModel?.[2] || undefined,
472
- preset: fallback.preset || attrs.preset || undefined,
473
- effort: fallback.effort || attrs.effort || undefined,
474
- fast: fallback.fast ?? attrs.fast,
475
- },
476
- result: body || agentJobStatusText({ status: fallback.status || attrs.status || 'completed', taskId: fallback.taskId || attrs.task_id || attrs.taskid || '' }),
477
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(fallback.status || attrs.status || ''),
478
- };
479
- }
480
-
91
+ // Re-export the shared tool-result/notification helpers so importers (and tests)
92
+ // keep resolving them from engine.mjs unchanged.
481
93
  export { toolResultText, toolAggregateDetailFallback, toolGroupedDisplayFallback };
482
-
483
- export function parseBackgroundTaskEnvelope(text) {
484
- const value = String(text ?? '').trim();
485
- if (!/^background task\b/i.test(value)) return null;
486
- const allLines = value.split('\n');
487
- const rest = allLines.slice(1);
488
- const blank = rest.findIndex((line) => !line.trim());
489
- const headLines = blank >= 0 ? rest.slice(0, blank) : rest;
490
- const body = blank >= 0 ? rest.slice(blank + 1).join('\n').trim() : '';
491
- const fields = {};
492
- for (const line of headLines) {
493
- const match = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(line.trim());
494
- if (match) fields[match[1].toLowerCase()] = match[2].trim();
495
- }
496
- const surface = String(fields.surface || fields.operation || 'task').toLowerCase();
497
- const name = surface === 'explore' || surface === 'search' || surface === 'shell' || surface === 'agent' ? surface : 'task';
498
- const status = String(fields.status || '').toLowerCase();
499
- const taskId = fields.task_id || fields.taskid || '';
500
- const errorText = fields.error || '';
501
- const agentResult = parseAgentResultEnvelope(body, {
502
- status,
503
- taskId,
504
- tag: fields.tag || fields.label || '',
505
- role: fields.role || fields.agent || '',
506
- agent: fields.agent || '',
507
- provider: fields.provider || '',
508
- model: fields.model || '',
509
- preset: fields.preset || '',
510
- effort: fields.effort || '',
511
- fast: fields.fast,
512
- });
513
- if (agentResult) return { ...agentResult, rawResult: value };
514
- const errorOnlyBody = isBackgroundErrorOnlyBody(body, errorText);
515
- const resultBody = body && !errorOnlyBody ? body : '';
516
- return {
517
- name,
518
- label: status || 'notification',
519
- args: {
520
- type: body ? 'result' : (fields.operation || 'status'),
521
- status,
522
- task_id: taskId || undefined,
523
- surface,
524
- operation: fields.operation || undefined,
525
- label: fields.label || undefined,
526
- tag: fields.tag || undefined,
527
- agent: fields.agent || fields.role || undefined,
528
- role: fields.role || fields.agent || undefined,
529
- provider: fields.provider || undefined,
530
- model: fields.model || undefined,
531
- preset: fields.preset || undefined,
532
- effort: fields.effort || undefined,
533
- fast: fields.fast || undefined,
534
- error: errorText || undefined,
535
- startedAt: fields.started || fields.startedat || undefined,
536
- finishedAt: fields.finished || fields.finishedat || undefined,
537
- },
538
- result: resultBody || (!errorText ? [status ? `status: ${status}` : '', taskId ? `task_id: ${taskId}` : ''].filter(Boolean).join(' · ') : ''),
539
- rawResult: value,
540
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText),
541
- };
542
- }
543
-
544
- function isStatusOnlyAgentCompletionNotification(text) {
545
- const background = parseBackgroundTaskEnvelope(text);
546
- if (background?.name === 'agent' && /^(completed|cancelled|canceled)$/i.test(background.label || '')) {
547
- return !(hasAgentResponseResultText(background.result) || hasAgentResponseResultText(text));
548
- }
549
- const parsed = parseAgentJob(text);
550
- const result = agentJobResultText(text, parsed);
551
- if (!parsed?.taskId || !/^(completed|cancelled|canceled)$/i.test(parsed.status || '')) return false;
552
- return !(hasAgentResponseResultText(result) || hasAgentResponseResultText(text));
553
- }
554
-
555
- function hasAgentResponseResultText(text) {
556
- const value = String(text || '').trim();
557
- if (!value) return false;
558
- if (/^status:\s*(?:running|pending|queued|completed|failed|cancelled|canceled)(?:\s*·\s*task_id:\s*\S+)?$/i.test(value)) return false;
559
- if (/^(?:background task\b|agent task:|task_id:)/i.test(value) && !/\n\s*\n[\s\S]*\S/.test(value)) return false;
560
- return true;
561
- }
562
-
563
- function bracketField(text, name) {
564
- const re = new RegExp(`^\\[${name}:\\s*([^\\]]*)\\]`, 'mi');
565
- return re.exec(String(text ?? ''))?.[1]?.trim() || '';
566
- }
567
-
568
- function toolResultStatus(text) {
569
- const value = String(text ?? '');
570
- const tagged = textBetweenTag(value, 'status');
571
- if (tagged) return tagged.trim();
572
- const bracketed = bracketField(value, 'status');
573
- if (bracketed) return bracketed.trim();
574
- const inline = /^(?:status|state):\s*([^\s·,;]+)/mi.exec(value);
575
- return inline ? inline[1].trim() : '';
576
- }
577
-
578
- function isErrorToolStatus(status) {
579
- return /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(String(status || '').trim());
580
- }
581
-
582
- function parseSyntheticAgentMessage(text) {
583
- const value = String(text ?? '').trim();
584
- if (!value) return null;
585
- const finalAnswer = textBetweenTag(value, 'final-answer');
586
- if (finalAnswer) {
587
- return {
588
- name: 'agent',
589
- label: 'final',
590
- args: { type: 'read', description: 'agent result' },
591
- result: finalAnswer,
592
- };
593
- }
594
- const agentResult = parseAgentResultEnvelope(value);
595
- if (agentResult) return agentResult;
596
- const backgroundTask = parseBackgroundTaskEnvelope(value);
597
- if (backgroundTask) return backgroundTask;
598
- const shellTaskId = bracketField(value, 'task_id');
599
- if (shellTaskId) {
600
- const status = bracketField(value, 'status') || 'done';
601
- const exit = bracketField(value, 'exit');
602
- const command = bracketField(value, 'command');
603
- return {
604
- name: 'shell',
605
- label: status,
606
- args: { type: 'result', task_id: shellTaskId, command },
607
- result: value,
608
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(status) || (exit && exit !== '0' && exit !== 'n/a'),
609
- };
610
- }
611
- const agentJob = parseAgentJob(value);
612
- if (agentJob?.taskId) {
613
- const label = agentJob.status || 'notification';
614
- const result = agentJobResultText(value, agentJob);
615
- return {
616
- name: 'agent',
617
- label,
618
- args: agentArgsWithResultMetadata({ type: agentJob.type || 'notification', description: 'agent notification' }, agentJob),
619
- result: result || agentJobStatusText(agentJob) || 'agent notification',
620
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(label),
621
- };
622
- }
623
- if (/<task-notification\b/i.test(value)) {
624
- const status = textBetweenTag(value, 'status') || 'completed';
625
- const summary = textBetweenTag(value, 'summary') || `Agent ${status}`;
626
- const taskId = textBetweenTag(value, 'task-id');
627
- const result = stripSyntheticAgentTags(value);
628
- return {
629
- name: 'agent',
630
- label: status,
631
- taskId,
632
- summary,
633
- result: result || summary,
634
- };
635
- }
636
- return null;
637
- }
638
-
639
- function normalizeToolName(name) {
640
- return String(name || 'tool')
641
- .replace(/^mcp__.*__/, '')
642
- .replace(/^functions\./, '')
643
- .replace(/-/g, '_')
644
- .toLowerCase();
645
- }
646
-
647
- function parseToolArgs(args) {
648
- if (!args) return {};
649
- if (typeof args === 'string') {
650
- try {
651
- const parsed = JSON.parse(args);
652
- return parsed && typeof parsed === 'object' ? parsed : {};
653
- } catch {
654
- return {};
655
- }
656
- }
657
- return typeof args === 'object' ? args : {};
658
- }
659
-
660
- const yieldToRenderer = () => new Promise((resolve) => setImmediate(resolve));
661
-
662
- function parseAgentJob(text) {
663
- const value = String(text || '');
664
- const idMatch = /^agent task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
665
- if (!idMatch) return null;
666
- const statusMatch = /^status:\s*([^\s(]+)/m.exec(value);
667
- const typeMatch = /^type:\s*(.+)$/m.exec(value);
668
- const targetMatch = /^target:\s*(.+)$/m.exec(value);
669
- const roleMatch = /^(?:agent|role):\s*(.+)$/m.exec(value);
670
- const presetMatch = /^preset:\s*(.+)$/m.exec(value);
671
- const modelMatch = /^model:\s*([^/\s]+)\/(.+)$/m.exec(value);
672
- const effortMatch = /^effort:\s*(.+)$/m.exec(value);
673
- const fastMatch = /^fast:\s*(on|off|true|false)$/m.exec(value);
674
- return {
675
- taskId: idMatch[1],
676
- status: (statusMatch?.[1] || '').toLowerCase(),
677
- type: (typeMatch?.[1] || '').trim(),
678
- target: (targetMatch?.[1] || '').trim(),
679
- role: (roleMatch?.[1] || '').trim(),
680
- preset: (presetMatch?.[1] || '').trim(),
681
- provider: (modelMatch?.[1] || '').trim(),
682
- model: (modelMatch?.[2] || '').trim(),
683
- effort: (effortMatch?.[1] || '').trim(),
684
- fast: fastMatch ? /^(on|true)$/i.test(fastMatch[1]) : undefined,
685
- };
686
- }
687
-
688
- const QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
689
-
690
- function queuePriorityValue(value) {
691
- return QUEUE_PRIORITY[String(value || 'next')] ?? QUEUE_PRIORITY.next;
692
- }
693
-
694
- function defaultQueuePriority(mode) {
695
- // Queue priority defaults:
696
- // - user/bashed prompt input defaults to `next`, so it can be attached at the
697
- // next model-send boundary while a turn is active.
698
- // - task notifications default to `later`, unless the caller explicitly marks
699
- // them urgent (e.g. interactive shell stall/completion).
700
- return mode === 'task-notification' ? 'later' : 'next';
701
- }
702
-
703
- function isQueuedEntryEditable(entry) {
704
- const mode = entry?.mode || 'prompt';
705
- return mode !== 'task-notification' && mode !== 'pending-resume';
706
- }
707
-
708
- function isQueuedEntryVisible(entry) {
709
- // state.queued drives the user-command wait list above the prompt. Background
710
- // task completions stay in the internal pending queue, but should never look
711
- // like commands typed by the user while they wait to be drained.
712
- const mode = entry?.mode || 'prompt';
713
- if (mode === 'pending-resume') return false;
714
- return isQueuedEntryEditable(entry);
715
- }
716
-
717
- function isSlashQueuedEntry(entry) {
718
- if (entry?.skipSlashCommands) return false;
719
- const text = promptContentText(entry?.content ?? entry?.text ?? '');
720
- return text.trim().startsWith('/');
721
- }
722
-
723
- function firstQueueLine(text) {
724
- return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
725
- }
726
-
727
- function shortTextFingerprint(text) {
728
- const value = String(text || '').trim();
729
- let hash = 2166136261;
730
- for (let i = 0; i < value.length; i += 1) {
731
- hash ^= value.charCodeAt(i);
732
- hash = Math.imul(hash, 16777619);
733
- }
734
- return (hash >>> 0).toString(36);
735
- }
736
-
737
- function notificationDisplayText(text) {
738
- const parsed = parseAgentJob(text);
739
- const result = agentJobResultText(text, parsed);
740
- const synthetic = parseSyntheticAgentMessage(text);
741
- return firstQueueLine(synthetic?.result || result || text) || 'agent notification';
742
- }
743
-
744
- function promptContentText(content) {
745
- if (typeof content === 'string') return content;
746
- if (Array.isArray(content)) {
747
- return content.map((part) => {
748
- if (typeof part === 'string') return part;
749
- if (part?.type === 'text') return part.text || '';
750
- if (part?.type === 'image') return '[Image]';
751
- return part?.text || '';
752
- }).filter(Boolean).join('\n');
753
- }
754
- return String(content ?? '');
755
- }
756
-
757
- function timestampMs(value) {
758
- if (value == null || value === '') return 0;
759
- const n = Number(value);
760
- if (Number.isFinite(n) && n > 0) return n;
761
- const parsed = Date.parse(String(value));
762
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
763
- }
764
-
765
- function hasModelVisibleConversation(session) {
766
- const messages = Array.isArray(session?.messages) ? session.messages : [];
767
- return messages.some((message) => {
768
- const role = message?.role;
769
- if (role !== 'user' && role !== 'assistant' && role !== 'tool') return false;
770
- const text = promptContentText(message.content).trim();
771
- if (role === 'user' && text.startsWith('<system-reminder>')) return false;
772
- if (role === 'assistant' && text === '.' && !Array.isArray(message.toolCalls)) return false;
773
- return !!text || role === 'assistant' || role === 'tool';
774
- });
775
- }
776
-
777
- function sessionActivityTimestamp(session, fallback = 0) {
778
- if (!hasModelVisibleConversation(session)) return 0;
779
- return timestampMs(session?.lastUsedAt)
780
- || timestampMs(session?.updatedAt)
781
- || timestampMs(fallback);
782
- }
783
-
784
- function promptDisplayText(content, options = {}) {
785
- if (typeof options.displayText === 'string') return options.displayText;
786
- return promptContentText(content);
787
- }
788
-
789
- function mergePromptContents(entries) {
790
- const batch = Array.isArray(entries) ? entries : [];
791
- if (batch.every((entry) => typeof entry?.content === 'string')) {
792
- return batch.map((entry) => entry.content).filter((text) => String(text || '').trim()).join('\n');
793
- }
794
- const parts = [];
795
- for (const entry of batch) {
796
- const content = entry?.content;
797
- if (typeof content === 'string') {
798
- if (content.trim()) parts.push({ type: 'text', text: content });
799
- } else if (Array.isArray(content)) {
800
- parts.push(...content);
801
- }
802
- parts.push({ type: 'text', text: '\n' });
803
- }
804
- while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
805
- return parts.length === 1 && parts[0]?.type === 'text' ? parts[0].text : parts;
806
- }
807
-
808
- function mergePastedImages(entries) {
809
- const out = {};
810
- for (const entry of entries || []) {
811
- const images = entry?.pastedImages;
812
- if (!images || typeof images !== 'object') continue;
813
- for (const [id, image] of Object.entries(images)) {
814
- if (image) out[id] = image;
815
- }
816
- }
817
- return Object.keys(out).length > 0 ? out : null;
818
- }
819
-
820
- function callCommitCallbacks(entries) {
821
- for (const entry of entries || []) {
822
- try { entry?.onCommitted?.(); } catch {}
823
- }
824
- }
94
+ export { parseBackgroundTaskEnvelope };
95
+
96
+ // Ink renders through a maxFps throttle (120fps in index.jsx, ≈8.3ms). A plain
97
+ // setImmediate only yields to the event loop; if Ink already painted within the
98
+ // current throttle window, the next paint may still be queued and our following
99
+ // transcript mutation can coalesce into the same visible frame. Wait just past
100
+ // one render window when we intentionally split transcript commits for visual
101
+ // stability (preamble frame tool-card frame).
102
+ const RENDER_THROTTLE_FLUSH_MS = 12;
103
+ const yieldToRenderer = () => new Promise((resolve) => {
104
+ setTimeout(resolve, RENDER_THROTTLE_FLUSH_MS);
105
+ });
825
106
 
826
107
  function notificationQueueKey(event, text, parsed) {
827
108
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
@@ -837,8 +118,8 @@ function notificationQueueKey(event, text, parsed) {
837
118
  : tag
838
119
  ? `tag:${tag}:${shortTextFingerprint(synthetic.result || text)}`
839
120
  : '';
840
- const role = String(synthetic.args?.agent || synthetic.args?.role || '').trim();
841
- if (resultId || role) return ['agent-result', resultId, role].filter(Boolean).join(':');
121
+ const agent = String(synthetic.args?.agent || '').trim();
122
+ if (resultId || agent) return ['agent-result', resultId, agent].filter(Boolean).join(':');
842
123
  }
843
124
  const id = String(meta.execution_id || parsed?.taskId || '').trim();
844
125
  if (!id) return '';
@@ -883,40 +164,14 @@ function isExecutionNotification(event, text, parsed) {
883
164
  return Boolean(parsed?.taskId && /^(?:agent task:|task_id:)/mi.test(String(text || '')));
884
165
  }
885
166
 
886
- function agentArgsWithResultMetadata(args, parsed) {
887
- if (!parsed) return args;
888
- const next = { ...(args && typeof args === 'object' ? args : {}) };
889
- const requestedAction = String(next.type || next.action || next.mode || '').trim().toLowerCase();
890
- if (parsed.type) {
891
- // Job status envelopes report the original job type (usually "spawn").
892
- // Preserve the user's current agent tool action ("status", "read", …) so
893
- // manual checks render as "Reviewer status" instead of another
894
- // "Spawning Reviewer" card. Keep the job type as metadata for detail.
895
- if (!requestedAction || /^(notification|result|completion)$/i.test(requestedAction)) next.type = parsed.type;
896
- else next.jobType = parsed.type;
897
- }
898
- if (parsed.status) next.status = parsed.status;
899
- if (parsed.taskId) next.task_id = parsed.taskId;
900
- if (parsed.role) next.role = parsed.role;
901
- if (parsed.preset) next.preset = parsed.preset;
902
- if (parsed.provider) next.provider = parsed.provider;
903
- if (parsed.model) next.model = parsed.model;
904
- if (parsed.effort) next.effort = parsed.effort;
905
- if (parsed.fast !== undefined) next.fast = parsed.fast;
906
- if (!next.tag && parsed.target) {
907
- const target = parsed.target.split(/\s+/)[0];
908
- if (target && !target.startsWith('sess_')) next.tag = target;
909
- }
910
- return next;
911
- }
912
-
913
167
  export async function createEngineSession({
914
168
  provider: providerName,
915
169
  model,
916
170
  toolMode = 'full',
171
+ remote = false,
917
172
  } = {}) {
918
173
  const startedAt = performance.now();
919
- bootProfile('engine:create:start', { provider: providerName, model, toolMode });
174
+ bootProfile('engine:create:start', { provider: providerName, model, toolMode, remote });
920
175
  // Silence provider/session diagnostics so they cannot tear through the
921
176
  // alternate-screen React/ink render.
922
177
  process.env.MIXDOG_QUIET_PROVIDER_LOG = '1';
@@ -928,7 +183,7 @@ export async function createEngineSession({
928
183
  const importStartedAt = performance.now();
929
184
  const { createMixdogSessionRuntime } = await import(SESSION_RUNTIME_MODULE);
930
185
  bootProfile('session-runtime:imported', { ms: (performance.now() - importStartedAt).toFixed(1) });
931
- const runtime = await createMixdogSessionRuntime({ provider: providerName, model, toolMode });
186
+ const runtime = await createMixdogSessionRuntime({ provider: providerName, model, toolMode, remote });
932
187
  bootProfile('engine:create:runtime-ready', { ms: (performance.now() - startedAt).toFixed(1) });
933
188
  const cwd = runtime.cwd || process.cwd();
934
189
  const stateStartedAt = performance.now();
@@ -965,6 +220,7 @@ export async function createEngineSession({
965
220
  searchRoute: runtime.getSearchRoute?.() || runtime.searchRoute || null,
966
221
  autoClear: autoClearState(),
967
222
  workflow: runtime.workflow || null,
223
+ remoteEnabled: runtime.isRemoteEnabled?.() === true,
968
224
  });
969
225
 
970
226
  const routeState = () => ({
@@ -997,6 +253,7 @@ export async function createEngineSession({
997
253
  let state = {
998
254
  items: [],
999
255
  toasts: [],
256
+ progressHint: null,
1000
257
  busy: false,
1001
258
  commandBusy: false,
1002
259
  commandStatus: null,
@@ -1006,6 +263,14 @@ export async function createEngineSession({
1006
263
  toolApproval: null,
1007
264
  lastTurn: null,
1008
265
  stats: createSessionStats(),
266
+ // Incremental derivations published by the engine so App does not scan all
267
+ // transcript items on every change:
268
+ // - activeToolSummary: running Explore/Search active counts + earliest
269
+ // startedAt for the prompt-line status (replaces App.jsx O(n) items scan).
270
+ // - promptHistoryList: newest-first deduped user-prompt history, rebuilt
271
+ // only when a user item is appended (replaces the per-change rescan).
272
+ activeToolSummary: null,
273
+ promptHistoryList: [],
1009
274
  ...baseRouteState(),
1010
275
  displayContextWindow: 0,
1011
276
  compactBoundaryTokens: 0,
@@ -1119,13 +384,102 @@ export async function createEngineSession({
1119
384
  const id = nextItems[i]?.id;
1120
385
  if (id != null) itemIndexById.set(id, i);
1121
386
  }
387
+ // Bulk item swap (session load / clear / compact). Derive the prompt-history
388
+ // list from the NEW items and stage it onto state here so App never rescans;
389
+ // the callers that invoke replaceItems always follow with a set({items:...,
390
+ // ...}) that carries fresh references, so this pre-stage does not defeat any
391
+ // emit (the accompanying set() diffs the full patch). A bulk swap also
392
+ // discards the old transcript, so drop any tracked active tool calls.
393
+ activeToolCalls.clear();
394
+ state = { ...state, items: nextItems, promptHistoryList: recomputePromptHistory(nextItems), activeToolSummary: null };
1122
395
  return nextItems;
1123
396
  };
397
+ let flushDeferredBeforeImmediatePush = null;
398
+ let pushingFromDeferredEntry = false;
399
+ // --- Prompt-history list (newest-first, deduped) maintained incrementally ---
400
+ // App previously rebuilt this from state.items on EVERY transcript change
401
+ // (App.jsx recentPromptHistory useMemo). It only changes when a user item is
402
+ // appended, so rebuild it there and on bulk item swaps, publishing to
403
+ // state.promptHistoryList.
404
+ const PROMPT_HISTORY_LIMIT = 50;
405
+ const promptHistoryKey = (value) => String(value || '').trim().replace(/\s+/g, ' ');
406
+ const recomputePromptHistory = (sourceItems = null) => {
407
+ // Pure: derive the newest-first deduped user-prompt history from the given
408
+ // items (defaults to state.items) and RETURN it. Callers decide whether to
409
+ // publish via set() so the immutable-emit contract is preserved.
410
+ const items = Array.isArray(sourceItems) ? sourceItems : (Array.isArray(state.items) ? state.items : []);
411
+ const seen = new Set();
412
+ const history = [];
413
+ for (let i = items.length - 1; i >= 0 && history.length < PROMPT_HISTORY_LIMIT; i -= 1) {
414
+ const item = items[i];
415
+ if (item?.kind !== 'user') continue;
416
+ const text = String(item.text || '').trim();
417
+ const key = promptHistoryKey(text);
418
+ if (!key || seen.has(key)) continue;
419
+ seen.add(key);
420
+ history.push(text);
421
+ }
422
+ return history;
423
+ };
424
+ // --- Active-tool summary (Explore/Search) maintained incrementally ---
425
+ // App previously scanned every transcript item on every change to derive the
426
+ // prompt-line "Exploring N / Searching N" status. Instead the tool lifecycle
427
+ // below tracks per-callId category + started-at in activeToolCalls and derives
428
+ // the small summary from it, publishing state.activeToolSummary only when the
429
+ // aggregate (counts + earliest start) actually changes.
430
+ const activeToolCalls = new Map(); // callKey -> { category, count, startedAt }
431
+ const recomputeActiveToolSummary = () => {
432
+ let exploreCount = 0, exploreStart = 0, searchCount = 0, searchStart = 0;
433
+ for (const rec of activeToolCalls.values()) {
434
+ if (!rec) continue;
435
+ const c = Math.max(1, Number(rec.count || 1));
436
+ const started = Number(rec.startedAt || 0);
437
+ if (rec.category === 'Explore') {
438
+ exploreCount += c;
439
+ if (started > 0 && (exploreStart === 0 || started < exploreStart)) exploreStart = started;
440
+ } else if (rec.category === 'Search') {
441
+ searchCount += c;
442
+ if (started > 0 && (searchStart === 0 || started < searchStart)) searchStart = started;
443
+ }
444
+ }
445
+ const next = (exploreCount || searchCount)
446
+ ? `${exploreCount}:${exploreStart}:${searchCount}:${searchStart}`
447
+ : '';
448
+ const prev = state.activeToolSummary || '';
449
+ if (next !== prev) set({ activeToolSummary: next || null });
450
+ };
451
+ const markToolCallActive = (callKey, category, count, startedAt) => {
452
+ if (!callKey || (category !== 'Explore' && category !== 'Search')) return;
453
+ activeToolCalls.set(callKey, { category, count: Math.max(1, Number(count || 1)), startedAt: Number(startedAt || Date.now()) });
454
+ recomputeActiveToolSummary();
455
+ };
456
+ const markToolCallDone = (callKey) => {
457
+ if (!callKey || !activeToolCalls.has(callKey)) return;
458
+ activeToolCalls.delete(callKey);
459
+ recomputeActiveToolSummary();
460
+ };
461
+ const clearActiveToolSummary = () => {
462
+ if (activeToolCalls.size === 0 && !state.activeToolSummary) return;
463
+ activeToolCalls.clear();
464
+ if (state.activeToolSummary) set({ activeToolSummary: null });
465
+ };
1124
466
  const pushItem = (item) => {
467
+ if (!pushingFromDeferredEntry && flushDeferredBeforeImmediatePush) {
468
+ flushDeferredBeforeImmediatePush();
469
+ }
1125
470
  const index = state.items.length;
1126
471
  const items = [...state.items, item];
1127
472
  if (item?.id != null) itemIndexById.set(item.id, index);
1128
- set({ items });
473
+ if (item?.kind === 'user') {
474
+ // Rebuild the derived history against the NEW list (not yet in state) and
475
+ // publish items + the fresh list in ONE set(). Do NOT pre-assign to state
476
+ // first — set() diffs against the current state, so a pre-assign would make
477
+ // the references identical and skip emit().
478
+ const promptHistoryList = recomputePromptHistory(items);
479
+ set({ items, promptHistoryList });
480
+ } else {
481
+ set({ items });
482
+ }
1129
483
  };
1130
484
  const upsertSyntheticToolItem = (text, id = nextId(), parsed = null) => {
1131
485
  const synthetic = parseSyntheticAgentMessage(text);
@@ -1181,6 +535,16 @@ export async function createEngineSession({
1181
535
  pushItem({ kind: 'notice', id, text: value, tone });
1182
536
  return id;
1183
537
  };
538
+ // Sticky (non-TTL) input-hint-line progress state, for long-running
539
+ // installs (e.g. voice runtime download) that would otherwise spam the
540
+ // 3s toast queue. Distinct from pushToast/pushNotice: it persists across
541
+ // renders until explicitly cleared (setProgressHint('', ...) or a falsy
542
+ // text), and App.jsx's inputHint falls back to it only when no promptHint
543
+ // and no live toast currently cover the same line.
544
+ const setProgressHint = (text, tone = 'info') => {
545
+ const value = String(text ?? '').trim();
546
+ set({ progressHint: value ? { text: value, tone } : null });
547
+ };
1184
548
  const toolApprovalQueue = [];
1185
549
  let activeToolApproval = null;
1186
550
  function normalizeToolApprovalRequest(input = {}, id = nextId()) {
@@ -1384,6 +748,51 @@ export async function createEngineSession({
1384
748
  });
1385
749
  }
1386
750
 
751
+ const CANCELLED_RESULT_STATUS_LINE = '[status: cancelled]';
752
+
753
+ function normalizedResultStatusToken(value) {
754
+ const raw = String(value || '').trim().toLowerCase();
755
+ if (!raw) return '';
756
+ if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
757
+ if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
758
+ if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
759
+ if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
760
+ return '';
761
+ }
762
+
763
+ function resultTextTerminalStatus(text) {
764
+ const body = String(text || '');
765
+ const tagged = body.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
766
+ if (tagged) return normalizedResultStatusToken(tagged);
767
+ const bracketed = body.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
768
+ if (bracketed) return normalizedResultStatusToken(bracketed);
769
+ const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
770
+ return normalizedResultStatusToken(inline);
771
+ }
772
+
773
+ function itemHasKnownTerminalStatus(item, texts = []) {
774
+ const settled = (token) => token === 'completed' || token === 'failed' || token === 'cancelled';
775
+ if (settled(normalizedResultStatusToken(item?.args?.status))) return true;
776
+ for (const text of texts) {
777
+ if (settled(resultTextTerminalStatus(text))) return true;
778
+ }
779
+ return false;
780
+ }
781
+
782
+ function withCancelledResultMarker(text, item) {
783
+ const body = String(text || '');
784
+ // Do NOT inspect item.rawResult here: aggregate rawResult is child tool
785
+ // output (`1. grep\n<result>…`) that can incidentally contain a `status:`
786
+ // line, which would false-positive as an already-terminal status and skip
787
+ // the cancelled marker. Only result/text/body are engine-controlled
788
+ // collapsed detail (empty / status word / an existing marker), so they are
789
+ // the trustworthy terminal-status sources.
790
+ const sources = [item?.result, item?.text, body];
791
+ if (itemHasKnownTerminalStatus(item, sources)) return body;
792
+ if (!body.trim()) return `${CANCELLED_RESULT_STATUS_LINE}\n`;
793
+ return `${CANCELLED_RESULT_STATUS_LINE}\n${body}`;
794
+ }
795
+
1387
796
  function groupedToolResultText(group) {
1388
797
  const completed = Math.min(group.count, group.completed);
1389
798
  if (group.count <= 1) return group.results.at(-1)?.text ?? '';
@@ -1424,7 +833,7 @@ export async function createEngineSession({
1424
833
  const chunks = [];
1425
834
  for (const rec of calls || []) {
1426
835
  if (rec?.resolved !== true) continue;
1427
- const text = String(rec?.resultText || '').replace(/\s+$/, '');
836
+ let text = String(rec?.resultText || '').replace(/\s+$/, '');
1428
837
  if (!text.trim()) continue;
1429
838
  const label = String(rec?.name || rec?.category || 'tool').trim() || 'tool';
1430
839
  chunks.push(`${chunks.length + 1}. ${label}\n${text}`);
@@ -1432,37 +841,15 @@ export async function createEngineSession({
1432
841
  return chunks.join('\n\n');
1433
842
  }
1434
843
 
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
844
  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
- }
845
+ // Merge consecutive tool calls of the SAME category into one aggregate card;
846
+ // a different category opens a fresh card (no cross-category merge). The
847
+ // bucket key is the category itself, so a run of Search calls collapses into
848
+ // one Search card while an adjacent Read/Patch stays separate. Falls back to
849
+ // 'default' when a call has no resolved category. Hook/approval denials keep
850
+ // their dedicated ToolHookDenialCard path in App.jsx.
851
+ const key = String(category || '').trim();
852
+ return key ? `category:${key}` : 'default';
1466
853
  }
1467
854
 
1468
855
  function aggregateSummaries(aggregate) {
@@ -1483,6 +870,9 @@ export async function createEngineSession({
1483
870
  if (!card || card.done) return false;
1484
871
  const callId = toolResultCallId(message) || card.callId;
1485
872
  if (callId && done.has(callId)) return false;
873
+ // Any resolving call clears its active-summary entry (keyed by the same
874
+ // callKey used at markToolCallActive; card.callId holds it for both branches).
875
+ markToolCallDone(card.callId);
1486
876
  // A result for this card arrived (possibly before its deferred push delay
1487
877
  // elapsed) — surface the card now so the patch below has a live item and the
1488
878
  // fast tool paints a completed card directly, no pending placeholder stage.
@@ -1511,22 +901,20 @@ export async function createEngineSession({
1511
901
  const allCalls = [...aggregate.calls.values()];
1512
902
  const completed = allCalls.filter((r) => r.resolved).length;
1513
903
  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
- }
904
+ // Collapsed detail carries the merged per-call count summary
905
+ // ("512 lines, 6 matches, 3 files") so the finished card answers "how
906
+ // much" without ctrl+o. Failures keep a bare 'N Ok · N Failed' status so
907
+ // an error stays visible while collapsed.
908
+ const succeeded = completed - errors;
909
+ const detailText = errors > 0
910
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
911
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1525
912
  const currentItem = state.items.find((it) => it.id === card.itemId);
1526
913
  const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
1527
914
  const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
1528
915
  const rawResult = aggregateRawResult(allCalls);
1529
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
916
+ // The numbered+labelled raw (rawResult) is preserved for ctrl+o expansion.
917
+ const displayDetail = detailText;
1530
918
  patchItem(card.itemId, {
1531
919
  result: displayDetail,
1532
920
  text: displayDetail,
@@ -1575,7 +963,7 @@ export async function createEngineSession({
1575
963
  return true;
1576
964
  }
1577
965
 
1578
- const flushToolResults = (messages, toolCards, cardByCallId, toolGroups, done, { finalize = false } = {}) => {
966
+ const flushToolResults = (messages, toolCards, cardByCallId, toolGroups, done, { finalize = false, cancelled = false } = {}) => {
1579
967
  const results = [];
1580
968
  for (const m of messages || []) {
1581
969
  if (!m || m.role !== 'tool') continue;
@@ -1613,14 +1001,38 @@ export async function createEngineSession({
1613
1001
  const aggregate = card.aggregate;
1614
1002
  if (aggregate && card.itemId === aggregate.itemId) {
1615
1003
  const allCalls = [...aggregate.calls.values()];
1004
+ // Never let a call that truly never resolved be presented as a real
1005
+ // completion. Stamp it resolved so completedCount reflects an honest
1006
+ // (if degenerate) accounting instead of manufacturing success out of
1007
+ // a call that never came back. A record already marked completedEarly
1008
+ // (via __earlyNotify) already carries a real isError/resultText/summary
1009
+ // from its actual result — preserve those; only blank-fill for calls
1010
+ // truly never heard from (no completedEarly, no resolved).
1011
+ for (const rec of allCalls) {
1012
+ if (rec.resolved) continue;
1013
+ rec.resolved = true;
1014
+ if (!rec.completedEarly) {
1015
+ rec.isError = false;
1016
+ rec.resultText = rec.resultText || '';
1017
+ }
1018
+ }
1616
1019
  const completed = allCalls.filter((r) => r.resolved).length;
1617
- const remaining = allCalls.length - completed;
1618
- const totalCompleted = remaining > 0 ? completed + remaining : completed;
1020
+ const totalCompleted = completed;
1619
1021
  const errors = allCalls.filter((r) => r.isError).length;
1620
- const summaries = aggregateSummaries(aggregate);
1621
- const detailText = aggregateDisplayDetail(summaries, allCalls);
1022
+ const succeeded = completed - errors;
1622
1023
  const rawResult = aggregateRawResult(allCalls);
1623
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1024
+ // Collapsed detail carries the merged per-call count summary; failures
1025
+ // keep a bare 'N Ok · N Failed' status. Raw is kept for ctrl+o.
1026
+ let displayDetail = errors > 0
1027
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1028
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1029
+ if (cancelled) {
1030
+ // Cancelled aggregates MUST keep the [status: cancelled] marker on the
1031
+ // result so terminalStatus parsing resolves to 'cancelled'. Only normal
1032
+ // completions drop the summary; cancelled ones prepend the marker.
1033
+ const currentItem = state.items.find((it) => it.id === card.itemId);
1034
+ displayDetail = withCancelledResultMarker(displayDetail, currentItem);
1035
+ }
1624
1036
  patchItem(card.itemId, {
1625
1037
  result: displayDetail,
1626
1038
  text: displayDetail,
@@ -1642,7 +1054,11 @@ export async function createEngineSession({
1642
1054
  const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
1643
1055
  group.completed = Math.min(group.count, group.completed + 1);
1644
1056
  toolGroups.set(card.itemId, group);
1645
- const resultText = groupedToolResultText(group);
1057
+ let resultText = groupedToolResultText(group);
1058
+ if (cancelled) {
1059
+ const currentItem = state.items.find((it) => it.id === card.itemId);
1060
+ resultText = withCancelledResultMarker(resultText, currentItem);
1061
+ }
1646
1062
  patchItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, count: group.count, completedCount: group.completed, completedAt: Date.now() });
1647
1063
  card.done = true;
1648
1064
  if (card.callId) done.add(card.callId);
@@ -1660,6 +1076,7 @@ export async function createEngineSession({
1660
1076
  activePromptRestore = {
1661
1077
  text: String(displayText || '').trim(),
1662
1078
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
1079
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
1663
1080
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
1664
1081
  restorable: options.restorable !== false,
1665
1082
  submittedIds,
@@ -1688,8 +1105,7 @@ export async function createEngineSession({
1688
1105
  // cards (send() still in flight). Hold those by callId until the batch lands.
1689
1106
  const earlyResultBuffer = new Map();
1690
1107
  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;
1108
+ const aggregateByBucket = new Map(); // per-bucket reusable card for the current consecutive tool block; cleared on block seal
1693
1109
 
1694
1110
  // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
1695
1111
  // A tool card used to enter the transcript the instant onToolCall fired,
@@ -1722,12 +1138,23 @@ export async function createEngineSession({
1722
1138
  try { e.push(); } catch {}
1723
1139
  }
1724
1140
  };
1141
+ flushDeferredBeforeImmediatePush = () => {
1142
+ if (!deferredEntries.length) return;
1143
+ const last = deferredEntries[deferredEntries.length - 1];
1144
+ if (last) flushDeferredUpTo(last);
1145
+ };
1725
1146
  const registerDeferredCard = (card) => {
1726
1147
  const entry = {
1727
1148
  seq: deferredSeqCounter++,
1728
1149
  pushed: false,
1729
1150
  timer: null,
1730
- push: () => { card.pushed = true; if (card.spec) { card.spec.deferredDisplayReady = true; pushItem(card.spec); } },
1151
+ push: () => {
1152
+ card.pushed = true;
1153
+ if (!card.spec) return;
1154
+ card.spec.deferredDisplayReady = true;
1155
+ pushingFromDeferredEntry = true;
1156
+ try { pushItem(card.spec); } finally { pushingFromDeferredEntry = false; }
1157
+ },
1731
1158
  };
1732
1159
  card.deferred = entry;
1733
1160
  card.ensureVisible = () => flushDeferredUpTo(entry);
@@ -1744,7 +1171,13 @@ export async function createEngineSession({
1744
1171
  seq: deferredSeqCounter++,
1745
1172
  pushed: false,
1746
1173
  timer: null,
1747
- push: () => { aggregate.pushed = true; if (aggregate.pendingSpec) { aggregate.pendingSpec.deferredDisplayReady = true; pushItem(aggregate.pendingSpec); } },
1174
+ push: () => {
1175
+ aggregate.pushed = true;
1176
+ if (!aggregate.pendingSpec) return;
1177
+ aggregate.pendingSpec.deferredDisplayReady = true;
1178
+ pushingFromDeferredEntry = true;
1179
+ try { pushItem(aggregate.pendingSpec); } finally { pushingFromDeferredEntry = false; }
1180
+ },
1748
1181
  };
1749
1182
  aggregate.deferred = entry;
1750
1183
  aggregate.ensureVisible = () => flushDeferredUpTo(entry);
@@ -1772,6 +1205,7 @@ export async function createEngineSession({
1772
1205
  activePromptRestore.committed = true;
1773
1206
  activePromptRestore.requeueEntries = [];
1774
1207
  activePromptRestore.pastedImages = null;
1208
+ activePromptRestore.pastedTexts = null;
1775
1209
  }
1776
1210
  };
1777
1211
 
@@ -1804,10 +1238,14 @@ export async function createEngineSession({
1804
1238
  if (allCalls.length === 0) continue;
1805
1239
  aggregate.ensureVisible?.();
1806
1240
  const errors = allCalls.filter((r) => r.isError).length;
1807
- const summaries = aggregateSummaries(aggregate);
1808
- const detailText = aggregateDisplayDetail(summaries, allCalls);
1241
+ const completed = allCalls.filter((r) => r.resolved).length;
1242
+ const succeeded = completed - errors;
1809
1243
  const rawResult = aggregateRawResult(allCalls);
1810
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1244
+ // Merged count summary (see patchToolCardResult); failures keep
1245
+ // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
1246
+ const displayDetail = errors > 0
1247
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1248
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1811
1249
  patchItem(aggregate.itemId, {
1812
1250
  result: displayDetail,
1813
1251
  text: displayDetail,
@@ -1824,13 +1262,11 @@ export async function createEngineSession({
1824
1262
  completeAggregateVisual();
1825
1263
  finalizeToolHeaders();
1826
1264
  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;
1265
+ // Seal the block: same-bucket calls after this point must open a fresh
1266
+ // card, never continue one from before the seal (assistant text/turn
1267
+ // end boundary). Cross-block behavior is unchanged by the in-block
1268
+ // reuse relaxation below.
1269
+ aggregateByBucket.clear();
1834
1270
  };
1835
1271
 
1836
1272
  const rememberActiveAggregate = (aggregate) => {
@@ -1840,24 +1276,19 @@ export async function createEngineSession({
1840
1276
  };
1841
1277
 
1842
1278
  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;
1279
+ // Reuse any same-bucket aggregate created earlier in the SAME consecutive
1280
+ // tool block, even when a different-category card was interleaved after
1281
+ // it (Read, Search, Read the two Reads merge into one card; the Search
1282
+ // stays separate). The block is only sealed forcing a fresh card per
1283
+ // bucket by clearAggregateContinuation (assistant text lands or the
1284
+ // turn ends), which clears aggregateByBucket. Reusing a pushed, non-tail
1285
+ // card is safe: the aggregate card body is a fixed header+1-detail-row
1286
+ // height, so patching its count/completedCount later never reflows the
1287
+ // cards that were pushed after it.
1288
+ const cached = aggregateByBucket.get(bucket);
1289
+ if (cached) {
1290
+ rememberActiveAggregate(cached);
1291
+ return cached;
1861
1292
  }
1862
1293
  const itemId = nextId();
1863
1294
  const aggregate = {
@@ -1874,7 +1305,6 @@ export async function createEngineSession({
1874
1305
  // pendingSpec current until the timer/result flushes it in call order.
1875
1306
  registerDeferredAggregate(aggregate);
1876
1307
  rememberActiveAggregate(aggregate);
1877
- openAggregateCard = aggregate;
1878
1308
  return aggregate;
1879
1309
  };
1880
1310
 
@@ -1973,6 +1403,17 @@ export async function createEngineSession({
1973
1403
  let _pendingThinkFlush = false; // true when a thinking update is queued
1974
1404
  let _pendingThinkingLastEndedAt = 0;
1975
1405
  let compactingActive = false;
1406
+ // Engine-local streaming scalars. Neither responseLength nor thinkingText is
1407
+ // rendered per-token by any consumer: App reads state.thinking only as a
1408
+ // boolean (App.jsx `!!(state.thinking || liveSpinner?.thinking)`) and the
1409
+ // Spinner takes outputTokens, not responseLength. So we keep these growing
1410
+ // values in engine-local vars and publish to the store only on a visible
1411
+ // transition (thinking on↔off), a completed visible text line, tool/usage
1412
+ // updates, or finalization — not on every 8ms streaming flush.
1413
+ let _publishedThinkingActive = false; // last thinking boolean pushed to store
1414
+ // responseLength is only consumed at finalize as an outputTokens fallback
1415
+ // (Math.round(responseLength/4)); we refresh state.spinner.responseLength on
1416
+ // visible-line flush and finalize so that fallback stays valid.
1976
1417
 
1977
1418
  const flushStreamBatch = () => {
1978
1419
  if (_batchTimer !== null) {
@@ -2017,24 +1458,51 @@ export async function createEngineSession({
2017
1458
  }
2018
1459
  }
2019
1460
  }
1461
+ // Only touch the spinner when there is a real reason: a visible-line
1462
+ // change (patch.items set above), a thinking→responding transition, or a
1463
+ // pending thinking end timestamp. Refresh responseLength here so the
1464
+ // finalize outputTokens fallback stays valid without a per-token push.
2020
1465
  const responseLengthVal = assistantText.length + thinkingText.length;
2021
- if (state.spinner) {
1466
+ const visibleLineChanged = patch.items !== undefined;
1467
+ const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
1468
+ if (state.spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
2022
1469
  patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || state.spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
1470
+ _publishedThinkingActive = false;
2023
1471
  }
2024
1472
  if (Object.keys(patch).length > 0) set(patch);
2025
1473
  _pendingThinkingLastEndedAt = 0;
2026
1474
  }
2027
1475
  if (_pendingThinkFlush) {
2028
1476
  _pendingThinkFlush = false;
2029
- const responseLengthVal = assistantText.length + thinkingText.length;
2030
- const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
2031
- const patch = { thinking: compactingActive ? null : thinkingText };
2032
- if (state.spinner) {
2033
- patch.spinner = compactingActive
2034
- ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
2035
- : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1477
+ // App only consumes state.thinking as a boolean and the Spinner only
1478
+ // reads the thinking flag + timing anchors none of them render the
1479
+ // growing thinkingText. So publish the thinking boolean only on the
1480
+ // OFF→ON transition (or when compacting toggles the flag), not on every
1481
+ // 8ms reasoning chunk. The full thinkingText stays engine-local and is
1482
+ // emitted at finalize via the normal spinner/thinking teardown.
1483
+ const nextThinkingActive = !compactingActive;
1484
+ // Skip the push when the published thinking boolean is unchanged: neither
1485
+ // the growing thinkingText nor responseLength is rendered per-token, and
1486
+ // the Spinner derives its live elapsed from the (already-published)
1487
+ // thinkingSegmentStartedAt anchor. Applies to both thinking and
1488
+ // compacting steady state.
1489
+ if (nextThinkingActive === _publishedThinkingActive) {
1490
+ // no-op: boolean unchanged
1491
+ } else {
1492
+ const responseLengthVal = assistantText.length + thinkingText.length;
1493
+ const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
1494
+ // state.thinking stays a truthy sentinel while active; consumers read it
1495
+ // as a boolean. Keep the value stable (thinkingText) so a late consumer
1496
+ // still sees real text, but only push on transition.
1497
+ const patch = { thinking: compactingActive ? null : thinkingText };
1498
+ if (state.spinner) {
1499
+ patch.spinner = compactingActive
1500
+ ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
1501
+ : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1502
+ }
1503
+ set(patch);
1504
+ _publishedThinkingActive = nextThinkingActive;
2036
1505
  }
2037
- set(patch);
2038
1506
  }
2039
1507
  };
2040
1508
 
@@ -2049,6 +1517,8 @@ export async function createEngineSession({
2049
1517
  const markToolCardCompletedState = (callId, message) => {
2050
1518
  const card = cardByCallId.get(callId);
2051
1519
  if (!card) return;
1520
+ // Early completion also clears the active-summary entry.
1521
+ markToolCallDone(card.callId);
2052
1522
  const aggregate = card.aggregate;
2053
1523
  if (aggregate && card.itemId === aggregate.itemId) {
2054
1524
  const callRec = aggregate.calls.get(callId);
@@ -2065,19 +1535,13 @@ export async function createEngineSession({
2065
1535
  const allCalls = [...aggregate.calls.values()];
2066
1536
  const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
2067
1537
  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
- }
1538
+ const succeeded = completedCount - errors;
2079
1539
  const rawResult = aggregateRawResult(allCalls);
2080
- const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1540
+ // Status-only collapsed detail (no per-result summary); failures keep
1541
+ // 'N Failed'. Raw preserved for ctrl+o expansion.
1542
+ const displayDetail = errors > 0
1543
+ ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1544
+ : '';
2081
1545
  const currentItem = state.items.find((it) => it.id === card.itemId);
2082
1546
  const visualCompleted = Math.max(
2083
1547
  completedCount,
@@ -2127,7 +1591,11 @@ export async function createEngineSession({
2127
1591
  assistantText = '';
2128
1592
  const value = String(text || '').trim();
2129
1593
  if (value) {
2130
- finalizeToolHeaders();
1594
+ // Any non-tool transcript item is a block boundary: seal the
1595
+ // aggregate continuation (not just finalize headers) so a later
1596
+ // same-category tool call opens a fresh card instead of reusing
1597
+ // one whose count would then change ABOVE this steered user item.
1598
+ clearAggregateContinuation();
2131
1599
  pushUserOrSyntheticItem(value);
2132
1600
  }
2133
1601
  },
@@ -2141,26 +1609,41 @@ export async function createEngineSession({
2141
1609
  if (thinkingText && state.thinking) {
2142
1610
  const thinkingLastEndedAt = closeThinkingSegment();
2143
1611
  set({ thinking: null, spinner: state.spinner ? { ...state.spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : state.spinner });
1612
+ _publishedThinkingActive = false;
2144
1613
  } else if (state.spinner) {
2145
1614
  set({ spinner: { ...state.spinner, mode: 'tool-use' } });
2146
1615
  }
2147
1616
  const batchCalls = (calls || []).filter(Boolean);
2148
1617
  if (batchCalls.length === 0) return;
2149
- commitAssistantSegment({ sealToolBlock: true });
1618
+ const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
1619
+ if (committedAssistantSegment) {
1620
+ // Let the pre-tool assistant preamble paint as its own frame before
1621
+ // the tool card reserves/pushes rows. When both enter the transcript
1622
+ // in the same render, the bottom-pinned viewport can appear to jump
1623
+ // upward by the combined height ("preamble + tool card" at once).
1624
+ await yieldToRenderer();
1625
+ }
2150
1626
 
2151
1627
  const touchedAggregates = new Set();
2152
1628
  for (let i = 0; i < batchCalls.length; i++) {
2153
1629
  const c = batchCalls[i];
2154
1630
  const name = toolCallName(c);
2155
1631
  const args = toolCallArgs(c);
1632
+ // Category drives the aggregate bucket so only same-category calls
1633
+ // merge into one card; classify first, then bucket by it.
2156
1634
  const category = classifyToolCategory(name, args);
2157
1635
  const bucket = aggregateBucketForCategory(category);
2158
- const categoryEntry = aggregateToolCategoryEntry(name, args, category);
2159
1636
  const callId = toolCallId(c);
2160
1637
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
1638
+ // The old App scan counted multi-pattern calls via
1639
+ // aggregateToolCategoryEntry(...).count, not a flat 1. Derive the same
1640
+ // count here so the incremental Explore/Search summary matches.
1641
+ const activeCount = Number(aggregateToolCategoryEntry(name, args, category)?.count || 1);
1642
+ // Track Explore/Search calls as active for the incremental prompt-
1643
+ // line summary; cleared when their result lands or the turn ends.
1644
+ markToolCallActive(callKey, category, activeCount, Date.now());
2161
1645
 
2162
1646
  if (!bucket) {
2163
- openAggregateCard = null;
2164
1647
  const itemId = nextId();
2165
1648
  // Defer the visible push: hold the spec and only enter the
2166
1649
  // transcript when the real header/detail will paint (delay
@@ -2193,6 +1676,7 @@ export async function createEngineSession({
2193
1676
  continue;
2194
1677
  }
2195
1678
 
1679
+ const categoryEntry = aggregateToolCategoryEntry(name, args, category);
2196
1680
  const aggregateCard = ensureAggregateCard(bucket);
2197
1681
  if (!aggregateCard.categories.has(categoryEntry.key)) aggregateCard.categoryOrder.push(categoryEntry.key);
2198
1682
  const prevCategory = aggregateCard.categories.get(categoryEntry.key);
@@ -2212,6 +1696,17 @@ export async function createEngineSession({
2212
1696
  for (const aggregateCard of touchedAggregates) {
2213
1697
  syncAggregateHeader(aggregateCard);
2214
1698
  }
1699
+ if (committedAssistantSegment) {
1700
+ // A pre-tool assistant preamble has already had one render frame to
1701
+ // settle. Do not let the first grouped tool card sit off-screen until
1702
+ // the normal 1s deferred timer: when it later inserts its real 3 rows,
1703
+ // the already-wrapped preamble visibly jumps. Surface the first card
1704
+ // now via the existing deferredDisplayReady path, so the post-
1705
+ // preamble frame contains the intended Running tool card immediately
1706
+ // (no blank placeholder, no delayed row insertion).
1707
+ const firstTouchedAggregate = [...touchedAggregates][0] || null;
1708
+ firstTouchedAggregate?.ensureVisible?.();
1709
+ }
2215
1710
  for (const [bufferedCallId, bufferedMessage] of earlyResultBuffer) {
2216
1711
  if (!cardByCallId.has(bufferedCallId)) continue;
2217
1712
  deliverToolResultMessage(bufferedMessage);
@@ -2235,6 +1730,11 @@ export async function createEngineSession({
2235
1730
  },
2236
1731
  onCompactEvent: (event) => {
2237
1732
  flushStreamBatch();
1733
+ // Non-tool transcript item — same block-boundary rule as the
1734
+ // steered user item above: seal any live aggregate first so a
1735
+ // later same-category tool call doesn't reuse a card whose count
1736
+ // would then change above this statusdone item.
1737
+ clearAggregateContinuation();
2238
1738
  pushItem({
2239
1739
  kind: 'statusdone',
2240
1740
  id: nextId(),
@@ -2249,6 +1749,7 @@ export async function createEngineSession({
2249
1749
  compactingActive = true;
2250
1750
  const thinkingLastEndedAt = closeThinkingSegment();
2251
1751
  _pendingThinkFlush = false;
1752
+ _publishedThinkingActive = false; // compacting cleared the thinking flag
2252
1753
  set({
2253
1754
  thinking: null,
2254
1755
  spinner: {
@@ -2276,7 +1777,11 @@ export async function createEngineSession({
2276
1777
  if (!textChunk) return;
2277
1778
  markPromptCommitted();
2278
1779
  const thinkingLastEndedAt = closeThinkingSegment();
2279
- if (state.thinking) set({ thinking: null }); // collapse thinking panel immediately, no batch delay
1780
+ // Drop any queued think-flush too: it would otherwise re-publish
1781
+ // spinner.thinking:true from flushStreamBatch and resurrect the
1782
+ // indicator after we cleared it here.
1783
+ _pendingThinkFlush = false;
1784
+ if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; } // collapse thinking panel immediately, no batch delay
2280
1785
  assistantText += textChunk;
2281
1786
  currentAssistantText += textChunk;
2282
1787
  // Accumulate text and schedule a batched flush (≤1 render per
@@ -2305,7 +1810,8 @@ export async function createEngineSession({
2305
1810
  if (currentAssistantText.trim()) return;
2306
1811
  markPromptCommitted();
2307
1812
  closeThinkingSegment();
2308
- if (state.thinking) set({ thinking: null });
1813
+ _pendingThinkFlush = false; // see onTextDelta: prevent a stale think flush resurrecting the indicator
1814
+ if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; }
2309
1815
  assistantText += full;
2310
1816
  currentAssistantText += full;
2311
1817
  _pendingTextFlush = true;
@@ -2367,7 +1873,7 @@ export async function createEngineSession({
2367
1873
  // shows "cancelled", but in-flight tool cards remain in a perpetual
2368
1874
  // pending/blinking state because the normal finalize path (line 992)
2369
1875
  // was skipped when the error interrupted the try block.
2370
- flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
1876
+ flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
2371
1877
  finalizeToolHeaders();
2372
1878
  } else {
2373
1879
  finalizeToolHeaders();
@@ -2384,13 +1890,22 @@ export async function createEngineSession({
2384
1890
  if (last) flushDeferredUpTo(last);
2385
1891
  clearDeferredTimers();
2386
1892
  }
1893
+ flushDeferredBeforeImmediatePush = null;
2387
1894
  const producedTranscriptItem = state.items.length > itemsAtTurnStart;
2388
1895
  const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
2389
1896
  activePromptRestore = null;
2390
1897
  closeThinkingSegment();
2391
1898
  const elapsedMs = Date.now() - startedAt;
2392
1899
  const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
2393
- const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(Number(state.spinner?.responseLength || 0) / 4));
1900
+ // responseLength is engine-local now (not pushed per-token), so compute the
1901
+ // fallback from the live accumulator instead of the possibly-stale
1902
+ // state.spinner.responseLength. Final-only / non-streaming turns never
1903
+ // accumulate `assistantText` (only currentAssistantText is set at the
1904
+ // finalize reconcile above), so take the larger of the two text sources so
1905
+ // a no-usage turn still estimates tokens from the final content.
1906
+ const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1907
+ const finalResponseLength = finalAssistantLen + thinkingText.length;
1908
+ const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
2394
1909
  const turnStatus = cancelled ? 'cancelled' : 'done';
2395
1910
  const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
2396
1911
  const assistantOutput = (currentAssistantText || assistantText || '').trim();
@@ -2423,6 +1938,8 @@ export async function createEngineSession({
2423
1938
  });
2424
1939
  flushDeferredExecutionPendingResumeKick();
2425
1940
  }
1941
+ clearActiveToolSummary();
1942
+ _publishedThinkingActive = false; // turn teardown cleared state.thinking
2426
1943
  return cancelled ? 'cancelled' : 'done';
2427
1944
  }
2428
1945
 
@@ -2439,6 +1956,7 @@ export async function createEngineSession({
2439
1956
  text: displayText,
2440
1957
  content: text,
2441
1958
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
1959
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
2442
1960
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
2443
1961
  mode,
2444
1962
  priority,
@@ -2452,6 +1970,7 @@ export async function createEngineSession({
2452
1970
  const ids = new Set(entries.map((entry) => entry.id));
2453
1971
  const keys = entries.map((entry) => entry.key).filter(Boolean);
2454
1972
  for (const key of keys) pendingNotificationKeys.delete(key);
1973
+ for (const key of keys) displayedExecutionNotificationKeys.delete(key);
2455
1974
  const queued = state.queued.filter((q) => !ids.has(q.id));
2456
1975
  if (queued.length !== state.queued.length) set({ queued });
2457
1976
  }
@@ -2481,6 +2000,7 @@ export async function createEngineSession({
2481
2000
  if (pending.length === 0) return [];
2482
2001
  const max = queuePriorityValue(maxPriority);
2483
2002
  const predicate = typeof options.predicate === 'function' ? options.predicate : () => true;
2003
+ const limit = Math.max(1, Number(options.limit) || Infinity);
2484
2004
  let bestPriority = Infinity;
2485
2005
  let targetMode = null;
2486
2006
  for (const entry of pending) {
@@ -2499,6 +2019,7 @@ export async function createEngineSession({
2499
2019
  if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
2500
2020
  batch.push(entry);
2501
2021
  pending.splice(i, 1);
2022
+ if (batch.length >= limit) break;
2502
2023
  } else {
2503
2024
  i += 1;
2504
2025
  }
@@ -2509,13 +2030,16 @@ export async function createEngineSession({
2509
2030
 
2510
2031
  async function drain() {
2511
2032
  if (draining) return;
2033
+ if (autoClearRunning) return;
2512
2034
  draining = true;
2035
+ let firstBatch = true;
2513
2036
  try {
2514
2037
  while (pending.length > 0) {
2515
2038
  // Drain one priority/mode bucket at a time (unified command queue):
2516
2039
  // unified command queue semantics: prompt steering stays editable and
2517
2040
  // task notifications stay non-editable but model-visible.
2518
- const batch = dequeueQueueBatch('later');
2041
+ const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
2042
+ firstBatch = false;
2519
2043
  if (batch.length === 0) break;
2520
2044
  const ids = new Set(batch.map((e) => e.id));
2521
2045
  const merged = mergePromptContents(batch);
@@ -2525,9 +2049,11 @@ export async function createEngineSession({
2525
2049
  }
2526
2050
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
2527
2051
  const batchPastedImages = mergePastedImages(batch);
2052
+ const batchPastedTexts = mergePastedTexts(batch);
2528
2053
  const turnStatus = await runTurn(merged, {
2529
2054
  displayText: batch.map((entry) => entry.text).filter((text) => String(text || '').trim()).join('\n'),
2530
2055
  pastedImages: batchPastedImages,
2056
+ pastedTexts: batchPastedTexts,
2531
2057
  onCommitted: () => callCommitCallbacks(batch),
2532
2058
  submittedIds: [...ids],
2533
2059
  restorable: nonEditable.length === 0,
@@ -2559,9 +2085,12 @@ export async function createEngineSession({
2559
2085
 
2560
2086
  function drainPendingSteering() {
2561
2087
  // 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.
2088
+ // Injects queued user prompts (steering) plus non-editable internal entries
2089
+ // into the CURRENT provider pre-send window so the user can redirect a turn
2090
+ // that is already running. Slash commands are still excluded: they must run
2091
+ // through the normal command processor after the turn, not be sent as plain
2092
+ // text. Consumed entries are spliced out of `pending` here, so the post-turn
2093
+ // drain() loop will not re-execute them.
2565
2094
  const batch = dequeueQueueBatch('next', { predicate: (entry) => !isSlashQueuedEntry(entry) });
2566
2095
  if (batch.length === 0) return [];
2567
2096
  const out = batch
@@ -2592,7 +2121,14 @@ export async function createEngineSession({
2592
2121
  const startedAt = Date.now();
2593
2122
  set({ commandStatus: { active: true, verb: 'Auto-clearing idle conversation', startedAt, mode: 'auto-clear' } });
2594
2123
  try {
2595
- await runtime.clear({ compactType: cfg.compactType || null, requireCompactSuccess: !!cfg.compactType });
2124
+ // Give Ink one event-loop turn to paint the auto-clear status before the
2125
+ // clear/compact path starts doing synchronous session/transcript work.
2126
+ // Without this, long idle clears can look like a frozen prompt followed by
2127
+ // an already-complete status row.
2128
+ await new Promise((resolve) => setTimeout(resolve, 0));
2129
+ const compaction = runtime.getCompactionSettings();
2130
+ const compactType = compaction.compactType || compaction.type;
2131
+ await runtime.clear({ compactType, requireCompactSuccess: !!compactType });
2596
2132
  resetStats();
2597
2133
  clearUiActivityBeforeContextSync();
2598
2134
  syncContextStats({ allowEstimated: true });
@@ -2627,6 +2163,7 @@ export async function createEngineSession({
2627
2163
  lastUserActivityAt = Date.now();
2628
2164
  autoClearRunning = false;
2629
2165
  set({ commandStatus: null });
2166
+ void drain();
2630
2167
  }
2631
2168
  }
2632
2169
 
@@ -2644,7 +2181,7 @@ export async function createEngineSession({
2644
2181
  removeQueuedEntries(queued);
2645
2182
  const queuedText = queued.map((item) => item.text).filter((text) => String(text || '').trim()).join('\n');
2646
2183
  const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
2647
- return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued) };
2184
+ return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued), pastedTexts: mergePastedTexts(queued) };
2648
2185
  }
2649
2186
 
2650
2187
  const resetStats = () => {
@@ -2659,6 +2196,8 @@ export async function createEngineSession({
2659
2196
  state.spinner = null;
2660
2197
  state.lastTurn = null;
2661
2198
  state.busy = false;
2199
+ pendingNotificationKeys.clear();
2200
+ displayedExecutionNotificationKeys.clear();
2662
2201
  };
2663
2202
  const resetTuiForPendingSessionReset = () => {
2664
2203
  pendingSessionReset = true;
@@ -2739,6 +2278,10 @@ export async function createEngineSession({
2739
2278
  enqueue(text, queueOptions);
2740
2279
  return true;
2741
2280
  }
2281
+ if (autoClearRunning) {
2282
+ enqueue(text, queueOptions);
2283
+ return true;
2284
+ }
2742
2285
  void autoClearBeforeSubmit().then(() => enqueue(text, queueOptions));
2743
2286
  return true;
2744
2287
  },
@@ -2747,8 +2290,10 @@ export async function createEngineSession({
2747
2290
  if (state.commandBusy) return false;
2748
2291
  set({ commandBusy: true });
2749
2292
  try {
2750
- await runtime.setRoute({ model: m }, { applyToCurrentSession: true });
2751
- syncContextStats({ allowEstimated: true });
2293
+ // Model changes apply to the NEXT session only (default setRoute
2294
+ // behavior) never rewrite the live session's provider/model, which
2295
+ // would force a full prompt-cache rewrite mid-conversation.
2296
+ await runtime.setRoute({ model: m });
2752
2297
  set({ ...routeState(), stats: { ...state.stats } });
2753
2298
  return true;
2754
2299
  } finally {
@@ -2802,6 +2347,11 @@ export async function createEngineSession({
2802
2347
  set({ autoClear: next });
2803
2348
  return next;
2804
2349
  },
2350
+ getUpdateSettings: () => runtime.getUpdateSettings?.() || null,
2351
+ setAutoUpdate: (enabled) => runtime.setAutoUpdate?.(enabled),
2352
+ checkForUpdate: (input = {}) => runtime.checkForUpdate?.(input),
2353
+ runUpdateNow: () => runtime.runUpdateNow?.(),
2354
+ getUpdateStatus: () => runtime.getUpdateStatus?.() || { phase: 'idle' },
2805
2355
  getProfile: () => runtime.getProfile?.() || { title: '', language: 'system', languages: [] },
2806
2356
  setProfile: (input = {}) => {
2807
2357
  const next = runtime.setProfile?.(input) || runtime.getProfile?.() || null;
@@ -2815,8 +2365,15 @@ export async function createEngineSession({
2815
2365
  set({ commandBusy: true });
2816
2366
  try {
2817
2367
  const next = runtime.setCompactionSettings?.(input) || {};
2818
- syncContextStats({ allowEstimated: true });
2819
2368
  set({ ...routeState(), stats: { ...state.stats } });
2369
+ // Context-stats recompute (transcript scan + per-message JSON
2370
+ // stringify) is the secondary hitch source on this toggle; defer it
2371
+ // off the key-handler tick so Ink repaints the setting change first.
2372
+ // Stats become eventually consistent on the next tick/repaint.
2373
+ setTimeout(() => {
2374
+ syncContextStats({ allowEstimated: true });
2375
+ set({ stats: { ...state.stats } });
2376
+ }, 0);
2820
2377
  return next;
2821
2378
  } finally {
2822
2379
  set({ commandBusy: false });
@@ -2830,8 +2387,14 @@ export async function createEngineSession({
2830
2387
  set({ commandBusy: true });
2831
2388
  try {
2832
2389
  const next = await runtime.setMemoryEnabled?.(enabled);
2833
- syncContextStats({ allowEstimated: true });
2834
2390
  set({ ...routeState(), stats: { ...state.stats } });
2391
+ // Deferred for the same reason as setCompactionSettings above: keep
2392
+ // the recompute off the key-handler tick so the toggle repaints
2393
+ // immediately; stats catch up right after.
2394
+ setTimeout(() => {
2395
+ syncContextStats({ allowEstimated: true });
2396
+ set({ stats: { ...state.stats } });
2397
+ }, 0);
2835
2398
  return next;
2836
2399
  } finally {
2837
2400
  set({ commandBusy: false });
@@ -3157,8 +2720,24 @@ export async function createEngineSession({
3157
2720
  if (!state.busy) return false;
3158
2721
  denyAllToolApprovals('interrupted by user');
3159
2722
  const restoreState = activePromptRestore;
3160
- const restoreText = restoreState?.restorable ? restoreState.text : '';
3161
- const restorePastedImages = restoreState?.restorable && restoreState?.pastedImages ? restoreState.pastedImages : null;
2723
+ // A queued steering prompt means the user already redirected the turn:
2724
+ // interrupting should just cancel the running turn and let the steering
2725
+ // prompt run next, NOT resurrect the in-flight prompt back into the draft.
2726
+ const hasPendingSteering = pending.some((entry) => isQueuedEntryEditable(entry));
2727
+ const canRestore = restoreState?.restorable && !hasPendingSteering;
2728
+ const restoreText = canRestore ? restoreState.text : '';
2729
+ const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
2730
+ const restorePastedTexts = canRestore && restoreState?.pastedTexts ? restoreState.pastedTexts : null;
2731
+ // When steering suppresses the restore, the interrupted prompt's pasted
2732
+ // images never get committed (onCommitted won't fire) nor re-installed into
2733
+ // the draft, so hand them back for cleanup to avoid a stale `[Image #id]`
2734
+ // lingering in the paste snapshot.
2735
+ const discardPastedImages = restoreState?.restorable && hasPendingSteering && restoreState?.pastedImages
2736
+ ? restoreState.pastedImages
2737
+ : null;
2738
+ const discardPastedTexts = restoreState?.restorable && hasPendingSteering && restoreState?.pastedTexts
2739
+ ? restoreState.pastedTexts
2740
+ : null;
3162
2741
  const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
3163
2742
  ? restoreState.requeueEntries.slice()
3164
2743
  : [];
@@ -3180,7 +2759,7 @@ export async function createEngineSession({
3180
2759
  restoreState.restorable = false;
3181
2760
  restoreState.requeueEntries = [];
3182
2761
  }
3183
- return { aborted, restoreText, pastedImages: restorePastedImages };
2762
+ return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
3184
2763
  },
3185
2764
  resolveToolApproval: (id, decision = {}) => {
3186
2765
  const approved = decision === true || decision?.approved === true;
@@ -3242,8 +2821,14 @@ export async function createEngineSession({
3242
2821
  set({ commandBusy: true });
3243
2822
  try {
3244
2823
  const result = await runtime.setOutputStyle?.(styleId);
3245
- resetStatsAndSyncContext();
2824
+ resetStats();
3246
2825
  set({ ...routeState(), stats: { ...state.stats } });
2826
+ // Defer the context recompute (transcript scan) off this tick so
2827
+ // the style change repaints immediately; stats settle right after.
2828
+ setTimeout(() => {
2829
+ syncContextStats({ allowEstimated: true });
2830
+ set({ stats: { ...state.stats } });
2831
+ }, 0);
3247
2832
  return result;
3248
2833
  } finally {
3249
2834
  set({ commandBusy: false });
@@ -3260,6 +2845,18 @@ export async function createEngineSession({
3260
2845
  set({ commandBusy: false });
3261
2846
  }
3262
2847
  },
2848
+ // Toggle Discord remote mode for this session. Flips the runtime's
2849
+ // remoteEnabled flag (booting/stopping the channel worker) and returns the
2850
+ // NEW enabled state so the caller can render an ON/OFF notice.
2851
+ toggleRemote: () => {
2852
+ const enabled = runtime.isRemoteEnabled?.() === true;
2853
+ if (enabled) runtime.stopRemote?.();
2854
+ else runtime.startRemote?.();
2855
+ const next = runtime.isRemoteEnabled?.() === true;
2856
+ set({ remoteEnabled: next });
2857
+ return next;
2858
+ },
2859
+ isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
3263
2860
  // Theme is a TUI-local concern (no runtime round-trip). listThemes returns
3264
2861
  // picker metadata; getTheme reports the active id; setTheme applies the
3265
2862
  // palette in-place + persists ui.theme and bumps a themeEpoch so the React
@@ -3274,6 +2871,9 @@ export async function createEngineSession({
3274
2871
  setAgentRoute: async (agentId, opts) => {
3275
2872
  return await runtime.setAgentRoute?.(agentId, opts);
3276
2873
  },
2874
+ setDefaultProvider: async (provider) => {
2875
+ return await runtime.setDefaultProvider?.(provider);
2876
+ },
3277
2877
  listProviders: () => {
3278
2878
  return runtime.listProviders();
3279
2879
  },
@@ -3286,6 +2886,10 @@ export async function createEngineSession({
3286
2886
  getOnboardingStatus: () => {
3287
2887
  return runtime.getOnboardingStatus?.() || { completed: true, workflowRoutes: {} };
3288
2888
  },
2889
+ skipOnboarding: () => {
2890
+ // Completed-marking only; no route/agent/provider writes.
2891
+ return runtime.skipOnboarding?.() || null;
2892
+ },
3289
2893
  completeOnboarding: async (payload = {}) => {
3290
2894
  if (state.commandBusy) return null;
3291
2895
  set({ commandBusy: true });
@@ -3363,6 +2967,8 @@ export async function createEngineSession({
3363
2967
  getChannelSetup: () => {
3364
2968
  return runtime.getChannelSetup();
3365
2969
  },
2970
+ getChannelWorkerStatus: () => runtime.getChannelWorkerStatus?.(),
2971
+ setBackend: (name) => runtime.setBackend?.(name),
3366
2972
  saveDiscordToken: (token) => {
3367
2973
  const result = runtime.saveDiscordToken(token);
3368
2974
  pushNotice('discord token saved', 'info');
@@ -3373,6 +2979,16 @@ export async function createEngineSession({
3373
2979
  pushNotice('discord token forgotten', 'info');
3374
2980
  return result;
3375
2981
  },
2982
+ saveTelegramToken: (token) => {
2983
+ const result = runtime.saveTelegramToken?.(token);
2984
+ pushNotice('telegram token saved', 'info');
2985
+ return result;
2986
+ },
2987
+ forgetTelegramToken: () => {
2988
+ const result = runtime.forgetTelegramToken?.();
2989
+ pushNotice('telegram token forgotten', 'info');
2990
+ return result;
2991
+ },
3376
2992
  saveWebhookAuthtoken: (token) => {
3377
2993
  const result = runtime.saveWebhookAuthtoken(token);
3378
2994
  pushNotice('webhook/ngrok authtoken saved', 'info');
@@ -3433,7 +3049,9 @@ export async function createEngineSession({
3433
3049
  set({ commandBusy: true });
3434
3050
  try {
3435
3051
  const routeOpts = opts && typeof opts === 'object' ? opts : {};
3436
- const applyToCurrentSession = routeOpts.applyToCurrentSession === false ? false : true;
3052
+ // Default: apply to the NEXT session only. Only an explicit
3053
+ // `applyToCurrentSession: true` rewrites the live session in place.
3054
+ const applyToCurrentSession = routeOpts.applyToCurrentSession === true;
3437
3055
  const { applyToCurrentSession: _drop, ...nextRoute } = routeOpts;
3438
3056
  await runtime.setRoute(nextRoute, { applyToCurrentSession });
3439
3057
  if (applyToCurrentSession) syncContextStats({ allowEstimated: true });
@@ -3444,10 +3062,12 @@ export async function createEngineSession({
3444
3062
  }
3445
3063
  },
3446
3064
  pushNotice,
3065
+ setProgressHint,
3447
3066
  clear: async () => {
3448
3067
  if (state.commandBusy) return false;
3449
3068
  set({ commandBusy: true });
3450
3069
  clearToastTimers();
3070
+ resetAllStreamingMarkdownStablePrefixes();
3451
3071
  const rollbackSnapshot = snapshotTuiBeforeSessionReset();
3452
3072
  resetTuiForPendingSessionReset();
3453
3073
  set({
@@ -3483,6 +3103,7 @@ export async function createEngineSession({
3483
3103
  if (state.commandBusy) return false;
3484
3104
  set({ commandBusy: true });
3485
3105
  clearToastTimers();
3106
+ resetAllStreamingMarkdownStablePrefixes();
3486
3107
  const rollbackSnapshot = snapshotTuiBeforeSessionReset();
3487
3108
  resetTuiForPendingSessionReset();
3488
3109
  set({