@maudecode/talaria-web 1.16.1 → 1.18.0

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 (445) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.d.ts +1 -1
  3. package/dist/api/auth-raw.js +4 -4
  4. package/dist/api/auth-raw.js.map +1 -1
  5. package/dist/api/automation-raw.d.ts +6 -0
  6. package/dist/api/automation-raw.js +59 -9
  7. package/dist/api/automation-raw.js.map +1 -1
  8. package/dist/api/automation-router.d.ts +163 -10
  9. package/dist/api/automation-router.js +25 -8
  10. package/dist/api/automation-router.js.map +1 -1
  11. package/dist/api/chat-router.d.ts +700 -15
  12. package/dist/api/chat-router.js +74 -55
  13. package/dist/api/chat-router.js.map +1 -1
  14. package/dist/api/git-router.d.ts +47 -2
  15. package/dist/api/git-router.js +31 -22
  16. package/dist/api/git-router.js.map +1 -1
  17. package/dist/api/raw-routes.d.ts +14 -0
  18. package/dist/api/raw-routes.js +277 -36
  19. package/dist/api/raw-routes.js.map +1 -1
  20. package/dist/api/router.d.ts +10 -0
  21. package/dist/api/router.js +10 -8
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/api/sessions-router.d.ts +1710 -73
  24. package/dist/api/sessions-router.js +164 -10
  25. package/dist/api/sessions-router.js.map +1 -1
  26. package/dist/api/settings-router.d.ts +132 -2
  27. package/dist/api/settings-router.js +15 -5
  28. package/dist/api/settings-router.js.map +1 -1
  29. package/dist/api/sse-routes.d.ts +16 -3
  30. package/dist/api/sse-routes.js +194 -67
  31. package/dist/api/sse-routes.js.map +1 -1
  32. package/dist/api/tools-raw.js +12 -25
  33. package/dist/api/tools-raw.js.map +1 -1
  34. package/dist/api/tools-router.d.ts +425 -19
  35. package/dist/api/tools-router.js +157 -26
  36. package/dist/api/tools-router.js.map +1 -1
  37. package/dist/app.d.ts +0 -9377
  38. package/dist/app.js +42 -17
  39. package/dist/app.js.map +1 -1
  40. package/dist/auth/gate.js +8 -2
  41. package/dist/auth/gate.js.map +1 -1
  42. package/dist/auth/oidc.d.ts +5 -2
  43. package/dist/auth/oidc.js +8 -7
  44. package/dist/auth/oidc.js.map +1 -1
  45. package/dist/bin/talaria-web.js +1 -1
  46. package/dist/bin/talaria-web.js.map +1 -1
  47. package/dist/cli/dotenv.js +1 -0
  48. package/dist/cli/dotenv.js.map +1 -1
  49. package/dist/config/agent-config.d.ts +2 -0
  50. package/dist/config/agent-config.js +12 -2
  51. package/dist/config/agent-config.js.map +1 -1
  52. package/dist/config.d.ts +2 -0
  53. package/dist/config.js +5 -1
  54. package/dist/config.js.map +1 -1
  55. package/dist/http/context.d.ts +20 -3
  56. package/dist/http/context.js +41 -8
  57. package/dist/http/context.js.map +1 -1
  58. package/dist/http/csp.d.ts +2 -1
  59. package/dist/http/csp.js +3 -2
  60. package/dist/http/csp.js.map +1 -1
  61. package/dist/onboarding.js +4 -0
  62. package/dist/onboarding.js.map +1 -1
  63. package/dist/profiles/profiles.d.ts +10 -0
  64. package/dist/profiles/profiles.js +40 -2
  65. package/dist/profiles/profiles.js.map +1 -1
  66. package/dist/providers/catalog.d.ts +101 -8
  67. package/dist/providers/catalog.js +463 -123
  68. package/dist/providers/catalog.js.map +1 -1
  69. package/dist/raster-image.d.ts +7 -0
  70. package/dist/raster-image.js +267 -0
  71. package/dist/raster-image.js.map +1 -0
  72. package/dist/redact.d.ts +1 -0
  73. package/dist/redact.js +354 -44
  74. package/dist/redact.js.map +1 -1
  75. package/dist/runtime.js +111 -27
  76. package/dist/runtime.js.map +1 -1
  77. package/dist/server.js +5 -0
  78. package/dist/server.js.map +1 -1
  79. package/dist/sessions/anchor.d.ts +2 -0
  80. package/dist/sessions/anchor.js +8 -3
  81. package/dist/sessions/anchor.js.map +1 -1
  82. package/dist/sessions/background-tasks.d.ts +2 -9
  83. package/dist/sessions/background-tasks.js +2 -12
  84. package/dist/sessions/background-tasks.js.map +1 -1
  85. package/dist/sessions/claude-code.d.ts +19 -0
  86. package/dist/sessions/claude-code.js +250 -0
  87. package/dist/sessions/claude-code.js.map +1 -0
  88. package/dist/sessions/cli-sessions.d.ts +15 -4
  89. package/dist/sessions/cli-sessions.js +120 -29
  90. package/dist/sessions/cli-sessions.js.map +1 -1
  91. package/dist/sessions/completions.d.ts +61 -9
  92. package/dist/sessions/completions.js +282 -34
  93. package/dist/sessions/completions.js.map +1 -1
  94. package/dist/sessions/compress.d.ts +14 -1
  95. package/dist/sessions/compress.js +52 -11
  96. package/dist/sessions/compress.js.map +1 -1
  97. package/dist/sessions/export.d.ts +5 -0
  98. package/dist/sessions/export.js +30 -0
  99. package/dist/sessions/export.js.map +1 -1
  100. package/dist/sessions/gateway-watcher.d.ts +7 -3
  101. package/dist/sessions/gateway-watcher.js +31 -19
  102. package/dist/sessions/gateway-watcher.js.map +1 -1
  103. package/dist/sessions/handoff.d.ts +32 -0
  104. package/dist/sessions/handoff.js +114 -0
  105. package/dist/sessions/handoff.js.map +1 -0
  106. package/dist/sessions/journal.d.ts +8 -0
  107. package/dist/sessions/journal.js +44 -10
  108. package/dist/sessions/journal.js.map +1 -1
  109. package/dist/sessions/list.d.ts +13 -3
  110. package/dist/sessions/list.js +46 -15
  111. package/dist/sessions/list.js.map +1 -1
  112. package/dist/sessions/merge.d.ts +53 -5
  113. package/dist/sessions/merge.js +219 -23
  114. package/dist/sessions/merge.js.map +1 -1
  115. package/dist/sessions/recovery.d.ts +22 -0
  116. package/dist/sessions/recovery.js +506 -0
  117. package/dist/sessions/recovery.js.map +1 -0
  118. package/dist/sessions/relay.d.ts +21 -2
  119. package/dist/sessions/relay.js +156 -11
  120. package/dist/sessions/relay.js.map +1 -1
  121. package/dist/sessions/service.d.ts +152 -8
  122. package/dist/sessions/service.js +640 -72
  123. package/dist/sessions/service.js.map +1 -1
  124. package/dist/sessions/session.d.ts +17 -1
  125. package/dist/sessions/session.js +63 -4
  126. package/dist/sessions/session.js.map +1 -1
  127. package/dist/sessions/state-db-worker.d.ts +1 -0
  128. package/dist/sessions/state-db-worker.js +33 -0
  129. package/dist/sessions/state-db-worker.js.map +1 -0
  130. package/dist/sessions/state-db.d.ts +87 -4
  131. package/dist/sessions/state-db.js +417 -8
  132. package/dist/sessions/state-db.js.map +1 -1
  133. package/dist/sessions/store.d.ts +7 -0
  134. package/dist/sessions/store.js +36 -22
  135. package/dist/sessions/store.js.map +1 -1
  136. package/dist/sessions/streams.d.ts +10 -0
  137. package/dist/sessions/streams.js +14 -3
  138. package/dist/sessions/streams.js.map +1 -1
  139. package/dist/sessions/titles.d.ts +2 -0
  140. package/dist/sessions/titles.js +4 -0
  141. package/dist/sessions/titles.js.map +1 -1
  142. package/dist/sessions/todo.d.ts +6 -1
  143. package/dist/sessions/todo.js +11 -4
  144. package/dist/sessions/todo.js.map +1 -1
  145. package/dist/sessions/tool-display.d.ts +12 -1
  146. package/dist/sessions/tool-display.js +50 -1
  147. package/dist/sessions/tool-display.js.map +1 -1
  148. package/dist/sessions/turn-context.d.ts +1 -1
  149. package/dist/sessions/turn-context.js +11 -7
  150. package/dist/sessions/turn-context.js.map +1 -1
  151. package/dist/sessions/turn.d.ts +67 -11
  152. package/dist/sessions/turn.js +413 -111
  153. package/dist/sessions/turn.js.map +1 -1
  154. package/dist/sessions/wakeup-pause.d.ts +59 -0
  155. package/dist/sessions/wakeup-pause.js +181 -0
  156. package/dist/sessions/wakeup-pause.js.map +1 -0
  157. package/dist/settings.d.ts +20 -2
  158. package/dist/settings.js +45 -9
  159. package/dist/settings.js.map +1 -1
  160. package/dist/sidecar/fake.d.ts +4 -4
  161. package/dist/sidecar/fake.js +5 -2
  162. package/dist/sidecar/fake.js.map +1 -1
  163. package/dist/spa.js +2 -1
  164. package/dist/spa.js.map +1 -1
  165. package/dist/startup.d.ts +13 -5
  166. package/dist/startup.js +48 -0
  167. package/dist/startup.js.map +1 -1
  168. package/dist/test/harness.js +2 -0
  169. package/dist/test/harness.js.map +1 -1
  170. package/dist/test/ts-worker-hooks.d.ts +1 -0
  171. package/dist/test/ts-worker-hooks.js +24 -0
  172. package/dist/test/ts-worker-hooks.js.map +1 -0
  173. package/dist/text/diff.d.ts +9 -14
  174. package/dist/text/diff.js +74 -108
  175. package/dist/text/diff.js.map +1 -1
  176. package/dist/tools/crons.js +45 -8
  177. package/dist/tools/crons.js.map +1 -1
  178. package/dist/tools/dashboard-plugins.d.ts +53 -0
  179. package/dist/tools/dashboard-plugins.js +188 -0
  180. package/dist/tools/dashboard-plugins.js.map +1 -0
  181. package/dist/tools/extensions.d.ts +13 -1
  182. package/dist/tools/extensions.js +132 -60
  183. package/dist/tools/extensions.js.map +1 -1
  184. package/dist/tools/health.d.ts +32 -0
  185. package/dist/tools/health.js +111 -19
  186. package/dist/tools/health.js.map +1 -1
  187. package/dist/tools/insights.d.ts +8 -2
  188. package/dist/tools/insights.js +18 -2
  189. package/dist/tools/insights.js.map +1 -1
  190. package/dist/tools/kanban.d.ts +41 -1
  191. package/dist/tools/kanban.js +113 -2
  192. package/dist/tools/kanban.js.map +1 -1
  193. package/dist/tools/mcp.d.ts +15 -1
  194. package/dist/tools/mcp.js +99 -5
  195. package/dist/tools/mcp.js.map +1 -1
  196. package/dist/tools/project-os.d.ts +11 -0
  197. package/dist/tools/project-os.js +267 -0
  198. package/dist/tools/project-os.js.map +1 -0
  199. package/dist/tools/skills.d.ts +5 -1
  200. package/dist/tools/skills.js +5 -3
  201. package/dist/tools/skills.js.map +1 -1
  202. package/dist/tools/terminal.d.ts +1 -0
  203. package/dist/tools/terminal.js +1 -1
  204. package/dist/tools/terminal.js.map +1 -1
  205. package/dist/tools/updates.d.ts +33 -8
  206. package/dist/tools/updates.js +236 -57
  207. package/dist/tools/updates.js.map +1 -1
  208. package/dist/tools/wiki.d.ts +68 -0
  209. package/dist/tools/wiki.js +413 -0
  210. package/dist/tools/wiki.js.map +1 -0
  211. package/dist/workspace/escape.d.ts +61 -0
  212. package/dist/workspace/escape.js +149 -0
  213. package/dist/workspace/escape.js.map +1 -0
  214. package/dist/workspace/extract.d.ts +22 -0
  215. package/dist/workspace/extract.js +308 -0
  216. package/dist/workspace/extract.js.map +1 -0
  217. package/dist/workspace/fs.d.ts +19 -3
  218. package/dist/workspace/fs.js +96 -23
  219. package/dist/workspace/fs.js.map +1 -1
  220. package/dist/workspace/git.d.ts +18 -0
  221. package/dist/workspace/git.js +38 -25
  222. package/dist/workspace/git.js.map +1 -1
  223. package/dist/workspace/media.d.ts +4 -4
  224. package/dist/workspace/media.js +18 -45
  225. package/dist/workspace/media.js.map +1 -1
  226. package/dist/workspace/preview.d.ts +28 -0
  227. package/dist/workspace/preview.js +110 -0
  228. package/dist/workspace/preview.js.map +1 -0
  229. package/dist/workspace/rollback.d.ts +36 -21
  230. package/dist/workspace/rollback.js +179 -270
  231. package/dist/workspace/rollback.js.map +1 -1
  232. package/dist/workspace/unzip.js +4 -1
  233. package/dist/workspace/unzip.js.map +1 -1
  234. package/dist/workspace/upload.d.ts +9 -4
  235. package/dist/workspace/upload.js +9 -3
  236. package/dist/workspace/upload.js.map +1 -1
  237. package/dist/workspace/worktrees.d.ts +2 -1
  238. package/dist/workspace/worktrees.js +14 -15
  239. package/dist/workspace/worktrees.js.map +1 -1
  240. package/package.json +4 -2
  241. package/sidecar/agent_dependency.json +4 -4
  242. package/sidecar/scripts/replay_sidecar.py +25 -1
  243. package/sidecar/talaria_sidecar/__init__.py +1 -1
  244. package/sidecar/talaria_sidecar/__main__.py +7 -1
  245. package/sidecar/talaria_sidecar/home.py +52 -2
  246. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  247. package/sidecar/talaria_sidecar/methods/aux.py +39 -5
  248. package/sidecar/talaria_sidecar/methods/chat.py +431 -43
  249. package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
  250. package/sidecar/talaria_sidecar/methods/commands.py +96 -2
  251. package/sidecar/talaria_sidecar/methods/cron.py +39 -8
  252. package/sidecar/talaria_sidecar/methods/gateway.py +32 -15
  253. package/sidecar/talaria_sidecar/methods/kanban.py +140 -19
  254. package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
  255. package/sidecar/talaria_sidecar/methods/process.py +115 -2
  256. package/sidecar/talaria_sidecar/methods/profiles.py +84 -8
  257. package/sidecar/talaria_sidecar/methods/providers.py +27 -8
  258. package/sidecar/talaria_sidecar/methods/runtime.py +3 -0
  259. package/sidecar/talaria_sidecar/methods/state_db.py +39 -5
  260. package/sidecar/talaria_sidecar/methods/text.py +17 -1
  261. package/sidecar/talaria_sidecar/methods/usage.py +257 -4
  262. package/sidecar/talaria_sidecar/runtime.py +17 -1
  263. package/static/dist/FILES.txt +127 -125
  264. package/static/dist/assets/AppShell-g3bkgnvt.js +1 -0
  265. package/static/dist/assets/ChatPage-oo7bdan6.js +25 -0
  266. package/static/dist/assets/Dialog-g355qxhp.js +1 -0
  267. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +1 -0
  268. package/static/dist/assets/HubRoute-lmkrbw47.js +2 -0
  269. package/static/dist/assets/{Markdown-jqrx5es0.js → Markdown-j4kut4j2.js} +125 -125
  270. package/static/dist/assets/RightPanel-ohlsdzfl.js +1 -0
  271. package/static/dist/assets/Select-kddq75qc.js +1 -0
  272. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +1 -0
  273. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +1 -0
  274. package/static/dist/assets/States-eq6fe29m.js +1 -0
  275. package/static/dist/assets/Toaster-pc247u9f.js +1 -0
  276. package/static/dist/assets/_app-f8n1i0m1.js +1 -0
  277. package/static/dist/assets/_app.ext._extensionId-gfbzpoou.js +1 -0
  278. package/static/dist/assets/_app.index-bzzv80hk.js +1 -0
  279. package/static/dist/assets/_app.insights-dzixbryi.js +1 -0
  280. package/static/dist/assets/_app.kanban-c6hep2us.js +1 -0
  281. package/static/dist/assets/_app.logs-i69qds98.js +1 -0
  282. package/static/dist/assets/_app.memory-kl4vf0gd.js +1 -0
  283. package/static/dist/assets/_app.profiles-b8zinmyp.js +1 -0
  284. package/static/dist/assets/{_app.session._sessionId-hh27wwu2.js → _app.session._sessionId-fn1bhde6.js} +1 -1
  285. package/static/dist/assets/_app.settings-cg8plwmf.js +1 -0
  286. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +3 -0
  287. package/static/dist/assets/_app.skills-cs47sbvs.js +1 -0
  288. package/static/dist/assets/_app.tasks-dwvk64tc.js +1 -0
  289. package/static/dist/assets/_app.todos-c8xjm8p3.js +1 -0
  290. package/static/dist/assets/_app.workspaces-ehvd1gds.js +1 -0
  291. package/static/dist/assets/{abnfDiagram-VCTEODGH-oity9y4o.js → abnfDiagram-VCTEODGH-c8kdixke.js} +1 -1
  292. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +1 -0
  293. package/static/dist/assets/{architectureDiagram-5GKGNRK7-ceaepnyr.js → architectureDiagram-5GKGNRK7-cku06x1p.js} +1 -1
  294. package/static/dist/assets/{back-eu64w89w.js → back-h7aun7ew.js} +1 -1
  295. package/static/dist/assets/{blockDiagram-I7D4REHJ-n79st4pj.js → blockDiagram-I7D4REHJ-jzthp1wa.js} +1 -1
  296. package/static/dist/assets/{c4Diagram-7LVT6UL2-mi73akzk.js → c4Diagram-7LVT6UL2-mwr67mt8.js} +1 -1
  297. package/static/dist/assets/{cancel-mzvkdjg8.js → cancel-fhi0vcnu.js} +1 -1
  298. package/static/dist/assets/channel-b2zo73z1.js +1 -0
  299. package/static/dist/assets/{chunk-4HAMMTFA-laat0g1q.js → chunk-4HAMMTFA-gpq1y228.js} +1 -1
  300. package/static/dist/assets/{chunk-75Z2AOVW-0psy1tr2.js → chunk-75Z2AOVW-ktje58c4.js} +1 -1
  301. package/static/dist/assets/{chunk-DU6HZSFF-lw8uox8x.js → chunk-DU6HZSFF-ofajdixh.js} +3 -3
  302. package/static/dist/assets/{chunk-F27PBJKO-erassib3.js → chunk-F27PBJKO-cmev85ti.js} +1 -1
  303. package/static/dist/assets/{chunk-GMAD6QVW-fz7rx619.js → chunk-GMAD6QVW-htjgt22y.js} +1 -1
  304. package/static/dist/assets/{chunk-GVQU2GXP-gdb0epb2.js → chunk-GVQU2GXP-qndvoxl7.js} +1 -1
  305. package/static/dist/assets/{chunk-IMKFNOWR-ea8t26qw.js → chunk-IMKFNOWR-e5bkd5q6.js} +1 -1
  306. package/static/dist/assets/{chunk-L3NEJ4N5-mb1gu9kz.js → chunk-L3NEJ4N5-mj1155r6.js} +1 -1
  307. package/static/dist/assets/{chunk-OSK3NFVY-eovmccvy.js → chunk-OSK3NFVY-e7gkikcw.js} +1 -1
  308. package/static/dist/assets/{chunk-P2QGCYS3-bo55zjo5.js → chunk-P2QGCYS3-f7t54xfm.js} +1 -1
  309. package/static/dist/assets/{chunk-POPQ4Y6H-ja7ohe4h.js → chunk-POPQ4Y6H-i927v9oa.js} +1 -1
  310. package/static/dist/assets/{chunk-PWAF6VOD-e30g9nus.js → chunk-PWAF6VOD-m84lmnyt.js} +1 -1
  311. package/static/dist/assets/{chunk-SHT3W25Y-gbb798f7.js → chunk-SHT3W25Y-mi0tlcu9.js} +1 -1
  312. package/static/dist/assets/{chunk-SVP7TREG-h2iw8urm.js → chunk-SVP7TREG-dnau702j.js} +1 -1
  313. package/static/dist/assets/{chunk-TICWLB2K-fd2cottz.js → chunk-TICWLB2K-edem1gkg.js} +1 -1
  314. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +1 -0
  315. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +1 -0
  316. package/static/dist/assets/{copied-h65fc3cx.js → copied-hcplv46p.js} +1 -1
  317. package/static/dist/assets/copy-magtofyp.js +1 -0
  318. package/static/dist/assets/{cynefin-OW5HDTMX-lbltyl5j.js → cynefin-OW5HDTMX-siqlulkl.js} +1 -1
  319. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-lxhsqq4a.js → cynefinDiagram-5FMLGOSQ-i648tah9.js} +1 -1
  320. package/static/dist/assets/{dagre-GXQ25YYZ-gn4nnaln.js → dagre-GXQ25YYZ-mwn6uc17.js} +1 -1
  321. package/static/dist/assets/{diagram-S7CK7UJ4-pa3ij6q7.js → diagram-S7CK7UJ4-l8abxaes.js} +1 -1
  322. package/static/dist/assets/{diagram-UQ7AKVKN-zbipyz57.js → diagram-UQ7AKVKN-mekq7zgq.js} +1 -1
  323. package/static/dist/assets/{diagram-VSXAHHWV-d1kbpzgf.js → diagram-VSXAHHWV-fnky6lvc.js} +1 -1
  324. package/static/dist/assets/{diagram-VX7I27RA-i3ty14xl.js → diagram-VX7I27RA-xe6r5qfw.js} +1 -1
  325. package/static/dist/assets/{diagram-Z3DM3KII-m8xefqa7.js → diagram-Z3DM3KII-nq07dg7k.js} +1 -1
  326. package/static/dist/assets/{ebnfDiagram-PWID7BFC-nz7wj05l.js → ebnfDiagram-PWID7BFC-c62ipm3g.js} +1 -1
  327. package/static/dist/assets/endpoints-klbxod4t.js +64 -0
  328. package/static/dist/assets/{erDiagram-RLTQ6QDP-fa41ib83.js → erDiagram-RLTQ6QDP-h39kmnzy.js} +1 -1
  329. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +1 -0
  330. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +1 -0
  331. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-n7zq01qu.js → ganttDiagram-EL5Y4UJY-ngbsyp37.js} +1 -1
  332. package/static/dist/assets/{gitGraph-4MIJSDKK-c89emb07.js → gitGraph-4MIJSDKK-fr2i7caa.js} +1 -1
  333. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-cz7cgr6w.js → gitGraphDiagram-WWUBYQGX-eravdti4.js} +1 -1
  334. package/static/dist/assets/{highlighted-body-KPVGNVTW-d9bapmni.js → highlighted-body-KPVGNVTW-jwpop9p8.js} +1 -1
  335. package/static/dist/assets/host-i828xuo7.js +1 -0
  336. package/static/dist/assets/index-eh8zilnv.css +2 -0
  337. package/static/dist/assets/index-r648f4o1.js +11 -0
  338. package/static/dist/assets/{info-A6RAGUB7-ndddhhhh.js → info-A6RAGUB7-h11x0dxo.js} +1 -1
  339. package/static/dist/assets/{infoDiagram-27XIBGKW-ccxigcpf.js → infoDiagram-27XIBGKW-dfh47dw9.js} +1 -1
  340. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-le0z9vtc.js → ishikawaDiagram-5VMMS53U-i6ypq351.js} +1 -1
  341. package/static/dist/assets/{journeyDiagram-3NMN7TZE-ivsmb26o.js → journeyDiagram-3NMN7TZE-lduf1z95.js} +1 -1
  342. package/static/dist/assets/{kanban-definition-UXKFOSKX-h6n2awt0.js → kanban-definition-UXKFOSKX-e18bu4pg.js} +1 -1
  343. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +1 -0
  344. package/static/dist/assets/{line-dmzlfe0u.js → line-meizqsvn.js} +1 -1
  345. package/static/dist/assets/link-oot2yn73.js +1 -0
  346. package/static/dist/assets/login-gu28l9ou.js +1 -0
  347. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +1 -0
  348. package/static/dist/assets/{mermaid-parser.core-gwyd5bnq.js → mermaid-parser.core-ntne45i9.js} +3 -3
  349. package/static/dist/assets/{mindmap-definition-YA3MSWOX-l68gg5h7.js → mindmap-definition-YA3MSWOX-27b26eok.js} +1 -1
  350. package/static/dist/assets/onboarding-lkphzu1s.js +1 -0
  351. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +1 -0
  352. package/static/dist/assets/{packet-AYTQ26CC-gpv06065.js → packet-AYTQ26CC-l6jdka8p.js} +1 -1
  353. package/static/dist/assets/{pegDiagram-XKGWAZYB-e52vs23u.js → pegDiagram-XKGWAZYB-g1er3bxj.js} +1 -1
  354. package/static/dist/assets/{pie-WAS4IAKB-l6m7nz5s.js → pie-WAS4IAKB-i0ylcrt9.js} +1 -1
  355. package/static/dist/assets/{pieDiagram-E7YTZNPT-b4is36bq.js → pieDiagram-E7YTZNPT-fbz1e55z.js} +1 -1
  356. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-m8vt2788.js → quadrantDiagram-AXDQQJYC-hhue7d8o.js} +1 -1
  357. package/static/dist/assets/{radar-RG4KPBEZ-nxbrhiov.js → radar-RG4KPBEZ-f9b70aa4.js} +1 -1
  358. package/static/dist/assets/{railroad-74A4TZTK-222mu9hi.js → railroad-74A4TZTK-lgsuoeda.js} +1 -1
  359. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +1 -0
  360. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +1 -0
  361. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +1 -0
  362. package/static/dist/assets/{railroadDiagram-O6MQD6OU-o8lldfzv.js → railroadDiagram-O6MQD6OU-eqpy9fnz.js} +1 -1
  363. package/static/dist/assets/refresh-abtu93ke.js +1 -0
  364. package/static/dist/assets/refresh-cw-hlwb02qv.js +1 -0
  365. package/static/dist/assets/{requirementDiagram-BXWQKSXE-ovyq38kv.js → requirementDiagram-BXWQKSXE-ogf6puhi.js} +1 -1
  366. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-ou7lznor.js → sankeyDiagram-P5KCCOFB-fqb8xcjf.js} +1 -1
  367. package/static/dist/assets/{saved-bazmcup9.js → saved-girnabkf.js} +1 -1
  368. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-ovckdzf2.js → sequenceDiagram-WJ2MYXX4-dlcz6qjz.js} +1 -1
  369. package/static/dist/assets/{settings_section_extensions_title-ertwwhlx.js → settings_section_extensions_title-h14cwk6f.js} +1 -1
  370. package/static/dist/assets/share._token-kl0r7ido.js +2 -0
  371. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +1 -0
  372. package/static/dist/assets/sse-iwku0sjo.js +1 -0
  373. package/static/dist/assets/{stateDiagram-D77RDMKH-fcrexkkv.js → stateDiagram-D77RDMKH-fyoznpcs.js} +1 -1
  374. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +1 -0
  375. package/static/dist/assets/{swimlanes-42K2YHIH-f7usu8fw.js → swimlanes-42K2YHIH-oefldhfy.js} +1 -1
  376. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +8 -0
  377. package/static/dist/assets/{timeline-definition-24CTP7MA-gp1hvz9f.js → timeline-definition-24CTP7MA-b1vl2zil.js} +1 -1
  378. package/static/dist/assets/todoStore-jjr5yr78.js +1 -0
  379. package/static/dist/assets/{treeView-Q6P3EWNA-gb9gf11u.js → treeView-Q6P3EWNA-f7hcci5o.js} +1 -1
  380. package/static/dist/assets/{treemap-WGGIJYW6-nuvfnvvi.js → treemap-WGGIJYW6-nl85071a.js} +1 -1
  381. package/static/dist/assets/units-kgxzxfhi.js +1 -0
  382. package/static/dist/assets/{untitled-htelhc68.js → untitled-cikfjntn.js} +1 -1
  383. package/static/dist/assets/{useForm-h2t0u14e.js → useForm-kqoqg0k9.js} +1 -1
  384. package/static/dist/assets/useLocale-o59tbiea.js +1 -0
  385. package/static/dist/assets/{useParams-dqd64jv7.js → useParams-b4dq6n4q.js} +1 -1
  386. package/static/dist/assets/{useSearch-j30pvsy7.js → useSearch-ipfmv3he.js} +1 -1
  387. package/static/dist/assets/{vennDiagram-4TSXK5OY-fzchbzaj.js → vennDiagram-4TSXK5OY-h5hzbk0n.js} +1 -1
  388. package/static/dist/assets/{wardley-WFR3VGLG-m47mhfbh.js → wardley-WFR3VGLG-jftvpm3e.js} +1 -1
  389. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-odg6ct1z.js → wardleyDiagram-VM6X3IG4-hedh85ge.js} +1 -1
  390. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-mrhms5um.js → xychartDiagram-S5SC5T6Z-e49523ab.js} +1 -1
  391. package/static/dist/index.html +1 -1
  392. package/static/dist/sw.js +2 -2
  393. package/static/dist/assets/AppShell-ef3bhu32.js +0 -1
  394. package/static/dist/assets/Button-nx37f4u1.js +0 -1
  395. package/static/dist/assets/ChatPage-cdcpykcg.js +0 -25
  396. package/static/dist/assets/Dialog-mec42hvb.js +0 -1
  397. package/static/dist/assets/HubRoute-ecb65zpr.js +0 -2
  398. package/static/dist/assets/RightPanel-d1lgvk4s.js +0 -1
  399. package/static/dist/assets/Select-h4ivl9qf.js +0 -1
  400. package/static/dist/assets/SessionListPanel-ewib046b.js +0 -1
  401. package/static/dist/assets/SettingsLayout-lx654cup.js +0 -1
  402. package/static/dist/assets/States-ehsb6lzo.js +0 -1
  403. package/static/dist/assets/Toaster-mch1pa3t.js +0 -1
  404. package/static/dist/assets/_app-m8d961ef.js +0 -1
  405. package/static/dist/assets/_app.ext._extensionId-mp33hbzi.js +0 -1
  406. package/static/dist/assets/_app.index-dk6zp7e0.js +0 -1
  407. package/static/dist/assets/_app.insights-jt2fk4i1.js +0 -1
  408. package/static/dist/assets/_app.kanban-if4pubeu.js +0 -1
  409. package/static/dist/assets/_app.logs-mfaw9m6g.js +0 -1
  410. package/static/dist/assets/_app.memory-h3eaarb6.js +0 -1
  411. package/static/dist/assets/_app.profiles-ienktovs.js +0 -1
  412. package/static/dist/assets/_app.settings-dpoe0bzf.js +0 -1
  413. package/static/dist/assets/_app.settings._section-l468ynag.js +0 -3
  414. package/static/dist/assets/_app.skills-kcbncc8i.js +0 -1
  415. package/static/dist/assets/_app.tasks-occdrqhw.js +0 -2
  416. package/static/dist/assets/_app.todos-nnke2rcc.js +0 -1
  417. package/static/dist/assets/_app.workspaces-cdlatvwj.js +0 -1
  418. package/static/dist/assets/architecture-7GRP2DOG-hj11333z.js +0 -1
  419. package/static/dist/assets/channel-mbvmf2vv.js +0 -1
  420. package/static/dist/assets/classDiagram-ZZMXUADV-o8r05k0n.js +0 -1
  421. package/static/dist/assets/classDiagram-v2-VYDZK3BY-o8r05k0n.js +0 -1
  422. package/static/dist/assets/copy-e6977uwq.js +0 -1
  423. package/static/dist/assets/endpoints-k7yf25m0.js +0 -64
  424. package/static/dist/assets/eventmodeling-NTZA5JFV-kpbq09bx.js +0 -1
  425. package/static/dist/assets/flowDiagram-HODETNUW-lcz0kx14.js +0 -1
  426. package/static/dist/assets/index-h00usru2.js +0 -11
  427. package/static/dist/assets/index-n73ijkv2.css +0 -2
  428. package/static/dist/assets/lazyRouteComponent-m1rtyk4t.js +0 -1
  429. package/static/dist/assets/link-budeg5t9.js +0 -1
  430. package/static/dist/assets/login-kjhasvp8.js +0 -1
  431. package/static/dist/assets/matchContext-f8oy3lo7.js +0 -1
  432. package/static/dist/assets/mermaid-HWGCJPDP-jb5d211j.js +0 -1
  433. package/static/dist/assets/onboarding-cdfrk0pj.js +0 -1
  434. package/static/dist/assets/railroad-abnf-HS5TGJTU-kdvgl6x8.js +0 -1
  435. package/static/dist/assets/railroad-ebnf-LZEXJU2U-itiqslby.js +0 -1
  436. package/static/dist/assets/railroad-peg-WCYAUIDC-go7kov78.js +0 -1
  437. package/static/dist/assets/refresh-cw-kbcje1uy.js +0 -1
  438. package/static/dist/assets/search-jen74z4u.js +0 -1
  439. package/static/dist/assets/share._token-dan9hgvj.js +0 -2
  440. package/static/dist/assets/sliders-horizontal-f9cb3lh3.js +0 -1
  441. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-lcfraua2.js +0 -1
  442. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-ogmanni0.js +0 -8
  443. package/static/dist/assets/todoStore-eqtid4r4.js +0 -1
  444. package/static/dist/assets/useLocale-hcsb84pj.js +0 -1
  445. package/static/dist/assets/useNavigate-nwy70ztj.js +0 -1
