mixdog 0.9.2 → 0.9.4

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 (417) hide show
  1. package/package.json +8 -3
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/review-tasks.json +20 -0
  7. package/scripts/bench/round-codex.json +114 -0
  8. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  9. package/scripts/bench/round-mixdog-lead.json +269 -0
  10. package/scripts/bench/round-mixdog.json +126 -0
  11. package/scripts/bench/round-r10-bigsample.json +679 -0
  12. package/scripts/bench/round-r11-codexalign.json +257 -0
  13. package/scripts/bench/round-r4-codex.json +114 -0
  14. package/scripts/bench/round-r4-mixed.json +225 -0
  15. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  16. package/scripts/bench/round-r6-codex.json +114 -0
  17. package/scripts/bench/round-r6-solo.json +257 -0
  18. package/scripts/bench/round-r7-full.json +254 -0
  19. package/scripts/bench/round-r8-fulldefault.json +255 -0
  20. package/scripts/bench-run.mjs +215 -29
  21. package/scripts/build-tui.mjs +13 -1
  22. package/scripts/explore-bench.mjs +124 -0
  23. package/scripts/freevar-smoke.mjs +95 -0
  24. package/scripts/hook-bus-test.mjs +191 -0
  25. package/scripts/internal-comms-bench.mjs +1 -0
  26. package/scripts/internal-comms-smoke.mjs +10 -9
  27. package/scripts/mouse-probe.mjs +45 -0
  28. package/scripts/output-style-bench.mjs +13 -6
  29. package/scripts/output-style-smoke.mjs +4 -4
  30. package/scripts/path-suffix-test.mjs +57 -0
  31. package/scripts/provider-toolcall-test.mjs +7 -3
  32. package/scripts/recall-bench.mjs +207 -0
  33. package/scripts/recall-usecase-cases.json +18 -0
  34. package/scripts/recall-usecase-probe.json +6 -0
  35. package/scripts/session-bench.mjs +152 -6
  36. package/scripts/tool-smoke.mjs +30 -67
  37. package/scripts/tui-render-smoke.mjs +90 -0
  38. package/scripts/webhook-smoke.mjs +208 -0
  39. package/src/agents/debugger/AGENT.md +5 -2
  40. package/src/agents/heavy-worker/AGENT.md +21 -11
  41. package/src/agents/maintainer/AGENT.md +4 -0
  42. package/src/agents/reviewer/AGENT.md +3 -2
  43. package/src/agents/worker/AGENT.md +21 -11
  44. package/src/lib/rules-builder.cjs +4 -0
  45. package/src/mixdog-session-runtime.mjs +933 -3731
  46. package/src/output-styles/default.md +34 -9
  47. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  48. package/src/output-styles/minimal.md +4 -1
  49. package/src/output-styles/simple.md +22 -7
  50. package/src/repl.mjs +5 -5
  51. package/src/rules/agent/00-common.md +2 -0
  52. package/src/rules/agent/30-explorer.md +8 -11
  53. package/src/rules/lead/lead-brief.md +12 -0
  54. package/src/rules/lead/lead-tool.md +2 -9
  55. package/src/rules/shared/01-tool.md +11 -5
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  60. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  61. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  62. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  63. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  65. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
  66. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
  70. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  71. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
  72. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  73. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  75. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  76. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  77. package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
  78. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  80. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  81. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  82. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  83. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
  89. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  90. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
  92. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
  93. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  94. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  95. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  96. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  97. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  99. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  100. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  101. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  102. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  103. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  104. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  105. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  106. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  107. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  108. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  109. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
  110. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  111. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  112. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  113. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  114. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
  115. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  116. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  117. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  118. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  119. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  120. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  121. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  122. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  123. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  124. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  125. package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
  126. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  127. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
  128. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  129. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
  130. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  131. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  132. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  133. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  137. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  138. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  139. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  141. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
  142. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  143. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  145. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  146. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  148. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  149. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
  152. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
  155. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
  156. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  157. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  158. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  159. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  161. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  163. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  164. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  165. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  166. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
  167. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  168. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  169. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  170. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  171. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  172. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  173. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  174. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  175. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  176. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  177. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  178. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  179. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  180. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  181. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  182. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  183. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  184. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
  185. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
  186. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  187. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  188. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  189. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  190. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  191. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  192. package/src/runtime/channels/backends/discord.mjs +12 -292
  193. package/src/runtime/channels/index.mjs +241 -894
  194. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  195. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  196. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  197. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  198. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  199. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  200. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  201. package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
  202. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  203. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  204. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  205. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  206. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  207. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  208. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  209. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  210. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  211. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  212. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  213. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  214. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  215. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  216. package/src/runtime/channels/lib/webhook.mjs +36 -570
  217. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  218. package/src/runtime/channels/tool-defs.mjs +11 -130
  219. package/src/runtime/memory/index.mjs +258 -2050
  220. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  221. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  222. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  223. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  224. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  225. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  226. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  227. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  228. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  229. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  230. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  231. package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
  232. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  233. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  234. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  235. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  236. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  237. package/src/runtime/memory/lib/memory.mjs +20 -0
  238. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  239. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  240. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  241. package/src/runtime/memory/lib/recall-format.mjs +238 -0
  242. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  243. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  244. package/src/runtime/memory/tool-defs.mjs +6 -14
  245. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  246. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  247. package/src/runtime/search/lib/web-tools.mjs +24 -602
  248. package/src/runtime/shared/abort-controller.mjs +1 -1
  249. package/src/runtime/shared/atomic-file.mjs +26 -1
  250. package/src/runtime/shared/background-tasks.mjs +2 -3
  251. package/src/runtime/shared/buffered-appender.mjs +149 -0
  252. package/src/runtime/shared/launcher-control.mjs +2 -2
  253. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  254. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  255. package/src/runtime/shared/tool-primitives.mjs +308 -0
  256. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  257. package/src/runtime/shared/tool-surface.mjs +80 -898
  258. package/src/runtime/shared/transcript-writer.mjs +52 -2
  259. package/src/runtime/shared/update-checker.mjs +7 -4
  260. package/src/session-runtime/config-helpers.mjs +291 -0
  261. package/src/session-runtime/config-lifecycle.mjs +232 -0
  262. package/src/session-runtime/cwd-plugins.mjs +226 -0
  263. package/src/session-runtime/effort.mjs +128 -0
  264. package/src/session-runtime/fs-utils.mjs +10 -0
  265. package/src/session-runtime/mcp-glue.mjs +177 -0
  266. package/src/session-runtime/model-capabilities.mjs +130 -0
  267. package/src/session-runtime/model-recency.mjs +111 -0
  268. package/src/session-runtime/native-search.mjs +247 -0
  269. package/src/session-runtime/output-styles.mjs +126 -0
  270. package/src/session-runtime/plugin-mcp.mjs +114 -0
  271. package/src/session-runtime/prewarm.mjs +142 -0
  272. package/src/session-runtime/provider-models.mjs +278 -0
  273. package/src/session-runtime/provider-usage.mjs +120 -0
  274. package/src/session-runtime/quick-model-rows.mjs +170 -0
  275. package/src/session-runtime/quick-search-models.mjs +46 -0
  276. package/src/session-runtime/session-hooks.mjs +93 -0
  277. package/src/session-runtime/session-text.mjs +100 -0
  278. package/src/session-runtime/settings-api.mjs +319 -0
  279. package/src/session-runtime/statusline-route.mjs +35 -0
  280. package/src/session-runtime/tool-catalog.mjs +720 -0
  281. package/src/session-runtime/tool-defs.mjs +84 -0
  282. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  283. package/src/session-runtime/workflow.mjs +358 -0
  284. package/src/standalone/agent-tool/helpers.mjs +237 -0
  285. package/src/standalone/agent-tool/notify.mjs +107 -0
  286. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  287. package/src/standalone/agent-tool/render.mjs +152 -0
  288. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  289. package/src/standalone/agent-tool.mjs +155 -677
  290. package/src/standalone/channel-worker.mjs +7 -9
  291. package/src/standalone/explore-tool.mjs +40 -12
  292. package/src/standalone/hook-bus/config.mjs +207 -0
  293. package/src/standalone/hook-bus/constants.mjs +90 -0
  294. package/src/standalone/hook-bus/handlers.mjs +481 -0
  295. package/src/standalone/hook-bus/payload.mjs +31 -0
  296. package/src/standalone/hook-bus/rules.mjs +77 -0
  297. package/src/standalone/hook-bus.mjs +110 -746
  298. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  299. package/src/standalone/opencode-go-login.mjs +125 -0
  300. package/src/standalone/provider-admin.mjs +15 -19
  301. package/src/standalone/usage-dashboard.mjs +3 -1
  302. package/src/tui/App.jsx +1163 -7571
  303. package/src/tui/app/app-format.mjs +206 -0
  304. package/src/tui/app/channel-pickers.mjs +510 -0
  305. package/src/tui/app/clipboard.mjs +67 -0
  306. package/src/tui/app/core-memory-picker.mjs +210 -0
  307. package/src/tui/app/extension-pickers.mjs +506 -0
  308. package/src/tui/app/input-parsers.mjs +193 -0
  309. package/src/tui/app/maintenance-pickers.mjs +324 -0
  310. package/src/tui/app/model-options.mjs +330 -0
  311. package/src/tui/app/model-picker.mjs +365 -0
  312. package/src/tui/app/onboarding-steps.mjs +400 -0
  313. package/src/tui/app/project-picker.mjs +247 -0
  314. package/src/tui/app/provider-setup-picker.mjs +580 -0
  315. package/src/tui/app/resume-picker.mjs +55 -0
  316. package/src/tui/app/route-pickers.mjs +419 -0
  317. package/src/tui/app/settings-picker.mjs +490 -0
  318. package/src/tui/app/slash-commands.mjs +101 -0
  319. package/src/tui/app/slash-dispatch.mjs +427 -0
  320. package/src/tui/app/text-layout.mjs +46 -0
  321. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  322. package/src/tui/app/transcript-window.mjs +671 -0
  323. package/src/tui/app/use-mouse-input.mjs +460 -0
  324. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  325. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  326. package/src/tui/app/use-transcript-window.mjs +589 -0
  327. package/src/tui/components/ConfirmBar.jsx +1 -1
  328. package/src/tui/components/Picker.jsx +32 -4
  329. package/src/tui/components/PromptInput.jsx +259 -80
  330. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  331. package/src/tui/components/StatusLine.jsx +63 -12
  332. package/src/tui/components/TextEntryPanel.jsx +11 -0
  333. package/src/tui/components/ToolExecution.jsx +56 -588
  334. package/src/tui/components/TranscriptItem.jsx +105 -0
  335. package/src/tui/components/UsagePanel.jsx +18 -4
  336. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  337. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  338. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  339. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  340. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  341. package/src/tui/components/tool-output-format.mjs +2 -2
  342. package/src/tui/display-width.mjs +20 -3
  343. package/src/tui/dist/index.mjs +18034 -17188
  344. package/src/tui/engine/agent-envelope.mjs +296 -0
  345. package/src/tui/engine/agent-job-feed.mjs +133 -0
  346. package/src/tui/engine/boot-profile.mjs +21 -0
  347. package/src/tui/engine/labels.mjs +67 -0
  348. package/src/tui/engine/notice-text.mjs +112 -0
  349. package/src/tui/engine/notification-plan.mjs +76 -0
  350. package/src/tui/engine/queue-helpers.mjs +161 -0
  351. package/src/tui/engine/render-timing.mjs +17 -0
  352. package/src/tui/engine/session-stats.mjs +46 -0
  353. package/src/tui/engine/tool-approval.mjs +94 -0
  354. package/src/tui/engine/tool-call-fields.mjs +23 -0
  355. package/src/tui/engine/tool-card-results.mjs +234 -0
  356. package/src/tui/engine/tool-result-status.mjs +135 -0
  357. package/src/tui/engine/tool-result-text.mjs +126 -0
  358. package/src/tui/engine.mjs +405 -1385
  359. package/src/tui/figures.mjs +5 -0
  360. package/src/tui/index.jsx +105 -0
  361. package/src/tui/input-editing.mjs +60 -10
  362. package/src/tui/keyboard-protocol.mjs +2 -2
  363. package/src/tui/lib/voice-recorder.mjs +35 -19
  364. package/src/tui/markdown/format-token.mjs +11 -9
  365. package/src/tui/paste-attachments.mjs +38 -0
  366. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  367. package/src/tui/theme.mjs +6 -0
  368. package/src/tui/themes/base.mjs +2 -2
  369. package/src/tui/themes/kanagawa.mjs +4 -4
  370. package/src/tui/themes/teal.mjs +4 -5
  371. package/src/tui/themes/utils.mjs +1 -1
  372. package/src/ui/statusline-agents.mjs +213 -0
  373. package/src/ui/statusline-format.mjs +146 -0
  374. package/src/ui/statusline-segments.mjs +148 -0
  375. package/src/ui/statusline.mjs +77 -462
  376. package/src/ui/tool-card.mjs +0 -1
  377. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  378. package/src/workflows/default/WORKFLOW.md +16 -9
  379. package/src/workflows/sequential/WORKFLOW.md +16 -11
  380. package/src/workflows/solo/WORKFLOW.md +5 -1
  381. package/vendor/ink/build/display-width.js +19 -3
  382. package/vendor/ink/build/ink.js +103 -6
  383. package/vendor/ink/build/log-update.js +17 -3
  384. package/vendor/ink/build/wrap-text.js +125 -0
  385. package/scripts/_test-folder-dialog.mjs +0 -30
  386. package/scripts/fix-brief-fn.mjs +0 -35
  387. package/scripts/fix-format-tool-surface.mjs +0 -24
  388. package/scripts/fix-tool-exec-visible.mjs +0 -42
  389. package/scripts/patch-agent-brief.mjs +0 -48
  390. package/scripts/patch-app.mjs +0 -21
  391. package/scripts/patch-app2.mjs +0 -18
  392. package/scripts/patch-dist-brief.mjs +0 -96
  393. package/scripts/patch-tool-exec.mjs +0 -70
  394. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  395. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  396. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  397. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  398. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  399. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  400. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  401. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  402. package/src/standalone/agent-task-status.test.mjs +0 -76
  403. package/src/tui/components/tool-output-format.test.mjs +0 -399
  404. package/src/tui/display-width.test.mjs +0 -35
  405. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  406. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  407. package/src/tui/markdown/format-token.test.mjs +0 -354
  408. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  409. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  410. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  411. package/src/tui/prompt-history-store.test.mjs +0 -52
  412. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  413. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  414. package/src/ui/markdown.test.mjs +0 -70
  415. package/src/ui/statusline-context-label.test.mjs +0 -15
  416. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  417. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -4,76 +4,81 @@
