@maudecode/talaria-web 1.14.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/_release.json +4 -4
  2. package/dist/api/automation-router.d.ts +123 -7
  3. package/dist/api/automation-router.js +5 -0
  4. package/dist/api/automation-router.js.map +1 -1
  5. package/dist/api/chat-router.d.ts +728 -55
  6. package/dist/api/chat-router.js +122 -126
  7. package/dist/api/chat-router.js.map +1 -1
  8. package/dist/api/git-router.js +44 -32
  9. package/dist/api/git-router.js.map +1 -1
  10. package/dist/api/raw-routes.js +5 -58
  11. package/dist/api/raw-routes.js.map +1 -1
  12. package/dist/api/router.d.ts +6 -3
  13. package/dist/api/router.js +10 -8
  14. package/dist/api/router.js.map +1 -1
  15. package/dist/api/sessions-router.d.ts +2583 -297
  16. package/dist/api/sessions-router.js +92 -47
  17. package/dist/api/sessions-router.js.map +1 -1
  18. package/dist/api/settings-router.d.ts +188 -17
  19. package/dist/api/settings-router.js +75 -26
  20. package/dist/api/settings-router.js.map +1 -1
  21. package/dist/api/sse-routes.js +1 -1
  22. package/dist/api/sse-routes.js.map +1 -1
  23. package/dist/api/tools-router.d.ts +13 -1
  24. package/dist/api/tools-router.js +10 -8
  25. package/dist/api/tools-router.js.map +1 -1
  26. package/dist/app.d.ts +3620 -343
  27. package/dist/auth/gate.js +8 -4
  28. package/dist/auth/gate.js.map +1 -1
  29. package/dist/auth/oidc.d.ts +6 -2
  30. package/dist/auth/oidc.js +16 -11
  31. package/dist/auth/oidc.js.map +1 -1
  32. package/dist/auth/store.d.ts +14 -2
  33. package/dist/auth/store.js +26 -1
  34. package/dist/auth/store.js.map +1 -1
  35. package/dist/cli/dotenv.d.ts +1 -1
  36. package/dist/cli/dotenv.js +36 -3
  37. package/dist/cli/dotenv.js.map +1 -1
  38. package/dist/config/agent-config.js +20 -15
  39. package/dist/config/agent-config.js.map +1 -1
  40. package/dist/http/addresses.d.ts +6 -0
  41. package/dist/http/addresses.js +26 -0
  42. package/dist/http/addresses.js.map +1 -1
  43. package/dist/http/context.d.ts +7 -5
  44. package/dist/http/context.js.map +1 -1
  45. package/dist/links.d.ts +26 -0
  46. package/dist/links.js +60 -0
  47. package/dist/links.js.map +1 -0
  48. package/dist/onboarding.d.ts +1 -1
  49. package/dist/onboarding.js +28 -8
  50. package/dist/onboarding.js.map +1 -1
  51. package/dist/profiles/profiles.js +1 -1
  52. package/dist/profiles/profiles.js.map +1 -1
  53. package/dist/providers/catalog.d.ts +85 -1
  54. package/dist/providers/catalog.js +280 -43
  55. package/dist/providers/catalog.js.map +1 -1
  56. package/dist/providers/tables.d.ts +3 -1
  57. package/dist/providers/tables.js +9 -4
  58. package/dist/providers/tables.js.map +1 -1
  59. package/dist/redact.js +493 -13
  60. package/dist/redact.js.map +1 -1
  61. package/dist/runtime.js +104 -42
  62. package/dist/runtime.js.map +1 -1
  63. package/dist/sessions/anchor.d.ts +22 -3
  64. package/dist/sessions/anchor.js +87 -42
  65. package/dist/sessions/anchor.js.map +1 -1
  66. package/dist/sessions/background-tasks.d.ts +140 -0
  67. package/dist/sessions/background-tasks.js +352 -0
  68. package/dist/sessions/background-tasks.js.map +1 -0
  69. package/dist/sessions/background-updates.d.ts +56 -0
  70. package/dist/sessions/background-updates.js +152 -0
  71. package/dist/sessions/background-updates.js.map +1 -0
  72. package/dist/sessions/cli-sessions.d.ts +6 -0
  73. package/dist/sessions/cli-sessions.js +20 -7
  74. package/dist/sessions/cli-sessions.js.map +1 -1
  75. package/dist/sessions/completions.d.ts +17 -1
  76. package/dist/sessions/completions.js +84 -13
  77. package/dist/sessions/completions.js.map +1 -1
  78. package/dist/sessions/compress.d.ts +33 -0
  79. package/dist/sessions/compress.js +66 -0
  80. package/dist/sessions/compress.js.map +1 -0
  81. package/dist/sessions/list.d.ts +22 -4
  82. package/dist/sessions/list.js +113 -33
  83. package/dist/sessions/list.js.map +1 -1
  84. package/dist/sessions/merge.d.ts +81 -7
  85. package/dist/sessions/merge.js +391 -26
  86. package/dist/sessions/merge.js.map +1 -1
  87. package/dist/sessions/pending.d.ts +5 -0
  88. package/dist/sessions/pending.js +30 -10
  89. package/dist/sessions/pending.js.map +1 -1
  90. package/dist/sessions/relay.d.ts +7 -1
  91. package/dist/sessions/relay.js +17 -4
  92. package/dist/sessions/relay.js.map +1 -1
  93. package/dist/sessions/service.d.ts +118 -6
  94. package/dist/sessions/service.js +564 -65
  95. package/dist/sessions/service.js.map +1 -1
  96. package/dist/sessions/session.d.ts +22 -1
  97. package/dist/sessions/session.js +29 -2
  98. package/dist/sessions/session.js.map +1 -1
  99. package/dist/sessions/shares.js +11 -2
  100. package/dist/sessions/shares.js.map +1 -1
  101. package/dist/sessions/source-kind.d.ts +22 -0
  102. package/dist/sessions/source-kind.js +54 -0
  103. package/dist/sessions/source-kind.js.map +1 -0
  104. package/dist/sessions/state-db.d.ts +10 -1
  105. package/dist/sessions/state-db.js +48 -17
  106. package/dist/sessions/state-db.js.map +1 -1
  107. package/dist/sessions/store.d.ts +2 -0
  108. package/dist/sessions/store.js +24 -5
  109. package/dist/sessions/store.js.map +1 -1
  110. package/dist/sessions/streams.d.ts +3 -0
  111. package/dist/sessions/streams.js +0 -5
  112. package/dist/sessions/streams.js.map +1 -1
  113. package/dist/sessions/titles.d.ts +4 -0
  114. package/dist/sessions/titles.js +16 -0
  115. package/dist/sessions/titles.js.map +1 -1
  116. package/dist/sessions/turn-context.d.ts +1 -0
  117. package/dist/sessions/turn-context.js +3 -0
  118. package/dist/sessions/turn-context.js.map +1 -1
  119. package/dist/sessions/turn.d.ts +85 -7
  120. package/dist/sessions/turn.js +518 -170
  121. package/dist/sessions/turn.js.map +1 -1
  122. package/dist/settings.d.ts +2 -0
  123. package/dist/settings.js +13 -0
  124. package/dist/settings.js.map +1 -1
  125. package/dist/sidecar/fake.js +2 -0
  126. package/dist/sidecar/fake.js.map +1 -1
  127. package/dist/tools/commands.d.ts +15 -0
  128. package/dist/tools/commands.js +52 -0
  129. package/dist/tools/commands.js.map +1 -0
  130. package/dist/tools/crons.d.ts +38 -4
  131. package/dist/tools/crons.js +323 -36
  132. package/dist/tools/crons.js.map +1 -1
  133. package/dist/tools/mcp.js +5 -1
  134. package/dist/tools/mcp.js.map +1 -1
  135. package/dist/tools/update-notifications.d.ts +1 -1
  136. package/dist/tools/update-notifications.js +1 -1
  137. package/dist/tools/update-notifications.js.map +1 -1
  138. package/dist/tools/updates.d.ts +8 -3
  139. package/dist/tools/updates.js +133 -34
  140. package/dist/tools/updates.js.map +1 -1
  141. package/dist/workspace/git.d.ts +57 -10
  142. package/dist/workspace/git.js +302 -195
  143. package/dist/workspace/git.js.map +1 -1
  144. package/dist/workspace/media-refs.d.ts +13 -0
  145. package/dist/workspace/media-refs.js +165 -0
  146. package/dist/workspace/media-refs.js.map +1 -0
  147. package/dist/workspace/media.d.ts +19 -0
  148. package/dist/workspace/media.js +61 -2
  149. package/dist/workspace/media.js.map +1 -1
  150. package/dist/workspace/workspaces.d.ts +7 -1
  151. package/dist/workspace/workspaces.js +30 -21
  152. package/dist/workspace/workspaces.js.map +1 -1
  153. package/dist/workspace/worktrees.d.ts +2 -4
  154. package/dist/workspace/worktrees.js +28 -33
  155. package/dist/workspace/worktrees.js.map +1 -1
  156. package/package.json +4 -2
  157. package/sidecar/scripts/replay_sidecar.py +3 -1
  158. package/sidecar/talaria_sidecar/__init__.py +1 -1
  159. package/sidecar/talaria_sidecar/home.py +59 -2
  160. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  161. package/sidecar/talaria_sidecar/methods/aux.py +19 -3
  162. package/sidecar/talaria_sidecar/methods/chat.py +360 -27
  163. package/sidecar/talaria_sidecar/methods/commands.py +46 -31
  164. package/sidecar/talaria_sidecar/methods/config.py +14 -10
  165. package/sidecar/talaria_sidecar/methods/cron.py +22 -14
  166. package/sidecar/talaria_sidecar/methods/oauth.py +439 -0
  167. package/sidecar/talaria_sidecar/methods/process.py +318 -0
  168. package/sidecar/talaria_sidecar/methods/profiles.py +5 -4
  169. package/sidecar/talaria_sidecar/methods/providers.py +161 -1
  170. package/sidecar/talaria_sidecar/methods/runtime.py +2 -0
  171. package/sidecar/talaria_sidecar/methods/text.py +1 -20
  172. package/sidecar/talaria_sidecar/methods/usage.py +7 -1
  173. package/static/dist/FILES.txt +124 -119
  174. package/static/dist/assets/AppShell-ef3bhu32.js +1 -0
  175. package/static/dist/assets/Button-nx37f4u1.js +1 -0
  176. package/static/dist/assets/ChatPage-cdcpykcg.js +25 -0
  177. package/static/dist/assets/Dialog-mec42hvb.js +1 -0
  178. package/static/dist/assets/HubRoute-ecb65zpr.js +2 -0
  179. package/static/dist/assets/{Markdown-emii55o0.js → Markdown-jqrx5es0.js} +125 -125
  180. package/static/dist/assets/RightPanel-d1lgvk4s.js +1 -0
  181. package/static/dist/assets/Select-h4ivl9qf.js +1 -0
  182. package/static/dist/assets/SessionListPanel-ewib046b.js +1 -0
  183. package/static/dist/assets/SettingsLayout-lx654cup.js +1 -0
  184. package/static/dist/assets/States-ehsb6lzo.js +1 -0
  185. package/static/dist/assets/Toaster-mch1pa3t.js +1 -0
  186. package/static/dist/assets/_app-m8d961ef.js +1 -0
  187. package/static/dist/assets/_app.ext._extensionId-mp33hbzi.js +1 -0
  188. package/static/dist/assets/_app.index-dk6zp7e0.js +1 -0
  189. package/static/dist/assets/_app.insights-jt2fk4i1.js +1 -0
  190. package/static/dist/assets/_app.kanban-if4pubeu.js +1 -0
  191. package/static/dist/assets/_app.logs-mfaw9m6g.js +1 -0
  192. package/static/dist/assets/_app.memory-h3eaarb6.js +1 -0
  193. package/static/dist/assets/_app.profiles-ienktovs.js +1 -0
  194. package/static/dist/assets/{_app.session._sessionId-m6nx9g8t.js → _app.session._sessionId-hh27wwu2.js} +1 -1
  195. package/static/dist/assets/_app.settings-dpoe0bzf.js +1 -0
  196. package/static/dist/assets/_app.settings._section-l468ynag.js +3 -0
  197. package/static/dist/assets/_app.skills-kcbncc8i.js +1 -0
  198. package/static/dist/assets/_app.tasks-occdrqhw.js +2 -0
  199. package/static/dist/assets/_app.todos-nnke2rcc.js +1 -0
  200. package/static/dist/assets/_app.workspaces-cdlatvwj.js +1 -0
  201. package/static/dist/assets/{abnfDiagram-VCTEODGH-kejgxpil.js → abnfDiagram-VCTEODGH-oity9y4o.js} +1 -1
  202. package/static/dist/assets/architecture-7GRP2DOG-hj11333z.js +1 -0
  203. package/static/dist/assets/architectureDiagram-5GKGNRK7-ceaepnyr.js +36 -0
  204. package/static/dist/assets/back-eu64w89w.js +1 -0
  205. package/static/dist/assets/{blockDiagram-I7D4REHJ-l1466a7j.js → blockDiagram-I7D4REHJ-n79st4pj.js} +1 -1
  206. package/static/dist/assets/{c4Diagram-7LVT6UL2-gg3aq3sr.js → c4Diagram-7LVT6UL2-mi73akzk.js} +1 -1
  207. package/static/dist/assets/cancel-mzvkdjg8.js +1 -0
  208. package/static/dist/assets/channel-mbvmf2vv.js +1 -0
  209. package/static/dist/assets/{chunk-4HAMMTFA-oalgz5l8.js → chunk-4HAMMTFA-laat0g1q.js} +1 -1
  210. package/static/dist/assets/{chunk-75Z2AOVW-babmzhdg.js → chunk-75Z2AOVW-0psy1tr2.js} +1 -1
  211. package/static/dist/assets/{chunk-DU6HZSFF-lagu350c.js → chunk-DU6HZSFF-lw8uox8x.js} +3 -3
  212. package/static/dist/assets/{chunk-F27PBJKO-gjh2tqif.js → chunk-F27PBJKO-erassib3.js} +1 -1
  213. package/static/dist/assets/{chunk-GMAD6QVW-ipdgja5c.js → chunk-GMAD6QVW-fz7rx619.js} +1 -1
  214. package/static/dist/assets/{chunk-GVQU2GXP-j8wkn1at.js → chunk-GVQU2GXP-gdb0epb2.js} +1 -1
  215. package/static/dist/assets/{chunk-IMKFNOWR-k00laqyk.js → chunk-IMKFNOWR-ea8t26qw.js} +1 -1
  216. package/static/dist/assets/{chunk-L3NEJ4N5-fju6xqj4.js → chunk-L3NEJ4N5-mb1gu9kz.js} +1 -1
  217. package/static/dist/assets/{chunk-OSK3NFVY-bi6widqt.js → chunk-OSK3NFVY-eovmccvy.js} +1 -1
  218. package/static/dist/assets/{chunk-P2QGCYS3-czr4tebv.js → chunk-P2QGCYS3-bo55zjo5.js} +1 -1
  219. package/static/dist/assets/{chunk-POPQ4Y6H-ktdhn67w.js → chunk-POPQ4Y6H-ja7ohe4h.js} +1 -1
  220. package/static/dist/assets/{chunk-PWAF6VOD-ii4dsu0d.js → chunk-PWAF6VOD-e30g9nus.js} +1 -1
  221. package/static/dist/assets/{chunk-SHT3W25Y-fpwk3xxp.js → chunk-SHT3W25Y-gbb798f7.js} +3 -3
  222. package/static/dist/assets/{chunk-SVP7TREG-k0jrvhei.js → chunk-SVP7TREG-h2iw8urm.js} +1 -1
  223. package/static/dist/assets/{chunk-TICWLB2K-nkqv9ot0.js → chunk-TICWLB2K-fd2cottz.js} +1 -1
  224. package/static/dist/assets/classDiagram-ZZMXUADV-o8r05k0n.js +1 -0
  225. package/static/dist/assets/classDiagram-v2-VYDZK3BY-o8r05k0n.js +1 -0
  226. package/static/dist/assets/{copied-gpchexqu.js → copied-h65fc3cx.js} +1 -1
  227. package/static/dist/assets/copy-e6977uwq.js +1 -0
  228. package/static/dist/assets/{cynefin-OW5HDTMX-lqxt8rs3.js → cynefin-OW5HDTMX-lbltyl5j.js} +1 -1
  229. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-j9eqovf5.js → cynefinDiagram-5FMLGOSQ-lxhsqq4a.js} +1 -1
  230. package/static/dist/assets/{dagre-GXQ25YYZ-m9agobjm.js → dagre-GXQ25YYZ-gn4nnaln.js} +4 -4
  231. package/static/dist/assets/{diagram-S7CK7UJ4-itodc50c.js → diagram-S7CK7UJ4-pa3ij6q7.js} +3 -3
  232. package/static/dist/assets/{diagram-UQ7AKVKN-n1h2t4ah.js → diagram-UQ7AKVKN-zbipyz57.js} +1 -1
  233. package/static/dist/assets/{diagram-VSXAHHWV-lvtvm9bj.js → diagram-VSXAHHWV-d1kbpzgf.js} +1 -1
  234. package/static/dist/assets/{diagram-VX7I27RA-cgas58sy.js → diagram-VX7I27RA-i3ty14xl.js} +1 -1
  235. package/static/dist/assets/{diagram-Z3DM3KII-d0aeps1q.js → diagram-Z3DM3KII-m8xefqa7.js} +1 -1
  236. package/static/dist/assets/{ebnfDiagram-PWID7BFC-md345fyq.js → ebnfDiagram-PWID7BFC-nz7wj05l.js} +1 -1
  237. package/static/dist/assets/endpoints-k7yf25m0.js +64 -0
  238. package/static/dist/assets/{erDiagram-RLTQ6QDP-i4rua63b.js → erDiagram-RLTQ6QDP-fa41ib83.js} +1 -1
  239. package/static/dist/assets/eventmodeling-NTZA5JFV-kpbq09bx.js +1 -0
  240. package/static/dist/assets/flowDiagram-HODETNUW-lcz0kx14.js +1 -0
  241. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-geqx7zgl.js → ganttDiagram-EL5Y4UJY-n7zq01qu.js} +1 -1
  242. package/static/dist/assets/{gitGraph-4MIJSDKK-leoq0z8t.js → gitGraph-4MIJSDKK-c89emb07.js} +1 -1
  243. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-llrlxfha.js → gitGraphDiagram-WWUBYQGX-cz7cgr6w.js} +1 -1
  244. package/static/dist/assets/{highlighted-body-KPVGNVTW-kq2cnw6q.js → highlighted-body-KPVGNVTW-d9bapmni.js} +1 -1
  245. package/static/dist/assets/{index-f9bzglkn.js → index-h00usru2.js} +3 -3
  246. package/static/dist/assets/index-n73ijkv2.css +2 -0
  247. package/static/dist/assets/{info-A6RAGUB7-lkly4xj6.js → info-A6RAGUB7-ndddhhhh.js} +1 -1
  248. package/static/dist/assets/infoDiagram-27XIBGKW-ccxigcpf.js +2 -0
  249. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-hq6jf374.js → ishikawaDiagram-5VMMS53U-le0z9vtc.js} +1 -1
  250. package/static/dist/assets/{journeyDiagram-3NMN7TZE-is03ntb0.js → journeyDiagram-3NMN7TZE-ivsmb26o.js} +1 -1
  251. package/static/dist/assets/{kanban-definition-UXKFOSKX-jmerizqb.js → kanban-definition-UXKFOSKX-h6n2awt0.js} +1 -1
  252. package/static/dist/assets/lazyRouteComponent-m1rtyk4t.js +1 -0
  253. package/static/dist/assets/{line-laaa8h99.js → line-dmzlfe0u.js} +1 -1
  254. package/static/dist/assets/link-budeg5t9.js +1 -0
  255. package/static/dist/assets/login-kjhasvp8.js +1 -0
  256. package/static/dist/assets/mermaid-HWGCJPDP-jb5d211j.js +1 -0
  257. package/static/dist/assets/{mermaid-parser.core-ix4junux.js → mermaid-parser.core-gwyd5bnq.js} +3 -3
  258. package/static/dist/assets/{mindmap-definition-YA3MSWOX-o0e0adl1.js → mindmap-definition-YA3MSWOX-l68gg5h7.js} +1 -1
  259. package/static/dist/assets/onboarding-cdfrk0pj.js +1 -0
  260. package/static/dist/assets/{packet-AYTQ26CC-kl8pwsyg.js → packet-AYTQ26CC-gpv06065.js} +1 -1
  261. package/static/dist/assets/{pegDiagram-XKGWAZYB-bbqn9jo4.js → pegDiagram-XKGWAZYB-e52vs23u.js} +1 -1
  262. package/static/dist/assets/{pie-WAS4IAKB-cpyo8bkr.js → pie-WAS4IAKB-l6m7nz5s.js} +1 -1
  263. package/static/dist/assets/{pieDiagram-E7YTZNPT-pelug34f.js → pieDiagram-E7YTZNPT-b4is36bq.js} +3 -3
  264. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-jds4jmr5.js → quadrantDiagram-AXDQQJYC-m8vt2788.js} +1 -1
  265. package/static/dist/assets/{radar-RG4KPBEZ-e02oxnvd.js → radar-RG4KPBEZ-nxbrhiov.js} +1 -1
  266. package/static/dist/assets/{railroad-74A4TZTK-kz3delwo.js → railroad-74A4TZTK-222mu9hi.js} +1 -1
  267. package/static/dist/assets/railroad-abnf-HS5TGJTU-kdvgl6x8.js +1 -0
  268. package/static/dist/assets/railroad-ebnf-LZEXJU2U-itiqslby.js +1 -0
  269. package/static/dist/assets/railroad-peg-WCYAUIDC-go7kov78.js +1 -0
  270. package/static/dist/assets/{railroadDiagram-O6MQD6OU-jgroj2ew.js → railroadDiagram-O6MQD6OU-o8lldfzv.js} +1 -1
  271. package/static/dist/assets/{refresh-cw-k7yxjdn3.js → refresh-cw-kbcje1uy.js} +1 -1
  272. package/static/dist/assets/{requirementDiagram-BXWQKSXE-m4az5e0j.js → requirementDiagram-BXWQKSXE-ovyq38kv.js} +1 -1
  273. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-vlryqiv3.js → sankeyDiagram-P5KCCOFB-ou7lznor.js} +1 -1
  274. package/static/dist/assets/{saved-brdfjjnw.js → saved-bazmcup9.js} +1 -1
  275. package/static/dist/assets/search-jen74z4u.js +1 -0
  276. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-fr8hrpql.js → sequenceDiagram-WJ2MYXX4-ovckdzf2.js} +1 -1
  277. package/static/dist/assets/{settings_section_extensions_title-l0zodj0b.js → settings_section_extensions_title-ertwwhlx.js} +1 -1
  278. package/static/dist/assets/share._token-dan9hgvj.js +2 -0
  279. package/static/dist/assets/{sliders-horizontal-gcgscv8r.js → sliders-horizontal-f9cb3lh3.js} +1 -1
  280. package/static/dist/assets/{stateDiagram-D77RDMKH-bg548iyr.js → stateDiagram-D77RDMKH-fcrexkkv.js} +1 -1
  281. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-lcfraua2.js +1 -0
  282. package/static/dist/assets/{swimlanes-42K2YHIH-hk5z3efh.js → swimlanes-42K2YHIH-f7usu8fw.js} +1 -1
  283. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-ogmanni0.js +8 -0
  284. package/static/dist/assets/{timeline-definition-24CTP7MA-olvknr62.js → timeline-definition-24CTP7MA-gp1hvz9f.js} +1 -1
  285. package/static/dist/assets/todoStore-eqtid4r4.js +1 -0
  286. package/static/dist/assets/{treeView-Q6P3EWNA-ovvjsxtf.js → treeView-Q6P3EWNA-gb9gf11u.js} +1 -1
  287. package/static/dist/assets/{treemap-WGGIJYW6-ggkipj59.js → treemap-WGGIJYW6-nuvfnvvi.js} +1 -1
  288. package/static/dist/assets/{untitled-c8ydw0bu.js → untitled-htelhc68.js} +1 -1
  289. package/static/dist/assets/useForm-h2t0u14e.js +1 -0
  290. package/static/dist/assets/useLocale-hcsb84pj.js +1 -0
  291. package/static/dist/assets/useNavigate-nwy70ztj.js +1 -0
  292. package/static/dist/assets/{useParams-katwvzil.js → useParams-dqd64jv7.js} +1 -1
  293. package/static/dist/assets/{useSearch-c70sgau1.js → useSearch-j30pvsy7.js} +1 -1
  294. package/static/dist/assets/{vennDiagram-4TSXK5OY-hzld7zt7.js → vennDiagram-4TSXK5OY-fzchbzaj.js} +1 -1
  295. package/static/dist/assets/{wardley-WFR3VGLG-hxkeoms8.js → wardley-WFR3VGLG-m47mhfbh.js} +1 -1
  296. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-ljh90mb1.js → wardleyDiagram-VM6X3IG4-odg6ct1z.js} +2 -2
  297. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-mnuaes03.js → xychartDiagram-S5SC5T6Z-mrhms5um.js} +1 -1
  298. package/static/dist/index.html +1 -1
  299. package/static/dist/sw.js +1 -1
  300. package/static/dist/assets/AppShell-haqi9qzu.js +0 -1
  301. package/static/dist/assets/ChatPage-hjyu2g64.js +0 -22
  302. package/static/dist/assets/HubRoute-gkd8o1vg.js +0 -2
  303. package/static/dist/assets/RightPanel-o73y98dn.js +0 -1
  304. package/static/dist/assets/Select-jld3yeni.js +0 -1
  305. package/static/dist/assets/SessionListPanel-d3qx4ric.js +0 -1
  306. package/static/dist/assets/SettingsLayout-lnn2wo2s.js +0 -1
  307. package/static/dist/assets/States-e5hv6gdh.js +0 -1
  308. package/static/dist/assets/Toaster-c4jq1i26.js +0 -1
  309. package/static/dist/assets/_app-bnip2pjg.js +0 -1
  310. package/static/dist/assets/_app.ext._extensionId-egptzrfy.js +0 -1
  311. package/static/dist/assets/_app.index-vdz13ul5.js +0 -1
  312. package/static/dist/assets/_app.insights-lgjshsba.js +0 -1
  313. package/static/dist/assets/_app.kanban-c18cjrpz.js +0 -1
  314. package/static/dist/assets/_app.logs-ezhojgeg.js +0 -1
  315. package/static/dist/assets/_app.memory-i00paisv.js +0 -1
  316. package/static/dist/assets/_app.profiles-kd02bwjx.js +0 -1
  317. package/static/dist/assets/_app.settings-giaudkuk.js +0 -1
  318. package/static/dist/assets/_app.settings._section-k4qtlexk.js +0 -1
  319. package/static/dist/assets/_app.skills-bby2uvl8.js +0 -1
  320. package/static/dist/assets/_app.tasks-1kz9bosm.js +0 -2
  321. package/static/dist/assets/_app.todos-izjdbkm2.js +0 -1
  322. package/static/dist/assets/_app.workspaces-lfh2kw7s.js +0 -1
  323. package/static/dist/assets/architecture-7GRP2DOG-b4u9z1w3.js +0 -1
  324. package/static/dist/assets/architectureDiagram-5GKGNRK7-hqnf84fc.js +0 -36
  325. package/static/dist/assets/channel-o77ioktg.js +0 -1
  326. package/static/dist/assets/classDiagram-ZZMXUADV-f5rvod26.js +0 -1
  327. package/static/dist/assets/classDiagram-v2-VYDZK3BY-f5rvod26.js +0 -1
  328. package/static/dist/assets/endpoints-e58saih3.js +0 -9
  329. package/static/dist/assets/eventmodeling-NTZA5JFV-bn3fjsaq.js +0 -1
  330. package/static/dist/assets/flowDiagram-HODETNUW-li70d120.js +0 -1
  331. package/static/dist/assets/index-gkh7kum4.css +0 -2
  332. package/static/dist/assets/infoDiagram-27XIBGKW-oo7kdyte.js +0 -2
  333. package/static/dist/assets/lazyRouteComponent-b1h2ykmb.js +0 -1
  334. package/static/dist/assets/link-d8f0788e.js +0 -1
  335. package/static/dist/assets/login-llkohrcg.js +0 -1
  336. package/static/dist/assets/mermaid-HWGCJPDP-id0ffk2r.js +0 -1
  337. package/static/dist/assets/onboarding-lx0d6b38.js +0 -1
  338. package/static/dist/assets/railroad-abnf-HS5TGJTU-l13ped2z.js +0 -1
  339. package/static/dist/assets/railroad-ebnf-LZEXJU2U-c7rg3g4g.js +0 -1
  340. package/static/dist/assets/railroad-peg-WCYAUIDC-od0b8niq.js +0 -1
  341. package/static/dist/assets/search-gt2ueyvg.js +0 -1
  342. package/static/dist/assets/share._token-oyjv046r.js +0 -2
  343. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-j5v40qih.js +0 -1
  344. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-grzefo9c.js +0 -8
  345. package/static/dist/assets/tabBuild-idliyw9f.js +0 -56
  346. package/static/dist/assets/todoStore-j4c2xq0b.js +0 -1
  347. package/static/dist/assets/useForm-kd759sjk.js +0 -1
  348. package/static/dist/assets/useLocale-hds4xmja.js +0 -1