@@ -12,29 +12,54 @@ import { isWithin, resolvePathLikePython } from '../workspace/paths.js';
12
12
  import { SidecarError } from '../sidecar/client.js';
13
13
  import { HttpFailure, markSessionTitleGenerated } from './service.js';
14
14
  import { StreamRegistry, SessionChannels } from './streams.js';
15
+ import { ChatUsageSchema } from '@maudecode/talaria-web-contracts';
15
16
  import { PendingPrompts, clarifyReply } from './pending.js';
16
17
  import { RunJournal } from './journal.js';
17
- import { CONTEXT_USAGE_FIELDS, Session, titleFrom } from './session.js';
18
- import { buildActiveTurnToken, completedToolIndex, publicToolFrame, redactSessionData, redactString, withToolId } from '../redact.js';
18
+ import { COMPRESSION_RECOVERY_ACTION_START_FOCUSED, COMPRESSION_RECOVERY_TERMINAL_STATE, CONTEXT_USAGE_FIELDS, Session, contextUsage, titleFrom } from './session.js';
19
+ import { anchorMessageKey, compressionReference, markerSummary, visibleMessagesForAnchor } from './compress.js';
20
+ import { buildActiveTurnToken, completedToolIndex, publicToolFrame, redactNestedMessageContainers, redactSessionData, redactString, withToolId } from '../redact.js';
19
21
  import { dict } from '../config/agent-config.js';