4
4
  * Runs mixdog's session manager outside React and exposes a tiny subscribable
5
5
  * store. The React/ink layer consumes it via useSyncExternalStore
6
6
  * (see hooks/useEngine.mjs).
7
+ *
8
+ * Pure/stateless helpers live in ./engine/* (boot-profile, session-stats,
9
+ * labels, notice-text, tool-result-text, tool-call-fields, agent-envelope,
10
+ * queue-helpers) and are re-exported here so the public surface is unchanged.
11
+ * This file keeps the stateful createEngineSession store + notification plan.
7
12
  */
8
13
  import { performance } from 'node:perf_hooks';
9
- import { SPINNER_VERBS } from './spinner-verbs.mjs';
10
14
  import {
11
15
  aggregateToolCategoryEntry,
12
16
  classifyToolCategory,
17
+ formatAggregateDetail,
13
18
  summarizeToolResult,
14
19
  } from '../runtime/shared/tool-surface.mjs';
15
- import { isBackgroundErrorOnlyBody, presentErrorText } from '../runtime/shared/err-text.mjs';
16
20
  import {
17
21
  isModelVisibleToolCompletionWrapper,
18
- modelVisibleToolCompletionMessage,
19
22
  } from '../runtime/shared/tool-execution-contract.mjs';
23
+ import { presentErrorText } from '../runtime/shared/err-text.mjs';
20
24
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
21
25
  import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
22
-
23
- const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
24
- const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
25
-
26
- function bootProfile(event, fields = {}) {
27
- if (!BOOT_PROFILE_ENABLED) return;
28
- const elapsedMs = performance.now() - BOOT_PROFILE_START;
29
- const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `tui:${event}`];
30
- for (const [key, value] of Object.entries(fields || {})) {
31
- if (value === undefined || value === null || value === '') continue;
32
- parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
33
- }
34
- try { process.stderr.write(`${parts.join(' ')}\n`); } catch {}
35
- }
36
-
37
- // Session-usage accumulator - inlined (not imported from ui/statusline.mjs) so
38
- // engine.mjs has no static dependency on the vendored statusline closure.
39
- function createSessionStats() {
40
- return {
41
- inputTokens: 0,
42
- outputTokens: 0,
43
- cachedTokens: 0,
44
- cacheWriteTokens: 0,
45
- promptTokens: 0,
46
- latestInputTokens: 0,
47
- latestOutputTokens: 0,
48
- latestCachedTokens: 0,
49
- latestCacheWriteTokens: 0,
50
- latestPromptTokens: 0,
51
- currentContextTokens: 0,
52
- costUsd: 0,
53
- turns: 0,
54
- };
55
- }
56
- function num(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; }
57
- function applyUsageDelta(stats, delta = {}) {
58
- if (!stats || !delta) return stats;
59
- const inputTokens = num(delta.deltaInput);
60
- const outputTokens = num(delta.deltaOutput);
61
- const cachedTokens = num(delta.deltaCachedRead);
62
- const cacheWriteTokens = num(delta.deltaCacheWrite);
63
- const promptTokens = num(delta.deltaPrompt);
64
- stats.inputTokens += inputTokens;
65
- stats.outputTokens += outputTokens;
66
- stats.cachedTokens += cachedTokens;
67
- stats.cacheWriteTokens += cacheWriteTokens;
68
- stats.promptTokens += promptTokens;
69
- stats.latestInputTokens = inputTokens;
70
- stats.latestOutputTokens = outputTokens;
71
- stats.latestCachedTokens = cachedTokens;
72
- stats.latestCacheWriteTokens = cacheWriteTokens;
73
- stats.latestPromptTokens = promptTokens;
74
- stats.costUsd += num(delta.costUsd);
75
- return stats;
76
- }
26
+ import { bootProfile } from './engine/boot-profile.mjs';
27
+ import { createSessionStats, applyUsageDelta } from './engine/session-stats.mjs';
28
+ import {
29
+ pickVerb,
30
+ pickDoneVerb,
31
+ formatElapsedSeconds,
32
+ compactEventLabel,
33
+ compactEventDetail,
34
+ projectNameFromPath,
35
+ } from './engine/labels.mjs';
36
+ import { polishNoticeText } from './engine/notice-text.mjs';
37
+ import {
38
+ toolResultText,
39
+ toolAggregateDetailFallback,
40
+ toolGroupedDisplayFallback,
41
+ toolErrorDisplay,
42
+ } from './engine/tool-result-text.mjs';
43
+ import {
44
+ toolCallId,
45
+ toolResultCallId,
46
+ toolCallName,
47
+ toolCallArgs,
48
+ } from './engine/tool-call-fields.mjs';
49
+ import {
50
+ parseBackgroundTaskEnvelope,
51
+ parseSyntheticAgentMessage,
52
+ toolResultStatus,
53
+ isErrorToolStatus,
54
+ } from './engine/agent-envelope.mjs';
55
+ import {
56
+ queuePriorityValue,
57
+ defaultQueuePriority,
58
+ isQueuedEntryEditable,
59
+ isQueuedEntryVisible,
60
+ isSlashQueuedEntry,
61
+ notificationDisplayText,
62
+ sessionActivityTimestamp,
63
+ promptDisplayText,
64
+ mergePromptContents,
65
+ mergePastedImages,
66
+ mergePastedTexts,
67
+ callCommitCallbacks,
68
+ } from './engine/queue-helpers.mjs';
69
+ import {
70
+ resolveTuiRuntimeNotificationDelivery,
71
+ } from './engine/notification-plan.mjs';
72
+ import { yieldToRenderer } from './engine/render-timing.mjs';
73
+ import {
74
+ aggregateRawResult,
75
+ aggregateBucketForCategory,
76
+ aggregateSummaries,
77
+ assignAggregateSummaryOrder,
78
+ } from './engine/tool-result-status.mjs';
79
+ import { createToolApproval } from './engine/tool-approval.mjs';
80
+ import { createToolCardResults } from './engine/tool-card-results.mjs';
81
+ import { createAgentJobFeed } from './engine/agent-job-feed.mjs';
77
82
 
78
83
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
79
84
  // src/tui/dist/index.mjs.
