mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -8,11 +8,17 @@
8
8
  import { performance } from 'node:perf_hooks';
9
9
  import { SPINNER_VERBS } from './spinner-verbs.mjs';
10
10
  import {
11
+ aggregateToolCategoryEntry,
11
12
  classifyToolCategory,
12
13
  formatAggregateDetail,
13
14
  summarizeToolResult,
14
15
  } from '../runtime/shared/tool-surface.mjs';
15
- import { presentErrorText } from '../runtime/shared/err-text.mjs';
16
+ import { isBackgroundErrorOnlyBody, presentErrorText } from '../runtime/shared/err-text.mjs';
17
+ import {
18
+ isModelVisibleToolCompletionWrapper,
19
+ modelVisibleToolCompletionMessage,
20
+ } from '../runtime/shared/tool-execution-contract.mjs';
21
+ import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
16
22
 
17
23
  const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
18
24
  const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
@@ -75,6 +81,11 @@ const SESSION_RUNTIME_MODULE = import.meta.url.replace(/\\/g, '/').includes('/tu
75
81
  ? '../../mixdog-session-runtime.mjs'
76
82
  : '../mixdog-session-runtime.mjs';
77
83
 
84
+ const TOOL_APPROVAL_TIMEOUT_MS = (() => {
85
+ const value = Number(process.env.MIXDOG_TOOL_APPROVAL_TIMEOUT_MS);
86
+ return Number.isFinite(value) && value > 0 ? Math.max(1000, Math.round(value)) : 120_000;
87
+ })();
88
+
78
89
  let _idSeq = 0;
79
90
  const nextId = () => `it_${++_idSeq}`;
80
91
 
@@ -97,21 +108,47 @@ function pickDoneVerb(turn) {
97
108
  return TURN_DONE_VERBS[(turn * 5 + 2) % TURN_DONE_VERBS.length];
98
109
  }
99
110
 
100
- function formatIdleDuration(ms) {
111
+ function formatElapsedSeconds(ms) {
101
112
  const value = Math.max(0, Number(ms) || 0);
102
- if (value >= 3_600_000 && value % 3_600_000 === 0) return `${value / 3_600_000}h`;
103
- if (value >= 60_000) return `${Math.round(value / 60_000)}m`;
104
- if (value < 1_000) return '';
105
- return `${Math.floor(value / 1000)}s`;
113
+ if (value <= 0) return '0s';
114
+ return `${Math.max(1, Math.ceil(value / 1000))}s`;
106
115
  }
107
116
 
108
- function formatTokenCount(value) {
109
- const n = Number(value || 0);
110
- if (!Number.isFinite(n) || n <= 0) return '0';
111
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
112
- if (n >= 10_000) return `${Math.round(n / 1000)}k`;
113
- if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
114
- return `${Math.round(n)}`;
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)';
115
152
  }
116
153
 
117
154
  const FAILED_NOTICE_ACTIONS = new Map([
@@ -119,7 +156,7 @@ const FAILED_NOTICE_ACTIONS = new Map([
119
156
  ['auth-forget', 'forget auth'],
120
157
  ['auto-clear', 'update auto-clear'],
121
158
  ['autoclear', 'update auto-clear'],
122
- ['bridge', 'run bridge command'],
159
+ ['agent', 'run agent command'],
123
160
  ['channels', 'load channels'],
124
161
  ['channels update', 'update channels'],
125
162
  ['clear', 'clear chat'],
@@ -226,23 +263,110 @@ function polishNoticeText(text) {
226
263
  function toolResultText(content) {
227
264
  if (content == null) return '';
228
265
  if (typeof content === 'string') return content;
229
- const parts = Array.isArray(content)
230
- ? content
231
- : (content && typeof content === 'object' && Array.isArray(content.content) ? content.content : null);
232
- if (parts) {
233
- return parts.map((c) => {
234
- if (typeof c === 'string') return c;
235
- if (c?.type === 'image') return `[image: ${c.mimeType || c.mediaType || c.source?.media_type || 'image'}]`;
236
- return c?.text ?? '';
237
- }).filter(Boolean).join('\n');
238
- }
239
266
  if (Array.isArray(content)) {
240
- return content.map((c) => (typeof c === 'string' ? c : c?.text ?? '')).filter(Boolean).join('\n');
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;
241
286
  }
242
- if (typeof content === 'object' && typeof content.text === 'string') return content.text;
243
287
  try { return JSON.stringify(content); } catch { return String(content); }
244
288
  }
245
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
+
246
370
  function toolErrorDisplay(value, surface = 'tool') {
247
371
  const text = presentErrorText(value, { surface });
248
372
  if (/^(?:Search failed|Fetch failed|No first response|The .+ went stale|(?:Web search agent|Agent|Tool) (?:stopped|was cancelled))/i.test(text)) {
@@ -284,7 +408,7 @@ function stripSyntheticAgentTags(text) {
284
408
  const taskResult = textBetweenTag(value, 'result');
285
409
  if (taskResult) return taskResult;
286
410
  return value
287
- .replace(/^bridge result[^\n]*(?:\n|$)/i, '')
411
+ .replace(/^agent result[^\n]*(?:\n|$)/i, '')
288
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, '')
289
413
  .trim();
290
414
  }
@@ -300,7 +424,7 @@ function splitBridgeEnvelope(text) {
300
424
  };
301
425
  }
302
426
 
303
- function bridgeJobStatusText(parsed) {
427
+ function agentJobStatusText(parsed) {
304
428
  if (!parsed) return '';
305
429
  const parts = [];
306
430
  if (parsed.status) parts.push(`status: ${parsed.status}`);
@@ -308,19 +432,55 @@ function bridgeJobStatusText(parsed) {
308
432
  return parts.join(' · ');
309
433
  }
310
434
 
311
- function bridgeJobResultText(text, parsed = parseBridgeJob(text)) {
435
+ function agentJobResultText(text, parsed = parseAgentJob(text)) {
312
436
  const value = String(text ?? '').trim();
313
437
  if (!value) return '';
314
438
  if (parsed?.taskId) {
315
439
  const { body } = splitBridgeEnvelope(value);
316
440
  const cleanBody = stripSyntheticAgentTags(body);
317
441
  if (cleanBody) return cleanBody;
318
- return bridgeJobStatusText(parsed);
442
+ return agentJobStatusText(parsed);
319
443
  }
320
444
  return stripSyntheticAgentTags(value) || value;
321
445
  }
322
446
 
323
- function parseBackgroundTaskEnvelope(text) {
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
+
481
+ export { toolResultText, toolAggregateDetailFallback, toolGroupedDisplayFallback };
482
+
483
+ export function parseBackgroundTaskEnvelope(text) {
324
484
  const value = String(text ?? '').trim();
325
485
  if (!/^background task\b/i.test(value)) return null;
326
486
  const allLines = value.split('\n');
@@ -334,9 +494,25 @@ function parseBackgroundTaskEnvelope(text) {
334
494
  if (match) fields[match[1].toLowerCase()] = match[2].trim();
335
495
  }
336
496
  const surface = String(fields.surface || fields.operation || 'task').toLowerCase();
337
- const name = surface === 'explore' || surface === 'search' || surface === 'shell' ? surface : 'task';
497
+ const name = surface === 'explore' || surface === 'search' || surface === 'shell' || surface === 'agent' ? surface : 'task';
338
498
  const status = String(fields.status || '').toLowerCase();
339
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 : '';
340
516
  return {
341
517
  name,
342
518
  label: status || 'notification',
@@ -345,30 +521,78 @@ function parseBackgroundTaskEnvelope(text) {
345
521
  status,
346
522
  task_id: taskId || undefined,
347
523
  surface,
524
+ operation: fields.operation || undefined,
348
525
  label: fields.label || undefined,
349
- },
350
- result: body || [status ? `status: ${status}` : '', taskId ? `task_id: ${taskId}` : ''].filter(Boolean).join(' · ') || 'background task',
351
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status) || /^error:/i.test(body),
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),
352
541
  };
353
542
  }
354
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
+
355
563
  function bracketField(text, name) {
356
564
  const re = new RegExp(`^\\[${name}:\\s*([^\\]]*)\\]`, 'mi');
357
565
  return re.exec(String(text ?? ''))?.[1]?.trim() || '';
358
566
  }
359
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
+
360
582
  function parseSyntheticAgentMessage(text) {
361
583
  const value = String(text ?? '').trim();
362
584
  if (!value) return null;
363
585
  const finalAnswer = textBetweenTag(value, 'final-answer');
364
586
  if (finalAnswer) {
365
587
  return {
366
- name: 'bridge',
588
+ name: 'agent',
367
589
  label: 'final',
368
590
  args: { type: 'read', description: 'agent result' },
369
591
  result: finalAnswer,
370
592
  };
371
593
  }
594
+ const agentResult = parseAgentResultEnvelope(value);
595
+ if (agentResult) return agentResult;
372
596
  const backgroundTask = parseBackgroundTaskEnvelope(value);
373
597
  if (backgroundTask) return backgroundTask;
374
598
  const shellTaskId = bracketField(value, 'task_id');
@@ -384,15 +608,15 @@ function parseSyntheticAgentMessage(text) {
384
608
  isError: /^(failed|error|timeout|cancelled|killed)$/i.test(status) || (exit && exit !== '0' && exit !== 'n/a'),
385
609
  };
386
610
  }
387
- const bridgeJob = parseBridgeJob(value);
388
- if (bridgeJob?.taskId) {
389
- const label = bridgeJob.status || 'notification';
390
- const result = bridgeJobResultText(value, bridgeJob);
611
+ const agentJob = parseAgentJob(value);
612
+ if (agentJob?.taskId) {
613
+ const label = agentJob.status || 'notification';
614
+ const result = agentJobResultText(value, agentJob);
391
615
  return {
392
- name: 'bridge',
616
+ name: 'agent',
393
617
  label,
394
- args: bridgeArgsWithResultMetadata({ type: bridgeJob.type || 'notification', description: 'agent notification' }, bridgeJob),
395
- result: result || bridgeJobStatusText(bridgeJob) || 'agent notification',
618
+ args: agentArgsWithResultMetadata({ type: agentJob.type || 'notification', description: 'agent notification' }, agentJob),
619
+ result: result || agentJobStatusText(agentJob) || 'agent notification',
396
620
  isError: /^(failed|error|timeout|cancelled|killed)$/i.test(label),
397
621
  };
398
622
  }
@@ -402,7 +626,7 @@ function parseSyntheticAgentMessage(text) {
402
626
  const taskId = textBetweenTag(value, 'task-id');
403
627
  const result = stripSyntheticAgentTags(value);
404
628
  return {
405
- name: 'bridge',
629
+ name: 'agent',
406
630
  label: status,
407
631
  taskId,
408
632
  summary,
@@ -435,9 +659,9 @@ function parseToolArgs(args) {
435
659
 
436
660
  const yieldToRenderer = () => new Promise((resolve) => setImmediate(resolve));
437
661
 
438
- function parseBridgeJob(text) {
662
+ function parseAgentJob(text) {
439
663
  const value = String(text || '');
440
- const idMatch = /^bridge task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
664
+ const idMatch = /^agent task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
441
665
  if (!idMatch) return null;
442
666
  const statusMatch = /^status:\s*([^\s(]+)/m.exec(value);
443
667
  const typeMatch = /^type:\s*(.+)$/m.exec(value);
@@ -468,20 +692,51 @@ function queuePriorityValue(value) {
468
692
  }
469
693
 
470
694
  function defaultQueuePriority(mode) {
471
- return mode === 'task-notification' ? 'next' : 'later';
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';
472
701
  }
473
702
 
474
703
  function isQueuedEntryEditable(entry) {
475
- return (entry?.mode || 'prompt') !== 'task-notification';
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('/');
476
721
  }
477
722
 
478
723
  function firstQueueLine(text) {
479
724
  return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
480
725
  }
481
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
+
482
737
  function notificationDisplayText(text) {
483
- const parsed = parseBridgeJob(text);
484
- const result = bridgeJobResultText(text, parsed);
738
+ const parsed = parseAgentJob(text);
739
+ const result = agentJobResultText(text, parsed);
485
740
  const synthetic = parseSyntheticAgentMessage(text);
486
741
  return firstQueueLine(synthetic?.result || result || text) || 'agent notification';
487
742
  }
@@ -499,6 +754,33 @@ function promptContentText(content) {
499
754
  return String(content ?? '');
500
755
  }
501
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
+
502
784
  function promptDisplayText(content, options = {}) {
503
785
  if (typeof options.displayText === 'string') return options.displayText;
504
786
  return promptContentText(content);
@@ -543,18 +825,76 @@ function callCommitCallbacks(entries) {
543
825
 
544
826
  function notificationQueueKey(event, text, parsed) {
545
827
  const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
828
+ const synthetic = parseSyntheticAgentMessage(text);
829
+ if (synthetic?.name === 'agent' && String(synthetic.args?.type || '').toLowerCase() === 'result') {
830
+ const taskId = String(synthetic.args?.task_id || '').trim();
831
+ const executionId = String(meta.execution_id || '').trim();
832
+ const tag = String(synthetic.args?.tag || '').trim();
833
+ const resultId = taskId
834
+ ? `task:${taskId}`
835
+ : executionId
836
+ ? `exec:${executionId}`
837
+ : tag
838
+ ? `tag:${tag}:${shortTextFingerprint(synthetic.result || text)}`
839
+ : '';
840
+ const role = String(synthetic.args?.agent || synthetic.args?.role || '').trim();
841
+ if (resultId || role) return ['agent-result', resultId, role].filter(Boolean).join(':');
842
+ }
546
843
  const id = String(meta.execution_id || parsed?.taskId || '').trim();
547
844
  if (!id) return '';
548
845
  const type = String(meta.type || '').trim();
549
846
  const status = String(meta.status || parsed?.status || '').trim();
550
847
  const fallbackKind = String(text || '').split('\n', 1)[0]?.trim() || 'notification';
551
- return [id, type || fallbackKind, status].filter(Boolean).join(':');
848
+ // Distinguish a body-carrying completion from a header-only preview that
849
+ // shares the same id/type/status. An early agent preview can arrive before
850
+ // the session is persisted (no result body); the canonical notification that
851
+ // follows DOES carry the body. Without this dimension the bodyless preview
852
+ // would claim the dedupe key and suppress the real result. A blank-line gap
853
+ // separates the task header block from the result body in the envelope.
854
+ const hasBody = /\n\s*\n[\s\S]*\S/.test(String(text || '')) ? 'b1' : 'b0';
855
+ return [id, type || fallbackKind, status, hasBody].filter(Boolean).join(':');
856
+ }
857
+
858
+ /** Pure delivery plan for runtime.onNotification execution envelopes (tests + handler). */
859
+ export function resolveTuiRuntimeNotificationDelivery(event, text) {
860
+ const trimmed = String(text ?? '').trim();
861
+ if (!trimmed) return { action: 'ignore' };
862
+ const parsed = parseAgentJob(trimmed);
863
+ const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
864
+ if (!isExecutionNotification(event, trimmed, parsed)) {
865
+ return { action: 'enqueue', displayText: trimmed, modelContent: trimmed };
866
+ }
867
+ if (isStatusOnlyAgentCompletionNotification(trimmed)) {
868
+ return { action: 'status-only', displayText: trimmed, modelContent: '' };
869
+ }
870
+ const modelContent = modelVisibleToolCompletionMessage(trimmed, meta);
871
+ return {
872
+ action: 'execution-ui',
873
+ displayText: trimmed,
874
+ modelContent,
875
+ };
876
+ }
877
+
878
+ function isExecutionNotification(event, text, parsed) {
879
+ const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
880
+ if (meta.execution_id || meta.execution_surface) return true;
881
+ if (parseAgentResultEnvelope(text)) return true;
882
+ if (parseBackgroundTaskEnvelope(text)) return true;
883
+ return Boolean(parsed?.taskId && /^(?:agent task:|task_id:)/mi.test(String(text || '')));
552
884
  }
553
885
 
554
- function bridgeArgsWithResultMetadata(args, parsed) {
886
+ function agentArgsWithResultMetadata(args, parsed) {
555
887
  if (!parsed) return args;
556
888
  const next = { ...(args && typeof args === 'object' ? args : {}) };
557
- if (parsed.type) next.type = parsed.type;
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
+ }
558
898
  if (parsed.status) next.status = parsed.status;
559
899
  if (parsed.taskId) next.task_id = parsed.taskId;
560
900
  if (parsed.role) next.role = parsed.role;
@@ -593,23 +933,22 @@ export async function createEngineSession({
593
933
  const cwd = runtime.cwd || process.cwd();
594
934
  const stateStartedAt = performance.now();
595
935
  const autoClearState = () => runtime.getAutoClear?.() || runtime.autoClear || { enabled: true, idleMs: 60 * 60 * 1000 };
596
- const BRIDGE_STATUS_CACHE_MS = 250;
597
- let bridgeStatusCache = null;
598
- let bridgeStatusCacheAt = 0;
599
- const bridgeStatusState = ({ force = false } = {}) => {
936
+ const AGENT_STATUS_CACHE_MS = 250;
937
+ let agentStatusCache = null;
938
+ let agentStatusCacheAt = 0;
939
+ const agentStatusState = ({ force = false } = {}) => {
600
940
  const now = Date.now();
601
- if (!force && bridgeStatusCache && now - bridgeStatusCacheAt < BRIDGE_STATUS_CACHE_MS) return bridgeStatusCache;
602
- const status = runtime.bridgeStatus?.() || {};
603
- bridgeStatusCache = {
604
- bridgeMode: runtime.bridgeMode || status.bridgeMode || 'async',
605
- bridgeWorkers: Array.isArray(status.bridgeWorkers) ? status.bridgeWorkers : [],
606
- bridgeJobs: Array.isArray(status.bridgeJobs) ? status.bridgeJobs : [],
607
- bridgeScope: status.bridgeScope || null,
941
+ if (!force && agentStatusCache && now - agentStatusCacheAt < AGENT_STATUS_CACHE_MS) return agentStatusCache;
942
+ const status = runtime.agentStatus?.() || {};
943
+ agentStatusCache = {
944
+ agentWorkers: Array.isArray(status.agentWorkers) ? status.agentWorkers : [],
945
+ agentJobs: Array.isArray(status.agentJobs) ? status.agentJobs : [],
946
+ agentScope: status.agentScope || null,
608
947
  };
609
- bridgeStatusCacheAt = now;
610
- return bridgeStatusCache;
948
+ agentStatusCacheAt = now;
949
+ return agentStatusCache;
611
950
  };
612
- const routeState = () => ({
951
+ const baseRouteState = () => ({
613
952
  sessionId: runtime.id,
614
953
  clientHostPid: runtime.clientHostPid || null,
615
954
  model: runtime.model,
@@ -623,18 +962,37 @@ export async function createEngineSession({
623
962
  effectiveContextWindowPercent: runtime.effectiveContextWindowPercent,
624
963
  cwd: runtime.cwd || process.cwd(),
625
964
  systemShell: runtime.systemShell || { source: 'auto', command: '', effective: '' },
965
+ searchRoute: runtime.getSearchRoute?.() || runtime.searchRoute || null,
626
966
  autoClear: autoClearState(),
627
967
  workflow: runtime.workflow || null,
628
968
  });
629
969
 
630
- const routeStateStartedAt = performance.now();
631
- const initialRouteState = routeState();
632
- bootProfile('engine:route-state-ready', { ms: (performance.now() - routeStateStartedAt).toFixed(1) });
633
- const initialBridgeState = {
634
- bridgeMode: runtime.bridgeMode || 'async',
635
- bridgeWorkers: [],
636
- bridgeJobs: [],
637
- bridgeScope: null,
970
+ const routeState = () => ({
971
+ ...baseRouteState(),
972
+ displayContextWindow: state.displayContextWindow || 0,
973
+ compactBoundaryTokens: state.compactBoundaryTokens || 0,
974
+ autoCompactTokenLimit: state.autoCompactTokenLimit || 0,
975
+ });
976
+
977
+ function syncContextDisplayFields(ctx = null) {
978
+ const status = ctx || runtime.contextStatus?.() || null;
979
+ if (!status) return;
980
+ const displayWindow = Number(status.contextWindow || 0);
981
+ const compactBoundary = Number(status.compaction?.boundaryTokens || 0);
982
+ const autoCompact = Number(
983
+ status.compaction?.autoCompactTokenLimit
984
+ || runtime.session?.autoCompactTokenLimit
985
+ || 0,
986
+ );
987
+ if (displayWindow > 0) state.displayContextWindow = displayWindow;
988
+ if (compactBoundary > 0) state.compactBoundaryTokens = compactBoundary;
989
+ if (autoCompact > 0) state.autoCompactTokenLimit = autoCompact;
990
+ }
991
+
992
+ const initialAgentState = {
993
+ agentWorkers: [],
994
+ agentJobs: [],
995
+ agentScope: null,
638
996
  };
639
997
  let state = {
640
998
  items: [],
@@ -645,25 +1003,74 @@ export async function createEngineSession({
645
1003
  spinner: null,
646
1004
  queued: [],
647
1005
  thinking: null,
1006
+ toolApproval: null,
648
1007
  lastTurn: null,
649
1008
  stats: createSessionStats(),
650
- ...initialRouteState,
651
- ...initialBridgeState,
1009
+ ...baseRouteState(),
1010
+ displayContextWindow: 0,
1011
+ compactBoundaryTokens: 0,
1012
+ autoCompactTokenLimit: 0,
1013
+ ...initialAgentState,
652
1014
  toolMode: runtime.toolMode,
653
1015
  cwd,
1016
+ themeEpoch: 0,
654
1017
  };
1018
+ bootProfile('engine:route-state-ready', { ms: (performance.now() - stateStartedAt).toFixed(1) });
655
1019
  bootProfile('engine:state-ready', { ms: (performance.now() - stateStartedAt).toFixed(1) });
1020
+ let pendingSessionReset = false;
656
1021
  const syncContextStats = ({ allowEstimated = false } = {}) => {
1022
+ if (pendingSessionReset) return null;
657
1023
  const ctx = runtime.contextStatus?.() || null;
1024
+ if (!ctx) return null;
1025
+ syncContextDisplayFields(ctx);
658
1026
  const hasProviderUsage = Number(state.stats.latestPromptTokens || state.stats.latestInputTokens || state.stats.inputTokens || 0) > 0;
659
- const lastApiTokens = Number(ctx?.lastApiRequestStale ? 0 : (ctx?.lastApiRequestTokens || ctx?.usage?.lastContextTokens || 0));
660
- const estimatedTokens = Number(ctx?.currentEstimatedTokens || 0);
661
- const used = lastApiTokens;
662
- if (!allowEstimated && !hasProviderUsage && ctx?.usedSource !== 'last_api_request') return ctx;
663
- if (Number.isFinite(used) && used > 0) state.stats.currentContextTokens = Math.max(0, used);
664
- else state.stats.currentContextTokens = 0;
665
- state.stats.currentEstimatedContextTokens = Number.isFinite(estimatedTokens) ? Math.max(0, estimatedTokens) : 0;
666
- state.stats.currentContextSource = used > 0 ? 'last_api_request' : (estimatedTokens > 0 ? 'estimated' : null);
1027
+ const hasApiContextUsage = Number(ctx?.lastApiRequestTokens ?? ctx?.usage?.lastContextTokens ?? 0) > 0;
1028
+ const hasTurnActivity = state.busy === true
1029
+ || state.spinner != null
1030
+ || state.thinking != null;
1031
+ const isFreshSession = !hasProviderUsage && !hasApiContextUsage && !hasTurnActivity;
1032
+ if (isFreshSession) {
1033
+ state.stats.currentEstimatedContextTokens = 0;
1034
+ state.stats.currentContextTokens = 0;
1035
+ state.stats.currentContextSource = null;
1036
+ state.stats.currentContextUpdatedAt = Date.now();
1037
+ return ctx;
1038
+ }
1039
+ const estimatedTokens = Math.max(0, Number(ctx.currentEstimatedTokens ?? ctx.usedTokens ?? 0));
1040
+ const usedTokens = Math.max(0, Number(ctx.usedTokens ?? estimatedTokens ?? 0));
1041
+ const usedSource = String(ctx.usedSource || '').toLowerCase();
1042
+ const shouldPublishEstimate = allowEstimated && (
1043
+ usedSource === 'estimated'
1044
+ || Number(ctx.currentEstimatedTokens) > 0
1045
+ || usedTokens > 0
1046
+ );
1047
+ if (!allowEstimated && !hasProviderUsage && usedSource !== 'last_api_request') return ctx;
1048
+ if (shouldPublishEstimate) {
1049
+ state.stats.currentEstimatedContextTokens = estimatedTokens;
1050
+ state.stats.currentContextSource = 'estimated';
1051
+ state.stats.currentContextTokens = 0;
1052
+ } else if (allowEstimated && (hasProviderUsage || hasApiContextUsage || hasTurnActivity)) {
1053
+ state.stats.currentEstimatedContextTokens = estimatedTokens;
1054
+ state.stats.currentContextSource = usedSource || (estimatedTokens > 0 ? 'estimated' : null);
1055
+ const publishedSource = String(state.stats.currentContextSource || '').toLowerCase();
1056
+ if (publishedSource === 'last_api_request') {
1057
+ const apiUsed = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
1058
+ state.stats.currentContextTokens = apiUsed;
1059
+ } else if (publishedSource === 'estimated') {
1060
+ state.stats.currentContextTokens = 0;
1061
+ } else {
1062
+ state.stats.currentContextTokens = usedTokens > 0 ? usedTokens : 0;
1063
+ }
1064
+ } else {
1065
+ state.stats.currentEstimatedContextTokens = 0;
1066
+ if (usedSource === 'last_api_request' && Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0) > 0) {
1067
+ state.stats.currentContextTokens = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
1068
+ state.stats.currentContextSource = 'last_api_request';
1069
+ } else {
1070
+ state.stats.currentContextTokens = 0;
1071
+ state.stats.currentContextSource = null;
1072
+ }
1073
+ }
667
1074
  state.stats.currentContextUpdatedAt = Date.now();
668
1075
  return ctx;
669
1076
  };
@@ -671,7 +1078,24 @@ export async function createEngineSession({
671
1078
  syncContextStats({ allowEstimated: true });
672
1079
  bootProfile('engine:context-ready', { ms: (performance.now() - contextStartedAt).toFixed(1) });
673
1080
  const listeners = new Set();
674
- const emit = () => { for (const l of listeners) l(); };
1081
+ // Coalesce store notifications: a single onToolCall batch / finalize path
1082
+ // fires many set() calls in one synchronous block (aggregate header sync,
1083
+ // spinner, item pushes). Notifying React on every set() painted the
1084
+ // intermediate layouts ("툭툭" header/count jitter). Collapsing the
1085
+ // notifications into one microtask means listeners see only the final state
1086
+ // of the current tick — "draw twice, keep the last one". getState() stays
1087
+ // synchronous and always returns the latest snapshot, so useSyncExternalStore
1088
+ // never tears.
1089
+ let emitScheduled = false;
1090
+ const flushEmit = () => {
1091
+ emitScheduled = false;
1092
+ for (const l of listeners) l();
1093
+ };
1094
+ const emit = () => {
1095
+ if (emitScheduled) return;
1096
+ emitScheduled = true;
1097
+ queueMicrotask(flushEmit);
1098
+ };
675
1099
  const set = (patch) => {
676
1100
  if (!patch || typeof patch !== 'object') return false;
677
1101
  let changed = false;
@@ -703,38 +1127,36 @@ export async function createEngineSession({
703
1127
  if (item?.id != null) itemIndexById.set(item.id, index);
704
1128
  set({ items });
705
1129
  };
706
- const removeItemsByIds = (ids) => {
707
- const idSet = new Set((ids || []).filter((id) => id != null));
708
- if (idSet.size === 0) return false;
709
- const items = state.items.filter((item) => !idSet.has(item?.id));
710
- if (items.length === state.items.length) return false;
711
- set({ items: replaceItems(items) });
712
- return true;
713
- };
714
- const pushUserOrSyntheticItem = (text, id = nextId()) => {
1130
+ const upsertSyntheticToolItem = (text, id = nextId(), parsed = null) => {
715
1131
  const synthetic = parseSyntheticAgentMessage(text);
716
- if (!synthetic) {
717
- pushItem({ kind: 'user', id, text });
718
- return;
719
- }
1132
+ if (!synthetic) return false;
720
1133
  const label = synthetic.label || 'notification';
1134
+ const args = synthetic.args || {
1135
+ type: label,
1136
+ task_id: synthetic.taskId || parsed?.taskId || undefined,
1137
+ description: synthetic.summary || 'agent notification',
1138
+ };
1139
+ const isError = synthetic.isError ?? /^(failed|error|timeout|killed|cancelled)$/i.test(label);
721
1140
  pushItem({
722
1141
  kind: 'tool',
723
1142
  id,
724
- name: synthetic.name || 'bridge',
725
- args: synthetic.args || {
726
- type: label,
727
- task_id: synthetic.taskId || undefined,
728
- description: synthetic.summary || 'agent notification',
729
- },
1143
+ name: synthetic.name || 'agent',
1144
+ args,
730
1145
  result: synthetic.result,
731
- isError: synthetic.isError ?? /^(failed|error|killed|cancelled)$/i.test(label),
1146
+ rawResult: synthetic.rawResult ?? text,
1147
+ isError,
732
1148
  expanded: false,
733
1149
  count: 1,
734
1150
  completedCount: 1,
735
1151
  startedAt: Date.now(),
736
1152
  completedAt: Date.now(),
737
1153
  });
1154
+ return true;
1155
+ };
1156
+ const pushUserOrSyntheticItem = (text, id = nextId()) => {
1157
+ if (isModelVisibleToolCompletionWrapper(text)) return;
1158
+ if (upsertSyntheticToolItem(text, id)) return;
1159
+ pushItem({ kind: 'user', id, text });
738
1160
  };
739
1161
  const pushToast = (text, tone = 'info', ttlMs = 3000) => {
740
1162
  const id = nextId();
@@ -759,6 +1181,80 @@ export async function createEngineSession({
759
1181
  pushItem({ kind: 'notice', id, text: value, tone });
760
1182
  return id;
761
1183
  };
1184
+ const toolApprovalQueue = [];
1185
+ let activeToolApproval = null;
1186
+ function normalizeToolApprovalRequest(input = {}, id = nextId()) {
1187
+ const now = Date.now();
1188
+ const timeoutMs = TOOL_APPROVAL_TIMEOUT_MS;
1189
+ return {
1190
+ id,
1191
+ name: String(input?.name || input?.tool_name || 'tool'),
1192
+ args: input?.args ?? input?.tool_input ?? null,
1193
+ cwd: input?.cwd || null,
1194
+ sessionId: input?.sessionId || input?.session_id || null,
1195
+ toolCallId: input?.toolCallId || input?.tool_use_id || null,
1196
+ reason: String(input?.reason || input?.message || 'approval requested by hook').trim(),
1197
+ requestedAt: now,
1198
+ timeoutMs,
1199
+ expiresAt: now + timeoutMs,
1200
+ };
1201
+ }
1202
+ function presentNextToolApproval() {
1203
+ if (activeToolApproval || disposed) return;
1204
+ const entry = toolApprovalQueue.shift();
1205
+ if (!entry) {
1206
+ if (state.toolApproval) set({ toolApproval: null });
1207
+ return;
1208
+ }
1209
+ activeToolApproval = entry;
1210
+ entry.timer = setTimeout(() => {
1211
+ finishToolApproval(entry.id, false, 'approval timed out');
1212
+ }, entry.request.timeoutMs);
1213
+ entry.timer.unref?.();
1214
+ set({ toolApproval: entry.request });
1215
+ }
1216
+ function finishToolApproval(id, approved, reason = '') {
1217
+ const targetId = String(id || '');
1218
+ if (activeToolApproval && activeToolApproval.id === targetId) {
1219
+ const entry = activeToolApproval;
1220
+ activeToolApproval = null;
1221
+ if (entry.timer) clearTimeout(entry.timer);
1222
+ set({ toolApproval: null });
1223
+ try { entry.resolve({ approved: approved === true, reason: String(reason || '') }); } catch {}
1224
+ presentNextToolApproval();
1225
+ return true;
1226
+ }
1227
+ const index = toolApprovalQueue.findIndex((entry) => entry.id === targetId);
1228
+ if (index >= 0) {
1229
+ const [entry] = toolApprovalQueue.splice(index, 1);
1230
+ if (entry?.timer) clearTimeout(entry.timer);
1231
+ try { entry.resolve({ approved: approved === true, reason: String(reason || '') }); } catch {}
1232
+ return true;
1233
+ }
1234
+ return false;
1235
+ }
1236
+ function denyAllToolApprovals(reason = 'approval cancelled') {
1237
+ if (activeToolApproval) {
1238
+ const entry = activeToolApproval;
1239
+ activeToolApproval = null;
1240
+ if (entry.timer) clearTimeout(entry.timer);
1241
+ try { entry.resolve({ approved: false, reason }); } catch {}
1242
+ }
1243
+ while (toolApprovalQueue.length > 0) {
1244
+ const entry = toolApprovalQueue.shift();
1245
+ if (entry?.timer) clearTimeout(entry.timer);
1246
+ try { entry.resolve({ approved: false, reason }); } catch {}
1247
+ }
1248
+ if (state.toolApproval) set({ toolApproval: null });
1249
+ }
1250
+ function requestToolApproval(input = {}) {
1251
+ if (disposed) return Promise.resolve({ approved: false, reason: 'runtime disposed' });
1252
+ return new Promise((resolve) => {
1253
+ const id = nextId();
1254
+ toolApprovalQueue.push({ id, request: normalizeToolApprovalRequest(input, id), resolve, timer: null });
1255
+ presentNextToolApproval();
1256
+ });
1257
+ }
762
1258
  const patchItem = (id, patch) => {
763
1259
  let index = itemIndexById.get(id);
764
1260
  if (!Number.isInteger(index) || state.items[index]?.id !== id) {
@@ -782,6 +1278,17 @@ export async function createEngineSession({
782
1278
  };
783
1279
  const toastTimers = new Set();
784
1280
  let disposed = false;
1281
+ const runtimePulseTimer = setInterval(() => {
1282
+ if (disposed) return;
1283
+ if (pendingSessionReset) return;
1284
+ syncContextStats({ allowEstimated: true });
1285
+ set({
1286
+ ...routeState(),
1287
+ stats: { ...state.stats },
1288
+ ...agentStatusState(),
1289
+ });
1290
+ }, 2000);
1291
+ runtimePulseTimer.unref?.();
785
1292
 
786
1293
  function clearToastTimers() {
787
1294
  for (const timer of toastTimers) {
@@ -794,18 +1301,46 @@ export async function createEngineSession({
794
1301
  let lastUserActivityAt = Date.now();
795
1302
  let autoClearRunning = false;
796
1303
  const pendingNotificationKeys = new Set();
1304
+ const displayedExecutionNotificationKeys = new Set();
1305
+ let executionResumeKickDeferred = false;
1306
+
1307
+ function kickExecutionPendingResume() {
1308
+ if (disposed) return;
1309
+ if (state.busy) {
1310
+ executionResumeKickDeferred = true;
1311
+ return;
1312
+ }
1313
+ if (pending.some((entry) => entry.mode === 'pending-resume')) {
1314
+ executionResumeKickDeferred = true;
1315
+ return;
1316
+ }
1317
+ executionResumeKickDeferred = false;
1318
+ pending.push(makeQueueEntry('', { mode: 'pending-resume', priority: 'next' }));
1319
+ void drain();
1320
+ }
797
1321
 
798
- function updateBridgeJobCard(itemId, text, isError = false) {
799
- const parsed = parseBridgeJob(text);
1322
+ function flushDeferredExecutionPendingResumeKick() {
1323
+ if (!executionResumeKickDeferred || disposed || state.busy) return;
1324
+ kickExecutionPendingResume();
1325
+ }
1326
+
1327
+ function scheduleExecutionPendingResumeKick() {
1328
+ // notifyFnForSession enqueues the model-visible body after onNotification
1329
+ // returns; defer the kick so askSession pre-drain sees session pending.
1330
+ queueMicrotask(() => kickExecutionPendingResume());
1331
+ }
1332
+
1333
+ function updateAgentJobCard(itemId, text, isError = false) {
1334
+ const parsed = parseAgentJob(text);
800
1335
  const current = state.items.find((it) => it.id === itemId);
801
- const rawDisplayText = bridgeJobResultText(text, parsed) || String(text || '').trim();
802
- const displayText = isError ? toolErrorDisplay(rawDisplayText, 'bridge') : rawDisplayText;
1336
+ const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
1337
+ const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
803
1338
  patchItem(itemId, {
804
1339
  result: displayText,
805
1340
  text: displayText,
806
1341
  isError,
807
1342
  errorCount: isError ? 1 : 0,
808
- ...(parsed ? { args: bridgeArgsWithResultMetadata(current?.args, parsed) } : {}),
1343
+ ...(parsed ? { args: agentArgsWithResultMetadata(current?.args, parsed) } : {}),
809
1344
  });
810
1345
  }
811
1346
 
@@ -814,23 +1349,38 @@ export async function createEngineSession({
814
1349
  if (disposed) return;
815
1350
  const text = String(event?.content ?? event?.text ?? event ?? '').trim();
816
1351
  if (!text) return;
817
- const parsed = parseBridgeJob(text);
1352
+ const parsed = parseAgentJob(text);
818
1353
  const notificationKey = notificationQueueKey(event, text, parsed);
819
- if (parsed?.taskId) {
820
- const existing = [...state.items].reverse().find((item) => {
821
- if (!item || item.kind !== 'tool' || item.name !== 'bridge') return false;
822
- const args = parseToolArgs(item.args);
823
- return args.task_id === parsed.taskId;
824
- });
825
- if (existing) {
826
- updateBridgeJobCard(existing.id, text, /^(failed|error|timeout|cancelled|killed)$/i.test(parsed.status));
1354
+ const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
1355
+ if (delivery.action === 'ignore') return;
1356
+ if (delivery.action === 'status-only') {
1357
+ if (parsed?.taskId) set(agentStatusState({ force: true }));
1358
+ return true;
1359
+ }
1360
+ if (delivery.action === 'execution-ui') {
1361
+ const firstDelivery = !notificationKey || !displayedExecutionNotificationKeys.has(notificationKey);
1362
+ if (firstDelivery) {
1363
+ if (notificationKey) displayedExecutionNotificationKeys.add(notificationKey);
1364
+ pushUserOrSyntheticItem(delivery.displayText, nextId());
827
1365
  }
1366
+ if (parsed?.taskId) set(agentStatusState({ force: true }));
1367
+ if (String(delivery.modelContent || '').trim()) {
1368
+ scheduleExecutionPendingResumeKick();
1369
+ }
1370
+ return true;
828
1371
  }
829
- enqueue(text, {
1372
+ if (parsed?.taskId) {
1373
+ set(agentStatusState({ force: true }));
1374
+ }
1375
+ const modelContent = String(delivery.modelContent ?? delivery.displayText ?? text).trim();
1376
+ if (!modelContent) return true;
1377
+ enqueue(modelContent, {
830
1378
  mode: 'task-notification',
831
1379
  priority: 'next',
832
1380
  key: notificationKey || undefined,
1381
+ displayText: delivery.displayText || text,
833
1382
  });
1383
+ return true;
834
1384
  });
835
1385
  }
836
1386
 
@@ -852,6 +1402,10 @@ export async function createEngineSession({
852
1402
  ...uniqueReasons.slice(1),
853
1403
  ].join('\n');
854
1404
  }
1405
+ for (const result of group.results || []) {
1406
+ const line = String(result?.text || '').trim();
1407
+ if (line) return result.text;
1408
+ }
855
1409
  return '';
856
1410
  }
857
1411
 
@@ -869,6 +1423,7 @@ export async function createEngineSession({
869
1423
  function aggregateRawResult(calls) {
870
1424
  const chunks = [];
871
1425
  for (const rec of calls || []) {
1426
+ if (rec?.resolved !== true) continue;
872
1427
  const text = String(rec?.resultText || '').replace(/\s+$/, '');
873
1428
  if (!text.trim()) continue;
874
1429
  const label = String(rec?.name || rec?.category || 'tool').trim() || 'tool';
@@ -877,6 +1432,19 @@ export async function createEngineSession({
877
1432
  return chunks.join('\n\n');
878
1433
  }
879
1434
 
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
+
880
1448
  function aggregateBucketForCategory(category) {
881
1449
  switch (category) {
882
1450
  case 'Read':
@@ -915,21 +1483,31 @@ export async function createEngineSession({
915
1483
  if (!card || card.done) return false;
916
1484
  const callId = toolResultCallId(message) || card.callId;
917
1485
  if (callId && done.has(callId)) return false;
1486
+ // A result for this card arrived (possibly before its deferred push delay
1487
+ // elapsed) — surface the card now so the patch below has a live item and the
1488
+ // fast tool paints a completed card directly, no pending placeholder stage.
1489
+ // ensureVisible flushes this card AND every earlier-created still-deferred
1490
+ // card in order, so transcript order always matches call order.
1491
+ (card.aggregate?.ensureVisible || card.ensureVisible)?.();
918
1492
  const rawText = toolResultText(message?.content);
919
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText);
1493
+ const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
920
1494
  const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
921
1495
 
922
1496
  // Aggregate card handling — collect semantic summaries per call
923
1497
  const aggregate = card.aggregate;
924
1498
  if (aggregate && card.itemId === aggregate.itemId) {
925
1499
  const callRec = callId ? aggregate.calls.get(callId) : null;
926
- if (callRec) {
927
- callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
928
- assignAggregateSummaryOrder(aggregate, callRec);
929
- callRec.isError = isError;
930
- callRec.resultText = text;
931
- callRec.resolved = true;
1500
+ if (!callRec) return false;
1501
+ if (callRec.resolved) {
1502
+ card.done = true;
1503
+ if (callId) done.add(callId);
1504
+ return false;
932
1505
  }
1506
+ callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
1507
+ assignAggregateSummaryOrder(aggregate, callRec);
1508
+ callRec.isError = isError;
1509
+ callRec.resultText = text;
1510
+ callRec.resolved = true;
933
1511
  const allCalls = [...aggregate.calls.values()];
934
1512
  const completed = allCalls.filter((r) => r.resolved).length;
935
1513
  const errors = allCalls.filter((r) => r.isError).length;
@@ -939,36 +1517,42 @@ export async function createEngineSession({
939
1517
  const succeeded = completed - errors;
940
1518
  detailText = succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`;
941
1519
  } else {
942
- detailText = formatAggregateDetail(summaries) || '';
1520
+ detailText = aggregateDisplayDetail(summaries, allCalls);
1521
+ if (errors > 0) {
1522
+ detailText = detailText ? `${detailText} · ${errors} Failed` : `${errors} Failed`;
1523
+ }
943
1524
  }
944
1525
  const currentItem = state.items.find((it) => it.id === card.itemId);
945
- const visualCompleted = Math.max(completed, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
1526
+ const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
1527
+ const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
946
1528
  const rawResult = aggregateRawResult(allCalls);
1529
+ const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
947
1530
  patchItem(card.itemId, {
948
- result: detailText,
949
- text: detailText,
1531
+ result: displayDetail,
1532
+ text: displayDetail,
950
1533
  rawResult: rawResult || null,
951
1534
  isError: errors > 0,
952
1535
  errorCount: errors,
953
1536
  count: allCalls.length,
954
1537
  completedCount: visualCompleted,
955
- completedAt: Date.now(),
1538
+ completedAt: Number(currentItem?.completedAt) || Date.now(),
956
1539
  });
957
1540
  card.done = true;
958
1541
  if (callId) done.add(callId);
959
1542
  return true;
960
1543
  }
961
1544
 
962
- // Non-aggregate (legacy bridge-job cards, etc.)
1545
+ // Non-aggregate (legacy agent-job cards, etc.)
963
1546
  const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
964
1547
  group.completed = Math.min(group.count, group.completed + 1);
965
1548
  group.errors += isError ? 1 : 0;
966
1549
  group.results.push({ text, isError });
967
1550
  toolGroups.set(card.itemId, group);
968
1551
  const resultText = groupedToolResultText(group);
1552
+ const displayResult = toolGroupedDisplayFallback(resultText, text, rawText);
969
1553
  const patch = {
970
- result: resultText,
971
- text: resultText,
1554
+ result: displayResult,
1555
+ text: displayResult,
972
1556
  isError: group.errors > 0,
973
1557
  errorCount: group.errors,
974
1558
  count: group.count,
@@ -976,11 +1560,16 @@ export async function createEngineSession({
976
1560
  completedAt: Date.now(),
977
1561
  };
978
1562
  if (group.count <= 1) {
979
- const parsedBridge = parseBridgeJob(rawText);
980
- if (parsedBridge) patch.args = bridgeArgsWithResultMetadata(state.items.find((it) => it.id === card.itemId)?.args, parsedBridge);
1563
+ const body = String(text || rawText || '').trim();
1564
+ if (body) patch.rawResult = text || rawText;
1565
+ const parsedAgent = parseAgentJob(rawText);
1566
+ if (parsedAgent) {
1567
+ patch.args = agentArgsWithResultMetadata(state.items.find((it) => it.id === card.itemId)?.args, parsedAgent);
1568
+ set(agentStatusState({ force: true }));
1569
+ }
981
1570
  }
982
1571
  patchItem(card.itemId, patch);
983
- if (group.count <= 1) updateBridgeJobCard(card.itemId, rawText, isError);
1572
+ if (group.count <= 1) updateAgentJobCard(card.itemId, rawText, isError);
984
1573
  card.done = true;
985
1574
  if (callId) done.add(callId);
986
1575
  return true;
@@ -1017,6 +1606,9 @@ export async function createEngineSession({
1017
1606
  if (!finalize) return;
1018
1607
  for (const card of toolCards || []) {
1019
1608
  if (card.done) continue;
1609
+ // Finalize must surface any still-deferred card before patching its result
1610
+ // so the completed/cancelled card is never silently dropped.
1611
+ (card.aggregate?.ensureVisible || card.ensureVisible)?.();
1020
1612
  // Aggregate finalize — mark any remaining calls as done
1021
1613
  const aggregate = card.aggregate;
1022
1614
  if (aggregate && card.itemId === aggregate.itemId) {
@@ -1026,11 +1618,12 @@ export async function createEngineSession({
1026
1618
  const totalCompleted = remaining > 0 ? completed + remaining : completed;
1027
1619
  const errors = allCalls.filter((r) => r.isError).length;
1028
1620
  const summaries = aggregateSummaries(aggregate);
1029
- const detailText = formatAggregateDetail(summaries) || '';
1621
+ const detailText = aggregateDisplayDetail(summaries, allCalls);
1030
1622
  const rawResult = aggregateRawResult(allCalls);
1623
+ const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1031
1624
  patchItem(card.itemId, {
1032
- result: detailText,
1033
- text: detailText,
1625
+ result: displayDetail,
1626
+ text: displayDetail,
1034
1627
  rawResult: rawResult || null,
1035
1628
  isError: errors > 0,
1036
1629
  errorCount: errors,
@@ -1084,13 +1677,90 @@ export async function createEngineSession({
1084
1677
  let thinkingSegmentStartedAt = 0;
1085
1678
  let accumulatedThinkingMs = 0;
1086
1679
  let cancelled = false;
1680
+ let askResult = null;
1681
+ let turnFinishedNormally = false;
1682
+ const itemsAtTurnStart = state.items.length;
1087
1683
  const cardByCallId = new Map();
1088
1684
  const toolCards = [];
1089
1685
  const toolGroups = new Map();
1090
1686
  const resultsDone = new Set();
1687
+ // Streaming providers can deliver eager onToolResult before onToolCall registers
1688
+ // cards (send() still in flight). Hold those by callId until the batch lands.
1689
+ const earlyResultBuffer = new Map();
1091
1690
  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
1092
1692
  let openAggregateCard = null;
1093
- let autoCompactStatus = null;
1693
+
1694
+ // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
1695
+ // A tool card used to enter the transcript the instant onToolCall fired,
1696
+ // reserving its estimated height (margin+header+detail) while ToolExecution
1697
+ // only painted blank placeholder rows for TOOL_PENDING_SHOW_DELAY_MS. With a
1698
+ // bottom-fixed viewport that shoved the body up BEFORE any glyph appeared, so
1699
+ // the scroll ran ahead of the text. We now hold each card off-screen for the
1700
+ // same delay and push it only when its real header/detail will paint (delay
1701
+ // elapsed) OR a result lands first (fast tool → completed card, no pending
1702
+ // flicker). Either way the pushed spec is stamped `deferredDisplayReady` so
1703
+ // ToolExecution renders the real header + 'Running' detail immediately
1704
+ // instead of the blank pre-delay placeholder — this matters for the
1705
+ // result-forced chain push (flushDeferredUpTo), where earlier-seq sibling
1706
+ // cards are pushed alongside the result-bearing one before their own delay
1707
+ // elapses and would otherwise paint an empty reserved band.
1708
+ // Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
1709
+ const TOOL_CARD_PUSH_DELAY_MS = 1000;
1710
+ let deferredSeqCounter = 0;
1711
+ const deferredEntries = []; // creation-order list; each is pushed at most once
1712
+ // Push this entry AND every earlier-created still-deferred entry, in order,
1713
+ // so transcript order always matches call order even when a later card's
1714
+ // result/timer fires before an earlier one's.
1715
+ const flushDeferredUpTo = (entry) => {
1716
+ if (!entry) return;
1717
+ for (const e of deferredEntries) {
1718
+ if (e.seq > entry.seq) break;
1719
+ if (e.pushed) continue;
1720
+ e.pushed = true;
1721
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
1722
+ try { e.push(); } catch {}
1723
+ }
1724
+ };
1725
+ const registerDeferredCard = (card) => {
1726
+ const entry = {
1727
+ seq: deferredSeqCounter++,
1728
+ pushed: false,
1729
+ timer: null,
1730
+ push: () => { card.pushed = true; if (card.spec) { card.spec.deferredDisplayReady = true; pushItem(card.spec); } },
1731
+ };
1732
+ card.deferred = entry;
1733
+ card.ensureVisible = () => flushDeferredUpTo(entry);
1734
+ deferredEntries.push(entry);
1735
+ entry.timer = setTimeout(() => {
1736
+ entry.timer = null;
1737
+ if (disposed) return;
1738
+ flushDeferredUpTo(entry);
1739
+ }, TOOL_CARD_PUSH_DELAY_MS);
1740
+ entry.timer.unref?.();
1741
+ };
1742
+ const registerDeferredAggregate = (aggregate) => {
1743
+ const entry = {
1744
+ seq: deferredSeqCounter++,
1745
+ pushed: false,
1746
+ timer: null,
1747
+ push: () => { aggregate.pushed = true; if (aggregate.pendingSpec) { aggregate.pendingSpec.deferredDisplayReady = true; pushItem(aggregate.pendingSpec); } },
1748
+ };
1749
+ aggregate.deferred = entry;
1750
+ aggregate.ensureVisible = () => flushDeferredUpTo(entry);
1751
+ deferredEntries.push(entry);
1752
+ entry.timer = setTimeout(() => {
1753
+ entry.timer = null;
1754
+ if (disposed) return;
1755
+ flushDeferredUpTo(entry);
1756
+ }, TOOL_CARD_PUSH_DELAY_MS);
1757
+ entry.timer.unref?.();
1758
+ };
1759
+ const clearDeferredTimers = () => {
1760
+ for (const e of deferredEntries) {
1761
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
1762
+ }
1763
+ };
1094
1764
 
1095
1765
  const markPromptCommitted = () => {
1096
1766
  if (activePromptRestore) {
@@ -1109,9 +1779,13 @@ export async function createEngineSession({
1109
1779
  const ids = new Set();
1110
1780
  for (const card of toolCards || []) {
1111
1781
  if (card?.itemId) ids.add(card.itemId);
1782
+ // Seal not-yet-pushed specs too, so a card that pushes later (timer)
1783
+ // enters already-finalized instead of flashing the active header form.
1784
+ if (card && card.pushed === false && card.spec) card.spec.headerFinalized = true;
1112
1785
  }
1113
1786
  for (const aggregate of aggregateCards || []) {
1114
1787
  if (aggregate?.itemId) ids.add(aggregate.itemId);
1788
+ if (aggregate && aggregate.pushed === false && aggregate.pendingSpec) aggregate.pendingSpec.headerFinalized = true;
1115
1789
  }
1116
1790
  if (ids.size === 0) return false;
1117
1791
  let changed = false;
@@ -1128,13 +1802,15 @@ export async function createEngineSession({
1128
1802
  for (const aggregate of aggregateCards) {
1129
1803
  const allCalls = [...aggregate.calls.values()];
1130
1804
  if (allCalls.length === 0) continue;
1805
+ aggregate.ensureVisible?.();
1131
1806
  const errors = allCalls.filter((r) => r.isError).length;
1132
1807
  const summaries = aggregateSummaries(aggregate);
1133
- const detailText = formatAggregateDetail(summaries) || '';
1808
+ const detailText = aggregateDisplayDetail(summaries, allCalls);
1134
1809
  const rawResult = aggregateRawResult(allCalls);
1810
+ const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
1135
1811
  patchItem(aggregate.itemId, {
1136
- result: detailText,
1137
- text: detailText,
1812
+ result: displayDetail,
1813
+ text: displayDetail,
1138
1814
  rawResult: rawResult || null,
1139
1815
  isError: errors > 0,
1140
1816
  count: allCalls.length,
@@ -1151,8 +1827,38 @@ export async function createEngineSession({
1151
1827
  openAggregateCard = null;
1152
1828
  };
1153
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;
1834
+ };
1835
+
1836
+ const rememberActiveAggregate = (aggregate) => {
1837
+ if (!aggregate) return;
1838
+ if (!aggregateCards.includes(aggregate)) aggregateCards.push(aggregate);
1839
+ aggregateByBucket.set(aggregate.bucket, aggregate);
1840
+ };
1841
+
1154
1842
  const ensureAggregateCard = (bucket) => {
1155
- if (openAggregateCard?.bucket === bucket) return openAggregateCard;
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;
1861
+ }
1156
1862
  const itemId = nextId();
1157
1863
  const aggregate = {
1158
1864
  itemId,
@@ -1164,7 +1870,10 @@ export async function createEngineSession({
1164
1870
  pushed: false,
1165
1871
  startedAt: Date.now(),
1166
1872
  };
1167
- aggregateCards.push(aggregate);
1873
+ // Arm the deferred push once at creation; syncAggregateHeader only keeps
1874
+ // pendingSpec current until the timer/result flushes it in call order.
1875
+ registerDeferredAggregate(aggregate);
1876
+ rememberActiveAggregate(aggregate);
1168
1877
  openAggregateCard = aggregate;
1169
1878
  return aggregate;
1170
1879
  };
@@ -1174,15 +1883,17 @@ export async function createEngineSession({
1174
1883
  const patch = {
1175
1884
  args: { categoryOrder: aggregate.categoryOrder.slice() },
1176
1885
  count: aggregate.calls.size,
1177
- completedCount: [...aggregate.calls.values()].filter((r) => r.resolved).length,
1886
+ completedCount: [...aggregate.calls.values()].filter((r) => r.resolved || r.completedEarly).length,
1178
1887
  categories: Object.fromEntries(aggregate.categories),
1179
1888
  };
1180
1889
  if (aggregate.pushed) {
1181
1890
  patchItem(aggregate.itemId, patch);
1182
1891
  return;
1183
1892
  }
1184
- aggregate.pushed = true;
1185
- pushItem({
1893
+ // Not yet visible: keep the latest header spec current. The deferred entry
1894
+ // (armed at creation) pushes pendingSpec when its timer fires or a result
1895
+ // forces it visible, preserving call order via flushDeferredUpTo.
1896
+ aggregate.pendingSpec = {
1186
1897
  kind: 'tool',
1187
1898
  id: aggregate.itemId,
1188
1899
  name: '__aggregate__',
@@ -1194,14 +1905,20 @@ export async function createEngineSession({
1194
1905
  expanded: false,
1195
1906
  headerFinalized: false,
1196
1907
  startedAt: aggregate.startedAt || Date.now(),
1197
- });
1908
+ };
1198
1909
  };
1199
1910
 
1200
- const ensureAssistant = () => {
1911
+ const ensureAssistant = (initialText = '') => {
1201
1912
  if (!currentAssistantId) {
1202
1913
  currentAssistantId = nextId();
1203
- currentAssistantText = '';
1204
- pushItem({ kind: 'assistant', id: currentAssistantId, text: '', streaming: true });
1914
+ // Do NOT reset currentAssistantText here. The first onTextDelta has
1915
+ // already accumulated the opening chunk before this batched flush runs;
1916
+ // wiping it dropped the leading characters and forced a later set() to
1917
+ // re-add them. Segment resets are owned by closeAssistantSegment().
1918
+ // Seed the new row with the already-visible text so the ● gutter and the
1919
+ // first body line appear in the SAME set()/emit() — no empty "●-only"
1920
+ // row that scrolls once on its own and again when the body lands.
1921
+ pushItem({ kind: 'assistant', id: currentAssistantId, text: String(initialText || ''), streaming: true });
1205
1922
  }
1206
1923
  return currentAssistantId;
1207
1924
  };
@@ -1211,6 +1928,19 @@ export async function createEngineSession({
1211
1928
  currentAssistantText = '';
1212
1929
  };
1213
1930
 
1931
+ const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
1932
+ const text = currentAssistantText || '';
1933
+ if (!text.trim()) {
1934
+ closeAssistantSegment();
1935
+ return false;
1936
+ }
1937
+ if (sealToolBlock) clearAggregateContinuation();
1938
+ const id = currentAssistantId || ensureAssistant(text);
1939
+ patchItem(id, { text, streaming: false });
1940
+ closeAssistantSegment();
1941
+ return true;
1942
+ };
1943
+
1214
1944
  const startThinkingSegment = () => {
1215
1945
  const now = Date.now();
1216
1946
  if (!thinkingStartedAt) thinkingStartedAt = now;
@@ -1232,11 +1962,17 @@ export async function createEngineSession({
1232
1962
  // batch accumulated text and flush at most once per STREAM_BATCH_INTERVAL_MS
1233
1963
  // (≈16ms / 60fps cap). A forced flush happens before any tool call,
1234
1964
  // finalization, or error so those code paths see the correct text state.
1235
- const STREAM_BATCH_INTERVAL_MS = 16;
1965
+ // Flush cadence for streamed text/thinking. 8ms (~120fps) matches the Ink
1966
+ // render maxFps (index.jsx render({ maxFps: 120 })), so a queued batch is
1967
+ // never held back waiting for the next Ink frame. 16ms (~60fps) left every
1968
+ // other Ink frame idle, which made fast provider streams visibly land in
1969
+ // coarse chunks ("10 chars at a time").
1970
+ const STREAM_BATCH_INTERVAL_MS = 8;
1236
1971
  let _batchTimer = null;
1237
1972
  let _pendingTextFlush = false; // true when a text/spinner update is queued
1238
1973
  let _pendingThinkFlush = false; // true when a thinking update is queued
1239
1974
  let _pendingThinkingLastEndedAt = 0;
1975
+ let compactingActive = false;
1240
1976
 
1241
1977
  const flushStreamBatch = () => {
1242
1978
  if (_batchTimer !== null) {
@@ -1245,22 +1981,45 @@ export async function createEngineSession({
1245
1981
  }
1246
1982
  if (_pendingTextFlush) {
1247
1983
  _pendingTextFlush = false;
1248
- const id = ensureAssistant();
1249
- // Emit the accumulated assistant text and spinner update together so a
1250
- // streaming batch costs one set() one emit()one React reconcile.
1984
+ // Show only COMPLETED lines while streaming. The in-progress trailing
1985
+ // line stays hidden until its '\n' arrives, so the visible text never
1986
+ // grows a glyph at a time (no "무"pause→"무슨 일…" partial reveal, no
1987
+ // CJK-width reflow jitter). The final non-streaming patch
1988
+ // (streaming:false) always carries the full text, so the tail line that
1989
+ // never got a newline still lands once at finalize.
1990
+ const newlineIdx = currentAssistantText.lastIndexOf('\n');
1991
+ const streamingVisibleText = newlineIdx >= 0
1992
+ ? currentAssistantText.slice(0, newlineIdx + 1)
1993
+ : '';
1251
1994
  const patch = {};
1252
- const index = state.items.findIndex((it) => it.id === id);
1253
- if (index >= 0) {
1254
- const current = state.items[index];
1255
- if (!Object.is(current.text, currentAssistantText) || current.streaming !== true) {
1256
- const items = state.items.slice();
1257
- items[index] = { ...current, text: currentAssistantText, streaming: true };
1258
- patch.items = items;
1995
+ // Do NOT create the assistant row (and scroll the transcript) before
1996
+ // there is a completed line with VISIBLE content to show. Until the
1997
+ // first '\n' the only pending state is the spinner; the row appears
1998
+ // together with its first visible line, so no empty "●-only" row
1999
+ // flashes/scrolls ahead of text. `.trim()` also guards the
2000
+ // whitespace-only case: a response that opens with leading newlines
2001
+ // ("\n\n# …") completes a blank line first, whose estimated height
2002
+ // still reserves rows and scrolls the transcript, but Markdown trims
2003
+ // the body to nothing — so the scroll advances onto an empty band for
2004
+ // a few seconds until a non-blank line lands. Don't create the row
2005
+ // until there is real content to paint.
2006
+ if (currentAssistantId || streamingVisibleText.trim()) {
2007
+ const id = ensureAssistant(streamingVisibleText);
2008
+ // Emit the accumulated assistant text and spinner update together so a
2009
+ // streaming batch costs one set() → one emit() → one React reconcile.
2010
+ const index = state.items.findIndex((it) => it.id === id);
2011
+ if (index >= 0) {
2012
+ const current = state.items[index];
2013
+ if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
2014
+ const items = state.items.slice();
2015
+ items[index] = { ...current, text: streamingVisibleText, streaming: true };
2016
+ patch.items = items;
2017
+ }
1259
2018
  }
1260
2019
  }
1261
2020
  const responseLengthVal = assistantText.length + thinkingText.length;
1262
2021
  if (state.spinner) {
1263
- patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || state.spinner.thinkingLastEndedAt, mode: 'responding' };
2022
+ patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || state.spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
1264
2023
  }
1265
2024
  if (Object.keys(patch).length > 0) set(patch);
1266
2025
  _pendingThinkingLastEndedAt = 0;
@@ -1269,9 +2028,11 @@ export async function createEngineSession({
1269
2028
  _pendingThinkFlush = false;
1270
2029
  const responseLengthVal = assistantText.length + thinkingText.length;
1271
2030
  const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
1272
- const patch = { thinking: thinkingText };
2031
+ const patch = { thinking: compactingActive ? null : thinkingText };
1273
2032
  if (state.spinner) {
1274
- patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
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' };
1275
2036
  }
1276
2037
  set(patch);
1277
2038
  }
@@ -1283,10 +2044,87 @@ export async function createEngineSession({
1283
2044
  if (_batchTimer?.unref) _batchTimer.unref(); // don't prevent process exit
1284
2045
  };
1285
2046
 
2047
+ // __earlyNotify: show 1-line summary + completedCount immediately; defer
2048
+ // rawResult/expand and resultsDone to the history flush.
2049
+ const markToolCardCompletedState = (callId, message) => {
2050
+ const card = cardByCallId.get(callId);
2051
+ if (!card) return;
2052
+ const aggregate = card.aggregate;
2053
+ if (aggregate && card.itemId === aggregate.itemId) {
2054
+ const callRec = aggregate.calls.get(callId);
2055
+ if (!callRec || callRec.resolved || callRec.completedEarly) return;
2056
+ aggregate.ensureVisible?.();
2057
+ const rawText = toolResultText(message?.content);
2058
+ const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
2059
+ const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
2060
+ callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
2061
+ assignAggregateSummaryOrder(aggregate, callRec);
2062
+ callRec.isError = isError;
2063
+ callRec.resultText = text;
2064
+ callRec.completedEarly = true;
2065
+ const allCalls = [...aggregate.calls.values()];
2066
+ const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
2067
+ 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
+ }
2079
+ const rawResult = aggregateRawResult(allCalls);
2080
+ const displayDetail = toolAggregateDetailFallback(detailText, rawResult);
2081
+ const currentItem = state.items.find((it) => it.id === card.itemId);
2082
+ const visualCompleted = Math.max(
2083
+ completedCount,
2084
+ Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
2085
+ );
2086
+ const patch = {
2087
+ result: displayDetail,
2088
+ text: displayDetail,
2089
+ isError: errors > 0,
2090
+ errorCount: errors,
2091
+ count: allCalls.length,
2092
+ completedCount: visualCompleted,
2093
+ };
2094
+ if (visualCompleted >= allCalls.length) {
2095
+ patch.completedAt = Number(currentItem?.completedAt) || Date.now();
2096
+ }
2097
+ patchItem(card.itemId, patch);
2098
+ return;
2099
+ }
2100
+ // Non-aggregate eager tools are rare; flipping completedCount without
2101
+ // result changes pending/detail rendering and risks row jitter — wait for
2102
+ // the real history flush (unchanged behavior).
2103
+ };
2104
+
2105
+ const deliverToolResultMessage = (message) => {
2106
+ if (message?.__earlyNotify === true) {
2107
+ const earlyCallId = toolResultCallId(message);
2108
+ if (earlyCallId) markToolCardCompletedState(earlyCallId, message);
2109
+ return;
2110
+ }
2111
+ flushToolResults([message], toolCards, cardByCallId, toolGroups, resultsDone);
2112
+ };
2113
+
1286
2114
  try {
1287
2115
  const { result, session } = await runtime.ask(userText, {
1288
2116
  drainSteering: () => drainPendingSteering(),
1289
2117
  onSteerMessage: (text) => {
2118
+ // Steering can be injected after a terminal no-tool response has
2119
+ // already streamed but before runTurn finalizes. Seal the current
2120
+ // assistant segment first so the steered user turn and the next
2121
+ // assistant response do not get visually merged into one bubble.
2122
+ flushStreamBatch();
2123
+ if (currentAssistantId) {
2124
+ patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
2125
+ closeAssistantSegment();
2126
+ }
2127
+ assistantText = '';
1290
2128
  const value = String(text || '').trim();
1291
2129
  if (value) {
1292
2130
  finalizeToolHeaders();
@@ -1295,17 +2133,20 @@ export async function createEngineSession({
1295
2133
  },
1296
2134
  onToolCall: async (_iter, calls) => {
1297
2135
  markPromptCommitted();
2136
+ // Always flush any buffered mid-turn assistant text before the tool
2137
+ // card appears. Without this, when neither a thinking panel nor a
2138
+ // spinner is active the buffered text was dropped by the following
2139
+ // closeAssistantSegment(), so the message above the tool card vanished.
2140
+ flushStreamBatch();
1298
2141
  if (thinkingText && state.thinking) {
1299
2142
  const thinkingLastEndedAt = closeThinkingSegment();
1300
- flushStreamBatch(); // flush any buffered text/thinking before the tool card appears
1301
2143
  set({ thinking: null, spinner: state.spinner ? { ...state.spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : state.spinner });
1302
2144
  } else if (state.spinner) {
1303
- flushStreamBatch(); // flush any buffered text before the tool card appears
1304
2145
  set({ spinner: { ...state.spinner, mode: 'tool-use' } });
1305
2146
  }
1306
2147
  const batchCalls = (calls || []).filter(Boolean);
1307
2148
  if (batchCalls.length === 0) return;
1308
- closeAssistantSegment();
2149
+ commitAssistantSegment({ sealToolBlock: true });
1309
2150
 
1310
2151
  const touchedAggregates = new Set();
1311
2152
  for (let i = 0; i < batchCalls.length; i++) {
@@ -1314,26 +2155,37 @@ export async function createEngineSession({
1314
2155
  const args = toolCallArgs(c);
1315
2156
  const category = classifyToolCategory(name, args);
1316
2157
  const bucket = aggregateBucketForCategory(category);
2158
+ const categoryEntry = aggregateToolCategoryEntry(name, args, category);
1317
2159
  const callId = toolCallId(c);
1318
2160
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
1319
2161
 
1320
2162
  if (!bucket) {
1321
2163
  openAggregateCard = null;
1322
2164
  const itemId = nextId();
1323
- pushItem({
1324
- kind: 'tool',
1325
- id: itemId,
1326
- name,
1327
- args,
1328
- result: null,
1329
- isError: false,
1330
- expanded: false,
1331
- headerFinalized: false,
1332
- count: 1,
1333
- completedCount: 0,
1334
- startedAt: Date.now(),
1335
- });
1336
- const card = { itemId, callId: callKey, done: false };
2165
+ // Defer the visible push: hold the spec and only enter the
2166
+ // transcript when the real header/detail will paint (delay
2167
+ // elapsed) or its result lands first. Avoids reserving blank
2168
+ // placeholder height that scrolls the body ahead of the glyphs.
2169
+ const card = {
2170
+ itemId,
2171
+ callId: callKey,
2172
+ done: false,
2173
+ pushed: false,
2174
+ spec: {
2175
+ kind: 'tool',
2176
+ id: itemId,
2177
+ name,
2178
+ args,
2179
+ result: null,
2180
+ isError: false,
2181
+ expanded: false,
2182
+ headerFinalized: false,
2183
+ count: 1,
2184
+ completedCount: 0,
2185
+ startedAt: Date.now(),
2186
+ },
2187
+ };
2188
+ registerDeferredCard(card);
1337
2189
  if (callId) {
1338
2190
  cardByCallId.set(callId, card);
1339
2191
  }
@@ -1342,9 +2194,13 @@ export async function createEngineSession({
1342
2194
  }
1343
2195
 
1344
2196
  const aggregateCard = ensureAggregateCard(bucket);
1345
- if (!aggregateCard.categories.has(category)) aggregateCard.categoryOrder.push(category);
1346
- aggregateCard.categories.set(category, (aggregateCard.categories.get(category) || 0) + 1);
1347
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false });
2197
+ if (!aggregateCard.categories.has(categoryEntry.key)) aggregateCard.categoryOrder.push(categoryEntry.key);
2198
+ const prevCategory = aggregateCard.categories.get(categoryEntry.key);
2199
+ aggregateCard.categories.set(categoryEntry.key, {
2200
+ ...categoryEntry,
2201
+ count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
2202
+ });
2203
+ aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
1348
2204
  touchedAggregates.add(aggregateCard);
1349
2205
  const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
1350
2206
  if (callId) {
@@ -1356,21 +2212,62 @@ export async function createEngineSession({
1356
2212
  for (const aggregateCard of touchedAggregates) {
1357
2213
  syncAggregateHeader(aggregateCard);
1358
2214
  }
2215
+ for (const [bufferedCallId, bufferedMessage] of earlyResultBuffer) {
2216
+ if (!cardByCallId.has(bufferedCallId)) continue;
2217
+ deliverToolResultMessage(bufferedMessage);
2218
+ earlyResultBuffer.delete(bufferedCallId);
2219
+ }
1359
2220
  await yieldToRenderer();
1360
2221
  },
1361
2222
  onToolResult: (message) => {
1362
- flushToolResults([message], toolCards, cardByCallId, toolGroups, resultsDone);
2223
+ const callId = toolResultCallId(message);
2224
+ if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
2225
+ earlyResultBuffer.set(callId, message);
2226
+ return;
2227
+ }
2228
+ deliverToolResultMessage(message);
2229
+ },
2230
+ onToolApproval: async (request) => {
2231
+ markPromptCommitted();
2232
+ flushStreamBatch();
2233
+ if (state.spinner) set({ spinner: { ...state.spinner, mode: 'tool-approval' } });
2234
+ return await requestToolApproval(request);
2235
+ },
2236
+ onCompactEvent: (event) => {
2237
+ flushStreamBatch();
2238
+ pushItem({
2239
+ kind: 'statusdone',
2240
+ id: nextId(),
2241
+ label: compactEventLabel(event),
2242
+ detail: compactEventDetail(event),
2243
+ });
1363
2244
  },
1364
2245
  onStageChange: (stage) => {
1365
2246
  if (!state.spinner) return;
1366
2247
  const value = String(stage || '');
2248
+ if (value === 'compacting') {
2249
+ compactingActive = true;
2250
+ const thinkingLastEndedAt = closeThinkingSegment();
2251
+ _pendingThinkFlush = false;
2252
+ set({
2253
+ thinking: null,
2254
+ spinner: {
2255
+ ...state.spinner,
2256
+ thinking: false,
2257
+ thinkingSegmentStartedAt: 0,
2258
+ thinkingAccumulatedMs: accumulatedThinkingMs,
2259
+ thinkingLastEndedAt: thinkingLastEndedAt || state.spinner.thinkingLastEndedAt || 0,
2260
+ mode: 'compacting',
2261
+ },
2262
+ });
2263
+ return;
2264
+ }
2265
+ if (value === 'requesting' || value === 'streaming') compactingActive = false;
1367
2266
  const mode = value === 'requesting'
1368
2267
  ? 'requesting'
1369
2268
  : value === 'streaming'
1370
2269
  ? (state.spinner.thinking ? 'thinking' : 'responding')
1371
- : value === 'compacting'
1372
- ? 'compacting'
1373
- : null;
2270
+ : null;
1374
2271
  if (!mode || state.spinner.mode === mode) return;
1375
2272
  set({ spinner: { ...state.spinner, mode } });
1376
2273
  },
@@ -1380,15 +2277,40 @@ export async function createEngineSession({
1380
2277
  markPromptCommitted();
1381
2278
  const thinkingLastEndedAt = closeThinkingSegment();
1382
2279
  if (state.thinking) set({ thinking: null }); // collapse thinking panel immediately, no batch delay
1383
- if (textChunk.trim()) clearAggregateContinuation();
1384
2280
  assistantText += textChunk;
1385
- ensureAssistant(); // create the assistant item in state immediately so the slot exists
1386
2281
  currentAssistantText += textChunk;
1387
- // Accumulate text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
2282
+ // Accumulate text and schedule a batched flush (≤1 render per
2283
+ // STREAM_BATCH_INTERVAL_MS). Without scheduling, mid-turn text only
2284
+ // surfaced via the tool-call/finalize flush, so a text→tool segment
2285
+ // with no spinner/thinking dropped the message above the tool card.
1388
2286
  _pendingTextFlush = true;
1389
2287
  if (thinkingLastEndedAt) _pendingThinkingLastEndedAt = thinkingLastEndedAt;
1390
2288
  scheduleStreamFlush();
1391
2289
  },
2290
+ onAssistantText: (text) => {
2291
+ // Mid-turn assistant text that precedes a tool call. Providers that
2292
+ // stream via onTextDelta already accumulated it into assistantText;
2293
+ // providers that only return the final response.content (no deltas)
2294
+ // never fired onTextDelta, so without this the preamble shows nothing
2295
+ // before the tool card. De-dup against already-streamed text so the
2296
+ // streaming path is unaffected.
2297
+ const full = String(text ?? '');
2298
+ if (!full.trim()) return;
2299
+ // If the streaming path already produced text for THIS segment,
2300
+ // onTextDelta owns the render — content is the same accumulated text
2301
+ // (or a superset), so skip to avoid double-printing the preamble.
2302
+ // Do not check turn-global assistantText: earlier closed preambles stay
2303
+ // there across tool calls, and would suppress later non-streaming
2304
+ // preambles even though currentAssistantText has been reset.
2305
+ if (currentAssistantText.trim()) return;
2306
+ markPromptCommitted();
2307
+ closeThinkingSegment();
2308
+ if (state.thinking) set({ thinking: null });
2309
+ assistantText += full;
2310
+ currentAssistantText += full;
2311
+ _pendingTextFlush = true;
2312
+ flushStreamBatch();
2313
+ },
1392
2314
  onReasoningDelta: (chunk) => {
1393
2315
  if (String(chunk ?? '')) markPromptCommitted();
1394
2316
  startThinkingSegment();
@@ -1399,7 +2321,7 @@ export async function createEngineSession({
1399
2321
  },
1400
2322
  onUsageDelta: (delta) => {
1401
2323
  applyUsageDelta(state.stats, delta);
1402
- syncContextStats();
2324
+ syncContextStats({ allowEstimated: true });
1403
2325
  const currentTurnInput = Math.max(0, state.stats.inputTokens - inputBaseline);
1404
2326
  const currentTurnOutput = Math.max(0, state.stats.outputTokens - outputBaseline);
1405
2327
  if (state.spinner) {
@@ -1409,24 +2331,30 @@ export async function createEngineSession({
1409
2331
  }
1410
2332
  },
1411
2333
  });
2334
+ askResult = result;
1412
2335
  markPromptCommitted();
1413
- if (result?.postTurnCompact?.changed) autoCompactStatus = result.postTurnCompact;
1414
2336
 
1415
2337
  flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
1416
2338
  finalizeToolHeaders();
1417
2339
  flushStreamBatch(); // force-flush any batched streaming text before finalization writes
1418
- syncContextStats();
1419
-
1420
- const finalText = (result?.content != null && String(result.content)) || assistantText;
1421
- if (assistantText.trim() && currentAssistantId) {
1422
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1423
- }
1424
- if (finalText && !assistantText.trim()) {
1425
- const id = ensureAssistant();
2340
+ syncContextStats({ allowEstimated: true });
2341
+
2342
+ const finalText = result?.content != null ? String(result.content) : '';
2343
+ if (finalText.trim()) {
2344
+ // The persisted transcript is written from the provider's final content,
2345
+ // while the live TUI row is fed by streaming deltas. If a provider/parser
2346
+ // misses or suppresses an early delta, keeping the streamed buffer here
2347
+ // leaves the final on-screen assistant row missing leading characters even
2348
+ // though the transcript is correct. Always reconcile the active segment to
2349
+ // the final provider text when it is available.
2350
+ const id = currentAssistantId || ensureAssistant(finalText);
1426
2351
  currentAssistantText = finalText;
1427
2352
  patchItem(id, { text: finalText, streaming: false });
2353
+ } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
2354
+ const streamedText = currentAssistantText || assistantText;
2355
+ patchItem(currentAssistantId, { text: streamedText, streaming: false });
1428
2356
  }
1429
- state.stats.turns = (state.stats.turns || 0) + 1;
2357
+ turnFinishedNormally = true;
1430
2358
  } catch (error) {
1431
2359
  flushStreamBatch(); // ensure any batched text lands before the error notice
1432
2360
  if (error?.name === 'SessionClosedError') {
@@ -1446,6 +2374,17 @@ export async function createEngineSession({
1446
2374
  pushNotice(toolErrorDisplay(error, 'turn'), 'error');
1447
2375
  }
1448
2376
  } finally {
2377
+ denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
2378
+ // Flush any still-deferred tool cards into the transcript and cancel their
2379
+ // pending push timers so nothing fires (or leaks) after the turn ends. The
2380
+ // finalize path above already patches results onto visible cards; this just
2381
+ // guarantees every registered card is materialized before the turn closes.
2382
+ if (deferredEntries.length) {
2383
+ const last = deferredEntries[deferredEntries.length - 1];
2384
+ if (last) flushDeferredUpTo(last);
2385
+ clearDeferredTimers();
2386
+ }
2387
+ const producedTranscriptItem = state.items.length > itemsAtTurnStart;
1449
2388
  const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
1450
2389
  activePromptRestore = null;
1451
2390
  closeThinkingSegment();
@@ -1453,25 +2392,24 @@ export async function createEngineSession({
1453
2392
  const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
1454
2393
  const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(Number(state.spinner?.responseLength || 0) / 4));
1455
2394
  const turnStatus = cancelled ? 'cancelled' : 'done';
2395
+ const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
2396
+ const assistantOutput = (currentAssistantText || assistantText || '').trim();
2397
+ // Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
2398
+ const isNoOpTurn = turnFinishedNormally
2399
+ && !cancelled
2400
+ && toolCards.length === 0
2401
+ && !resultContent
2402
+ && !assistantOutput
2403
+ && !producedTranscriptItem;
2404
+ if (!isNoOpTurn) {
2405
+ state.stats.turns = (state.stats.turns || 0) + 1;
2406
+ }
1456
2407
  // Pin the post-think summary into the transcript right after this turn's
1457
2408
  // output so it scrolls up with the answer and stays in the scrollback,
1458
- // mirroring Claude Code. (Previously TurnDone rendered only in the
2409
+ // in scrollback. (Previously TurnDone rendered only in the
1459
2410
  // bottom-fixed live-status slot and vanished on the next turn.)
1460
- if (!reclaimed) {
2411
+ if (!reclaimed && !isNoOpTurn) {
1461
2412
  pushItem({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1462
- if (!cancelled && autoCompactStatus?.changed) {
1463
- const before = formatTokenCount(autoCompactStatus.beforeTokens || autoCompactStatus.pressureTokens || 0);
1464
- const after = formatTokenCount(autoCompactStatus.afterTokens || 0);
1465
- const trigger = formatTokenCount(autoCompactStatus.triggerTokens || 0);
1466
- pushItem({
1467
- kind: 'statusdone',
1468
- id: nextId(),
1469
- label: 'Auto-compact complete',
1470
- detail: [`context ${before}→${after}`, trigger !== '0' ? `trigger ${trigger}` : '']
1471
- .filter(Boolean)
1472
- .join(' · '),
1473
- });
1474
- }
1475
2413
  }
1476
2414
  set({
1477
2415
  busy: false,
@@ -1481,8 +2419,9 @@ export async function createEngineSession({
1481
2419
  stats: { ...state.stats },
1482
2420
  ...routeState(),
1483
2421
  toolMode: runtime.toolMode,
1484
- ...bridgeStatusState(),
2422
+ ...agentStatusState({ force: true }),
1485
2423
  });
2424
+ flushDeferredExecutionPendingResumeKick();
1486
2425
  }
1487
2426
  return cancelled ? 'cancelled' : 'done';
1488
2427
  }
@@ -1504,6 +2443,7 @@ export async function createEngineSession({
1504
2443
  mode,
1505
2444
  priority,
1506
2445
  key: options.key || null,
2446
+ skipSlashCommands: options.skipSlashCommands === true,
1507
2447
  displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
1508
2448
  };
1509
2449
  }
@@ -1512,7 +2452,8 @@ export async function createEngineSession({
1512
2452
  const ids = new Set(entries.map((entry) => entry.id));
1513
2453
  const keys = entries.map((entry) => entry.key).filter(Boolean);
1514
2454
  for (const key of keys) pendingNotificationKeys.delete(key);
1515
- set({ queued: state.queued.filter((q) => !ids.has(q.id)) });
2455
+ const queued = state.queued.filter((q) => !ids.has(q.id));
2456
+ if (queued.length !== state.queued.length) set({ queued });
1516
2457
  }
1517
2458
 
1518
2459
  function requeueEntriesFront(entries) {
@@ -1531,16 +2472,19 @@ export async function createEngineSession({
1531
2472
  }
1532
2473
  if (restored.length === 0) return false;
1533
2474
  pending.unshift(...restored);
1534
- set({ queued: [...restored, ...state.queued] });
2475
+ const visible = restored.filter(isQueuedEntryVisible);
2476
+ if (visible.length > 0) set({ queued: [...visible, ...state.queued] });
1535
2477
  return true;
1536
2478
  }
1537
2479
 
1538
- function dequeueQueueBatch(maxPriority = 'later') {
2480
+ function dequeueQueueBatch(maxPriority = 'later', options = {}) {
1539
2481
  if (pending.length === 0) return [];
1540
2482
  const max = queuePriorityValue(maxPriority);
2483
+ const predicate = typeof options.predicate === 'function' ? options.predicate : () => true;
1541
2484
  let bestPriority = Infinity;
1542
2485
  let targetMode = null;
1543
2486
  for (const entry of pending) {
2487
+ if (!predicate(entry)) continue;
1544
2488
  const p = queuePriorityValue(entry.priority);
1545
2489
  if (p > max) continue;
1546
2490
  if (p < bestPriority) {
@@ -1552,7 +2496,7 @@ export async function createEngineSession({
1552
2496
  const batch = [];
1553
2497
  for (let i = 0; i < pending.length;) {
1554
2498
  const entry = pending[i];
1555
- if ((entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
2499
+ if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
1556
2500
  batch.push(entry);
1557
2501
  pending.splice(i, 1);
1558
2502
  } else {
@@ -1568,7 +2512,7 @@ export async function createEngineSession({
1568
2512
  draining = true;
1569
2513
  try {
1570
2514
  while (pending.length > 0) {
1571
- // Drain one priority/mode bucket at a time, matching Claude Code's
2515
+ // Drain one priority/mode bucket at a time (unified command queue):
1572
2516
  // unified command queue semantics: prompt steering stays editable and
1573
2517
  // task notifications stay non-editable but model-visible.
1574
2518
  const batch = dequeueQueueBatch('later');
@@ -1576,6 +2520,7 @@ export async function createEngineSession({
1576
2520
  const ids = new Set(batch.map((e) => e.id));
1577
2521
  const merged = mergePromptContents(batch);
1578
2522
  for (const entry of batch) {
2523
+ if (entry.mode === 'pending-resume') continue;
1579
2524
  pushUserOrSyntheticItem(entry.text, entry.id);
1580
2525
  }
1581
2526
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
@@ -1596,6 +2541,8 @@ export async function createEngineSession({
1596
2541
  }
1597
2542
  } finally {
1598
2543
  draining = false;
2544
+ if (pending.length > 0) void drain();
2545
+ else flushDeferredExecutionPendingResumeKick();
1599
2546
  }
1600
2547
  }
1601
2548
  function enqueue(text, options = {}) {
@@ -1605,13 +2552,17 @@ export async function createEngineSession({
1605
2552
  pendingNotificationKeys.add(entry.key);
1606
2553
  }
1607
2554
  pending.push(entry);
1608
- set({ queued: [...state.queued, entry] });
2555
+ if (isQueuedEntryVisible(entry)) set({ queued: [...state.queued, entry] });
1609
2556
  void drain();
1610
2557
  return true;
1611
2558
  }
1612
2559
 
1613
2560
  function drainPendingSteering() {
1614
- const batch = dequeueQueueBatch('next');
2561
+ // 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.
2565
+ const batch = dequeueQueueBatch('next', { predicate: (entry) => !isSlashQueuedEntry(entry) });
1615
2566
  if (batch.length === 0) return [];
1616
2567
  const out = batch
1617
2568
  .map((entry) => {
@@ -1631,24 +2582,20 @@ export async function createEngineSession({
1631
2582
  async function autoClearBeforeSubmit() {
1632
2583
  const cfg = autoClearState();
1633
2584
  const now = Date.now();
1634
- const idleMs = now - lastUserActivityAt;
2585
+ const activityAt = sessionActivityTimestamp(runtime.session, lastUserActivityAt);
2586
+ const idleMs = activityAt ? now - activityAt : 0;
1635
2587
  if (!cfg.enabled || state.busy || pending.length > 0 || autoClearRunning || idleMs < cfg.idleMs) {
1636
- lastUserActivityAt = now;
2588
+ if (!activityAt) lastUserActivityAt = now;
1637
2589
  return false;
1638
2590
  }
1639
2591
  autoClearRunning = true;
2592
+ const startedAt = Date.now();
2593
+ set({ commandStatus: { active: true, verb: 'Auto-clearing idle conversation', startedAt, mode: 'auto-clear' } });
1640
2594
  try {
1641
- const beforeContext = runtime.contextStatus?.() || null;
1642
- await runtime.clear();
2595
+ await runtime.clear({ compactType: cfg.compactType || null, requireCompactSuccess: !!cfg.compactType });
1643
2596
  resetStats();
1644
- const afterContext = syncContextStats({ allowEstimated: true }) || runtime.contextStatus?.() || null;
1645
- const idleLabel = formatIdleDuration(idleMs);
1646
- const thresholdLabel = formatIdleDuration(cfg.idleMs);
1647
- const beforeTokens = Number(beforeContext?.usedTokens || beforeContext?.currentEstimatedTokens || beforeContext?.usage?.lastContextTokens || 0);
1648
- const afterTokens = Number(afterContext?.usedTokens || afterContext?.currentEstimatedTokens || afterContext?.usage?.lastContextTokens || 0);
1649
- const contextDetail = beforeTokens > 0 || afterTokens > 0
1650
- ? `context ${formatTokenCount(beforeTokens)}→${formatTokenCount(afterTokens)}`
1651
- : 'context reset';
2597
+ clearUiActivityBeforeContextSync();
2598
+ syncContextStats({ allowEstimated: true });
1652
2599
  set({
1653
2600
  items: replaceItems([]),
1654
2601
  toasts: [],
@@ -1663,17 +2610,23 @@ export async function createEngineSession({
1663
2610
  kind: 'statusdone',
1664
2611
  id: nextId(),
1665
2612
  label: 'Auto-clear complete',
1666
- detail: [idleLabel ? `idle ${idleLabel}` : '', contextDetail, thresholdLabel ? `threshold ${thresholdLabel}` : '']
1667
- .filter(Boolean)
1668
- .join(' · '),
2613
+ detail: formatElapsedSeconds(Date.now() - startedAt),
1669
2614
  });
1670
2615
  return true;
1671
2616
  } catch (error) {
1672
- pushNotice(`auto-clear failed: ${error?.message || error}`, 'error');
2617
+ const message = presentErrorText(error, { surface: 'auto-clear' });
2618
+ pushItem({
2619
+ kind: 'statusdone',
2620
+ id: nextId(),
2621
+ label: 'Auto-clear skipped',
2622
+ detail: `conversation kept · ${message}`,
2623
+ });
2624
+ pushNotice(`auto-clear skipped: ${message}`, 'error');
1673
2625
  return false;
1674
2626
  } finally {
1675
2627
  lastUserActivityAt = Date.now();
1676
2628
  autoClearRunning = false;
2629
+ set({ commandStatus: null });
1677
2630
  }
1678
2631
  }
1679
2632
 
@@ -1698,6 +2651,63 @@ export async function createEngineSession({
1698
2651
  state.stats = createSessionStats();
1699
2652
  return state.stats;
1700
2653
  };
2654
+ const clearUiActivityBeforeContextSync = () => {
2655
+ state.items = replaceItems([]);
2656
+ state.toasts = [];
2657
+ state.queued = [];
2658
+ state.thinking = null;
2659
+ state.spinner = null;
2660
+ state.lastTurn = null;
2661
+ state.busy = false;
2662
+ };
2663
+ const resetTuiForPendingSessionReset = () => {
2664
+ pendingSessionReset = true;
2665
+ clearUiActivityBeforeContextSync();
2666
+ resetStats();
2667
+ state.stats.currentContextTokens = 0;
2668
+ state.stats.currentEstimatedContextTokens = 0;
2669
+ state.stats.currentContextSource = null;
2670
+ state.stats.currentContextUpdatedAt = Date.now();
2671
+ state.displayContextWindow = 0;
2672
+ state.compactBoundaryTokens = 0;
2673
+ state.autoCompactTokenLimit = 0;
2674
+ };
2675
+ const snapshotTuiBeforeSessionReset = () => ({
2676
+ items: state.items.slice(),
2677
+ toasts: state.toasts.slice(),
2678
+ queued: state.queued.slice(),
2679
+ thinking: state.thinking,
2680
+ spinner: state.spinner,
2681
+ lastTurn: state.lastTurn,
2682
+ busy: state.busy,
2683
+ stats: { ...state.stats },
2684
+ sessionId: state.sessionId,
2685
+ });
2686
+ const restoreTuiAfterFailedSessionReset = (snapshot) => {
2687
+ if (!snapshot) return;
2688
+ pendingSessionReset = false;
2689
+ state.items = replaceItems(snapshot.items);
2690
+ state.toasts = snapshot.toasts.slice();
2691
+ state.queued = snapshot.queued.slice();
2692
+ state.thinking = snapshot.thinking;
2693
+ state.spinner = snapshot.spinner;
2694
+ state.lastTurn = snapshot.lastTurn;
2695
+ state.busy = snapshot.busy;
2696
+ state.stats = { ...snapshot.stats };
2697
+ syncContextStats({ allowEstimated: true });
2698
+ set({
2699
+ items: state.items,
2700
+ toasts: state.toasts,
2701
+ queued: state.queued,
2702
+ thinking: state.thinking,
2703
+ spinner: state.spinner,
2704
+ lastTurn: state.lastTurn,
2705
+ busy: state.busy,
2706
+ ...routeState(),
2707
+ stats: { ...state.stats },
2708
+ ...agentStatusState(),
2709
+ });
2710
+ };
1701
2711
  const resetStatsAndSyncContext = () => {
1702
2712
  resetStats();
1703
2713
  syncContextStats({ allowEstimated: true });
@@ -1715,11 +2725,10 @@ export async function createEngineSession({
1715
2725
  const t = promptDisplayText(text, options).trim();
1716
2726
  if (!t || state.commandBusy) return false;
1717
2727
  const mode = options.mode || 'prompt';
1718
- // Plain user input entered while a turn is busy is a post-turn follow-up
1719
- // by default, so it lands after any auto-compact/save boundary instead of
1720
- // being swallowed as mid-turn steering. Task notifications still opt into
1721
- // priority: 'next' and are injected at the next tool boundary.
1722
- const priority = options.priority;
2728
+ // Prompt input queued while a turn is active keeps the
2729
+ // default `next` priority, so it is injected at the next tool/model
2730
+ // boundary. Explicit options.priority still wins.
2731
+ const priority = options.priority;
1723
2732
  const queueOptions = {
1724
2733
  ...options,
1725
2734
  mode,
@@ -1738,8 +2747,8 @@ export async function createEngineSession({
1738
2747
  if (state.commandBusy) return false;
1739
2748
  set({ commandBusy: true });
1740
2749
  try {
1741
- await runtime.setRoute({ model: m });
1742
- resetStatsAndSyncContext();
2750
+ await runtime.setRoute({ model: m }, { applyToCurrentSession: true });
2751
+ syncContextStats({ allowEstimated: true });
1743
2752
  set({ ...routeState(), stats: { ...state.stats } });
1744
2753
  return true;
1745
2754
  } finally {
@@ -1787,34 +2796,75 @@ export async function createEngineSession({
1787
2796
  })
1788
2797
  .catch((error) => pushNotice(toolErrorDisplay(error, 'tool'), 'error'));
1789
2798
  },
1790
- toggleBridgeMode: () => {
1791
- const mode = runtime.toggleBridgeMode();
1792
- set(bridgeStatusState({ force: true }));
1793
- pushNotice(`bridge mode -> ${mode}`, 'info');
1794
- return mode;
1795
- },
1796
- setBridgeMode: (mode) => {
1797
- const next = runtime.setBridgeMode(mode);
1798
- set(bridgeStatusState({ force: true }));
1799
- return next;
1800
- },
1801
2799
  getAutoClear: () => autoClearState(),
1802
2800
  setAutoClear: (input = {}) => {
1803
2801
  const next = runtime.setAutoClear?.(input) || autoClearState();
1804
2802
  set({ autoClear: next });
1805
2803
  return next;
1806
2804
  },
1807
- bridgeControl: async (args = {}) => {
2805
+ getProfile: () => runtime.getProfile?.() || { title: '', language: 'system', languages: [] },
2806
+ setProfile: (input = {}) => {
2807
+ const next = runtime.setProfile?.(input) || runtime.getProfile?.() || null;
2808
+ return next;
2809
+ },
2810
+ getCompactionSettings: () => {
2811
+ return runtime.getCompactionSettings?.() || {};
2812
+ },
2813
+ setCompactionSettings: async (input = {}) => {
2814
+ if (state.commandBusy) return null;
2815
+ set({ commandBusy: true });
2816
+ try {
2817
+ const next = runtime.setCompactionSettings?.(input) || {};
2818
+ syncContextStats({ allowEstimated: true });
2819
+ set({ ...routeState(), stats: { ...state.stats } });
2820
+ return next;
2821
+ } finally {
2822
+ set({ commandBusy: false });
2823
+ }
2824
+ },
2825
+ getMemorySettings: () => {
2826
+ return runtime.getMemorySettings?.() || { enabled: true };
2827
+ },
2828
+ setMemoryEnabled: async (enabled) => {
1808
2829
  if (state.commandBusy) return null;
1809
2830
  set({ commandBusy: true });
1810
2831
  try {
1811
- const result = await runtime.bridgeControl(args);
1812
- const text = String(result || '').trim() || '(empty bridge result)';
2832
+ const next = await runtime.setMemoryEnabled?.(enabled);
2833
+ syncContextStats({ allowEstimated: true });
2834
+ set({ ...routeState(), stats: { ...state.stats } });
2835
+ return next;
2836
+ } finally {
2837
+ set({ commandBusy: false });
2838
+ }
2839
+ },
2840
+ getChannelSettings: (options = {}) => {
2841
+ return runtime.getChannelSettings?.(options) || {
2842
+ enabled: true,
2843
+ ...(options?.includeStatus === false ? {} : { status: runtime.getChannelWorkerStatus?.() }),
2844
+ };
2845
+ },
2846
+ setChannelsEnabled: async (enabled) => {
2847
+ if (state.commandBusy) return null;
2848
+ set({ commandBusy: true });
2849
+ try {
2850
+ const next = await runtime.setChannelsEnabled?.(enabled);
2851
+ set({ ...routeState(), stats: { ...state.stats } });
2852
+ return next;
2853
+ } finally {
2854
+ set({ commandBusy: false });
2855
+ }
2856
+ },
2857
+ agentControl: async (args = {}) => {
2858
+ if (state.commandBusy) return null;
2859
+ set({ commandBusy: true });
2860
+ try {
2861
+ const result = await runtime.agentControl(args);
2862
+ const text = String(result ?? '').trim();
1813
2863
  const itemId = nextId();
1814
2864
  pushItem({
1815
2865
  kind: 'tool',
1816
2866
  id: itemId,
1817
- name: 'bridge',
2867
+ name: 'agent',
1818
2868
  args,
1819
2869
  result: null,
1820
2870
  isError: false,
@@ -1823,8 +2873,8 @@ export async function createEngineSession({
1823
2873
  completedCount: 0,
1824
2874
  startedAt: Date.now(),
1825
2875
  });
1826
- updateBridgeJobCard(itemId, text, /^error:/i.test(text));
1827
- set(bridgeStatusState({ force: true }));
2876
+ updateAgentJobCard(itemId, text, /^error:/i.test(text));
2877
+ set(agentStatusState({ force: true }));
1828
2878
  return result;
1829
2879
  } finally {
1830
2880
  set({ commandBusy: false });
@@ -1845,10 +2895,12 @@ export async function createEngineSession({
1845
2895
  );
1846
2896
  return result;
1847
2897
  },
1848
- setCwd: (path) => {
2898
+ setCwd: (path, options = {}) => {
1849
2899
  const next = runtime.setCwd(path);
1850
2900
  set({ cwd: next });
1851
- pushNotice(`cwd -> ${next}`, 'info');
2901
+ if (options?.notice !== false) {
2902
+ pushNotice(options?.message || `Project set: ${projectNameFromPath(next)}`, 'info');
2903
+ }
1852
2904
  return next;
1853
2905
  },
1854
2906
  getSystemShell: () => {
@@ -2065,14 +3117,36 @@ export async function createEngineSession({
2065
3117
  },
2066
3118
  compact: async () => {
2067
3119
  if (state.commandBusy) return null;
3120
+ if (state.busy) {
3121
+ pushNotice('Compact skipped: turn in progress', 'info');
3122
+ return { changed: false, reason: 'compact skipped: turn in progress' };
3123
+ }
2068
3124
  const startedAt = Date.now();
2069
3125
  set({ commandBusy: true, commandStatus: { active: true, verb: 'Compacting conversation', startedAt, mode: 'compacting' } });
2070
3126
  try {
2071
- const result = await runtime.compact({ recoverBridge: true });
3127
+ const result = await runtime.compact({ recoverAgent: true });
2072
3128
  syncContextStats({ allowEstimated: true });
2073
3129
  set({ ...routeState(), stats: { ...state.stats } });
2074
3130
  if (result) {
2075
- pushItem({ kind: 'turndone', id: nextId(), elapsedMs: Date.now() - startedAt, status: 'done', outputTokens: 0, thinkingElapsedMs: 0, verb: 'Compacted' });
3131
+ pushItem({
3132
+ kind: 'statusdone',
3133
+ id: nextId(),
3134
+ label: result.error ? 'Compact failed' : (result.changed === false ? 'Compact checked' : 'Compact complete'),
3135
+ detail: compactEventDetail({
3136
+ stage: 'manual',
3137
+ trigger: 'manual',
3138
+ status: result.error ? 'failed' : (result.changed === false ? 'no_change' : 'compacted'),
3139
+ compactType: result.compactType,
3140
+ beforeTokens: result.beforeTokens,
3141
+ afterTokens: result.afterTokens,
3142
+ beforeMessages: result.beforeMessages,
3143
+ afterMessages: result.afterMessages,
3144
+ semantic: result.semanticCompact,
3145
+ recallFastTrack: result.recallFastTrack,
3146
+ durationMs: Date.now() - startedAt,
3147
+ error: result.error,
3148
+ }),
3149
+ });
2076
3150
  }
2077
3151
  return result;
2078
3152
  } finally {
@@ -2081,6 +3155,7 @@ export async function createEngineSession({
2081
3155
  },
2082
3156
  abort: () => {
2083
3157
  if (!state.busy) return false;
3158
+ denyAllToolApprovals('interrupted by user');
2084
3159
  const restoreState = activePromptRestore;
2085
3160
  const restoreText = restoreState?.restorable ? restoreState.text : '';
2086
3161
  const restorePastedImages = restoreState?.restorable && restoreState?.pastedImages ? restoreState.pastedImages : null;
@@ -2107,12 +3182,49 @@ export async function createEngineSession({
2107
3182
  }
2108
3183
  return { aborted, restoreText, pastedImages: restorePastedImages };
2109
3184
  },
3185
+ resolveToolApproval: (id, decision = {}) => {
3186
+ const approved = decision === true || decision?.approved === true;
3187
+ return finishToolApproval(id, approved, decision?.reason || (approved ? 'approved by user' : 'denied by user'));
3188
+ },
2110
3189
  listPresets: () => {
2111
3190
  return runtime.listPresets();
2112
3191
  },
2113
3192
  listProviderModels: (options = {}) => {
2114
3193
  return runtime.listProviderModels(options);
2115
3194
  },
3195
+ getSearchRoute: () => {
3196
+ return runtime.getSearchRoute?.() || runtime.searchRoute || null;
3197
+ },
3198
+ listSearchModels: (options = {}) => {
3199
+ return runtime.listSearchModels?.(options) || [];
3200
+ },
3201
+ setSearchRoute: async (opts) => {
3202
+ if (state.commandBusy) return null;
3203
+ const beforeRouteState = routeState();
3204
+ const optimisticSearchRoute = opts?.provider && opts?.model
3205
+ ? {
3206
+ provider: String(opts.provider).trim(),
3207
+ model: String(opts.model).trim(),
3208
+ ...(opts.effort ? { effort: opts.effort } : {}),
3209
+ ...(opts.fast === true ? { fast: true } : {}),
3210
+ ...(opts.toolType ? { toolType: opts.toolType } : {}),
3211
+ }
3212
+ : null;
3213
+ set({ commandBusy: true });
3214
+ try {
3215
+ if (optimisticSearchRoute?.provider && optimisticSearchRoute.model) {
3216
+ set({ searchRoute: optimisticSearchRoute });
3217
+ }
3218
+ const result = await runtime.setSearchRoute?.(opts);
3219
+ set({ ...routeState(), stats: { ...state.stats } });
3220
+ return result;
3221
+ } catch (e) {
3222
+ set({ searchRoute: beforeRouteState.searchRoute || null });
3223
+ throw e;
3224
+ } finally {
3225
+ set({ commandBusy: false });
3226
+ }
3227
+ },
2116
3228
  listAgents: () => {
2117
3229
  return runtime.listAgents?.() || [];
2118
3230
  },
@@ -2141,13 +3253,24 @@ export async function createEngineSession({
2141
3253
  if (state.commandBusy) return null;
2142
3254
  set({ commandBusy: true });
2143
3255
  try {
2144
- const result = runtime.setWorkflow?.(workflowId);
3256
+ const result = await runtime.setWorkflow?.(workflowId);
2145
3257
  set({ ...routeState(), stats: { ...state.stats } });
2146
3258
  return result;
2147
3259
  } finally {
2148
3260
  set({ commandBusy: false });
2149
3261
  }
2150
3262
  },
3263
+ // Theme is a TUI-local concern (no runtime round-trip). listThemes returns
3264
+ // picker metadata; getTheme reports the active id; setTheme applies the
3265
+ // palette in-place + persists ui.theme and bumps a themeEpoch so the React
3266
+ // tree re-renders (markdown/status/spinner colorizers re-resolve).
3267
+ listThemes: () => listThemes(),
3268
+ getTheme: () => getThemeSetting(),
3269
+ setTheme: (id, options = {}) => {
3270
+ const applied = setThemeSetting(id, options);
3271
+ set({ themeEpoch: (state.themeEpoch || 0) + 1 });
3272
+ return applied;
3273
+ },
2151
3274
  setAgentRoute: async (agentId, opts) => {
2152
3275
  return await runtime.setAgentRoute?.(agentId, opts);
2153
3276
  },
@@ -2187,6 +3310,17 @@ export async function createEngineSession({
2187
3310
  set({ commandBusy: false });
2188
3311
  }
2189
3312
  },
3313
+ beginOAuthProviderLogin: async (provider) => {
3314
+ if (state.commandBusy) throw new Error('command busy');
3315
+ set({ commandBusy: true });
3316
+ try {
3317
+ const result = await runtime.beginOAuthProviderLogin(provider);
3318
+ pushNotice(`provider oauth started: ${result.provider}`, 'info');
3319
+ return result;
3320
+ } finally {
3321
+ set({ commandBusy: false });
3322
+ }
3323
+ },
2190
3324
  saveProviderApiKey: (provider, secret) => {
2191
3325
  const result = runtime.saveProviderApiKey(provider, secret);
2192
3326
  pushNotice(`provider api key saved: ${result.provider}`, 'info');
@@ -2298,8 +3432,11 @@ export async function createEngineSession({
2298
3432
  if (state.commandBusy) return false;
2299
3433
  set({ commandBusy: true });
2300
3434
  try {
2301
- await runtime.setRoute(opts);
2302
- resetStatsAndSyncContext();
3435
+ const routeOpts = opts && typeof opts === 'object' ? opts : {};
3436
+ const applyToCurrentSession = routeOpts.applyToCurrentSession === false ? false : true;
3437
+ const { applyToCurrentSession: _drop, ...nextRoute } = routeOpts;
3438
+ await runtime.setRoute(nextRoute, { applyToCurrentSession });
3439
+ if (applyToCurrentSession) syncContextStats({ allowEstimated: true });
2303
3440
  set({ ...routeState(), stats: { ...state.stats } });
2304
3441
  return true;
2305
3442
  } finally {
@@ -2311,13 +3448,31 @@ export async function createEngineSession({
2311
3448
  if (state.commandBusy) return false;
2312
3449
  set({ commandBusy: true });
2313
3450
  clearToastTimers();
3451
+ const rollbackSnapshot = snapshotTuiBeforeSessionReset();
3452
+ resetTuiForPendingSessionReset();
3453
+ set({
3454
+ items: state.items,
3455
+ toasts: state.toasts,
3456
+ queued: state.queued,
3457
+ thinking: null,
3458
+ spinner: null,
3459
+ lastTurn: null,
3460
+ sessionId: null,
3461
+ stats: { ...state.stats },
3462
+ });
2314
3463
  try {
2315
- await runtime.clear({ recoverBridge: true });
3464
+ await runtime.clear({ recoverAgent: true });
3465
+ clearUiActivityBeforeContextSync();
3466
+ pendingSessionReset = false;
2316
3467
  resetStatsAndSyncContext();
2317
3468
  set({ items: replaceItems([]), toasts: [], queued: [], thinking: null, spinner: null, lastTurn: null, ...routeState(), stats: { ...state.stats } });
2318
3469
  lastUserActivityAt = Date.now();
2319
3470
  return true;
3471
+ } catch (error) {
3472
+ restoreTuiAfterFailedSessionReset(rollbackSnapshot);
3473
+ throw error;
2320
3474
  } finally {
3475
+ pendingSessionReset = false;
2321
3476
  set({ commandBusy: false });
2322
3477
  }
2323
3478
  },
@@ -2328,12 +3483,30 @@ export async function createEngineSession({
2328
3483
  if (state.commandBusy) return false;
2329
3484
  set({ commandBusy: true });
2330
3485
  clearToastTimers();
3486
+ const rollbackSnapshot = snapshotTuiBeforeSessionReset();
3487
+ resetTuiForPendingSessionReset();
3488
+ set({
3489
+ items: state.items,
3490
+ toasts: state.toasts,
3491
+ queued: state.queued,
3492
+ thinking: null,
3493
+ spinner: null,
3494
+ lastTurn: null,
3495
+ sessionId: null,
3496
+ stats: { ...state.stats },
3497
+ });
2331
3498
  try {
2332
3499
  await runtime.newSession();
3500
+ clearUiActivityBeforeContextSync();
3501
+ pendingSessionReset = false;
2333
3502
  resetStatsAndSyncContext();
2334
3503
  set({ items: replaceItems([]), toasts: [], queued: [], thinking: null, spinner: null, lastTurn: null, ...routeState(), stats: { ...state.stats } });
2335
3504
  return true;
3505
+ } catch (error) {
3506
+ restoreTuiAfterFailedSessionReset(rollbackSnapshot);
3507
+ throw error;
2336
3508
  } finally {
3509
+ pendingSessionReset = false;
2337
3510
  set({ commandBusy: false });
2338
3511
  }
2339
3512
  },
@@ -2359,13 +3532,14 @@ export async function createEngineSession({
2359
3532
  items.push({
2360
3533
  kind: 'tool',
2361
3534
  id: nextId(),
2362
- name: synthetic.name || 'bridge',
3535
+ name: synthetic.name || 'agent',
2363
3536
  args: synthetic.args || {
2364
3537
  type: label,
2365
3538
  task_id: synthetic.taskId || undefined,
2366
3539
  description: synthetic.summary || 'agent notification',
2367
3540
  },
2368
3541
  result: synthetic.result,
3542
+ rawResult: synthetic.rawResult ?? text,
2369
3543
  isError: synthetic.isError ?? /^(failed|error|killed|cancelled)$/i.test(label),
2370
3544
  expanded: false,
2371
3545
  count: 1,
@@ -2401,8 +3575,10 @@ export async function createEngineSession({
2401
3575
  if (disposed) return;
2402
3576
  disposed = true;
2403
3577
  clearToastTimers();
3578
+ try { clearInterval(runtimePulseTimer); } catch {}
2404
3579
  try { unsubscribeRuntimeNotifications?.(); } catch {}
2405
3580
  unsubscribeRuntimeNotifications = null;
3581
+ denyAllToolApprovals('runtime closing');
2406
3582
  await runtime.close(reason, options);
2407
3583
  listeners.clear();
2408
3584
  },