20
22
  import { ReasoningTitleTracker, reasoningEventPayload } from './reasoning-titles.js';
21
23
  import { messageWindowForDisplay, messagesForLimitedPayload, toolCallsForMessageWindow } from './window.js';
22
- import { attachTodoState } from './todo.js';
24
+ import { UNSETTLED_TODO_KEY, attachTodoState, parseTodoToolResult } from './todo.js';
23
25
  import { withSessionWireFlags } from './list.js';
24
26
  import { hydrateAnchorActivityScenes, turnTerminalState, withTurnIds } from './anchor.js';
25
27
  import { persistentStateChanges, persistentStateSnapshot } from './state-saved.js';
26
28
  import { maxIterationsFromConfig, maxTokensFromConfig, processWakeupMaxIterations, reasoningConfigFromConfig, webuiEphemeralSystemPrompt, workspaceSystemMessage } from './turn-context.js';
27
- import { agentSteerText, assistantReplyAddedAfterCurrentTurn, buildPartialMessage, escapeWorkspacePrefixPath, checkpointTurnStart, extractToolCallsFromMessages, injectMaxIterationSummaryFallback, isDict, mergeDisplayMessagesAfterAgentResult, messageIdentity, messageText, pendingUserRow, sanitizeMessagesForApi, sessionLacksFinalAssistantAnswer, splitDisplayText, joinReasoning, reasoningFieldsText, stoppedTurnContext, toolOutcome, withAttachmentObjects, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withToolCallOutcomes, workspaceContextPrefix } from './merge.js';
29
+ import { agentSteerText, assistantReplyAddedAfterCurrentTurn, attachedFilesPrompt, buildPartialMessage, dedupeContext, checkpointTurnStart, extractToolCallsFromMessages, hasNativeImages, injectMaxIterationSummaryFallback, isDict, mergeDisplayMessagesAfterAgentResult, messageIdentity, messageText, pendingUserRow, sanitizeMessagesForApi, sessionLacksFinalAssistantAnswer, splitDisplayText, stateDbSeenId, joinReasoning, reasoningFieldsText, toolOutcome, withAttachmentObjects, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withToolCallOutcomes, withoutMaxIterationSummaryRequest, withNativeImagesRestored, withoutNativeImages, withoutToolImages } from './merge.js';
28
30
  import { mayBecomeSilentReply, turnOrigin, withBackgroundUpdates } from './background-updates.js';
29
- import { fallbackTitleFromExchange, firstExchangeSnippets, isGenericFallbackTitle, latestExchangeSnippets, looksInvalidGeneratedTitle, looksLikeDefaultCliTitle, sanitizeGeneratedTitle, titleLanguageMismatch, titlePrompts } from './titles.js';
31
+ import { countExchanges, fallbackTitleFromExchange, firstExchangeSnippets, isGenericFallbackTitle, latestExchangeSnippets, looksInvalidGeneratedTitle, looksLikeDefaultCliTitle, sanitizeGeneratedTitle, titleLanguageMismatch, titlePrompts } from './titles.js';
30
32
  import { WORKSPACE_BUSY_MESSAGE } from '../workspace/git.js';