@@ -14,7 +14,7 @@ import { HttpFailure, markSessionTitleGenerated } from './service.js';
14
14
  import { StreamRegistry, SessionChannels } from './streams.js';
15
15
  import { PendingPrompts, clarifyReply } from './pending.js';
16
16
  import { RunJournal } from './journal.js';
17
- import { Session, titleFrom } from './session.js';
17
+ import { CONTEXT_USAGE_FIELDS, Session, titleFrom } from './session.js';
18
18
  import { buildActiveTurnToken, completedToolIndex, publicToolFrame, redactSessionData, redactString, withToolId } from '../redact.js';
19
19
  import { dict } from '../config/agent-config.js';
20
20
  import { ReasoningTitleTracker, reasoningEventPayload } from './reasoning-titles.js';
@@ -24,14 +24,22 @@ import { withSessionWireFlags } from './list.js';
24
24
  import { hydrateAnchorActivityScenes, turnTerminalState, withTurnIds } from './anchor.js';
25
25
  import { persistentStateChanges, persistentStateSnapshot } from './state-saved.js';
26
26
  import { maxIterationsFromConfig, maxTokensFromConfig, processWakeupMaxIterations, reasoningConfigFromConfig, webuiEphemeralSystemPrompt, workspaceSystemMessage } from './turn-context.js';
