@maudecode/talaria-web 1.18.0 → 1.19.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 (567) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.js +16 -16
  3. package/dist/api/auth-raw.js.map +1 -1
  4. package/dist/api/automation-raw.d.ts +2 -2
  5. package/dist/api/automation-raw.js +32 -32
  6. package/dist/api/automation-raw.js.map +1 -1
  7. package/dist/api/automation-router.d.ts +102 -22
  8. package/dist/api/automation-router.js +15 -13
  9. package/dist/api/automation-router.js.map +1 -1
  10. package/dist/api/chat-router.d.ts +1205 -34
  11. package/dist/api/chat-router.js +158 -78
  12. package/dist/api/chat-router.js.map +1 -1
  13. package/dist/api/git-router.d.ts +2 -2
  14. package/dist/api/git-router.js +29 -29
  15. package/dist/api/git-router.js.map +1 -1
  16. package/dist/api/raw-routes.d.ts +3 -3
  17. package/dist/api/raw-routes.js +63 -61
  18. package/dist/api/raw-routes.js.map +1 -1
  19. package/dist/api/router.d.ts +6 -6
  20. package/dist/api/router.js +22 -22
  21. package/dist/api/router.js.map +1 -1
  22. package/dist/api/session-visibility.d.ts +4 -4
  23. package/dist/api/session-visibility.js +26 -16
  24. package/dist/api/session-visibility.js.map +1 -1
  25. package/dist/api/sessions-router.d.ts +1928 -124
  26. package/dist/api/sessions-router.js +177 -173
  27. package/dist/api/sessions-router.js.map +1 -1
  28. package/dist/api/settings-router.d.ts +427 -9
  29. package/dist/api/settings-router.js +48 -32
  30. package/dist/api/settings-router.js.map +1 -1
  31. package/dist/api/sse-routes.d.ts +2 -2
  32. package/dist/api/sse-routes.js +63 -63
  33. package/dist/api/sse-routes.js.map +1 -1
  34. package/dist/api/tools-raw.d.ts +5 -0
  35. package/dist/api/tools-raw.js +58 -33
  36. package/dist/api/tools-raw.js.map +1 -1
  37. package/dist/api/tools-router.d.ts +663 -91
  38. package/dist/api/tools-router.js +309 -52
  39. package/dist/api/tools-router.js.map +1 -1
  40. package/dist/api/transcribe-stream.d.ts +16 -0
  41. package/dist/api/transcribe-stream.js +172 -0
  42. package/dist/api/transcribe-stream.js.map +1 -0
  43. package/dist/app.d.ts +4 -1
  44. package/dist/app.js +66 -47
  45. package/dist/app.js.map +1 -1
  46. package/dist/auth/gate.js +10 -9
  47. package/dist/auth/gate.js.map +1 -1
  48. package/dist/auth/passkeys.d.ts +15 -14
  49. package/dist/auth/passkeys.js +89 -91
  50. package/dist/auth/passkeys.js.map +1 -1
  51. package/dist/auth/store.d.ts +17 -7
  52. package/dist/auth/store.js +93 -52
  53. package/dist/auth/store.js.map +1 -1
  54. package/dist/bin/talaria-web.js +12 -81
  55. package/dist/bin/talaria-web.js.map +1 -1
  56. package/dist/cli/ctl.js +2 -1
  57. package/dist/cli/ctl.js.map +1 -1
  58. package/dist/cli/dotenv.d.ts +5 -0
  59. package/dist/cli/dotenv.js +14 -2
  60. package/dist/cli/dotenv.js.map +1 -1
  61. package/dist/cli/launcher.js +5 -5
  62. package/dist/cli/launcher.js.map +1 -1
  63. package/dist/cli/main.d.ts +1 -0
  64. package/dist/cli/main.js +85 -0
  65. package/dist/cli/main.js.map +1 -0
  66. package/dist/cli/supervise.d.ts +2 -0
  67. package/dist/cli/supervise.js +67 -4
  68. package/dist/cli/supervise.js.map +1 -1
  69. package/dist/config/agent-config.d.ts +34 -11
  70. package/dist/config/agent-config.js +113 -58
  71. package/dist/config/agent-config.js.map +1 -1
  72. package/dist/config.d.ts +2 -0
  73. package/dist/config.js +14 -5
  74. package/dist/config.js.map +1 -1
  75. package/dist/fs/atomic.d.ts +1 -0
  76. package/dist/fs/atomic.js +4 -1
  77. package/dist/fs/atomic.js.map +1 -1
  78. package/dist/fs/stat.d.ts +1 -0
  79. package/dist/fs/stat.js +11 -0
  80. package/dist/fs/stat.js.map +1 -0
  81. package/dist/http/context.d.ts +14 -9
  82. package/dist/http/context.js +29 -20
  83. package/dist/http/context.js.map +1 -1
  84. package/dist/onboarding.d.ts +2 -2
  85. package/dist/onboarding.js +10 -8
  86. package/dist/onboarding.js.map +1 -1
  87. package/dist/profiles/profiles.d.ts +10 -4
  88. package/dist/profiles/profiles.js +77 -30
  89. package/dist/profiles/profiles.js.map +1 -1
  90. package/dist/projects.d.ts +15 -6
  91. package/dist/projects.js +55 -47
  92. package/dist/projects.js.map +1 -1
  93. package/dist/providers/catalog.d.ts +78 -12
  94. package/dist/providers/catalog.js +445 -141
  95. package/dist/providers/catalog.js.map +1 -1
  96. package/dist/providers/env-file.d.ts +10 -3
  97. package/dist/providers/env-file.js +24 -75
  98. package/dist/providers/env-file.js.map +1 -1
  99. package/dist/providers/runtime-env.d.ts +4 -2
  100. package/dist/providers/runtime-env.js +9 -6
  101. package/dist/providers/runtime-env.js.map +1 -1
  102. package/dist/providers/tables.d.ts +3 -1
  103. package/dist/providers/tables.js +4 -2
  104. package/dist/providers/tables.js.map +1 -1
  105. package/dist/redact.js +18 -9
  106. package/dist/redact.js.map +1 -1
  107. package/dist/release.d.ts +7 -0
  108. package/dist/release.js +14 -0
  109. package/dist/release.js.map +1 -1
  110. package/dist/runtime.d.ts +5 -1
  111. package/dist/runtime.js +233 -90
  112. package/dist/runtime.js.map +1 -1
  113. package/dist/server.js +10 -0
  114. package/dist/server.js.map +1 -1
  115. package/dist/sessions/anchor.d.ts +9 -3
  116. package/dist/sessions/anchor.js +28 -36
  117. package/dist/sessions/anchor.js.map +1 -1
  118. package/dist/sessions/background-events.d.ts +2 -0
  119. package/dist/sessions/background-events.js +10 -0
  120. package/dist/sessions/background-events.js.map +1 -0
  121. package/dist/sessions/background-tasks.d.ts +26 -20
  122. package/dist/sessions/background-tasks.js +88 -86
  123. package/dist/sessions/background-tasks.js.map +1 -1
  124. package/dist/sessions/background-updates.d.ts +15 -8
  125. package/dist/sessions/background-updates.js +101 -25
  126. package/dist/sessions/background-updates.js.map +1 -1
  127. package/dist/sessions/cli-sessions.d.ts +15 -5
  128. package/dist/sessions/cli-sessions.js +62 -43
  129. package/dist/sessions/cli-sessions.js.map +1 -1
  130. package/dist/sessions/completions.d.ts +30 -2
  131. package/dist/sessions/completions.js +294 -53
  132. package/dist/sessions/completions.js.map +1 -1
  133. package/dist/sessions/compress.d.ts +8 -0
  134. package/dist/sessions/compress.js +2 -2
  135. package/dist/sessions/compress.js.map +1 -1
  136. package/dist/sessions/drafts.d.ts +14 -10
  137. package/dist/sessions/drafts.js +38 -43
  138. package/dist/sessions/drafts.js.map +1 -1
  139. package/dist/sessions/gateway-sessions.d.ts +46 -0
  140. package/dist/sessions/gateway-sessions.js +378 -0
  141. package/dist/sessions/gateway-sessions.js.map +1 -0
  142. package/dist/sessions/goals.d.ts +31 -0
  143. package/dist/sessions/goals.js +75 -0
  144. package/dist/sessions/goals.js.map +1 -0
  145. package/dist/sessions/journal.d.ts +5 -79
  146. package/dist/sessions/journal.js +15 -606
  147. package/dist/sessions/journal.js.map +1 -1
  148. package/dist/sessions/list.d.ts +27 -18
  149. package/dist/sessions/list.js +133 -131
  150. package/dist/sessions/list.js.map +1 -1
  151. package/dist/sessions/merge.d.ts +68 -10
  152. package/dist/sessions/merge.js +259 -51
  153. package/dist/sessions/merge.js.map +1 -1
  154. package/dist/sessions/notices.d.ts +22 -0
  155. package/dist/sessions/notices.js +72 -0
  156. package/dist/sessions/notices.js.map +1 -0
  157. package/dist/sessions/pending.d.ts +10 -4
  158. package/dist/sessions/pending.js +83 -7
  159. package/dist/sessions/pending.js.map +1 -1
  160. package/dist/sessions/prefix-index.d.ts +82 -0
  161. package/dist/sessions/prefix-index.js +232 -0
  162. package/dist/sessions/prefix-index.js.map +1 -0
  163. package/dist/sessions/recovery.d.ts +4 -7
  164. package/dist/sessions/recovery.js +97 -281
  165. package/dist/sessions/recovery.js.map +1 -1
  166. package/dist/sessions/relay.d.ts +20 -20
  167. package/dist/sessions/relay.js +205 -127
  168. package/dist/sessions/relay.js.map +1 -1
  169. package/dist/sessions/service.d.ts +162 -59
  170. package/dist/sessions/service.js +1133 -521
  171. package/dist/sessions/service.js.map +1 -1
  172. package/dist/sessions/session.d.ts +18 -2
  173. package/dist/sessions/session.js +29 -5
  174. package/dist/sessions/session.js.map +1 -1
  175. package/dist/sessions/shares.d.ts +7 -6
  176. package/dist/sessions/shares.js +22 -45
  177. package/dist/sessions/shares.js.map +1 -1
  178. package/dist/sessions/source-kind.js +1 -0
  179. package/dist/sessions/source-kind.js.map +1 -1
  180. package/dist/sessions/state-db-identity.d.ts +18 -0
  181. package/dist/sessions/state-db-identity.js +67 -0
  182. package/dist/sessions/state-db-identity.js.map +1 -0
  183. package/dist/sessions/state-db-worker.js +3 -1
  184. package/dist/sessions/state-db-worker.js.map +1 -1
  185. package/dist/sessions/state-db.d.ts +53 -8
  186. package/dist/sessions/state-db.js +576 -50
  187. package/dist/sessions/state-db.js.map +1 -1
  188. package/dist/sessions/storage/backend.d.ts +162 -0
  189. package/dist/sessions/storage/backend.js +19 -0
  190. package/dist/sessions/storage/backend.js.map +1 -0
  191. package/dist/sessions/storage/conformance.d.ts +20 -0
  192. package/dist/sessions/storage/conformance.js +237 -0
  193. package/dist/sessions/storage/conformance.js.map +1 -0
  194. package/dist/sessions/storage/document.d.ts +16 -0
  195. package/dist/sessions/storage/document.js +44 -0
  196. package/dist/sessions/storage/document.js.map +1 -0
  197. package/dist/sessions/storage/json-documents.d.ts +23 -0
  198. package/dist/sessions/storage/json-documents.js +233 -0
  199. package/dist/sessions/storage/json-documents.js.map +1 -0
  200. package/dist/sessions/storage/json-files.d.ts +12 -0
  201. package/dist/sessions/storage/json-files.js +124 -0
  202. package/dist/sessions/storage/json-files.js.map +1 -0
  203. package/dist/sessions/storage/json-installation.d.ts +3 -0
  204. package/dist/sessions/storage/json-installation.js +31 -0
  205. package/dist/sessions/storage/json-installation.js.map +1 -0
  206. package/dist/sessions/storage/json-journal.d.ts +114 -0
  207. package/dist/sessions/storage/json-journal.js +686 -0
  208. package/dist/sessions/storage/json-journal.js.map +1 -0
  209. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  210. package/dist/sessions/storage/json-recovery.js +195 -0
  211. package/dist/sessions/storage/json-recovery.js.map +1 -0
  212. package/dist/sessions/storage/json.d.ts +68 -0
  213. package/dist/sessions/storage/json.js +258 -0
  214. package/dist/sessions/storage/json.js.map +1 -0
  215. package/dist/sessions/storage/row.d.ts +6 -0
  216. package/dist/sessions/storage/row.js +49 -0
  217. package/dist/sessions/storage/row.js.map +1 -0
  218. package/dist/sessions/storage/search-text.d.ts +4 -0
  219. package/dist/sessions/storage/search-text.js +16 -0
  220. package/dist/sessions/storage/search-text.js.map +1 -0
  221. package/dist/sessions/store.d.ts +63 -73
  222. package/dist/sessions/store.js +305 -611
  223. package/dist/sessions/store.js.map +1 -1
  224. package/dist/sessions/streams.d.ts +4 -0
  225. package/dist/sessions/streams.js +9 -1
  226. package/dist/sessions/streams.js.map +1 -1
  227. package/dist/sessions/titles.js +1 -1
  228. package/dist/sessions/titles.js.map +1 -1
  229. package/dist/sessions/todo.d.ts +5 -0
  230. package/dist/sessions/todo.js +28 -21
  231. package/dist/sessions/todo.js.map +1 -1
  232. package/dist/sessions/tool-display.d.ts +5 -4
  233. package/dist/sessions/tool-display.js +22 -16
  234. package/dist/sessions/tool-display.js.map +1 -1
  235. package/dist/sessions/turn-context.d.ts +10 -1
  236. package/dist/sessions/turn-context.js +7 -4
  237. package/dist/sessions/turn-context.js.map +1 -1
  238. package/dist/sessions/turn.d.ts +87 -17
  239. package/dist/sessions/turn.js +795 -347
  240. package/dist/sessions/turn.js.map +1 -1
  241. package/dist/sessions/wakeup-pause.d.ts +8 -13
  242. package/dist/sessions/wakeup-pause.js +31 -49
  243. package/dist/sessions/wakeup-pause.js.map +1 -1
  244. package/dist/sessions/window.js +13 -12
  245. package/dist/sessions/window.js.map +1 -1
  246. package/dist/settings.d.ts +19 -7
  247. package/dist/settings.js +179 -183
  248. package/dist/settings.js.map +1 -1
  249. package/dist/sidecar/client.d.ts +17 -3
  250. package/dist/sidecar/client.js +78 -8
  251. package/dist/sidecar/client.js.map +1 -1
  252. package/dist/sidecar/discover.d.ts +2 -1
  253. package/dist/sidecar/discover.js +93 -10
  254. package/dist/sidecar/discover.js.map +1 -1
  255. package/dist/sidecar/env.d.ts +4 -0
  256. package/dist/sidecar/env.js +3 -0
  257. package/dist/sidecar/env.js.map +1 -0
  258. package/dist/sidecar/fake.js +19 -2
  259. package/dist/sidecar/fake.js.map +1 -1
  260. package/dist/test/env-file.d.ts +5 -0
  261. package/dist/test/env-file.js +88 -0
  262. package/dist/test/env-file.js.map +1 -0
  263. package/dist/test/harness.d.ts +1 -0
  264. package/dist/test/harness.js +1 -1
  265. package/dist/test/harness.js.map +1 -1
  266. package/dist/test/passkeys.d.ts +22 -0
  267. package/dist/test/passkeys.js +62 -0
  268. package/dist/test/passkeys.js.map +1 -0
  269. package/dist/test/storage.d.ts +15 -0
  270. package/dist/test/storage.js +26 -0
  271. package/dist/test/storage.js.map +1 -0
  272. package/dist/tools/commands.d.ts +6 -1
  273. package/dist/tools/commands.js +8 -2
  274. package/dist/tools/commands.js.map +1 -1
  275. package/dist/tools/connectors.d.ts +33 -0
  276. package/dist/tools/connectors.js +121 -0
  277. package/dist/tools/connectors.js.map +1 -0
  278. package/dist/tools/crons.d.ts +38 -1
  279. package/dist/tools/crons.js +82 -15
  280. package/dist/tools/crons.js.map +1 -1
  281. package/dist/tools/dashboard-plugins.d.ts +5 -5
  282. package/dist/tools/dashboard-plugins.js +14 -14
  283. package/dist/tools/dashboard-plugins.js.map +1 -1
  284. package/dist/tools/extensions.d.ts +5 -4
  285. package/dist/tools/extensions.js +213 -207
  286. package/dist/tools/extensions.js.map +1 -1
  287. package/dist/tools/gateway-restart.d.ts +62 -0
  288. package/dist/tools/gateway-restart.js +142 -0
  289. package/dist/tools/gateway-restart.js.map +1 -0
  290. package/dist/tools/health.d.ts +15 -3
  291. package/dist/tools/health.js +97 -34
  292. package/dist/tools/health.js.map +1 -1
  293. package/dist/tools/hygiene.d.ts +4 -3
  294. package/dist/tools/hygiene.js +23 -9
  295. package/dist/tools/hygiene.js.map +1 -1
  296. package/dist/tools/kanban.d.ts +4 -2
  297. package/dist/tools/kanban.js +28 -19
  298. package/dist/tools/kanban.js.map +1 -1
  299. package/dist/tools/memory-masks.d.ts +5 -0
  300. package/dist/tools/memory-masks.js +256 -0
  301. package/dist/tools/memory-masks.js.map +1 -0
  302. package/dist/tools/memory.d.ts +29 -3
  303. package/dist/tools/memory.js +69 -27
  304. package/dist/tools/memory.js.map +1 -1
  305. package/dist/tools/plugin-settings.d.ts +15 -0
  306. package/dist/tools/plugin-settings.js +90 -0
  307. package/dist/tools/plugin-settings.js.map +1 -0
  308. package/dist/tools/skills.d.ts +9 -4
  309. package/dist/tools/skills.js +44 -13
  310. package/dist/tools/skills.js.map +1 -1
  311. package/dist/tools/update-notifications.d.ts +34 -15
  312. package/dist/tools/update-notifications.js +141 -57
  313. package/dist/tools/update-notifications.js.map +1 -1
  314. package/dist/tools/update-rollback.d.ts +11 -0
  315. package/dist/tools/update-rollback.js +28 -0
  316. package/dist/tools/update-rollback.js.map +1 -0
  317. package/dist/tools/updates.d.ts +93 -33
  318. package/dist/tools/updates.js +1051 -345
  319. package/dist/tools/updates.js.map +1 -1
  320. package/dist/util.d.ts +2 -0
  321. package/dist/util.js +5 -0
  322. package/dist/util.js.map +1 -1
  323. package/dist/workspace/git.d.ts +16 -1
  324. package/dist/workspace/git.js +291 -11
  325. package/dist/workspace/git.js.map +1 -1
  326. package/dist/workspace/media.d.ts +12 -9
  327. package/dist/workspace/media.js +31 -20
  328. package/dist/workspace/media.js.map +1 -1
  329. package/dist/workspace/rollback.d.ts +2 -2
  330. package/dist/workspace/rollback.js +5 -5
  331. package/dist/workspace/rollback.js.map +1 -1
  332. package/dist/workspace/workspaces.d.ts +15 -19
  333. package/dist/workspace/workspaces.js +55 -127
  334. package/dist/workspace/workspaces.js.map +1 -1
  335. package/dist/workspace/worktrees.js +170 -32
  336. package/dist/workspace/worktrees.js.map +1 -1
  337. package/package.json +5 -3
  338. package/sidecar/agent_dependency.json +4 -4
  339. package/sidecar/agent_support.json +26 -0
  340. package/sidecar/scripts/record-fixtures.py +12 -3
  341. package/sidecar/scripts/replay_sidecar.py +8 -0
  342. package/sidecar/scripts/suite_runner.py +302 -0
  343. package/sidecar/scripts/test.sh +12 -4
  344. package/sidecar/talaria_sidecar/__init__.py +6 -1
  345. package/sidecar/talaria_sidecar/__main__.py +4 -2
  346. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  347. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  348. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  349. package/sidecar/talaria_sidecar/home.py +43 -1
  350. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  351. package/sidecar/talaria_sidecar/methods/aux.py +139 -31
  352. package/sidecar/talaria_sidecar/methods/chat.py +847 -134
  353. package/sidecar/talaria_sidecar/methods/commands.py +213 -4
  354. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  355. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  356. package/sidecar/talaria_sidecar/methods/cron.py +366 -34
  357. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  358. package/sidecar/talaria_sidecar/methods/gateway.py +194 -54
  359. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  360. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  361. package/sidecar/talaria_sidecar/methods/kanban.py +80 -27
  362. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  363. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  364. package/sidecar/talaria_sidecar/methods/process.py +160 -11
  365. package/sidecar/talaria_sidecar/methods/profiles.py +44 -6
  366. package/sidecar/talaria_sidecar/methods/providers.py +196 -26
  367. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  368. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  369. package/sidecar/talaria_sidecar/methods/state_db.py +277 -70
  370. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  371. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  372. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  373. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  374. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  375. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  376. package/sidecar/talaria_sidecar/rpc.py +45 -4
  377. package/sidecar/talaria_sidecar/runtime.py +81 -12
  378. package/sidecar/talaria_sidecar/speed.py +35 -0
  379. package/static/dist/FILES.txt +126 -127
  380. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  381. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  382. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  383. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  384. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  385. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  386. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  387. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  388. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  389. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  390. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  391. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  392. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  393. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  394. package/static/dist/assets/{_app.ext._extensionId-gfbzpoou.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  395. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  396. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  397. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  398. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  399. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  400. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  401. package/static/dist/assets/{_app.session._sessionId-fn1bhde6.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  402. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  403. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  404. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  405. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  406. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  407. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  408. package/static/dist/assets/{abnfDiagram-VCTEODGH-c8kdixke.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  409. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  410. package/static/dist/assets/{architectureDiagram-5GKGNRK7-cku06x1p.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  411. package/static/dist/assets/archive-b1j004vn.js +1 -0
  412. package/static/dist/assets/back-g74gkozu.js +1 -0
  413. package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  414. package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  415. package/static/dist/assets/channel-es95o4y8.js +1 -0
  416. package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  417. package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  418. package/static/dist/assets/{chunk-DU6HZSFF-ofajdixh.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  419. package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  420. package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  421. package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  422. package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  423. package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  424. package/static/dist/assets/{chunk-OSK3NFVY-e7gkikcw.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  425. package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  426. package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  427. package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  428. package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  429. package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  430. package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  431. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  432. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  433. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  434. package/static/dist/assets/{copy-magtofyp.js → copy-jamtd1pf.js} +1 -1
  435. package/static/dist/assets/{cynefin-OW5HDTMX-siqlulkl.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  436. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-i648tah9.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  437. package/static/dist/assets/{dagre-GXQ25YYZ-mwn6uc17.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  438. package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  439. package/static/dist/assets/{diagram-UQ7AKVKN-mekq7zgq.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  440. package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  441. package/static/dist/assets/{diagram-VX7I27RA-xe6r5qfw.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  442. package/static/dist/assets/{diagram-Z3DM3KII-nq07dg7k.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  443. package/static/dist/assets/{ebnfDiagram-PWID7BFC-c62ipm3g.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  444. package/static/dist/assets/{erDiagram-RLTQ6QDP-h39kmnzy.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  445. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  446. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  447. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  448. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  449. package/static/dist/assets/{gitGraph-4MIJSDKK-fr2i7caa.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  450. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  451. package/static/dist/assets/{highlighted-body-KPVGNVTW-jwpop9p8.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  452. package/static/dist/assets/host-hzm8n85n.js +1 -0
  453. package/static/dist/assets/index-drdcaqeq.css +2 -0
  454. package/static/dist/assets/index-jzm844he.js +11 -0
  455. package/static/dist/assets/{info-A6RAGUB7-h11x0dxo.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  456. package/static/dist/assets/{infoDiagram-27XIBGKW-dfh47dw9.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  457. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  458. package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  459. package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  460. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  461. package/static/dist/assets/{line-meizqsvn.js → line-h0ojzsvs.js} +1 -1
  462. package/static/dist/assets/link-k01ryaf9.js +1 -0
  463. package/static/dist/assets/login-bsae49ew.js +1 -0
  464. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  465. package/static/dist/assets/{mermaid-parser.core-ntne45i9.js → mermaid-parser.core-bnre27u8.js} +3 -3
  466. package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  467. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  468. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  469. package/static/dist/assets/{packet-AYTQ26CC-l6jdka8p.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  470. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  471. package/static/dist/assets/{pegDiagram-XKGWAZYB-g1er3bxj.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  472. package/static/dist/assets/{pie-WAS4IAKB-i0ylcrt9.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  473. package/static/dist/assets/{pieDiagram-E7YTZNPT-fbz1e55z.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  474. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  475. package/static/dist/assets/{radar-RG4KPBEZ-f9b70aa4.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  476. package/static/dist/assets/{railroad-74A4TZTK-lgsuoeda.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  477. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  478. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  479. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  480. package/static/dist/assets/{railroadDiagram-O6MQD6OU-eqpy9fnz.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  481. package/static/dist/assets/{refresh-cw-hlwb02qv.js → refresh-cw-mqbkv2a3.js} +1 -1
  482. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  483. package/static/dist/assets/{requirementDiagram-BXWQKSXE-ogf6puhi.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  484. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  485. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  486. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  487. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  488. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  489. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  490. package/static/dist/assets/{stateDiagram-D77RDMKH-fyoznpcs.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  491. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  492. package/static/dist/assets/{swimlanes-42K2YHIH-oefldhfy.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  493. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  494. package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  495. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  496. package/static/dist/assets/{treeView-Q6P3EWNA-f7hcci5o.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  497. package/static/dist/assets/{treemap-WGGIJYW6-nl85071a.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  498. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  499. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  500. package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-gjc70qru.js} +1 -1
  501. package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-e6ae539u.js} +1 -1
  502. package/static/dist/assets/{vennDiagram-4TSXK5OY-h5hzbk0n.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  503. package/static/dist/assets/{wardley-WFR3VGLG-jftvpm3e.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  504. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-hedh85ge.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  505. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  506. package/static/dist/index.html +1 -1
  507. package/static/dist/sw.js +1 -1
  508. package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
  509. package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
  510. package/static/dist/assets/Dialog-g355qxhp.js +0 -1
  511. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
  512. package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
  513. package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
  514. package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
  515. package/static/dist/assets/Select-kddq75qc.js +0 -1
  516. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
  517. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
  518. package/static/dist/assets/States-eq6fe29m.js +0 -1
  519. package/static/dist/assets/Toaster-pc247u9f.js +0 -1
  520. package/static/dist/assets/_app-f8n1i0m1.js +0 -1
  521. package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
  522. package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
  523. package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
  524. package/static/dist/assets/_app.logs-i69qds98.js +0 -1
  525. package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
  526. package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
  527. package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
  528. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
  529. package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
  530. package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
  531. package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
  532. package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
  533. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
  534. package/static/dist/assets/back-h7aun7ew.js +0 -1
  535. package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
  536. package/static/dist/assets/channel-b2zo73z1.js +0 -1
  537. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
  538. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
  539. package/static/dist/assets/copied-hcplv46p.js +0 -1
  540. package/static/dist/assets/endpoints-klbxod4t.js +0 -64
  541. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
  542. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
  543. package/static/dist/assets/host-i828xuo7.js +0 -1
  544. package/static/dist/assets/index-eh8zilnv.css +0 -2
  545. package/static/dist/assets/index-r648f4o1.js +0 -11
  546. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
  547. package/static/dist/assets/link-oot2yn73.js +0 -1
  548. package/static/dist/assets/login-gu28l9ou.js +0 -1
  549. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
  550. package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
  551. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
  552. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
  553. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
  554. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
  555. package/static/dist/assets/refresh-abtu93ke.js +0 -1
  556. package/static/dist/assets/saved-girnabkf.js +0 -1
  557. package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
  558. package/static/dist/assets/share._token-kl0r7ido.js +0 -2
  559. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
  560. package/static/dist/assets/sse-iwku0sjo.js +0 -1
  561. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
  562. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
  563. package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
  564. package/static/dist/assets/units-kgxzxfhi.js +0 -1
  565. package/static/dist/assets/untitled-cikfjntn.js +0 -1
  566. package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
  567. package/static/dist/assets/useLocale-o59tbiea.js +0 -1
@@ -6,39 +6,43 @@
6
6
  * `api/streaming.py::_run_agent_streaming`, `cancel_stream`).
7
7
  */
8
8
  import { randomUUID } from 'node:crypto';
9
- import { closeSync, fstatSync, readFileSync, rmSync } from 'node:fs';
9
+ import { closeSync, fstatSync, readFileSync } from 'node:fs';
10
10
  import { openAnchoredFd } from '../workspace/fs.js';
11
11
  import { isWithin, resolvePathLikePython } from '../workspace/paths.js';
12
- import { SidecarError } from '../sidecar/client.js';
12
+ import { runtimeFailureDetails, 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';
16
- import { PendingPrompts, clarifyReply } from './pending.js';
17
- import { RunJournal } from './journal.js';
18
- import { COMPRESSION_RECOVERY_ACTION_START_FOCUSED, COMPRESSION_RECOVERY_TERMINAL_STATE, CONTEXT_USAGE_FIELDS, Session, contextUsage, titleFrom } from './session.js';
15
+ import { ChatUsageSchema, ConnectorOperationSnapshotSchema } from '@maudecode/talaria-web-contracts';
16
+ import { catalogNames, connectorOperation } from '../tools/connectors.js';
17
+ import { PendingPrompts, clarifyReply, isVaultPrompt, vaultReply } from './pending.js';
18
+ import { COMPRESSION_RECOVERY_ACTION_START_FOCUSED, COMPRESSION_RECOVERY_TERMINAL_STATE, CONTEXT_USAGE_FIELDS, Session, contextUsage, forgetContextFigures, titleFrom } from './session.js';
19
19
  import { anchorMessageKey, compressionReference, markerSummary, visibleMessagesForAnchor } from './compress.js';
20
20
  import { buildActiveTurnToken, completedToolIndex, publicToolFrame, redactNestedMessageContainers, redactSessionData, redactString, withToolId } from '../redact.js';
21
- import { dict } from '../config/agent-config.js';
21
+ import { dict, findPersonality } from '../config/agent-config.js';
22
22
  import { ReasoningTitleTracker, reasoningEventPayload } from './reasoning-titles.js';
23
23
  import { messageWindowForDisplay, messagesForLimitedPayload, toolCallsForMessageWindow } from './window.js';
24
24
  import { UNSETTLED_TODO_KEY, attachTodoState, parseTodoToolResult } from './todo.js';
25
- import { withSessionWireFlags } from './list.js';
25
+ import { SessionNotices } from './notices.js';
26
+ import { SessionGoals } from './goals.js';
26
27
  import { hydrateAnchorActivityScenes, turnTerminalState, withTurnIds } from './anchor.js';
27
28
  import { persistentStateChanges, persistentStateSnapshot } from './state-saved.js';
28
- import { maxIterationsFromConfig, maxTokensFromConfig, processWakeupMaxIterations, reasoningConfigFromConfig, webuiEphemeralSystemPrompt, workspaceSystemMessage } from './turn-context.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';
30
- import { mayBecomeSilentReply, turnOrigin, withBackgroundUpdates } from './background-updates.js';
29
+ import { agentBuildParams, webuiEphemeralSystemPrompt, workspaceSystemMessage } from './turn-context.js';
30
+ import { agentSteerText, assistantReplyAddedAfterCurrentTurn, attachedFilesPrompt, buildPartialMessage, dedupeContext, checkpointTurnStart, extractToolCallsFromMessages, hasNativeImages, injectMaxIterationSummaryFallback, isDict, mergeDisplayMessagesAfterAgentResult, messageIdentity, messageText, pendingUserRow, sanitizeMessagesForApi, sessionLacksFinalAssistantAnswer, splitDisplayText, stateDbSeenId, joinReasoning, reasoningFieldsText, toolOutcome, withAttachmentObjects, newestSettledReply, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withToolCallOutcomes, withoutMaxIterationSummaryRequest, withNativeImagesRestored, withoutNativeImages, withoutToolImages } from './merge.js';
31
+ import { mayBecomeSilentReply, turnOrigin, recordBackgroundReview, recordBackgroundUpdate } from './background-updates.js';
31
32
  import { countExchanges, fallbackTitleFromExchange, firstExchangeSnippets, isGenericFallbackTitle, latestExchangeSnippets, looksInvalidGeneratedTitle, looksLikeDefaultCliTitle, sanitizeGeneratedTitle, titleLanguageMismatch, titlePrompts } from './titles.js';
32
33
  import { WORKSPACE_BUSY_MESSAGE } from '../workspace/git.js';
33
34
  import { CHECKPOINT_STORE_BUSY_MESSAGE } from '../workspace/rollback.js';
34
35
  import { str } from '../util.js';
35
36
  import { toolEditDiff } from './tool-display.js';
36
37
  import { recordWakeupPause } from './wakeup-pause.js';
38
+ import { displayName } from '../providers/catalog.js';
37
39
  export const CHAT_LOCK_WAIT_SECONDS = 2;
38
40
  const IMAGE_MODE_TIMEOUT_MS = 15_000;
39
41
  const TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT = 80;
40
42
  /** TAL-397: streamed text reports the live meter at most this often (seconds); a counter change reports at once. */
41
43
  const METERING_INTERVAL_S = 1;
44
+ /** The Agent's content frames, which end a rate-limit wait (TAL-821) and a compression span (TAL-1006). A terminal frame ends the run, its wait, and its span. */
45
+ const CONTENT_EVENTS = new Set(['token', 'reasoning', 'interim_assistant', 'tool', 'tool_complete']);
42
46
  /** How long a user's message waits for a stopped background turn to unwind before its own turn is admitted. */
43
47
  const BACKGROUND_UNWIND_WAIT_MS = 30_000;
44
48
  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.' };
@@ -65,6 +69,9 @@ export function classifyProviderError(errStr, opts = {}) {
65
69
  const lower = errStr.toLowerCase();
66
70
  if (opts.compressionExhausted)
67
71
  return COMPRESSION_EXHAUSTED;
72
+ // TAL-798: the Agent's local llama.cpp provider has no key to add; its server is off or still starting.
73
+ if (opts.condition === 'local_runtime_unavailable')
74
+ return { label: 'Local model server unavailable', type: 'local_runtime_unavailable', hint: 'The local model server is off or still starting. Turn it on in Hermes Agent, or pick another model.' };
68
75
  if (opts.condition === 'credential_missing')
69
76
  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.' };
70
77
  const cancelled = ['cancelled by user', 'canceled by user', 'user cancelled', 'user canceled', 'task cancelled', 'task canceled', 'cancellederror'].some((k) => lower.includes(k));
@@ -209,6 +216,10 @@ export class TurnRunner {
209
216
  liveUsage = new Map();
210
217
  /** TAL-397: each running turn's latest todo list, which an error or cancel keeps on the session. */
211
218
  liveTodos = new Map();
219
+ /** The current presentation, scoped to its owning stream and released on every terminal path. */
220
+ runStatuses = new Map();
221
+ /** TAL-1006: each live stream's open compression span, by its start (epoch seconds). */
222
+ compressionSpans = new Map();
212
223
  /** Streams whose run completed (`done` emitted) and only await title work; a late cancel is a no-op for these. */
213
224
  settledStreams = new Set();
214
225
  /** The last adaptive title refresh per session; a successor turn's refresh runs after it (TAL-589). */
@@ -222,14 +233,121 @@ export class TurnRunner {
222
233
  stopContexts = new Map();
223
234
  /** Each turn's worker, so a stopped background turn can be awaited before the user's turn takes the session. */
224
235
  workers = new Map();
236
+ /** TAL-899: sessions whose user pressed Stop; no automatic turn starts in them until the user's next turn is admitted. */
237
+ stopHeld = new Set();
238
+ notices = new SessionNotices(() => this.deps.now());
239
+ goals = new SessionGoals(() => this.deps.now());
225
240
  constructor(deps) {
226
241
  this.deps = deps;
227
242
  }
228
243
  get registry() { return this.deps.registry; }
229
244
  // ── admission ────────────────────────────────────────────────────────────
245
+ /** TAL-899: whether a Stop holds the session's automatic turns (session `automatic_turns_held`). */
246
+ async automaticTurnsHeld(sessionId) {
247
+ if (this.stopHeld.has(sessionId))
248
+ return true;
249
+ try {
250
+ return (await this.deps.store.get(sessionId, { metadataOnly: true })).automatic_turns_stop_held;
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ }
256
+ /** TAL-803: the Agent's active notices for the session (session `notices`). */
257
+ sessionNotices(sessionId) {
258
+ const redact = this.deps.redactEnabled();
259
+ return this.notices.list(sessionId).map((n) => ({ ...n, text: redactString(n.text, redact) }));
260
+ }
261
+ /** TAL-808: the session's standing goal (session `goal`). */
262
+ sessionGoal(sessionId) {
263
+ const goal = this.goals.view(sessionId);
264
+ if (!goal)
265
+ return null;
266
+ const redact = this.deps.redactEnabled();
267
+ return { ...goal, text: redactString(goal.text, redact), reason: goal.reason === null ? null : redactString(goal.reason, redact) };
268
+ }
269
+ /**
270
+ * TAL-808: reads the session's goal from the Agent and, when its view changed, sends `session_goal` on `put` (else the
271
+ * session's live stream) and `sessions_changed`, which reloads the session for a client between turns.
272
+ */
273
+ async refreshGoal(s, put = async (event, data) => { (await this.emitToSession(s.session_id, event, data)); }) {
274
+ if (!(await this.readGoal(s)))
275
+ return;
276
+ await put('session_goal', { session_id: s.session_id, goal: this.sessionGoal(s.session_id) });
277
+ this.deps.events.publish('goal', { profile: s.profile, sessionId: s.session_id });
278
+ }
279
+ /** TAL-808: a session's first detail after a restart reads its goal before answering, so it tells no one else. */
280
+ async loadGoal(sessionId) {
281
+ if (this.goals.has(sessionId))
282
+ return;
283
+ let s;
284
+ try {
285
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
286
+ }
287
+ catch {
288
+ return;
289
+ }
290
+ await this.readGoal(s);
291
+ }
292
+ /** The Agent's `goals.get` into the cache, and a re-read when a timed park ends; whether the view changed. A failed read keeps the last one. */
293
+ async readGoal(s) {
294
+ const sidecar = this.deps.sidecar();
295
+ if (!sidecar)
296
+ return false;
297
+ let status;
298
+ try {
299
+ // Short: a session's first load waits on it.
300
+ status = await sidecar.call('goals.get', { session_id: s.session_id, profile_home: this.deps.profileHome(s.profile) }, { timeoutMs: 3_000 });
301
+ }
302
+ catch (error) {
303
+ this.deps.log(`[webui] WARNING: goal read failed for ${s.session_id}: ${error.message}`);
304
+ return false;
305
+ }
306
+ const changed = this.goals.set(s.session_id, status);
307
+ this.goals.rereadAfterPark(s.session_id, () => { void this.refreshGoal({ session_id: s.session_id, profile: s.profile }); });
308
+ return changed;
309
+ }
310
+ /** Hygiene sweep: a deleted session's hold, notices and goal go with it (TAL-899, TAL-803, TAL-808). */
311
+ async sweep() {
312
+ const exists = async (sid) => (await this.deps.store.exists(sid));
313
+ for (const sid of this.stopHeld)
314
+ if (!(await exists(sid)))
315
+ this.stopHeld.delete(sid);
316
+ await this.notices.sweep(exists);
317
+ await this.goals.sweep(exists);
318
+ }
319
+ /**
320
+ * Admit automatic turns through the same workspace recovery and model resolution as user turns. After the user's Stop
321
+ * none starts (TAL-899): the answer is a `held` 409, and the caller keeps its work for the user's next turn.
322
+ */
323
+ async startServerTurn(session, opts) {
324
+ if ((await this.automaticTurnsHeld(session.session_id)))
325
+ return { _status: 409, error: 'automatic turns are held after Stop', held: true };
326
+ try {
327
+ const service = this.deps.service();
328
+ const workspace = (await service.turnWorkspace(session));
329
+ const [model, modelProvider, normalizedModel] = service.turnModel(session);
330
+ return (await this.admit(session, { ...opts, workspace, model, modelProvider, normalizedModel }));
331
+ }
332
+ catch (error) {
333
+ if (error instanceof HttpFailure)
334
+ return { _status: error.status, error: error.message };
335
+ throw error;
336
+ }
337
+ }
338
+ /** A user's turn; admitting it releases the hold a Stop put on automatic turns (TAL-899). */
339
+ async start(session, opts) {
340
+ const response = (await this.admit(session, opts, true));
341
+ if (response._status === undefined)
342
+ this.stopHeld.delete(session.session_id);
343
+ return response;
344
+ }
230
345
  /** Python `_start_chat_stream_for_session`: persist pending state, register the channel, launch the worker. */
231
- start(session, opts) {
346
+ async admit(session, opts, releaseStopHold = false) {
232
347
  const s = session;
348
+ const readOnly = (await this.deps.service().readOnlyReason(s));
349
+ if (readOnly)
350
+ return { error: readOnly, _status: 403 };
233
351
  if (this.deps.updateInProgress?.())
234
352
  return { error: 'Web is updating. Retry after it restarts.', _status: 503 };
235
353
  if (this.deps.profileDeleting?.(s.profile ?? null))
@@ -242,7 +360,7 @@ export class TurnRunner {
242
360
  if (locked) {
243
361
  if (this.registry.liveIds.has(locked) || this.registry.activeRuns.has(locked))
244
362
  return { error: 'session already has an active stream', active_stream_id: locked, _status: 409 };
245
- this.deps.service().clearStaleStreamState(s);
363
+ await this.deps.service().clearStaleStreamState(s);
246
364
  if (s.active_stream_id)
247
365
  return { error: 'session already has an active stream', active_stream_id: s.active_stream_id, _status: 409 };
248
366
  }
@@ -251,11 +369,21 @@ export class TurnRunner {
251
369
  if (blocking)
252
370
  return { error: 'session already has an active stream', active_stream_id: blocking, _status: 409 };
253
371
  }
372
+ const accepted = isDict(s.extra.completion_acceptances) ? s.extra.completion_acceptances : {};
373
+ if (opts.completionKeys?.length && opts.completionKeys.every((key) => typeof accepted[key] === 'string' && accepted[key])) {
374
+ return { stream_id: str(accepted[opts.completionKeys[0]]), session_id: s.session_id };
375
+ }
254
376
  const streamId = randomUUID().replace(/-/g, '');
255
377
  const wasHiddenEmpty = !s.messages.length && !s.context_messages.length && !s.hasPendingPrompt;
256
378
  const attachments = opts.attachments ?? [];
257
379
  // A background wakeup stays one in a forked session too (TAL-460).
258
380
  const source = opts.source !== 'process_wakeup' && str(s.source_tag).toLowerCase() === 'fork' ? 'fork' : (opts.source ?? 'webui');
381
+ // TAL-1005: the context figures the last turn reported hold for a turn on the same model only, and are judged before
382
+ // admission clears the post-compression estimate. A `/btw` clone is thrown away after one answer, so it is never due.
383
+ const sameRuntime = str(s.model) === str(opts.model) && str(s.model_provider) === str(opts.modelProvider);
384
+ const compactionDue = !opts.ephemeral && sameRuntime && this.deps.service().compactionDue(s);
385
+ if (!sameRuntime)
386
+ forgetContextFigures(s);
259
387
  s.workspace = opts.workspace;
260
388
  s.model = opts.model;
261
389
  s.model_provider = opts.modelProvider;
@@ -265,6 +393,12 @@ export class TurnRunner {
265
393
  s.pending_attachments = attachments;
266
394
  s.pending_started_at = this.deps.now();
267
395
  s.pending_user_source = source;
396
+ // TAL-927: reasoning one provider issued can fail on another (hermes-agent#122831), so a provider switch moves the
397
+ // boundary its carriers replay from.
398
+ if (s.reasoning_replay_provider !== str(opts.modelProvider)) {
399
+ s.reasoning_replay_provider = str(opts.modelProvider);
400
+ s.reasoning_replay_from = s.pending_started_at;
401
+ }
268
402
  if (['', 'Untitled', 'New Chat'].includes(str(s.title).trim())) {
269
403
  const provisional = titleFrom([{ role: 'user', content: opts.msg }], s.title || 'Untitled');
270
404
  if (provisional && !['', 'Untitled', 'New Chat'].includes(provisional.trim()))
@@ -272,19 +406,39 @@ export class TurnRunner {
272
406
  }
273
407
  if (this.deps.saveMode() === 'eager')
274
408
  this.checkpointUserMessage(s, opts.msg, attachments, s.pending_started_at, source, streamId);
275
- this.deps.store.save(s);
409
+ const previousStopHold = s.automatic_turns_stop_held;
410
+ if (releaseStopHold)
411
+ s.automatic_turns_stop_held = false;
412
+ const previousAcceptances = s.extra.completion_acceptances;
413
+ if (opts.completionKeys?.length)
414
+ s.extra.completion_acceptances = { ...accepted, ...Object.fromEntries(opts.completionKeys.map((key) => [key, streamId])) };
415
+ const previousUpdates = s.extra.background_updates;
416
+ if (opts.backgroundUpdate)
417
+ recordBackgroundUpdate(s, streamId, opts.backgroundUpdate);
418
+ const admission = { 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), admitting: true };
419
+ this.registry.registerActiveRun(admission);
420
+ try {
421
+ (await this.deps.store.save(s));
422
+ }
423
+ catch (error) {
424
+ this.registry.activeRuns.delete(streamId);
425
+ s.extra.background_updates = previousUpdates;
426
+ s.extra.completion_acceptances = previousAcceptances;
427
+ s.automatic_turns_stop_held = previousStopHold;
428
+ throw error;
429
+ }
276
430
  this.registry.writebackOwners.set(s.session_id, streamId);
277
431
  // A new chat appears in the list; every turn tells open clients a run started (TAL-434).
278
432
  this.deps.events.publish(wasHiddenEmpty ? 'session_new' : 'turn_started', { profile: s.profile, sessionId: s.session_id });
279
433
  try {
280
- this.deps.workspaces.setLastWorkspace(opts.workspace, s.profile);
434
+ (await this.deps.workspaces.setLastWorkspace(opts.workspace, s.profile));
281
435
  }
282
436
  catch { /* best effort */ }
283
437
  const channel = this.registry.create(streamId, s.session_id);
438
+ admission.admitting = false;
284
439
  if (opts.goalRelated)
285
440
  this.registry.goalRelated.add(streamId);
286
- 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) });
287
- const worker = this.run(s.session_id, streamId, channel, opts).catch((error) => {
441
+ const worker = this.run(s.session_id, streamId, channel, opts, compactionDue).catch((error) => {
288
442
  this.deps.log(`[webui] ERROR turn ${streamId} crashed\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
289
443
  }).finally(() => { this.workers.delete(streamId); });
290
444
  this.workers.set(streamId, worker);
@@ -307,25 +461,69 @@ export class TurnRunner {
307
461
  s.messages.push(pendingUserRow(msg, attachments, startedAt, source, turnId));
308
462
  }
309
463
  // ── worker ───────────────────────────────────────────────────────────────
310
- async run(sessionId, streamId, channel, opts) {
464
+ async run(sessionId, streamId, channel, opts, compactionDue) {
311
465
  const { deps } = this;
312
466
  let writer = null;
313
467
  try {
314
- writer = deps.journal.writer(sessionId, streamId);
468
+ writer = (await deps.journal.writer(sessionId, streamId));
315
469
  this.writers.set(streamId, writer);
316
470
  }
317
471
  catch (error) {
318
472
  deps.log(`[webui] WARNING: run journal degraded for stream ${streamId}: ${error.message}`);
319
473
  }
320
474
  const failure = { apperror: null, cancelled: false };
321
- const put = (event, data, meta = {}) => {
475
+ let waitDetail = null;
476
+ let providerPhase = null;
477
+ let statusEnded = false;
478
+ // TAL-1006: the open compression span closes on the Agent's end of it, else on the next content frame.
479
+ const closeCompression = async (createdAt) => {
480
+ if (!this.compressionSpans.has(streamId))
481
+ return;
482
+ await put('compressed', { session_id: sessionId, message: 'Compression finished' }, createdAt === undefined ? {} : { createdAt });
483
+ };
484
+ const put = async (event, data, meta = {}) => {
485
+ if (this.registry.cancelled.has(streamId) && !['cancel', 'apperror', 'steer_consumed', 'steer_withdrawn', 'run_status'].includes(event))
486
+ return;
487
+ const terminal = ['done', 'cancel', 'apperror', 'error', 'stream_end'].includes(event);
488
+ if (terminal)
489
+ statusEnded = true;
490
+ if (CONTENT_EVENTS.has(event) || terminal)
491
+ providerPhase = null;
492
+ if (event === 'rate_limit_wait')
493
+ waitDetail = data.wait;
494
+ // Both pre-turn compaction and Agent callbacks publish through this boundary.
495
+ if (event === 'compressing' && !statusEnded) {
496
+ const startedAt = this.compressionSpans.get(streamId) ?? meta.createdAt ?? deps.now();
497
+ this.compressionSpans.set(streamId, startedAt);
498
+ data = { ...data, started_at: startedAt };
499
+ }
500
+ else if (event === 'compressed')
501
+ this.compressionSpans.delete(streamId);
502
+ if (CONTENT_EVENTS.has(event)) {
503
+ if (waitDetail) {
504
+ await put('rate_limit_wait', { session_id: sessionId, wait: null }, meta.createdAt === undefined ? {} : { createdAt: meta.createdAt });
505
+ }
506
+ await closeCompression(meta.createdAt);
507
+ }
508
+ const status = event === 'run_status' ? data.status : statusEnded ? null : this.compressionSpans.has(streamId) ? { phase: 'compressing' }
509
+ : waitDetail ? { phase: 'rate_limit_wait', wait: waitDetail } : providerPhase ?? { phase: 'working' };
510
+ if (JSON.stringify(this.runStatuses.get(streamId) ?? null) !== JSON.stringify(status)) {
511
+ if (status)
512
+ this.runStatuses.set(streamId, status);
513
+ else
514
+ this.runStatuses.delete(streamId);
515
+ await put('run_status', { session_id: sessionId, status }, meta);
516
+ }
517
+ // The sidecar phase is private; only the selected status crosses the client boundary.
518
+ if (event === 'provider_phase')
519
+ return;
322
520
  if (event === 'apperror')
323
521
  failure.apperror = data;
324
522
  else if (event === 'cancel')
325
523
  failure.cancelled = true;
326
524
  for (const steerEvent of this.takeSteerEventsBefore(streamId, event))
327
- put(steerEvent[0], steerEvent[1]);
328
- if (this.registry.cancelled.has(streamId) && !['cancel', 'apperror', 'steer_consumed', 'steer_withdrawn'].includes(event))
525
+ (await put(steerEvent[0], steerEvent[1]));
526
+ if (this.registry.cancelled.has(streamId) && !['cancel', 'apperror', 'steer_consumed', 'steer_withdrawn', 'run_status'].includes(event))
329
527
  return;
330
528
  // `cancel()` already wrote the terminal row and closed the stream: the worker's unwind adds no second one.
331
529
  if (event === 'cancel' && !this.registry.streams.has(streamId)) {
@@ -338,7 +536,7 @@ export class TurnRunner {
338
536
  let eventId = null;
339
537
  if (writer) {
340
538
  try {
341
- eventId = writer.appendSseEvent(event, data, meta).event_id;
539
+ eventId = (await writer.appendSseEvent(event, data, meta)).event_id;
342
540
  }
343
541
  catch (error) {
344
542
  this.registry.degradedJournals.add(streamId);
@@ -360,25 +558,43 @@ export class TurnRunner {
360
558
  const activeRun = this.registry.activeRuns.get(streamId);
361
559
  let s;
362
560
  try {
363
- s = deps.store.get(sessionId);
561
+ s = (await deps.store.get(sessionId));
364
562
  }
365
563
  catch {
366
- put('apperror', { type: 'error', terminal_state: 'error', message: 'Session not found', session_id: sessionId });
367
- this.teardown(sessionId, streamId);
564
+ await put('apperror', { type: 'error', terminal_state: 'error', message: 'Session not found', session_id: sessionId });
565
+ await this.teardown(sessionId, streamId);
368
566
  return;
369
567
  }
370
568
  const msgText = opts.msg;
371
- const previousMessages = structuredClone(s.messages);
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 });
569
+ const history = () => {
570
+ // TAL-493: one state.db read builds the history and marks where this turn's own rows begin.
571
+ const startRead = deps.service().beginStateDbTurn(s);
572
+ const previousMessages = structuredClone(s.messages);
573
+ // TAL-578: an eager save already put this turn's prompt in the transcript; `user_message` carries it, so the history
574
+ // leaves it out.
575
+ const previousContext = structuredClone(deps.service().modelContext(s, startRead.rows)).filter((m) => m._turn_id !== streamId);
576
+ // Python `_sanitize_messages_for_agent`: the model never sees display-only rows or a replayed cancelled prompt, and
577
+ // the Agent gets back the exact bytes it sent (TAL-541).
578
+ const apiHistory = sanitizeMessagesForApi(previousContext, { preserveApiContent: true, issuedReasoningSince: s.reasoning_replay_from });
579
+ this.stopContexts.set(streamId, { previousContext, historyLength: apiHistory.length, prompt: msgText, msgText, checkpointed: false, stateDbStartId: startRead.ok ? stateDbSeenId(startRead.rows) ?? 0 : null, stateDbStartGeneration: startRead.generation });
580
+ return { previousMessages, previousContext, apiHistory };
581
+ };
380
582
  // Before the first await: a Stop can land at any point after admission.
381
- this.stopContexts.set(streamId, { previousContext, historyLength: apiHistory.length, prompt: msgText, msgText, checkpointed: false, stateDbStartId: startRead.ok ? stateDbSeenId(startRead.rows) ?? 0 : null });
583
+ let { previousMessages, previousContext, apiHistory } = history();
584
+ const controller = new AbortController();
585
+ this.abortControllers.set(streamId, controller);
586
+ // TAL-1005: an over-threshold session compacts in its joinable compression job before the turn reads its history, so
587
+ // a Stop ends only this wait and a resend joins the same job. The turn then reads the compacted session afresh.
588
+ const compacted = await deps.service().ensureCompacted(sessionId, { turn: streamId, signal: controller.signal, due: compactionDue, onWait: async () => { (await put('compressing', { session_id: sessionId, message: 'Compressing context' })); } });
589
+ if (compacted) {
590
+ try {
591
+ s = (await deps.store.get(sessionId));
592
+ }
593
+ catch { /* the cancel or error path below reports a deleted session */ }
594
+ ;
595
+ ({ previousMessages, previousContext, apiHistory } = history());
596
+ await put('compressed', this.compressedFrame(s));
597
+ }
382
598
  const activeTurnToken = buildActiveTurnToken(streamId, s.pending_started_at);
383
599
  const sidecar = deps.sidecar();
384
600
  const partialText = this.registry.partialText.get(streamId) ?? [];
@@ -395,21 +611,19 @@ export class TurnRunner {
395
611
  // TAL-397: the live meter. Counters start from the session's and follow the Agent's; the rate counts text deltas.
396
612
  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
613
  const meter = { deltas: 0, first: 0, last: 0, sentAt: null };
398
- const putMetering = (force) => {
399
- const now = deps.now();
614
+ const putMetering = async (force, now = deps.now()) => {
400
615
  if (!force && meter.sentAt !== null && now - meter.sentAt < METERING_INTERVAL_S)
401
616
  return;
402
617
  meter.sentAt = now;
403
618
  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 });
619
+ await put('metering', { session_id: sessionId, usage: meteringUsage(liveUsage), tps, tps_available: tps !== null, estimated: false });
405
620
  };
406
- const meterDelta = () => {
407
- const now = deps.now();
621
+ const meterDelta = async (now = deps.now()) => {
408
622
  if (!meter.deltas)
409
623
  meter.first = now;
410
624
  meter.last = now;
411
625
  meter.deltas += 1;
412
- putMetering(false);
626
+ await putMetering(false, now);
413
627
  };
414
628
  // TAL-460: a background turn's text is held back while it could still be a silence marker, so one never flashes by.
415
629
  // ponytail: checks the turn's whole text, so a marker after earlier prose streams until the settled row hides it;
@@ -418,13 +632,11 @@ export class TurnRunner {
418
632
  let heldText = '';
419
633
  const titles = new ReasoningTitleTracker();
420
634
  let capturedTerminalError = null;
421
- const controller = new AbortController();
422
- this.abortControllers.set(streamId, controller);
423
635
  const attachments = opts.attachments ?? [];
424
636
  // TAL-545: one image mode decides both this turn's upload and the replayed history, which a text-mode turn sends
425
637
  // without native images; only a turn with an image on either side asks for it.
426
638
  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);
639
+ const userMessage = this.buildUserMessage(opts.prompt ?? msgText, attachments, opts.workspace, sessionId, imageMode);
428
640
  const conversationHistory = imageMode === 'text' ? withoutNativeImages(apiHistory) : apiHistory;
429
641
  // The prompt the Agent actually gets, native image parts included.
430
642
  const stop = this.stopContexts.get(streamId);
@@ -434,6 +646,10 @@ export class TurnRunner {
434
646
  activeRun.phase = 'running';
435
647
  const settledAt = { value: false };
436
648
  let succeeded = false;
649
+ let ownershipRefused = false;
650
+ let agentDispatched = false;
651
+ let streamWrites = Promise.resolve();
652
+ let streamWriteError;
437
653
  // TAL-424: steers an errored turn ended without taking, sent as one follow-up turn once this one is torn down.
438
654
  let followUp = [];
439
655
  try {
@@ -443,6 +659,11 @@ export class TurnRunner {
443
659
  // system message carries the frozen session workspace, the WebUI guidance rides as the ephemeral prompt with this
444
660
  // turn's workspace (TAL-710: never a tag on the user's message, which the Agent can persist from memory).
445
661
  const cfg = (await deps.profileConfig?.(s.profile ?? null)) ?? {};
662
+ const personality = str(s.personality);
663
+ // A selected personality never silently drops: an unknown catalog fails the turn (an absent name is the Agent's no-overlay).
664
+ const personalities = personality && deps.personalities
665
+ ? await deps.personalities(s.profile ?? null).catch((error) => { throw new SidecarError(`The session's personality "${personality}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`, { condition: 'sidecar_error' }); })
666
+ : [];
446
667
  // The sidecar's per-session YOLO state does not survive its restarts: push the local flag (either way) before
447
668
  // the turn so a stale sidecar-side enable cannot auto-approve a session the UI reports as guarded.
448
669
  await this.withYoloLock(sessionId, async () => { try {
@@ -452,153 +673,226 @@ export class TurnRunner {
452
673
  // Last yield before `chat.start`: a cancel that landed during the attachment, config or YOLO awaits must not
453
674
  // start an Agent turn (the client also refuses an already-aborted signal outright).
454
675
  if (this.registry.cancelled.has(streamId)) {
455
- this.finalizeCancelled(s, streamId, opts.ephemeral);
456
- put('cancel', this.cancelFrame(sessionId));
676
+ await this.finalizeCancelled(s, streamId, opts.ephemeral);
677
+ await put('cancel', (await this.cancelFrame(sessionId)));
457
678
  return;
458
679
  }
459
680
  const turnContext = {
460
681
  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' }),
462
- max_iterations: processWakeupMaxIterations(maxIterationsFromConfig(cfg), opts.source ?? 'webui', deps.env ?? {}),
463
- max_tokens: maxTokensFromConfig(cfg),
464
- reasoning_config: reasoningConfigFromConfig(cfg, opts.modelProvider ?? s.model_provider ?? null),
682
+ ephemeral_system_prompt: webuiEphemeralSystemPrompt({ config: cfg, personalityPrompt: findPersonality(personalities, personality)?.prompt ?? null, sessionId, profile: s.profile ?? null, workspace: opts.workspace, hermesHome: deps.profileHome(s.profile), homeDisplay: deps.homeDisplay?.() ?? '~/.hermes', background: turnOrigin(opts.source) === 'background' }),
683
+ ...agentBuildParams(cfg, opts.source ?? 'webui', opts.modelProvider ?? s.model_provider ?? null, deps.env ?? {}),
465
684
  };
466
685
  // Python `_public_prefill_context_status`: the session-recall prefill hook was dropped with TAL-245, so the
467
686
  // context frame always reports the not-configured shape.
468
687
  const persistentBefore = persistentStateSnapshot(deps.profileHome(s.profile));
469
- put('context_status', { session_id: sessionId, prefill: { status: 'not_configured', source: 'none', label: '', message_count: 0 } });
688
+ await put('context_status', { session_id: sessionId, prefill: { status: 'not_configured', source: 'none', label: '', message_count: 0 } });
689
+ // Setup awaits can outlive a gateway adoption; no await separates this read from dispatch.
690
+ deps.service().assertWritable(s);
691
+ agentDispatched = true;
470
692
  const result = await sidecar.call('chat.start', {
471
- profile_home: deps.profileHome(s.profile), session_id: sessionId, stream_id: streamId, workspace: opts.workspace, model: opts.model ?? '', model_provider: opts.modelProvider,
693
+ profile_home: deps.profileHome(s.profile), session_id: sessionId, stream_id: streamId, ...this.agentWorkspace(s, opts.workspace), model: opts.model ?? '', model_provider: opts.modelProvider,
472
694
  user_message: userMessage, ...turnContext, conversation_history: conversationHistory, enabled_toolsets: deps.toolsetsFor(s), ...(msgText ? { persist_user_message: msgText } : {}), ...(opts.ephemeral ? { ephemeral: true } : {}),
695
+ ...(opts.voiceTurn ? { voice_turn: true } : {}),
696
+ ...(opts.connectCard ? { connect_card: true } : {}),
697
+ ...(opts.skillSecret ? { skill_secret: true } : {}),
473
698
  }, {
474
699
  signal: controller.signal,
475
700
  timeoutMs: 0,
476
701
  onStream: (frame) => {
477
- const data = isDict(frame.data) ? frame.data : {};
478
- switch (frame.event) {
479
- case 'token': {
480
- tokenSent = true;
481
- firstTokenAt ??= deps.now();
482
- // Visible text closes the reasoning segment: a stable title snapshot (or a clear) goes out first.
483
- const stable = titles.stableSnapshot(reasoningText.join(''));
484
- if (stable !== null)
485
- put('reasoning', { text: '', titles: stable });
486
- partialText.push(str(data.text));
487
- if (holdSilence && mayBecomeSilentReply(partialText.join(''))) {
488
- heldText += str(data.text);
489
- meterDelta();
702
+ const receivedAt = deps.now();
703
+ streamWrites = streamWrites.then(async () => {
704
+ const publish = put;
705
+ const emitFrame = (event, data, meta = {}) => publish(event, data, { ...meta, createdAt: receivedAt });
706
+ const data = isDict(frame.data) ? frame.data : {};
707
+ switch (frame.event) {
708
+ case 'token': {
709
+ tokenSent = true;
710
+ firstTokenAt ??= receivedAt;
711
+ // Visible text closes the reasoning segment: a stable title snapshot (or a clear) goes out first.
712
+ const stable = titles.stableSnapshot(reasoningText.join(''));
713
+ if (stable !== null)
714
+ (await emitFrame('reasoning', { text: '', titles: stable }));
715
+ partialText.push(str(data.text));
716
+ if (holdSilence && mayBecomeSilentReply(partialText.join(''))) {
717
+ heldText += str(data.text);
718
+ (await meterDelta(receivedAt));
719
+ return;
720
+ }
721
+ await emitFrame('token', { text: heldText + str(data.text) });
722
+ heldText = '';
723
+ await meterDelta(receivedAt);
490
724
  return;
491
725
  }
492
- put('token', { text: heldText + str(data.text) });
493
- heldText = '';
494
- meterDelta();
495
- return;
496
- }
497
- case 'reasoning': {
498
- if (partialText.length && !reasoningText.length)
499
- titles.resetSegment();
500
- reasoningText.push(str(data.text));
501
- // Python `reasoning_event_payload`: bold or first-line titles ride on the delta they complete.
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);
726
+ case 'reasoning': {
727
+ if (partialText.length && !reasoningText.length)
728
+ titles.resetSegment();
729
+ reasoningText.push(str(data.text));
730
+ // Python `reasoning_event_payload`: bold or first-line titles ride on the delta they complete.
731
+ await emitFrame('reasoning', titles.prepare(reasoningEventPayload(str(data.text), reasoningText.join(''))));
732
+ await meterDelta(receivedAt);
733
+ return;
511
734
  }
512
- putMetering(true);
513
- return;
514
- }
515
- case 'steer_pending':
516
- // TAL-424: while a withdraw or Send now rewrites the Agent's queue its pending text is ambiguous; it settles after.
517
- if (this.steerRewrites.has(streamId)) {
518
- this.deferredPendingSteer.set(streamId, str(data.text));
735
+ case 'usage': {
736
+ const usage = ChatUsageSchema.safeParse(data);
737
+ if (usage.success) {
738
+ applyAgentUsage(liveUsage, usage.data);
739
+ this.liveUsage.set(streamId, liveUsage);
740
+ }
741
+ await putMetering(true, receivedAt);
519
742
  return;
520
743
  }
521
- this.settleConsumedSteers(sessionId, streamId, str(data.text));
522
- return;
523
- case 'tool': {
524
- const call = { name: data.name, args: data.args ?? {}, tid: str(data.tid), done: false };
525
- liveToolCalls.push(call);
526
- toolIds.set(call, call.tid || mintToolId());
527
- toolStartedAt.set(call, deps.now());
528
- const redacted = deps.redactEnabled();
529
- put('tool', publicToolFrame(withToolId(data, toolIds.get(call)), redacted), { redacted });
530
- return;
531
- }
532
- case 'tool_complete': {
533
- // TAL-313: the server decides failure from the sidecar's raw result, which never leaves the server; TAL-315: so
534
- // are its display sections, which keep the stderr and exit code the flat preview drops.
535
- const { raw_result: rawResult, todo_result: todoResult, result_diff: resultDiff, ...complete } = data;
536
- const outcome = toolOutcome(rawResult);
537
- complete.is_error = outcome.is_error;
538
- if (rawResult !== undefined)
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;
544
- const tc = liveToolCalls[completedToolIndex(liveToolCalls, str(data.tid), data.name)];
545
- if (tc) {
546
- const startedAt = toolStartedAt.get(tc);
547
- if (startedAt !== undefined)
548
- complete.duration = Math.round(Math.max(0, deps.now() - startedAt) * 1000) / 1000;
549
- // TAL-315: the decided view too, so a failed or cancelled turn's snapshot keeps the sections shown live.
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 } : {}) });
744
+ case 'steer_pending': {
745
+ // TAL-424: while a withdraw or Send now rewrites the Agent's queue its pending text is ambiguous; it settles after.
746
+ if (this.steerRewrites.has(streamId)) {
747
+ this.deferredPendingSteer.set(streamId, str(data.text));
748
+ return;
749
+ }
750
+ // Capture the causal position before the next frame; only persistence is asynchronous.
751
+ const saving = this.settleConsumedSteers(sessionId, streamId, str(data.text));
752
+ await saving;
753
+ return;
551
754
  }
552
- const id = (tc && toolIds.get(tc)) || str(data.tid) || mintToolId();
553
- this.lastCompletedTool.set(streamId, id);
554
- const redacted = deps.redactEnabled();
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));
755
+ case 'tool': {
756
+ const call = { name: data.name, args: data.args ?? {}, tid: str(data.tid), done: false };
757
+ liveToolCalls.push(call);
758
+ toolIds.set(call, call.tid || mintToolId());
759
+ toolStartedAt.set(call, receivedAt);
760
+ const redacted = deps.redactEnabled();
761
+ await emitFrame('tool', publicToolFrame(withToolId(data, toolIds.get(call)), redacted), { redacted });
762
+ return;
562
763
  }
563
- return;
564
- }
565
- // Python: the live chat frame carries the queue head plus depth, not the entry that just arrived.
566
- case 'approval': {
567
- deps.pending.submitApproval(sessionId, { ...data, session_id: sessionId });
568
- const { pending, pending_count } = deps.pending.approvalPending(sessionId);
569
- put('approval', { ...(pending ?? data), pending_count });
570
- return;
571
- }
572
- // TAL-514: the Agent stopped waiting on an approval (answer, timeout, or interrupt); withdraw its copy.
573
- case 'approval_resolved': {
574
- if (!str(data.approval_id))
764
+ case 'tool_complete': {
765
+ // TAL-313: the server decides failure from the sidecar's raw result, which never leaves the server; TAL-315: so
766
+ // are its display sections, which keep the stderr and exit code the flat preview drops.
767
+ const { raw_result: rawResult, todo_result: todoResult, result_diff: resultDiff, display_metadata: displayMetadata, ...complete } = data;
768
+ const outcome = toolOutcome(rawResult);
769
+ complete.is_error = outcome.is_error;
770
+ if (rawResult !== undefined)
771
+ complete.result_view = outcome.result_view;
772
+ // TAL-448: a file edit's change, counted over the whole diff the sidecar forwards; redacted with the frame. TAL-909:
773
+ // else the diff in the metadata the Agent persisted on the result row, which a reload reads the same way.
774
+ const editDiff = toolEditDiff(data.name, { diff: resultDiff }, displayMetadata);
775
+ if (editDiff)
776
+ complete.edit_diff = editDiff;
777
+ const tc = liveToolCalls[completedToolIndex(liveToolCalls, str(data.tid), data.name)];
778
+ if (tc) {
779
+ const startedAt = toolStartedAt.get(tc);
780
+ if (startedAt !== undefined)
781
+ complete.duration = Math.round(Math.max(0, receivedAt - startedAt) * 1000) / 1000;
782
+ // TAL-315: the decided view too, so a failed or cancelled turn's snapshot keeps the sections shown live.
783
+ 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 } : {}) });
784
+ }
785
+ const id = (tc && toolIds.get(tc)) || str(data.tid) || mintToolId();
786
+ this.lastCompletedTool.set(streamId, id);
787
+ const redacted = deps.redactEnabled();
788
+ await emitFrame('tool_complete', publicToolFrame(withToolId(complete, id), redacted), { redacted });
789
+ // TAL-397: the todo tool's result is the session's new list; the settled session derives the same one.
790
+ // TAL-889: the sidecar attaches `todo_result` only to the Agent's todo tool, so no name check here.
791
+ const todos = parseTodoToolResult(todoResult);
792
+ if (todos) {
793
+ todos.ts = receivedAt;
794
+ this.liveTodos.set(streamId, todos);
795
+ await emitFrame('todo_state', redactNestedMessageContainers({ ...todos, session_id: sessionId, stream_id: streamId, source: 'live' }, redacted));
796
+ }
575
797
  return;
576
- deps.pending.resolveApproval(sessionId, str(data.approval_id));
577
- put(...this.approvalHeadFrame(sessionId));
578
- return;
579
- }
580
- case 'clarify': {
581
- deps.pending.submitClarify(sessionId, { ...data, session_id: sessionId, kind: 'clarify' });
582
- const head = deps.pending.clarifyHeadFrame(sessionId);
583
- if (head)
584
- put('clarify', head);
585
- return;
586
- }
587
- case 'clarify_resolved': {
588
- const { head } = deps.pending.resolveClarify(sessionId, str(data.clarify_id));
589
- if (head)
590
- put('clarify', head);
591
- return;
798
+ }
799
+ // Python: the live chat frame carries the queue head plus depth, not the entry that just arrived.
800
+ case 'approval': {
801
+ deps.pending.submitApproval(sessionId, { ...data, session_id: sessionId });
802
+ const { pending, pending_count } = deps.pending.approvalPending(sessionId);
803
+ await emitFrame('approval', { ...(pending ?? data), pending_count });
804
+ return;
805
+ }
806
+ // TAL-514: the Agent stopped waiting on an approval (answer, timeout, or interrupt); withdraw its copy.
807
+ case 'approval_resolved': {
808
+ if (!str(data.approval_id))
809
+ return;
810
+ deps.pending.resolveApproval(sessionId, str(data.approval_id));
811
+ await emitFrame(...this.approvalHeadFrame(sessionId));
812
+ return;
813
+ }
814
+ case 'clarify': {
815
+ deps.pending.submitClarify(sessionId, { ...data, session_id: sessionId });
816
+ const head = deps.pending.clarifyHeadFrame(sessionId);
817
+ if (head)
818
+ (await emitFrame('clarify', head));
819
+ return;
820
+ }
821
+ case 'clarify_resolved': {
822
+ const { head } = deps.pending.resolveClarify(sessionId, str(data.clarify_id));
823
+ if (head)
824
+ (await emitFrame('clarify', head));
825
+ return;
826
+ }
827
+ case 'provider_phase': {
828
+ const phase = data.phase;
829
+ if (phase !== null && phase?.phase !== 'prompt_reading')
830
+ return;
831
+ const percent = phase?.progress_percent;
832
+ providerPhase = phase === null ? null : { phase: 'prompt_reading', progress_percent: typeof percent === 'number' && Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null };
833
+ await put('provider_phase', {}, { createdAt: receivedAt });
834
+ return;
835
+ }
836
+ // TAL-821: the Agent sits out a provider's rate limit (`until`, epoch seconds: its reset, else the Agent's retry).
837
+ case 'rate_limit_wait': {
838
+ const until = typeof data.until === 'number' ? data.until : null;
839
+ if (until === null && !waitDetail)
840
+ return;
841
+ const provider = str(data.provider) || null;
842
+ await emitFrame('rate_limit_wait', { session_id: sessionId, wait: until === null ? null : { provider, provider_label: provider && (deps.providerLabel?.(s.profile ?? null, provider) ?? displayName(provider)), until, until_kind: data.until_kind === 'reset' ? 'reset' : 'retry' } });
843
+ return;
844
+ }
845
+ // TAL-818: a `manage_connections` card opened; clients poll and answer the operation by its `op_id`.
846
+ case 'connection': {
847
+ const snapshot = ConnectorOperationSnapshotSchema.safeParse(data);
848
+ if (snapshot.success)
849
+ (await emitFrame('connect_card', connectorOperation(snapshot.data, catalogNames(deps.profileHome(s.profile)))));
850
+ return;
851
+ }
852
+ case 'compressing':
853
+ await emitFrame('compressing', data);
854
+ return;
855
+ case 'compression_finished':
856
+ await closeCompression(receivedAt);
857
+ return;
858
+ case 'status':
859
+ if (data.kind === 'terminal_error' && !capturedTerminalError)
860
+ capturedTerminalError = str(data.message);
861
+ return;
862
+ // TAL-803: the session's notice list changed; every client replaces its copy.
863
+ case 'notice': {
864
+ const notice = this.notices.add(sessionId, data);
865
+ if (!notice)
866
+ return;
867
+ await emitFrame('notices', { session_id: sessionId, notices: this.sessionNotices(sessionId) });
868
+ // ponytail: the expiry reaches a client only while a turn streams; an idle tab drops it on its next session load.
869
+ if (notice.expires_at !== null) {
870
+ const expiresAt = notice.expires_at;
871
+ const emitAfterExpiry = async () => {
872
+ // Timers can run before the wall clock expires the notice. Let SessionNotices decide, then retry.
873
+ if (this.notices.list(sessionId).includes(notice))
874
+ setTimeout(() => { void emitAfterExpiry().catch((error) => { deps.log(`[webui] notice expiry failed: ${String(error)}`); }); }, Math.max(1, Math.ceil((expiresAt - deps.now()) * 1000))).unref();
875
+ else
876
+ (await this.emitToSession(sessionId, 'notices', { session_id: sessionId, notices: this.sessionNotices(sessionId) }));
877
+ };
878
+ await emitAfterExpiry();
879
+ }
880
+ return;
881
+ }
882
+ case 'notice_clear':
883
+ case 'notices_reset':
884
+ if (frame.event === 'notice_clear' ? this.notices.clear(sessionId, str(data.key)) : this.notices.reset(sessionId))
885
+ (await emitFrame('notices', { session_id: sessionId, notices: this.sessionNotices(sessionId) }));
886
+ return;
887
+ default:
888
+ await emitFrame(frame.event, data);
592
889
  }
593
- case 'status':
594
- if (data.kind === 'terminal_error' && !capturedTerminalError)
595
- capturedTerminalError = str(data.message);
596
- return;
597
- default:
598
- put(frame.event, data);
599
- }
890
+ }).catch((error) => { streamWriteError = error instanceof Error ? error : new Error(String(error)); });
600
891
  },
601
892
  });
893
+ await streamWrites;
894
+ if (streamWriteError !== undefined)
895
+ throw streamWriteError;
602
896
  settledAt.value = true;
603
897
  // TAL-545: every transcript the Agent hands back settles with the history's images, not the request's projection.
604
898
  const withImages = (rows) => withNativeImagesRestored(rows, conversationHistory, apiHistory);
@@ -611,14 +905,14 @@ export class TurnRunner {
611
905
  const interrupted = await this.interrupts.get(streamId);
612
906
  applyAgentUsage(liveUsage, result.usage);
613
907
  this.liveUsage.set(streamId, liveUsage);
614
- this.finalizeCancelled(s, streamId, opts.ephemeral, interrupted?.checkpoint ? withImages(interrupted.checkpoint) : result.messages);
615
- put('cancel', this.cancelFrame(sessionId));
908
+ await this.finalizeCancelled(s, streamId, opts.ephemeral, interrupted?.checkpoint ? withImages(interrupted.checkpoint) : result.messages);
909
+ await put('cancel', (await this.cancelFrame(sessionId)));
616
910
  return;
617
911
  }
618
912
  // Writeback only while this stream still owns the session (Python `_stream_writeback_is_current`).
619
913
  let current;
620
914
  try {
621
- current = deps.store.get(sessionId);
915
+ current = (await deps.store.get(sessionId));
622
916
  }
623
917
  catch {
624
918
  current = s;
@@ -654,15 +948,15 @@ export class TurnRunner {
654
948
  await this.steerRewrites.get(streamId);
655
949
  const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, str(result.pending_steer), 'followup');
656
950
  followUp = leftovers;
657
- this.persistError(s, streamId, classification.label, payload, activeTurnToken, resultMessages, result.provider);
951
+ await this.persistError(s, streamId, classification.label, payload, activeTurnToken, resultMessages, result.provider);
658
952
  // TAL-512: a btw error carries no session: its journaled frame must not keep a copy of the parent conversation.
659
953
  if (!opts.ephemeral)
660
- payload.session = this.publicTerminalSession(s);
954
+ payload.session = (await this.publicTerminalSession(s));
661
955
  payload.session_id = s.session_id;
662
956
  payload.old_session_id = sessionId;
663
957
  for (const [event, data] of steerEvents)
664
- put(event, data);
665
- put('apperror', payload);
958
+ (await put(event, data));
959
+ await put('apperror', payload);
666
960
  return;
667
961
  }
668
962
  // TAL-512: a btw turn passes the failure check above first, so its last assistant row is this turn's reply.
@@ -676,11 +970,11 @@ export class TurnRunner {
676
970
  }
677
971
  }
678
972
  succeeded = true;
679
- opts.onDone?.(answer);
973
+ await opts.onDone?.(answer);
680
974
  // Python `_ephemeral_session_payload`: only role and content leave the server for a btw turn.
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' });
975
+ await 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' });
682
976
  try {
683
- rmSync(deps.store.pathFor(sessionId), { force: true });
977
+ await deps.store.delete(sessionId, { tombstone: false });
684
978
  }
685
979
  catch { /* ignore */ }
686
980
  deps.store.sessions.delete(sessionId);
@@ -707,8 +1001,9 @@ export class TurnRunner {
707
1001
  const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, result.pending_steer, 'followup');
708
1002
  s.messages = withoutToolImages(mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId, attachments: opts.attachments ?? [] }));
709
1003
  s.context_messages = dedupeContext(context);
1004
+ // The settled turn's own figures: a compaction it waited on (TAL-1005) installed an estimate its prompt now supersedes.
1005
+ s.post_compression_context_tokens_estimate = result.compressed ? estimate : null;
710
1006
  if (result.compressed) {
711
- s.post_compression_context_tokens_estimate = estimate;
712
1007
  // TAL-540: the anchor is the last visible row before this turn (never its eager checkpoint); the merged transcript
713
1008
  // holds no compaction marker, so the summary is the model context's newest one.
714
1009
  const before = visibleMessagesForAnchor(previousMessages.filter((m) => m._turn_id !== streamId));
@@ -733,20 +1028,11 @@ export class TurnRunner {
733
1028
  s.threshold_tokens = result.context.threshold_tokens;
734
1029
  if (typeof result.context.last_prompt_tokens === 'number')
735
1030
  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
- }
1031
+ s.next_turn_threshold_tokens = result.context.next_turn_threshold_tokens ?? null;
1032
+ if (result.compressed)
1033
+ (await put('compressed', this.compressedFrame(s)));
744
1034
  s.tool_calls = extractToolCallsFromMessages(s.messages, liveToolCalls, s.tool_calls);
745
- s.active_stream_id = null;
746
- s.pending_user_message = null;
747
- s.pending_attachments = [];
748
- s.pending_started_at = null;
749
- s.pending_user_source = null;
1035
+ this.clearPendingTurn(s);
750
1036
  // TAL-576: a turn that ran means the provider has usable credentials again.
751
1037
  s.process_wakeup_pause = null;
752
1038
  const attachments = opts.attachments ?? [];
@@ -801,8 +1087,9 @@ export class TurnRunner {
801
1087
  this.persistConsumedSteers(s, streamId, previousStartedAt(s, activeRun), now);
802
1088
  // 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
1089
  // (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 });
805
- deps.store.save(s);
1090
+ this.settleStateDb(s, streamId, agentRows, { previousContext });
1091
+ recordBackgroundReview(s, streamId);
1092
+ await deps.store.save(s);
806
1093
  deps.pending.clearApprovals(sessionId);
807
1094
  deps.pending.clearClarifies(sessionId);
808
1095
  deps.events.publish('session_done', { profile: s.profile, sessionId: s.session_id });
@@ -810,9 +1097,9 @@ export class TurnRunner {
810
1097
  try {
811
1098
  const changes = persistentStateChanges(persistentBefore, persistentStateSnapshot(deps.profileHome(s.profile)));
812
1099
  if (changes.memory_saved)
813
- put('state_saved', { session_id: sessionId, kind: 'memory', action: 'saved' });
1100
+ (await put('state_saved', { session_id: sessionId, kind: 'memory', action: 'saved' }));
814
1101
  for (const change of changes.skills)
815
- put('state_saved', { session_id: sessionId, kind: 'skill', action: change.action, name: change.name });
1102
+ (await put('state_saved', { session_id: sessionId, kind: 'skill', action: change.action, name: change.name }));
816
1103
  }
817
1104
  catch (error) {
818
1105
  deps.log(`[webui] persistent state change detection failed for ${sessionId}: ${error.message}`);
@@ -828,7 +1115,7 @@ export class TurnRunner {
828
1115
  cache_hit_percent: cacheHit(cacheReadTokens, inputTokens), turn_cache_hit_percent: cacheHit(turnCacheRead, turnInput), duration_seconds: Math.round(duration * 1000) / 1000,
829
1116
  context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
830
1117
  };
831
- const doneSession = this.publicTerminalSession(s);
1118
+ const doneSession = (await this.publicTerminalSession(s));
832
1119
  // TAL-299: the ring's figures match the terminal session's (and a detail reload's).
833
1120
  for (const key of CONTEXT_USAGE_FIELDS)
834
1121
  doneUsage[key] = doneSession[key];
@@ -847,58 +1134,76 @@ export class TurnRunner {
847
1134
  if (result.tool_limit_reached)
848
1135
  donePayload.terminal_reason = 'max_iterations';
849
1136
  // 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 });
851
- put('done', donePayload);
1137
+ await put('metering', { session_id: sessionId, usage: meteringUsage(s), tps: doneUsage.tps ?? null, tps_available: doneUsage.tps !== undefined, estimated: false });
1138
+ await put('done', donePayload);
852
1139
  for (const [event, payload] of steerEvents)
853
- put(event, payload);
1140
+ (await put(event, payload));
854
1141
  // The turn is over: release admission before the title work so a follow-up message is accepted while the
855
1142
  // (up to two) title prompts run; the channel and journal stay open for the `title` events (Python retired the
856
1143
  // worker before its daemon-thread title generation).
857
1144
  this.registry.activeRuns.delete(streamId);
858
1145
  this.settledStreams.add(streamId);
859
- this.startSteerFollowUp(sessionId, leftovers);
1146
+ // TAL-1005: a turn that settled at or above the next turn's threshold starts the compaction now, so the next message
1147
+ // usually finds the session compacted; a follow-up turn admitted meanwhile joins it.
1148
+ void deps.service().ensureCompacted(sessionId);
1149
+ await this.startSteerFollowUp(sessionId, leftovers);
860
1150
  // Before the title work, so a generated title's own state.db sync lands after this one's provisional title.
861
1151
  await deps.syncUsage?.(s, usedModel || null);
862
1152
  // Title work and the goal judge run side by side; `stream_end` follows both, so their frames reach the stream.
863
1153
  const work = await Promise.allSettled([this.backgroundTitle(s, put), this.continueGoal(s, streamId, put)]);
864
- put('stream_end', { session_id: sessionId });
1154
+ await put('stream_end', { session_id: sessionId });
865
1155
  for (const r of work)
866
1156
  if (r.status === 'rejected')
867
1157
  throw r.reason;
868
1158
  succeeded = true;
869
- opts.onDone?.(lastAnswer(s.messages));
1159
+ await opts.onDone?.(lastAnswer(s.messages));
870
1160
  }
871
1161
  catch (error) {
1162
+ await streamWrites;
1163
+ const readOnly = !agentDispatched ? (await deps.service().readOnlyReason(s)) : null;
1164
+ if (readOnly) {
1165
+ ownershipRefused = true;
1166
+ if (this.registry.writebackOwners.get(sessionId) === streamId && s.active_stream_id === streamId) {
1167
+ // Cancellation persists the prompt and a Stop row; an unstarted, refused turn must add neither.
1168
+ s.messages = s.messages.filter((m) => !(m.role === 'user' && m._turn_id === streamId));
1169
+ this.clearPendingTurn(s);
1170
+ if (isDict(s.extra.completion_acceptances))
1171
+ s.extra.completion_acceptances = Object.fromEntries(Object.entries(s.extra.completion_acceptances).filter(([, value]) => value !== streamId));
1172
+ await deps.store.save(s, { touchUpdatedAt: false });
1173
+ }
1174
+ await put('apperror', { type: 'error', terminal_state: 'error', message: readOnly, session_id: sessionId, session: (await this.publicTerminalSession(s)) });
1175
+ return;
1176
+ }
872
1177
  if (settledAt.value && !(error instanceof SidecarError)) {
873
1178
  deps.log(`[webui] ERROR settling turn ${streamId}\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
874
1179
  }
875
1180
  if (this.registry.cancelled.has(streamId)) {
876
- this.finalizeCancelled(s, streamId, opts.ephemeral);
877
- put('cancel', this.cancelFrame(sessionId));
1181
+ await this.finalizeCancelled(s, streamId, opts.ephemeral);
1182
+ await put('cancel', (await this.cancelFrame(sessionId)));
878
1183
  return;
879
1184
  }
880
1185
  const message = error instanceof Error ? error.message : String(error);
881
1186
  const condition = error instanceof SidecarError ? error.condition : undefined;
882
- const classification = classifyProviderError(message, { condition });
883
- const payload = providerErrorPayload(message, classification.type, classification.hint, deps.redactEnabled());
884
- if (condition === 'sidecar_unavailable' || condition === 'agent_runtime_stale') {
885
- payload.type = condition === 'agent_runtime_stale' ? 'agent_runtime_stale' : 'sidecar_unavailable';
886
- payload.condition = condition;
887
- }
1187
+ const runtime = error instanceof SidecarError && ['sidecar_unavailable', 'agent_runtime_stale', 'agent_incompatible'].includes(error.condition)
1188
+ ? runtimeFailureDetails(error.condition, error.data) : null;
1189
+ const classification = runtime ? { label: 'Error', type: runtime.type, hint: '' } : classifyProviderError(message, { condition });
1190
+ const payload = providerErrorPayload(runtime?.message ?? message, classification.type, classification.hint, deps.redactEnabled());
1191
+ if (runtime)
1192
+ Object.assign(payload, runtime);
888
1193
  try {
889
1194
  let current = s;
890
1195
  try {
891
- current = deps.store.get(sessionId);
1196
+ current = (await deps.store.get(sessionId));
892
1197
  }
893
1198
  catch {
894
1199
  current = s;
895
1200
  }
896
1201
  if (current.active_stream_id === streamId) {
897
1202
  for (const [event, data] of this.takeSteerEventsBefore(streamId, 'apperror'))
898
- put(event, data);
899
- this.persistError(current, streamId, classification.label, payload, activeTurnToken);
1203
+ (await put(event, data));
1204
+ await this.persistError(current, streamId, classification.label, payload, activeTurnToken);
900
1205
  if (!opts.ephemeral)
901
- payload.session = this.publicTerminalSession(current);
1206
+ payload.session = (await this.publicTerminalSession(current));
902
1207
  }
903
1208
  }
904
1209
  catch (persistError) {
@@ -906,15 +1211,20 @@ export class TurnRunner {
906
1211
  }
907
1212
  payload.session_id = sessionId;
908
1213
  payload.old_session_id = sessionId;
909
- put('apperror', payload);
1214
+ await put('apperror', payload);
910
1215
  }
911
1216
  finally {
912
- this.teardown(sessionId, streamId);
1217
+ await this.teardown(sessionId, streamId);
913
1218
  // After persistence and teardown: a background route's failure cleanup deletes the hidden session, which must
914
1219
  // not race the error writeback above (a re-saved session would resurface in the sidebar).
915
1220
  if (!succeeded)
916
- opts.onFailed?.(failure);
917
- this.startSteerFollowUp(sessionId, followUp);
1221
+ await opts.onFailed?.(failure);
1222
+ // TAL-1005: a Stop or an error after the turn ran on a compaction's result left that job's estimate under a context
1223
+ // its prompt and work may have grown; the idle trigger judges the context measured again.
1224
+ if (!succeeded && !ownershipRefused && !opts.ephemeral)
1225
+ void deps.service().remeasureContext(sessionId).then((measured) => { if (measured)
1226
+ void deps.service().ensureCompacted(sessionId); });
1227
+ await this.startSteerFollowUp(sessionId, followUp);
918
1228
  }
919
1229
  }
920
1230
  /**
@@ -995,21 +1305,22 @@ export class TurnRunner {
995
1305
  return images ? [{ type: 'text', text: withFiles() }, ...parts] : withFiles();
996
1306
  }
997
1307
  /** 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());
1308
+ async publicTerminalSession(s) {
1309
+ return redactSessionData((await this.terminalSessionPayload(s)), this.deps.redactEnabled());
1000
1310
  }
1001
1311
  /**
1002
1312
  * Python `_session_payload_with_terminal_window`: the same visible-message window contract as `GET /api/session?
1003
1313
  * msg_limit=` (renderable rows, limited payload shape, windowed tool calls, todo state) with the full count.
1004
1314
  */
1005
- terminalSessionPayload(s) {
1006
- const payload = withSessionWireFlags(s.compact({ contextLengthFor: this.deps.service().deps.contextLengthFor, modelOptionFor: this.deps.service().deps.modelOptionFor }), this.registry.liveIds);
1007
- payload.assistant_name = this.deps.service().assistantName(s);
1008
- payload.workspace_name = this.deps.service().workspaceNames()(s);
1315
+ async terminalSessionPayload(s) {
1316
+ const payload = (await this.deps.service().wireRow(s));
1317
+ payload.automatic_turns_held = (await this.automaticTurnsHeld(s.session_id));
1318
+ payload.notices = this.sessionNotices(s.session_id);
1319
+ payload.goal = this.sessionGoal(s.session_id);
1009
1320
  const service = this.deps.service();
1010
- 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 }));
1321
+ const scened = (await service.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await service.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(s.messages)), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, clipToolResults: true })));
1011
1322
  const [window, offset] = messageWindowForDisplay(scened, TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT, null);
1012
- const limited = messagesForLimitedPayload(withBodyExcerpts(withDisplayMedia(window, service.mediaProjector(s)), s.active_stream_id));
1323
+ const limited = messagesForLimitedPayload(withBodyExcerpts(withDisplayMedia(window, (await service.mediaProjector(s))), s.active_stream_id, newestSettledReply(scened, s.active_stream_id), offset));
1013
1324
  payload.messages = limited;
1014
1325
  payload.message_count = s.messages.length;
1015
1326
  payload._messages_offset = offset;
@@ -1019,9 +1330,26 @@ export class TurnRunner {
1019
1330
  payload.compression_reference = compressionReference(s, scened);
1020
1331
  return payload;
1021
1332
  }
1333
+ /** TAL-540, Python `_live_usage_snapshot`: a finished compression's frame, with the counters and the ring's post-compression figures, so clients update before `done`. */
1334
+ compressedFrame(s) {
1335
+ const usage = {
1336
+ ...meteringUsage(s), context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
1337
+ post_compression_context_tokens_estimate: s.post_compression_context_tokens_estimate, ...contextUsage(s, () => this.deps.service().deps.contextLengthFor(s.model, s.model_provider)),
1338
+ };
1339
+ const sid = s.session_id;
1340
+ return { session_id: sid, old_session_id: sid, new_session_id: sid, continuation_session_id: sid, message: 'Compression finished', usage };
1341
+ }
1342
+ /** Release only the pending admission metadata; transcript settlement belongs to the caller. */
1343
+ clearPendingTurn(s) {
1344
+ s.active_stream_id = null;
1345
+ s.pending_user_message = null;
1346
+ s.pending_attachments = [];
1347
+ s.pending_started_at = null;
1348
+ s.pending_user_source = null;
1349
+ }
1022
1350
  /** Python `_materialize_pending_user_turn_before_error` + error message append + save. */
1023
1351
  /** `runtimeProvider`: the provider the Agent resolved for the turn, when it reported one. */
1024
- persistError(s, streamId, label, payload, activeTurnToken, agentRows = [], runtimeProvider = '') {
1352
+ async persistError(s, streamId, label, payload, activeTurnToken, agentRows = [], runtimeProvider = '') {
1025
1353
  const startedAt = s.pending_started_at;
1026
1354
  // TAL-576: an automatic wakeup with no usable credentials pauses the next ones instead of failing each.
1027
1355
  if (s.pending_user_source === 'process_wakeup' && payload.type === 'credential_pool_empty') {
@@ -1038,11 +1366,7 @@ export class TurnRunner {
1038
1366
  }
1039
1367
  this.materializePendingUserTurn(s, activeTurnToken, streamId);
1040
1368
  const duration = typeof startedAt === 'number' && startedAt > 0 ? Math.max(0, this.deps.now() - startedAt) : null;
1041
- s.active_stream_id = null;
1042
- s.pending_user_message = null;
1043
- s.pending_attachments = [];
1044
- s.pending_started_at = null;
1045
- s.pending_user_source = null;
1369
+ this.clearPendingTurn(s);
1046
1370
  this.appendPartialSnapshot(s, streamId);
1047
1371
  const hint = str(payload.hint);
1048
1372
  const errorMessage = { role: 'assistant', content: `**${label}:** ${str(payload.message) || label}${hint ? `\n\n*${hint}*` : ''}`, timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: streamId };
@@ -1072,10 +1396,9 @@ export class TurnRunner {
1072
1396
  const context = payload.type === 'compression_exhausted' ? null : this.unsettledContext(streamId, agentRows, read);
1073
1397
  if (context)
1074
1398
  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 });
1399
+ this.settleStateDb(s, streamId, agentRows, { read });
1077
1400
  try {
1078
- this.deps.store.save(s);
1401
+ (await this.deps.store.save(s));
1079
1402
  }
1080
1403
  catch (error) {
1081
1404
  this.deps.log(`[webui] WARNING: failed to save error turn for ${s.session_id}: ${error.message}`);
@@ -1086,6 +1409,12 @@ export class TurnRunner {
1086
1409
  this.deps.pending.clearClarifies(s.session_id);
1087
1410
  this.deps.events.publish('session_error', { profile: s.profile, sessionId: s.session_id });
1088
1411
  }
1412
+ /** Every terminal path carries the same starting read's id and generation into settlement. */
1413
+ settleStateDb(s, streamId, agentRows, opts = {}) {
1414
+ const stop = this.stopContexts.get(streamId);
1415
+ const service = this.deps.service();
1416
+ service.settleStateDb(s, { turnId: streamId, previousContext: stop?.previousContext ?? opts.previousContext ?? [], agentRows, startId: stop?.stateDbStartId ?? null, startGeneration: stop?.stateDbStartGeneration ?? null, read: opts.read ?? service.stateDbRead(s) });
1417
+ }
1089
1418
  /**
1090
1419
  * TAL-921: the model context a turn that ends without its settled result leaves: the work its Agent reported, else the
1091
1420
  * rows it committed past its starting read in `read`, the read its settlement uses.
@@ -1094,7 +1423,7 @@ export class TurnRunner {
1094
1423
  const stop = this.stopContexts.get(streamId);
1095
1424
  if (!stop)
1096
1425
  return null;
1097
- const after = stop.stateDbStartId;
1426
+ const after = stop.stateDbStartGeneration !== null && read.generation !== null && stop.stateDbStartGeneration !== read.generation ? -1 : stop.stateDbStartId;
1098
1427
  const stateRows = after === null ? [] : read.rows.filter((m) => typeof m._state_db_row_id === 'number' && m._state_db_row_id > after);
1099
1428
  return this.deps.service().unsettledTurnContext({ ...stop, streamedText: (this.registry.partialText.get(streamId) ?? []).join(''), agentRows, stateRows });
1100
1429
  }
@@ -1143,11 +1472,11 @@ export class TurnRunner {
1143
1472
  * Python `_emit_cancel_event`: the terminal frame carries the settled session so tabs can render the partial without a
1144
1473
  * reload. TAL-364: no copy — clients show their one localized status for the `cancelled` outcome.
1145
1474
  */
1146
- cancelFrame(sessionId) {
1475
+ async cancelFrame(sessionId) {
1147
1476
  let snapshot = null;
1148
1477
  if (sessionId)
1149
1478
  try {
1150
- snapshot = this.publicTerminalSession(this.deps.store.get(sessionId));
1479
+ snapshot = (await this.publicTerminalSession((await this.deps.store.get(sessionId))));
1151
1480
  }
1152
1481
  catch {
1153
1482
  snapshot = null;
@@ -1159,12 +1488,12 @@ export class TurnRunner {
1159
1488
  * turn's model context keeps what the Agent had captured (`checkpoint`, its canonical transcript) and the prose that
1160
1489
  * streamed, so the next request continues from it; the terminal row carries the outcome and no copy.
1161
1490
  */
1162
- finalizeCancelled(s, streamId, ephemeral, checkpoint = null) {
1491
+ async finalizeCancelled(s, streamId, ephemeral, checkpoint = null) {
1163
1492
  if (this.registry.writebackOwners.get(s.session_id) !== streamId)
1164
1493
  return false;
1165
1494
  let current = s;
1166
1495
  try {
1167
- current = this.deps.store.get(s.session_id);
1496
+ current = (await this.deps.store.get(s.session_id));
1168
1497
  }
1169
1498
  catch {
1170
1499
  return false;
@@ -1173,7 +1502,7 @@ export class TurnRunner {
1173
1502
  return false;
1174
1503
  if (ephemeral) {
1175
1504
  try {
1176
- rmSync(this.deps.store.pathFor(current.session_id), { force: true });
1505
+ await this.deps.store.delete(current.session_id, { tombstone: false });
1177
1506
  }
1178
1507
  catch { /* ignore */ }
1179
1508
  this.deps.store.sessions.delete(current.session_id);
@@ -1185,9 +1514,9 @@ export class TurnRunner {
1185
1514
  if (quiet && current.active_stream_id === null) {
1186
1515
  // TAL-493: rows its worker committed while unwinding are covered by what it reports; others are kept.
1187
1516
  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 });
1517
+ this.settleStateDb(current, streamId, checkpoint);
1189
1518
  try {
1190
- this.deps.store.save(current);
1519
+ (await this.deps.store.save(current));
1191
1520
  }
1192
1521
  catch {
1193
1522
  return false;
@@ -1216,9 +1545,9 @@ export class TurnRunner {
1216
1545
  const late = canonical && !stop.checkpointed ? settle(read) : null;
1217
1546
  if (late)
1218
1547
  current.context_messages = dedupeContext(late);
1219
- this.deps.service().settleStateDb(current, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows: checkpoint, startId: stop?.stateDbStartId ?? null, read });
1548
+ this.settleStateDb(current, streamId, checkpoint, { read });
1220
1549
  try {
1221
- this.deps.store.save(current);
1550
+ (await this.deps.store.save(current));
1222
1551
  }
1223
1552
  catch {
1224
1553
  return false;
@@ -1232,20 +1561,16 @@ export class TurnRunner {
1232
1561
  const context = settle(read);
1233
1562
  if (context)
1234
1563
  current.context_messages = dedupeContext(context);
1235
- current.active_stream_id = null;
1236
- current.pending_user_message = null;
1237
- current.pending_attachments = [];
1238
- current.pending_started_at = null;
1239
- current.pending_user_source = null;
1564
+ this.clearPendingTurn(current);
1240
1565
  if (!quiet) {
1241
1566
  this.appendPartialSnapshot(current, streamId);
1242
1567
  current.messages.push({ role: 'assistant', content: '', _error: true, _terminal_state: 'cancelled', timestamp: Math.trunc(this.deps.now()), _turn_id: streamId });
1243
1568
  }
1244
1569
  this.keepLiveState(current, streamId);
1245
1570
  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 });
1571
+ this.settleStateDb(current, streamId, checkpoint, { read });
1247
1572
  try {
1248
- this.deps.store.save(current);
1573
+ (await this.deps.store.save(current));
1249
1574
  }
1250
1575
  catch {
1251
1576
  return false;
@@ -1257,8 +1582,8 @@ export class TurnRunner {
1257
1582
  return true;
1258
1583
  }
1259
1584
  /** Push a frame onto the session's live chat stream from outside the turn (Python gateway notify callbacks). */
1260
- emitToSession(sessionId, event, data) {
1261
- this.sessionPuts.get(sessionId)?.(event, data);
1585
+ async emitToSession(sessionId, event, data) {
1586
+ await this.sessionPuts.get(sessionId)?.(event, data);
1262
1587
  }
1263
1588
  /** TAL-397: a turn that ends without its result keeps what the live stream already showed: counters and todo list. */
1264
1589
  keepLiveState(s, streamId) {
@@ -1269,13 +1594,15 @@ export class TurnRunner {
1269
1594
  if (todos)
1270
1595
  s.extra[UNSETTLED_TODO_KEY] = todos;
1271
1596
  }
1272
- teardown(sessionId, streamId) {
1597
+ async teardown(sessionId, streamId) {
1598
+ this.runStatuses.delete(streamId);
1273
1599
  this.liveUsage.delete(streamId);
1600
+ this.compressionSpans.delete(streamId);
1274
1601
  this.liveTodos.delete(streamId);
1275
1602
  const writer = this.writers.get(streamId);
1276
1603
  if (writer) {
1277
1604
  try {
1278
- writer.close();
1605
+ (await writer.close());
1279
1606
  }
1280
1607
  catch { /* ignore */ }
1281
1608
  this.writers.delete(streamId);
@@ -1304,21 +1631,27 @@ export class TurnRunner {
1304
1631
  catch { /* best effort */ }
1305
1632
  }
1306
1633
  /** Journal files with a live writer (retention must leave them alone). */
1307
- activeJournalPaths() {
1308
- return new Set([...this.writers.values()].map((w) => this.deps.journal.pathFor(w.sessionId, w.runId)));
1309
- }
1310
1634
  // ── title ────────────────────────────────────────────────────────────────
1311
1635
  /** Python `_generate_llm_session_title_via_aux`: aux-route statuses (`llm_aux`, `llm_aux_retry`, `llm_*_aux`). */
1312
1636
  async llmTitle(s, userText, assistantText) {
1313
1637
  const sidecar = this.deps.sidecar();
1314
1638
  if (!sidecar)
1315
1639
  return { title: '', status: 'llm_error_aux', rawPreview: '' };
1640
+ const service = this.deps.service();
1641
+ await service.deps.warmSessionModelRepair?.(s.profile);
1642
+ const [model, provider] = service.turnModel(s);
1316
1643
  const [qa, prompts] = titlePrompts(userText, assistantText);
1317
1644
  let status = 'llm_error_aux';
1318
1645
  for (const [idx, prompt] of prompts.entries()) {
1646
+ if ((await this.deps.service().isReadOnly(s)))
1647
+ return { title: '', status: 'read_only', rawPreview: '' };
1319
1648
  let raw = '';
1320
1649
  try {
1321
- const result = await sidecar.call('aux.complete', { profile_home: this.deps.profileHome(s.profile), task: 'title_generation', messages: [{ role: 'system', content: prompt }, { role: 'user', content: qa }], max_tokens: 64 }, { timeoutMs: 15_000 });
1650
+ const result = await sidecar.call('aux.complete', {
1651
+ profile_home: this.deps.profileHome(s.profile), task: 'title_generation',
1652
+ messages: [{ role: 'system', content: prompt }, { role: 'user', content: qa }], max_tokens: 64,
1653
+ main_runtime: { model: model ?? '', provider }, main_fallback: true,
1654
+ });
1322
1655
  raw = str(result.text);
1323
1656
  }
1324
1657
  catch (error) {
@@ -1339,6 +1672,42 @@ export class TurnRunner {
1339
1672
  }
1340
1673
  return { title: '', status, rawPreview: '' };
1341
1674
  }
1675
+ /**
1676
+ * TAL-902: `/refine [focus]` starts the Agent's memory/skill review of the chat's model context on its agent
1677
+ * (`chat.refine`, the agent a turn would build); no turn starts. Without a chat, while a turn runs, or before anything
1678
+ * was said it answers with the Agent's wording instead.
1679
+ */
1680
+ async refine(s, focus) {
1681
+ if (!s)
1682
+ return 'Nothing to refine yet — send a message first.';
1683
+ if (s.active_stream_id)
1684
+ return 'Agent is running — wait for the turn to finish, then /refine.';
1685
+ const messages = sanitizeMessagesForApi(this.deps.service().modelContext(s), { preserveApiContent: true, issuedReasoningSince: s.reasoning_replay_from });
1686
+ if (!messages.length)
1687
+ return 'Nothing to refine yet — the conversation is empty.';
1688
+ // Like a turn: no agent is built in a profile whose home is going away.
1689
+ if (this.deps.profileDeleting?.(s.profile ?? null))
1690
+ throw new HttpFailure(409, `Profile '${str(s.profile)}' is being deleted.`);
1691
+ const sidecar = this.deps.sidecar();
1692
+ if (!sidecar)
1693
+ throw new SidecarError('The Agent sidecar is not running; /refine is unavailable until it starts.', { condition: 'sidecar_unavailable' });
1694
+ const [model, provider] = this.deps.service().turnModel(s);
1695
+ const cfg = (await this.deps.profileConfig?.(s.profile ?? null)) ?? {};
1696
+ this.deps.service().assertWritable(s);
1697
+ return (await sidecar.call('chat.refine', {
1698
+ profile_home: this.deps.profileHome(s.profile), session_id: s.session_id, messages, focus, model: model ?? '', model_provider: provider,
1699
+ enabled_toolsets: this.deps.toolsetsFor(s), ...agentBuildParams(cfg, 'webui', provider, this.deps.env ?? {}),
1700
+ ...this.agentWorkspace(s, (await this.deps.service().turnWorkspace(s))),
1701
+ }, { timeoutMs: 120_000 })).output;
1702
+ }
1703
+ /**
1704
+ * TAL-903: the workspace a session's agent is built at, and whether it is a host path (the profile's terminal runs
1705
+ * locally). Only then does the Agent anchor AGENTS.md discovery and its memory provider there; `chat.start` and
1706
+ * `chat.refine` send the same pair, so the agent `/refine` builds is the one the next turn reuses.
1707
+ */
1708
+ agentWorkspace(s, workspace) {
1709
+ return { workspace, workspace_local: this.deps.workspaces.profileSupportsLocalIo(s.profile) };
1710
+ }
1342
1711
  /**
1343
1712
  * Python `generate_session_title_for_session`: on-demand title from the persisted transcript; never touches
1344
1713
  * `llm_title_generated`. An explicit regenerate runs even with `auxiliary.title_generation.enabled` off (TAL-531).
@@ -1352,6 +1721,7 @@ export class TurnRunner {
1352
1721
  let rawPreview = '';
1353
1722
  if (assistantText)
1354
1723
  ({ title: next, status: llmStatus, rawPreview } = await this.llmTitle(s, userText, assistantText));
1724
+ this.deps.service().assertWritable(s);
1355
1725
  if (next)
1356
1726
  return { title: next, status: llmStatus, rawPreview };
1357
1727
  const fallback = fallbackTitleFromExchange(userText, assistantText);
@@ -1366,7 +1736,7 @@ export class TurnRunner {
1366
1736
  async titleClaimed(s) {
1367
1737
  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
1738
  return;
1369
- await this.backgroundTitle(s, () => undefined).catch((error) => { this.deps.log(`[webui] imported session title failed for ${s.session_id}: ${error.message}`); });
1739
+ await this.backgroundTitle(s, () => Promise.resolve()).catch((error) => { this.deps.log(`[webui] imported session title failed for ${s.session_id}: ${error.message}`); });
1370
1740
  }
1371
1741
  /** Python `_run_background_title_update` (spawned only when `_background_title_generation_inputs` is eligible). */
1372
1742
  async backgroundTitle(s, put) {
@@ -1378,8 +1748,8 @@ export class TurnRunner {
1378
1748
  const eligible = ['Untitled', 'New Chat', ''].includes(placeholder) || placeholder === titleFrom(s.messages, '') || invalidExisting || defaultTitle(s);
1379
1749
  const [userText, assistantText] = firstExchangeSnippets(s.messages);
1380
1750
  // Python `_put_title_status`: empty reason/title/raw_preview keys are omitted.
1381
- const status = (status, reason = '', title = '', rawPreview = '') => {
1382
- put('title_status', { session_id: sessionId, status, ...(reason ? { reason } : {}), ...(title ? { title } : {}), ...(rawPreview ? { raw_preview: rawPreview } : {}) });
1751
+ const status = async (status, reason = '', title = '', rawPreview = '') => {
1752
+ await put('title_status', { session_id: sessionId, status, ...(reason ? { reason } : {}), ...(title ? { title } : {}), ...(rawPreview ? { raw_preview: rawPreview } : {}) });
1383
1753
  };
1384
1754
  // Python ran the adaptive refresh only when the first-title job was not eligible.
1385
1755
  if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText) {
@@ -1387,21 +1757,25 @@ export class TurnRunner {
1387
1757
  return;
1388
1758
  }
1389
1759
  if (s.manual_title) {
1390
- status('skipped', 'manual_title', placeholder);
1760
+ (await status('skipped', 'manual_title', placeholder));
1391
1761
  return;
1392
1762
  }
1393
1763
  // A missing config.yaml reads as `{}` (enabled); an unreadable one cannot confirm the user has not opted out.
1394
1764
  const cfg = await this.deps.profileConfig?.(s.profile ?? null);
1395
1765
  if (!cfg) {
1396
- status('skipped', 'config_unavailable', placeholder);
1766
+ (await status('skipped', 'config_unavailable', placeholder));
1397
1767
  return;
1398
1768
  }
1399
1769
  if (!titleGenerationEnabled(cfg)) {
1400
- status('skipped', 'title_generation_disabled', placeholder);
1770
+ (await status('skipped', 'title_generation_disabled', placeholder));
1401
1771
  return;
1402
1772
  }
1403
1773
  const generated = await this.llmTitle(s, userText, assistantText);
1404
1774
  const { status: llmStatus, rawPreview } = generated;
1775
+ if (llmStatus === 'read_only') {
1776
+ (await status('skipped', 'read_only', placeholder));
1777
+ return;
1778
+ }
1405
1779
  let next = generated.title;
1406
1780
  let source = llmStatus;
1407
1781
  if (!next) {
@@ -1414,26 +1788,30 @@ export class TurnRunner {
1414
1788
  const fallbackReason = source === 'fallback' && llmStatus ? `local_summary:${llmStatus}` : 'local_summary';
1415
1789
  let current = s;
1416
1790
  try {
1417
- current = this.deps.store.get(sessionId);
1791
+ current = (await this.deps.store.get(sessionId));
1418
1792
  }
1419
1793
  catch {
1420
1794
  // A session deleted while the title prompt ran stays deleted.
1421
- if (this.deps.store.wasDeleted(sessionId))
1795
+ if ((await this.deps.store.wasDeleted(sessionId)))
1422
1796
  return;
1423
1797
  current = s;
1424
1798
  }
1799
+ if ((await this.deps.service().isReadOnly(current))) {
1800
+ (await status('skipped', 'read_only', str(current.title).trim()));
1801
+ return;
1802
+ }
1425
1803
  let effective = str(current.title).trim();
1426
1804
  let wrote = false;
1427
1805
  if (next) {
1428
1806
  const stillAuto = effective === placeholder || ['Untitled', 'New Chat', ''].includes(effective) || effective === titleFrom(current.messages, '') || looksInvalidGeneratedTitle(current.title) || defaultTitle(current);
1429
1807
  if (current.manual_title || !stillAuto) {
1430
- status('skipped', 'manual_title', effective);
1808
+ (await status('skipped', 'manual_title', effective));
1431
1809
  return;
1432
1810
  }
1433
1811
  if (next !== effective) {
1434
1812
  current.title = next;
1435
1813
  markSessionTitleGenerated(current);
1436
- this.deps.store.save(current, { touchUpdatedAt: false });
1814
+ await this.deps.store.save(current, { touchUpdatedAt: false });
1437
1815
  // Python `sync_session_title` after generation: the state.db row follows when `sync_to_insights` is on.
1438
1816
  await this.deps.syncTitle?.(current);
1439
1817
  this.deps.events.publish('title', { profile: current.profile, sessionId });
@@ -1442,11 +1820,11 @@ export class TurnRunner {
1442
1820
  }
1443
1821
  }
1444
1822
  if (wrote) {
1445
- status(source, source === 'fallback' ? fallbackReason : llmStatus, effective, rawPreview);
1446
- put('title', { session_id: sessionId, title: effective });
1823
+ await status(source, source === 'fallback' ? fallbackReason : llmStatus, effective, rawPreview);
1824
+ await put('title', { session_id: sessionId, title: effective });
1447
1825
  }
1448
1826
  else
1449
- status('skipped', source || 'unchanged', effective, rawPreview);
1827
+ (await status('skipped', source || 'unchanged', effective, rawPreview));
1450
1828
  }
1451
1829
  /**
1452
1830
  * Python `_maybe_schedule_title_refresh` + `_run_background_title_refresh`: every N exchanges a generated title is
@@ -1463,7 +1841,7 @@ export class TurnRunner {
1463
1841
  // Admission is released before title work, so an earlier turn's refresh may still be running: refreshes apply in
1464
1842
  // turn order and each reads the title its predecessor wrote.
1465
1843
  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));
1844
+ const run = (this.titleRefreshes.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(async () => { await this.applyTitleRefresh(sessionId, s, userText, assistantText, put, status); });
1467
1845
  this.titleRefreshes.set(sessionId, run);
1468
1846
  try {
1469
1847
  await run;
@@ -1474,44 +1852,48 @@ export class TurnRunner {
1474
1852
  }
1475
1853
  }
1476
1854
  async applyTitleRefresh(sessionId, s, userText, assistantText, put, status) {
1477
- const read = () => { try {
1478
- return this.deps.store.get(sessionId);
1855
+ const read = async () => { try {
1856
+ return (await this.deps.store.get(sessionId));
1479
1857
  }
1480
1858
  catch {
1481
1859
  return s;
1482
1860
  } };
1483
- const latest = read();
1861
+ const latest = (await read());
1484
1862
  const placeholder = str(latest.title).trim();
1485
1863
  if (['Untitled', 'New Chat', ''].includes(placeholder) || latest.manual_title || !latest.llm_title_generated)
1486
1864
  return;
1487
1865
  const cfg = await this.deps.profileConfig?.(latest.profile ?? null);
1488
1866
  if (!cfg || !titleGenerationEnabled(cfg)) {
1489
- status('refresh_skipped', cfg ? 'title_generation_disabled' : 'config_unavailable', placeholder);
1867
+ (await status('refresh_skipped', cfg ? 'title_generation_disabled' : 'config_unavailable', placeholder));
1490
1868
  return;
1491
1869
  }
1492
1870
  const { title: next, status: llmStatus, rawPreview } = await this.llmTitle(latest, userText, assistantText);
1493
1871
  if (!next) {
1494
- status('refresh_skipped', llmStatus || 'empty', placeholder, rawPreview);
1872
+ (await status('refresh_skipped', llmStatus || 'empty', placeholder, rawPreview));
1495
1873
  return;
1496
1874
  }
1497
1875
  const normalize = (value) => value.replace(/\s+/g, ' ').trim().toLowerCase();
1498
1876
  if (normalize(next) === normalize(placeholder)) {
1499
- status('refresh_skipped', 'same_title', placeholder, rawPreview);
1877
+ (await status('refresh_skipped', 'same_title', placeholder, rawPreview));
1500
1878
  return;
1501
1879
  }
1502
- const current = read();
1880
+ const current = (await read());
1503
1881
  // The user may have renamed the session while the title prompt ran.
1504
1882
  if (current.manual_title || str(current.title).trim() !== placeholder) {
1505
- status('skipped', 'manual_title', str(current.title).trim());
1883
+ (await status('skipped', 'manual_title', str(current.title).trim()));
1884
+ return;
1885
+ }
1886
+ if ((await this.deps.service().isReadOnly(current))) {
1887
+ (await status('refresh_skipped', 'read_only', str(current.title).trim()));
1506
1888
  return;
1507
1889
  }
1508
1890
  current.title = next;
1509
1891
  markSessionTitleGenerated(current);
1510
- this.deps.store.save(current, { touchUpdatedAt: false });
1892
+ await this.deps.store.save(current, { touchUpdatedAt: false });
1511
1893
  await this.deps.syncTitle?.(current);
1512
1894
  this.deps.events.publish('title', { profile: current.profile, sessionId });
1513
- status('refreshed', llmStatus, next, rawPreview);
1514
- put('title', { session_id: sessionId, title: next });
1895
+ await status('refreshed', llmStatus, next, rawPreview);
1896
+ await put('title', { session_id: sessionId, title: next });
1515
1897
  }
1516
1898
  // ── cancel / steer ───────────────────────────────────────────────────────
1517
1899
  /**
@@ -1519,6 +1901,26 @@ export class TurnRunner {
1519
1901
  * `steer_withdrawn` (stopped) this Stop emitted, for the cancel response (TAL-426).
1520
1902
  */
1521
1903
  async cancel(streamId) {
1904
+ const ownerCancelled = await this.deps.service().owner?.cancel(streamId);
1905
+ if (ownerCancelled !== null && ownerCancelled !== undefined)
1906
+ return { cancelled: ownerCancelled, withdrawn: [] };
1907
+ // TAL-899: the user's Stop holds automatic turns, even one that lands after `done`, unless a newer turn already
1908
+ // took the session: a late or retried Stop for a turn the user followed holds nothing.
1909
+ const owner = this.registry.ownerSessionId(streamId) ?? this.registry.activeRuns.get(streamId)?.session_id;
1910
+ if (owner && this.registry.writebackOwners.get(owner) === streamId) {
1911
+ this.stopHeld.add(owner);
1912
+ try {
1913
+ const session = (await this.deps.store.get(owner));
1914
+ session.automatic_turns_stop_held = true;
1915
+ await this.deps.store.save(session, { touchUpdatedAt: false });
1916
+ }
1917
+ catch (error) {
1918
+ this.deps.log(`[webui] WARNING: failed to persist Stop hold for session ${owner}; interrupting with the in-memory hold: ${error.message}`);
1919
+ }
1920
+ }
1921
+ return (await this.stop(streamId));
1922
+ }
1923
+ async stop(streamId) {
1522
1924
  const channel = this.registry.peek(streamId);
1523
1925
  const run = this.registry.activeRuns.get(streamId);
1524
1926
  if (!channel && !run)
@@ -1553,24 +1955,24 @@ export class TurnRunner {
1553
1955
  if (sessionId) {
1554
1956
  let current = null;
1555
1957
  try {
1556
- current = this.deps.store.get(sessionId);
1958
+ current = (await this.deps.store.get(sessionId));
1557
1959
  }
1558
1960
  catch {
1559
1961
  current = null;
1560
1962
  }
1561
1963
  if (current?.active_stream_id === streamId) {
1562
- this.finalizeCancelled(current, streamId, run?.ephemeral, checkpoint);
1964
+ await this.finalizeCancelled(current, streamId, run?.ephemeral, checkpoint);
1563
1965
  }
1564
1966
  }
1565
1967
  // The worker may already have written its terminal row while the interrupt was in flight; the stream is then
1566
1968
  // gone from the registry and this path must not add a second one.
1567
1969
  if (channel && this.registry.streams.has(streamId)) {
1568
1970
  const writer = this.writers.get(streamId) ?? null;
1569
- const emit = (event, data) => {
1971
+ const emit = async (event, data) => {
1570
1972
  let eventId = null;
1571
1973
  if (writer) {
1572
1974
  try {
1573
- eventId = writer.appendSseEvent(event, data).event_id;
1975
+ eventId = (await writer.appendSseEvent(event, data)).event_id;
1574
1976
  }
1575
1977
  catch {
1576
1978
  eventId = null;
@@ -1579,8 +1981,10 @@ export class TurnRunner {
1579
1981
  channel.put([event, data, eventId]);
1580
1982
  };
1581
1983
  for (const [event, data] of steerEvents)
1582
- emit(event, data);
1583
- emit('cancel', this.cancelFrame(sessionId));
1984
+ (await emit(event, data));
1985
+ this.runStatuses.delete(streamId);
1986
+ await emit('run_status', { session_id: sessionId, status: null });
1987
+ await emit('cancel', (await this.cancelFrame(sessionId)));
1584
1988
  this.registry.streams.delete(streamId);
1585
1989
  this.registry.liveIds.delete(streamId);
1586
1990
  }
@@ -1596,7 +2000,7 @@ export class TurnRunner {
1596
2000
  const streamId = this.registry.activeRunStreamForSession(sessionId);
1597
2001
  if (!streamId || this.registry.activeRuns.get(streamId)?.origin !== 'background')
1598
2002
  return false;
1599
- await this.cancel(streamId);
2003
+ await this.stop(streamId);
1600
2004
  let timer;
1601
2005
  await Promise.race([this.workers.get(streamId), new Promise((resolve) => { timer = setTimeout(resolve, BACKGROUND_UNWIND_WAIT_MS); })]);
1602
2006
  clearTimeout(timer);
@@ -1605,7 +2009,7 @@ export class TurnRunner {
1605
2009
  async steer(sessionId, text, displayText, steerId) {
1606
2010
  let s = null;
1607
2011
  try {
1608
- s = this.deps.store.get(sessionId, { metadataOnly: true });
2012
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
1609
2013
  }
1610
2014
  catch {
1611
2015
  s = null;
@@ -1624,7 +2028,7 @@ export class TurnRunner {
1624
2028
  return { accepted: false, fallback: 'no_cached_agent', stream_id: null };
1625
2029
  // Register provisionally before the RPC: the Agent may apply the steer and finish the turn before the steer
1626
2030
  // reply arrives, and the turn's finalisation must then already see the record. A rejection removes it again.
1627
- return this.withSteerChain(activeStreamId, () => this.steerInto(sidecar, sessionId, activeStreamId, text, displayText, steerId));
2031
+ return this.withSteerChain(activeStreamId, sessionId, () => this.steerInto(sidecar, sessionId, activeStreamId, text, displayText, steerId));
1628
2032
  }
1629
2033
  async steerInto(sidecar, sessionId, activeStreamId, text, displayText, steerId) {
1630
2034
  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 };
@@ -1656,7 +2060,7 @@ export class TurnRunner {
1656
2060
  record.can_redirect = result.can_redirect === true;
1657
2061
  // TAL-424: every client shows it until the Agent takes it (unless it already did while the reply was in flight).
1658
2062
  if (this.steers.get(activeStreamId)?.includes(record))
1659
- this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
2063
+ (await this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record)));
1660
2064
  }
1661
2065
  return { accepted: result.accepted, fallback: result.accepted ? null : (result.fallback ?? 'not_running'), stream_id: activeStreamId, steer_id: steerId };
1662
2066
  }
@@ -1725,10 +2129,10 @@ export class TurnRunner {
1725
2129
  return boundary;
1726
2130
  }
1727
2131
  /** A steer is saved as it enters the stream, so a reload mid-turn already has it; the terminal save re-places it. */
1728
- saveConsumedSteers(sessionId, streamId) {
2132
+ async saveConsumedSteers(sessionId, streamId) {
1729
2133
  let s;
1730
2134
  try {
1731
- s = this.deps.store.get(sessionId);
2135
+ s = (await this.deps.store.get(sessionId));
1732
2136
  }
1733
2137
  catch {
1734
2138
  return;
@@ -1736,7 +2140,7 @@ export class TurnRunner {
1736
2140
  if (s.active_stream_id !== streamId || this.placeConsumedSteers(s, streamId, s.pending_started_at) === null)
1737
2141
  return;
1738
2142
  try {
1739
- this.deps.store.save(s, { touchUpdatedAt: false });
2143
+ (await this.deps.store.save(s, { touchUpdatedAt: false }));
1740
2144
  }
1741
2145
  catch (error) {
1742
2146
  this.deps.log(`[webui] WARNING: failed to save consumed steer for ${sessionId}: ${error.message}`);
@@ -1796,18 +2200,18 @@ export class TurnRunner {
1796
2200
  * TAL-424: the steers a finished turn never took go on as one follow-up turn, sent once by the server for every client.
1797
2201
  * Should another turn already hold the session, they steer into that one instead, so no text is lost.
1798
2202
  */
1799
- startSteerFollowUp(sessionId, texts) {
2203
+ async startSteerFollowUp(sessionId, texts) {
1800
2204
  if (!texts.length)
1801
2205
  return;
1802
2206
  let s;
1803
2207
  try {
1804
- s = this.deps.store.get(sessionId);
2208
+ s = (await this.deps.store.get(sessionId));
1805
2209
  }
1806
2210
  catch {
1807
2211
  return;
1808
2212
  }
1809
2213
  const msg = texts.join('\n');
1810
- const started = this.start(s, { msg, attachments: [], workspace: s.workspace, model: s.model, modelProvider: s.model_provider });
2214
+ const started = (await this.startServerTurn(s, { msg }));
1811
2215
  if (!started._status || started._status < 400)
1812
2216
  return;
1813
2217
  if (started._status === 409 && started.active_stream_id) {
@@ -1835,61 +2239,83 @@ export class TurnRunner {
1835
2239
  // Only a known goal turn shows progress up front; any other turn shows nothing unless a goal turns out to be active.
1836
2240
  const goalTurn = this.registry.goalRelated.has(streamId);
1837
2241
  if (goalTurn)
1838
- put('goal', { session_id: sessionId, state: 'evaluating', message: 'Evaluating goal progress…', message_key: 'goal_evaluating_progress' });
2242
+ (await put('goal', { session_id: sessionId, state: 'evaluating', message: 'Evaluating goal progress…', message_key: 'goal_evaluating_progress' }));
1839
2243
  let decision;
1840
2244
  try {
1841
- decision = await sidecar.call('goals.evaluate', { session_id: sessionId, profile_home: this.deps.profileHome(s.profile), last_response: lastResponse, user_initiated: true });
2245
+ // The workspace the turn ran in, unrecovered: the Agent pauses the goal rather than run its gates anywhere else.
2246
+ this.deps.service().assertWritable(s);
2247
+ decision = await sidecar.call('goals.evaluate', { session_id: sessionId, profile_home: this.deps.profileHome(s.profile), last_response: lastResponse, user_initiated: true, workspace: str(s.workspace) || undefined });
1842
2248
  }
1843
2249
  catch (error) {
1844
2250
  this.deps.log(`[webui] WARNING: goal evaluation failed for ${sessionId}: ${error.message}`);
1845
2251
  if (goalTurn)
1846
- put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
2252
+ (await put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' }));
1847
2253
  return;
1848
2254
  }
2255
+ // The judgement moved the goal on (turns, park, pause, done); an inactive one only matters if a goal still shows.
2256
+ if (decision.verdict !== 'inactive' || this.goals.view(sessionId))
2257
+ await this.refreshGoal(s, put);
1849
2258
  if (!goalTurn && decision.verdict === 'inactive')
1850
2259
  return;
1851
2260
  const prompt = str(decision.continuation_prompt).trim();
1852
2261
  const proceed = decision.should_continue && Boolean(prompt);
1853
2262
  const status = { message: decision.message, message_key: decision.message_key, message_args: decision.message_args ?? [], decision: decision.verdict };
1854
- put('goal', { session_id: sessionId, state: proceed ? 'continuing' : 'idle', ...status });
1855
- if (!proceed)
2263
+ if (!proceed) {
2264
+ (await put('goal', { session_id: sessionId, state: 'idle', ...status }));
1856
2265
  return;
2266
+ }
1857
2267
  let current;
1858
2268
  try {
1859
- current = this.deps.store.get(sessionId);
2269
+ current = (await this.deps.store.get(sessionId));
1860
2270
  }
1861
2271
  catch {
1862
2272
  return;
1863
2273
  }
1864
- const started = this.start(current, { msg: prompt, attachments: [], workspace: current.workspace, model: current.model, modelProvider: current.model_provider, goalRelated: true });
2274
+ const started = (await this.startServerTurn(current, { msg: prompt, goalRelated: true }));
2275
+ // TAL-899: a Stop, even one after the answer, holds the continuation; the user's next turn is judged instead.
2276
+ if (started.held) {
2277
+ (await put('goal', { session_id: sessionId, state: 'idle', decision: 'held', message: '' }));
2278
+ return;
2279
+ }
2280
+ await put('goal', { session_id: sessionId, state: 'continuing', ...status });
1865
2281
  if (started._status === 409 && started.active_stream_id) {
1866
2282
  this.registry.goalRelated.add(started.active_stream_id);
1867
2283
  return;
1868
2284
  }
1869
2285
  if (started._status !== undefined && started._status >= 400) {
1870
2286
  this.deps.log(`[webui] WARNING: goal continuation for ${sessionId} not started (${String(started._status)}): ${str(started.error)}`);
1871
- put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
2287
+ await put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
1872
2288
  return;
1873
2289
  }
1874
- put('goal_continue', { session_id: sessionId, state: 'continuing', continuation_prompt: prompt, text: prompt, stream_id: str(started.stream_id), ...status });
2290
+ await put('goal_continue', { session_id: sessionId, state: 'continuing', continuation_prompt: prompt, text: prompt, stream_id: str(started.stream_id), ...status });
1875
2291
  }
1876
2292
  /** The Agent's pending steer text settles which steers it has taken: those become `steer_consumed` and their rows. */
1877
- settleConsumedSteers(sessionId, streamId, pendingText) {
2293
+ async settleConsumedSteers(sessionId, streamId, pendingText) {
1878
2294
  for (const record of this.takeConsumedSteers(streamId, pendingText, { keepLeftovers: true }))
1879
- this.emitToSession(sessionId, 'steer_consumed', record);
1880
- this.saveConsumedSteers(sessionId, streamId);
2295
+ (await this.emitToSession(sessionId, 'steer_consumed', record));
2296
+ await this.saveConsumedSteers(sessionId, streamId);
1881
2297
  }
1882
2298
  /** What every client shows for a pending steer; the server decides which actions it offers. */
1883
2299
  pendingSteerView(r) {
1884
2300
  const open = r.state === 'pending';
1885
2301
  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 } };
1886
2302
  }
2303
+ runStatus(streamId) {
2304
+ return this.runStatuses.get(streamId) ?? null;
2305
+ }
2306
+ /** TAL-1006: the start (epoch seconds) of the stream's open compression span, else null. */
2307
+ compressionStartedAt(streamId) {
2308
+ return this.compressionSpans.get(streamId) ?? null;
2309
+ }
1887
2310
  /** TAL-424: the active stream's pending steers, oldest first (session detail `pending_steers`). */
1888
2311
  pendingSteers(streamId) {
1889
2312
  return (this.steers.get(streamId) ?? []).map((r) => this.pendingSteerView(r));
1890
2313
  }
1891
- withSteerChain(streamId, fn) {
1892
- const run = (this.steerChains.get(streamId) ?? Promise.resolve()).then(fn);
2314
+ withSteerChain(streamId, sessionId, fn) {
2315
+ const run = (this.steerChains.get(streamId) ?? Promise.resolve()).then(async () => {
2316
+ await this.deps.service().getForMutation(sessionId);
2317
+ return fn();
2318
+ });
1893
2319
  const settled = run.catch(() => undefined).then(() => { if (this.steerChains.get(streamId) === settled)
1894
2320
  this.steerChains.delete(streamId); });
1895
2321
  this.steerChains.set(streamId, settled);
@@ -1903,7 +2329,7 @@ export class TurnRunner {
1903
2329
  async rewriteSteer(sessionId, steerId, fallback, rewrite) {
1904
2330
  let s = null;
1905
2331
  try {
1906
- s = this.deps.store.get(sessionId, { metadataOnly: true });
2332
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
1907
2333
  }
1908
2334
  catch {
1909
2335
  s = null;
@@ -1911,21 +2337,21 @@ export class TurnRunner {
1911
2337
  const streamId = s?.active_stream_id;
1912
2338
  const sidecar = this.deps.sidecar();
1913
2339
  if (!streamId || !sidecar)
1914
- return fallback;
1915
- return this.withSteerChain(streamId, async () => {
2340
+ return fallback('not_running');
2341
+ return this.withSteerChain(streamId, sessionId, async () => {
1916
2342
  const records = this.steers.get(streamId) ?? [];
1917
2343
  const index = records.findIndex((r) => r.steer_id === steerId);
1918
2344
  if (index < 0 || records[index].state !== 'pending')
1919
- return fallback;
2345
+ return fallback('taken');
1920
2346
  // In flight before the sidecar call, so a pending-text report racing its reply is deferred too.
1921
2347
  let finished = () => undefined;
1922
2348
  this.steerRewrites.set(streamId, new Promise((resolve) => { finished = resolve; }));
1923
- let outcome = { result: fallback, changed: false };
2349
+ let outcome = { result: fallback('failed'), changed: false };
1924
2350
  try {
1925
2351
  outcome = await rewrite(sidecar, streamId, records, index);
1926
2352
  }
1927
2353
  catch {
1928
- outcome = { result: fallback, changed: false };
2354
+ outcome = { result: fallback('failed'), changed: false };
1929
2355
  }
1930
2356
  finally {
1931
2357
  this.steerRewrites.delete(streamId);
@@ -1933,7 +2359,7 @@ export class TurnRunner {
1933
2359
  const deferred = this.deferredPendingSteer.get(streamId);
1934
2360
  this.deferredPendingSteer.delete(streamId);
1935
2361
  if (deferred !== undefined && !outcome.changed)
1936
- this.settleConsumedSteers(sessionId, streamId, deferred);
2362
+ (await this.settleConsumedSteers(sessionId, streamId, deferred));
1937
2363
  }
1938
2364
  return outcome.result;
1939
2365
  });
@@ -1948,54 +2374,61 @@ export class TurnRunner {
1948
2374
  * TAL-424 Edit and Cancel: take a pending steer back before the Agent takes it. `withdrawn: false` (and nothing
1949
2375
  * emitted) when it is unknown, already taken or being sent; a taken one then settles as consumed.
1950
2376
  */
1951
- withdrawSteer(sessionId, steerId, reason) {
1952
- return this.rewriteSteer(sessionId, steerId, { withdrawn: false }, async (sidecar, streamId, records, index) => {
2377
+ async withdrawSteer(sessionId, steerId, reason) {
2378
+ return (await this.rewriteSteer(sessionId, steerId, () => ({ withdrawn: false }), async (sidecar, streamId, records, index) => {
1953
2379
  const record = records[index];
1954
2380
  const reply = await sidecar.call('chat.steer_withdraw', { stream_id: streamId, pending: records.map((r) => r.text), index });
1955
2381
  if (!reply.withdrawn)
1956
2382
  return { result: { withdrawn: false }, changed: false };
1957
2383
  this.dropSteer(streamId, record);
1958
- this.emitToSession(sessionId, 'steer_withdrawn', { steer_id: record.steer_id, reason, text: record.display_text });
2384
+ await this.emitToSession(sessionId, 'steer_withdrawn', { steer_id: record.steer_id, reason, text: record.display_text });
1959
2385
  return { result: { withdrawn: true, text: record.display_text }, changed: true };
1960
- });
2386
+ }));
1961
2387
  }
1962
2388
  /**
1963
2389
  * TAL-424 Send now: deliver a pending steer with the Agent's redirect. A model request restarts with it (consumed at
1964
2390
  * once); during tools it goes last on the Agent's queue and stays `sending_now` until taken; with nothing live it
1965
2391
  * stays pending.
1966
2392
  */
1967
- sendSteerNow(sessionId, steerId) {
1968
- return this.rewriteSteer(sessionId, steerId, { redirected: false }, async (sidecar, streamId, records, index) => {
2393
+ async sendSteerNow(sessionId, steerId) {
2394
+ return (await this.rewriteSteer(sessionId, steerId, (outcome) => ({ redirected: false, outcome }), async (sidecar, streamId, records, index) => {
1969
2395
  const record = records[index];
1970
2396
  if (!record.can_redirect)
1971
- return { result: { redirected: false }, changed: false };
2397
+ return { result: { redirected: false, outcome: 'failed' }, changed: false };
1972
2398
  const reply = await sidecar.call('chat.steer_now', { stream_id: streamId, pending: records.map((r) => r.text), index });
1973
2399
  if (!reply.withdrawn)
1974
- return { result: { redirected: false }, changed: false };
2400
+ return { result: { redirected: false, outcome: reply.reason === 'not_running' ? 'not_running' : 'taken' }, changed: false };
1975
2401
  if (!reply.redirected) {
1976
2402
  // The Agent's queue changed meanwhile, so it went back last: clients follow the new order.
1977
2403
  if (reply.requeued === 'last') {
1978
2404
  this.dropSteer(streamId, record);
1979
2405
  records.push(record);
1980
- this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
2406
+ (await this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record)));
1981
2407
  }
1982
- return { result: { redirected: false }, changed: true };
2408
+ return { result: { redirected: false, outcome: 'next_step' }, changed: true };
1983
2409
  }
1984
2410
  this.dropSteer(streamId, record);
1985
2411
  if (reply.delivery === 'steer') {
1986
2412
  record.state = 'sending_now';
1987
2413
  records.push(record);
1988
- this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
2414
+ await this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
1989
2415
  }
1990
2416
  else {
1991
- this.emitToSession(sessionId, 'steer_consumed', this.consumedSteerPayload(record, { redirected: true }));
1992
- this.saveConsumedSteers(sessionId, streamId);
2417
+ await this.emitToSession(sessionId, 'steer_consumed', this.consumedSteerPayload(record, { redirected: true }));
2418
+ await this.saveConsumedSteers(sessionId, streamId);
1993
2419
  }
1994
- return { result: { redirected: true }, changed: true };
1995
- });
2420
+ return { result: { redirected: true, outcome: 'delivered' }, changed: true };
2421
+ }));
1996
2422
  }
1997
2423
  // ── approvals / clarify ──────────────────────────────────────────────────
1998
2424
  async respondApproval(sessionId, choice, approvalId, enableYolo) {
2425
+ if (this.deps.service().owner?.isOwned(sessionId)) {
2426
+ if (enableYolo)
2427
+ throw new HttpFailure(403, 'Change approval policy through the owning Agent');
2428
+ await this.deps.service().owner.respond(sessionId, 'approval', approvalId, choice);
2429
+ return { ok: true, choice };
2430
+ }
2431
+ sessionId = (await this.deps.service().idForMutation(sessionId));
1999
2432
  // Python `_enable_session_yolo_and_release_pending`: a stale card id while another approval is parked
2000
2433
  // must not commit YOLO for the whole session — fail closed with the relay envelope instead.
2001
2434
  if (enableYolo && approvalId && !this.deps.pending.hasApprovalId(sessionId, approvalId) && this.deps.pending.hasPendingApproval(sessionId)) {
@@ -2008,7 +2441,7 @@ export class TurnRunner {
2008
2441
  const relayFailure = (message) => ({ ok: false, choice, error: `The Agent sidecar did not accept the approval (${message}); retry in a moment.`, _status: 503 });
2009
2442
  let s = null;
2010
2443
  try {
2011
- s = this.deps.store.get(sessionId, { metadataOnly: true });
2444
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
2012
2445
  }
2013
2446
  catch {
2014
2447
  s = null;
@@ -2050,7 +2483,7 @@ export class TurnRunner {
2050
2483
  return relayFailure(str(error.message));
2051
2484
  }
2052
2485
  }
2053
- this.deps.service().setYolo(sessionId, true);
2486
+ await this.deps.service().setYolo(sessionId, true);
2054
2487
  return null;
2055
2488
  });
2056
2489
  if (failure)
@@ -2084,17 +2517,21 @@ export class TurnRunner {
2084
2517
  return;
2085
2518
  }
2086
2519
  if (this.deps.pending.dropApprovals(sessionId, new Set(before.filter((id) => !live.has(id)))).length)
2087
- this.emitToSession(sessionId, ...this.approvalHeadFrame(sessionId));
2520
+ (await this.emitToSession(sessionId, ...this.approvalHeadFrame(sessionId)));
2088
2521
  }
2089
2522
  withYoloLock(sessionId, fn) {
2090
- const run = (this.yoloChains.get(sessionId) ?? Promise.resolve()).then(fn);
2523
+ const run = (this.yoloChains.get(sessionId) ?? Promise.resolve()).then(async () => {
2524
+ sessionId = (await this.deps.service().idForMutation(sessionId));
2525
+ return fn();
2526
+ });
2091
2527
  const settled = run.catch(() => undefined).then(() => { if (this.yoloChains.get(sessionId) === settled)
2092
2528
  this.yoloChains.delete(sessionId); });
2093
2529
  this.yoloChains.set(sessionId, settled);
2094
2530
  return run;
2095
2531
  }
2096
2532
  /** Python `set_session_yolo_enabled(False)`: the sidecar must drop its per-session YOLO state before the local flag clears. */
2097
- disableYolo(sessionId) {
2533
+ async disableYolo(sessionId) {
2534
+ sessionId = (await this.deps.service().idForMutation(sessionId));
2098
2535
  return this.withYoloLock(sessionId, async () => {
2099
2536
  const sidecar = this.deps.sidecar();
2100
2537
  if (sidecar) {
@@ -2105,7 +2542,7 @@ export class TurnRunner {
2105
2542
  return { ok: false, error: `The Agent sidecar did not disable YOLO (${str(error.message)}); retry in a moment.`, yolo_enabled: this.deps.service().yolo(sessionId).yolo_enabled === true, _status: 503 };
2106
2543
  }
2107
2544
  }
2108
- return this.deps.service().setYolo(sessionId, false);
2545
+ return (await this.deps.service().setYolo(sessionId, false));
2109
2546
  });
2110
2547
  }
2111
2548
  /**
@@ -2114,16 +2551,25 @@ export class TurnRunner {
2114
2551
  * did not acknowledge (prompt retained).
2115
2552
  */
2116
2553
  async respondClarify(sessionId, clarifyId, reply) {
2554
+ if (this.deps.service().owner?.isOwned(sessionId)) {
2555
+ await this.deps.service().owner.respond(sessionId, 'clarify', clarifyId, reply);
2556
+ return { ok: true };
2557
+ }
2558
+ sessionId = (await this.deps.service().idForMutation(sessionId));
2117
2559
  const entry = this.deps.pending.peekClarify(sessionId, clarifyId);
2118
2560
  if (!entry)
2119
2561
  return { ok: false, stale: true };
2120
- const response = typeof reply === 'string' ? reply : clarifyReply(entry, reply);
2562
+ // TAL-887: a password-manager answer goes to the Agent and nowhere else; `null` (a decline) is its empty reply.
2563
+ const vault = isVaultPrompt(entry);
2564
+ if (vault && typeof reply === 'string')
2565
+ return { ok: false, invalid: true };
2566
+ const response = vault ? vaultReply(entry, reply) : reply === null ? null : clarifyReply(entry, reply);
2121
2567
  if (response === null)
2122
2568
  return { ok: false, invalid: true };
2123
2569
  // A test-hook prompt has no parked Agent waiter: it is answered here.
2124
2570
  if (entry._injected === true) {
2125
2571
  this.deps.pending.resolveClarify(sessionId, str(entry.clarify_id));
2126
- return { ok: true, response };
2572
+ return vault ? { ok: true } : { ok: true, response };
2127
2573
  }
2128
2574
  const sidecar = this.deps.sidecar();
2129
2575
  if (!sidecar)
@@ -2133,14 +2579,16 @@ export class TurnRunner {
2133
2579
  ok = (await sidecar.call('clarify.respond', { session_id: sessionId, clarify_id: str(entry.clarify_id), response })).ok;
2134
2580
  }
2135
2581
  catch (error) {
2582
+ if (vault)
2583
+ return { ok: false, error: 'The Agent sidecar did not accept the secure answer; retry in a moment.' };
2136
2584
  return { ok: false, error: `The Agent sidecar did not accept the answer (${str(error.message)}); retry in a moment.` };
2137
2585
  }
2138
2586
  if (!ok)
2139
2587
  return { ok: false, stale: true };
2140
2588
  const { head } = this.deps.pending.resolveClarify(sessionId, str(entry.clarify_id));
2141
2589
  if (head)
2142
- this.emitToSession(sessionId, 'clarify', head);
2143
- return { ok: true, response };
2590
+ (await this.emitToSession(sessionId, 'clarify', head));
2591
+ return vault ? { ok: true } : { ok: true, response };
2144
2592
  }
2145
2593
  }
2146
2594
  /** The turn's answer: the last non-error assistant reply with content. */