33
+ import { CHECKPOINT_STORE_BUSY_MESSAGE } from '../workspace/rollback.js';
31
34
  import { str } from '../util.js';
35
+ import { toolEditDiff } from './tool-display.js';
36
+ import { recordWakeupPause } from './wakeup-pause.js';
32
37
  export const CHAT_LOCK_WAIT_SECONDS = 2;
33
38
  const IMAGE_MODE_TIMEOUT_MS = 15_000;
34
39
  const TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT = 80;
40
+ /** TAL-397: streamed text reports the live meter at most this often (seconds); a counter change reports at once. */
41
+ const METERING_INTERVAL_S = 1;
35
42
  /** How long a user's message waits for a stopped background turn to unwind before its own turn is admitted. */
36
43
  const BACKGROUND_UNWIND_WAIT_MS = 30_000;
37
44
  const COMPRESSION_EXHAUSTED = { label: 'Context compression exhausted', type: 'compression_exhausted', hint: 'The conversation context is too large to compress safely. Start a new conversation or retry with a narrower task.' };
45
+ /** The Agent's session counters onto a session's: a zero or missing reading keeps the figure it already has. */
46
+ function applyAgentUsage(target, usage) {
47
+ if (usage.prompt_tokens > 0)
48
+ target.input_tokens = usage.prompt_tokens;
49
+ if (usage.completion_tokens > 0)
50
+ target.output_tokens = usage.completion_tokens;
51
+ if (usage.estimated_cost_usd !== null)
52
+ target.estimated_cost = usage.estimated_cost_usd;
53
+ if (usage.cache_read_tokens > 0)
54
+ target.cache_read_tokens = usage.cache_read_tokens;
55
+ if (usage.cache_write_tokens > 0)
56
+ target.cache_write_tokens = usage.cache_write_tokens;
57
+ }
58
+ /** TAL-397: the `metering` frame's counters, the same figures the session persists. */
59
+ const meteringUsage = (c) => ({
60
+ input_tokens: c.input_tokens || 0, output_tokens: c.output_tokens || 0, estimated_cost: typeof c.estimated_cost === 'number' ? c.estimated_cost : null,
61
+ cache_read_tokens: c.cache_read_tokens || 0, cache_write_tokens: c.cache_write_tokens || 0,
62
+ });
38
63
  /** `compressionExhausted`: the Agent flagged the turn itself, so its wording does not matter. */