27
- import { agentSteerText, assistantReplyAddedAfterCurrentTurn, buildPartialMessage, checkpointTurnStart, extractToolCallsFromMessages, injectMaxIterationSummaryFallback, isContextCompressionMarker, isDict, mergeDisplayMessagesAfterAgentResult, messageIdentity, messageText, pendingUserRow, sanitizeMessagesForApi, sessionLacksFinalAssistantAnswer, splitThinkingFromContent, stoppedTurnContext, stripXmlToolCalls, toolOutcome, withAttachmentObjects, withToolCallOutcomes, workspaceContextPrefix } from './merge.js';
28
- import { fallbackTitleFromExchange, firstExchangeSnippets, isGenericFallbackTitle, latestExchangeSnippets, looksInvalidGeneratedTitle, sanitizeGeneratedTitle, titleLanguageMismatch, titlePrompts } from './titles.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';
28
+ import { mayBecomeSilentReply, turnOrigin, withBackgroundUpdates } from './background-updates.js';
29
+ import { fallbackTitleFromExchange, firstExchangeSnippets, isGenericFallbackTitle, latestExchangeSnippets, looksInvalidGeneratedTitle, looksLikeDefaultCliTitle, sanitizeGeneratedTitle, titleLanguageMismatch, titlePrompts } from './titles.js';
30
+ import { WORKSPACE_BUSY_MESSAGE } from '../workspace/git.js';
29
31
  import { str } from '../util.js';
30
32
  export const CHAT_LOCK_WAIT_SECONDS = 2;
31
33
  const IMAGE_MODE_TIMEOUT_MS = 15_000;
32
34
  const TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT = 80;