@@ -89,836 +94,13 @@ const TOOL_APPROVAL_TIMEOUT_MS = (() => {
89
94
  let _idSeq = 0;
90
95
  const nextId = () => `it_${++_idSeq}`;
91
96
 
92
- function pickVerb(turn) {
93
- return SPINNER_VERBS[(turn * 7 + 3) % SPINNER_VERBS.length];
94
- }
95
-
96
- const TURN_DONE_VERBS = [
97
- 'Thought',
98
- 'Reasoned',
99
- 'Mapped',
100
- 'Checked',
101
- 'Solved',
102
- 'Composed',
103
- 'Synthesized',
104
- 'Wrapped',
105
- ];
106
-
107
- function pickDoneVerb(turn) {
108
- return TURN_DONE_VERBS[(turn * 5 + 2) % TURN_DONE_VERBS.length];
109
- }
110
-
111
- function formatElapsedSeconds(ms) {
112
- const value = Math.max(0, Number(ms) || 0);
113
- if (value <= 0) return '0s';
114
- return `${Math.max(1, Math.ceil(value / 1000))}s`;
115
- }
116
-
117
- function compactEventLabel(event = {}) {
118
- const status = String(event.status || '').toLowerCase();
119
- const reactive = String(event.trigger || '').toLowerCase() === 'reactive';
120
- if (status === 'failed') return reactive ? 'Compact failed (overflow retry)' : 'Compact failed';
121
- if (status === 'skipped') return 'Compact skipped';
122
- if (status === 'no_change') return 'Compact checked';
123
- return reactive ? 'Compact complete (overflow recovery)' : 'Compact complete';
124
- }
125
-
126
- function compactEventDetail(event = {}) {
127
- // Keep the elapsed time as the lead detail, but no longer discard the rest of
128
- // the compact metadata. Surface type/trigger and the boundary/pressure so the
129
- // statusdone marker reflects what actually fired.
130
- const parts = [];
131
- const elapsed = formatElapsedSeconds(Number(event.durationMs ?? event.elapsedMs ?? 0));
132
- if (elapsed) parts.push(elapsed);
133
- const type = String(event.compactType || event.type || '').trim();
134
- if (type && type !== 'semantic') parts.push(type);
135
- const trigger = String(event.trigger || '').toLowerCase();
136
- if (trigger === 'reactive') parts.push('reactive');
137
- else if (trigger === 'manual') parts.push('manual');
138
- const before = Number(event.beforeTokens ?? event.pressureTokens ?? 0);
139
- const after = Number(event.afterTokens ?? 0);
140
- const fmtTok = (n) => {
141
- const v = Number(n) || 0;
142
- if (v >= 1000) return `${(v / 1000).toFixed(v >= 10_000 ? 0 : 1)}k`;
143
- return `${Math.round(v)}`;
144
- };
145
- if (before > 0 && after > 0 && after !== before) parts.push(`${fmtTok(before)}→${fmtTok(after)}`);
146
- return parts.join(' · ');
147
- }
148
-
149
- function projectNameFromPath(value) {
150
- const text = String(value || '').replace(/[\\/]+$/, '');
151
- return text.split(/[\\/]/).pop() || text || '(current)';
152
- }
153
-
154
- const FAILED_NOTICE_ACTIONS = new Map([
155
- ['api key save', 'save API key'],
156
- ['auth-forget', 'forget auth'],
157
- ['auto-clear', 'update auto-clear'],
158
- ['autoclear', 'update auto-clear'],
159
- ['agent', 'run agent command'],
160
- ['channels', 'load channels'],
161
- ['channels update', 'update channels'],
162
- ['clear', 'clear chat'],
163
- ['compact', 'compact context'],
164
- ['copy', 'copy'],
165
- ['core memory', 'load core memory'],
166
- ['cwd', 'update working directory'],
167
- ['effort switch', 'switch effort'],
168
- ['fast', 'update fast mode'],
169
- ['hook rule update', 'update hook rule'],
170
- ['hook toggle', 'toggle hook'],
171
- ['hook update', 'update hook'],
172
- ['hooks status', 'load hooks'],
173
- ['local provider update', 'update local provider'],
174
- ['mcp add', 'add MCP server'],
175
- ['mcp reconnect', 'reconnect MCP server'],
176
- ['mcp status', 'load MCP status'],
177
- ['mcp toggle', 'toggle MCP server'],
178
- ['memory', 'run memory command'],
179
- ['memory status', 'load memory status'],
180
- ['model save', 'save model'],
181
- ['model switch', 'switch model'],
182
- ['oauth code', 'finish OAuth login'],
183
- ['oauth login', 'start OAuth login'],
184
- ['output style switch', 'switch output style'],
185
- ['OpenAI usage auth save', 'save OpenAI usage auth'],
186
- ['OpenCode Go usage auth save', 'save OpenCode Go usage auth'],
187
- ['plugin add', 'add plugin'],
188
- ['plugin MCP enable', 'enable plugin MCP'],
189
- ['plugin uninstall', 'uninstall plugin'],
190
- ['plugin update', 'update plugin'],
191
- ['plugins status', 'load plugins'],
192
- ['providers', 'load providers'],
193
- ['recall', 'run recall'],
194
- ['resume', 'resume chat'],
195
- ['schedule toggle', 'toggle schedule'],
196
- ['setup save', 'save setup'],
197
- ['settings update', 'update settings'],
198
- ['skill add', 'add skill'],
199
- ['skills status', 'load skills'],
200
- ['tools status', 'load tool status'],
201
- ['usage', 'load usage'],
202
- ['webhook toggle', 'toggle webhook'],
203
- ['workflow switch', 'switch workflow'],
204
- ]);
205
-
206
- function polishNoticeAction(action) {
207
- const value = String(action || '').trim();
208
- if (!value) return 'finish';
209
- const key = value.toLowerCase();
210
- for (const [candidate, replacement] of FAILED_NOTICE_ACTIONS.entries()) {
211
- if (candidate.toLowerCase() === key) return replacement;
212
- }
213
- const suffixes = [
214
- [' save', 'save'],
215
- [' switch', 'switch'],
216
- [' update', 'update'],
217
- [' toggle', 'toggle'],
218
- [' reconnect', 'reconnect'],
219
- [' enable', 'enable'],
220
- [' uninstall', 'uninstall'],
221
- [' add', 'add'],
222
- ];
223
- for (const [suffix, verb] of suffixes) {
224
- if (!key.endsWith(suffix)) continue;
225
- const subject = value.slice(0, -suffix.length).trim();
226
- return subject ? `${verb} ${subject}` : verb;
227
- }
228
- return value;
229
- }
230
-
231
- function sentenceStart(text) {
232
- const value = String(text || '').trim();
233
- return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
234
- }
235
-
236
- function polishNoticeText(text) {
237
- let value = String(text ?? '').trim().replace(/^✓\s*/, '');
238
- if (!value) return '';
239
- const error = /^error\s*:\s*(.+)$/i.exec(value);
240
- if (error?.[1]) value = error[1].trim();
241
- const couldNot = /^could not\s+(.+?)(?::\s*(.+))?$/i.exec(value);
242
- if (couldNot) {
243
- return couldNot[2]
244
- ? `Couldn’t ${couldNot[1]}: ${couldNot[2]}`
245
- : `Couldn’t ${couldNot[1]}.`;
246
- }
247
- const failed = /^(.+?)\s+failed(?::\s*(.+))?$/i.exec(value);
248
- if (failed) {
249
- const action = polishNoticeAction(failed[1]);
250
- return failed[2] ? `Couldn’t ${action}: ${failed[2]}` : `Couldn’t ${action}.`;
251
- }
252
- const busy = /^(.+?)\s+already in progress\.?$/i.exec(value);
253
- if (busy) return `${sentenceStart(polishNoticeAction(busy[1]))} is already running.`;
254
- const required = /^(.+?)\s+is required(?:\s+for\s+(.+))?\.?$/i.exec(value);
255
- if (required) {
256
- const subject = required[1].trim();
257
- const target = required[2]?.trim();
258
- return `${subject}${target ? ` required for ${target}` : ' required'}.`;
259
- }
260
- return value;
261
- }
262
-
263
- function toolResultText(content) {
264
- if (content == null) return '';
265
- if (typeof content === 'string') return content;
266
- if (Array.isArray(content)) {
267
- return content.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
268
- }
269
- if (typeof content === 'object') {
270
- if (Array.isArray(content.content)) {
271
- const nested = content.content.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
272
- if (nested) return nested;
273
- } else if (content.content != null && typeof content.content === 'object') {
274
- const nested = toolResultPartText(content.content);
275
- if (nested) return nested;
276
- }
277
- if (Array.isArray(content.parts)) {
278
- const nested = content.parts.map((c) => toolResultPartText(c)).filter((t) => t !== '').join('\n');
279
- if (nested) return nested;
280
- }
281
- const fromPart = toolResultPartText(content);
282
- if (fromPart) return fromPart;
283
- if (content?.type === 'tool_result') return '';
284
- if (typeof content.text === 'string') return content.text;
285
- if (typeof content.content === 'string') return content.content;
286
- }
287
- try { return JSON.stringify(content); } catch { return String(content); }
288
- }
289
-
290
- const TOOL_RESULT_PART_MAX_DEPTH = 12;
291
- const TOOL_RESULT_JSON_FALLBACK_MAX = 480;
292
- // Absolute cap for a collapsed tool detail line (the second row under the ⎿
293
- // gutter). Terminal-width independent so a wide terminal never lets a long line
294
- // stretch the row; lockstep with ToolExecution RESULT_LINE_HARD_MAX (80).
295
- const TOOL_DETAIL_LINE_MAX = 80;
296
-
297
- function compactToolResultObjectFallback(obj) {
298
- if (obj?.type === 'tool_result') return '';
299
- try {
300
- const json = JSON.stringify(obj);
301
- if (!json || json === '{}') return '';
302
- if (json.length <= TOOL_RESULT_JSON_FALLBACK_MAX) return json;
303
- return `${json.slice(0, TOOL_RESULT_JSON_FALLBACK_MAX - 1)}…`;
304
- } catch {
305
- return String(obj);
306
- }
307
- }
308
-
309
- function toolResultPartText(part, depth = 0) {
310
- if (part == null) return '';
311
- if (depth > TOOL_RESULT_PART_MAX_DEPTH) return '';
312
- if (typeof part === 'string') return part;
313
- if (part?.type === 'image' || part?.type === 'input_image') {
314
- return `[image: ${part.mimeType || part.mediaType || part.source?.media_type || 'image'}]`;
315
- }
316
- if (part?.type === 'tool_result') {
317
- const inner = part.content;
318
- if (typeof inner === 'string') return inner;
319
- if (Array.isArray(inner)) {
320
- return inner.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
321
- }
322
- if (inner != null && typeof inner === 'object') {
323
- return toolResultPartText(inner, depth + 1);
324
- }
325
- return '';
326
- }
327
- if (part?.type === 'text' || part?.type === 'output_text' || part?.type === 'input_text') {
328
- return part.text ?? '';
329
- }
330
- if (Array.isArray(part)) {
331
- return part.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
332
- }
333
- if (typeof part === 'object') {
334
- if (Array.isArray(part.content)) {
335
- const nested = part.content.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
336
- if (nested) return nested;
337
- }
338
- if (part.content != null && typeof part.content === 'object') {
339
- const nested = toolResultPartText(part.content, depth + 1);
340
- if (nested) return nested;
341
- }
342
- if (Array.isArray(part.parts)) {
343
- const nested = part.parts.map((c) => toolResultPartText(c, depth + 1)).filter((t) => t !== '').join('\n');
344
- if (nested) return nested;
345
- }
346
- if (typeof part.text === 'string' && part.text) return part.text;
347
- if (typeof part.output === 'string' && part.output) return part.output;
348
- if (typeof part.message === 'string' && part.message) return part.message;
349
- if (typeof part.content === 'string') return part.content;
350
- if (part.source?.type === 'base64' && part.source?.data) {
351
- return `[image: ${part.source.media_type || part.source.mediaType || 'base64'}]`;
352
- }
353
- return compactToolResultObjectFallback(part);
354
- }
355
- return '';
356
- }
357
-
358
- function toolAggregateDetailFallback(detailText, rawResult) {
359
- if (String(detailText || '').trim()) return detailText;
360
- const raw = String(rawResult || '').replace(/\s+$/, '').trim();
361
- if (!raw) return detailText;
362
- const line = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
363
- if (!line) return detailText;
364
- return line.length > TOOL_DETAIL_LINE_MAX ? `${line.slice(0, TOOL_DETAIL_LINE_MAX - 3)}…` : line;
365
- }
366
-
367
- function toolGroupedDisplayFallback(resultText, text, rawText) {
368
- if (String(resultText || '').trim()) return resultText;
369
- const body = String(text || rawText || '').trim();
370
- if (body) return text || rawText;
371
- return resultText;
372
- }
373
-
374
- function toolErrorDisplay(value, surface = 'tool') {
375
- const text = presentErrorText(value, { surface });
376
- if (/^(?:Search failed|Fetch failed|No first response|The .+ went stale|(?:Web search agent|Agent|Tool) (?:stopped|was cancelled))/i.test(text)) {
377
- return text;
378
- }
379
- return /^error\s*:/i.test(text) ? text : `Error: ${text}`;
380
- }
381
-
382
- function toolCallId(call) {
383
- return call?.id ?? call?.toolCallId ?? call?.tool_call_id ?? call?.call_id;
384
- }
385
-
386
- function toolResultCallId(message) {
387
- return message?.toolCallId
388
- ?? message?.tool_call_id
389
- ?? message?.tool_use_id
390
- ?? message?.call_id
391
- ?? message?.id;
392
- }
393
-
394
- function toolCallName(call) {
395
- return call?.name ?? call?.function?.name ?? call?.toolName ?? call?.tool_name ?? 'tool';
396
- }
397
-
398
- function toolCallArgs(call) {
399
- return call?.arguments ?? call?.args ?? call?.input ?? call?.function?.arguments;
400
- }
401
-
402
- function textBetweenTag(text, tag) {
403
- const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
404
- const match = re.exec(String(text ?? ''));
405
- return match ? match[1].trim() : '';
406
- }
407
-
408
- function stripSyntheticAgentTags(text) {
409
- const value = String(text ?? '').trim();
410
- const finalAnswer = textBetweenTag(value, 'final-answer');
411
- if (finalAnswer) return finalAnswer;
412
- const taskResult = textBetweenTag(value, 'result');
413
- if (taskResult) return taskResult;
414
- return value
415
- .replace(/^agent result[^\n]*(?:\n|$)/i, '')
416
- .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, '')
417
- .trim();
418
- }
419
-
420
- function splitBridgeEnvelope(text) {
421
- const value = String(text ?? '').trim();
422
- if (!value) return { head: '', body: '' };
423
- const match = /\n\s*\n/.exec(value);
424
- if (!match) return { head: value, body: '' };
425
- return {
426
- head: value.slice(0, match.index).trim(),
427
- body: value.slice(match.index + match[0].length).trim(),
428
- };
429
- }
430
-
431
- function agentJobStatusText(parsed) {
432
- if (!parsed) return '';
433
- const parts = [];
434
- if (parsed.status) parts.push(`status: ${parsed.status}`);
435
- if (parsed.taskId) parts.push(`task_id: ${parsed.taskId}`);
436
- return parts.join(' · ');
437
- }
438
-
439
- function agentJobResultText(text, parsed = parseAgentJob(text)) {
440
- const value = String(text ?? '').trim();
441
- if (!value) return '';
442
- if (parsed?.taskId) {
443
- const { body } = splitBridgeEnvelope(value);
444
- const cleanBody = stripSyntheticAgentTags(body);
445
- if (cleanBody) return cleanBody;
446
- return agentJobStatusText(parsed);
447
- }
448
- return stripSyntheticAgentTags(value) || value;
449
- }
450
-
451
- function parseAgentResultEnvelope(text, fallback = {}) {
452
- const value = String(text ?? '').trim();
453
- if (!/^agent result\b/i.test(value)) return null;
454
- const [head = '', ...restLines] = value.split('\n');
455
- const body = stripSyntheticAgentTags(restLines.join('\n'));
456
- const attrs = {};
457
- const attrRe = /([a-zA-Z][\w-]*)=("[^"]*"|'[^']*'|\S+)/g;
458
- let match;
459
- while ((match = attrRe.exec(head))) {
460
- attrs[match[1].toLowerCase()] = String(match[2] || '').replace(/^["']|["']$/g, '');
461
- }
462
- const providerModel = /\s([a-zA-Z0-9_.-]+)\/([^\s]+)\s*$/i.exec(head);
463
- const agent = attrs.agent || fallback.agent || '';
464
- return {
465
- name: 'agent',
466
- label: String(fallback.status || attrs.status || 'completed').toLowerCase(),
467
- args: {
468
- type: 'result',
469
- status: fallback.status || attrs.status || 'completed',
470
- task_id: fallback.taskId || attrs.task_id || attrs.taskid || undefined,
471
- tag: fallback.tag || attrs.tag || undefined,
472
- agent: agent || undefined,
473
- provider: fallback.provider || attrs.provider || providerModel?.[1] || undefined,
474
- model: fallback.model || attrs.model || providerModel?.[2] || undefined,
475
- preset: fallback.preset || attrs.preset || undefined,
476
- effort: fallback.effort || attrs.effort || undefined,
477
- fast: fallback.fast ?? attrs.fast,
478
- },
479
- result: body || agentJobStatusText({ status: fallback.status || attrs.status || 'completed', taskId: fallback.taskId || attrs.task_id || attrs.taskid || '' }),
480
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(fallback.status || attrs.status || ''),
481
- };
482
- }
483
-
97
+ // Re-export the shared tool-result/notification helpers so importers (and tests)
98
+ // keep resolving them from engine.mjs unchanged.
484
99
  export { toolResultText, toolAggregateDetailFallback, toolGroupedDisplayFallback };
485
-
486
- export function parseBackgroundTaskEnvelope(text) {
487
- const value = String(text ?? '').trim();
488
- if (!/^background task\b/i.test(value)) return null;
489
- const allLines = value.split('\n');
490
- const rest = allLines.slice(1);
491
- const blank = rest.findIndex((line) => !line.trim());
492
- const headLines = blank >= 0 ? rest.slice(0, blank) : rest;
493
- const body = blank >= 0 ? rest.slice(blank + 1).join('\n').trim() : '';
494
- const fields = {};
495
- for (const line of headLines) {
496
- const match = /^([a-zA-Z][\w-]*):\s*(.*)$/.exec(line.trim());
497
- if (match) fields[match[1].toLowerCase()] = match[2].trim();
498
- }
499
- const surface = String(fields.surface || fields.operation || 'task').toLowerCase();
500
- const name = surface === 'explore' || surface === 'search' || surface === 'shell' || surface === 'agent' ? surface : 'task';
501
- const status = String(fields.status || '').toLowerCase();
502
- const taskId = fields.task_id || fields.taskid || '';
503
- const errorText = fields.error || '';
504
- const agentResult = parseAgentResultEnvelope(body, {
505
- status,
506
- taskId,
507
- tag: fields.tag || fields.label || '',
508
- agent: fields.agent || '',
509
- provider: fields.provider || '',
510
- model: fields.model || '',
511
- preset: fields.preset || '',
512
- effort: fields.effort || '',
513
- fast: fields.fast,
514
- });
515
- if (agentResult) return { ...agentResult, rawResult: value };
516
- const errorOnlyBody = isBackgroundErrorOnlyBody(body, errorText);
517
- const resultBody = body && !errorOnlyBody ? body : '';
518
- return {
519
- name,
520
- label: status || 'notification',
521
- args: {
522
- type: body ? 'result' : (fields.operation || 'status'),
523
- status,
524
- task_id: taskId || undefined,
525
- surface,
526
- operation: fields.operation || undefined,
527
- label: fields.label || undefined,
528
- tag: fields.tag || undefined,
529
- agent: fields.agent || undefined,
530
- provider: fields.provider || undefined,
531
- model: fields.model || undefined,
532
- preset: fields.preset || undefined,
533
- effort: fields.effort || undefined,
534
- fast: fields.fast || undefined,
535
- error: errorText || undefined,
536
- startedAt: fields.started || fields.startedat || undefined,
537
- finishedAt: fields.finished || fields.finishedat || undefined,
538
- },
539
- result: resultBody || (!errorText ? [status ? `status: ${status}` : '', taskId ? `task_id: ${taskId}` : ''].filter(Boolean).join(' · ') : ''),
540
- rawResult: value,
541
- isError: /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(status) || /^error:/i.test(body) || Boolean(errorText),
542
- };
543
- }
544
-
545
- function isStatusOnlyAgentCompletionNotification(text) {
546
- const background = parseBackgroundTaskEnvelope(text);
547
- if (background?.name === 'agent' && /^(completed|cancelled|canceled)$/i.test(background.label || '')) {
548
- return !(hasAgentResponseResultText(background.result) || hasAgentResponseResultText(text));
549
- }
550
- const parsed = parseAgentJob(text);
551
- const result = agentJobResultText(text, parsed);
552
- if (!parsed?.taskId || !/^(completed|cancelled|canceled)$/i.test(parsed.status || '')) return false;
553
- return !(hasAgentResponseResultText(result) || hasAgentResponseResultText(text));
554
- }
555
-
556
- function hasAgentResponseResultText(text) {
557
- const value = String(text || '').trim();
558
- if (!value) return false;
559
- if (/^status:\s*(?:running|pending|queued|completed|failed|cancelled|canceled)(?:\s*·\s*task_id:\s*\S+)?$/i.test(value)) return false;
560
- if (/^(?:background task\b|agent task:|task_id:)/i.test(value) && !/\n\s*\n[\s\S]*\S/.test(value)) return false;
561
- return true;
562
- }
563
-
564
- function bracketField(text, name) {
565
- const re = new RegExp(`^\\[${name}:\\s*([^\\]]*)\\]`, 'mi');
566
- return re.exec(String(text ?? ''))?.[1]?.trim() || '';
567
- }
568
-
569
- function toolResultStatus(text) {
570
- const value = String(text ?? '');
571
- const tagged = textBetweenTag(value, 'status');
572
- if (tagged) return tagged.trim();
573
- const bracketed = bracketField(value, 'status');
574
- if (bracketed) return bracketed.trim();
575
- const inline = /^(?:status|state):\s*([^\s·,;]+)/mi.exec(value);
576
- return inline ? inline[1].trim() : '';
577
- }
578
-
579
- function isErrorToolStatus(status) {
580
- return /^(failed|error|timeout|cancelled|canceled|killed)$/i.test(String(status || '').trim());
581
- }
582
-
583
- function parseSyntheticAgentMessage(text) {
584
- const value = String(text ?? '').trim();
585
- if (!value) return null;
586
- const finalAnswer = textBetweenTag(value, 'final-answer');
587
- if (finalAnswer) {
588
- return {
589
- name: 'agent',
590
- label: 'final',
591
- args: { type: 'read', description: 'agent result' },
592
- result: finalAnswer,
593
- };
594
- }
595
- const agentResult = parseAgentResultEnvelope(value);
596
- if (agentResult) return agentResult;
597
- const backgroundTask = parseBackgroundTaskEnvelope(value);
598
- if (backgroundTask) return backgroundTask;
599
- const shellTaskId = bracketField(value, 'task_id');
600
- if (shellTaskId) {
601
- const status = bracketField(value, 'status') || 'done';
602
- const exit = bracketField(value, 'exit');
603
- const command = bracketField(value, 'command');
604
- return {
605
- name: 'shell',
606
- label: status,
607
- args: { type: 'result', task_id: shellTaskId, command },
608
- result: value,
609
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(status) || (exit && exit !== '0' && exit !== 'n/a'),
610
- };
611
- }
612
- const agentJob = parseAgentJob(value);
613
- if (agentJob?.taskId) {
614
- const label = agentJob.status || 'notification';
615
- const result = agentJobResultText(value, agentJob);
616
- return {
617
- name: 'agent',
618
- label,
619
- args: agentArgsWithResultMetadata({ type: agentJob.type || 'notification', description: 'agent notification' }, agentJob),
620
- result: result || agentJobStatusText(agentJob) || 'agent notification',
621
- isError: /^(failed|error|timeout|cancelled|killed)$/i.test(label),
622
- };
623
- }
624
- if (/<task-notification\b/i.test(value)) {
625
- const status = textBetweenTag(value, 'status') || 'completed';
626
- const summary = textBetweenTag(value, 'summary') || `Agent ${status}`;
627
- const taskId = textBetweenTag(value, 'task-id');
628
- const result = stripSyntheticAgentTags(value);
629
- return {
630
- name: 'agent',
631
- label: status,
632
- taskId,
633
- summary,
634
- result: result || summary,
635
- };
636
- }
637
- return null;
638
- }
639
-
640
- function normalizeToolName(name) {
641
- return String(name || 'tool')
642
- .replace(/^mcp__.*__/, '')
643
- .replace(/^functions\./, '')
644
- .replace(/-/g, '_')
645
- .toLowerCase();
646
- }
647
-
648
- function parseToolArgs(args) {
649
- if (!args) return {};
650
- if (typeof args === 'string') {
651
- try {
652
- const parsed = JSON.parse(args);
653
- return parsed && typeof parsed === 'object' ? parsed : {};
654
- } catch {
655
- return {};
656
- }
657
- }
658
- return typeof args === 'object' ? args : {};
659
- }
660
-
661
- // Ink renders through a maxFps throttle (120fps in index.jsx, ≈8.3ms). A plain
662
- // setImmediate only yields to the event loop; if Ink already painted within the
663
- // current throttle window, the next paint may still be queued and our following
664
- // transcript mutation can coalesce into the same visible frame. Wait just past
665
- // one render window when we intentionally split transcript commits for visual
666
- // stability (preamble frame → tool-card frame).
667
- const RENDER_THROTTLE_FLUSH_MS = 12;
668
- const yieldToRenderer = () => new Promise((resolve) => {
669
- setTimeout(resolve, RENDER_THROTTLE_FLUSH_MS);
670
- });
671
-
672
- function parseAgentJob(text) {
673
- const value = String(text || '');
674
- const idMatch = /^agent task:\s*([^\s]+)/m.exec(value) || /^task_id:\s*([^\s]+)/m.exec(value);
675
- if (!idMatch) return null;
676
- const statusMatch = /^status:\s*([^\s(]+)/m.exec(value);
677
- const typeMatch = /^type:\s*(.+)$/m.exec(value);
678
- const targetMatch = /^target:\s*(.+)$/m.exec(value);
679
- const agentMatch = /^agent:\s*(.+)$/m.exec(value);
680
- const presetMatch = /^preset:\s*(.+)$/m.exec(value);
681
- const modelMatch = /^model:\s*([^/\s]+)\/(.+)$/m.exec(value);
682
- const effortMatch = /^effort:\s*(.+)$/m.exec(value);
683
- const fastMatch = /^fast:\s*(on|off|true|false)$/m.exec(value);
684
- return {
685
- taskId: idMatch[1],
686
- status: (statusMatch?.[1] || '').toLowerCase(),
687
- type: (typeMatch?.[1] || '').trim(),
688
- target: (targetMatch?.[1] || '').trim(),
689
- agent: (agentMatch?.[1] || '').trim(),
690
- preset: (presetMatch?.[1] || '').trim(),
691
- provider: (modelMatch?.[1] || '').trim(),
692
- model: (modelMatch?.[2] || '').trim(),
693
- effort: (effortMatch?.[1] || '').trim(),
694
- fast: fastMatch ? /^(on|true)$/i.test(fastMatch[1]) : undefined,
695
- };
696
- }
697
-
698
- const QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
699
-
700
- function queuePriorityValue(value) {
701
- return QUEUE_PRIORITY[String(value || 'next')] ?? QUEUE_PRIORITY.next;
702
- }
703
-
704
- function defaultQueuePriority(mode) {
705
- // Queue priority defaults:
706
- // - user/bashed prompt input defaults to `next`, so it can be attached at the
707
- // next model-send boundary while a turn is active.
708
- // - task notifications default to `later`, unless the caller explicitly marks
709
- // them urgent (e.g. interactive shell stall/completion).
710
- return mode === 'task-notification' ? 'later' : 'next';
711
- }
712
-
713
- function isQueuedEntryEditable(entry) {
714
- const mode = entry?.mode || 'prompt';
715
- return mode !== 'task-notification' && mode !== 'pending-resume';
716
- }
717
-
718
- function isQueuedEntryVisible(entry) {
719
- // state.queued drives the user-command wait list above the prompt. Background
720
- // task completions stay in the internal pending queue, but should never look
721
- // like commands typed by the user while they wait to be drained.
722
- const mode = entry?.mode || 'prompt';
723
- if (mode === 'pending-resume') return false;
724
- return isQueuedEntryEditable(entry);
725
- }
726
-
727
- function isSlashQueuedEntry(entry) {
728
- if (entry?.skipSlashCommands) return false;
729
- const text = promptContentText(entry?.content ?? entry?.text ?? '');
730
- return text.trim().startsWith('/');
731
- }
732
-
733
- function firstQueueLine(text) {
734
- return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
735
- }
736
-
737
- function shortTextFingerprint(text) {
738
- const value = String(text || '').trim();
739
- let hash = 2166136261;
740
- for (let i = 0; i < value.length; i += 1) {
741
- hash ^= value.charCodeAt(i);
742
- hash = Math.imul(hash, 16777619);
743
- }
744
- return (hash >>> 0).toString(36);
745
- }
746
-
747
- function notificationDisplayText(text) {
748
- const parsed = parseAgentJob(text);
749
- const result = agentJobResultText(text, parsed);
750
- const synthetic = parseSyntheticAgentMessage(text);
751
- return firstQueueLine(synthetic?.result || result || text) || 'agent notification';
752
- }
753
-
754
- function promptContentText(content) {
755
- if (typeof content === 'string') return content;
756
- if (Array.isArray(content)) {
757
- return content.map((part) => {
758
- if (typeof part === 'string') return part;
759
- if (part?.type === 'text') return part.text || '';
760
- if (part?.type === 'image') return '[Image]';
761
- return part?.text || '';
762
- }).filter(Boolean).join('\n');
763
- }
764
- return String(content ?? '');
765
- }
766
-
767
- function timestampMs(value) {
768
- if (value == null || value === '') return 0;
769
- const n = Number(value);
770
- if (Number.isFinite(n) && n > 0) return n;
771
- const parsed = Date.parse(String(value));
772
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
773
- }
774
-
775
- function hasModelVisibleConversation(session) {
776
- const messages = Array.isArray(session?.messages) ? session.messages : [];
777
- return messages.some((message) => {
778
- const role = message?.role;
779
- if (role !== 'user' && role !== 'assistant' && role !== 'tool') return false;
780
- const text = promptContentText(message.content).trim();
781
- if (role === 'user' && text.startsWith('<system-reminder>')) return false;
782
- if (role === 'assistant' && text === '.' && !Array.isArray(message.toolCalls)) return false;
783
- return !!text || role === 'assistant' || role === 'tool';
784
- });
785
- }
786
-
787
- function sessionActivityTimestamp(session, fallback = 0) {
788
- if (!hasModelVisibleConversation(session)) return 0;
789
- return timestampMs(session?.lastUsedAt)
790
- || timestampMs(session?.updatedAt)
791
- || timestampMs(fallback);
792
- }
793
-
794
- function promptDisplayText(content, options = {}) {
795
- if (typeof options.displayText === 'string') return options.displayText;
796
- return promptContentText(content);
797
- }
798
-
799
- function mergePromptContents(entries) {
800
- const batch = Array.isArray(entries) ? entries : [];
801
- if (batch.every((entry) => typeof entry?.content === 'string')) {
802
- return batch.map((entry) => entry.content).filter((text) => String(text || '').trim()).join('\n');
803
- }
804
- const parts = [];
805
- for (const entry of batch) {
806
- const content = entry?.content;
807
- if (typeof content === 'string') {
808
- if (content.trim()) parts.push({ type: 'text', text: content });
809
- } else if (Array.isArray(content)) {
810
- parts.push(...content);
811
- }
812
- parts.push({ type: 'text', text: '\n' });
813
- }
814
- while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
815
- return parts.length === 1 && parts[0]?.type === 'text' ? parts[0].text : parts;
816
- }
817
-
818
- function mergePastedImages(entries) {
819
- const out = {};
820
- for (const entry of entries || []) {
821
- const images = entry?.pastedImages;
822
- if (!images || typeof images !== 'object') continue;
823
- for (const [id, image] of Object.entries(images)) {
824
- if (image) out[id] = image;
825
- }
826
- }
827
- return Object.keys(out).length > 0 ? out : null;
828
- }
829
-
830
- function callCommitCallbacks(entries) {
831
- for (const entry of entries || []) {
832
- try { entry?.onCommitted?.(); } catch {}
833
- }
834
- }
835
-
836
- function notificationQueueKey(event, text, parsed) {
837
- const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
838
- const synthetic = parseSyntheticAgentMessage(text);
839
- if (synthetic?.name === 'agent' && String(synthetic.args?.type || '').toLowerCase() === 'result') {
840
- const taskId = String(synthetic.args?.task_id || '').trim();
841
- const executionId = String(meta.execution_id || '').trim();
842
- const tag = String(synthetic.args?.tag || '').trim();
843
- const resultId = taskId
844
- ? `task:${taskId}`
845
- : executionId
846
- ? `exec:${executionId}`
847
- : tag
848
- ? `tag:${tag}:${shortTextFingerprint(synthetic.result || text)}`
849
- : '';
850
- const agent = String(synthetic.args?.agent || '').trim();
851
- if (resultId || agent) return ['agent-result', resultId, agent].filter(Boolean).join(':');
852
- }
853
- const id = String(meta.execution_id || parsed?.taskId || '').trim();
854
- if (!id) return '';
855
- const type = String(meta.type || '').trim();
856
- const status = String(meta.status || parsed?.status || '').trim();
857
- const fallbackKind = String(text || '').split('\n', 1)[0]?.trim() || 'notification';
858
- // Distinguish a body-carrying completion from a header-only preview that
859
- // shares the same id/type/status. An early agent preview can arrive before
860
- // the session is persisted (no result body); the canonical notification that
861
- // follows DOES carry the body. Without this dimension the bodyless preview
862
- // would claim the dedupe key and suppress the real result. A blank-line gap
863
- // separates the task header block from the result body in the envelope.
864
- const hasBody = /\n\s*\n[\s\S]*\S/.test(String(text || '')) ? 'b1' : 'b0';
865
- return [id, type || fallbackKind, status, hasBody].filter(Boolean).join(':');
866
- }
867
-
868
- /** Pure delivery plan for runtime.onNotification execution envelopes (tests + handler). */
869
- export function resolveTuiRuntimeNotificationDelivery(event, text) {
870
- const trimmed = String(text ?? '').trim();
871
- if (!trimmed) return { action: 'ignore' };
872
- const parsed = parseAgentJob(trimmed);
873
- const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
874
- if (!isExecutionNotification(event, trimmed, parsed)) {
875
- return { action: 'enqueue', displayText: trimmed, modelContent: trimmed };
876
- }
877
- if (isStatusOnlyAgentCompletionNotification(trimmed)) {
878
- return { action: 'status-only', displayText: trimmed, modelContent: '' };
879
- }
880
- const modelContent = modelVisibleToolCompletionMessage(trimmed, meta);
881
- return {
882
- action: 'execution-ui',
883
- displayText: trimmed,
884
- modelContent,
885
- };
886
- }
887
-
888
- function isExecutionNotification(event, text, parsed) {
889
- const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
890
- if (meta.execution_id || meta.execution_surface) return true;
891
- if (parseAgentResultEnvelope(text)) return true;
892
- if (parseBackgroundTaskEnvelope(text)) return true;
893
- return Boolean(parsed?.taskId && /^(?:agent task:|task_id:)/mi.test(String(text || '')));
894
- }
895
-
896
- function agentArgsWithResultMetadata(args, parsed) {
897
- if (!parsed) return args;
898
- const next = { ...(args && typeof args === 'object' ? args : {}) };
899
- const requestedAction = String(next.type || next.action || next.mode || '').trim().toLowerCase();
900
- if (parsed.type) {
901
- // Job status envelopes report the original job type (usually "spawn").
902
- // Preserve the user's current agent tool action ("status", "read", …) so
903
- // manual checks render as "Reviewer status" instead of another
904
- // "Spawning Reviewer" card. Keep the job type as metadata for detail.
905
- if (!requestedAction || /^(notification|result|completion)$/i.test(requestedAction)) next.type = parsed.type;
906
- else next.jobType = parsed.type;
907
- }
908
- if (parsed.status) next.status = parsed.status;
909
- if (parsed.taskId) next.task_id = parsed.taskId;
910
- if (parsed.agent) next.agent = parsed.agent;
911
- if (parsed.preset) next.preset = parsed.preset;
912
- if (parsed.provider) next.provider = parsed.provider;
913
- if (parsed.model) next.model = parsed.model;
914
- if (parsed.effort) next.effort = parsed.effort;
915
- if (parsed.fast !== undefined) next.fast = parsed.fast;
916
- if (!next.tag && parsed.target) {
917
- const target = parsed.target.split(/\s+/)[0];
918
- if (target && !target.startsWith('sess_')) next.tag = target;
919
- }
920
- return next;
921
- }
100
+ export { parseBackgroundTaskEnvelope };
101
+ // Re-export the pure notification delivery plan (moved to ./engine/notification-plan.mjs)
102
+ // so importers/tests keep resolving resolveTuiRuntimeNotificationDelivery from engine.mjs.
103
+ export { resolveTuiRuntimeNotificationDelivery };
922
104
 
923
105
  export async function createEngineSession({
924
106
  provider: providerName,
@@ -943,7 +125,7 @@ export async function createEngineSession({
943
125
  bootProfile('engine:create:runtime-ready', { ms: (performance.now() - startedAt).toFixed(1) });
944
126
  const cwd = runtime.cwd || process.cwd();
945
127
  const stateStartedAt = performance.now();
946
- const autoClearState = () => runtime.getAutoClear?.() || runtime.autoClear || { enabled: true, idleMs: 60 * 60 * 1000 };
128
+ const autoClearState = () => runtime.getAutoClear?.() || runtime.autoClear || { enabled: true, idleMs: 60 * 60 * 1000, custom: false, providerDefault: 60 * 60 * 1000, provider: null };
947
129
  const AGENT_STATUS_CACHE_MS = 250;
948
130
  let agentStatusCache = null;
949
131
  let agentStatusCacheAt = 0;
@@ -1009,6 +191,7 @@ export async function createEngineSession({
1009
191
  let state = {
1010
192
  items: [],
1011
193
  toasts: [],
194
+ progressHint: null,
1012
195
  busy: false,
1013
196
  commandBusy: false,
1014
197
  commandStatus: null,
@@ -1018,6 +201,14 @@ export async function createEngineSession({
1018
201
  toolApproval: null,
1019
202
  lastTurn: null,
1020
203
  stats: createSessionStats(),
204
+ // Incremental derivations published by the engine so App does not scan all
205
+ // transcript items on every change:
206
+ // - activeToolSummary: running Explore/Search active counts + earliest
207
+ // startedAt for the prompt-line status (replaces App.jsx O(n) items scan).
208
+ // - promptHistoryList: newest-first deduped user-prompt history, rebuilt
209
+ // only when a user item is appended (replaces the per-change rescan).
210
+ activeToolSummary: null,
211
+ promptHistoryList: [],
1021
212
  ...baseRouteState(),
1022
213
  displayContextWindow: 0,
1023
214
  compactBoundaryTokens: 0,
@@ -1131,10 +322,85 @@ export async function createEngineSession({
1131
322
  const id = nextItems[i]?.id;
1132
323
  if (id != null) itemIndexById.set(id, i);
1133
324
  }
325
+ // Bulk item swap (session load / clear / compact). Derive the prompt-history
326
+ // list from the NEW items and stage it onto state here so App never rescans;
327
+ // the callers that invoke replaceItems always follow with a set({items:...,
328
+ // ...}) that carries fresh references, so this pre-stage does not defeat any
329
+ // emit (the accompanying set() diffs the full patch). A bulk swap also
330
+ // discards the old transcript, so drop any tracked active tool calls.
331
+ activeToolCalls.clear();
332
+ state = { ...state, items: nextItems, promptHistoryList: recomputePromptHistory(nextItems), activeToolSummary: null };
1134
333
  return nextItems;
1135
334
  };
1136
335
  let flushDeferredBeforeImmediatePush = null;
1137
336
  let pushingFromDeferredEntry = false;
337
+ // --- Prompt-history list (newest-first, deduped) maintained incrementally ---
338
+ // App previously rebuilt this from state.items on EVERY transcript change
339
+ // (App.jsx recentPromptHistory useMemo). It only changes when a user item is
340
+ // appended, so rebuild it there and on bulk item swaps, publishing to
341
+ // state.promptHistoryList.
342
+ const PROMPT_HISTORY_LIMIT = 50;
343
+ const promptHistoryKey = (value) => String(value || '').trim().replace(/\s+/g, ' ');
344
+ const recomputePromptHistory = (sourceItems = null) => {
345
+ // Pure: derive the newest-first deduped user-prompt history from the given
346
+ // items (defaults to state.items) and RETURN it. Callers decide whether to
347
+ // publish via set() so the immutable-emit contract is preserved.
348
+ const items = Array.isArray(sourceItems) ? sourceItems : (Array.isArray(state.items) ? state.items : []);
349
+ const seen = new Set();
350
+ const history = [];
351
+ for (let i = items.length - 1; i >= 0 && history.length < PROMPT_HISTORY_LIMIT; i -= 1) {
352
+ const item = items[i];
353
+ if (item?.kind !== 'user') continue;
354
+ const text = String(item.text || '').trim();
355
+ const key = promptHistoryKey(text);
356
+ if (!key || seen.has(key)) continue;
357
+ seen.add(key);
358
+ history.push(text);
359
+ }
360
+ return history;
361
+ };
362
+ // --- Active-tool summary (Explore/Search) maintained incrementally ---
363
+ // App previously scanned every transcript item on every change to derive the
364
+ // prompt-line "Exploring N / Searching N" status. Instead the tool lifecycle
365
+ // below tracks per-callId category + started-at in activeToolCalls and derives
366
+ // the small summary from it, publishing state.activeToolSummary only when the
367
+ // aggregate (counts + earliest start) actually changes.
368
+ const activeToolCalls = new Map(); // callKey -> { category, count, startedAt }
369
+ const recomputeActiveToolSummary = () => {
370
+ let exploreCount = 0, exploreStart = 0, searchCount = 0, searchStart = 0;
371
+ for (const rec of activeToolCalls.values()) {
372
+ if (!rec) continue;
373
+ const c = Math.max(1, Number(rec.count || 1));
374
+ const started = Number(rec.startedAt || 0);
375
+ if (rec.category === 'Explore') {
376
+ exploreCount += c;
377
+ if (started > 0 && (exploreStart === 0 || started < exploreStart)) exploreStart = started;
378
+ } else if (rec.category === 'Search') {
379
+ searchCount += c;
380
+ if (started > 0 && (searchStart === 0 || started < searchStart)) searchStart = started;
381
+ }
382
+ }
383
+ const next = (exploreCount || searchCount)
384
+ ? `${exploreCount}:${exploreStart}:${searchCount}:${searchStart}`
385
+ : '';
386
+ const prev = state.activeToolSummary || '';
387
+ if (next !== prev) set({ activeToolSummary: next || null });
388
+ };
389
+ const markToolCallActive = (callKey, category, count, startedAt) => {
390
+ if (!callKey || (category !== 'Explore' && category !== 'Search')) return;
391
+ activeToolCalls.set(callKey, { category, count: Math.max(1, Number(count || 1)), startedAt: Number(startedAt || Date.now()) });
392
+ recomputeActiveToolSummary();
393
+ };
394
+ const markToolCallDone = (callKey) => {
395
+ if (!callKey || !activeToolCalls.has(callKey)) return;
396
+ activeToolCalls.delete(callKey);
397
+ recomputeActiveToolSummary();
398
+ };
399
+ const clearActiveToolSummary = () => {
400
+ if (activeToolCalls.size === 0 && !state.activeToolSummary) return;
401
+ activeToolCalls.clear();
402
+ if (state.activeToolSummary) set({ activeToolSummary: null });
403
+ };
1138
404
  const pushItem = (item) => {
1139
405
  if (!pushingFromDeferredEntry && flushDeferredBeforeImmediatePush) {
1140
406
  flushDeferredBeforeImmediatePush();
@@ -1142,7 +408,16 @@ export async function createEngineSession({
1142
408
  const index = state.items.length;
1143
409
  const items = [...state.items, item];
1144
410
  if (item?.id != null) itemIndexById.set(item.id, index);
1145
- set({ items });
411
+ if (item?.kind === 'user') {
412
+ // Rebuild the derived history against the NEW list (not yet in state) and
413
+ // publish items + the fresh list in ONE set(). Do NOT pre-assign to state
414
+ // first — set() diffs against the current state, so a pre-assign would make
415
+ // the references identical and skip emit().
416
+ const promptHistoryList = recomputePromptHistory(items);
417
+ set({ items, promptHistoryList });
418
+ } else {
419
+ set({ items });
420
+ }
1146
421
  };
1147
422
  const upsertSyntheticToolItem = (text, id = nextId(), parsed = null) => {
1148
423
  const synthetic = parseSyntheticAgentMessage(text);
@@ -1198,80 +473,38 @@ export async function createEngineSession({
1198
473
  pushItem({ kind: 'notice', id, text: value, tone });
1199
474
  return id;
1200
475
  };
1201
- const toolApprovalQueue = [];
1202
- let activeToolApproval = null;
1203
- function normalizeToolApprovalRequest(input = {}, id = nextId()) {
1204
- const now = Date.now();
1205
- const timeoutMs = TOOL_APPROVAL_TIMEOUT_MS;
1206
- return {
1207
- id,
1208
- name: String(input?.name || input?.tool_name || 'tool'),
1209
- args: input?.args ?? input?.tool_input ?? null,
1210
- cwd: input?.cwd || null,
1211
- sessionId: input?.sessionId || input?.session_id || null,
1212
- toolCallId: input?.toolCallId || input?.tool_use_id || null,
1213
- reason: String(input?.reason || input?.message || 'approval requested by hook').trim(),
1214
- requestedAt: now,
1215
- timeoutMs,
1216
- expiresAt: now + timeoutMs,
1217
- };
1218
- }
1219
- function presentNextToolApproval() {
1220
- if (activeToolApproval || disposed) return;
1221
- const entry = toolApprovalQueue.shift();
1222
- if (!entry) {
1223
- if (state.toolApproval) set({ toolApproval: null });
1224
- return;
1225
- }
1226
- activeToolApproval = entry;
1227
- entry.timer = setTimeout(() => {
1228
- finishToolApproval(entry.id, false, 'approval timed out');
1229
- }, entry.request.timeoutMs);
1230
- entry.timer.unref?.();
1231
- set({ toolApproval: entry.request });
1232
- }
1233
- function finishToolApproval(id, approved, reason = '') {
1234
- const targetId = String(id || '');
1235
- if (activeToolApproval && activeToolApproval.id === targetId) {
1236
- const entry = activeToolApproval;
1237
- activeToolApproval = null;
1238
- if (entry.timer) clearTimeout(entry.timer);
1239
- set({ toolApproval: null });
1240
- try { entry.resolve({ approved: approved === true, reason: String(reason || '') }); } catch {}
1241
- presentNextToolApproval();
1242
- return true;
1243
- }
1244
- const index = toolApprovalQueue.findIndex((entry) => entry.id === targetId);
1245
- if (index >= 0) {
1246
- const [entry] = toolApprovalQueue.splice(index, 1);
1247
- if (entry?.timer) clearTimeout(entry.timer);
1248
- try { entry.resolve({ approved: approved === true, reason: String(reason || '') }); } catch {}
1249
- return true;
1250
- }
1251
- return false;
1252
- }
1253
- function denyAllToolApprovals(reason = 'approval cancelled') {
1254
- if (activeToolApproval) {
1255
- const entry = activeToolApproval;
1256
- activeToolApproval = null;
1257
- if (entry.timer) clearTimeout(entry.timer);
1258
- try { entry.resolve({ approved: false, reason }); } catch {}
1259
- }
1260
- while (toolApprovalQueue.length > 0) {
1261
- const entry = toolApprovalQueue.shift();
1262
- if (entry?.timer) clearTimeout(entry.timer);
1263
- try { entry.resolve({ approved: false, reason }); } catch {}
1264
- }
1265
- if (state.toolApproval) set({ toolApproval: null });
1266
- }
1267
- function requestToolApproval(input = {}) {
1268
- if (disposed) return Promise.resolve({ approved: false, reason: 'runtime disposed' });
1269
- return new Promise((resolve) => {
1270
- const id = nextId();
1271
- toolApprovalQueue.push({ id, request: normalizeToolApprovalRequest(input, id), resolve, timer: null });
1272
- presentNextToolApproval();
1273
- });
1274
- }
476
+ // Remove a transcript notice previously created via pushNotice(...,
477
+ // {transcript:true}). Used for transient-but-persistent notices (e.g. the
478
+ // manual OAuth URL) that must disappear once their flow concludes.
479
+ const removeNotice = (id) => {
480
+ if (id == null) return false;
481
+ const items = state.items.filter((it) => !(it?.kind === 'notice' && it?.id === id));
482
+ if (items.length === state.items.length) return false;
483
+ set({ items: replaceItems(items) });
484
+ return true;
485
+ };
486
+ // Sticky (non-TTL) input-hint-line progress state, for long-running
487
+ // installs (e.g. voice runtime download) that would otherwise spam the
488
+ // 3s toast queue. Distinct from pushToast/pushNotice: it persists across
489
+ // renders until explicitly cleared (setProgressHint('', ...) or a falsy
490
+ // text), and App.jsx's inputHint falls back to it only when no promptHint
491
+ // and no live toast currently cover the same line.
492
+ const setProgressHint = (text, tone = 'info') => {
493
+ const value = String(text ?? '').trim();
494
+ set({ progressHint: value ? { text: value, tone } : null });
495
+ };
496
+ const {
497
+ presentNextToolApproval,
498
+ finishToolApproval,
499
+ denyAllToolApprovals,
500
+ requestToolApproval,
501
+ } = createToolApproval({
502
+ getState: () => state,
503
+ set,
504
+ nextId,
505
+ getDisposed: () => disposed,
506
+ timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
507
+ });
1275
508
  const patchItem = (id, patch) => {
1276
509
  let index = itemIndexById.get(id);
1277
510
  if (!Number.isInteger(index) || state.items[index]?.id !== id) {
@@ -1319,396 +552,51 @@ export async function createEngineSession({
1319
552
  let autoClearRunning = false;
1320
553
  const pendingNotificationKeys = new Set();
1321
554
  const displayedExecutionNotificationKeys = new Set();
1322
- let executionResumeKickDeferred = false;
1323
-
1324
- function kickExecutionPendingResume() {
1325
- if (disposed) return;
1326
- if (state.busy) {
1327
- executionResumeKickDeferred = true;
1328
- return;
1329
- }
1330
- if (pending.some((entry) => entry.mode === 'pending-resume')) {
1331
- executionResumeKickDeferred = true;
1332
- return;
1333
- }
1334
- executionResumeKickDeferred = false;
1335
- pending.push(makeQueueEntry('', { mode: 'pending-resume', priority: 'next' }));
1336
- void drain();
1337
- }
1338
-
1339
- function flushDeferredExecutionPendingResumeKick() {
1340
- if (!executionResumeKickDeferred || disposed || state.busy) return;
1341
- kickExecutionPendingResume();
1342
- }
1343
-
1344
- function scheduleExecutionPendingResumeKick() {
1345
- // notifyFnForSession enqueues the model-visible body after onNotification
1346
- // returns; defer the kick so askSession pre-drain sees session pending.
1347
- queueMicrotask(() => kickExecutionPendingResume());
1348
- }
1349
-
1350
- function updateAgentJobCard(itemId, text, isError = false) {
1351
- const parsed = parseAgentJob(text);
1352
- const current = state.items.find((it) => it.id === itemId);
1353
- const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
1354
- const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
1355
- patchItem(itemId, {
1356
- result: displayText,
1357
- text: displayText,
1358
- isError,
1359
- errorCount: isError ? 1 : 0,
1360
- ...(parsed ? { args: agentArgsWithResultMetadata(current?.args, parsed) } : {}),
1361
- });
1362
- }
1363
-
1364
- if (typeof runtime.onNotification === 'function') {
1365
- unsubscribeRuntimeNotifications = runtime.onNotification((event) => {
555
+ const {
556
+ kickExecutionPendingResume,
557
+ flushDeferredExecutionPendingResumeKick,
558
+ scheduleExecutionPendingResumeKick,
559
+ updateAgentJobCard,
560
+ subscribeRuntimeNotifications,
561
+ } = createAgentJobFeed({
562
+ runtime,
563
+ getState: () => state,
564
+ set,
565
+ nextId,
566
+ getDisposed: () => disposed,
567
+ patchItem,
568
+ enqueue: (...args) => enqueue(...args),
569
+ drain: (...args) => drain(...args),
570
+ pushUserOrSyntheticItem,
571
+ makeQueueEntry: (...args) => makeQueueEntry(...args),
572
+ getPending: () => pending,
573
+ agentStatusState,
574
+ displayedExecutionNotificationKeys,
575
+ });
576
+ unsubscribeRuntimeNotifications = subscribeRuntimeNotifications();
577
+
578
+ // Remote seat superseded by another session: runtime already stopped its
579
+ // worker; sync the indicator and tell the user. Non-user-initiated, so a
580
+ // toast (not transcript) is right.
581
+ let unsubscribeRemoteState = null;
582
+ if (typeof runtime.onRemoteStateChange === 'function') {
583
+ unsubscribeRemoteState = runtime.onRemoteStateChange(({ enabled, reason }) => {
1366
584
  if (disposed) return;
1367
- const text = String(event?.content ?? event?.text ?? event ?? '').trim();
1368
- if (!text) return;
1369
- const parsed = parseAgentJob(text);
1370
- const notificationKey = notificationQueueKey(event, text, parsed);
1371
- const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
1372
- if (delivery.action === 'ignore') return;
1373
- if (delivery.action === 'status-only') {
1374
- if (parsed?.taskId) set(agentStatusState({ force: true }));
1375
- return true;
1376
- }
1377
- if (delivery.action === 'execution-ui') {
1378
- const firstDelivery = !notificationKey || !displayedExecutionNotificationKeys.has(notificationKey);
1379
- if (firstDelivery) {
1380
- if (notificationKey) displayedExecutionNotificationKeys.add(notificationKey);
1381
- pushUserOrSyntheticItem(delivery.displayText, nextId());
1382
- }
1383
- if (parsed?.taskId) set(agentStatusState({ force: true }));
1384
- if (String(delivery.modelContent || '').trim()) {
1385
- scheduleExecutionPendingResumeKick();
1386
- }
1387
- return true;
1388
- }
1389
- if (parsed?.taskId) {
1390
- set(agentStatusState({ force: true }));
585
+ set({ remoteEnabled: enabled === true });
586
+ if (reason === 'superseded') {
587
+ pushNotice('Remote mode OFF — another session took over remote.', 'warn');
1391
588
  }
1392
- const modelContent = String(delivery.modelContent ?? delivery.displayText ?? text).trim();
1393
- if (!modelContent) return true;
1394
- enqueue(modelContent, {
1395
- mode: 'task-notification',
1396
- priority: 'next',
1397
- key: notificationKey || undefined,
1398
- displayText: delivery.displayText || text,
1399
- });
1400
- return true;
1401
589
  });
1402
590
  }
1403
591
 
1404
- const CANCELLED_RESULT_STATUS_LINE = '[status: cancelled]';
1405
-
1406
- function normalizedResultStatusToken(value) {
1407
- const raw = String(value || '').trim().toLowerCase();
1408
- if (!raw) return '';
1409
- if (/^(running|pending|queued|in_progress|in-progress)$/.test(raw)) return 'running';
1410
- if (/^(completed|complete|done|success|succeeded|ok)$/.test(raw)) return 'completed';
1411
- if (/^(failed|fail|error|errored|timeout|timed_out|killed)$/.test(raw)) return 'failed';
1412
- if (/^(cancelled|canceled|cancel)$/.test(raw)) return 'cancelled';
1413
- return '';
1414
- }
1415
-
1416
- function resultTextTerminalStatus(text) {
1417
- const body = String(text || '');
1418
- const tagged = body.match(/<status[^>]*>([\s\S]*?)<\/status>/i)?.[1]?.trim();
1419
- if (tagged) return normalizedResultStatusToken(tagged);
1420
- const bracketed = body.match(/^\[status:\s*([^\]]*)\]/mi)?.[1]?.trim();
1421
- if (bracketed) return normalizedResultStatusToken(bracketed);
1422
- const inline = body.match(/^(?:status|state):\s*([^\s·,;]+)/mi)?.[1]?.trim();
1423
- return normalizedResultStatusToken(inline);
1424
- }
1425
-
1426
- function itemHasKnownTerminalStatus(item, texts = []) {
1427
- const settled = (token) => token === 'completed' || token === 'failed' || token === 'cancelled';
1428
- if (settled(normalizedResultStatusToken(item?.args?.status))) return true;
1429
- for (const text of texts) {
1430
- if (settled(resultTextTerminalStatus(text))) return true;
1431
- }
1432
- return false;
1433
- }
1434
-
1435
- function withCancelledResultMarker(text, item) {
1436
- const body = String(text || '');
1437
- // Do NOT inspect item.rawResult here: aggregate rawResult is child tool
1438
- // output (`1. grep\n<result>…`) that can incidentally contain a `status:`
1439
- // line, which would false-positive as an already-terminal status and skip
1440
- // the cancelled marker. Only result/text/body are engine-controlled
1441
- // collapsed detail (empty / status word / an existing marker), so they are
1442
- // the trustworthy terminal-status sources.
1443
- const sources = [item?.result, item?.text, body];
1444
- if (itemHasKnownTerminalStatus(item, sources)) return body;
1445
- if (!body.trim()) return `${CANCELLED_RESULT_STATUS_LINE}\n`;
1446
- return `${CANCELLED_RESULT_STATUS_LINE}\n${body}`;
1447
- }
1448
-
1449
- function groupedToolResultText(group) {
1450
- const completed = Math.min(group.count, group.completed);
1451
- if (group.count <= 1) return group.results.at(-1)?.text ?? '';
1452
- if (group.errors > 0) {
1453
- const succeeded = Math.max(0, completed - group.errors);
1454
- const reasons = group.results
1455
- .filter((result) => result?.isError)
1456
- .map((result) => firstErrorLine(result?.text))
1457
- .filter(Boolean);
1458
- const uniqueReasons = [...new Set(reasons)].slice(0, 2);
1459
- const base = succeeded > 0
1460
- ? `${succeeded} Ok · ${group.errors} Failed`
1461
- : `${group.errors} Failed`;
1462
- return [
1463
- `${base}${uniqueReasons[0] ? ` · ${uniqueReasons[0]}` : ''}`,
1464
- ...uniqueReasons.slice(1),
1465
- ].join('\n');
1466
- }
1467
- for (const result of group.results || []) {
1468
- const line = String(result?.text || '').trim();
1469
- if (line) return result.text;
1470
- }
1471
- return '';
1472
- }
1473
-
1474
- function firstErrorLine(text) {
1475
- const clean = toolErrorDisplay(text, 'tool');
1476
- if (clean) return clean;
1477
- for (const line of String(text || '').split('\n')) {
1478
- const trimmed = line.trim();
1479
- if (!trimmed) continue;
1480
- if (/^(Error|\[?error|FAIL\b)/i.test(trimmed)) return trimmed;
1481
- }
1482
- return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || '';
1483
- }
1484
-
1485
- function aggregateRawResult(calls) {
1486
- const chunks = [];
1487
- for (const rec of calls || []) {
1488
- if (rec?.resolved !== true) continue;
1489
- let text = String(rec?.resultText || '').replace(/\s+$/, '');
1490
- if (!text.trim()) continue;
1491
- const label = String(rec?.name || rec?.category || 'tool').trim() || 'tool';
1492
- chunks.push(`${chunks.length + 1}. ${label}\n${text}`);
1493
- }
1494
- return chunks.join('\n\n');
1495
- }
1496
-
1497
- function aggregateBucketForCategory(category) {
1498
- // Merge consecutive tool calls of the SAME category into one aggregate card;
1499
- // a different category opens a fresh card (no cross-category merge). The
1500
- // bucket key is the category itself, so a run of Search calls collapses into
1501
- // one Search card while an adjacent Read/Patch stays separate. Falls back to
1502
- // 'default' when a call has no resolved category. Hook/approval denials keep
1503
- // their dedicated ToolHookDenialCard path in App.jsx.
1504
- const key = String(category || '').trim();
1505
- return key ? `category:${key}` : 'default';
1506
- }
1507
-
1508
- function aggregateSummaries(aggregate) {
1509
- return [...(aggregate?.calls?.values?.() || [])]
1510
- .filter((r) => r.summary)
1511
- .sort((a, b) => Number(a.summarySeq ?? 0) - Number(b.summarySeq ?? 0))
1512
- .map((r) => r.summary);
1513
- }
1514
-
1515
- function assignAggregateSummaryOrder(aggregate, callRec) {
1516
- if (!aggregate || !callRec?.summary || callRec.summarySeq != null) return;
1517
- const next = Math.max(0, Number(aggregate.nextSummarySeq || 0));
1518
- callRec.summarySeq = next;
1519
- aggregate.nextSummarySeq = next + 1;
1520
- }
1521
-
1522
- function patchToolCardResult(card, message, toolGroups, done) {
1523
- if (!card || card.done) return false;
1524
- const callId = toolResultCallId(message) || card.callId;
1525
- if (callId && done.has(callId)) return false;
1526
- // A result for this card arrived (possibly before its deferred push delay
1527
- // elapsed) — surface the card now so the patch below has a live item and the
1528
- // fast tool paints a completed card directly, no pending placeholder stage.
1529
- // ensureVisible flushes this card AND every earlier-created still-deferred
1530
- // card in order, so transcript order always matches call order.
1531
- (card.aggregate?.ensureVisible || card.ensureVisible)?.();
1532
- const rawText = toolResultText(message?.content);
1533
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
1534
- const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
1535
-
1536
- // Aggregate card handling — collect semantic summaries per call
1537
- const aggregate = card.aggregate;
1538
- if (aggregate && card.itemId === aggregate.itemId) {
1539
- const callRec = callId ? aggregate.calls.get(callId) : null;
1540
- if (!callRec) return false;
1541
- if (callRec.resolved) {
1542
- card.done = true;
1543
- if (callId) done.add(callId);
1544
- return false;
1545
- }
1546
- callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
1547
- assignAggregateSummaryOrder(aggregate, callRec);
1548
- callRec.isError = isError;
1549
- callRec.resultText = text;
1550
- callRec.resolved = true;
1551
- const allCalls = [...aggregate.calls.values()];
1552
- const completed = allCalls.filter((r) => r.resolved).length;
1553
- const errors = allCalls.filter((r) => r.isError).length;
1554
- // Collapsed detail is status-only (no per-result summary). Failures keep a
1555
- // bare 'N Failed' status so an error stays visible while collapsed.
1556
- const succeeded = completed - errors;
1557
- const detailText = errors > 0
1558
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1559
- : '';
1560
- const currentItem = state.items.find((it) => it.id === card.itemId);
1561
- const earlyCompleted = allCalls.filter((r) => r.resolved || r.completedEarly).length;
1562
- const visualCompleted = Math.max(completed, earlyCompleted, Math.min(allCalls.length, Number(currentItem?.completedCount || 0)));
1563
- const rawResult = aggregateRawResult(allCalls);
1564
- // Collapsed aggregate detail carries no per-result summary — the card body
1565
- // shows only a status word ('Finished') or, on failure, 'N Failed'. The
1566
- // numbered+labelled raw (rawResult) is preserved for ctrl+o expansion.
1567
- const displayDetail = detailText;
1568
- patchItem(card.itemId, {
1569
- result: displayDetail,
1570
- text: displayDetail,
1571
- rawResult: rawResult || null,
1572
- isError: errors > 0,
1573
- errorCount: errors,
1574
- count: allCalls.length,
1575
- completedCount: visualCompleted,
1576
- completedAt: Number(currentItem?.completedAt) || Date.now(),
1577
- });
1578
- card.done = true;
1579
- if (callId) done.add(callId);
1580
- return true;
1581
- }
1582
-
1583
- // Non-aggregate (legacy agent-job cards, etc.)
1584
- const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
1585
- group.completed = Math.min(group.count, group.completed + 1);
1586
- group.errors += isError ? 1 : 0;
1587
- group.results.push({ text, isError });
1588
- toolGroups.set(card.itemId, group);
1589
- const resultText = groupedToolResultText(group);
1590
- const displayResult = toolGroupedDisplayFallback(resultText, text, rawText);
1591
- const patch = {
1592
- result: displayResult,
1593
- text: displayResult,
1594
- isError: group.errors > 0,
1595
- errorCount: group.errors,
1596
- count: group.count,
1597
- completedCount: group.completed,
1598
- completedAt: Date.now(),
1599
- };
1600
- if (group.count <= 1) {
1601
- const body = String(text || rawText || '').trim();
1602
- if (body) patch.rawResult = text || rawText;
1603
- const parsedAgent = parseAgentJob(rawText);
1604
- if (parsedAgent) {
1605
- patch.args = agentArgsWithResultMetadata(state.items.find((it) => it.id === card.itemId)?.args, parsedAgent);
1606
- set(agentStatusState({ force: true }));
1607
- }
1608
- }
1609
- patchItem(card.itemId, patch);
1610
- if (group.count <= 1) updateAgentJobCard(card.itemId, rawText, isError);
1611
- card.done = true;
1612
- if (callId) done.add(callId);
1613
- return true;
1614
- }
1615
-
1616
- const flushToolResults = (messages, toolCards, cardByCallId, toolGroups, done, { finalize = false, cancelled = false } = {}) => {
1617
- const results = [];
1618
- for (const m of messages || []) {
1619
- if (!m || m.role !== 'tool') continue;
1620
- const callId = toolResultCallId(m);
1621
- results.push({ message: m, callId, used: false });
1622
- if (!callId || done.has(callId)) continue;
1623
- const card = cardByCallId.get(callId);
1624
- if (patchToolCardResult(card, m, toolGroups, done)) {
1625
- results[results.length - 1].used = true;
1626
- }
1627
- }
1628
-
1629
- const openCards = (toolCards || []).filter((card) => !card.done);
1630
- if (openCards.length === 0) return;
1631
-
1632
- const unusedResults = results.filter((result) => !result.used);
1633
- const fallbackResults = unusedResults.slice(-openCards.length);
1634
- for (let i = 0; i < fallbackResults.length; i++) {
1635
- const card = openCards[i];
1636
- const result = fallbackResults[i];
1637
- if (!card || !result || card.done) continue;
1638
- if (patchToolCardResult(card, result.message, toolGroups, done)) {
1639
- if (result.callId) done.add(result.callId);
1640
- result.used = true;
1641
- }
1642
- }
1643
-
1644
- if (!finalize) return;
1645
- for (const card of toolCards || []) {
1646
- if (card.done) continue;
1647
- // Finalize must surface any still-deferred card before patching its result
1648
- // so the completed/cancelled card is never silently dropped.
1649
- (card.aggregate?.ensureVisible || card.ensureVisible)?.();
1650
- // Aggregate finalize — mark any remaining calls as done
1651
- const aggregate = card.aggregate;
1652
- if (aggregate && card.itemId === aggregate.itemId) {
1653
- const allCalls = [...aggregate.calls.values()];
1654
- // Never let a call that truly never resolved be presented as a real
1655
- // completion. Stamp it resolved with an empty, non-error result so
1656
- // completedCount reflects an honest (if degenerate) accounting instead
1657
- // of manufacturing success out of a call that never came back.
1658
- for (const rec of allCalls) {
1659
- if (rec.resolved) continue;
1660
- rec.resolved = true;
1661
- rec.isError = false;
1662
- rec.resultText = rec.resultText || '';
1663
- }
1664
- const completed = allCalls.filter((r) => r.resolved).length;
1665
- const totalCompleted = completed;
1666
- const errors = allCalls.filter((r) => r.isError).length;
1667
- const succeeded = completed - errors;
1668
- const rawResult = aggregateRawResult(allCalls);
1669
- // Collapsed detail is status-only: no per-result summary. Failures keep a
1670
- // bare 'N Failed' status. The numbered+labelled raw is kept for ctrl+o.
1671
- let displayDetail = errors > 0
1672
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1673
- : '';
1674
- if (cancelled) {
1675
- // Cancelled aggregates MUST keep the [status: cancelled] marker on the
1676
- // result so terminalStatus parsing resolves to 'cancelled'. Only normal
1677
- // completions drop the summary; cancelled ones prepend the marker.
1678
- const currentItem = state.items.find((it) => it.id === card.itemId);
1679
- displayDetail = withCancelledResultMarker(displayDetail, currentItem);
1680
- }
1681
- patchItem(card.itemId, {
1682
- result: displayDetail,
1683
- text: displayDetail,
1684
- rawResult: rawResult || null,
1685
- isError: errors > 0,
1686
- errorCount: errors,
1687
- count: allCalls.length,
1688
- completedCount: totalCompleted,
1689
- completedAt: Date.now(),
1690
- });
1691
- for (const sibling of toolCards || []) {
1692
- if (sibling.itemId !== card.itemId) continue;
1693
- sibling.done = true;
1694
- if (sibling.callId) done.add(sibling.callId);
1695
- }
1696
- continue;
1697
- }
1698
- // Non-aggregate finalize
1699
- const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
1700
- group.completed = Math.min(group.count, group.completed + 1);
1701
- toolGroups.set(card.itemId, group);
1702
- let resultText = groupedToolResultText(group);
1703
- if (cancelled) {
1704
- const currentItem = state.items.find((it) => it.id === card.itemId);
1705
- resultText = withCancelledResultMarker(resultText, currentItem);
1706
- }
1707
- patchItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, count: group.count, completedCount: group.completed, completedAt: Date.now() });
1708
- card.done = true;
1709
- if (card.callId) done.add(card.callId);
1710
- }
1711
- };
592
+ const { patchToolCardResult, flushToolResults } = createToolCardResults({
593
+ getState: () => state,
594
+ set,
595
+ patchItem,
596
+ markToolCallDone,
597
+ updateAgentJobCard,
598
+ agentStatusState,
599
+ });
1712
600
 
1713
601
  async function runTurn(userText, options = {}) {
1714
602
  const turnIndex = state.stats.turns || 0;
@@ -1721,6 +609,7 @@ export async function createEngineSession({
1721
609
  activePromptRestore = {
1722
610
  text: String(displayText || '').trim(),
1723
611
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
612
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
1724
613
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
1725
614
  restorable: options.restorable !== false,
1726
615
  submittedIds,
@@ -1749,7 +638,7 @@ export async function createEngineSession({
1749
638
  // cards (send() still in flight). Hold those by callId until the batch lands.
1750
639
  const earlyResultBuffer = new Map();
1751
640
  const aggregateCards = []; // active aggregate cards in the current consecutive tool block
1752
- const aggregateByBucket = new Map(); // per-bucket reusable card for the current consecutive tool block; cleared on block seal
641
+ let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
1753
642
 
1754
643
  // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
1755
644
  // A tool card used to enter the transcript the instant onToolCall fired,
@@ -1849,6 +738,7 @@ export async function createEngineSession({
1849
738
  activePromptRestore.committed = true;
1850
739
  activePromptRestore.requeueEntries = [];
1851
740
  activePromptRestore.pastedImages = null;
741
+ activePromptRestore.pastedTexts = null;
1852
742
  }
1853
743
  };
1854
744
 
@@ -1884,11 +774,11 @@ export async function createEngineSession({
1884
774
  const completed = allCalls.filter((r) => r.resolved).length;
1885
775
  const succeeded = completed - errors;
1886
776
  const rawResult = aggregateRawResult(allCalls);
1887
- // Status-only collapsed detail (see patchToolCardResult): no per-result
1888
- // summary; failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
777
+ // Merged count summary (see patchToolCardResult); failures keep
778
+ // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
1889
779
  const displayDetail = errors > 0
1890
780
  ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1891
- : '';
781
+ : formatAggregateDetail(aggregateSummaries(aggregate));
1892
782
  patchItem(aggregate.itemId, {
1893
783
  result: displayDetail,
1894
784
  text: displayDetail,
@@ -1907,28 +797,25 @@ export async function createEngineSession({
1907
797
  aggregateCards.length = 0;
1908
798
  // Seal the block: same-bucket calls after this point must open a fresh
1909
799
  // card, never continue one from before the seal (assistant text/turn
1910
- // end boundary). Cross-block behavior is unchanged by the in-block
1911
- // reuse relaxation below.
1912
- aggregateByBucket.clear();
800
+ // end boundary).
801
+ tailAggregate = null;
1913
802
  };
1914
803
 
1915
804
  const rememberActiveAggregate = (aggregate) => {
1916
805
  if (!aggregate) return;
1917
806
  if (!aggregateCards.includes(aggregate)) aggregateCards.push(aggregate);
1918
- aggregateByBucket.set(aggregate.bucket, aggregate);
807
+ tailAggregate = aggregate;
1919
808
  };
1920
809
 
1921
810
  const ensureAggregateCard = (bucket) => {
1922
- // Reuse any same-bucket aggregate created earlier in the SAME consecutive
1923
- // tool block, even when a different-category card was interleaved after
1924
- // it (Read, Search, Read the two Reads merge into one card; the Search
1925
- // stays separate). The block is only sealedforcing a fresh card per
1926
- // bucket by clearAggregateContinuation (assistant text lands or the
1927
- // turn ends), which clears aggregateByBucket. Reusing a pushed, non-tail
1928
- // card is safe: the aggregate card body is a fixed header+1-detail-row
1929
- // height, so patching its count/completedCount later never reflows the
1930
- // cards that were pushed after it.
1931
- const cached = aggregateByBucket.get(bucket);
811
+ // Only the TAIL aggregate (most recent card) may absorb the next call,
812
+ // and only when the bucket matches. Any different-bucket aggregate or
813
+ // standalone card in between breaks the run, so Search, Memory, Search
814
+ // renders as three cards in call order — a new call never merges into
815
+ // an earlier card above the current tail (which read as out-of-order
816
+ // count changes in the transcript). clearAggregateContinuation seals
817
+ // the block at assistant-text/turn boundaries.
818
+ const cached = tailAggregate && tailAggregate.bucket === bucket ? tailAggregate : null;
1932
819
  if (cached) {
1933
820
  rememberActiveAggregate(cached);
1934
821
  return cached;
@@ -2046,6 +933,17 @@ export async function createEngineSession({
2046
933
  let _pendingThinkFlush = false; // true when a thinking update is queued
2047
934
  let _pendingThinkingLastEndedAt = 0;
2048
935
  let compactingActive = false;
936
+ // Engine-local streaming scalars. Neither responseLength nor thinkingText is
937
+ // rendered per-token by any consumer: App reads state.thinking only as a
938
+ // boolean (App.jsx `!!(state.thinking || liveSpinner?.thinking)`) and the
939
+ // Spinner takes outputTokens, not responseLength. So we keep these growing
940
+ // values in engine-local vars and publish to the store only on a visible
941
+ // transition (thinking on↔off), a completed visible text line, tool/usage
942
+ // updates, or finalization — not on every 8ms streaming flush.
943
+ let _publishedThinkingActive = false; // last thinking boolean pushed to store
944
+ // responseLength is only consumed at finalize as an outputTokens fallback
945
+ // (Math.round(responseLength/4)); we refresh state.spinner.responseLength on
946
+ // visible-line flush and finalize so that fallback stays valid.
2049
947
 
2050
948
  const flushStreamBatch = () => {
2051
949
  if (_batchTimer !== null) {
@@ -2090,24 +988,51 @@ export async function createEngineSession({
2090
988
  }
2091
989
  }
2092
990
  }
991
+ // Only touch the spinner when there is a real reason: a visible-line
992
+ // change (patch.items set above), a thinking→responding transition, or a
993
+ // pending thinking end timestamp. Refresh responseLength here so the
994
+ // finalize outputTokens fallback stays valid without a per-token push.
2093
995
  const responseLengthVal = assistantText.length + thinkingText.length;
2094
- if (state.spinner) {
996
+ const visibleLineChanged = patch.items !== undefined;
997
+ const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
998
+ if (state.spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
2095
999
  patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || state.spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
1000
+ _publishedThinkingActive = false;
2096
1001
  }
2097
1002
  if (Object.keys(patch).length > 0) set(patch);
2098
1003
  _pendingThinkingLastEndedAt = 0;
2099
1004
  }
2100
1005
  if (_pendingThinkFlush) {
2101
1006
  _pendingThinkFlush = false;
2102
- const responseLengthVal = assistantText.length + thinkingText.length;
2103
- const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
2104
- const patch = { thinking: compactingActive ? null : thinkingText };
2105
- if (state.spinner) {
2106
- patch.spinner = compactingActive
2107
- ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
2108
- : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1007
+ // App only consumes state.thinking as a boolean and the Spinner only
1008
+ // reads the thinking flag + timing anchors none of them render the
1009
+ // growing thinkingText. So publish the thinking boolean only on the
1010
+ // OFF→ON transition (or when compacting toggles the flag), not on every
1011
+ // 8ms reasoning chunk. The full thinkingText stays engine-local and is
1012
+ // emitted at finalize via the normal spinner/thinking teardown.
1013
+ const nextThinkingActive = !compactingActive;
1014
+ // Skip the push when the published thinking boolean is unchanged: neither
1015
+ // the growing thinkingText nor responseLength is rendered per-token, and
1016
+ // the Spinner derives its live elapsed from the (already-published)
1017
+ // thinkingSegmentStartedAt anchor. Applies to both thinking and
1018
+ // compacting steady state.
1019
+ if (nextThinkingActive === _publishedThinkingActive) {
1020
+ // no-op: boolean unchanged
1021
+ } else {
1022
+ const responseLengthVal = assistantText.length + thinkingText.length;
1023
+ const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
1024
+ // state.thinking stays a truthy sentinel while active; consumers read it
1025
+ // as a boolean. Keep the value stable (thinkingText) so a late consumer
1026
+ // still sees real text, but only push on transition.
1027
+ const patch = { thinking: compactingActive ? null : thinkingText };
1028
+ if (state.spinner) {
1029
+ patch.spinner = compactingActive
1030
+ ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
1031
+ : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1032
+ }
1033
+ set(patch);
1034
+ _publishedThinkingActive = nextThinkingActive;
2109
1035
  }
2110
- set(patch);
2111
1036
  }
2112
1037
  };
2113
1038
 
@@ -2122,6 +1047,8 @@ export async function createEngineSession({
2122
1047
  const markToolCardCompletedState = (callId, message) => {
2123
1048
  const card = cardByCallId.get(callId);
2124
1049
  if (!card) return;
1050
+ // Early completion also clears the active-summary entry.
1051
+ markToolCallDone(card.callId);
2125
1052
  const aggregate = card.aggregate;
2126
1053
  if (aggregate && card.itemId === aggregate.itemId) {
2127
1054
  const callRec = aggregate.calls.get(callId);
@@ -2194,7 +1121,11 @@ export async function createEngineSession({
2194
1121
  assistantText = '';
2195
1122
  const value = String(text || '').trim();
2196
1123
  if (value) {
2197
- finalizeToolHeaders();
1124
+ // Any non-tool transcript item is a block boundary: seal the
1125
+ // aggregate continuation (not just finalize headers) so a later
1126
+ // same-category tool call opens a fresh card instead of reusing
1127
+ // one whose count would then change ABOVE this steered user item.
1128
+ clearAggregateContinuation();
2198
1129
  pushUserOrSyntheticItem(value);
2199
1130
  }
2200
1131
  },
@@ -2208,6 +1139,7 @@ export async function createEngineSession({
2208
1139
  if (thinkingText && state.thinking) {
2209
1140
  const thinkingLastEndedAt = closeThinkingSegment();
2210
1141
  set({ thinking: null, spinner: state.spinner ? { ...state.spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : state.spinner });
1142
+ _publishedThinkingActive = false;
2211
1143
  } else if (state.spinner) {
2212
1144
  set({ spinner: { ...state.spinner, mode: 'tool-use' } });
2213
1145
  }
@@ -2230,9 +1162,20 @@ export async function createEngineSession({
2230
1162
  // Category drives the aggregate bucket so only same-category calls
2231
1163
  // merge into one card; classify first, then bucket by it.
2232
1164
  const category = classifyToolCategory(name, args);
2233
- const bucket = aggregateBucketForCategory(category);
1165
+ // Agent/bridge calls always get their own standalone card so
1166
+ // ToolExecution's isAgentTool/agentActionTitle path renders a
1167
+ // "Spawn <Agent> (model, tag)" header instead of being folded
1168
+ // into the "Called N agent(s)" category-aggregate card.
1169
+ const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
2234
1170
  const callId = toolCallId(c);
2235
1171
  const callKey = callId || `__tool_${toolCards.length}_${i}`;
1172
+ // The old App scan counted multi-pattern calls via
1173
+ // aggregateToolCategoryEntry(...).count, not a flat 1. Derive the same
1174
+ // count here so the incremental Explore/Search summary matches.
1175
+ const activeCount = Number(aggregateToolCategoryEntry(name, args, category)?.count || 1);
1176
+ // Track Explore/Search calls as active for the incremental prompt-
1177
+ // line summary; cleared when their result lands or the turn ends.
1178
+ markToolCallActive(callKey, category, activeCount, Date.now());
2236
1179
 
2237
1180
  if (!bucket) {
2238
1181
  const itemId = nextId();
@@ -2264,6 +1207,10 @@ export async function createEngineSession({
2264
1207
  cardByCallId.set(callId, card);
2265
1208
  }
2266
1209
  toolCards.push(card);
1210
+ // A standalone card (Agent) breaks the consecutive run too: a
1211
+ // later same-bucket call must open a fresh card BELOW it, not
1212
+ // merge into an aggregate above it.
1213
+ tailAggregate = null;
2267
1214
  continue;
2268
1215
  }
2269
1216
 
@@ -2321,6 +1268,11 @@ export async function createEngineSession({
2321
1268
  },
2322
1269
  onCompactEvent: (event) => {
2323
1270
  flushStreamBatch();
1271
+ // Non-tool transcript item — same block-boundary rule as the
1272
+ // steered user item above: seal any live aggregate first so a
1273
+ // later same-category tool call doesn't reuse a card whose count
1274
+ // would then change above this statusdone item.
1275
+ clearAggregateContinuation();
2324
1276
  pushItem({
2325
1277
  kind: 'statusdone',
2326
1278
  id: nextId(),
@@ -2335,6 +1287,7 @@ export async function createEngineSession({
2335
1287
  compactingActive = true;
2336
1288
  const thinkingLastEndedAt = closeThinkingSegment();
2337
1289
  _pendingThinkFlush = false;
1290
+ _publishedThinkingActive = false; // compacting cleared the thinking flag
2338
1291
  set({
2339
1292
  thinking: null,
2340
1293
  spinner: {
@@ -2362,7 +1315,11 @@ export async function createEngineSession({
2362
1315
  if (!textChunk) return;
2363
1316
  markPromptCommitted();
2364
1317
  const thinkingLastEndedAt = closeThinkingSegment();
2365
- if (state.thinking) set({ thinking: null }); // collapse thinking panel immediately, no batch delay
1318
+ // Drop any queued think-flush too: it would otherwise re-publish
1319
+ // spinner.thinking:true from flushStreamBatch and resurrect the
1320
+ // indicator after we cleared it here.
1321
+ _pendingThinkFlush = false;
1322
+ if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; } // collapse thinking panel immediately, no batch delay
2366
1323
  assistantText += textChunk;
2367
1324
  currentAssistantText += textChunk;
2368
1325
  // Accumulate text and schedule a batched flush (≤1 render per
@@ -2391,7 +1348,8 @@ export async function createEngineSession({
2391
1348
  if (currentAssistantText.trim()) return;
2392
1349
  markPromptCommitted();
2393
1350
  closeThinkingSegment();
2394
- if (state.thinking) set({ thinking: null });
1351
+ _pendingThinkFlush = false; // see onTextDelta: prevent a stale think flush resurrecting the indicator
1352
+ if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; }
2395
1353
  assistantText += full;
2396
1354
  currentAssistantText += full;
2397
1355
  _pendingTextFlush = true;
@@ -2477,7 +1435,15 @@ export async function createEngineSession({
2477
1435
  closeThinkingSegment();
2478
1436
  const elapsedMs = Date.now() - startedAt;
2479
1437
  const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
2480
- const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(Number(state.spinner?.responseLength || 0) / 4));
1438
+ // responseLength is engine-local now (not pushed per-token), so compute the
1439
+ // fallback from the live accumulator instead of the possibly-stale
1440
+ // state.spinner.responseLength. Final-only / non-streaming turns never
1441
+ // accumulate `assistantText` (only currentAssistantText is set at the
1442
+ // finalize reconcile above), so take the larger of the two text sources so
1443
+ // a no-usage turn still estimates tokens from the final content.
1444
+ const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1445
+ const finalResponseLength = finalAssistantLen + thinkingText.length;
1446
+ const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
2481
1447
  const turnStatus = cancelled ? 'cancelled' : 'done';
2482
1448
  const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
2483
1449
  const assistantOutput = (currentAssistantText || assistantText || '').trim();
@@ -2510,6 +1476,8 @@ export async function createEngineSession({
2510
1476
  });
2511
1477
  flushDeferredExecutionPendingResumeKick();
2512
1478
  }
1479
+ clearActiveToolSummary();
1480
+ _publishedThinkingActive = false; // turn teardown cleared state.thinking
2513
1481
  return cancelled ? 'cancelled' : 'done';
2514
1482
  }
2515
1483
 
@@ -2526,6 +1494,7 @@ export async function createEngineSession({
2526
1494
  text: displayText,
2527
1495
  content: text,
2528
1496
  pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
1497
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
2529
1498
  onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
2530
1499
  mode,
2531
1500
  priority,
@@ -2539,6 +1508,7 @@ export async function createEngineSession({
2539
1508
  const ids = new Set(entries.map((entry) => entry.id));
2540
1509
  const keys = entries.map((entry) => entry.key).filter(Boolean);
2541
1510
  for (const key of keys) pendingNotificationKeys.delete(key);
1511
+ for (const key of keys) displayedExecutionNotificationKeys.delete(key);
2542
1512
  const queued = state.queued.filter((q) => !ids.has(q.id));
2543
1513
  if (queued.length !== state.queued.length) set({ queued });
2544
1514
  }
@@ -2617,9 +1587,11 @@ export async function createEngineSession({
2617
1587
  }
2618
1588
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
2619
1589
  const batchPastedImages = mergePastedImages(batch);
1590
+ const batchPastedTexts = mergePastedTexts(batch);
2620
1591
  const turnStatus = await runTurn(merged, {
2621
1592
  displayText: batch.map((entry) => entry.text).filter((text) => String(text || '').trim()).join('\n'),
2622
1593
  pastedImages: batchPastedImages,
1594
+ pastedTexts: batchPastedTexts,
2623
1595
  onCommitted: () => callCommitCallbacks(batch),
2624
1596
  submittedIds: [...ids],
2625
1597
  restorable: nonEditable.length === 0,
@@ -2747,7 +1719,7 @@ export async function createEngineSession({
2747
1719
  removeQueuedEntries(queued);
2748
1720
  const queuedText = queued.map((item) => item.text).filter((text) => String(text || '').trim()).join('\n');
2749
1721
  const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
2750
- return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued) };
1722
+ return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued), pastedTexts: mergePastedTexts(queued) };
2751
1723
  }
2752
1724
 
2753
1725
  const resetStats = () => {
@@ -2762,6 +1734,8 @@ export async function createEngineSession({
2762
1734
  state.spinner = null;
2763
1735
  state.lastTurn = null;
2764
1736
  state.busy = false;
1737
+ pendingNotificationKeys.clear();
1738
+ displayedExecutionNotificationKeys.clear();
2765
1739
  };
2766
1740
  const resetTuiForPendingSessionReset = () => {
2767
1741
  pendingSessionReset = true;
@@ -2929,8 +1903,15 @@ export async function createEngineSession({
2929
1903
  set({ commandBusy: true });
2930
1904
  try {
2931
1905
  const next = runtime.setCompactionSettings?.(input) || {};
2932
- syncContextStats({ allowEstimated: true });
2933
1906
  set({ ...routeState(), stats: { ...state.stats } });
1907
+ // Context-stats recompute (transcript scan + per-message JSON
1908
+ // stringify) is the secondary hitch source on this toggle; defer it
1909
+ // off the key-handler tick so Ink repaints the setting change first.
1910
+ // Stats become eventually consistent on the next tick/repaint.
1911
+ setTimeout(() => {
1912
+ syncContextStats({ allowEstimated: true });
1913
+ set({ stats: { ...state.stats } });
1914
+ }, 0);
2934
1915
  return next;
2935
1916
  } finally {
2936
1917
  set({ commandBusy: false });
@@ -2944,8 +1925,14 @@ export async function createEngineSession({
2944
1925
  set({ commandBusy: true });
2945
1926
  try {
2946
1927
  const next = await runtime.setMemoryEnabled?.(enabled);
2947
- syncContextStats({ allowEstimated: true });
2948
1928
  set({ ...routeState(), stats: { ...state.stats } });
1929
+ // Deferred for the same reason as setCompactionSettings above: keep
1930
+ // the recompute off the key-handler tick so the toggle repaints
1931
+ // immediately; stats catch up right after.
1932
+ setTimeout(() => {
1933
+ syncContextStats({ allowEstimated: true });
1934
+ set({ stats: { ...state.stats } });
1935
+ }, 0);
2949
1936
  return next;
2950
1937
  } finally {
2951
1938
  set({ commandBusy: false });
@@ -3278,6 +2265,7 @@ export async function createEngineSession({
3278
2265
  const canRestore = restoreState?.restorable && !hasPendingSteering;
3279
2266
  const restoreText = canRestore ? restoreState.text : '';
3280
2267
  const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
2268
+ const restorePastedTexts = canRestore && restoreState?.pastedTexts ? restoreState.pastedTexts : null;
3281
2269
  // When steering suppresses the restore, the interrupted prompt's pasted
3282
2270
  // images never get committed (onCommitted won't fire) nor re-installed into
3283
2271
  // the draft, so hand them back for cleanup to avoid a stale `[Image #id]`
@@ -3285,6 +2273,9 @@ export async function createEngineSession({
3285
2273
  const discardPastedImages = restoreState?.restorable && hasPendingSteering && restoreState?.pastedImages
3286
2274
  ? restoreState.pastedImages
3287
2275
  : null;
2276
+ const discardPastedTexts = restoreState?.restorable && hasPendingSteering && restoreState?.pastedTexts
2277
+ ? restoreState.pastedTexts
2278
+ : null;
3288
2279
  const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
3289
2280
  ? restoreState.requeueEntries.slice()
3290
2281
  : [];
@@ -3306,7 +2297,7 @@ export async function createEngineSession({
3306
2297
  restoreState.restorable = false;
3307
2298
  restoreState.requeueEntries = [];
3308
2299
  }
3309
- return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages };
2300
+ return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
3310
2301
  },
3311
2302
  resolveToolApproval: (id, decision = {}) => {
3312
2303
  const approved = decision === true || decision?.approved === true;
@@ -3368,8 +2359,14 @@ export async function createEngineSession({
3368
2359
  set({ commandBusy: true });
3369
2360
  try {
3370
2361
  const result = await runtime.setOutputStyle?.(styleId);
3371
- resetStatsAndSyncContext();
2362
+ resetStats();
3372
2363
  set({ ...routeState(), stats: { ...state.stats } });
2364
+ // Defer the context recompute (transcript scan) off this tick so
2365
+ // the style change repaints immediately; stats settle right after.
2366
+ setTimeout(() => {
2367
+ syncContextStats({ allowEstimated: true });
2368
+ set({ stats: { ...state.stats } });
2369
+ }, 0);
3373
2370
  return result;
3374
2371
  } finally {
3375
2372
  set({ commandBusy: false });
@@ -3397,6 +2394,16 @@ export async function createEngineSession({
3397
2394
  set({ remoteEnabled: next });
3398
2395
  return next;
3399
2396
  },
2397
+ // Force-claim remote for this session (single-holder, last-wins). Always
2398
+ // turns remote ON here and steals the bridge seat; the previous holder is
2399
+ // superseded and flips itself OFF via onRemoteStateChange. Used by the
2400
+ // `/remote` slash command — repeated /remote just re-claims (idempotent).
2401
+ claimRemote: () => {
2402
+ runtime.startRemote?.();
2403
+ const next = runtime.isRemoteEnabled?.() === true;
2404
+ set({ remoteEnabled: next });
2405
+ return next;
2406
+ },
3400
2407
  isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
3401
2408
  // Theme is a TUI-local concern (no runtime round-trip). listThemes returns
3402
2409
  // picker metadata; getTheme reports the active id; setTheme applies the
@@ -3479,6 +2486,15 @@ export async function createEngineSession({
3479
2486
  'info');
3480
2487
  return true;
3481
2488
  },
2489
+ loginOpenCodeGoUsage: async () => {
2490
+ if (state.commandBusy) throw new Error('command busy');
2491
+ set({ commandBusy: true });
2492
+ try {
2493
+ return await runtime.loginOpenCodeGoUsage();
2494
+ } finally {
2495
+ set({ commandBusy: false });
2496
+ }
2497
+ },
3482
2498
  saveOpenAIUsageSessionKey: (secret) => {
3483
2499
  runtime.saveOpenAIUsageSessionKey(secret);
3484
2500
  pushNotice('OpenAI usage auth saved', 'info');
@@ -3603,6 +2619,8 @@ export async function createEngineSession({
3603
2619
  }
3604
2620
  },
3605
2621
  pushNotice,
2622
+ removeNotice,
2623
+ setProgressHint,
3606
2624
  clear: async () => {
3607
2625
  if (state.commandBusy) return false;
3608
2626
  set({ commandBusy: true });
@@ -3739,6 +2757,8 @@ export async function createEngineSession({
3739
2757
  try { clearInterval(runtimePulseTimer); } catch {}
3740
2758
  try { unsubscribeRuntimeNotifications?.(); } catch {}
3741
2759
  unsubscribeRuntimeNotifications = null;
2760
+ try { unsubscribeRemoteState?.(); } catch {}
2761
+ unsubscribeRemoteState = null;
3742
2762
  denyAllToolApprovals('runtime closing');
3743
2763
  await runtime.close(reason, options);
3744
2764
  listeners.clear();