39
64
  export function classifyProviderError(errStr, opts = {}) {
40
65
  const lower = errStr.toLowerCase();
@@ -48,7 +73,7 @@ export function classifyProviderError(errStr, opts = {}) {
48
73
  if (['interrupted by user', 'response interrupted', 'operation interrupted', 'operation was interrupted', 'operation aborted', 'request was aborted', 'aborterror'].some((k) => lower.includes(k))) {
49
74
  return { label: 'Response interrupted', type: 'interrupted', hint: 'The run stopped before a provider response completed. If you did not cancel it, try again.' };
50
75
  }
51
- const quota = ['insufficient_quota', 'insufficient credits', 'out of credits', 'credits exhausted', 'quota exceeded', 'usage limit', 'plan limit', 'billing'].some((k) => lower.includes(k));
76
+ const quota = ['insufficient_quota', 'insufficient credits', 'out of credits', 'credits exhausted', 'quota exceeded', 'usage limit', 'plan limit', 'billing', 'more credits', 'can only afford', 'fewer max_tokens', 'credit balance', 'exceeded your current quota', 'usage_limit_exceeded', 'reached the limit of messages', 'used up your usage'].some((k) => lower.includes(k));
52
77
  const poolEmpty = lower.includes('credential(s) exhausted') || lower.includes('credentials exhausted') || (lower.includes('credential') && lower.includes('exhausted'));
53
78
  const auth = !quota && !poolEmpty && (errStr.includes('401') || lower.includes('authentication') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('invalid_api_key') || lower.includes('no cookie auth credentials'));
54
79
  const notFound = errStr.includes('404') || lower.includes('not found') || lower.includes('does not exist') || lower.includes('model_not_found') || lower.includes('invalid model') || lower.includes('does not match any known model') || lower.includes('unknown model');
@@ -102,8 +127,6 @@ export function sniffImageMime(bytes, declared = '') {
102
127
  return 'image/bmp';
103
128
  return null;
104
129
  }
105
- const COMPRESSION_RECOVERY_TERMINAL_STATE = 'compression_exhausted';
106
- const COMPRESSION_RECOVERY_ACTION_START_FOCUSED = 'start_focused_continuation';
107
130
  /** Python `build_compression_recovery_payload` + `stamp_compression_exhausted_recovery`. */
108
131
  export function stampCompressionExhaustedRecovery(s, message, details) {
109
132
  const positive = (v) => { const n = Math.trunc(Number(v) || 0); return n > 0 ? n : 0; };
@@ -137,7 +160,27 @@ export function isGenericContinuationIntent(text) {
137
160
  const parts = normalized.split(' ');
138
161
  return parts.length > 0 && parts.length <= 2 && parts.every((p) => GENERIC_CONTINUATION_INTENTS.has(p));
139
162
  }
163
+ /**
164
+ * Python `_aux_title_generation_enabled`: `auxiliary.title_generation.enabled` with the Agent's
165
+ * `is_truthy_value(default=True)` semantics. Missing/null enables; a string enables only as 1/true/yes/on.
166
+ */
167
+ export function titleGenerationEnabled(cfg) {
168
+ const value = dict(dict(cfg.auxiliary).title_generation).enabled;
169
+ if (value === undefined || value === null)
170
+ return true;
171
+ if (typeof value === 'string')
172
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
173
+ // Python `bool()`: empty containers are false; any other number than zero (NaN included) is true.
174
+ if (Array.isArray(value))
175
+ return value.length > 0;
176
+ if (typeof value === 'object')
177
+ return Object.keys(value).length > 0;
178
+ if (typeof value === 'number')
179
+ return value !== 0;
180
+ return Boolean(value);
181
+ }
140
182
  /** Python `_explicit_text_signal`: `agent.image_input_mode: text` or a configured `auxiliary.vision` backend. */
183
+ const isImageAttachment = (att) => Boolean(str(att.path).trim()) && str(att.mime).trim().startsWith('image/');
141
184
  export function explicitTextSignal(cfg) {
142
185
  const agent = dict(cfg.agent);
143
186
  if (str(agent.image_input_mode ?? 'auto').trim().toLowerCase() === 'text')
@@ -162,8 +205,14 @@ export class TurnRunner {
162
205
  steerRewrites = new Map();
163
206
  deferredPendingSteer = new Map();
164
207
  lastCompletedTool = new Map();
208
+ /** TAL-397: each running turn's live counters, once the Agent reported any; an error or cancel persists them too. */
209
+ liveUsage = new Map();
210
+ /** TAL-397: each running turn's latest todo list, which an error or cancel keeps on the session. */
211
+ liveTodos = new Map();
165
212
  /** Streams whose run completed (`done` emitted) and only await title work; a late cancel is a no-op for these. */
166
213
  settledStreams = new Set();
214
+ /** The last adaptive title refresh per session; a successor turn's refresh runs after it (TAL-589). */
215
+ titleRefreshes = new Map();
167
216
  /**
168
217
  * TAL-364: what a Stop needs to write the turn's model context: its starting context, the prompt the Agent got, and
169
218
  * whether a settlement already used a canonical checkpoint (which marks the stop boundary; later work never replaces it).
@@ -187,6 +236,8 @@ export class TurnRunner {
187
236
  return { error: `Profile '${str(s.profile)}' is being deleted.`, _status: 409 };
188
237
  if (this.deps.workspaceBusy?.(opts.workspace))
189
238
  return { error: WORKSPACE_BUSY_MESSAGE, _status: 409 };
239
+ if (this.deps.checkpointStoreBusy?.(s.profile ?? null))
240
+ return { error: CHECKPOINT_STORE_BUSY_MESSAGE, _status: 409 };
190
241
  const locked = s.active_stream_id;
191
242
  if (locked) {
192
243
  if (this.registry.liveIds.has(locked) || this.registry.activeRuns.has(locked))
@@ -244,6 +295,10 @@ export class TurnRunner {
244
295
  response.effective_model_provider = opts.modelProvider;
245
296
  return response;
246
297
  }
298
+ /** Resolves once the stream's worker has ended (settled, failed, or torn down); it never rejects. */
299
+ finished(streamId) {
300
+ return this.workers.get(streamId) ?? Promise.resolve();
301
+ }
247
302
  /** Python `_checkpoint_user_message_for_eager_session_save`. */
248
303
  checkpointUserMessage(s, msg, attachments, startedAt, source, turnId) {
249
304
  const latest = s.messages[s.messages.length - 1];
@@ -262,7 +317,12 @@ export class TurnRunner {
262
317
  catch (error) {
263
318
  deps.log(`[webui] WARNING: run journal degraded for stream ${streamId}: ${error.message}`);
264
319
  }
320
+ const failure = { apperror: null, cancelled: false };
265
321
  const put = (event, data, meta = {}) => {
322
+ if (event === 'apperror')
323
+ failure.apperror = data;
324
+ else if (event === 'cancel')
325
+ failure.cancelled = true;
266
326
  for (const steerEvent of this.takeSteerEventsBefore(streamId, event))
267
327
  put(steerEvent[0], steerEvent[1]);
268
328
  if (this.registry.cancelled.has(streamId) && !['cancel', 'apperror', 'steer_consumed', 'steer_withdrawn'].includes(event))
@@ -309,13 +369,16 @@ export class TurnRunner {
309
369
  }
310
370
  const msgText = opts.msg;
311
371
  const previousMessages = structuredClone(s.messages);
312
- const previousContext = structuredClone(deps.service().modelContext(s));
313
- // Python `_sanitize_messages_for_api`: the model never sees display-only rows or a replayed cancelled prompt.
314
- const apiHistory = sanitizeMessagesForApi(previousContext);
315
- const workspaceCtx = workspaceContextPrefix(opts.workspace);
372
+ // TAL-493: one state.db read builds the history and marks where this turn's own rows begin.
373
+ const startRead = deps.service().stateDbRead(s);
374
+ // TAL-578: an eager save already put this turn's prompt in the transcript; `user_message` carries it, so the history
375
+ // leaves it out.
376
+ const previousContext = structuredClone(deps.service().modelContext(s, startRead.rows)).filter((m) => m._turn_id !== streamId);
377
+ // Python `_sanitize_messages_for_agent`: the model never sees display-only rows or a replayed cancelled prompt, and
378
+ // the Agent gets back the exact bytes it sent (TAL-541).
379
+ const apiHistory = sanitizeMessagesForApi(previousContext, { preserveApiContent: true });
316
380
  // Before the first await: a Stop can land at any point after admission.
317
- // An eager save already put this turn's prompt in the transcript; the Stop fallback appends it once itself.
318
- this.stopContexts.set(streamId, { previousContext: previousContext.filter((m) => m._turn_id !== streamId), historyLength: apiHistory.length, prompt: workspaceCtx + msgText, msgText, checkpointed: false });
381
+ this.stopContexts.set(streamId, { previousContext, historyLength: apiHistory.length, prompt: msgText, msgText, checkpointed: false, stateDbStartId: startRead.ok ? stateDbSeenId(startRead.rows) ?? 0 : null });
319
382
  const activeTurnToken = buildActiveTurnToken(streamId, s.pending_started_at);
320
383
  const sidecar = deps.sidecar();
321
384
  const partialText = this.registry.partialText.get(streamId) ?? [];
@@ -329,6 +392,25 @@ export class TurnRunner {
329
392
  const toolStartedAt = new WeakMap();
330
393
  let tokenSent = false;
331
394
  let firstTokenAt = null;
395
+ // TAL-397: the live meter. Counters start from the session's and follow the Agent's; the rate counts text deltas.
396
+ const liveUsage = { input_tokens: s.input_tokens, output_tokens: s.output_tokens, estimated_cost: s.estimated_cost, cache_read_tokens: s.cache_read_tokens, cache_write_tokens: s.cache_write_tokens };
397
+ const meter = { deltas: 0, first: 0, last: 0, sentAt: null };
398
+ const putMetering = (force) => {
399
+ const now = deps.now();
400
+ if (!force && meter.sentAt !== null && now - meter.sentAt < METERING_INTERVAL_S)
401
+ return;
402
+ meter.sentAt = now;
403
+ const tps = meter.deltas && meter.last > meter.first ? Math.round((meter.deltas / (meter.last - meter.first)) * 10) / 10 : null;
404
+ put('metering', { session_id: sessionId, usage: meteringUsage(liveUsage), tps, tps_available: tps !== null, estimated: false });
405
+ };
406
+ const meterDelta = () => {
407
+ const now = deps.now();
408
+ if (!meter.deltas)
409
+ meter.first = now;
410
+ meter.last = now;
411
+ meter.deltas += 1;
412
+ putMetering(false);
413
+ };
332
414
  // TAL-460: a background turn's text is held back while it could still be a silence marker, so one never flashes by.
333
415
  // ponytail: checks the turn's whole text, so a marker after earlier prose streams until the settled row hides it;
334
416
  // check the last text segment instead if that shows up.
@@ -338,7 +420,12 @@ export class TurnRunner {
338
420
  let capturedTerminalError = null;
339
421
  const controller = new AbortController();
340
422
  this.abortControllers.set(streamId, controller);
341
- const userMessage = await this.buildUserMessage(workspaceCtx, msgText, opts.attachments ?? [], opts.workspace, sessionId, s, opts, controller.signal);
423
+ const attachments = opts.attachments ?? [];
424
+ // TAL-545: one image mode decides both this turn's upload and the replayed history, which a text-mode turn sends
425
+ // without native images; only a turn with an image on either side asks for it.
426
+ const imageMode = attachments.some(isImageAttachment) || hasNativeImages(apiHistory) ? await this.imageInputMode(sessionId, s, opts, controller.signal) : null;
427
+ const userMessage = this.buildUserMessage(msgText, attachments, opts.workspace, sessionId, imageMode);
428
+ const conversationHistory = imageMode === 'text' ? withoutNativeImages(apiHistory) : apiHistory;
342
429
  // The prompt the Agent actually gets, native image parts included.
343
430
  const stop = this.stopContexts.get(streamId);
344
431
  if (stop)
@@ -346,14 +433,15 @@ export class TurnRunner {
346
433
  if (activeRun)
347
434
  activeRun.phase = 'running';
348
435
  const settledAt = { value: false };
349
- let failed = false;
436
+ let succeeded = false;
350
437
  // TAL-424: steers an errored turn ended without taking, sent as one follow-up turn once this one is torn down.
351
438
  let followUp = [];
352
439
  try {
353
440
  if (!sidecar)
354
441
  throw new SidecarError('The Agent sidecar is not running; chat is unavailable until it starts.', { condition: 'sidecar_unavailable' });
355
442
  // Python: budgets, reasoning config, personality and delivery context come from the profile's config.yaml; the
356
- // system message carries the frozen session workspace, the WebUI guidance rides as the ephemeral prompt.
443
+ // system message carries the frozen session workspace, the WebUI guidance rides as the ephemeral prompt with this
444
+ // turn's workspace (TAL-710: never a tag on the user's message, which the Agent can persist from memory).
357
445
  const cfg = (await deps.profileConfig?.(s.profile ?? null)) ?? {};
358
446
  // The sidecar's per-session YOLO state does not survive its restarts: push the local flag (either way) before
359
447
  // the turn so a stale sidecar-side enable cannot auto-approve a session the UI reports as guarded.
@@ -368,10 +456,9 @@ export class TurnRunner {
368
456
  put('cancel', this.cancelFrame(sessionId));
369
457
  return;
370
458
  }
371
- const frozenWorkspace = str(s.created_workspace) || str(s.workspace);
372
459
  const turnContext = {
373
- system_message: workspaceSystemMessage(frozenWorkspace),
374
- ephemeral_system_prompt: webuiEphemeralSystemPrompt({ config: cfg, personality: str(s.personality) || null, sessionId, profile: s.profile ?? null, workspace: frozenWorkspace, hermesHome: deps.profileHome(s.profile), homeDisplay: deps.homeDisplay?.() ?? '~/.hermes', background: turnOrigin(opts.source) === 'background' }),
460
+ system_message: workspaceSystemMessage(str(s.created_workspace) || str(s.workspace)),
461
+ ephemeral_system_prompt: webuiEphemeralSystemPrompt({ config: cfg, personality: str(s.personality) || null, sessionId, profile: s.profile ?? null, workspace: opts.workspace, hermesHome: deps.profileHome(s.profile), homeDisplay: deps.homeDisplay?.() ?? '~/.hermes', background: turnOrigin(opts.source) === 'background' }),
375
462
  max_iterations: processWakeupMaxIterations(maxIterationsFromConfig(cfg), opts.source ?? 'webui', deps.env ?? {}),
376
463
  max_tokens: maxTokensFromConfig(cfg),
377
464
  reasoning_config: reasoningConfigFromConfig(cfg, opts.modelProvider ?? s.model_provider ?? null),
@@ -382,7 +469,7 @@ export class TurnRunner {
382
469
  put('context_status', { session_id: sessionId, prefill: { status: 'not_configured', source: 'none', label: '', message_count: 0 } });
383
470
  const result = await sidecar.call('chat.start', {
384
471
  profile_home: deps.profileHome(s.profile), session_id: sessionId, stream_id: streamId, workspace: opts.workspace, model: opts.model ?? '', model_provider: opts.modelProvider,
385
- user_message: userMessage, ...turnContext, conversation_history: apiHistory, enabled_toolsets: deps.toolsetsFor(s),
472
+ user_message: userMessage, ...turnContext, conversation_history: conversationHistory, enabled_toolsets: deps.toolsetsFor(s), ...(msgText ? { persist_user_message: msgText } : {}), ...(opts.ephemeral ? { ephemeral: true } : {}),
386
473
  }, {
387
474
  signal: controller.signal,
388
475
  timeoutMs: 0,
@@ -399,10 +486,12 @@ export class TurnRunner {
399
486
  partialText.push(str(data.text));
400
487
  if (holdSilence && mayBecomeSilentReply(partialText.join(''))) {
401
488
  heldText += str(data.text);
489
+ meterDelta();
402
490
  return;
403
491
  }
404
492
  put('token', { text: heldText + str(data.text) });
405
493
  heldText = '';
494
+ meterDelta();
406
495
  return;
407
496
  }
408
497
  case 'reasoning': {
@@ -411,6 +500,16 @@ export class TurnRunner {
411
500
  reasoningText.push(str(data.text));
412
501
  // Python `reasoning_event_payload`: bold or first-line titles ride on the delta they complete.
413
502
  put('reasoning', titles.prepare(reasoningEventPayload(str(data.text), reasoningText.join(''))));
503
+ meterDelta();
504
+ return;
505
+ }
506
+ case 'usage': {
507
+ const usage = ChatUsageSchema.safeParse(data);
508
+ if (usage.success) {
509
+ applyAgentUsage(liveUsage, usage.data);
510
+ this.liveUsage.set(streamId, liveUsage);
511
+ }
512
+ putMetering(true);
414
513
  return;
415
514
  }
416
515
  case 'steer_pending':
@@ -433,23 +532,34 @@ export class TurnRunner {
433
532
  case 'tool_complete': {
434
533
  // TAL-313: the server decides failure from the sidecar's raw result, which never leaves the server; TAL-315: so
435
534
  // are its display sections, which keep the stderr and exit code the flat preview drops.
436
- const { raw_result: rawResult, ...complete } = data;
535
+ const { raw_result: rawResult, todo_result: todoResult, result_diff: resultDiff, ...complete } = data;
437
536
  const outcome = toolOutcome(rawResult);
438
537
  complete.is_error = outcome.is_error;
439
538
  if (rawResult !== undefined)
440
539
  complete.result_view = outcome.result_view;
540
+ // TAL-448: a file edit's change, counted over the whole diff the sidecar forwards; redacted with the frame.
541
+ const editDiff = toolEditDiff(data.name, { diff: resultDiff });
542
+ if (editDiff)
543
+ complete.edit_diff = editDiff;
441
544
  const tc = liveToolCalls[completedToolIndex(liveToolCalls, str(data.tid), data.name)];
442
545
  if (tc) {
443
546
  const startedAt = toolStartedAt.get(tc);
444
547
  if (startedAt !== undefined)
445
548
  complete.duration = Math.round(Math.max(0, deps.now() - startedAt) * 1000) / 1000;
446
549
  // TAL-315: the decided view too, so a failed or cancelled turn's snapshot keeps the sections shown live.
447
- Object.assign(tc, { done: true, snippet: data.preview, is_error: complete.is_error, duration: complete.duration ?? null, ...(complete.result_view ? { result_view: complete.result_view } : {}) });
550
+ Object.assign(tc, { done: true, snippet: data.preview, is_error: complete.is_error, duration: complete.duration ?? null, ...(complete.result_view ? { result_view: complete.result_view } : {}), ...(editDiff ? { edit_diff: editDiff } : {}) });
448
551
  }
449
552
  const id = (tc && toolIds.get(tc)) || str(data.tid) || mintToolId();
450
553
  this.lastCompletedTool.set(streamId, id);
451
554
  const redacted = deps.redactEnabled();
452
555
  put('tool_complete', publicToolFrame(withToolId(complete, id), redacted), { redacted });
556
+ // TAL-397: the todo tool's result is the session's new list; the settled session derives the same one.
557
+ const todos = data.name === 'todo' ? parseTodoToolResult(todoResult) : null;
558
+ if (todos) {
559
+ todos.ts = deps.now();
560
+ this.liveTodos.set(streamId, todos);
561
+ put('todo_state', redactNestedMessageContainers({ ...todos, session_id: sessionId, stream_id: streamId, source: 'live' }, redacted));
562
+ }
453
563
  return;
454
564
  }
455
565
  // Python: the live chat frame carries the queue head plus depth, not the entry that just arrived.
@@ -490,11 +600,18 @@ export class TurnRunner {
490
600
  },
491
601
  });
492
602
  settledAt.value = true;
603
+ // TAL-545: every transcript the Agent hands back settles with the history's images, not the request's projection.
604
+ const withImages = (rows) => withNativeImagesRestored(rows, conversationHistory, apiHistory);
605
+ result.messages = withImages(result.messages);
606
+ if (result.context_messages)
607
+ result.context_messages = withImages(result.context_messages);
493
608
  if (this.registry.cancelled.has(streamId) || result.status === 'cancelled') {
494
609
  // The Stop's pre-interrupt snapshot is the boundary, even when its reply lands after this result (the sidecar
495
610
  // answers each request on its own thread); without one, the Agent's interrupted result is its canonical transcript.
496
611
  const interrupted = await this.interrupts.get(streamId);
497
- this.finalizeCancelled(s, streamId, opts.ephemeral, interrupted?.checkpoint ?? result.messages);
612
+ applyAgentUsage(liveUsage, result.usage);
613
+ this.liveUsage.set(streamId, liveUsage);
614
+ this.finalizeCancelled(s, streamId, opts.ephemeral, interrupted?.checkpoint ? withImages(interrupted.checkpoint) : result.messages);
498
615
  put('cancel', this.cancelFrame(sessionId));
499
616
  return;
500
617
  }
@@ -513,7 +630,10 @@ export class TurnRunner {
513
630
  s = current;
514
631
  // Python `_maybe_inject_max_iteration_summary_fallback`: an exhausted tool budget leaves the closing
515
632
  // explanation in `final_response` only, so it becomes the turn's assistant answer before anything else reads it.
516
- const resultMessages = result.tool_limit_reached ? injectMaxIterationSummaryFallback(result.messages, result.final_response) : result.messages;
633
+ const withFallback = (rows) => (result.tool_limit_reached ? injectMaxIterationSummaryFallback(rows, result.final_response) : rows);
634
+ const withoutRequest = (rows) => (result.tool_limit_reached ? withoutMaxIterationSummaryRequest(rows, result.max_iterations_summary_request, msgText) : rows);
635
+ const agentRows = withFallback(result.messages);
636
+ const resultMessages = withoutRequest(agentRows);
517
637
  // Python `_assistant_reply_added_after_current_turn`: replayed history never counts as this turn's answer (the
518
638
  // sidecar reports `completed` whenever a failed run still carries messages).
519
639
  // Python's second chance: a turn that emitted no new row still counts when the merged transcript it produced
@@ -534,31 +654,31 @@ export class TurnRunner {
534
654
  await this.steerRewrites.get(streamId);
535
655
  const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, str(result.pending_steer), 'followup');
536
656
  followUp = leftovers;
537
- this.persistError(s, streamId, classification.label, payload, activeTurnToken);
657
+ this.persistError(s, streamId, classification.label, payload, activeTurnToken, resultMessages, result.provider);
538
658
  // TAL-512: a btw error carries no session: its journaled frame must not keep a copy of the parent conversation.
539
659
  if (!opts.ephemeral)
540
- payload.session = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
660
+ payload.session = this.publicTerminalSession(s);
541
661
  payload.session_id = s.session_id;
542
662
  payload.old_session_id = sessionId;
543
663
  for (const [event, data] of steerEvents)
544
664
  put(event, data);
545
665
  put('apperror', payload);
546
- failed = true;
547
666
  return;
548
667
  }
549
668
  // TAL-512: a btw turn passes the failure check above first, so its last assistant row is this turn's reply.
550
669
  if (opts.ephemeral) {
551
670
  let answer = '';
552
- for (let i = result.messages.length - 1; i >= 0; i -= 1) {
553
- const m = result.messages[i];
671
+ for (let i = resultMessages.length - 1; i >= 0; i -= 1) {
672
+ const m = resultMessages[i];
554
673
  if (m.role === 'assistant') {
555
674
  answer = str(m.content);
556
675
  break;
557
676
  }
558
677
  }
678
+ succeeded = true;
559
679
  opts.onDone?.(answer);
560
680
  // Python `_ephemeral_session_payload`: only role and content leave the server for a btw turn.
561
- put('done', { session: { session_id: sessionId, messages: (result.messages).map((m) => ({ role: m.role, content: m.content })) }, usage: { input_tokens: 0, output_tokens: 0 }, ephemeral: true, answer, terminal_state: answer.trim() ? 'completed' : 'no_response' });
681
+ put('done', { session: { session_id: sessionId, messages: resultMessages.map((m) => ({ role: m.role, content: m.content })) }, usage: { input_tokens: 0, output_tokens: 0 }, ephemeral: true, answer, terminal_state: answer.trim() ? 'completed' : 'no_response' });
562
682
  try {
563
683
  rmSync(deps.store.pathFor(sessionId), { force: true });
564
684
  }
@@ -567,14 +687,36 @@ export class TurnRunner {
567
687
  return;
568
688
  }
569
689
  // ── settle the transcript ──
690
+ // TAL-539: after a mid-turn compression the model context is the sidecar's pruned copy, settled like the transcript.
691
+ const pruned = result.compressed ? result.context_messages ?? null : null;
692
+ const context = withoutToolImages(pruned ? withoutRequest(withFallback(pruned)) : resultMessages);
693
+ let estimate = result.compressed ? result.post_compression_context_tokens_estimate ?? null : null;
694
+ // The sidecar estimated its own rows; a tool-limit settlement or stripped screenshots (TAL-544) that changed them move
695
+ // the estimate by the difference.
696
+ if (pruned && estimate !== null && context !== pruned) {
697
+ try {
698
+ const [before, after] = await Promise.all([pruned, context].map((messages) => sidecar.call('models.estimate_tokens', { messages })));
699
+ estimate = Math.max(1, estimate + after.tokens - before.tokens);
700
+ }
701
+ catch {
702
+ estimate = null;
703
+ }
704
+ }
570
705
  // The Agent's last pending-steer text settles the remaining steers before the turn is written back.
571
706
  await this.steerRewrites.get(streamId);
572
707
  const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, result.pending_steer, 'followup');
573
- s.messages = mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId, attachments: opts.attachments ?? [] });
574
- s.context_messages = dedupeContext(resultMessages);
708
+ s.messages = withoutToolImages(mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId, attachments: opts.attachments ?? [] }));
709
+ s.context_messages = dedupeContext(context);
575
710
  if (result.compressed) {
576
- s.compression_anchor_visible_idx = Math.max(0, previousMessages.length - 1);
577
- put('compressed', { session_id: sessionId, old_session_id: sessionId, new_session_id: sessionId, continuation_session_id: sessionId, message: 'Compression finished' });
711
+ s.post_compression_context_tokens_estimate = estimate;
712
+ // TAL-540: the anchor is the last visible row before this turn (never its eager checkpoint); the merged transcript
713
+ // holds no compaction marker, so the summary is the model context's newest one.
714
+ const before = visibleMessagesForAnchor(previousMessages.filter((m) => m._turn_id !== streamId));
715
+ const after = visibleMessagesForAnchor(s.messages);
716
+ const anchorIdx = before.length ? before.length - 1 : after.length ? 0 : null;
717
+ s.compression_anchor_visible_idx = anchorIdx;
718
+ s.compression_anchor_message_key = anchorMessageKey(anchorIdx !== null && anchorIdx < after.length ? after[anchorIdx] : after.at(-1));
719
+ s.compression_anchor_summary = markerSummary(s.context_messages);
578
720
  }
579
721
  const now = deps.now();
580
722
  for (const m of s.messages)
@@ -584,28 +726,29 @@ export class TurnRunner {
584
726
  const usage = result.usage;
585
727
  const prevInputTokens = s.input_tokens || 0;
586
728
  const prevCacheReadTokens = s.cache_read_tokens || 0;
587
- if (usage.prompt_tokens > 0)
588
- s.input_tokens = usage.prompt_tokens;
589
- if (usage.completion_tokens > 0)
590
- s.output_tokens = usage.completion_tokens;
591
- if (usage.estimated_cost_usd !== null)
592
- s.estimated_cost = usage.estimated_cost_usd;
593
- if (usage.cache_read_tokens > 0)
594
- s.cache_read_tokens = usage.cache_read_tokens;
595
- if (usage.cache_write_tokens > 0)
596
- s.cache_write_tokens = usage.cache_write_tokens;
729
+ applyAgentUsage(s, usage);
597
730
  if (typeof result.context.context_length === 'number')
598
731
  s.context_length = result.context.context_length;
599
732
  if (typeof result.context.threshold_tokens === 'number')
600
733
  s.threshold_tokens = result.context.threshold_tokens;
601
734
  if (typeof result.context.last_prompt_tokens === 'number')
602
735
  s.last_prompt_tokens = result.context.last_prompt_tokens;
736
+ if (result.compressed) {
737
+ // TAL-540, Python `_live_usage_snapshot`: the counters and the ring's post-compression figures, so clients update before `done`.
738
+ const usage = {
739
+ ...meteringUsage(s), context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
740
+ post_compression_context_tokens_estimate: s.post_compression_context_tokens_estimate, ...contextUsage(s, () => deps.service().deps.contextLengthFor(s.model, s.model_provider)),
741
+ };
742
+ put('compressed', { session_id: sessionId, old_session_id: sessionId, new_session_id: sessionId, continuation_session_id: sessionId, message: 'Compression finished', usage });
743
+ }
603
744
  s.tool_calls = extractToolCallsFromMessages(s.messages, liveToolCalls, s.tool_calls);
604
745
  s.active_stream_id = null;
605
746
  s.pending_user_message = null;
606
747
  s.pending_attachments = [];
607
748
  s.pending_started_at = null;
608
749
  s.pending_user_source = null;
750
+ // TAL-576: a turn that ran means the provider has usable credentials again.
751
+ s.process_wakeup_pause = null;
609
752
  const attachments = opts.attachments ?? [];
610
753
  if (attachments.length) {
611
754
  for (let i = s.messages.length - 1; i >= 0; i -= 1) {
@@ -656,6 +799,9 @@ export class TurnRunner {
656
799
  }
657
800
  }
658
801
  this.persistConsumedSteers(s, streamId, previousStartedAt(s, activeRun), now);
802
+ // TAL-493: the rows this turn's Agent wrote to state.db are now in the transcript, so the merge must not replay them
803
+ // (all of them, the dropped summary request too).
804
+ deps.service().settleStateDb(s, { turnId: streamId, previousContext: this.stopContexts.get(streamId)?.previousContext ?? previousContext, agentRows, startId: this.stopContexts.get(streamId)?.stateDbStartId ?? null });
659
805
  deps.store.save(s);
660
806
  deps.pending.clearApprovals(sessionId);
661
807
  deps.pending.clearClarifies(sessionId);
@@ -682,7 +828,7 @@ export class TurnRunner {
682
828
  cache_hit_percent: cacheHit(cacheReadTokens, inputTokens), turn_cache_hit_percent: cacheHit(turnCacheRead, turnInput), duration_seconds: Math.round(duration * 1000) / 1000,
683
829
  context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
684
830
  };
685
- const doneSession = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
831
+ const doneSession = this.publicTerminalSession(s);
686
832
  // TAL-299: the ring's figures match the terminal session's (and a detail reload's).
687
833
  for (const key of CONTEXT_USAGE_FIELDS)
688
834
  doneUsage[key] = doneSession[key];
@@ -700,6 +846,8 @@ export class TurnRunner {
700
846
  };
701
847
  if (result.tool_limit_reached)
702
848
  donePayload.terminal_reason = 'max_iterations';
849
+ // TAL-397: the live meter ends on the persisted counters and turn rate.
850
+ put('metering', { session_id: sessionId, usage: meteringUsage(s), tps: doneUsage.tps ?? null, tps_available: doneUsage.tps !== undefined, estimated: false });
703
851
  put('done', donePayload);
704
852
  for (const [event, payload] of steerEvents)
705
853
  put(event, payload);
@@ -709,20 +857,21 @@ export class TurnRunner {
709
857
  this.registry.activeRuns.delete(streamId);
710
858
  this.settledStreams.add(streamId);
711
859
  this.startSteerFollowUp(sessionId, leftovers);
860
+ // Before the title work, so a generated title's own state.db sync lands after this one's provisional title.
861
+ await deps.syncUsage?.(s, usedModel || null);
712
862
  // Title work and the goal judge run side by side; `stream_end` follows both, so their frames reach the stream.
713
863
  const work = await Promise.allSettled([this.backgroundTitle(s, put), this.continueGoal(s, streamId, put)]);
714
864
  put('stream_end', { session_id: sessionId });
715
865
  for (const r of work)
716
866
  if (r.status === 'rejected')
717
867
  throw r.reason;
718
- // Python: the last non-error assistant reply with content, else "(no answer produced)".
719
- opts.onDone?.(lastAnswer(s.messages) || '(no answer produced)');
868
+ succeeded = true;
869
+ opts.onDone?.(lastAnswer(s.messages));
720
870
  }
721
871
  catch (error) {
722
872
  if (settledAt.value && !(error instanceof SidecarError)) {
723
873
  deps.log(`[webui] ERROR settling turn ${streamId}\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
724
874
  }
725
- failed = true;
726
875
  if (this.registry.cancelled.has(streamId)) {
727
876
  this.finalizeCancelled(s, streamId, opts.ephemeral);
728
877
  put('cancel', this.cancelFrame(sessionId));
@@ -749,7 +898,7 @@ export class TurnRunner {
749
898
  put(event, data);
750
899
  this.persistError(current, streamId, classification.label, payload, activeTurnToken);
751
900
  if (!opts.ephemeral)
752
- payload.session = redactSessionData(this.terminalSessionPayload(current), deps.redactEnabled());
901
+ payload.session = this.publicTerminalSession(current);
753
902
  }
754
903
  }
755
904
  catch (persistError) {
@@ -763,51 +912,54 @@ export class TurnRunner {
763
912
  this.teardown(sessionId, streamId);
764
913
  // After persistence and teardown: a background route's failure cleanup deletes the hidden session, which must
765
914
  // not race the error writeback above (a re-saved session would resurface in the sidebar).
766
- if (failed)
767
- opts.onFailed?.();
915
+ if (!succeeded)
916
+ opts.onFailed?.(failure);
768
917
  this.startSteerFollowUp(sessionId, followUp);
769
918
  }
770
919
  }
771
920
  /**
772
- * Python `_build_user_message`: image attachments are embedded as native `image_url` parts only when the Agent's
773
- * resolved image mode for this model is `native` (text mode routes them through the Agent's vision tool path), and
774
- * only after the bytes are read through an anchored descriptor and sniffed as a real image format. Every attached
775
- * file is also named by path after the text (TAL-276), so an attachment-only turn still gives the model a request
776
- * and its transcript row a distinct identity.
921
+ * Python `_resolve_image_input_mode`: the Agent's image mode for this turn's model under its profile config, or null
922
+ * when it is unknown (no sidecar, a cancel, a failed lookup). A canonical "text" is honoured only with an explicit
923
+ * text signal or a model KNOWN to be text-only; an unknown/custom model forwards natively and lets the Agent's retry
924
+ * guard downgrade.
777
925
  */
778
- async buildUserMessage(workspaceCtx, msgText, attachments, workspace, sessionId, s, opts, signal) {
779
- const text = workspaceCtx + msgText;
780
- const named = attachments.map((att) => str(att.path).trim()).filter(Boolean).map(escapeWorkspacePrefixPath);
781
- const withFiles = () => (named.length ? `${text}\n\n[Attached files: ${named.join(', ')}]` : text);
782
- const candidates = attachments.filter((att) => str(att.path).trim() && str(att.mime).trim().startsWith('image/'));
783
- if (!candidates.length)
784
- return withFiles();
926
+ async imageInputMode(sessionId, s, opts, signal) {
785
927
  const sidecar = this.deps.sidecar();
786
- if (!sidecar)
787
- return withFiles();
788
928
  // A cancel that landed before this point is final: never start the lookup or wait on it.
789
- if (signal.aborted)
790
- return text;
929
+ if (!sidecar || signal.aborted)
930
+ return null;
931
+ // The session's provider before the Agent canonicalizes it, so a `custom:<name>` override entry still matches.
932
+ const provider = str(opts.modelProvider ?? s.model_provider);
791
933
  try {
792
934
  // A cancelled turn must not sit behind this lookup: the abort wins the race and `run()` then takes the cancelled path.
793
- const lookup = sidecar.call('text.image_mode', { profile_home: this.deps.profileHome(s.profile), provider: str(opts.modelProvider ?? s.model_provider), model: str(opts.model ?? s.model) }, { signal, timeoutMs: IMAGE_MODE_TIMEOUT_MS });
935
+ const lookup = sidecar.call('text.image_mode', { profile_home: this.deps.profileHome(s.profile), provider, model: str(opts.model ?? s.model), requested_provider: provider }, { signal, timeoutMs: IMAGE_MODE_TIMEOUT_MS });
794
936
  const mode = await Promise.race([lookup, new Promise((_, reject) => { if (signal.aborted) {
795
937
  reject(new Error('turn cancelled'));
796
938
  return;
797
939
  } signal.addEventListener('abort', () => { reject(new Error('turn cancelled')); }, { once: true }); })]);
798
- // Python `_resolve_image_input_mode`: a canonical "text" is honoured only with an explicit text signal or a model
799
- // KNOWN to be text-only; an unknown/custom model forwards natively and lets the Agent's retry guard downgrade.
800
- if (mode.mode !== 'native') {
801
- const cfg = (await this.deps.profileConfig?.(s.profile ?? null)) ?? {};
802
- if (explicitTextSignal(cfg) || mode.supports_vision === false)
803
- return withFiles();
804
- }
940
+ if (mode.mode === 'native')
941
+ return 'native';
942
+ const cfg = (await this.deps.profileConfig?.(s.profile ?? null)) ?? {};
943
+ return explicitTextSignal(cfg) || mode.supports_vision === false ? 'text' : 'native';
805
944
  }
806
945
  catch (error) {
807
946
  if (!signal.aborted)
808
947
  this.deps.log(`[webui] image mode lookup failed for ${sessionId}: ${error.message}`);
809
- return withFiles();
948
+ return null;
810
949
  }
950
+ }
951
+ /**
952
+ * Python `_build_user_message`: image attachments are embedded as native `image_url` parts only when the turn's
953
+ * image mode is `native` (text mode routes them through the Agent's vision tool path), and only after the bytes are
954
+ * read through an anchored descriptor and sniffed as a real image format. Every attached file is also named by path
955
+ * after the text (TAL-276), so an attachment-only turn still gives the model a request and its transcript row a
956
+ * distinct identity.
957
+ */
958
+ buildUserMessage(msgText, attachments, workspace, sessionId, imageMode) {
959
+ const withFiles = () => attachedFilesPrompt(msgText, attachments);
960
+ const candidates = attachments.filter(isImageAttachment);
961
+ if (!candidates.length || imageMode !== 'native')
962
+ return withFiles();
811
963
  const parts = [];
812
964
  let images = 0;
813
965
  const roots = [workspace, this.deps.attachmentDir(sessionId)].map((r) => resolvePathLikePython(r));
@@ -842,6 +994,10 @@ export class TurnRunner {
842
994
  }
843
995
  return images ? [{ type: 'text', text: withFiles() }, ...parts] : withFiles();
844
996
  }
997
+ /** The settled session as a terminal frame carries it: the display window, redacted. */
998
+ publicTerminalSession(s) {
999
+ return redactSessionData(this.terminalSessionPayload(s), this.deps.redactEnabled());
1000
+ }
845
1001
  /**
846
1002
  * Python `_session_payload_with_terminal_window`: the same visible-message window contract as `GET /api/session?
847
1003
  * msg_limit=` (renderable rows, limited payload shape, windowed tool calls, todo state) with the full count.
@@ -858,13 +1014,28 @@ export class TurnRunner {
858
1014
  payload.message_count = s.messages.length;
859
1015
  payload._messages_offset = offset;
860
1016
  payload._messages_truncated = offset > 0;
861
- attachTodoState(payload, s.messages);
1017
+ attachTodoState(payload, s.messages, s.extra[UNSETTLED_TODO_KEY]);
862
1018
  payload.tool_calls = toolCallsForMessageWindow(s.tool_calls, offset, limited.length);
1019
+ payload.compression_reference = compressionReference(s, scened);
863
1020
  return payload;
864
1021
  }
865
1022
  /** Python `_materialize_pending_user_turn_before_error` + error message append + save. */
866
- persistError(s, streamId, label, payload, activeTurnToken) {
1023
+ /** `runtimeProvider`: the provider the Agent resolved for the turn, when it reported one. */
1024
+ persistError(s, streamId, label, payload, activeTurnToken, agentRows = [], runtimeProvider = '') {
867
1025
  const startedAt = s.pending_started_at;
1026
+ // TAL-576: an automatic wakeup with no usable credentials pauses the next ones instead of failing each.
1027
+ if (s.pending_user_source === 'process_wakeup' && payload.type === 'credential_pool_empty') {
1028
+ // A profile whose home now escapes the profiles root (`profileHome` throws) records no pause; the error still persists.
1029
+ let home = null;
1030
+ try {
1031
+ home = this.deps.profileHome(s.profile);
1032
+ }
1033
+ catch {
1034
+ home = null;
1035
+ }
1036
+ if (home !== null)
1037
+ recordWakeupPause(s, home, this.deps.now(), runtimeProvider, this.deps.configPath?.(home));
1038
+ }
868
1039
  this.materializePendingUserTurn(s, activeTurnToken, streamId);
869
1040
  const duration = typeof startedAt === 'number' && startedAt > 0 ? Math.max(0, this.deps.now() - startedAt) : null;
870
1041
  s.active_stream_id = null;
@@ -893,17 +1064,40 @@ export class TurnRunner {
893
1064
  else if (payload.type === 'interrupted')
894
1065
  errorMessage.provider_details_label = 'Interruption details';
895
1066
  s.messages.push(errorMessage);
1067
+ this.keepLiveState(s, streamId);
896
1068
  this.persistConsumedSteers(s, streamId, startedAt, this.deps.now());
1069
+ // TAL-921: the next prompt remembers the turn's work; an exhausted compression's work would only overflow again. One
1070
+ // read builds the context and settles, so a row committed in between is never covered without reaching either.
1071
+ const read = this.deps.service().stateDbRead(s);
1072
+ const context = payload.type === 'compression_exhausted' ? null : this.unsettledContext(streamId, agentRows, read);
1073
+ if (context)
1074
+ s.context_messages = context;
1075
+ const stop = this.stopContexts.get(streamId);
1076
+ this.deps.service().settleStateDb(s, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows, startId: stop?.stateDbStartId ?? null, read });
897
1077
  try {
898
1078
  this.deps.store.save(s);
899
1079
  }
900
1080
  catch (error) {
901
1081
  this.deps.log(`[webui] WARNING: failed to save error turn for ${s.session_id}: ${error.message}`);
902
1082
  }
1083
+ // The kept live counters are the session's usage now, so the insights row follows them (the runtime logs failures).
1084
+ void this.deps.syncUsage?.(s, str(s.model) || null);
903
1085
  this.deps.pending.clearApprovals(s.session_id);
904
1086
  this.deps.pending.clearClarifies(s.session_id);
905
1087
  this.deps.events.publish('session_error', { profile: s.profile, sessionId: s.session_id });
906
1088
  }
1089
+ /**
1090
+ * TAL-921: the model context a turn that ends without its settled result leaves: the work its Agent reported, else the
1091
+ * rows it committed past its starting read in `read`, the read its settlement uses.
1092
+ */
1093
+ unsettledContext(streamId, agentRows, read) {
1094
+ const stop = this.stopContexts.get(streamId);
1095
+ if (!stop)
1096
+ return null;
1097
+ const after = stop.stateDbStartId;
1098
+ const stateRows = after === null ? [] : read.rows.filter((m) => typeof m._state_db_row_id === 'number' && m._state_db_row_id > after);
1099
+ return this.deps.service().unsettledTurnContext({ ...stop, streamedText: (this.registry.partialText.get(streamId) ?? []).join(''), agentRows, stateRows });
1100
+ }
907
1101
  materializePendingUserTurn(s, activeTurnToken, turnId) {
908
1102
  const pendingText = str(s.pending_user_message);
909
1103
  if (!s.hasPendingPrompt)
@@ -953,7 +1147,7 @@ export class TurnRunner {
953
1147
  let snapshot = null;
954
1148
  if (sessionId)
955
1149
  try {
956
- snapshot = redactSessionData(this.terminalSessionPayload(this.deps.store.get(sessionId)), this.deps.redactEnabled());
1150
+ snapshot = this.publicTerminalSession(this.deps.store.get(sessionId));
957
1151
  }
958
1152
  catch {
959
1153
  snapshot = null;
@@ -988,24 +1182,41 @@ export class TurnRunner {
988
1182
  // TAL-460: a background turn stops quietly: its prompt and model context are kept, but no partial and no Stop row,
989
1183
  // and its first settlement is final.
990
1184
  const quiet = this.registry.activeRuns.get(streamId)?.origin === 'background';
991
- if (quiet && current.active_stream_id === null)
1185
+ if (quiet && current.active_stream_id === null) {
1186
+ // TAL-493: rows its worker committed while unwinding are covered by what it reports; others are kept.
1187
+ if (checkpoint) {
1188
+ this.deps.service().settleStateDb(current, { turnId: streamId, previousContext: this.stopContexts.get(streamId)?.previousContext ?? [], agentRows: checkpoint, startId: this.stopContexts.get(streamId)?.stateDbStartId ?? null });
1189
+ try {
1190
+ this.deps.store.save(current);
1191
+ }
1192
+ catch {
1193
+ return false;
1194
+ }
1195
+ }
992
1196
  return true;
1197
+ }
993
1198
  const stop = this.stopContexts.get(streamId);
994
1199
  const canonical = stop !== undefined && checkpoint !== null && checkpointTurnStart(checkpoint, stop.msgText, stop.historyLength) !== null;
995
- const settle = () => {
1200
+ // TAL-921: one state.db read builds the context and settles the turn.
1201
+ const settle = (read) => {
996
1202
  if (!stop)
997
1203
  return null;
998
1204
  if (canonical)
999
1205
  stop.checkpointed = true;
1000
- return stoppedTurnContext(stop.previousContext, checkpoint, stop.prompt, stop.msgText, (this.registry.partialText.get(streamId) ?? []).join(''), stop.historyLength);
1206
+ return this.unsettledContext(streamId, checkpoint, read);
1001
1207
  };
1002
1208
  if (current.messages.some((m) => isCancelMarker(m)) && current.active_stream_id === null && !current.pending_user_message) {
1003
1209
  // The worker's canonical result can arrive after cancel() settled without a checkpoint (the interrupt reply failed
1004
1210
  // or timed out): it replaces this stream's context and never adds a second Stop row. A settlement that already had
1005
1211
  // one keeps it, so work the Agent finished after the Stop stays out.
1006
- const late = canonical && !stop.checkpointed && current.messages.some((m) => isCancelMarker(m) && m._turn_id === streamId) ? settle() : null;
1007
- if (late) {
1008
- current.context_messages = dedupeContext(late);
1212
+ const ownStop = current.messages.some((m) => isCancelMarker(m) && m._turn_id === streamId);
1213
+ // TAL-493: the worker's rows committed after the first settlement are covered by what it reports; others are kept.
1214
+ if (ownStop && checkpoint) {
1215
+ const read = this.deps.service().stateDbRead(current);
1216
+ const late = canonical && !stop.checkpointed ? settle(read) : null;
1217
+ if (late)
1218
+ current.context_messages = dedupeContext(late);
1219
+ this.deps.service().settleStateDb(current, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows: checkpoint, startId: stop?.stateDbStartId ?? null, read });
1009
1220
  try {
1010
1221
  this.deps.store.save(current);
1011
1222
  }
@@ -1017,7 +1228,8 @@ export class TurnRunner {
1017
1228
  }
1018
1229
  const startedAt = current.pending_started_at;
1019
1230
  this.materializePendingUserTurn(current, buildActiveTurnToken(streamId, current.pending_started_at), streamId);
1020
- const context = settle();
1231
+ const read = this.deps.service().stateDbRead(current);
1232
+ const context = settle(read);
1021
1233
  if (context)
1022
1234
  current.context_messages = dedupeContext(context);
1023
1235
  current.active_stream_id = null;
@@ -1029,13 +1241,16 @@ export class TurnRunner {
1029
1241
  this.appendPartialSnapshot(current, streamId);
1030
1242
  current.messages.push({ role: 'assistant', content: '', _error: true, _terminal_state: 'cancelled', timestamp: Math.trunc(this.deps.now()), _turn_id: streamId });
1031
1243
  }
1244
+ this.keepLiveState(current, streamId);
1032
1245
  this.persistConsumedSteers(current, streamId, startedAt, this.deps.now());
1246
+ this.deps.service().settleStateDb(current, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows: checkpoint, startId: stop?.stateDbStartId ?? null, read });
1033
1247
  try {
1034
1248
  this.deps.store.save(current);
1035
1249
  }
1036
1250
  catch {
1037
1251
  return false;
1038
1252
  }
1253
+ void this.deps.syncUsage?.(current, str(current.model) || null);
1039
1254
  this.deps.pending.clearApprovals(current.session_id);
1040
1255
  this.deps.pending.clearClarifies(current.session_id);
1041
1256
  this.deps.events.publish('session_cancel', { profile: current.profile, sessionId: current.session_id });
@@ -1045,7 +1260,18 @@ export class TurnRunner {
1045
1260
  emitToSession(sessionId, event, data) {
1046
1261
  this.sessionPuts.get(sessionId)?.(event, data);
1047
1262
  }
1263
+ /** TAL-397: a turn that ends without its result keeps what the live stream already showed: counters and todo list. */
1264
+ keepLiveState(s, streamId) {
1265
+ const usage = this.liveUsage.get(streamId);
1266
+ if (usage)
1267
+ Object.assign(s, usage);
1268
+ const todos = this.liveTodos.get(streamId);
1269
+ if (todos)
1270
+ s.extra[UNSETTLED_TODO_KEY] = todos;
1271
+ }
1048
1272
  teardown(sessionId, streamId) {
1273
+ this.liveUsage.delete(streamId);
1274
+ this.liveTodos.delete(streamId);
1049
1275
  const writer = this.writers.get(streamId);
1050
1276
  if (writer) {
1051
1277
  try {
@@ -1113,13 +1339,14 @@ export class TurnRunner {
1113
1339
  }
1114
1340
  return { title: '', status, rawPreview: '' };
1115
1341
  }
1116
- /** Python `generate_session_title_for_session`: on-demand title from the persisted transcript; never touches `llm_title_generated`. */
1342
+ /**
1343
+ * Python `generate_session_title_for_session`: on-demand title from the persisted transcript; never touches
1344
+ * `llm_title_generated`. An explicit regenerate runs even with `auxiliary.title_generation.enabled` off (TAL-531).
1345
+ */
1117
1346
  async generateTitle(s, opts = {}) {
1118
1347
  const [userText, assistantText] = opts.preferLatest ? latestExchangeSnippets(s.messages) : firstExchangeSnippets(s.messages, { scanPastConsecutiveUsers: true });
1119
1348
  if (!userText)
1120
1349
  return { title: null, status: 'empty_user_message', rawPreview: '' };
1121
- if (!this.deps.titleGenerationEnabled())
1122
- return { title: null, status: 'title_generation_disabled', rawPreview: '' };
1123
1350
  let next = '';
1124
1351
  let llmStatus = 'empty_assistant_message';
1125
1352
  let rawPreview = '';
@@ -1132,6 +1359,15 @@ export class TurnRunner {
1132
1359
  return { title: fallback, status: `local_summary:${llmStatus}`, rawPreview };
1133
1360
  return { title: null, status: llmStatus || 'empty_title', rawPreview };
1134
1361
  }
1362
+ /**
1363
+ * TAL-591 (Python `_queue_generated_title_for_imported_session`): a CLI session a mutation claims while it still
1364
+ * carries its default title gets the background title job at once instead of after its first WebUI turn.
1365
+ */
1366
+ async titleClaimed(s) {
1367
+ if (!looksLikeDefaultCliTitle({ title: s.title, source_tag: s.source_tag, raw_source: s.raw_source, session_source: s.session_source, source_label: s.source_label }))
1368
+ return;
1369
+ await this.backgroundTitle(s, () => undefined).catch((error) => { this.deps.log(`[webui] imported session title failed for ${s.session_id}: ${error.message}`); });
1370
+ }
1135
1371
  /** Python `_run_background_title_update` (spawned only when `_background_title_generation_inputs` is eligible). */
1136
1372
  async backgroundTitle(s, put) {
1137
1373
  const sessionId = s.session_id;
@@ -1145,13 +1381,22 @@ export class TurnRunner {
1145
1381
  const status = (status, reason = '', title = '', rawPreview = '') => {
1146
1382
  put('title_status', { session_id: sessionId, status, ...(reason ? { reason } : {}), ...(title ? { title } : {}), ...(rawPreview ? { raw_preview: rawPreview } : {}) });
1147
1383
  };
1148
- if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText)
1384
+ // Python ran the adaptive refresh only when the first-title job was not eligible.
1385
+ if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText) {
1386
+ await this.refreshTitle(s, put, status);
1149
1387
  return;
1388
+ }
1150
1389
  if (s.manual_title) {
1151
1390
  status('skipped', 'manual_title', placeholder);
1152
1391
  return;
1153
1392
  }
1154
- if (!this.deps.titleGenerationEnabled()) {
1393
+ // A missing config.yaml reads as `{}` (enabled); an unreadable one cannot confirm the user has not opted out.
1394
+ const cfg = await this.deps.profileConfig?.(s.profile ?? null);
1395
+ if (!cfg) {
1396
+ status('skipped', 'config_unavailable', placeholder);
1397
+ return;
1398
+ }
1399
+ if (!titleGenerationEnabled(cfg)) {
1155
1400
  status('skipped', 'title_generation_disabled', placeholder);
1156
1401
  return;
1157
1402
  }
@@ -1172,6 +1417,9 @@ export class TurnRunner {
1172
1417
  current = this.deps.store.get(sessionId);
1173
1418
  }
1174
1419
  catch {
1420
+ // A session deleted while the title prompt ran stays deleted.
1421
+ if (this.deps.store.wasDeleted(sessionId))
1422
+ return;
1175
1423
  current = s;
1176
1424
  }
1177
1425
  let effective = str(current.title).trim();
@@ -1200,6 +1448,71 @@ export class TurnRunner {
1200
1448
  else
1201
1449
  status('skipped', source || 'unchanged', effective, rawPreview);
1202
1450
  }
1451
+ /**
1452
+ * Python `_maybe_schedule_title_refresh` + `_run_background_title_refresh`: every N exchanges a generated title is
1453
+ * regenerated from the latest exchange; a manual title is never touched.
1454
+ */
1455
+ async refreshTitle(s, put, status) {
1456
+ const every = this.deps.titleRefreshEvery?.() ?? 0;
1457
+ const exchanges = countExchanges(s.messages);
1458
+ if (every <= 0 || exchanges <= 0 || exchanges % every !== 0)
1459
+ return;
1460
+ const [userText, assistantText] = latestExchangeSnippets(s.messages);
1461
+ if (!userText || !assistantText)
1462
+ return;
1463
+ // Admission is released before title work, so an earlier turn's refresh may still be running: refreshes apply in
1464
+ // turn order and each reads the title its predecessor wrote.
1465
+ const sessionId = s.session_id;
1466
+ const run = (this.titleRefreshes.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(() => this.applyTitleRefresh(sessionId, s, userText, assistantText, put, status));
1467
+ this.titleRefreshes.set(sessionId, run);
1468
+ try {
1469
+ await run;
1470
+ }
1471
+ finally {
1472
+ if (this.titleRefreshes.get(sessionId) === run)
1473
+ this.titleRefreshes.delete(sessionId);
1474
+ }
1475
+ }
1476
+ async applyTitleRefresh(sessionId, s, userText, assistantText, put, status) {
1477
+ const read = () => { try {
1478
+ return this.deps.store.get(sessionId);
1479
+ }
1480
+ catch {
1481
+ return s;
1482
+ } };
1483
+ const latest = read();
1484
+ const placeholder = str(latest.title).trim();
1485
+ if (['Untitled', 'New Chat', ''].includes(placeholder) || latest.manual_title || !latest.llm_title_generated)
1486
+ return;
1487
+ const cfg = await this.deps.profileConfig?.(latest.profile ?? null);
1488
+ if (!cfg || !titleGenerationEnabled(cfg)) {
1489
+ status('refresh_skipped', cfg ? 'title_generation_disabled' : 'config_unavailable', placeholder);
1490
+ return;
1491
+ }
1492
+ const { title: next, status: llmStatus, rawPreview } = await this.llmTitle(latest, userText, assistantText);
1493
+ if (!next) {
1494
+ status('refresh_skipped', llmStatus || 'empty', placeholder, rawPreview);
1495
+ return;
1496
+ }
1497
+ const normalize = (value) => value.replace(/\s+/g, ' ').trim().toLowerCase();
1498
+ if (normalize(next) === normalize(placeholder)) {
1499
+ status('refresh_skipped', 'same_title', placeholder, rawPreview);
1500
+ return;
1501
+ }
1502
+ const current = read();
1503
+ // The user may have renamed the session while the title prompt ran.
1504
+ if (current.manual_title || str(current.title).trim() !== placeholder) {
1505
+ status('skipped', 'manual_title', str(current.title).trim());
1506
+ return;
1507
+ }
1508
+ current.title = next;
1509
+ markSessionTitleGenerated(current);
1510
+ this.deps.store.save(current, { touchUpdatedAt: false });
1511
+ await this.deps.syncTitle?.(current);
1512
+ this.deps.events.publish('title', { profile: current.profile, sessionId });
1513
+ status('refreshed', llmStatus, next, rawPreview);
1514
+ put('title', { session_id: sessionId, title: next });
1515
+ }
1203
1516
  // ── cancel / steer ───────────────────────────────────────────────────────
1204
1517
  /**
1205
1518
  * Python `cancel_stream`: persist the partial, mark cancelled, interrupt the Agent, release admission. `withdrawn` is the
@@ -1477,12 +1790,6 @@ export class TurnRunner {
1477
1790
  return { events, leftovers: [...(unmatched ? [unmatched] : []), ...left.map((r) => r.text)] };
1478
1791
  if (unmatched)
1479
1792
  events.push(['steer_withdrawn', { steer_id: null, reason: 'stopped', text: unmatched }]);
1480
- // ponytail: legacy `pending_steer_leftover` keeps today's Web and App from losing a stopped steer (they requeue it);
1481
- // drop it once TAL-425 / TAL-426 put `steer_withdrawn` text back in the composer.
1482
- for (const r of left)
1483
- events.push(['pending_steer_leftover', { steer_id: r.steer_id, stream_id: streamId, text: r.text, display_text: r.display_text, leftover_at: this.deps.now() }]);
1484
- if (unmatched)
1485
- events.push(['pending_steer_leftover', { stream_id: streamId, text: unmatched }]);
1486
1793
  return { events, leftovers: [] };
1487
1794
  }
1488
1795
  /**
@@ -1709,7 +2016,9 @@ export class TurnRunner {
1709
2016
  const profileHome = this.deps.profileHome(s?.profile ?? null);
1710
2017
  let resolved = false;
1711
2018
  let withdrawn = false;
1712
- if (entry) {
2019
+ if (entry?._injected === true)
2020
+ resolved = Boolean(this.deps.pending.resolveApproval(sessionId, str(entry.approval_id)).entry);
2021
+ else if (entry) {
1713
2022
  if (!sidecar)
1714
2023
  return relayFailure('not running');
1715
2024
  try {
@@ -1811,6 +2120,11 @@ export class TurnRunner {
1811
2120
  const response = typeof reply === 'string' ? reply : clarifyReply(entry, reply);
1812
2121
  if (response === null)
1813
2122
  return { ok: false, invalid: true };
2123
+ // A test-hook prompt has no parked Agent waiter: it is answered here.
2124
+ if (entry._injected === true) {
2125
+ this.deps.pending.resolveClarify(sessionId, str(entry.clarify_id));
2126
+ return { ok: true, response };
2127
+ }
1814
2128
  const sidecar = this.deps.sidecar();
1815
2129
  if (!sidecar)
1816
2130
  return { ok: false, error: 'The Agent sidecar is not running; retry in a moment.' };
@@ -1848,18 +2162,6 @@ function previousStartedAt(s, run) {
1848
2162
  function isCancelMarker(m) {
1849
2163
  return m._error === true && (m._terminal_state === 'cancelled' || str(m.content).startsWith('**Task cancelled:**'));
1850
2164
  }
1851
- function dedupeContext(messages) {
1852
- const out = [];
1853
- let lastKey = null;
1854
- for (const m of messages) {
1855
- const key = messageIdentity(m);
1856
- if (key !== null && key === lastKey && m.role === 'assistant')
1857
- continue;
1858
- out.push(m);
1859
- lastKey = key;
1860
- }
1861
- return out;
1862
- }
1863
2165
  export { HttpFailure };
1864
2166
  /** Where a consumed steer goes: past the tool results of the call it followed, else past the turn's prompt. */
1865
2167
  function roundDuration(seconds) {