35
+ /** How long a user's message waits for a stopped background turn to unwind before its own turn is admitted. */
36
+ const BACKGROUND_UNWIND_WAIT_MS = 30_000;
37
+ 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.' };
38
+ /** `compressionExhausted`: the Agent flagged the turn itself, so its wording does not matter. */
33
39
  export function classifyProviderError(errStr, opts = {}) {
34
40
  const lower = errStr.toLowerCase();
41
+ if (opts.compressionExhausted)
42
+ return COMPRESSION_EXHAUSTED;
35
43
  if (opts.condition === 'credential_missing')
36
44
  return { label: 'Authentication failed', type: 'auth_mismatch', hint: 'The selected model may not be supported by your configured provider or your API key is invalid. Run `hermes model` in your terminal to update credentials, then restart the WebUI.' };
37
45
  const cancelled = ['cancelled by user', 'canceled by user', 'user cancelled', 'user canceled', 'task cancelled', 'task canceled', 'cancellederror'].some((k) => lower.includes(k));
@@ -57,7 +65,7 @@ export function classifyProviderError(errStr, opts = {}) {
57
65
  if (notFound)
58
66
  return { label: 'Model not found', type: 'model_not_found', hint: 'The selected model was not found by the provider. Check the model ID in Settings or run `hermes model` to verify it exists for your provider.' };
59
67
  if (compressionExhausted)
60
- return { 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.' };
68
+ return COMPRESSION_EXHAUSTED;
61
69
  if (opts.silentFailure)
62
70
  return { label: 'No response from provider', type: 'no_response', hint: 'The provider returned no content and no error. This often means a usage/rate limit was hit silently. Check provider status, switch providers via `hermes model`, or try again in a moment.' };
63
71
  return { label: 'Error', type: 'error', hint: '' };
@@ -148,6 +156,11 @@ export class TurnRunner {
148
156
  abortControllers = new Map();
149
157
  steers = new Map();
150
158
  consumedSteers = new Map();
159
+ /** Per stream: steer, withdraw and Send now run one at a time, so each sees the queue the Agent holds. */
160
+ steerChains = new Map();
161
+ /** Streams whose Agent steer queue a withdraw or Send now is rewriting right now, and the pending text seen meanwhile. */
162
+ steerRewrites = new Map();
163
+ deferredPendingSteer = new Map();
151
164
  lastCompletedTool = new Map();
152
165
  /** Streams whose run completed (`done` emitted) and only await title work; a late cancel is a no-op for these. */
153
166
  settledStreams = new Set();
@@ -158,6 +171,8 @@ export class TurnRunner {
158
171
  /** TAL-364: a Stop's in-flight `chat.interrupt` reply (null when it failed), so a worker that settles first can use its checkpoint. */
159
172
  interrupts = new Map();
160
173
  stopContexts = new Map();
174
+ /** Each turn's worker, so a stopped background turn can be awaited before the user's turn takes the session. */
175
+ workers = new Map();
161
176
  constructor(deps) {
162
177
  this.deps = deps;
163
178
  }
@@ -170,6 +185,8 @@ export class TurnRunner {
170
185
  return { error: 'Web is updating. Retry after it restarts.', _status: 503 };
171
186
  if (this.deps.profileDeleting?.(s.profile ?? null))
172
187
  return { error: `Profile '${str(s.profile)}' is being deleted.`, _status: 409 };
188
+ if (this.deps.workspaceBusy?.(opts.workspace))
189
+ return { error: WORKSPACE_BUSY_MESSAGE, _status: 409 };
173
190
  const locked = s.active_stream_id;
174
191
  if (locked) {
175
192
  if (this.registry.liveIds.has(locked) || this.registry.activeRuns.has(locked))
@@ -184,9 +201,10 @@ export class TurnRunner {
184
201
  return { error: 'session already has an active stream', active_stream_id: blocking, _status: 409 };
185
202
  }
186
203
  const streamId = randomUUID().replace(/-/g, '');
187
- const wasHiddenEmpty = !s.messages.length && !s.context_messages.length && !s.pending_user_message;
204
+ const wasHiddenEmpty = !s.messages.length && !s.context_messages.length && !s.hasPendingPrompt;
188
205
  const attachments = opts.attachments ?? [];
189
- const source = str(s.source_tag).toLowerCase() === 'fork' ? 'fork' : (opts.source ?? 'webui');
206
+ // A background wakeup stays one in a forked session too (TAL-460).
207
+ const source = opts.source !== 'process_wakeup' && str(s.source_tag).toLowerCase() === 'fork' ? 'fork' : (opts.source ?? 'webui');
190
208
  s.workspace = opts.workspace;
191
209
  s.model = opts.model;
192
210
  s.model_provider = opts.modelProvider;
@@ -214,10 +232,11 @@ export class TurnRunner {
214
232
  const channel = this.registry.create(streamId, s.session_id);
215
233
  if (opts.goalRelated)
216
234
  this.registry.goalRelated.add(streamId);
217
- this.registry.registerActiveRun({ stream_id: streamId, session_id: s.session_id, started_at: this.deps.now(), phase: 'starting', workspace: opts.workspace, model: opts.model, provider: opts.modelProvider, ephemeral: Boolean(opts.ephemeral) });
218
- void this.run(s.session_id, streamId, channel, opts).catch((error) => {
235
+ this.registry.registerActiveRun({ stream_id: streamId, session_id: s.session_id, started_at: this.deps.now(), phase: 'starting', workspace: opts.workspace, model: opts.model, provider: opts.modelProvider, ephemeral: Boolean(opts.ephemeral), origin: turnOrigin(source) });
236
+ const worker = this.run(s.session_id, streamId, channel, opts).catch((error) => {
219
237
  this.deps.log(`[webui] ERROR turn ${streamId} crashed\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
220
- });
238
+ }).finally(() => { this.workers.delete(streamId); });
239
+ this.workers.set(streamId, worker);
221
240
  const response = { stream_id: streamId, session_id: s.session_id, pending_started_at: s.pending_started_at, turn_id: streamId, title: s.title };
222
241
  if (opts.normalizedModel && opts.model)
223
242
  response.effective_model = opts.model;
@@ -246,7 +265,7 @@ export class TurnRunner {
246
265
  const put = (event, data, meta = {}) => {
247
266
  for (const steerEvent of this.takeSteerEventsBefore(streamId, event))
248
267
  put(steerEvent[0], steerEvent[1]);
249
- if (this.registry.cancelled.has(streamId) && !['cancel', 'apperror', 'steer_consumed', 'pending_steer_leftover'].includes(event))
268
+ if (this.registry.cancelled.has(streamId) && !['cancel', 'apperror', 'steer_consumed', 'steer_withdrawn'].includes(event))
250
269
  return;
251
270
  // `cancel()` already wrote the terminal row and closed the stream: the worker's unwind adds no second one.
252
271
  if (event === 'cancel' && !this.registry.streams.has(streamId)) {
@@ -290,11 +309,7 @@ export class TurnRunner {
290
309
  }
291
310
  const msgText = opts.msg;
292
311
  const previousMessages = structuredClone(s.messages);
293
- // Python `reconciled_state_db_messages_for_session(prefer_context=True)`: the model history is the owner context
294
- // extended append-only with the Agent's state.db rows (a CLI continuation of this session reaches the model), except
295
- // for a compressed context whose anchor cannot be verified — that stays context-only.
296
- const localContext = s.context_messages.length ? s.context_messages : s.messages.filter((m) => !m._error && !m._partial);
297
- const previousContext = structuredClone(localContext.some((m) => isContextCompressionMarker(m)) ? localContext : deps.service().mergedTranscript(s, localContext));
312
+ const previousContext = structuredClone(deps.service().modelContext(s));
298
313
  // Python `_sanitize_messages_for_api`: the model never sees display-only rows or a replayed cancelled prompt.
299
314
  const apiHistory = sanitizeMessagesForApi(previousContext);
300
315
  const workspaceCtx = workspaceContextPrefix(opts.workspace);
@@ -314,6 +329,11 @@ export class TurnRunner {
314
329
  const toolStartedAt = new WeakMap();
315
330
  let tokenSent = false;
316
331
  let firstTokenAt = null;
332
+ // TAL-460: a background turn's text is held back while it could still be a silence marker, so one never flashes by.
333
+ // ponytail: checks the turn's whole text, so a marker after earlier prose streams until the settled row hides it;
334
+ // check the last text segment instead if that shows up.
335
+ const holdSilence = turnOrigin(opts.source) === 'background';
336
+ let heldText = '';
317
337
  const titles = new ReasoningTitleTracker();
318
338
  let capturedTerminalError = null;
319
339
  const controller = new AbortController();
@@ -327,6 +347,8 @@ export class TurnRunner {
327
347
  activeRun.phase = 'running';
328
348
  const settledAt = { value: false };
329
349
  let failed = false;
350
+ // TAL-424: steers an errored turn ended without taking, sent as one follow-up turn once this one is torn down.
351
+ let followUp = [];
330
352
  try {
331
353
  if (!sidecar)
332
354
  throw new SidecarError('The Agent sidecar is not running; chat is unavailable until it starts.', { condition: 'sidecar_unavailable' });
@@ -349,7 +371,7 @@ export class TurnRunner {
349
371
  const frozenWorkspace = str(s.created_workspace) || str(s.workspace);
350
372
  const turnContext = {
351
373
  system_message: workspaceSystemMessage(frozenWorkspace),
352
- 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' }),
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' }),
353
375
  max_iterations: processWakeupMaxIterations(maxIterationsFromConfig(cfg), opts.source ?? 'webui', deps.env ?? {}),
354
376
  max_tokens: maxTokensFromConfig(cfg),
355
377
  reasoning_config: reasoningConfigFromConfig(cfg, opts.modelProvider ?? s.model_provider ?? null),
@@ -375,7 +397,12 @@ export class TurnRunner {
375
397
  if (stable !== null)
376
398
  put('reasoning', { text: '', titles: stable });
377
399
  partialText.push(str(data.text));
378
- put('token', { text: str(data.text) });
400
+ if (holdSilence && mayBecomeSilentReply(partialText.join(''))) {
401
+ heldText += str(data.text);
402
+ return;
403
+ }
404
+ put('token', { text: heldText + str(data.text) });
405
+ heldText = '';
379
406
  return;
380
407
  }
381
408
  case 'reasoning': {
@@ -387,9 +414,12 @@ export class TurnRunner {
387
414
  return;
388
415
  }
389
416
  case 'steer_pending':
390
- for (const record of this.takeConsumedSteers(streamId, str(data.text), { keepLeftovers: true }))
391
- put('steer_consumed', record);
392
- this.saveConsumedSteers(sessionId, streamId);
417
+ // TAL-424: while a withdraw or Send now rewrites the Agent's queue its pending text is ambiguous; it settles after.
418
+ if (this.steerRewrites.has(streamId)) {
419
+ this.deferredPendingSteer.set(streamId, str(data.text));
420
+ return;
421
+ }
422
+ this.settleConsumedSteers(sessionId, streamId, str(data.text));
393
423
  return;
394
424
  case 'tool': {
395
425
  const call = { name: data.name, args: data.args ?? {}, tid: str(data.tid), done: false };
@@ -401,15 +431,20 @@ export class TurnRunner {
401
431
  return;
402
432
  }
403
433
  case 'tool_complete': {
404
- // TAL-313: the server decides failure from the sidecar's raw result, which never leaves the server.
434
+ // TAL-313: the server decides failure from the sidecar's raw result, which never leaves the server; TAL-315: so
435
+ // are its display sections, which keep the stderr and exit code the flat preview drops.
405
436
  const { raw_result: rawResult, ...complete } = data;
406
- complete.is_error = toolOutcome(rawResult).is_error;
437
+ const outcome = toolOutcome(rawResult);
438
+ complete.is_error = outcome.is_error;
439
+ if (rawResult !== undefined)
440
+ complete.result_view = outcome.result_view;
407
441
  const tc = liveToolCalls[completedToolIndex(liveToolCalls, str(data.tid), data.name)];
408
442
  if (tc) {
409
443
  const startedAt = toolStartedAt.get(tc);
410
444
  if (startedAt !== undefined)
411
445
  complete.duration = Math.round(Math.max(0, deps.now() - startedAt) * 1000) / 1000;
412
- Object.assign(tc, { done: true, snippet: data.preview, is_error: complete.is_error, duration: complete.duration ?? null });
446
+ // 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 } : {}) });
413
448
  }
414
449
  const id = (tc && toolIds.get(tc)) || str(data.tid) || mintToolId();
415
450
  this.lastCompletedTool.set(streamId, id);
@@ -424,6 +459,14 @@ export class TurnRunner {
424
459
  put('approval', { ...(pending ?? data), pending_count });
425
460
  return;
426
461
  }
462
+ // TAL-514: the Agent stopped waiting on an approval (answer, timeout, or interrupt); withdraw its copy.
463
+ case 'approval_resolved': {
464
+ if (!str(data.approval_id))
465
+ return;
466
+ deps.pending.resolveApproval(sessionId, str(data.approval_id));
467
+ put(...this.approvalHeadFrame(sessionId));
468
+ return;
469
+ }
427
470
  case 'clarify': {
428
471
  deps.pending.submitClarify(sessionId, { ...data, session_id: sessionId, kind: 'clarify' });
429
472
  const head = deps.pending.clarifyHeadFrame(sessionId);
@@ -455,25 +498,6 @@ export class TurnRunner {
455
498
  put('cancel', this.cancelFrame(sessionId));
456
499
  return;
457
500
  }
458
- if (opts.ephemeral) {
459
- let answer = '';
460
- for (let i = result.messages.length - 1; i >= 0; i -= 1) {
461
- const m = result.messages[i];
462
- if (m.role === 'assistant') {
463
- answer = str(m.content);
464
- break;
465
- }
466
- }
467
- opts.onDone?.(answer);
468
- // Python `_ephemeral_session_payload`: only role and content leave the server for a btw turn.
469
- 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' });
470
- try {
471
- rmSync(deps.store.pathFor(sessionId), { force: true });
472
- }
473
- catch { /* ignore */ }
474
- deps.store.sessions.delete(sessionId);
475
- return;
476
- }
477
501
  // Writeback only while this stream still owns the session (Python `_stream_writeback_is_current`).
478
502
  let current;
479
503
  try {
@@ -494,19 +518,26 @@ export class TurnRunner {
494
518
  // sidecar reports `completed` whenever a failed run still carries messages).
495
519
  // Python's second chance: a turn that emitted no new row still counts when the merged transcript it produced
496
520
  // ends on a final answer (the current user row or trailing tool activity makes it "lacking").
497
- const mergedForCheck = () => mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId });
498
- const assistantAdded = assistantReplyAddedAfterCurrentTurn(resultMessages, previousContext, msgText) || !sessionLacksFinalAssistantAnswer(mergedForCheck());
521
+ const mergedForCheck = () => mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId, attachments: opts.attachments ?? [] });
522
+ // TAL-512: a btw answer must be a reply this turn added: everything before it is the parent's replayed history.
523
+ const assistantAdded = assistantReplyAddedAfterCurrentTurn(resultMessages, previousContext, msgText) || (!opts.ephemeral && !sessionLacksFinalAssistantAnswer(mergedForCheck()));
499
524
  const lastErr = result.error ?? capturedTerminalError ?? '';
500
- // Python `_turn_transcript_lacks_final_assistant_answer`: a partial result with no final answer is a silent failure even if tokens streamed.
501
- const stalePartial = result.result_status === 'partial' && !assistantAdded;
502
- if (result.status === 'error' || (!assistantAdded && !tokenSent) || stalePartial) {
503
- const classification = classifyProviderError(lastErr, { silentFailure: !lastErr });
525
+ // Python `_agent_result_terminal_failure`: the Agent's failed, partial, or compression-exhausted result ends the turn
526
+ // even after text streamed; only a partial with no error text and a final answer still completes.
527
+ const agentFailed = result.failed || result.compression_exhausted || (result.partial && (Boolean(lastErr) || !assistantAdded));
528
+ if (result.status === 'error' || agentFailed || (!assistantAdded && (!tokenSent || opts.ephemeral))) {
529
+ const classification = classifyProviderError(lastErr, { silentFailure: !lastErr, compressionExhausted: result.compression_exhausted });
504
530
  const errStr = lastErr || `${classification.label}.`;
505
531
  const payload = providerErrorPayload(errStr, classification.type, classification.hint, deps.redactEnabled());
506
- // Settle the steers first so the persisted turn carries every consumed one; the Agent's pending text stays a leftover.
507
- const steerEvents = this.finalizeSteerEvents(streamId, str(result.pending_steer));
532
+ // Settle the steers first so the persisted turn carries every consumed one; the Agent's pending text is sent next.
533
+ // A withdraw or Send now still in flight finishes first, so the settlement sees the queue it left.
534
+ await this.steerRewrites.get(streamId);
535
+ const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, str(result.pending_steer), 'followup');
536
+ followUp = leftovers;
508
537
  this.persistError(s, streamId, classification.label, payload, activeTurnToken);
509
- payload.session = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
538
+ // TAL-512: a btw error carries no session: its journaled frame must not keep a copy of the parent conversation.
539
+ if (!opts.ephemeral)
540
+ payload.session = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
510
541
  payload.session_id = s.session_id;
511
542
  payload.old_session_id = sessionId;
512
543
  for (const [event, data] of steerEvents)
@@ -515,17 +546,32 @@ export class TurnRunner {
515
546
  failed = true;
516
547
  return;
517
548
  }
549
+ // TAL-512: a btw turn passes the failure check above first, so its last assistant row is this turn's reply.
550
+ if (opts.ephemeral) {
551
+ let answer = '';
552
+ for (let i = result.messages.length - 1; i >= 0; i -= 1) {
553
+ const m = result.messages[i];
554
+ if (m.role === 'assistant') {
555
+ answer = str(m.content);
556
+ break;
557
+ }
558
+ }
559
+ opts.onDone?.(answer);
560
+ // 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' });
562
+ try {
563
+ rmSync(deps.store.pathFor(sessionId), { force: true });
564
+ }
565
+ catch { /* ignore */ }
566
+ deps.store.sessions.delete(sessionId);
567
+ return;
568
+ }
518
569
  // ── settle the transcript ──
519
570
  // The Agent's last pending-steer text settles the remaining steers before the turn is written back.
520
- const steerEvents = this.finalizeSteerEvents(streamId, result.pending_steer);
521
- s.messages = mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId });
571
+ await this.steerRewrites.get(streamId);
572
+ 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 ?? [] });
522
574
  s.context_messages = dedupeContext(resultMessages);
523
- for (const m of s.messages) {
524
- if (m.role !== 'assistant')
525
- continue;
526
- if (typeof m.content === 'string')
527
- m.content = stripXmlToolCalls(m.content);
528
- }
529
575
  if (result.compressed) {
530
576
  s.compression_anchor_visible_idx = Math.max(0, previousMessages.length - 1);
531
577
  put('compressed', { session_id: sessionId, old_session_id: sessionId, new_session_id: sessionId, continuation_session_id: sessionId, message: 'Compression finished' });
@@ -567,7 +613,8 @@ export class TurnRunner {
567
613
  if (m.role === 'user') {
568
614
  const content = messageText(m.content);
569
615
  const base = msgText.includes('\n\n[Attached files:') ? (msgText.split('\n\n[Attached files:')[0] ?? '').trim() : msgText;
570
- if (content.includes(base.slice(0, 60)) || msgText.includes(content.slice(0, 60)))
616
+ // An attachment-only prompt has no text to match: only this turn's own row takes its files.
617
+ if (msgText ? content.includes(base.slice(0, 60)) || msgText.includes(content.slice(0, 60)) : m._turn_id === streamId)
571
618
  m.attachments = [...attachments];
572
619
  break;
573
620
  }
@@ -583,12 +630,12 @@ export class TurnRunner {
583
630
  asstIdx += 1;
584
631
  if (turnIdx < prevAssistants)
585
632
  continue;
586
- const existing = str(m.reasoning);
633
+ // The settled row keeps its own fields: only its prose and its inline thinking (into `reasoning`) are rewritten.
587
634
  if (typeof m.content === 'string' && m.content) {
588
- const [content, merged] = splitThinkingFromContent(m.content, existing);
635
+ const [content, inline] = splitDisplayText(m.content);
589
636
  m.content = content;
590
- if (merged)
591
- m.reasoning = merged;
637
+ if (inline)
638
+ m.reasoning = joinReasoning([reasoningFieldsText({ reasoning: m.reasoning }), inline]);
592
639
  }
593
640
  if (turnIdx === prevAssistants && reasoning.trim() && !m.reasoning)
594
641
  m.reasoning = reasoning.trim();
@@ -635,6 +682,10 @@ export class TurnRunner {
635
682
  cache_hit_percent: cacheHit(cacheReadTokens, inputTokens), turn_cache_hit_percent: cacheHit(turnCacheRead, turnInput), duration_seconds: Math.round(duration * 1000) / 1000,
636
683
  context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
637
684
  };
685
+ const doneSession = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
686
+ // TAL-299: the ring's figures match the terminal session's (and a detail reload's).
687
+ for (const key of CONTEXT_USAGE_FIELDS)
688
+ doneUsage[key] = doneSession[key];
638
689
  if (usage.completion_tokens && duration > 0)
639
690
  doneUsage.tps = Math.round((usage.completion_tokens / duration) * 10) / 10;
640
691
  if (firstTokenAt !== null)
@@ -643,7 +694,7 @@ export class TurnRunner {
643
694
  if (usedModel)
644
695
  doneUsage.used_model = usedModel;
645
696
  const donePayload = {
646
- session: redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled()),
697
+ session: doneSession,
647
698
  usage: doneUsage,
648
699
  terminal_state: turnTerminalState(s.messages, streamId),
649
700
  };
@@ -657,22 +708,15 @@ export class TurnRunner {
657
708
  // worker before its daemon-thread title generation).
658
709
  this.registry.activeRuns.delete(streamId);
659
710
  this.settledStreams.add(streamId);
660
- await this.backgroundTitle(s, put);
711
+ this.startSteerFollowUp(sessionId, leftovers);
712
+ // Title work and the goal judge run side by side; `stream_end` follows both, so their frames reach the stream.
713
+ const work = await Promise.allSettled([this.backgroundTitle(s, put), this.continueGoal(s, streamId, put)]);
714
+ put('stream_end', { session_id: sessionId });
715
+ for (const r of work)
716
+ if (r.status === 'rejected')
717
+ throw r.reason;
661
718
  // Python: the last non-error assistant reply with content, else "(no answer produced)".
662
- if (opts.onDone) {
663
- let answer = '';
664
- for (let i = s.messages.length - 1; i >= 0; i -= 1) {
665
- const m = s.messages[i];
666
- if (m.role !== 'assistant' || m._error)
667
- continue;
668
- const text = messageText(m.content).trim();
669
- if (text) {
670
- answer = text;
671
- break;
672
- }
673
- }
674
- opts.onDone(answer || '(no answer produced)');
675
- }
719
+ opts.onDone?.(lastAnswer(s.messages) || '(no answer produced)');
676
720
  }
677
721
  catch (error) {
678
722
  if (settledAt.value && !(error instanceof SidecarError)) {
@@ -704,7 +748,8 @@ export class TurnRunner {
704
748
  for (const [event, data] of this.takeSteerEventsBefore(streamId, 'apperror'))
705
749
  put(event, data);
706
750
  this.persistError(current, streamId, classification.label, payload, activeTurnToken);
707
- payload.session = redactSessionData(this.terminalSessionPayload(current), deps.redactEnabled());
751
+ if (!opts.ephemeral)
752
+ payload.session = redactSessionData(this.terminalSessionPayload(current), deps.redactEnabled());
708
753
  }
709
754
  }
710
755
  catch (persistError) {
@@ -720,21 +765,26 @@ export class TurnRunner {
720
765
  // not race the error writeback above (a re-saved session would resurface in the sidebar).
721
766
  if (failed)
722
767
  opts.onFailed?.();
768
+ this.startSteerFollowUp(sessionId, followUp);
723
769
  }
724
770
  }
725
771
  /**
726
772
  * Python `_build_user_message`: image attachments are embedded as native `image_url` parts only when the Agent's
727
773
  * resolved image mode for this model is `native` (text mode routes them through the Agent's vision tool path), and
728
- * only after the bytes are read through an anchored descriptor and sniffed as a real image format.
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.
729
777
  */
730
778
  async buildUserMessage(workspaceCtx, msgText, attachments, workspace, sessionId, s, opts, signal) {
731
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);
732
782
  const candidates = attachments.filter((att) => str(att.path).trim() && str(att.mime).trim().startsWith('image/'));
733
783
  if (!candidates.length)
734
- return text;
784
+ return withFiles();
735
785
  const sidecar = this.deps.sidecar();
736
786
  if (!sidecar)
737
- return text;
787
+ return withFiles();
738
788
  // A cancel that landed before this point is final: never start the lookup or wait on it.
739
789
  if (signal.aborted)
740
790
  return text;
@@ -750,15 +800,15 @@ export class TurnRunner {
750
800
  if (mode.mode !== 'native') {
751
801
  const cfg = (await this.deps.profileConfig?.(s.profile ?? null)) ?? {};
752
802
  if (explicitTextSignal(cfg) || mode.supports_vision === false)
753
- return text;
803
+ return withFiles();
754
804
  }
755
805
  }
756
806
  catch (error) {
757
807
  if (!signal.aborted)
758
808
  this.deps.log(`[webui] image mode lookup failed for ${sessionId}: ${error.message}`);
759
- return text;
809
+ return withFiles();
760
810
  }
761
- const parts = [{ type: 'text', text }];
811
+ const parts = [];
762
812
  let images = 0;
763
813
  const roots = [workspace, this.deps.attachmentDir(sessionId)].map((r) => resolvePathLikePython(r));
764
814
  for (const att of candidates) {
@@ -790,17 +840,20 @@ export class TurnRunner {
790
840
  closeSync(fd);
791
841
  }
792
842
  }
793
- return images ? parts : text;
843
+ return images ? [{ type: 'text', text: withFiles() }, ...parts] : withFiles();
794
844
  }
795
845
  /**
796
846
  * Python `_session_payload_with_terminal_window`: the same visible-message window contract as `GET /api/session?
797
847
  * msg_limit=` (renderable rows, limited payload shape, windowed tool calls, todo state) with the full count.
798
848
  */
799
849
  terminalSessionPayload(s) {
800
- const payload = withSessionWireFlags(s.compact(), this.registry.liveIds);
801
- const scened = hydrateAnchorActivityScenes(withToolCallOutcomes(withTurnIds(withAttachmentObjects(s.messages)), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, clipToolResults: true });
850
+ const payload = withSessionWireFlags(s.compact({ contextLengthFor: this.deps.service().deps.contextLengthFor, modelOptionFor: this.deps.service().deps.modelOptionFor }), this.registry.liveIds);
851
+ payload.assistant_name = this.deps.service().assistantName(s);
852
+ payload.workspace_name = this.deps.service().workspaceNames()(s);
853
+ const service = this.deps.service();
854
+ const scened = service.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(withBackgroundUpdates(withMarkerKinds(withTurnIds(withAttachmentObjects(s.messages))), s), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, clipToolResults: true }));
802
855
  const [window, offset] = messageWindowForDisplay(scened, TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT, null);
803
- const limited = messagesForLimitedPayload(window);
856
+ const limited = messagesForLimitedPayload(withBodyExcerpts(withDisplayMedia(window, service.mediaProjector(s)), s.active_stream_id));
804
857
  payload.messages = limited;
805
858
  payload.message_count = s.messages.length;
806
859
  payload._messages_offset = offset;
@@ -853,7 +906,7 @@ export class TurnRunner {
853
906
  }
854
907
  materializePendingUserTurn(s, activeTurnToken, turnId) {
855
908
  const pendingText = str(s.pending_user_message);
856
- if (!pendingText)
909
+ if (!s.hasPendingPrompt)
857
910
  return false;
858
911
  const recoveredTs = typeof s.pending_started_at === 'number' && s.pending_started_at > 0 ? s.pending_started_at : this.deps.now();
859
912
  const source = s.pending_user_source ?? 'webui';
@@ -932,6 +985,11 @@ export class TurnRunner {
932
985
  this.deps.store.sessions.delete(current.session_id);
933
986
  return true;
934
987
  }
988
+ // TAL-460: a background turn stops quietly: its prompt and model context are kept, but no partial and no Stop row,
989
+ // and its first settlement is final.
990
+ const quiet = this.registry.activeRuns.get(streamId)?.origin === 'background';
991
+ if (quiet && current.active_stream_id === null)
992
+ return true;
935
993
  const stop = this.stopContexts.get(streamId);
936
994
  const canonical = stop !== undefined && checkpoint !== null && checkpointTurnStart(checkpoint, stop.msgText, stop.historyLength) !== null;
937
995
  const settle = () => {
@@ -967,8 +1025,10 @@ export class TurnRunner {
967
1025
  current.pending_attachments = [];
968
1026
  current.pending_started_at = null;
969
1027
  current.pending_user_source = null;
970
- this.appendPartialSnapshot(current, streamId);
971
- current.messages.push({ role: 'assistant', content: '', _error: true, _terminal_state: 'cancelled', timestamp: Math.trunc(this.deps.now()), _turn_id: streamId });
1028
+ if (!quiet) {
1029
+ this.appendPartialSnapshot(current, streamId);
1030
+ current.messages.push({ role: 'assistant', content: '', _error: true, _terminal_state: 'cancelled', timestamp: Math.trunc(this.deps.now()), _turn_id: streamId });
1031
+ }
972
1032
  this.persistConsumedSteers(current, streamId, startedAt, this.deps.now());
973
1033
  try {
974
1034
  this.deps.store.save(current);
@@ -1077,83 +1137,83 @@ export class TurnRunner {
1077
1137
  const sessionId = s.session_id;
1078
1138
  const placeholder = str(s.title).trim();
1079
1139
  const invalidExisting = looksInvalidGeneratedTitle(s.title);
1080
- const eligible = ['Untitled', 'New Chat', ''].includes(placeholder) || placeholder === titleFrom(s.messages, '') || invalidExisting;
1140
+ // A claimed CLI session still carrying its default `<Source> Session` title is retitled like a new chat (TAL-256).
1141
+ const defaultTitle = (session) => looksLikeDefaultCliTitle({ title: session.title, source_tag: session.source_tag, raw_source: session.raw_source, session_source: session.session_source, source_label: session.source_label });
1142
+ const eligible = ['Untitled', 'New Chat', ''].includes(placeholder) || placeholder === titleFrom(s.messages, '') || invalidExisting || defaultTitle(s);
1081
1143
  const [userText, assistantText] = firstExchangeSnippets(s.messages);
1082
1144
  // Python `_put_title_status`: empty reason/title/raw_preview keys are omitted.
1083
1145
  const status = (status, reason = '', title = '', rawPreview = '') => {
1084
1146
  put('title_status', { session_id: sessionId, status, ...(reason ? { reason } : {}), ...(title ? { title } : {}), ...(rawPreview ? { raw_preview: rawPreview } : {}) });
1085
1147
  };
1086
- try {
1087
- if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText)
1088
- return;
1089
- if (s.manual_title) {
1090
- status('skipped', 'manual_title', placeholder);
1091
- return;
1148
+ if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText)
1149
+ return;
1150
+ if (s.manual_title) {
1151
+ status('skipped', 'manual_title', placeholder);
1152
+ return;
1153
+ }
1154
+ if (!this.deps.titleGenerationEnabled()) {
1155
+ status('skipped', 'title_generation_disabled', placeholder);
1156
+ return;
1157
+ }
1158
+ const generated = await this.llmTitle(s, userText, assistantText);
1159
+ const { status: llmStatus, rawPreview } = generated;
1160
+ let next = generated.title;
1161
+ let source = llmStatus;
1162
+ if (!next) {
1163
+ const fallback = fallbackTitleFromExchange(userText, assistantText);
1164
+ if (fallback && !isGenericFallbackTitle(fallback)) {
1165
+ next = fallback;
1166
+ source = 'fallback';
1092
1167
  }
1093
- if (!this.deps.titleGenerationEnabled()) {
1094
- status('skipped', 'title_generation_disabled', placeholder);
1168
+ }
1169
+ const fallbackReason = source === 'fallback' && llmStatus ? `local_summary:${llmStatus}` : 'local_summary';
1170
+ let current = s;
1171
+ try {
1172
+ current = this.deps.store.get(sessionId);
1173
+ }
1174
+ catch {
1175
+ current = s;
1176
+ }
1177
+ let effective = str(current.title).trim();
1178
+ let wrote = false;
1179
+ if (next) {
1180
+ const stillAuto = effective === placeholder || ['Untitled', 'New Chat', ''].includes(effective) || effective === titleFrom(current.messages, '') || looksInvalidGeneratedTitle(current.title) || defaultTitle(current);
1181
+ if (current.manual_title || !stillAuto) {
1182
+ status('skipped', 'manual_title', effective);
1095
1183
  return;
1096
1184
  }
1097
- const generated = await this.llmTitle(s, userText, assistantText);
1098
- const { status: llmStatus, rawPreview } = generated;
1099
- let next = generated.title;
1100
- let source = llmStatus;
1101
- if (!next) {
1102
- const fallback = fallbackTitleFromExchange(userText, assistantText);
1103
- if (fallback && !isGenericFallbackTitle(fallback)) {
1104
- next = fallback;
1105
- source = 'fallback';
1106
- }
1107
- }
1108
- const fallbackReason = source === 'fallback' && llmStatus ? `local_summary:${llmStatus}` : 'local_summary';
1109
- let current = s;
1110
- try {
1111
- current = this.deps.store.get(sessionId);
1112
- }
1113
- catch {
1114
- current = s;
1115
- }
1116
- let effective = str(current.title).trim();
1117
- let wrote = false;
1118
- if (next) {
1119
- const stillAuto = effective === placeholder || ['Untitled', 'New Chat', ''].includes(effective) || effective === titleFrom(current.messages, '') || looksInvalidGeneratedTitle(current.title);
1120
- if (current.manual_title || !stillAuto) {
1121
- status('skipped', 'manual_title', effective);
1122
- return;
1123
- }
1124
- if (next !== effective) {
1125
- current.title = next;
1126
- markSessionTitleGenerated(current);
1127
- this.deps.store.save(current, { touchUpdatedAt: false });
1128
- // Python `sync_session_title` after generation: the state.db row follows when `sync_to_insights` is on.
1129
- await this.deps.syncTitle?.(current);
1130
- this.deps.events.publish('title', { profile: current.profile, sessionId });
1131
- effective = next;
1132
- wrote = true;
1133
- }
1134
- }
1135
- if (wrote) {
1136
- status(source, source === 'fallback' ? fallbackReason : llmStatus, effective, rawPreview);
1137
- put('title', { session_id: sessionId, title: effective });
1185
+ if (next !== effective) {
1186
+ current.title = next;
1187
+ markSessionTitleGenerated(current);
1188
+ this.deps.store.save(current, { touchUpdatedAt: false });
1189
+ // Python `sync_session_title` after generation: the state.db row follows when `sync_to_insights` is on.
1190
+ await this.deps.syncTitle?.(current);
1191
+ this.deps.events.publish('title', { profile: current.profile, sessionId });
1192
+ effective = next;
1193
+ wrote = true;
1138
1194
  }
1139
- else
1140
- status('skipped', source || 'unchanged', effective, rawPreview);
1141
1195
  }
1142
- finally {
1143
- put('stream_end', { session_id: sessionId });
1196
+ if (wrote) {
1197
+ status(source, source === 'fallback' ? fallbackReason : llmStatus, effective, rawPreview);
1198
+ put('title', { session_id: sessionId, title: effective });
1144
1199
  }
1200
+ else
1201
+ status('skipped', source || 'unchanged', effective, rawPreview);
1145
1202
  }
1146
1203
  // ── cancel / steer ───────────────────────────────────────────────────────
1147
- /** Python `cancel_stream`: persist the partial, mark cancelled, interrupt the Agent, release admission. */
1204
+ /**
1205
+ * Python `cancel_stream`: persist the partial, mark cancelled, interrupt the Agent, release admission. `withdrawn` is the
1206
+ * `steer_withdrawn` (stopped) this Stop emitted, for the cancel response (TAL-426).
1207
+ */
1148
1208
  async cancel(streamId) {
1149
1209
  const channel = this.registry.peek(streamId);
1150
1210
  const run = this.registry.activeRuns.get(streamId);
1151
1211
  if (!channel && !run)
1152
- return false;
1212
+ return { cancelled: false, withdrawn: [] };
1153
1213
  // Python popped the run before its title work: a cancel that lands after `done` (while the title prompts still run)
1154
1214
  // is not a cancellation, so it neither journals a `cancel` frame nor marks the completed run interrupted.
1155
1215
  if (!run && this.settledStreams.has(streamId))
1156
- return false;
1216
+ return { cancelled: false, withdrawn: [] };
1157
1217
  const sessionId = this.registry.ownerSessionId(streamId) ?? run?.session_id ?? null;
1158
1218
  this.registry.cancelled.add(streamId);
1159
1219
  if (run) {
@@ -1174,7 +1234,9 @@ export class TurnRunner {
1174
1234
  const leftover = str(reply?.pending_steer);
1175
1235
  const checkpoint = reply?.checkpoint ?? null;
1176
1236
  // Settle the steers first so the persisted cancel carries every consumed one.
1177
- const steerEvents = (this.steers.get(streamId) ?? []).length ? this.finalizeSteerEvents(streamId, leftover) : [];
1237
+ // TAL-424: a Stop withdraws the steers the Agent never took, with their text, and starts nothing after.
1238
+ await this.steerRewrites.get(streamId);
1239
+ const steerEvents = (this.steers.get(streamId) ?? []).length ? this.finalizeSteers(streamId, leftover, 'stopped').events : [];
1178
1240
  if (sessionId) {
1179
1241
  let current = null;
1180
1242
  try {
@@ -1210,6 +1272,21 @@ export class TurnRunner {
1210
1272
  this.registry.liveIds.delete(streamId);
1211
1273
  }
1212
1274
  this.abortControllers.get(streamId)?.abort();
1275
+ const withdrawn = steerEvents.filter(([event]) => event === 'steer_withdrawn').map(([, data]) => data);
1276
+ return { cancelled: true, withdrawn };
1277
+ }
1278
+ /**
1279
+ * TAL-460: a user's message never joins a background turn. That turn stops quietly (`finalizeCancelled`) and its worker
1280
+ * unwinds before the user's turn is admitted, so two Agent runs never share the session. True when one was stopped.
1281
+ */
1282
+ async yieldBackgroundTurn(sessionId) {
1283
+ const streamId = this.registry.activeRunStreamForSession(sessionId);
1284
+ if (!streamId || this.registry.activeRuns.get(streamId)?.origin !== 'background')
1285
+ return false;
1286
+ await this.cancel(streamId);
1287
+ let timer;
1288
+ await Promise.race([this.workers.get(streamId), new Promise((resolve) => { timer = setTimeout(resolve, BACKGROUND_UNWIND_WAIT_MS); })]);
1289
+ clearTimeout(timer);
1213
1290
  return true;
1214
1291
  }
1215
1292
  async steer(sessionId, text, displayText, steerId) {
@@ -1234,7 +1311,10 @@ export class TurnRunner {
1234
1311
  return { accepted: false, fallback: 'no_cached_agent', stream_id: null };
1235
1312
  // Register provisionally before the RPC: the Agent may apply the steer and finish the turn before the steer
1236
1313
  // reply arrives, and the turn's finalisation must then already see the record. A rejection removes it again.
1237
- const record = { steer_id: steerId, session_id: sessionId, stream_id: activeStreamId, text, display_text: displayText || text, created_at: this.deps.now() };
1314
+ return this.withSteerChain(activeStreamId, () => this.steerInto(sidecar, sessionId, activeStreamId, text, displayText, steerId));
1315
+ }
1316
+ async steerInto(sidecar, sessionId, activeStreamId, text, displayText, steerId) {
1317
+ const record = { steer_id: steerId, session_id: sessionId, stream_id: activeStreamId, text, display_text: displayText || text, created_at: this.deps.now(), state: 'pending', can_redirect: false };
1238
1318
  const records = this.steers.get(activeStreamId) ?? [];
1239
1319
  records.push(record);
1240
1320
  this.steers.set(activeStreamId, records);
@@ -1259,6 +1339,12 @@ export class TurnRunner {
1259
1339
  // An accepted steer whose turn finalised while the reply was in flight was already reported by that finalisation.
1260
1340
  if (!result.accepted)
1261
1341
  withdraw();
1342
+ else {
1343
+ record.can_redirect = result.can_redirect === true;
1344
+ // TAL-424: every client shows it until the Agent takes it (unless it already did while the reply was in flight).
1345
+ if (this.steers.get(activeStreamId)?.includes(record))
1346
+ this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
1347
+ }
1262
1348
  return { accepted: result.accepted, fallback: result.accepted ? null : (result.fallback ?? 'not_running'), stream_id: activeStreamId, steer_id: steerId };
1263
1349
  }
1264
1350
  /** Python `_webui_steer_events_before` for terminal frames: cancel/apperror flush consumed steers and leftovers first. */
@@ -1267,7 +1353,7 @@ export class TurnRunner {
1267
1353
  return [];
1268
1354
  if (!(this.steers.get(streamId) ?? []).length)
1269
1355
  return [];
1270
- return this.finalizeSteerEvents(streamId, '');
1356
+ return this.finalizeSteers(streamId, '', 'stopped').events;
1271
1357
  }
1272
1358
  /** Python `_pending_webui_steer_suffix_start`: the pending text is the newline-joined tail of the records; earlier ones are consumed. */
1273
1359
  static pendingSteerSuffixStart(records, pendingText) {
@@ -1280,15 +1366,15 @@ export class TurnRunner {
1280
1366
  }
1281
1367
  return null;
1282
1368
  }
1283
- consumedSteerPayload(record) {
1369
+ consumedSteerPayload(record, opts = {}) {
1284
1370
  const consumedAt = this.deps.now();
1285
1371
  const afterToolCallId = this.lastCompletedTool.get(record.stream_id) ?? null;
1286
1372
  const consumed = this.consumedSteers.get(record.stream_id) ?? [];
1287
1373
  if (!consumed.some((c) => c.steer_id === record.steer_id)) {
1288
- consumed.push({ steer_id: record.steer_id, text: record.display_text || record.text, agent_text: record.text, submitted_at: record.created_at, consumed_at: consumedAt, after_tool_call_id: afterToolCallId });
1374
+ consumed.push({ steer_id: record.steer_id, text: record.display_text || record.text, agent_text: record.text, submitted_at: record.created_at, consumed_at: consumedAt, after_tool_call_id: afterToolCallId, ...(opts.redirected ? { redirected: true } : {}) });
1289
1375
  this.consumedSteers.set(record.stream_id, consumed);
1290
1376
  }
1291
- return { ...record, agent_text: record.text, text: record.display_text || record.text, consumed_at: consumedAt, after_tool_call_id: afterToolCallId };
1377
+ return { steer_id: record.steer_id, session_id: record.session_id, stream_id: record.stream_id, display_text: record.display_text, created_at: record.created_at, agent_text: record.text, text: record.display_text || record.text, consumed_at: consumedAt, after_tool_call_id: afterToolCallId };
1292
1378
  }
1293
1379
  /**
1294
1380
  * Consumed steers become display-only `_steer` user rows at their causal place in the turn: after the tool results of
@@ -1309,7 +1395,14 @@ export class TurnRunner {
1309
1395
  };
1310
1396
  // An Agent that records the steer it delivered does so at its exact place: that row becomes the steer, not a copy.
1311
1397
  const agentRows = s.messages.flatMap((m, i) => (m._turn_id === streamId && agentSteerText(m) !== null ? [i] : []));
1312
- const agentRow = agentRows.find((i) => agentSteerText(s.messages[i]) === steer.agent_text.trim()) ?? agentRows[0];
1398
+ let agentRow = agentRows.find((i) => agentSteerText(s.messages[i]) === steer.agent_text.trim()) ?? agentRows[0];
1399
+ // A Send now redirect is recorded as a plain user row of the turn: that row becomes the steer too.
1400
+ if (steer.redirected) {
1401
+ const prompt = s.messages.findIndex((m) => m._turn_id === streamId && m.role === 'user' && !m._steer);
1402
+ const own = s.messages.findIndex((m, i) => i > prompt && m._turn_id === streamId && m.role === 'user' && !m._steer && messageText(m.content).trim() === steer.agent_text.trim());
1403
+ // Never another steer's row: without its own correction row (the Agent joins two redirects) it gets a new one.
1404
+ agentRow = own >= 0 ? own : undefined;
1405
+ }
1313
1406
  if (agentRow === undefined)
1314
1407
  s.messages.splice(steerInsertIndex(s.messages, streamId, steer.after_tool_call_id), 0, row);
1315
1408
  else
@@ -1364,22 +1457,235 @@ export class TurnRunner {
1364
1457
  return [];
1365
1458
  return records.slice(0, start).map((r) => this.consumedSteerPayload(r));
1366
1459
  }
1367
- /** Terminal steer bookkeeping for cancel/error paths: consumed records first, then `pending_steer_leftover` for the rest. */
1368
- finalizeSteerEvents(streamId, leftoverText) {
1460
+ /**
1461
+ * Terminal steer bookkeeping: the records the Agent no longer holds are consumed; the rest it never took are either
1462
+ * `stopped` (withdrawn with their text for the composer) or returned as one `followup` turn's texts (TAL-424).
1463
+ */
1464
+ finalizeSteers(streamId, leftoverText, mode) {
1369
1465
  const records = this.steers.get(streamId) ?? [];
1370
1466
  this.steers.delete(streamId);
1371
- const start = TurnRunner.pendingSteerSuffixStart(records, leftoverText);
1372
- if (start === null)
1373
- return leftoverText ? [['pending_steer_leftover', { stream_id: streamId, text: leftoverText }]] : [];
1374
- const events = records.slice(0, start).map((r) => ['steer_consumed', this.consumedSteerPayload(r)]);
1375
- const leftovers = records.slice(start);
1376
- for (const r of leftovers)
1377
- events.push(['pending_steer_leftover', { ...r, leftover_at: this.deps.now() }]);
1378
- const matched = leftovers.map((r) => r.text).join('\n');
1467
+ // Text that ends with none of these steers is another surface's: the Agent took every one of them.
1468
+ const start = TurnRunner.pendingSteerSuffixStart(records, leftoverText) ?? records.length;
1469
+ const consumed = records.slice(0, start);
1470
+ const left = records.slice(start);
1471
+ const matched = left.map((r) => r.text).join('\n');
1379
1472
  const unmatched = matched ? leftoverText.slice(0, leftoverText.length - matched.length).replace(/\n+$/, '') : leftoverText;
1473
+ const events = consumed.map((r) => ['steer_consumed', this.consumedSteerPayload(r)]);
1474
+ for (const r of left)
1475
+ events.push(['steer_withdrawn', { steer_id: r.steer_id, reason: mode, text: r.display_text }]);
1476
+ if (mode === 'followup')
1477
+ return { events, leftovers: [...(unmatched ? [unmatched] : []), ...left.map((r) => r.text)] };
1478
+ if (unmatched)
1479
+ 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() }]);
1380
1484
  if (unmatched)
1381
1485
  events.push(['pending_steer_leftover', { stream_id: streamId, text: unmatched }]);
1382
- return events;
1486
+ return { events, leftovers: [] };
1487
+ }
1488
+ /**
1489
+ * TAL-424: the steers a finished turn never took go on as one follow-up turn, sent once by the server for every client.
1490
+ * Should another turn already hold the session, they steer into that one instead, so no text is lost.
1491
+ */
1492
+ startSteerFollowUp(sessionId, texts) {
1493
+ if (!texts.length)
1494
+ return;
1495
+ let s;
1496
+ try {
1497
+ s = this.deps.store.get(sessionId);
1498
+ }
1499
+ catch {
1500
+ return;
1501
+ }
1502
+ const msg = texts.join('\n');
1503
+ const started = this.start(s, { msg, attachments: [], workspace: s.workspace, model: s.model, modelProvider: s.model_provider });
1504
+ if (!started._status || started._status < 400)
1505
+ return;
1506
+ if (started._status === 409 && started.active_stream_id) {
1507
+ void this.steer(sessionId, msg, msg, `steer-${randomUUID()}`).then((r) => {
1508
+ if (r.accepted !== true)
1509
+ this.deps.log(`[webui] WARNING: steer follow-up for ${sessionId} was not accepted (${str(r.fallback)}); text: ${msg.slice(0, 200)}`);
1510
+ });
1511
+ return;
1512
+ }
1513
+ this.deps.log(`[webui] WARNING: steer follow-up for ${sessionId} not started (${String(started._status)}): ${str(started.error)}`);
1514
+ }
1515
+ /**
1516
+ * TAL-396 (Agent gateway `_post_turn_goal_continuation`): after every turn settles, the Agent's GoalManager judges it
1517
+ * while a goal is active and the server starts the continuation turn it asks for. Any turn counts, so a resumed or
1518
+ * persisted goal picks up from the next user message. A user turn that took the session meanwhile runs first and is
1519
+ * judged next. A stopped or failed turn never gets here, so the run ends there.
1520
+ */
1521
+ async continueGoal(s, streamId, put) {
1522
+ const sidecar = this.deps.sidecar();
1523
+ const lastResponse = lastAnswer(s.messages);
1524
+ // The Agent never drives a goal from an empty reply.
1525
+ if (!sidecar || !lastResponse)
1526
+ return;
1527
+ const sessionId = s.session_id;
1528
+ // Only a known goal turn shows progress up front; any other turn shows nothing unless a goal turns out to be active.
1529
+ const goalTurn = this.registry.goalRelated.has(streamId);
1530
+ if (goalTurn)
1531
+ put('goal', { session_id: sessionId, state: 'evaluating', message: 'Evaluating goal progress…', message_key: 'goal_evaluating_progress' });
1532
+ let decision;
1533
+ try {
1534
+ decision = await sidecar.call('goals.evaluate', { session_id: sessionId, profile_home: this.deps.profileHome(s.profile), last_response: lastResponse, user_initiated: true });
1535
+ }
1536
+ catch (error) {
1537
+ this.deps.log(`[webui] WARNING: goal evaluation failed for ${sessionId}: ${error.message}`);
1538
+ if (goalTurn)
1539
+ put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
1540
+ return;
1541
+ }
1542
+ if (!goalTurn && decision.verdict === 'inactive')
1543
+ return;
1544
+ const prompt = str(decision.continuation_prompt).trim();
1545
+ const proceed = decision.should_continue && Boolean(prompt);
1546
+ const status = { message: decision.message, message_key: decision.message_key, message_args: decision.message_args ?? [], decision: decision.verdict };
1547
+ put('goal', { session_id: sessionId, state: proceed ? 'continuing' : 'idle', ...status });
1548
+ if (!proceed)
1549
+ return;
1550
+ let current;
1551
+ try {
1552
+ current = this.deps.store.get(sessionId);
1553
+ }
1554
+ catch {
1555
+ return;
1556
+ }
1557
+ const started = this.start(current, { msg: prompt, attachments: [], workspace: current.workspace, model: current.model, modelProvider: current.model_provider, goalRelated: true });
1558
+ if (started._status === 409 && started.active_stream_id) {
1559
+ this.registry.goalRelated.add(started.active_stream_id);
1560
+ return;
1561
+ }
1562
+ if (started._status !== undefined && started._status >= 400) {
1563
+ this.deps.log(`[webui] WARNING: goal continuation for ${sessionId} not started (${String(started._status)}): ${str(started.error)}`);
1564
+ put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
1565
+ return;
1566
+ }
1567
+ put('goal_continue', { session_id: sessionId, state: 'continuing', continuation_prompt: prompt, text: prompt, stream_id: str(started.stream_id), ...status });
1568
+ }
1569
+ /** The Agent's pending steer text settles which steers it has taken: those become `steer_consumed` and their rows. */
1570
+ settleConsumedSteers(sessionId, streamId, pendingText) {
1571
+ for (const record of this.takeConsumedSteers(streamId, pendingText, { keepLeftovers: true }))
1572
+ this.emitToSession(sessionId, 'steer_consumed', record);
1573
+ this.saveConsumedSteers(sessionId, streamId);
1574
+ }
1575
+ /** What every client shows for a pending steer; the server decides which actions it offers. */
1576
+ pendingSteerView(r) {
1577
+ const open = r.state === 'pending';
1578
+ return { steer_id: r.steer_id, text: r.display_text, submitted_at: r.created_at, state: r.state, actions: { edit: open, cancel: open, send_now: open && r.can_redirect } };
1579
+ }
1580
+ /** TAL-424: the active stream's pending steers, oldest first (session detail `pending_steers`). */
1581
+ pendingSteers(streamId) {
1582
+ return (this.steers.get(streamId) ?? []).map((r) => this.pendingSteerView(r));
1583
+ }
1584
+ withSteerChain(streamId, fn) {
1585
+ const run = (this.steerChains.get(streamId) ?? Promise.resolve()).then(fn);
1586
+ const settled = run.catch(() => undefined).then(() => { if (this.steerChains.get(streamId) === settled)
1587
+ this.steerChains.delete(streamId); });
1588
+ this.steerChains.set(streamId, settled);
1589
+ return run;
1590
+ }
1591
+ /**
1592
+ * A withdraw or Send now on one pending steer of the session's active stream. `rewrite` asks the sidecar to change the
1593
+ * Agent's queue and says whether it did; pending text the Agent reported meanwhile is settled after, unless the queue
1594
+ * changed (the next report then reflects it).
1595
+ */
1596
+ async rewriteSteer(sessionId, steerId, fallback, rewrite) {
1597
+ let s = null;
1598
+ try {
1599
+ s = this.deps.store.get(sessionId, { metadataOnly: true });
1600
+ }
1601
+ catch {
1602
+ s = null;
1603
+ }
1604
+ const streamId = s?.active_stream_id;
1605
+ const sidecar = this.deps.sidecar();
1606
+ if (!streamId || !sidecar)
1607
+ return fallback;
1608
+ return this.withSteerChain(streamId, async () => {
1609
+ const records = this.steers.get(streamId) ?? [];
1610
+ const index = records.findIndex((r) => r.steer_id === steerId);
1611
+ if (index < 0 || records[index].state !== 'pending')
1612
+ return fallback;
1613
+ // In flight before the sidecar call, so a pending-text report racing its reply is deferred too.
1614
+ let finished = () => undefined;
1615
+ this.steerRewrites.set(streamId, new Promise((resolve) => { finished = resolve; }));
1616
+ let outcome = { result: fallback, changed: false };
1617
+ try {
1618
+ outcome = await rewrite(sidecar, streamId, records, index);
1619
+ }
1620
+ catch {
1621
+ outcome = { result: fallback, changed: false };
1622
+ }
1623
+ finally {
1624
+ this.steerRewrites.delete(streamId);
1625
+ finished();
1626
+ const deferred = this.deferredPendingSteer.get(streamId);
1627
+ this.deferredPendingSteer.delete(streamId);
1628
+ if (deferred !== undefined && !outcome.changed)
1629
+ this.settleConsumedSteers(sessionId, streamId, deferred);
1630
+ }
1631
+ return outcome.result;
1632
+ });
1633
+ }
1634
+ dropSteer(streamId, record) {
1635
+ const records = this.steers.get(streamId);
1636
+ const index = records?.indexOf(record) ?? -1;
1637
+ if (records && index >= 0)
1638
+ records.splice(index, 1);
1639
+ }
1640
+ /**
1641
+ * TAL-424 Edit and Cancel: take a pending steer back before the Agent takes it. `withdrawn: false` (and nothing
1642
+ * emitted) when it is unknown, already taken or being sent; a taken one then settles as consumed.
1643
+ */
1644
+ withdrawSteer(sessionId, steerId, reason) {
1645
+ return this.rewriteSteer(sessionId, steerId, { withdrawn: false }, async (sidecar, streamId, records, index) => {
1646
+ const record = records[index];
1647
+ const reply = await sidecar.call('chat.steer_withdraw', { stream_id: streamId, pending: records.map((r) => r.text), index });
1648
+ if (!reply.withdrawn)
1649
+ return { result: { withdrawn: false }, changed: false };
1650
+ this.dropSteer(streamId, record);
1651
+ this.emitToSession(sessionId, 'steer_withdrawn', { steer_id: record.steer_id, reason, text: record.display_text });
1652
+ return { result: { withdrawn: true, text: record.display_text }, changed: true };
1653
+ });
1654
+ }
1655
+ /**
1656
+ * TAL-424 Send now: deliver a pending steer with the Agent's redirect. A model request restarts with it (consumed at
1657
+ * once); during tools it goes last on the Agent's queue and stays `sending_now` until taken; with nothing live it
1658
+ * stays pending.
1659
+ */
1660
+ sendSteerNow(sessionId, steerId) {
1661
+ return this.rewriteSteer(sessionId, steerId, { redirected: false }, async (sidecar, streamId, records, index) => {
1662
+ const record = records[index];
1663
+ if (!record.can_redirect)
1664
+ return { result: { redirected: false }, changed: false };
1665
+ const reply = await sidecar.call('chat.steer_now', { stream_id: streamId, pending: records.map((r) => r.text), index });
1666
+ if (!reply.withdrawn)
1667
+ return { result: { redirected: false }, changed: false };
1668
+ if (!reply.redirected) {
1669
+ // The Agent's queue changed meanwhile, so it went back last: clients follow the new order.
1670
+ if (reply.requeued === 'last') {
1671
+ this.dropSteer(streamId, record);
1672
+ records.push(record);
1673
+ this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
1674
+ }
1675
+ return { result: { redirected: false }, changed: true };
1676
+ }
1677
+ this.dropSteer(streamId, record);
1678
+ if (reply.delivery === 'steer') {
1679
+ record.state = 'sending_now';
1680
+ records.push(record);
1681
+ this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
1682
+ }
1683
+ else {
1684
+ this.emitToSession(sessionId, 'steer_consumed', this.consumedSteerPayload(record, { redirected: true }));
1685
+ this.saveConsumedSteers(sessionId, streamId);
1686
+ }
1687
+ return { result: { redirected: true }, changed: true };
1688
+ });
1383
1689
  }
1384
1690
  // ── approvals / clarify ──────────────────────────────────────────────────
1385
1691
  async respondApproval(sessionId, choice, approvalId, enableYolo) {
@@ -1402,6 +1708,7 @@ export class TurnRunner {
1402
1708
  }
1403
1709
  const profileHome = this.deps.profileHome(s?.profile ?? null);
1404
1710
  let resolved = false;
1711
+ let withdrawn = false;
1405
1712
  if (entry) {
1406
1713
  if (!sidecar)
1407
1714
  return relayFailure('not running');
@@ -1413,8 +1720,13 @@ export class TurnRunner {
1413
1720
  this.deps.log(`[webui] approval relay failed for ${sessionId}: ${error.message}`);
1414
1721
  return relayFailure(str(error.message));
1415
1722
  }
1416
- if (resolved && entry)
1723
+ if (resolved)
1417
1724
  this.deps.pending.resolveApproval(sessionId, str(entry.approval_id));
1725
+ else {
1726
+ // TAL-514: a rejected answer can mean the Agent already stopped waiting on this card; drop what it no longer holds.
1727
+ await this.reconcileApprovals(sessionId, sidecar);
1728
+ withdrawn = !this.deps.pending.hasApprovalId(sessionId, str(entry.approval_id));
1729
+ }
1418
1730
  }
1419
1731
  let yoloEnabled;
1420
1732
  if (enableYolo) {
@@ -1437,10 +1749,34 @@ export class TurnRunner {
1437
1749
  yoloEnabled = true;
1438
1750
  this.deps.pending.clearApprovals(sessionId);
1439
1751
  }
1752
+ if (withdrawn)
1753
+ return { ok: true, choice, stale_cleared: true, pending_count: this.deps.pending.approvalPending(sessionId).pending_count, ...(enableYolo ? { yolo_enabled: yoloEnabled } : {}) };
1440
1754
  if (!found && !this.deps.pending.hasPendingApproval(sessionId))
1441
1755
  return { ok: true, choice, stale_cleared: true, ...(enableYolo ? { yolo_enabled: yoloEnabled } : {}) };
1442
1756
  return { ok: resolved || !approvalId, choice, ...(enableYolo && (resolved || !approvalId) ? { yolo_enabled: yoloEnabled } : {}) };
1443
1757
  }
1758
+ /** The live chat frame after approvals were withdrawn: the new queue head, or `approval_cleared` once none is left. */
1759
+ approvalHeadFrame(sessionId) {
1760
+ const { pending, pending_count } = this.deps.pending.approvalPending(sessionId);
1761
+ return pending ? ['approval', { ...pending, pending_count }] : ['approval_cleared', { session_id: sessionId, pending_count: 0 }];
1762
+ }
1763
+ /**
1764
+ * Drop mirrored approvals the Agent no longer holds; a failed read keeps them (the card stays answerable). Only
1765
+ * entries queued before the read are candidates: one the Agent parks meanwhile can be missing from its snapshot.
1766
+ */
1767
+ async reconcileApprovals(sessionId, sidecar) {
1768
+ const before = this.deps.pending.approvalRequestIds(sessionId);
1769
+ let live;
1770
+ try {
1771
+ live = new Set((await sidecar.call('approval.pending', { session_id: sessionId })).pending.map((p) => str(p.request_id)));
1772
+ }
1773
+ catch (error) {
1774
+ this.deps.log(`[webui] approval reconcile failed for ${sessionId}: ${error.message}`);
1775
+ return;
1776
+ }
1777
+ if (this.deps.pending.dropApprovals(sessionId, new Set(before.filter((id) => !live.has(id)))).length)
1778
+ this.emitToSession(sessionId, ...this.approvalHeadFrame(sessionId));
1779
+ }
1444
1780
  withYoloLock(sessionId, fn) {
1445
1781
  const run = (this.yoloChains.get(sessionId) ?? Promise.resolve()).then(fn);
1446
1782
  const settled = run.catch(() => undefined).then(() => { if (this.yoloChains.get(sessionId) === settled)
@@ -1493,6 +1829,18 @@ export class TurnRunner {
1493
1829
  return { ok: true, response };
1494
1830
  }
1495
1831
  }
1832
+ /** The turn's answer: the last non-error assistant reply with content. */
1833
+ function lastAnswer(messages) {
1834
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1835
+ const m = messages[i];
1836
+ if (m.role !== 'assistant' || m._error)
1837
+ continue;
1838
+ const text = messageText(m.content).trim();
1839
+ if (text)
1840
+ return text;
1841
+ }
1842
+ return '';
1843
+ }
1496
1844
  function previousStartedAt(s, run) {
1497
1845
  return typeof s.pending_started_at === 'number' && s.pending_started_at > 0 ? s.pending_started_at : (run?.started_at ?? Date.now() / 1000);
1498
1846
  }