@maudecode/talaria-web 1.17.2 → 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 (623) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.d.ts +1 -1
  3. package/dist/api/auth-raw.js +20 -20
  4. package/dist/api/auth-raw.js.map +1 -1
  5. package/dist/api/automation-raw.d.ts +7 -1
  6. package/dist/api/automation-raw.js +74 -27
  7. package/dist/api/automation-raw.js.map +1 -1
  8. package/dist/api/automation-router.d.ts +128 -23
  9. package/dist/api/automation-router.js +29 -13
  10. package/dist/api/automation-router.js.map +1 -1
  11. package/dist/api/chat-router.d.ts +1845 -40
  12. package/dist/api/chat-router.js +196 -114
  13. package/dist/api/chat-router.js.map +1 -1
  14. package/dist/api/git-router.d.ts +37 -3
  15. package/dist/api/git-router.js +47 -48
  16. package/dist/api/git-router.js.map +1 -1
  17. package/dist/api/raw-routes.d.ts +9 -1
  18. package/dist/api/raw-routes.js +286 -73
  19. package/dist/api/raw-routes.js.map +1 -1
  20. package/dist/api/router.d.ts +16 -6
  21. package/dist/api/router.js +28 -26
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/api/session-visibility.d.ts +4 -4
  24. package/dist/api/session-visibility.js +26 -16
  25. package/dist/api/session-visibility.js.map +1 -1
  26. package/dist/api/sessions-router.d.ts +3312 -131
  27. package/dist/api/sessions-router.js +287 -157
  28. package/dist/api/sessions-router.js.map +1 -1
  29. package/dist/api/settings-router.d.ts +457 -8
  30. package/dist/api/settings-router.js +54 -33
  31. package/dist/api/settings-router.js.map +1 -1
  32. package/dist/api/sse-routes.d.ts +7 -2
  33. package/dist/api/sse-routes.js +109 -62
  34. package/dist/api/sse-routes.js.map +1 -1
  35. package/dist/api/tools-raw.d.ts +5 -0
  36. package/dist/api/tools-raw.js +63 -55
  37. package/dist/api/tools-raw.js.map +1 -1
  38. package/dist/api/tools-router.d.ts +998 -85
  39. package/dist/api/tools-router.js +447 -61
  40. package/dist/api/tools-router.js.map +1 -1
  41. package/dist/api/transcribe-stream.d.ts +16 -0
  42. package/dist/api/transcribe-stream.js +172 -0
  43. package/dist/api/transcribe-stream.js.map +1 -0
  44. package/dist/app.d.ts +4 -1
  45. package/dist/app.js +98 -59
  46. package/dist/app.js.map +1 -1
  47. package/dist/auth/gate.js +15 -11
  48. package/dist/auth/gate.js.map +1 -1
  49. package/dist/auth/oidc.d.ts +5 -2
  50. package/dist/auth/oidc.js +8 -7
  51. package/dist/auth/oidc.js.map +1 -1
  52. package/dist/auth/passkeys.d.ts +15 -14
  53. package/dist/auth/passkeys.js +89 -91
  54. package/dist/auth/passkeys.js.map +1 -1
  55. package/dist/auth/store.d.ts +17 -7
  56. package/dist/auth/store.js +93 -52
  57. package/dist/auth/store.js.map +1 -1
  58. package/dist/bin/talaria-web.js +12 -81
  59. package/dist/bin/talaria-web.js.map +1 -1
  60. package/dist/cli/ctl.js +2 -1
  61. package/dist/cli/ctl.js.map +1 -1
  62. package/dist/cli/dotenv.d.ts +5 -0
  63. package/dist/cli/dotenv.js +15 -2
  64. package/dist/cli/dotenv.js.map +1 -1
  65. package/dist/cli/launcher.js +5 -5
  66. package/dist/cli/launcher.js.map +1 -1
  67. package/dist/cli/main.d.ts +1 -0
  68. package/dist/cli/main.js +85 -0
  69. package/dist/cli/main.js.map +1 -0
  70. package/dist/cli/supervise.d.ts +2 -0
  71. package/dist/cli/supervise.js +67 -4
  72. package/dist/cli/supervise.js.map +1 -1
  73. package/dist/config/agent-config.d.ts +34 -11
  74. package/dist/config/agent-config.js +116 -60
  75. package/dist/config/agent-config.js.map +1 -1
  76. package/dist/config.d.ts +4 -0
  77. package/dist/config.js +19 -6
  78. package/dist/config.js.map +1 -1
  79. package/dist/fs/atomic.d.ts +1 -0
  80. package/dist/fs/atomic.js +4 -1
  81. package/dist/fs/atomic.js.map +1 -1
  82. package/dist/fs/stat.d.ts +1 -0
  83. package/dist/fs/stat.js +11 -0
  84. package/dist/fs/stat.js.map +1 -0
  85. package/dist/http/context.d.ts +31 -12
  86. package/dist/http/context.js +60 -24
  87. package/dist/http/context.js.map +1 -1
  88. package/dist/http/csp.d.ts +2 -1
  89. package/dist/http/csp.js +3 -2
  90. package/dist/http/csp.js.map +1 -1
  91. package/dist/onboarding.d.ts +2 -2
  92. package/dist/onboarding.js +10 -8
  93. package/dist/onboarding.js.map +1 -1
  94. package/dist/profiles/profiles.d.ts +20 -4
  95. package/dist/profiles/profiles.js +115 -30
  96. package/dist/profiles/profiles.js.map +1 -1
  97. package/dist/projects.d.ts +15 -6
  98. package/dist/projects.js +55 -47
  99. package/dist/projects.js.map +1 -1
  100. package/dist/providers/catalog.d.ts +84 -14
  101. package/dist/providers/catalog.js +474 -151
  102. package/dist/providers/catalog.js.map +1 -1
  103. package/dist/providers/env-file.d.ts +10 -3
  104. package/dist/providers/env-file.js +24 -75
  105. package/dist/providers/env-file.js.map +1 -1
  106. package/dist/providers/runtime-env.d.ts +4 -2
  107. package/dist/providers/runtime-env.js +9 -6
  108. package/dist/providers/runtime-env.js.map +1 -1
  109. package/dist/providers/tables.d.ts +3 -1
  110. package/dist/providers/tables.js +4 -2
  111. package/dist/providers/tables.js.map +1 -1
  112. package/dist/raster-image.d.ts +7 -0
  113. package/dist/raster-image.js +267 -0
  114. package/dist/raster-image.js.map +1 -0
  115. package/dist/redact.js +342 -46
  116. package/dist/redact.js.map +1 -1
  117. package/dist/release.d.ts +7 -0
  118. package/dist/release.js +14 -0
  119. package/dist/release.js.map +1 -1
  120. package/dist/runtime.d.ts +5 -1
  121. package/dist/runtime.js +305 -96
  122. package/dist/runtime.js.map +1 -1
  123. package/dist/server.js +15 -0
  124. package/dist/server.js.map +1 -1
  125. package/dist/sessions/anchor.d.ts +9 -3
  126. package/dist/sessions/anchor.js +28 -36
  127. package/dist/sessions/anchor.js.map +1 -1
  128. package/dist/sessions/background-events.d.ts +2 -0
  129. package/dist/sessions/background-events.js +10 -0
  130. package/dist/sessions/background-events.js.map +1 -0
  131. package/dist/sessions/background-tasks.d.ts +26 -27
  132. package/dist/sessions/background-tasks.js +89 -97
  133. package/dist/sessions/background-tasks.js.map +1 -1
  134. package/dist/sessions/background-updates.d.ts +15 -8
  135. package/dist/sessions/background-updates.js +101 -25
  136. package/dist/sessions/background-updates.js.map +1 -1
  137. package/dist/sessions/cli-sessions.d.ts +27 -6
  138. package/dist/sessions/cli-sessions.js +152 -42
  139. package/dist/sessions/cli-sessions.js.map +1 -1
  140. package/dist/sessions/completions.d.ts +77 -9
  141. package/dist/sessions/completions.js +482 -45
  142. package/dist/sessions/completions.js.map +1 -1
  143. package/dist/sessions/compress.d.ts +8 -0
  144. package/dist/sessions/compress.js +2 -2
  145. package/dist/sessions/compress.js.map +1 -1
  146. package/dist/sessions/drafts.d.ts +14 -10
  147. package/dist/sessions/drafts.js +38 -43
  148. package/dist/sessions/drafts.js.map +1 -1
  149. package/dist/sessions/export.d.ts +5 -0
  150. package/dist/sessions/export.js +30 -0
  151. package/dist/sessions/export.js.map +1 -1
  152. package/dist/sessions/gateway-sessions.d.ts +46 -0
  153. package/dist/sessions/gateway-sessions.js +378 -0
  154. package/dist/sessions/gateway-sessions.js.map +1 -0
  155. package/dist/sessions/gateway-watcher.d.ts +7 -3
  156. package/dist/sessions/gateway-watcher.js +31 -19
  157. package/dist/sessions/gateway-watcher.js.map +1 -1
  158. package/dist/sessions/goals.d.ts +31 -0
  159. package/dist/sessions/goals.js +75 -0
  160. package/dist/sessions/goals.js.map +1 -0
  161. package/dist/sessions/handoff.d.ts +32 -0
  162. package/dist/sessions/handoff.js +114 -0
  163. package/dist/sessions/handoff.js.map +1 -0
  164. package/dist/sessions/journal.d.ts +5 -79
  165. package/dist/sessions/journal.js +15 -606
  166. package/dist/sessions/journal.js.map +1 -1
  167. package/dist/sessions/list.d.ts +31 -16
  168. package/dist/sessions/list.js +139 -129
  169. package/dist/sessions/list.js.map +1 -1
  170. package/dist/sessions/merge.d.ts +69 -13
  171. package/dist/sessions/merge.js +265 -64
  172. package/dist/sessions/merge.js.map +1 -1
  173. package/dist/sessions/notices.d.ts +22 -0
  174. package/dist/sessions/notices.js +72 -0
  175. package/dist/sessions/notices.js.map +1 -0
  176. package/dist/sessions/pending.d.ts +10 -4
  177. package/dist/sessions/pending.js +83 -7
  178. package/dist/sessions/pending.js.map +1 -1
  179. package/dist/sessions/prefix-index.d.ts +82 -0
  180. package/dist/sessions/prefix-index.js +232 -0
  181. package/dist/sessions/prefix-index.js.map +1 -0
  182. package/dist/sessions/recovery.d.ts +19 -0
  183. package/dist/sessions/recovery.js +322 -0
  184. package/dist/sessions/recovery.js.map +1 -0
  185. package/dist/sessions/relay.d.ts +38 -19
  186. package/dist/sessions/relay.js +302 -79
  187. package/dist/sessions/relay.js.map +1 -1
  188. package/dist/sessions/service.d.ts +260 -55
  189. package/dist/sessions/service.js +1448 -462
  190. package/dist/sessions/service.js.map +1 -1
  191. package/dist/sessions/session.d.ts +24 -2
  192. package/dist/sessions/session.js +69 -7
  193. package/dist/sessions/session.js.map +1 -1
  194. package/dist/sessions/shares.d.ts +7 -6
  195. package/dist/sessions/shares.js +22 -45
  196. package/dist/sessions/shares.js.map +1 -1
  197. package/dist/sessions/source-kind.js +1 -0
  198. package/dist/sessions/source-kind.js.map +1 -1
  199. package/dist/sessions/state-db-identity.d.ts +18 -0
  200. package/dist/sessions/state-db-identity.js +67 -0
  201. package/dist/sessions/state-db-identity.js.map +1 -0
  202. package/dist/sessions/state-db-worker.d.ts +1 -0
  203. package/dist/sessions/state-db-worker.js +35 -0
  204. package/dist/sessions/state-db-worker.js.map +1 -0
  205. package/dist/sessions/state-db.d.ts +113 -7
  206. package/dist/sessions/state-db.js +861 -44
  207. package/dist/sessions/state-db.js.map +1 -1
  208. package/dist/sessions/storage/backend.d.ts +162 -0
  209. package/dist/sessions/storage/backend.js +19 -0
  210. package/dist/sessions/storage/backend.js.map +1 -0
  211. package/dist/sessions/storage/conformance.d.ts +20 -0
  212. package/dist/sessions/storage/conformance.js +237 -0
  213. package/dist/sessions/storage/conformance.js.map +1 -0
  214. package/dist/sessions/storage/document.d.ts +16 -0
  215. package/dist/sessions/storage/document.js +44 -0
  216. package/dist/sessions/storage/document.js.map +1 -0
  217. package/dist/sessions/storage/json-documents.d.ts +23 -0
  218. package/dist/sessions/storage/json-documents.js +233 -0
  219. package/dist/sessions/storage/json-documents.js.map +1 -0
  220. package/dist/sessions/storage/json-files.d.ts +12 -0
  221. package/dist/sessions/storage/json-files.js +124 -0
  222. package/dist/sessions/storage/json-files.js.map +1 -0
  223. package/dist/sessions/storage/json-installation.d.ts +3 -0
  224. package/dist/sessions/storage/json-installation.js +31 -0
  225. package/dist/sessions/storage/json-installation.js.map +1 -0
  226. package/dist/sessions/storage/json-journal.d.ts +114 -0
  227. package/dist/sessions/storage/json-journal.js +686 -0
  228. package/dist/sessions/storage/json-journal.js.map +1 -0
  229. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  230. package/dist/sessions/storage/json-recovery.js +195 -0
  231. package/dist/sessions/storage/json-recovery.js.map +1 -0
  232. package/dist/sessions/storage/json.d.ts +68 -0
  233. package/dist/sessions/storage/json.js +258 -0
  234. package/dist/sessions/storage/json.js.map +1 -0
  235. package/dist/sessions/storage/row.d.ts +6 -0
  236. package/dist/sessions/storage/row.js +49 -0
  237. package/dist/sessions/storage/row.js.map +1 -0
  238. package/dist/sessions/storage/search-text.d.ts +4 -0
  239. package/dist/sessions/storage/search-text.js +16 -0
  240. package/dist/sessions/storage/search-text.js.map +1 -0
  241. package/dist/sessions/store.d.ts +65 -68
  242. package/dist/sessions/store.js +305 -602
  243. package/dist/sessions/store.js.map +1 -1
  244. package/dist/sessions/streams.d.ts +4 -0
  245. package/dist/sessions/streams.js +9 -1
  246. package/dist/sessions/streams.js.map +1 -1
  247. package/dist/sessions/titles.d.ts +2 -0
  248. package/dist/sessions/titles.js +5 -1
  249. package/dist/sessions/titles.js.map +1 -1
  250. package/dist/sessions/todo.d.ts +5 -0
  251. package/dist/sessions/todo.js +28 -21
  252. package/dist/sessions/todo.js.map +1 -1
  253. package/dist/sessions/tool-display.d.ts +5 -4
  254. package/dist/sessions/tool-display.js +30 -50
  255. package/dist/sessions/tool-display.js.map +1 -1
  256. package/dist/sessions/turn-context.d.ts +11 -2
  257. package/dist/sessions/turn-context.js +18 -11
  258. package/dist/sessions/turn-context.js.map +1 -1
  259. package/dist/sessions/turn.d.ts +122 -17
  260. package/dist/sessions/turn.js +939 -358
  261. package/dist/sessions/turn.js.map +1 -1
  262. package/dist/sessions/wakeup-pause.d.ts +54 -0
  263. package/dist/sessions/wakeup-pause.js +163 -0
  264. package/dist/sessions/wakeup-pause.js.map +1 -0
  265. package/dist/sessions/window.js +13 -12
  266. package/dist/sessions/window.js.map +1 -1
  267. package/dist/settings.d.ts +19 -7
  268. package/dist/settings.js +178 -184
  269. package/dist/settings.js.map +1 -1
  270. package/dist/sidecar/client.d.ts +17 -3
  271. package/dist/sidecar/client.js +78 -8
  272. package/dist/sidecar/client.js.map +1 -1
  273. package/dist/sidecar/discover.d.ts +2 -1
  274. package/dist/sidecar/discover.js +93 -10
  275. package/dist/sidecar/discover.js.map +1 -1
  276. package/dist/sidecar/env.d.ts +4 -0
  277. package/dist/sidecar/env.js +3 -0
  278. package/dist/sidecar/env.js.map +1 -0
  279. package/dist/sidecar/fake.d.ts +2 -2
  280. package/dist/sidecar/fake.js +19 -2
  281. package/dist/sidecar/fake.js.map +1 -1
  282. package/dist/startup.d.ts +13 -5
  283. package/dist/startup.js +48 -0
  284. package/dist/startup.js.map +1 -1
  285. package/dist/test/env-file.d.ts +5 -0
  286. package/dist/test/env-file.js +88 -0
  287. package/dist/test/env-file.js.map +1 -0
  288. package/dist/test/harness.d.ts +1 -0
  289. package/dist/test/harness.js +1 -1
  290. package/dist/test/harness.js.map +1 -1
  291. package/dist/test/passkeys.d.ts +22 -0
  292. package/dist/test/passkeys.js +62 -0
  293. package/dist/test/passkeys.js.map +1 -0
  294. package/dist/test/storage.d.ts +15 -0
  295. package/dist/test/storage.js +26 -0
  296. package/dist/test/storage.js.map +1 -0
  297. package/dist/test/ts-worker-hooks.d.ts +1 -0
  298. package/dist/test/ts-worker-hooks.js +24 -0
  299. package/dist/test/ts-worker-hooks.js.map +1 -0
  300. package/dist/text/diff.d.ts +9 -14
  301. package/dist/text/diff.js +74 -108
  302. package/dist/text/diff.js.map +1 -1
  303. package/dist/tools/commands.d.ts +6 -1
  304. package/dist/tools/commands.js +8 -2
  305. package/dist/tools/commands.js.map +1 -1
  306. package/dist/tools/connectors.d.ts +33 -0
  307. package/dist/tools/connectors.js +121 -0
  308. package/dist/tools/connectors.js.map +1 -0
  309. package/dist/tools/crons.d.ts +38 -1
  310. package/dist/tools/crons.js +122 -18
  311. package/dist/tools/crons.js.map +1 -1
  312. package/dist/tools/dashboard-plugins.d.ts +53 -0
  313. package/dist/tools/dashboard-plugins.js +188 -0
  314. package/dist/tools/dashboard-plugins.js.map +1 -0
  315. package/dist/tools/extensions.d.ts +18 -5
  316. package/dist/tools/extensions.js +344 -266
  317. package/dist/tools/extensions.js.map +1 -1
  318. package/dist/tools/gateway-restart.d.ts +62 -0
  319. package/dist/tools/gateway-restart.js +142 -0
  320. package/dist/tools/gateway-restart.js.map +1 -0
  321. package/dist/tools/health.d.ts +44 -0
  322. package/dist/tools/health.js +201 -46
  323. package/dist/tools/health.js.map +1 -1
  324. package/dist/tools/hygiene.d.ts +4 -3
  325. package/dist/tools/hygiene.js +23 -9
  326. package/dist/tools/hygiene.js.map +1 -1
  327. package/dist/tools/kanban.d.ts +4 -2
  328. package/dist/tools/kanban.js +28 -19
  329. package/dist/tools/kanban.js.map +1 -1
  330. package/dist/tools/mcp.d.ts +13 -0
  331. package/dist/tools/mcp.js +88 -0
  332. package/dist/tools/mcp.js.map +1 -1
  333. package/dist/tools/memory-masks.d.ts +5 -0
  334. package/dist/tools/memory-masks.js +256 -0
  335. package/dist/tools/memory-masks.js.map +1 -0
  336. package/dist/tools/memory.d.ts +29 -3
  337. package/dist/tools/memory.js +69 -27
  338. package/dist/tools/memory.js.map +1 -1
  339. package/dist/tools/plugin-settings.d.ts +15 -0
  340. package/dist/tools/plugin-settings.js +90 -0
  341. package/dist/tools/plugin-settings.js.map +1 -0
  342. package/dist/tools/project-os.d.ts +11 -0
  343. package/dist/tools/project-os.js +267 -0
  344. package/dist/tools/project-os.js.map +1 -0
  345. package/dist/tools/skills.d.ts +14 -5
  346. package/dist/tools/skills.js +49 -16
  347. package/dist/tools/skills.js.map +1 -1
  348. package/dist/tools/update-notifications.d.ts +34 -15
  349. package/dist/tools/update-notifications.js +141 -57
  350. package/dist/tools/update-notifications.js.map +1 -1
  351. package/dist/tools/update-rollback.d.ts +11 -0
  352. package/dist/tools/update-rollback.js +28 -0
  353. package/dist/tools/update-rollback.js.map +1 -0
  354. package/dist/tools/updates.d.ts +110 -32
  355. package/dist/tools/updates.js +1112 -294
  356. package/dist/tools/updates.js.map +1 -1
  357. package/dist/tools/wiki.d.ts +68 -0
  358. package/dist/tools/wiki.js +413 -0
  359. package/dist/tools/wiki.js.map +1 -0
  360. package/dist/util.d.ts +2 -0
  361. package/dist/util.js +5 -0
  362. package/dist/util.js.map +1 -1
  363. package/dist/workspace/escape.d.ts +61 -0
  364. package/dist/workspace/escape.js +149 -0
  365. package/dist/workspace/escape.js.map +1 -0
  366. package/dist/workspace/extract.d.ts +22 -0
  367. package/dist/workspace/extract.js +308 -0
  368. package/dist/workspace/extract.js.map +1 -0
  369. package/dist/workspace/fs.d.ts +12 -0
  370. package/dist/workspace/fs.js +92 -23
  371. package/dist/workspace/fs.js.map +1 -1
  372. package/dist/workspace/git.d.ts +34 -1
  373. package/dist/workspace/git.js +328 -35
  374. package/dist/workspace/git.js.map +1 -1
  375. package/dist/workspace/media.d.ts +16 -13
  376. package/dist/workspace/media.js +43 -61
  377. package/dist/workspace/media.js.map +1 -1
  378. package/dist/workspace/rollback.d.ts +36 -22
  379. package/dist/workspace/rollback.js +178 -272
  380. package/dist/workspace/rollback.js.map +1 -1
  381. package/dist/workspace/unzip.js +4 -1
  382. package/dist/workspace/unzip.js.map +1 -1
  383. package/dist/workspace/upload.d.ts +7 -4
  384. package/dist/workspace/upload.js +5 -2
  385. package/dist/workspace/upload.js.map +1 -1
  386. package/dist/workspace/workspaces.d.ts +15 -19
  387. package/dist/workspace/workspaces.js +55 -127
  388. package/dist/workspace/workspaces.js.map +1 -1
  389. package/dist/workspace/worktrees.d.ts +2 -1
  390. package/dist/workspace/worktrees.js +179 -42
  391. package/dist/workspace/worktrees.js.map +1 -1
  392. package/package.json +7 -3
  393. package/sidecar/agent_dependency.json +4 -4
  394. package/sidecar/agent_support.json +26 -0
  395. package/sidecar/scripts/record-fixtures.py +12 -3
  396. package/sidecar/scripts/replay_sidecar.py +32 -0
  397. package/sidecar/scripts/suite_runner.py +302 -0
  398. package/sidecar/scripts/test.sh +12 -4
  399. package/sidecar/talaria_sidecar/__init__.py +6 -1
  400. package/sidecar/talaria_sidecar/__main__.py +4 -2
  401. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  402. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  403. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  404. package/sidecar/talaria_sidecar/home.py +56 -3
  405. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  406. package/sidecar/talaria_sidecar/methods/aux.py +166 -24
  407. package/sidecar/talaria_sidecar/methods/chat.py +852 -135
  408. package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
  409. package/sidecar/talaria_sidecar/methods/commands.py +294 -4
  410. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  411. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  412. package/sidecar/talaria_sidecar/methods/cron.py +395 -36
  413. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  414. package/sidecar/talaria_sidecar/methods/gateway.py +212 -55
  415. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  416. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  417. package/sidecar/talaria_sidecar/methods/kanban.py +108 -34
  418. package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
  419. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  420. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  421. package/sidecar/talaria_sidecar/methods/process.py +218 -15
  422. package/sidecar/talaria_sidecar/methods/profiles.py +121 -13
  423. package/sidecar/talaria_sidecar/methods/providers.py +217 -28
  424. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  425. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  426. package/sidecar/talaria_sidecar/methods/state_db.py +316 -75
  427. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  428. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  429. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  430. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  431. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  432. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  433. package/sidecar/talaria_sidecar/rpc.py +45 -4
  434. package/sidecar/talaria_sidecar/runtime.py +98 -13
  435. package/sidecar/talaria_sidecar/speed.py +35 -0
  436. package/static/dist/FILES.txt +126 -126
  437. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  438. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  439. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  440. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  441. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  442. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  443. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  444. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  445. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  446. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  447. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  448. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  449. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  450. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  451. package/static/dist/assets/{_app.ext._extensionId-opyh9y0v.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  452. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  453. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  454. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  455. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  456. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  457. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  458. package/static/dist/assets/{_app.session._sessionId-bfd4h3id.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  459. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  460. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  461. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  462. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  463. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  464. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  465. package/static/dist/assets/{abnfDiagram-VCTEODGH-b2x1lqot.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  466. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  467. package/static/dist/assets/{architectureDiagram-5GKGNRK7-h38o5l7b.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  468. package/static/dist/assets/archive-b1j004vn.js +1 -0
  469. package/static/dist/assets/back-g74gkozu.js +1 -0
  470. package/static/dist/assets/{blockDiagram-I7D4REHJ-bqquxv2u.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  471. package/static/dist/assets/{c4Diagram-7LVT6UL2-f3fb8w6z.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  472. package/static/dist/assets/channel-es95o4y8.js +1 -0
  473. package/static/dist/assets/{chunk-4HAMMTFA-bzfyyzp3.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  474. package/static/dist/assets/{chunk-75Z2AOVW-ly99rfx5.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  475. package/static/dist/assets/{chunk-DU6HZSFF-bg6ecfqf.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  476. package/static/dist/assets/{chunk-F27PBJKO-e8k7mmqw.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  477. package/static/dist/assets/{chunk-GMAD6QVW-cmalnoaj.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  478. package/static/dist/assets/{chunk-GVQU2GXP-gmif5o01.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  479. package/static/dist/assets/{chunk-IMKFNOWR-f1nwwh6u.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  480. package/static/dist/assets/{chunk-L3NEJ4N5-oktfh5v7.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  481. package/static/dist/assets/{chunk-OSK3NFVY-b1sdx96z.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  482. package/static/dist/assets/{chunk-P2QGCYS3-fhu4nzxt.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  483. package/static/dist/assets/{chunk-POPQ4Y6H-em31rnef.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  484. package/static/dist/assets/{chunk-PWAF6VOD-bxhhyftw.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  485. package/static/dist/assets/{chunk-SHT3W25Y-fmrvwcav.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  486. package/static/dist/assets/{chunk-SVP7TREG-lymukl2j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  487. package/static/dist/assets/{chunk-TICWLB2K-lmza6k23.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  488. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  489. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  490. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  491. package/static/dist/assets/{copy-fw5b1pc6.js → copy-jamtd1pf.js} +1 -1
  492. package/static/dist/assets/{cynefin-OW5HDTMX-czlhbyhs.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  493. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-gq7mvswb.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  494. package/static/dist/assets/{dagre-GXQ25YYZ-cqdbf7dr.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  495. package/static/dist/assets/{diagram-S7CK7UJ4-o6arxa58.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  496. package/static/dist/assets/{diagram-UQ7AKVKN-nhnd68tz.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  497. package/static/dist/assets/{diagram-VSXAHHWV-dk2sclzn.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  498. package/static/dist/assets/{diagram-VX7I27RA-n3k5nyx3.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  499. package/static/dist/assets/{diagram-Z3DM3KII-m6q9cwdh.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  500. package/static/dist/assets/{ebnfDiagram-PWID7BFC-lsa5641p.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  501. package/static/dist/assets/{erDiagram-RLTQ6QDP-fo2ckbe6.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  502. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  503. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  504. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  505. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-nzqz6x9g.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  506. package/static/dist/assets/{gitGraph-4MIJSDKK-gukvcxe6.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  507. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-lqhdp8f0.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  508. package/static/dist/assets/{highlighted-body-KPVGNVTW-igw6cp39.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  509. package/static/dist/assets/host-hzm8n85n.js +1 -0
  510. package/static/dist/assets/index-drdcaqeq.css +2 -0
  511. package/static/dist/assets/index-jzm844he.js +11 -0
  512. package/static/dist/assets/{info-A6RAGUB7-gvu7a93l.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  513. package/static/dist/assets/{infoDiagram-27XIBGKW-m8t8g6w1.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  514. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-l07vkbgj.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  515. package/static/dist/assets/{journeyDiagram-3NMN7TZE-hybkgj8l.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  516. package/static/dist/assets/{kanban-definition-UXKFOSKX-by13uvtn.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  517. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  518. package/static/dist/assets/{line-osxq6fq7.js → line-h0ojzsvs.js} +1 -1
  519. package/static/dist/assets/link-k01ryaf9.js +1 -0
  520. package/static/dist/assets/login-bsae49ew.js +1 -0
  521. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  522. package/static/dist/assets/{mermaid-parser.core-i91oxmou.js → mermaid-parser.core-bnre27u8.js} +3 -3
  523. package/static/dist/assets/{mindmap-definition-YA3MSWOX-m82mx8ww.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  524. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  525. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  526. package/static/dist/assets/{packet-AYTQ26CC-nbtgge2o.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  527. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  528. package/static/dist/assets/{pegDiagram-XKGWAZYB-f1zxzga1.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  529. package/static/dist/assets/{pie-WAS4IAKB-of8vuhxs.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  530. package/static/dist/assets/{pieDiagram-E7YTZNPT-o7y8ftio.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  531. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-iqiwia1t.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  532. package/static/dist/assets/{radar-RG4KPBEZ-delnsy2v.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  533. package/static/dist/assets/{railroad-74A4TZTK-nspnyq9u.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  534. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  535. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  536. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  537. package/static/dist/assets/{railroadDiagram-O6MQD6OU-z41tqx5z.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  538. package/static/dist/assets/{refresh-cw-nt1dtsx4.js → refresh-cw-mqbkv2a3.js} +1 -1
  539. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  540. package/static/dist/assets/{requirementDiagram-BXWQKSXE-bdvif194.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  541. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-h73e0rol.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  542. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  543. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-iv95mb1z.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  544. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  545. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  546. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  547. package/static/dist/assets/{stateDiagram-D77RDMKH-k417ixmf.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  548. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  549. package/static/dist/assets/{swimlanes-42K2YHIH-esola70w.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  550. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  551. package/static/dist/assets/{timeline-definition-24CTP7MA-getrz2y8.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  552. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  553. package/static/dist/assets/{treeView-Q6P3EWNA-hc7ap1q7.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  554. package/static/dist/assets/{treemap-WGGIJYW6-b7k1syy5.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  555. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  556. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  557. package/static/dist/assets/{useParams-lhg566ew.js → useParams-gjc70qru.js} +1 -1
  558. package/static/dist/assets/{useSearch-lbrh6ejn.js → useSearch-e6ae539u.js} +1 -1
  559. package/static/dist/assets/{vennDiagram-4TSXK5OY-n6pcy5qb.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  560. package/static/dist/assets/{wardley-WFR3VGLG-lpa18vz1.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  561. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-kpclwwg7.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  562. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-paa2n8y0.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  563. package/static/dist/index.html +1 -1
  564. package/static/dist/sw.js +1 -1
  565. package/static/dist/assets/AppShell-fb2td9zi.js +0 -1
  566. package/static/dist/assets/ChatPage-cj96kiy9.js +0 -25
  567. package/static/dist/assets/Dialog-kxcg7ard.js +0 -1
  568. package/static/dist/assets/ExtensionRoute-jf7h0754.js +0 -1
  569. package/static/dist/assets/HubRoute-gmanpd5y.js +0 -2
  570. package/static/dist/assets/Markdown-mdwlvj3w.js +0 -598
  571. package/static/dist/assets/RightPanel-ftvrjlcp.js +0 -1
  572. package/static/dist/assets/Select-bilaypej.js +0 -1
  573. package/static/dist/assets/SessionListPanel-og8n36qs.js +0 -1
  574. package/static/dist/assets/SettingsLayout-jonjefmj.js +0 -1
  575. package/static/dist/assets/States-nmejrwrm.js +0 -1
  576. package/static/dist/assets/Toaster-nnl8zkt9.js +0 -1
  577. package/static/dist/assets/_app-gdidzd94.js +0 -1
  578. package/static/dist/assets/_app.index-hbarzbjx.js +0 -1
  579. package/static/dist/assets/_app.insights-luywlarz.js +0 -1
  580. package/static/dist/assets/_app.kanban-kincnbr8.js +0 -1
  581. package/static/dist/assets/_app.logs-oi2gr3gs.js +0 -1
  582. package/static/dist/assets/_app.memory-b6jfivt6.js +0 -1
  583. package/static/dist/assets/_app.profiles-lpvixmqp.js +0 -1
  584. package/static/dist/assets/_app.settings-vowx3iq0.js +0 -1
  585. package/static/dist/assets/_app.settings._section-cnntg1tu.js +0 -3
  586. package/static/dist/assets/_app.skills-fb57lpau.js +0 -1
  587. package/static/dist/assets/_app.tasks-eszpx1q7.js +0 -2
  588. package/static/dist/assets/_app.todos-nr05ac6w.js +0 -1
  589. package/static/dist/assets/_app.workspaces-d2luomu6.js +0 -1
  590. package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +0 -1
  591. package/static/dist/assets/back-bemaer09.js +0 -1
  592. package/static/dist/assets/cancel-drd0g6d9.js +0 -1
  593. package/static/dist/assets/channel-cpdgf867.js +0 -1
  594. package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +0 -1
  595. package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +0 -1
  596. package/static/dist/assets/copied-ima3pysb.js +0 -1
  597. package/static/dist/assets/endpoints-d33siokx.js +0 -64
  598. package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +0 -1
  599. package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +0 -1
  600. package/static/dist/assets/index-cxq58xb1.js +0 -11
  601. package/static/dist/assets/index-hgtx08n5.css +0 -2
  602. package/static/dist/assets/lazyRouteComponent-af0lvgjx.js +0 -1
  603. package/static/dist/assets/link-jyxicdhg.js +0 -1
  604. package/static/dist/assets/login-ec21t39x.js +0 -1
  605. package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +0 -1
  606. package/static/dist/assets/onboarding-btnltvyz.js +0 -1
  607. package/static/dist/assets/onboarding_model_label-dp7xffoh.js +0 -1
  608. package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +0 -1
  609. package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +0 -1
  610. package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +0 -1
  611. package/static/dist/assets/refresh-b4uams1i.js +0 -1
  612. package/static/dist/assets/saved-kg52csy7.js +0 -1
  613. package/static/dist/assets/settings_section_extensions_title-ej0iapxe.js +0 -1
  614. package/static/dist/assets/share._token-f97e94r0.js +0 -2
  615. package/static/dist/assets/sliders-horizontal-fj18it47.js +0 -1
  616. package/static/dist/assets/sse-fwpqtzbg.js +0 -1
  617. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +0 -1
  618. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +0 -8
  619. package/static/dist/assets/todoStore-eginf9h4.js +0 -1
  620. package/static/dist/assets/tts-lxdxnmjd.js +0 -1
  621. package/static/dist/assets/untitled-ovpfthg4.js +0 -1
  622. package/static/dist/assets/useForm-bcblv1rx.js +0 -1
  623. package/static/dist/assets/useLocale-fi5jb8ma.js +0 -1
@@ -6,37 +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 { 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, stoppedTurnContext, toolOutcome, withAttachmentObjects, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withToolCallOutcomes, workspaceContextPrefix, withoutMaxIterationSummaryRequest, withNativeImagesRestored, withoutNativeImages, withoutToolImages } from './merge.js';
30
- import { mayBecomeSilentReply, turnOrigin, withBackgroundUpdates } from './background-updates.js';
31
- import { fallbackTitleFromExchange, firstExchangeSnippets, isGenericFallbackTitle, latestExchangeSnippets, looksInvalidGeneratedTitle, looksLikeDefaultCliTitle, sanitizeGeneratedTitle, titleLanguageMismatch, titlePrompts } from './titles.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';
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';
34
+ import { CHECKPOINT_STORE_BUSY_MESSAGE } from '../workspace/rollback.js';
33
35
  import { str } from '../util.js';
34
36
  import { toolEditDiff } from './tool-display.js';
37
+ import { recordWakeupPause } from './wakeup-pause.js';
38
+ import { displayName } from '../providers/catalog.js';
35
39
  export const CHAT_LOCK_WAIT_SECONDS = 2;
36
40
  const IMAGE_MODE_TIMEOUT_MS = 15_000;
37
41
  const TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT = 80;
38
42
  /** TAL-397: streamed text reports the live meter at most this often (seconds); a counter change reports at once. */
39
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']);
40
46
  /** How long a user's message waits for a stopped background turn to unwind before its own turn is admitted. */
41
47
  const BACKGROUND_UNWIND_WAIT_MS = 30_000;
42
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.' };
@@ -63,6 +69,9 @@ export function classifyProviderError(errStr, opts = {}) {
63
69
  const lower = errStr.toLowerCase();
64
70
  if (opts.compressionExhausted)
65
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.' };
66
75
  if (opts.condition === 'credential_missing')
67
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.' };
68
77
  const cancelled = ['cancelled by user', 'canceled by user', 'user cancelled', 'user canceled', 'task cancelled', 'task canceled', 'cancellederror'].some((k) => lower.includes(k));
@@ -71,7 +80,7 @@ export function classifyProviderError(errStr, opts = {}) {
71
80
  if (['interrupted by user', 'response interrupted', 'operation interrupted', 'operation was interrupted', 'operation aborted', 'request was aborted', 'aborterror'].some((k) => lower.includes(k))) {
72
81
  return { label: 'Response interrupted', type: 'interrupted', hint: 'The run stopped before a provider response completed. If you did not cancel it, try again.' };
73
82
  }
74
- const quota = ['insufficient_quota', 'insufficient credits', 'out of credits', 'credits exhausted', 'quota exceeded', 'usage limit', 'plan limit', 'billing'].some((k) => lower.includes(k));
83
+ const quota = ['insufficient_quota', 'insufficient credits', 'out of credits', 'credits exhausted', 'quota exceeded', 'usage limit', 'plan limit', 'billing', 'more credits', 'can only afford', 'fewer max_tokens', 'credit balance', 'exceeded your current quota', 'usage_limit_exceeded', 'reached the limit of messages', 'used up your usage'].some((k) => lower.includes(k));
75
84
  const poolEmpty = lower.includes('credential(s) exhausted') || lower.includes('credentials exhausted') || (lower.includes('credential') && lower.includes('exhausted'));
76
85
  const auth = !quota && !poolEmpty && (errStr.includes('401') || lower.includes('authentication') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('invalid_api_key') || lower.includes('no cookie auth credentials'));
77
86
  const notFound = errStr.includes('404') || lower.includes('not found') || lower.includes('does not exist') || lower.includes('model_not_found') || lower.includes('invalid model') || lower.includes('does not match any known model') || lower.includes('unknown model');
@@ -125,8 +134,6 @@ export function sniffImageMime(bytes, declared = '') {
125
134
  return 'image/bmp';
126
135
  return null;
127
136
  }
128
- const COMPRESSION_RECOVERY_TERMINAL_STATE = 'compression_exhausted';
129
- const COMPRESSION_RECOVERY_ACTION_START_FOCUSED = 'start_focused_continuation';
130
137
  /** Python `build_compression_recovery_payload` + `stamp_compression_exhausted_recovery`. */
131
138
  export function stampCompressionExhaustedRecovery(s, message, details) {
132
139
  const positive = (v) => { const n = Math.trunc(Number(v) || 0); return n > 0 ? n : 0; };
@@ -209,8 +216,14 @@ 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();
225
+ /** The last adaptive title refresh per session; a successor turn's refresh runs after it (TAL-589). */
226
+ titleRefreshes = new Map();
214
227
  /**
215
228
  * TAL-364: what a Stop needs to write the turn's model context: its starting context, the prompt the Agent got, and
216
229
  * whether a settlement already used a canonical checkpoint (which marks the stop boundary; later work never replaces it).
@@ -220,25 +233,134 @@ export class TurnRunner {
220
233
  stopContexts = new Map();
221
234
  /** Each turn's worker, so a stopped background turn can be awaited before the user's turn takes the session. */
222
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());
223
240
  constructor(deps) {
224
241
  this.deps = deps;
225
242
  }
226
243
  get registry() { return this.deps.registry; }
227
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
+ }
228
345
  /** Python `_start_chat_stream_for_session`: persist pending state, register the channel, launch the worker. */
229
- start(session, opts) {
346
+ async admit(session, opts, releaseStopHold = false) {
230
347
  const s = session;
348
+ const readOnly = (await this.deps.service().readOnlyReason(s));
349
+ if (readOnly)
350
+ return { error: readOnly, _status: 403 };
231
351
  if (this.deps.updateInProgress?.())
232
352
  return { error: 'Web is updating. Retry after it restarts.', _status: 503 };
233
353
  if (this.deps.profileDeleting?.(s.profile ?? null))
234
354
  return { error: `Profile '${str(s.profile)}' is being deleted.`, _status: 409 };
235
355
  if (this.deps.workspaceBusy?.(opts.workspace))
236
356
  return { error: WORKSPACE_BUSY_MESSAGE, _status: 409 };
357
+ if (this.deps.checkpointStoreBusy?.(s.profile ?? null))
358
+ return { error: CHECKPOINT_STORE_BUSY_MESSAGE, _status: 409 };
237
359
  const locked = s.active_stream_id;
238
360
  if (locked) {
239
361
  if (this.registry.liveIds.has(locked) || this.registry.activeRuns.has(locked))
240
362
  return { error: 'session already has an active stream', active_stream_id: locked, _status: 409 };
241
- this.deps.service().clearStaleStreamState(s);
363
+ await this.deps.service().clearStaleStreamState(s);
242
364
  if (s.active_stream_id)
243
365
  return { error: 'session already has an active stream', active_stream_id: s.active_stream_id, _status: 409 };
244
366
  }
@@ -247,11 +369,21 @@ export class TurnRunner {
247
369
  if (blocking)
248
370
  return { error: 'session already has an active stream', active_stream_id: blocking, _status: 409 };
249
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
+ }
250
376
  const streamId = randomUUID().replace(/-/g, '');
251
377
  const wasHiddenEmpty = !s.messages.length && !s.context_messages.length && !s.hasPendingPrompt;
252
378
  const attachments = opts.attachments ?? [];
253
379
  // A background wakeup stays one in a forked session too (TAL-460).
254
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);
255
387
  s.workspace = opts.workspace;
256
388
  s.model = opts.model;
257
389
  s.model_provider = opts.modelProvider;
@@ -261,6 +393,12 @@ export class TurnRunner {
261
393
  s.pending_attachments = attachments;
262
394
  s.pending_started_at = this.deps.now();
263
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
+ }
264
402
  if (['', 'Untitled', 'New Chat'].includes(str(s.title).trim())) {
265
403
  const provisional = titleFrom([{ role: 'user', content: opts.msg }], s.title || 'Untitled');
266
404
  if (provisional && !['', 'Untitled', 'New Chat'].includes(provisional.trim()))
@@ -268,19 +406,39 @@ export class TurnRunner {
268
406
  }
269
407
  if (this.deps.saveMode() === 'eager')
270
408
  this.checkpointUserMessage(s, opts.msg, attachments, s.pending_started_at, source, streamId);
271
- 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
+ }
272
430
  this.registry.writebackOwners.set(s.session_id, streamId);
273
431
  // A new chat appears in the list; every turn tells open clients a run started (TAL-434).
274
432
  this.deps.events.publish(wasHiddenEmpty ? 'session_new' : 'turn_started', { profile: s.profile, sessionId: s.session_id });
275
433
  try {
276
- this.deps.workspaces.setLastWorkspace(opts.workspace, s.profile);
434
+ (await this.deps.workspaces.setLastWorkspace(opts.workspace, s.profile));
277
435
  }
278
436
  catch { /* best effort */ }
279
437
  const channel = this.registry.create(streamId, s.session_id);
438
+ admission.admitting = false;
280
439
  if (opts.goalRelated)
281
440
  this.registry.goalRelated.add(streamId);
282
- 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) });
283
- 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) => {
284
442
  this.deps.log(`[webui] ERROR turn ${streamId} crashed\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
285
443
  }).finally(() => { this.workers.delete(streamId); });
286
444
  this.workers.set(streamId, worker);
@@ -291,6 +449,10 @@ export class TurnRunner {
291
449
  response.effective_model_provider = opts.modelProvider;
292
450
  return response;
293
451
  }
452
+ /** Resolves once the stream's worker has ended (settled, failed, or torn down); it never rejects. */
453
+ finished(streamId) {
454
+ return this.workers.get(streamId) ?? Promise.resolve();
455
+ }
294
456
  /** Python `_checkpoint_user_message_for_eager_session_save`. */
295
457
  checkpointUserMessage(s, msg, attachments, startedAt, source, turnId) {
296
458
  const latest = s.messages[s.messages.length - 1];
@@ -299,20 +461,69 @@ export class TurnRunner {
299
461
  s.messages.push(pendingUserRow(msg, attachments, startedAt, source, turnId));
300
462
  }
301
463
  // ── worker ───────────────────────────────────────────────────────────────
302
- async run(sessionId, streamId, channel, opts) {
464
+ async run(sessionId, streamId, channel, opts, compactionDue) {
303
465
  const { deps } = this;
304
466
  let writer = null;
305
467
  try {
306
- writer = deps.journal.writer(sessionId, streamId);
468
+ writer = (await deps.journal.writer(sessionId, streamId));
307
469
  this.writers.set(streamId, writer);
308
470
  }
309
471
  catch (error) {
310
472
  deps.log(`[webui] WARNING: run journal degraded for stream ${streamId}: ${error.message}`);
311
473
  }
312
- const put = (event, data, meta = {}) => {
474
+ const failure = { apperror: null, cancelled: false };
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;
520
+ if (event === 'apperror')
521
+ failure.apperror = data;
522
+ else if (event === 'cancel')
523
+ failure.cancelled = true;
313
524
  for (const steerEvent of this.takeSteerEventsBefore(streamId, event))
314
- put(steerEvent[0], steerEvent[1]);
315
- 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))
316
527
  return;
317
528
  // `cancel()` already wrote the terminal row and closed the stream: the worker's unwind adds no second one.
318
529
  if (event === 'cancel' && !this.registry.streams.has(streamId)) {
@@ -325,7 +536,7 @@ export class TurnRunner {
325
536
  let eventId = null;
326
537
  if (writer) {
327
538
  try {
328
- eventId = writer.appendSseEvent(event, data, meta).event_id;
539
+ eventId = (await writer.appendSseEvent(event, data, meta)).event_id;
329
540
  }
330
541
  catch (error) {
331
542
  this.registry.degradedJournals.add(streamId);
@@ -347,25 +558,43 @@ export class TurnRunner {
347
558
  const activeRun = this.registry.activeRuns.get(streamId);
348
559
  let s;
349
560
  try {
350
- s = deps.store.get(sessionId);
561
+ s = (await deps.store.get(sessionId));
351
562
  }
352
563
  catch {
353
- put('apperror', { type: 'error', terminal_state: 'error', message: 'Session not found', session_id: sessionId });
354
- 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);
355
566
  return;
356
567
  }
357
568
  const msgText = opts.msg;
358
- const previousMessages = structuredClone(s.messages);
359
- // TAL-493: one state.db read builds the history and marks where this turn's own rows begin.
360
- const startRead = deps.service().stateDbRead(s);
361
- const previousContext = structuredClone(deps.service().modelContext(s, startRead.rows));
362
- // Python `_sanitize_messages_for_agent`: the model never sees display-only rows or a replayed cancelled prompt, and
363
- // the Agent gets back the exact bytes it sent (TAL-541).
364
- const apiHistory = sanitizeMessagesForApi(previousContext, { preserveApiContent: true });
365
- const workspaceCtx = workspaceContextPrefix(opts.workspace);
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
+ };
366
582
  // Before the first await: a Stop can land at any point after admission.
367
- // An eager save already put this turn's prompt in the transcript; the Stop fallback appends it once itself.
368
- this.stopContexts.set(streamId, { previousContext: previousContext.filter((m) => m._turn_id !== streamId), historyLength: apiHistory.length, prompt: workspaceCtx + 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
+ }
369
598
  const activeTurnToken = buildActiveTurnToken(streamId, s.pending_started_at);
370
599
  const sidecar = deps.sidecar();
371
600
  const partialText = this.registry.partialText.get(streamId) ?? [];
@@ -382,21 +611,19 @@ export class TurnRunner {
382
611
  // TAL-397: the live meter. Counters start from the session's and follow the Agent's; the rate counts text deltas.
383
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 };
384
613
  const meter = { deltas: 0, first: 0, last: 0, sentAt: null };
385
- const putMetering = (force) => {
386
- const now = deps.now();
614
+ const putMetering = async (force, now = deps.now()) => {
387
615
  if (!force && meter.sentAt !== null && now - meter.sentAt < METERING_INTERVAL_S)
388
616
  return;
389
617
  meter.sentAt = now;
390
618
  const tps = meter.deltas && meter.last > meter.first ? Math.round((meter.deltas / (meter.last - meter.first)) * 10) / 10 : null;
391
- 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 });
392
620
  };
393
- const meterDelta = () => {
394
- const now = deps.now();
621
+ const meterDelta = async (now = deps.now()) => {
395
622
  if (!meter.deltas)
396
623
  meter.first = now;
397
624
  meter.last = now;
398
625
  meter.deltas += 1;
399
- putMetering(false);
626
+ await putMetering(false, now);
400
627
  };
401
628
  // TAL-460: a background turn's text is held back while it could still be a silence marker, so one never flashes by.
402
629
  // ponytail: checks the turn's whole text, so a marker after earlier prose streams until the settled row hides it;
@@ -405,13 +632,11 @@ export class TurnRunner {
405
632
  let heldText = '';
406
633
  const titles = new ReasoningTitleTracker();
407
634
  let capturedTerminalError = null;
408
- const controller = new AbortController();
409
- this.abortControllers.set(streamId, controller);
410
635
  const attachments = opts.attachments ?? [];
411
636
  // TAL-545: one image mode decides both this turn's upload and the replayed history, which a text-mode turn sends
412
637
  // without native images; only a turn with an image on either side asks for it.
413
638
  const imageMode = attachments.some(isImageAttachment) || hasNativeImages(apiHistory) ? await this.imageInputMode(sessionId, s, opts, controller.signal) : null;
414
- const userMessage = this.buildUserMessage(workspaceCtx, msgText, attachments, opts.workspace, sessionId, imageMode);
639
+ const userMessage = this.buildUserMessage(opts.prompt ?? msgText, attachments, opts.workspace, sessionId, imageMode);
415
640
  const conversationHistory = imageMode === 'text' ? withoutNativeImages(apiHistory) : apiHistory;
416
641
  // The prompt the Agent actually gets, native image parts included.
417
642
  const stop = this.stopContexts.get(streamId);
@@ -420,15 +645,25 @@ export class TurnRunner {
420
645
  if (activeRun)
421
646
  activeRun.phase = 'running';
422
647
  const settledAt = { value: false };
423
- let failed = false;
648
+ let succeeded = false;
649
+ let ownershipRefused = false;
650
+ let agentDispatched = false;
651
+ let streamWrites = Promise.resolve();
652
+ let streamWriteError;
424
653
  // TAL-424: steers an errored turn ended without taking, sent as one follow-up turn once this one is torn down.
425
654
  let followUp = [];
426
655
  try {
427
656
  if (!sidecar)
428
657
  throw new SidecarError('The Agent sidecar is not running; chat is unavailable until it starts.', { condition: 'sidecar_unavailable' });
429
658
  // Python: budgets, reasoning config, personality and delivery context come from the profile's config.yaml; the
430
- // system message carries the frozen session workspace, the WebUI guidance rides as the ephemeral prompt.
659
+ // system message carries the frozen session workspace, the WebUI guidance rides as the ephemeral prompt with this
660
+ // turn's workspace (TAL-710: never a tag on the user's message, which the Agent can persist from memory).
431
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
+ : [];
432
667
  // The sidecar's per-session YOLO state does not survive its restarts: push the local flag (either way) before
433
668
  // the turn so a stale sidecar-side enable cannot auto-approve a session the UI reports as guarded.
434
669
  await this.withYoloLock(sessionId, async () => { try {
@@ -438,154 +673,226 @@ export class TurnRunner {
438
673
  // Last yield before `chat.start`: a cancel that landed during the attachment, config or YOLO awaits must not
439
674
  // start an Agent turn (the client also refuses an already-aborted signal outright).
440
675
  if (this.registry.cancelled.has(streamId)) {
441
- this.finalizeCancelled(s, streamId, opts.ephemeral);
442
- put('cancel', this.cancelFrame(sessionId));
676
+ await this.finalizeCancelled(s, streamId, opts.ephemeral);
677
+ await put('cancel', (await this.cancelFrame(sessionId)));
443
678
  return;
444
679
  }
445
- const frozenWorkspace = str(s.created_workspace) || str(s.workspace);
446
680
  const turnContext = {
447
- system_message: workspaceSystemMessage(frozenWorkspace),
448
- ephemeral_system_prompt: webuiEphemeralSystemPrompt({ config: cfg, personality: str(s.personality) || null, sessionId, profile: s.profile ?? null, workspace: frozenWorkspace, hermesHome: deps.profileHome(s.profile), homeDisplay: deps.homeDisplay?.() ?? '~/.hermes', background: turnOrigin(opts.source) === 'background' }),
449
- max_iterations: processWakeupMaxIterations(maxIterationsFromConfig(cfg), opts.source ?? 'webui', deps.env ?? {}),
450
- max_tokens: maxTokensFromConfig(cfg),
451
- reasoning_config: reasoningConfigFromConfig(cfg, opts.modelProvider ?? s.model_provider ?? null),
681
+ system_message: workspaceSystemMessage(str(s.created_workspace) || str(s.workspace)),
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 ?? {}),
452
684
  };
453
685
  // Python `_public_prefill_context_status`: the session-recall prefill hook was dropped with TAL-245, so the
454
686
  // context frame always reports the not-configured shape.
455
687
  const persistentBefore = persistentStateSnapshot(deps.profileHome(s.profile));
456
- 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;
457
692
  const result = await sidecar.call('chat.start', {
458
- 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,
459
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 } : {}),
460
698
  }, {
461
699
  signal: controller.signal,
462
700
  timeoutMs: 0,
463
701
  onStream: (frame) => {
464
- const data = isDict(frame.data) ? frame.data : {};
465
- switch (frame.event) {
466
- case 'token': {
467
- tokenSent = true;
468
- firstTokenAt ??= deps.now();
469
- // Visible text closes the reasoning segment: a stable title snapshot (or a clear) goes out first.
470
- const stable = titles.stableSnapshot(reasoningText.join(''));
471
- if (stable !== null)
472
- put('reasoning', { text: '', titles: stable });
473
- partialText.push(str(data.text));
474
- if (holdSilence && mayBecomeSilentReply(partialText.join(''))) {
475
- heldText += str(data.text);
476
- 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);
477
724
  return;
478
725
  }
479
- put('token', { text: heldText + str(data.text) });
480
- heldText = '';
481
- meterDelta();
482
- return;
483
- }
484
- case 'reasoning': {
485
- if (partialText.length && !reasoningText.length)
486
- titles.resetSegment();
487
- reasoningText.push(str(data.text));
488
- // Python `reasoning_event_payload`: bold or first-line titles ride on the delta they complete.
489
- put('reasoning', titles.prepare(reasoningEventPayload(str(data.text), reasoningText.join(''))));
490
- meterDelta();
491
- return;
492
- }
493
- case 'usage': {
494
- const usage = ChatUsageSchema.safeParse(data);
495
- if (usage.success) {
496
- applyAgentUsage(liveUsage, usage.data);
497
- 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;
498
734
  }
499
- putMetering(true);
500
- return;
501
- }
502
- case 'steer_pending':
503
- // TAL-424: while a withdraw or Send now rewrites the Agent's queue its pending text is ambiguous; it settles after.
504
- if (this.steerRewrites.has(streamId)) {
505
- 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);
506
742
  return;
507
743
  }
508
- this.settleConsumedSteers(sessionId, streamId, str(data.text));
509
- return;
510
- case 'tool': {
511
- const call = { name: data.name, args: data.args ?? {}, tid: str(data.tid), done: false };
512
- liveToolCalls.push(call);
513
- toolIds.set(call, call.tid || mintToolId());
514
- toolStartedAt.set(call, deps.now());
515
- const redacted = deps.redactEnabled();
516
- put('tool', publicToolFrame(withToolId(data, toolIds.get(call)), redacted), { redacted });
517
- return;
518
- }
519
- case 'tool_complete': {
520
- // TAL-313: the server decides failure from the sidecar's raw result, which never leaves the server; TAL-315: so
521
- // are its display sections, which keep the stderr and exit code the flat preview drops.
522
- const { raw_result: rawResult, todo_result: todoResult, result_diff: resultDiff, ...complete } = data;
523
- const outcome = toolOutcome(rawResult);
524
- complete.is_error = outcome.is_error;
525
- if (rawResult !== undefined)
526
- complete.result_view = outcome.result_view;
527
- // TAL-448: a file edit's change, counted over the whole diff the sidecar forwards; redacted with the frame.
528
- const editDiff = toolEditDiff(data.name, { diff: resultDiff });
529
- if (editDiff)
530
- complete.edit_diff = editDiff;
531
- const tc = liveToolCalls[completedToolIndex(liveToolCalls, str(data.tid), data.name)];
532
- if (tc) {
533
- const startedAt = toolStartedAt.get(tc);
534
- if (startedAt !== undefined)
535
- complete.duration = Math.round(Math.max(0, deps.now() - startedAt) * 1000) / 1000;
536
- // TAL-315: the decided view too, so a failed or cancelled turn's snapshot keeps the sections shown live.
537
- 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;
538
754
  }
539
- const id = (tc && toolIds.get(tc)) || str(data.tid) || mintToolId();
540
- this.lastCompletedTool.set(streamId, id);
541
- const redacted = deps.redactEnabled();
542
- put('tool_complete', publicToolFrame(withToolId(complete, id), redacted), { redacted });
543
- // TAL-397: the todo tool's result is the session's new list; the settled session derives the same one.
544
- const todos = data.name === 'todo' ? parseTodoToolResult(todoResult) : null;
545
- if (todos) {
546
- todos.ts = deps.now();
547
- this.liveTodos.set(streamId, todos);
548
- 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;
549
763
  }
550
- return;
551
- }
552
- // Python: the live chat frame carries the queue head plus depth, not the entry that just arrived.
553
- case 'approval': {
554
- deps.pending.submitApproval(sessionId, { ...data, session_id: sessionId });
555
- const { pending, pending_count } = deps.pending.approvalPending(sessionId);
556
- put('approval', { ...(pending ?? data), pending_count });
557
- return;
558
- }
559
- // TAL-514: the Agent stopped waiting on an approval (answer, timeout, or interrupt); withdraw its copy.
560
- case 'approval_resolved': {
561
- 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
+ }
562
797
  return;
563
- deps.pending.resolveApproval(sessionId, str(data.approval_id));
564
- put(...this.approvalHeadFrame(sessionId));
565
- return;
566
- }
567
- case 'clarify': {
568
- deps.pending.submitClarify(sessionId, { ...data, session_id: sessionId, kind: 'clarify' });
569
- const head = deps.pending.clarifyHeadFrame(sessionId);
570
- if (head)
571
- put('clarify', head);
572
- return;
573
- }
574
- case 'clarify_resolved': {
575
- const { head } = deps.pending.resolveClarify(sessionId, str(data.clarify_id));
576
- if (head)
577
- put('clarify', head);
578
- 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);
579
889
  }
580
- case 'status':
581
- if (data.kind === 'terminal_error' && !capturedTerminalError)
582
- capturedTerminalError = str(data.message);
583
- return;
584
- default:
585
- put(frame.event, data);
586
- }
890
+ }).catch((error) => { streamWriteError = error instanceof Error ? error : new Error(String(error)); });
587
891
  },
588
892
  });
893
+ await streamWrites;
894
+ if (streamWriteError !== undefined)
895
+ throw streamWriteError;
589
896
  settledAt.value = true;
590
897
  // TAL-545: every transcript the Agent hands back settles with the history's images, not the request's projection.
591
898
  const withImages = (rows) => withNativeImagesRestored(rows, conversationHistory, apiHistory);
@@ -598,14 +905,14 @@ export class TurnRunner {
598
905
  const interrupted = await this.interrupts.get(streamId);
599
906
  applyAgentUsage(liveUsage, result.usage);
600
907
  this.liveUsage.set(streamId, liveUsage);
601
- this.finalizeCancelled(s, streamId, opts.ephemeral, interrupted?.checkpoint ? withImages(interrupted.checkpoint) : result.messages);
602
- 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)));
603
910
  return;
604
911
  }
605
912
  // Writeback only while this stream still owns the session (Python `_stream_writeback_is_current`).
606
913
  let current;
607
914
  try {
608
- current = deps.store.get(sessionId);
915
+ current = (await deps.store.get(sessionId));
609
916
  }
610
917
  catch {
611
918
  current = s;
@@ -641,16 +948,15 @@ export class TurnRunner {
641
948
  await this.steerRewrites.get(streamId);
642
949
  const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, str(result.pending_steer), 'followup');
643
950
  followUp = leftovers;
644
- this.persistError(s, streamId, classification.label, payload, activeTurnToken, resultMessages);
951
+ await this.persistError(s, streamId, classification.label, payload, activeTurnToken, resultMessages, result.provider);
645
952
  // TAL-512: a btw error carries no session: its journaled frame must not keep a copy of the parent conversation.
646
953
  if (!opts.ephemeral)
647
- payload.session = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
954
+ payload.session = (await this.publicTerminalSession(s));
648
955
  payload.session_id = s.session_id;
649
956
  payload.old_session_id = sessionId;
650
957
  for (const [event, data] of steerEvents)
651
- put(event, data);
652
- put('apperror', payload);
653
- failed = true;
958
+ (await put(event, data));
959
+ await put('apperror', payload);
654
960
  return;
655
961
  }
656
962
  // TAL-512: a btw turn passes the failure check above first, so its last assistant row is this turn's reply.
@@ -663,11 +969,12 @@ export class TurnRunner {
663
969
  break;
664
970
  }
665
971
  }
666
- opts.onDone?.(answer);
972
+ succeeded = true;
973
+ await opts.onDone?.(answer);
667
974
  // Python `_ephemeral_session_payload`: only role and content leave the server for a btw turn.
668
- 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' });
669
976
  try {
670
- rmSync(deps.store.pathFor(sessionId), { force: true });
977
+ await deps.store.delete(sessionId, { tombstone: false });
671
978
  }
672
979
  catch { /* ignore */ }
673
980
  deps.store.sessions.delete(sessionId);
@@ -694,8 +1001,9 @@ export class TurnRunner {
694
1001
  const { events: steerEvents, leftovers } = this.finalizeSteers(streamId, result.pending_steer, 'followup');
695
1002
  s.messages = withoutToolImages(mergeDisplayMessagesAfterAgentResult(previousMessages, previousContext, resultMessages, msgText, { source: opts.source ?? 'webui', activeTurnToken, now: deps.now(), turnId: streamId, attachments: opts.attachments ?? [] }));
696
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;
697
1006
  if (result.compressed) {
698
- s.post_compression_context_tokens_estimate = estimate;
699
1007
  // TAL-540: the anchor is the last visible row before this turn (never its eager checkpoint); the merged transcript
700
1008
  // holds no compaction marker, so the summary is the model context's newest one.
701
1009
  const before = visibleMessagesForAnchor(previousMessages.filter((m) => m._turn_id !== streamId));
@@ -720,20 +1028,13 @@ export class TurnRunner {
720
1028
  s.threshold_tokens = result.context.threshold_tokens;
721
1029
  if (typeof result.context.last_prompt_tokens === 'number')
722
1030
  s.last_prompt_tokens = result.context.last_prompt_tokens;
723
- if (result.compressed) {
724
- // TAL-540, Python `_live_usage_snapshot`: the counters and the ring's post-compression figures, so clients update before `done`.
725
- const usage = {
726
- ...meteringUsage(s), context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
727
- post_compression_context_tokens_estimate: s.post_compression_context_tokens_estimate, ...contextUsage(s, () => deps.service().deps.contextLengthFor(s.model, s.model_provider)),
728
- };
729
- put('compressed', { session_id: sessionId, old_session_id: sessionId, new_session_id: sessionId, continuation_session_id: sessionId, message: 'Compression finished', usage });
730
- }
1031
+ s.next_turn_threshold_tokens = result.context.next_turn_threshold_tokens ?? null;
1032
+ if (result.compressed)
1033
+ (await put('compressed', this.compressedFrame(s)));
731
1034
  s.tool_calls = extractToolCallsFromMessages(s.messages, liveToolCalls, s.tool_calls);
732
- s.active_stream_id = null;
733
- s.pending_user_message = null;
734
- s.pending_attachments = [];
735
- s.pending_started_at = null;
736
- s.pending_user_source = null;
1035
+ this.clearPendingTurn(s);
1036
+ // TAL-576: a turn that ran means the provider has usable credentials again.
1037
+ s.process_wakeup_pause = null;
737
1038
  const attachments = opts.attachments ?? [];
738
1039
  if (attachments.length) {
739
1040
  for (let i = s.messages.length - 1; i >= 0; i -= 1) {
@@ -786,8 +1087,9 @@ export class TurnRunner {
786
1087
  this.persistConsumedSteers(s, streamId, previousStartedAt(s, activeRun), now);
787
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
788
1089
  // (all of them, the dropped summary request too).
789
- deps.service().settleStateDb(s, { turnId: streamId, previousContext: this.stopContexts.get(streamId)?.previousContext ?? previousContext, agentRows, startId: this.stopContexts.get(streamId)?.stateDbStartId ?? null });
790
- deps.store.save(s);
1090
+ this.settleStateDb(s, streamId, agentRows, { previousContext });
1091
+ recordBackgroundReview(s, streamId);
1092
+ await deps.store.save(s);
791
1093
  deps.pending.clearApprovals(sessionId);
792
1094
  deps.pending.clearClarifies(sessionId);
793
1095
  deps.events.publish('session_done', { profile: s.profile, sessionId: s.session_id });
@@ -795,9 +1097,9 @@ export class TurnRunner {
795
1097
  try {
796
1098
  const changes = persistentStateChanges(persistentBefore, persistentStateSnapshot(deps.profileHome(s.profile)));
797
1099
  if (changes.memory_saved)
798
- put('state_saved', { session_id: sessionId, kind: 'memory', action: 'saved' });
1100
+ (await put('state_saved', { session_id: sessionId, kind: 'memory', action: 'saved' }));
799
1101
  for (const change of changes.skills)
800
- 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 }));
801
1103
  }
802
1104
  catch (error) {
803
1105
  deps.log(`[webui] persistent state change detection failed for ${sessionId}: ${error.message}`);
@@ -813,7 +1115,7 @@ export class TurnRunner {
813
1115
  cache_hit_percent: cacheHit(cacheReadTokens, inputTokens), turn_cache_hit_percent: cacheHit(turnCacheRead, turnInput), duration_seconds: Math.round(duration * 1000) / 1000,
814
1116
  context_length: s.context_length ?? 0, threshold_tokens: s.threshold_tokens ?? 0, last_prompt_tokens: s.last_prompt_tokens ?? 0,
815
1117
  };
816
- const doneSession = redactSessionData(this.terminalSessionPayload(s), deps.redactEnabled());
1118
+ const doneSession = (await this.publicTerminalSession(s));
817
1119
  // TAL-299: the ring's figures match the terminal session's (and a detail reload's).
818
1120
  for (const key of CONTEXT_USAGE_FIELDS)
819
1121
  doneUsage[key] = doneSession[key];
@@ -832,59 +1134,76 @@ export class TurnRunner {
832
1134
  if (result.tool_limit_reached)
833
1135
  donePayload.terminal_reason = 'max_iterations';
834
1136
  // TAL-397: the live meter ends on the persisted counters and turn rate.
835
- put('metering', { session_id: sessionId, usage: meteringUsage(s), tps: doneUsage.tps ?? null, tps_available: doneUsage.tps !== undefined, estimated: false });
836
- 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);
837
1139
  for (const [event, payload] of steerEvents)
838
- put(event, payload);
1140
+ (await put(event, payload));
839
1141
  // The turn is over: release admission before the title work so a follow-up message is accepted while the
840
1142
  // (up to two) title prompts run; the channel and journal stay open for the `title` events (Python retired the
841
1143
  // worker before its daemon-thread title generation).
842
1144
  this.registry.activeRuns.delete(streamId);
843
1145
  this.settledStreams.add(streamId);
844
- 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);
845
1150
  // Before the title work, so a generated title's own state.db sync lands after this one's provisional title.
846
1151
  await deps.syncUsage?.(s, usedModel || null);
847
1152
  // Title work and the goal judge run side by side; `stream_end` follows both, so their frames reach the stream.
848
1153
  const work = await Promise.allSettled([this.backgroundTitle(s, put), this.continueGoal(s, streamId, put)]);
849
- put('stream_end', { session_id: sessionId });
1154
+ await put('stream_end', { session_id: sessionId });
850
1155
  for (const r of work)
851
1156
  if (r.status === 'rejected')
852
1157
  throw r.reason;
853
- // Python: the last non-error assistant reply with content, else "(no answer produced)".
854
- opts.onDone?.(lastAnswer(s.messages) || '(no answer produced)');
1158
+ succeeded = true;
1159
+ await opts.onDone?.(lastAnswer(s.messages));
855
1160
  }
856
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
+ }
857
1177
  if (settledAt.value && !(error instanceof SidecarError)) {
858
1178
  deps.log(`[webui] ERROR settling turn ${streamId}\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
859
1179
  }
860
- failed = true;
861
1180
  if (this.registry.cancelled.has(streamId)) {
862
- this.finalizeCancelled(s, streamId, opts.ephemeral);
863
- put('cancel', this.cancelFrame(sessionId));
1181
+ await this.finalizeCancelled(s, streamId, opts.ephemeral);
1182
+ await put('cancel', (await this.cancelFrame(sessionId)));
864
1183
  return;
865
1184
  }
866
1185
  const message = error instanceof Error ? error.message : String(error);
867
1186
  const condition = error instanceof SidecarError ? error.condition : undefined;
868
- const classification = classifyProviderError(message, { condition });
869
- const payload = providerErrorPayload(message, classification.type, classification.hint, deps.redactEnabled());
870
- if (condition === 'sidecar_unavailable' || condition === 'agent_runtime_stale') {
871
- payload.type = condition === 'agent_runtime_stale' ? 'agent_runtime_stale' : 'sidecar_unavailable';
872
- payload.condition = condition;
873
- }
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);
874
1193
  try {
875
1194
  let current = s;
876
1195
  try {
877
- current = deps.store.get(sessionId);
1196
+ current = (await deps.store.get(sessionId));
878
1197
  }
879
1198
  catch {
880
1199
  current = s;
881
1200
  }
882
1201
  if (current.active_stream_id === streamId) {
883
1202
  for (const [event, data] of this.takeSteerEventsBefore(streamId, 'apperror'))
884
- put(event, data);
885
- this.persistError(current, streamId, classification.label, payload, activeTurnToken);
1203
+ (await put(event, data));
1204
+ await this.persistError(current, streamId, classification.label, payload, activeTurnToken);
886
1205
  if (!opts.ephemeral)
887
- payload.session = redactSessionData(this.terminalSessionPayload(current), deps.redactEnabled());
1206
+ payload.session = (await this.publicTerminalSession(current));
888
1207
  }
889
1208
  }
890
1209
  catch (persistError) {
@@ -892,15 +1211,20 @@ export class TurnRunner {
892
1211
  }
893
1212
  payload.session_id = sessionId;
894
1213
  payload.old_session_id = sessionId;
895
- put('apperror', payload);
1214
+ await put('apperror', payload);
896
1215
  }
897
1216
  finally {
898
- this.teardown(sessionId, streamId);
1217
+ await this.teardown(sessionId, streamId);
899
1218
  // After persistence and teardown: a background route's failure cleanup deletes the hidden session, which must
900
1219
  // not race the error writeback above (a re-saved session would resurface in the sidebar).
901
- if (failed)
902
- opts.onFailed?.();
903
- this.startSteerFollowUp(sessionId, followUp);
1220
+ if (!succeeded)
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);
904
1228
  }
905
1229
  }
906
1230
  /**
@@ -941,9 +1265,8 @@ export class TurnRunner {
941
1265
  * after the text (TAL-276), so an attachment-only turn still gives the model a request and its transcript row a
942
1266
  * distinct identity.
943
1267
  */
944
- buildUserMessage(workspaceCtx, msgText, attachments, workspace, sessionId, imageMode) {
945
- const text = workspaceCtx + msgText;
946
- const withFiles = () => attachedFilesPrompt(text, attachments);
1268
+ buildUserMessage(msgText, attachments, workspace, sessionId, imageMode) {
1269
+ const withFiles = () => attachedFilesPrompt(msgText, attachments);
947
1270
  const candidates = attachments.filter(isImageAttachment);
948
1271
  if (!candidates.length || imageMode !== 'native')
949
1272
  return withFiles();
@@ -981,18 +1304,23 @@ export class TurnRunner {
981
1304
  }
982
1305
  return images ? [{ type: 'text', text: withFiles() }, ...parts] : withFiles();
983
1306
  }
1307
+ /** The settled session as a terminal frame carries it: the display window, redacted. */
1308
+ async publicTerminalSession(s) {
1309
+ return redactSessionData((await this.terminalSessionPayload(s)), this.deps.redactEnabled());
1310
+ }
984
1311
  /**
985
1312
  * Python `_session_payload_with_terminal_window`: the same visible-message window contract as `GET /api/session?
986
1313
  * msg_limit=` (renderable rows, limited payload shape, windowed tool calls, todo state) with the full count.
987
1314
  */
988
- terminalSessionPayload(s) {
989
- const payload = withSessionWireFlags(s.compact({ contextLengthFor: this.deps.service().deps.contextLengthFor, modelOptionFor: this.deps.service().deps.modelOptionFor }), this.registry.liveIds);
990
- payload.assistant_name = this.deps.service().assistantName(s);
991
- 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);
992
1320
  const service = this.deps.service();
993
- 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 })));
994
1322
  const [window, offset] = messageWindowForDisplay(scened, TERMINAL_SSE_VISIBLE_MESSAGE_LIMIT, null);
995
- 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));
996
1324
  payload.messages = limited;
997
1325
  payload.message_count = s.messages.length;
998
1326
  payload._messages_offset = offset;
@@ -1002,16 +1330,43 @@ export class TurnRunner {
1002
1330
  payload.compression_reference = compressionReference(s, scened);
1003
1331
  return payload;
1004
1332
  }
1005
- /** Python `_materialize_pending_user_turn_before_error` + error message append + save. */
1006
- persistError(s, streamId, label, payload, activeTurnToken, agentRows = []) {
1007
- const startedAt = s.pending_started_at;
1008
- this.materializePendingUserTurn(s, activeTurnToken, streamId);
1009
- const duration = typeof startedAt === 'number' && startedAt > 0 ? Math.max(0, this.deps.now() - startedAt) : null;
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) {
1010
1344
  s.active_stream_id = null;
1011
1345
  s.pending_user_message = null;
1012
1346
  s.pending_attachments = [];
1013
1347
  s.pending_started_at = null;
1014
1348
  s.pending_user_source = null;
1349
+ }
1350
+ /** Python `_materialize_pending_user_turn_before_error` + error message append + save. */
1351
+ /** `runtimeProvider`: the provider the Agent resolved for the turn, when it reported one. */
1352
+ async persistError(s, streamId, label, payload, activeTurnToken, agentRows = [], runtimeProvider = '') {
1353
+ const startedAt = s.pending_started_at;
1354
+ // TAL-576: an automatic wakeup with no usable credentials pauses the next ones instead of failing each.
1355
+ if (s.pending_user_source === 'process_wakeup' && payload.type === 'credential_pool_empty') {
1356
+ // A profile whose home now escapes the profiles root (`profileHome` throws) records no pause; the error still persists.
1357
+ let home = null;
1358
+ try {
1359
+ home = this.deps.profileHome(s.profile);
1360
+ }
1361
+ catch {
1362
+ home = null;
1363
+ }
1364
+ if (home !== null)
1365
+ recordWakeupPause(s, home, this.deps.now(), runtimeProvider, this.deps.configPath?.(home));
1366
+ }
1367
+ this.materializePendingUserTurn(s, activeTurnToken, streamId);
1368
+ const duration = typeof startedAt === 'number' && startedAt > 0 ? Math.max(0, this.deps.now() - startedAt) : null;
1369
+ this.clearPendingTurn(s);
1015
1370
  this.appendPartialSnapshot(s, streamId);
1016
1371
  const hint = str(payload.hint);
1017
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 };
@@ -1035,10 +1390,15 @@ export class TurnRunner {
1035
1390
  s.messages.push(errorMessage);
1036
1391
  this.keepLiveState(s, streamId);
1037
1392
  this.persistConsumedSteers(s, streamId, startedAt, this.deps.now());
1038
- const stop = this.stopContexts.get(streamId);
1039
- this.deps.service().settleStateDb(s, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows, startId: stop?.stateDbStartId ?? null });
1393
+ // TAL-921: the next prompt remembers the turn's work; an exhausted compression's work would only overflow again. One
1394
+ // read builds the context and settles, so a row committed in between is never covered without reaching either.
1395
+ const read = this.deps.service().stateDbRead(s);
1396
+ const context = payload.type === 'compression_exhausted' ? null : this.unsettledContext(streamId, agentRows, read);
1397
+ if (context)
1398
+ s.context_messages = context;
1399
+ this.settleStateDb(s, streamId, agentRows, { read });
1040
1400
  try {
1041
- this.deps.store.save(s);
1401
+ (await this.deps.store.save(s));
1042
1402
  }
1043
1403
  catch (error) {
1044
1404
  this.deps.log(`[webui] WARNING: failed to save error turn for ${s.session_id}: ${error.message}`);
@@ -1049,6 +1409,24 @@ export class TurnRunner {
1049
1409
  this.deps.pending.clearClarifies(s.session_id);
1050
1410
  this.deps.events.publish('session_error', { profile: s.profile, sessionId: s.session_id });
1051
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
+ }
1418
+ /**
1419
+ * TAL-921: the model context a turn that ends without its settled result leaves: the work its Agent reported, else the
1420
+ * rows it committed past its starting read in `read`, the read its settlement uses.
1421
+ */
1422
+ unsettledContext(streamId, agentRows, read) {
1423
+ const stop = this.stopContexts.get(streamId);
1424
+ if (!stop)
1425
+ return null;
1426
+ const after = stop.stateDbStartGeneration !== null && read.generation !== null && stop.stateDbStartGeneration !== read.generation ? -1 : stop.stateDbStartId;
1427
+ const stateRows = after === null ? [] : read.rows.filter((m) => typeof m._state_db_row_id === 'number' && m._state_db_row_id > after);
1428
+ return this.deps.service().unsettledTurnContext({ ...stop, streamedText: (this.registry.partialText.get(streamId) ?? []).join(''), agentRows, stateRows });
1429
+ }
1052
1430
  materializePendingUserTurn(s, activeTurnToken, turnId) {
1053
1431
  const pendingText = str(s.pending_user_message);
1054
1432
  if (!s.hasPendingPrompt)
@@ -1094,11 +1472,11 @@ export class TurnRunner {
1094
1472
  * Python `_emit_cancel_event`: the terminal frame carries the settled session so tabs can render the partial without a
1095
1473
  * reload. TAL-364: no copy — clients show their one localized status for the `cancelled` outcome.
1096
1474
  */
1097
- cancelFrame(sessionId) {
1475
+ async cancelFrame(sessionId) {
1098
1476
  let snapshot = null;
1099
1477
  if (sessionId)
1100
1478
  try {
1101
- snapshot = redactSessionData(this.terminalSessionPayload(this.deps.store.get(sessionId)), this.deps.redactEnabled());
1479
+ snapshot = (await this.publicTerminalSession((await this.deps.store.get(sessionId))));
1102
1480
  }
1103
1481
  catch {
1104
1482
  snapshot = null;
@@ -1110,12 +1488,12 @@ export class TurnRunner {
1110
1488
  * turn's model context keeps what the Agent had captured (`checkpoint`, its canonical transcript) and the prose that
1111
1489
  * streamed, so the next request continues from it; the terminal row carries the outcome and no copy.
1112
1490
  */
1113
- finalizeCancelled(s, streamId, ephemeral, checkpoint = null) {
1491
+ async finalizeCancelled(s, streamId, ephemeral, checkpoint = null) {
1114
1492
  if (this.registry.writebackOwners.get(s.session_id) !== streamId)
1115
1493
  return false;
1116
1494
  let current = s;
1117
1495
  try {
1118
- current = this.deps.store.get(s.session_id);
1496
+ current = (await this.deps.store.get(s.session_id));
1119
1497
  }
1120
1498
  catch {
1121
1499
  return false;
@@ -1124,7 +1502,7 @@ export class TurnRunner {
1124
1502
  return false;
1125
1503
  if (ephemeral) {
1126
1504
  try {
1127
- rmSync(this.deps.store.pathFor(current.session_id), { force: true });
1505
+ await this.deps.store.delete(current.session_id, { tombstone: false });
1128
1506
  }
1129
1507
  catch { /* ignore */ }
1130
1508
  this.deps.store.sessions.delete(current.session_id);
@@ -1136,9 +1514,9 @@ export class TurnRunner {
1136
1514
  if (quiet && current.active_stream_id === null) {
1137
1515
  // TAL-493: rows its worker committed while unwinding are covered by what it reports; others are kept.
1138
1516
  if (checkpoint) {
1139
- 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);
1140
1518
  try {
1141
- this.deps.store.save(current);
1519
+ (await this.deps.store.save(current));
1142
1520
  }
1143
1521
  catch {
1144
1522
  return false;
@@ -1148,26 +1526,28 @@ export class TurnRunner {
1148
1526
  }
1149
1527
  const stop = this.stopContexts.get(streamId);
1150
1528
  const canonical = stop !== undefined && checkpoint !== null && checkpointTurnStart(checkpoint, stop.msgText, stop.historyLength) !== null;
1151
- const settle = () => {
1529
+ // TAL-921: one state.db read builds the context and settles the turn.
1530
+ const settle = (read) => {
1152
1531
  if (!stop)
1153
1532
  return null;
1154
1533
  if (canonical)
1155
1534
  stop.checkpointed = true;
1156
- return stoppedTurnContext(stop.previousContext, checkpoint, stop.prompt, stop.msgText, (this.registry.partialText.get(streamId) ?? []).join(''), stop.historyLength);
1535
+ return this.unsettledContext(streamId, checkpoint, read);
1157
1536
  };
1158
1537
  if (current.messages.some((m) => isCancelMarker(m)) && current.active_stream_id === null && !current.pending_user_message) {
1159
1538
  // The worker's canonical result can arrive after cancel() settled without a checkpoint (the interrupt reply failed
1160
1539
  // or timed out): it replaces this stream's context and never adds a second Stop row. A settlement that already had
1161
1540
  // one keeps it, so work the Agent finished after the Stop stays out.
1162
1541
  const ownStop = current.messages.some((m) => isCancelMarker(m) && m._turn_id === streamId);
1163
- const late = canonical && !stop.checkpointed && ownStop ? settle() : null;
1164
- if (late)
1165
- current.context_messages = dedupeContext(late);
1166
1542
  // TAL-493: the worker's rows committed after the first settlement are covered by what it reports; others are kept.
1167
- if (late || (ownStop && checkpoint)) {
1168
- this.deps.service().settleStateDb(current, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows: checkpoint, startId: stop?.stateDbStartId ?? null });
1543
+ if (ownStop && checkpoint) {
1544
+ const read = this.deps.service().stateDbRead(current);
1545
+ const late = canonical && !stop.checkpointed ? settle(read) : null;
1546
+ if (late)
1547
+ current.context_messages = dedupeContext(late);
1548
+ this.settleStateDb(current, streamId, checkpoint, { read });
1169
1549
  try {
1170
- this.deps.store.save(current);
1550
+ (await this.deps.store.save(current));
1171
1551
  }
1172
1552
  catch {
1173
1553
  return false;
@@ -1177,23 +1557,20 @@ export class TurnRunner {
1177
1557
  }
1178
1558
  const startedAt = current.pending_started_at;
1179
1559
  this.materializePendingUserTurn(current, buildActiveTurnToken(streamId, current.pending_started_at), streamId);
1180
- const context = settle();
1560
+ const read = this.deps.service().stateDbRead(current);
1561
+ const context = settle(read);
1181
1562
  if (context)
1182
1563
  current.context_messages = dedupeContext(context);
1183
- current.active_stream_id = null;
1184
- current.pending_user_message = null;
1185
- current.pending_attachments = [];
1186
- current.pending_started_at = null;
1187
- current.pending_user_source = null;
1564
+ this.clearPendingTurn(current);
1188
1565
  if (!quiet) {
1189
1566
  this.appendPartialSnapshot(current, streamId);
1190
1567
  current.messages.push({ role: 'assistant', content: '', _error: true, _terminal_state: 'cancelled', timestamp: Math.trunc(this.deps.now()), _turn_id: streamId });
1191
1568
  }
1192
1569
  this.keepLiveState(current, streamId);
1193
1570
  this.persistConsumedSteers(current, streamId, startedAt, this.deps.now());
1194
- this.deps.service().settleStateDb(current, { turnId: streamId, previousContext: stop?.previousContext ?? [], agentRows: checkpoint, startId: stop?.stateDbStartId ?? null });
1571
+ this.settleStateDb(current, streamId, checkpoint, { read });
1195
1572
  try {
1196
- this.deps.store.save(current);
1573
+ (await this.deps.store.save(current));
1197
1574
  }
1198
1575
  catch {
1199
1576
  return false;
@@ -1205,8 +1582,8 @@ export class TurnRunner {
1205
1582
  return true;
1206
1583
  }
1207
1584
  /** Push a frame onto the session's live chat stream from outside the turn (Python gateway notify callbacks). */
1208
- emitToSession(sessionId, event, data) {
1209
- this.sessionPuts.get(sessionId)?.(event, data);
1585
+ async emitToSession(sessionId, event, data) {
1586
+ await this.sessionPuts.get(sessionId)?.(event, data);
1210
1587
  }
1211
1588
  /** TAL-397: a turn that ends without its result keeps what the live stream already showed: counters and todo list. */
1212
1589
  keepLiveState(s, streamId) {
@@ -1217,13 +1594,15 @@ export class TurnRunner {
1217
1594
  if (todos)
1218
1595
  s.extra[UNSETTLED_TODO_KEY] = todos;
1219
1596
  }
1220
- teardown(sessionId, streamId) {
1597
+ async teardown(sessionId, streamId) {
1598
+ this.runStatuses.delete(streamId);
1221
1599
  this.liveUsage.delete(streamId);
1600
+ this.compressionSpans.delete(streamId);
1222
1601
  this.liveTodos.delete(streamId);
1223
1602
  const writer = this.writers.get(streamId);
1224
1603
  if (writer) {
1225
1604
  try {
1226
- writer.close();
1605
+ (await writer.close());
1227
1606
  }
1228
1607
  catch { /* ignore */ }
1229
1608
  this.writers.delete(streamId);
@@ -1252,21 +1631,27 @@ export class TurnRunner {
1252
1631
  catch { /* best effort */ }
1253
1632
  }
1254
1633
  /** Journal files with a live writer (retention must leave them alone). */
1255
- activeJournalPaths() {
1256
- return new Set([...this.writers.values()].map((w) => this.deps.journal.pathFor(w.sessionId, w.runId)));
1257
- }
1258
1634
  // ── title ────────────────────────────────────────────────────────────────
1259
1635
  /** Python `_generate_llm_session_title_via_aux`: aux-route statuses (`llm_aux`, `llm_aux_retry`, `llm_*_aux`). */
1260
1636
  async llmTitle(s, userText, assistantText) {
1261
1637
  const sidecar = this.deps.sidecar();
1262
1638
  if (!sidecar)
1263
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);
1264
1643
  const [qa, prompts] = titlePrompts(userText, assistantText);
1265
1644
  let status = 'llm_error_aux';
1266
1645
  for (const [idx, prompt] of prompts.entries()) {
1646
+ if ((await this.deps.service().isReadOnly(s)))
1647
+ return { title: '', status: 'read_only', rawPreview: '' };
1267
1648
  let raw = '';
1268
1649
  try {
1269
- 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
+ });
1270
1655
  raw = str(result.text);
1271
1656
  }
1272
1657
  catch (error) {
@@ -1287,6 +1672,42 @@ export class TurnRunner {
1287
1672
  }
1288
1673
  return { title: '', status, rawPreview: '' };
1289
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
+ }
1290
1711
  /**
1291
1712
  * Python `generate_session_title_for_session`: on-demand title from the persisted transcript; never touches
1292
1713
  * `llm_title_generated`. An explicit regenerate runs even with `auxiliary.title_generation.enabled` off (TAL-531).
@@ -1300,6 +1721,7 @@ export class TurnRunner {
1300
1721
  let rawPreview = '';
1301
1722
  if (assistantText)
1302
1723
  ({ title: next, status: llmStatus, rawPreview } = await this.llmTitle(s, userText, assistantText));
1724
+ this.deps.service().assertWritable(s);
1303
1725
  if (next)
1304
1726
  return { title: next, status: llmStatus, rawPreview };
1305
1727
  const fallback = fallbackTitleFromExchange(userText, assistantText);
@@ -1307,6 +1729,15 @@ export class TurnRunner {
1307
1729
  return { title: fallback, status: `local_summary:${llmStatus}`, rawPreview };
1308
1730
  return { title: null, status: llmStatus || 'empty_title', rawPreview };
1309
1731
  }
1732
+ /**
1733
+ * TAL-591 (Python `_queue_generated_title_for_imported_session`): a CLI session a mutation claims while it still
1734
+ * carries its default title gets the background title job at once instead of after its first WebUI turn.
1735
+ */
1736
+ async titleClaimed(s) {
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 }))
1738
+ return;
1739
+ await this.backgroundTitle(s, () => Promise.resolve()).catch((error) => { this.deps.log(`[webui] imported session title failed for ${s.session_id}: ${error.message}`); });
1740
+ }
1310
1741
  /** Python `_run_background_title_update` (spawned only when `_background_title_generation_inputs` is eligible). */
1311
1742
  async backgroundTitle(s, put) {
1312
1743
  const sessionId = s.session_id;
@@ -1317,27 +1748,34 @@ export class TurnRunner {
1317
1748
  const eligible = ['Untitled', 'New Chat', ''].includes(placeholder) || placeholder === titleFrom(s.messages, '') || invalidExisting || defaultTitle(s);
1318
1749
  const [userText, assistantText] = firstExchangeSnippets(s.messages);
1319
1750
  // Python `_put_title_status`: empty reason/title/raw_preview keys are omitted.
1320
- const status = (status, reason = '', title = '', rawPreview = '') => {
1321
- 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 } : {}) });
1322
1753
  };
1323
- if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText)
1754
+ // Python ran the adaptive refresh only when the first-title job was not eligible.
1755
+ if (!eligible || (s.llm_title_generated && !invalidExisting) || !userText || !assistantText) {
1756
+ await this.refreshTitle(s, put, status);
1324
1757
  return;
1758
+ }
1325
1759
  if (s.manual_title) {
1326
- status('skipped', 'manual_title', placeholder);
1760
+ (await status('skipped', 'manual_title', placeholder));
1327
1761
  return;
1328
1762
  }
1329
1763
  // A missing config.yaml reads as `{}` (enabled); an unreadable one cannot confirm the user has not opted out.
1330
1764
  const cfg = await this.deps.profileConfig?.(s.profile ?? null);
1331
1765
  if (!cfg) {
1332
- status('skipped', 'config_unavailable', placeholder);
1766
+ (await status('skipped', 'config_unavailable', placeholder));
1333
1767
  return;
1334
1768
  }
1335
1769
  if (!titleGenerationEnabled(cfg)) {
1336
- status('skipped', 'title_generation_disabled', placeholder);
1770
+ (await status('skipped', 'title_generation_disabled', placeholder));
1337
1771
  return;
1338
1772
  }
1339
1773
  const generated = await this.llmTitle(s, userText, assistantText);
1340
1774
  const { status: llmStatus, rawPreview } = generated;
1775
+ if (llmStatus === 'read_only') {
1776
+ (await status('skipped', 'read_only', placeholder));
1777
+ return;
1778
+ }
1341
1779
  let next = generated.title;
1342
1780
  let source = llmStatus;
1343
1781
  if (!next) {
@@ -1350,23 +1788,30 @@ export class TurnRunner {
1350
1788
  const fallbackReason = source === 'fallback' && llmStatus ? `local_summary:${llmStatus}` : 'local_summary';
1351
1789
  let current = s;
1352
1790
  try {
1353
- current = this.deps.store.get(sessionId);
1791
+ current = (await this.deps.store.get(sessionId));
1354
1792
  }
1355
1793
  catch {
1794
+ // A session deleted while the title prompt ran stays deleted.
1795
+ if ((await this.deps.store.wasDeleted(sessionId)))
1796
+ return;
1356
1797
  current = s;
1357
1798
  }
1799
+ if ((await this.deps.service().isReadOnly(current))) {
1800
+ (await status('skipped', 'read_only', str(current.title).trim()));
1801
+ return;
1802
+ }
1358
1803
  let effective = str(current.title).trim();
1359
1804
  let wrote = false;
1360
1805
  if (next) {
1361
1806
  const stillAuto = effective === placeholder || ['Untitled', 'New Chat', ''].includes(effective) || effective === titleFrom(current.messages, '') || looksInvalidGeneratedTitle(current.title) || defaultTitle(current);
1362
1807
  if (current.manual_title || !stillAuto) {
1363
- status('skipped', 'manual_title', effective);
1808
+ (await status('skipped', 'manual_title', effective));
1364
1809
  return;
1365
1810
  }
1366
1811
  if (next !== effective) {
1367
1812
  current.title = next;
1368
1813
  markSessionTitleGenerated(current);
1369
- this.deps.store.save(current, { touchUpdatedAt: false });
1814
+ await this.deps.store.save(current, { touchUpdatedAt: false });
1370
1815
  // Python `sync_session_title` after generation: the state.db row follows when `sync_to_insights` is on.
1371
1816
  await this.deps.syncTitle?.(current);
1372
1817
  this.deps.events.publish('title', { profile: current.profile, sessionId });
@@ -1375,11 +1820,80 @@ export class TurnRunner {
1375
1820
  }
1376
1821
  }
1377
1822
  if (wrote) {
1378
- status(source, source === 'fallback' ? fallbackReason : llmStatus, effective, rawPreview);
1379
- 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 });
1380
1825
  }
1381
1826
  else
1382
- status('skipped', source || 'unchanged', effective, rawPreview);
1827
+ (await status('skipped', source || 'unchanged', effective, rawPreview));
1828
+ }
1829
+ /**
1830
+ * Python `_maybe_schedule_title_refresh` + `_run_background_title_refresh`: every N exchanges a generated title is
1831
+ * regenerated from the latest exchange; a manual title is never touched.
1832
+ */
1833
+ async refreshTitle(s, put, status) {
1834
+ const every = this.deps.titleRefreshEvery?.() ?? 0;
1835
+ const exchanges = countExchanges(s.messages);
1836
+ if (every <= 0 || exchanges <= 0 || exchanges % every !== 0)
1837
+ return;
1838
+ const [userText, assistantText] = latestExchangeSnippets(s.messages);
1839
+ if (!userText || !assistantText)
1840
+ return;
1841
+ // Admission is released before title work, so an earlier turn's refresh may still be running: refreshes apply in
1842
+ // turn order and each reads the title its predecessor wrote.
1843
+ const sessionId = s.session_id;
1844
+ const run = (this.titleRefreshes.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(async () => { await this.applyTitleRefresh(sessionId, s, userText, assistantText, put, status); });
1845
+ this.titleRefreshes.set(sessionId, run);
1846
+ try {
1847
+ await run;
1848
+ }
1849
+ finally {
1850
+ if (this.titleRefreshes.get(sessionId) === run)
1851
+ this.titleRefreshes.delete(sessionId);
1852
+ }
1853
+ }
1854
+ async applyTitleRefresh(sessionId, s, userText, assistantText, put, status) {
1855
+ const read = async () => { try {
1856
+ return (await this.deps.store.get(sessionId));
1857
+ }
1858
+ catch {
1859
+ return s;
1860
+ } };
1861
+ const latest = (await read());
1862
+ const placeholder = str(latest.title).trim();
1863
+ if (['Untitled', 'New Chat', ''].includes(placeholder) || latest.manual_title || !latest.llm_title_generated)
1864
+ return;
1865
+ const cfg = await this.deps.profileConfig?.(latest.profile ?? null);
1866
+ if (!cfg || !titleGenerationEnabled(cfg)) {
1867
+ (await status('refresh_skipped', cfg ? 'title_generation_disabled' : 'config_unavailable', placeholder));
1868
+ return;
1869
+ }
1870
+ const { title: next, status: llmStatus, rawPreview } = await this.llmTitle(latest, userText, assistantText);
1871
+ if (!next) {
1872
+ (await status('refresh_skipped', llmStatus || 'empty', placeholder, rawPreview));
1873
+ return;
1874
+ }
1875
+ const normalize = (value) => value.replace(/\s+/g, ' ').trim().toLowerCase();
1876
+ if (normalize(next) === normalize(placeholder)) {
1877
+ (await status('refresh_skipped', 'same_title', placeholder, rawPreview));
1878
+ return;
1879
+ }
1880
+ const current = (await read());
1881
+ // The user may have renamed the session while the title prompt ran.
1882
+ if (current.manual_title || str(current.title).trim() !== placeholder) {
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()));
1888
+ return;
1889
+ }
1890
+ current.title = next;
1891
+ markSessionTitleGenerated(current);
1892
+ await this.deps.store.save(current, { touchUpdatedAt: false });
1893
+ await this.deps.syncTitle?.(current);
1894
+ this.deps.events.publish('title', { profile: current.profile, sessionId });
1895
+ await status('refreshed', llmStatus, next, rawPreview);
1896
+ await put('title', { session_id: sessionId, title: next });
1383
1897
  }
1384
1898
  // ── cancel / steer ───────────────────────────────────────────────────────
1385
1899
  /**
@@ -1387,6 +1901,26 @@ export class TurnRunner {
1387
1901
  * `steer_withdrawn` (stopped) this Stop emitted, for the cancel response (TAL-426).
1388
1902
  */
1389
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) {
1390
1924
  const channel = this.registry.peek(streamId);
1391
1925
  const run = this.registry.activeRuns.get(streamId);
1392
1926
  if (!channel && !run)
@@ -1421,24 +1955,24 @@ export class TurnRunner {
1421
1955
  if (sessionId) {
1422
1956
  let current = null;
1423
1957
  try {
1424
- current = this.deps.store.get(sessionId);
1958
+ current = (await this.deps.store.get(sessionId));
1425
1959
  }
1426
1960
  catch {
1427
1961
  current = null;
1428
1962
  }
1429
1963
  if (current?.active_stream_id === streamId) {
1430
- this.finalizeCancelled(current, streamId, run?.ephemeral, checkpoint);
1964
+ await this.finalizeCancelled(current, streamId, run?.ephemeral, checkpoint);
1431
1965
  }
1432
1966
  }
1433
1967
  // The worker may already have written its terminal row while the interrupt was in flight; the stream is then
1434
1968
  // gone from the registry and this path must not add a second one.
1435
1969
  if (channel && this.registry.streams.has(streamId)) {
1436
1970
  const writer = this.writers.get(streamId) ?? null;
1437
- const emit = (event, data) => {
1971
+ const emit = async (event, data) => {
1438
1972
  let eventId = null;
1439
1973
  if (writer) {
1440
1974
  try {
1441
- eventId = writer.appendSseEvent(event, data).event_id;
1975
+ eventId = (await writer.appendSseEvent(event, data)).event_id;
1442
1976
  }
1443
1977
  catch {
1444
1978
  eventId = null;
@@ -1447,8 +1981,10 @@ export class TurnRunner {
1447
1981
  channel.put([event, data, eventId]);
1448
1982
  };
1449
1983
  for (const [event, data] of steerEvents)
1450
- emit(event, data);
1451
- 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)));
1452
1988
  this.registry.streams.delete(streamId);
1453
1989
  this.registry.liveIds.delete(streamId);
1454
1990
  }
@@ -1464,7 +2000,7 @@ export class TurnRunner {
1464
2000
  const streamId = this.registry.activeRunStreamForSession(sessionId);
1465
2001
  if (!streamId || this.registry.activeRuns.get(streamId)?.origin !== 'background')
1466
2002
  return false;
1467
- await this.cancel(streamId);
2003
+ await this.stop(streamId);
1468
2004
  let timer;
1469
2005
  await Promise.race([this.workers.get(streamId), new Promise((resolve) => { timer = setTimeout(resolve, BACKGROUND_UNWIND_WAIT_MS); })]);
1470
2006
  clearTimeout(timer);
@@ -1473,7 +2009,7 @@ export class TurnRunner {
1473
2009
  async steer(sessionId, text, displayText, steerId) {
1474
2010
  let s = null;
1475
2011
  try {
1476
- s = this.deps.store.get(sessionId, { metadataOnly: true });
2012
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
1477
2013
  }
1478
2014
  catch {
1479
2015
  s = null;
@@ -1492,7 +2028,7 @@ export class TurnRunner {
1492
2028
  return { accepted: false, fallback: 'no_cached_agent', stream_id: null };
1493
2029
  // Register provisionally before the RPC: the Agent may apply the steer and finish the turn before the steer
1494
2030
  // reply arrives, and the turn's finalisation must then already see the record. A rejection removes it again.
1495
- 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));
1496
2032
  }
1497
2033
  async steerInto(sidecar, sessionId, activeStreamId, text, displayText, steerId) {
1498
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 };
@@ -1524,7 +2060,7 @@ export class TurnRunner {
1524
2060
  record.can_redirect = result.can_redirect === true;
1525
2061
  // TAL-424: every client shows it until the Agent takes it (unless it already did while the reply was in flight).
1526
2062
  if (this.steers.get(activeStreamId)?.includes(record))
1527
- this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
2063
+ (await this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record)));
1528
2064
  }
1529
2065
  return { accepted: result.accepted, fallback: result.accepted ? null : (result.fallback ?? 'not_running'), stream_id: activeStreamId, steer_id: steerId };
1530
2066
  }
@@ -1593,10 +2129,10 @@ export class TurnRunner {
1593
2129
  return boundary;
1594
2130
  }
1595
2131
  /** A steer is saved as it enters the stream, so a reload mid-turn already has it; the terminal save re-places it. */
1596
- saveConsumedSteers(sessionId, streamId) {
2132
+ async saveConsumedSteers(sessionId, streamId) {
1597
2133
  let s;
1598
2134
  try {
1599
- s = this.deps.store.get(sessionId);
2135
+ s = (await this.deps.store.get(sessionId));
1600
2136
  }
1601
2137
  catch {
1602
2138
  return;
@@ -1604,7 +2140,7 @@ export class TurnRunner {
1604
2140
  if (s.active_stream_id !== streamId || this.placeConsumedSteers(s, streamId, s.pending_started_at) === null)
1605
2141
  return;
1606
2142
  try {
1607
- this.deps.store.save(s, { touchUpdatedAt: false });
2143
+ (await this.deps.store.save(s, { touchUpdatedAt: false }));
1608
2144
  }
1609
2145
  catch (error) {
1610
2146
  this.deps.log(`[webui] WARNING: failed to save consumed steer for ${sessionId}: ${error.message}`);
@@ -1658,30 +2194,24 @@ export class TurnRunner {
1658
2194
  return { events, leftovers: [...(unmatched ? [unmatched] : []), ...left.map((r) => r.text)] };
1659
2195
  if (unmatched)
1660
2196
  events.push(['steer_withdrawn', { steer_id: null, reason: 'stopped', text: unmatched }]);
1661
- // ponytail: legacy `pending_steer_leftover` keeps today's Web and App from losing a stopped steer (they requeue it);
1662
- // drop it once TAL-425 / TAL-426 put `steer_withdrawn` text back in the composer.
1663
- for (const r of left)
1664
- events.push(['pending_steer_leftover', { steer_id: r.steer_id, stream_id: streamId, text: r.text, display_text: r.display_text, leftover_at: this.deps.now() }]);
1665
- if (unmatched)
1666
- events.push(['pending_steer_leftover', { stream_id: streamId, text: unmatched }]);
1667
2197
  return { events, leftovers: [] };
1668
2198
  }
1669
2199
  /**
1670
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.
1671
2201
  * Should another turn already hold the session, they steer into that one instead, so no text is lost.
1672
2202
  */
1673
- startSteerFollowUp(sessionId, texts) {
2203
+ async startSteerFollowUp(sessionId, texts) {
1674
2204
  if (!texts.length)
1675
2205
  return;
1676
2206
  let s;
1677
2207
  try {
1678
- s = this.deps.store.get(sessionId);
2208
+ s = (await this.deps.store.get(sessionId));
1679
2209
  }
1680
2210
  catch {
1681
2211
  return;
1682
2212
  }
1683
2213
  const msg = texts.join('\n');
1684
- 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 }));
1685
2215
  if (!started._status || started._status < 400)
1686
2216
  return;
1687
2217
  if (started._status === 409 && started.active_stream_id) {
@@ -1709,61 +2239,83 @@ export class TurnRunner {
1709
2239
  // Only a known goal turn shows progress up front; any other turn shows nothing unless a goal turns out to be active.
1710
2240
  const goalTurn = this.registry.goalRelated.has(streamId);
1711
2241
  if (goalTurn)
1712
- 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' }));
1713
2243
  let decision;
1714
2244
  try {
1715
- 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 });
1716
2248
  }
1717
2249
  catch (error) {
1718
2250
  this.deps.log(`[webui] WARNING: goal evaluation failed for ${sessionId}: ${error.message}`);
1719
2251
  if (goalTurn)
1720
- put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
2252
+ (await put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' }));
1721
2253
  return;
1722
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);
1723
2258
  if (!goalTurn && decision.verdict === 'inactive')
1724
2259
  return;
1725
2260
  const prompt = str(decision.continuation_prompt).trim();
1726
2261
  const proceed = decision.should_continue && Boolean(prompt);
1727
2262
  const status = { message: decision.message, message_key: decision.message_key, message_args: decision.message_args ?? [], decision: decision.verdict };
1728
- put('goal', { session_id: sessionId, state: proceed ? 'continuing' : 'idle', ...status });
1729
- if (!proceed)
2263
+ if (!proceed) {
2264
+ (await put('goal', { session_id: sessionId, state: 'idle', ...status }));
1730
2265
  return;
2266
+ }
1731
2267
  let current;
1732
2268
  try {
1733
- current = this.deps.store.get(sessionId);
2269
+ current = (await this.deps.store.get(sessionId));
1734
2270
  }
1735
2271
  catch {
1736
2272
  return;
1737
2273
  }
1738
- 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 });
1739
2281
  if (started._status === 409 && started.active_stream_id) {
1740
2282
  this.registry.goalRelated.add(started.active_stream_id);
1741
2283
  return;
1742
2284
  }
1743
2285
  if (started._status !== undefined && started._status >= 400) {
1744
2286
  this.deps.log(`[webui] WARNING: goal continuation for ${sessionId} not started (${String(started._status)}): ${str(started.error)}`);
1745
- put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
2287
+ await put('goal', { session_id: sessionId, state: 'idle', decision: 'error', message: '' });
1746
2288
  return;
1747
2289
  }
1748
- 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 });
1749
2291
  }
1750
2292
  /** The Agent's pending steer text settles which steers it has taken: those become `steer_consumed` and their rows. */
1751
- settleConsumedSteers(sessionId, streamId, pendingText) {
2293
+ async settleConsumedSteers(sessionId, streamId, pendingText) {
1752
2294
  for (const record of this.takeConsumedSteers(streamId, pendingText, { keepLeftovers: true }))
1753
- this.emitToSession(sessionId, 'steer_consumed', record);
1754
- this.saveConsumedSteers(sessionId, streamId);
2295
+ (await this.emitToSession(sessionId, 'steer_consumed', record));
2296
+ await this.saveConsumedSteers(sessionId, streamId);
1755
2297
  }
1756
2298
  /** What every client shows for a pending steer; the server decides which actions it offers. */
1757
2299
  pendingSteerView(r) {
1758
2300
  const open = r.state === 'pending';
1759
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 } };
1760
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
+ }
1761
2310
  /** TAL-424: the active stream's pending steers, oldest first (session detail `pending_steers`). */
1762
2311
  pendingSteers(streamId) {
1763
2312
  return (this.steers.get(streamId) ?? []).map((r) => this.pendingSteerView(r));
1764
2313
  }
1765
- withSteerChain(streamId, fn) {
1766
- 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
+ });
1767
2319
  const settled = run.catch(() => undefined).then(() => { if (this.steerChains.get(streamId) === settled)
1768
2320
  this.steerChains.delete(streamId); });
1769
2321
  this.steerChains.set(streamId, settled);
@@ -1777,7 +2329,7 @@ export class TurnRunner {
1777
2329
  async rewriteSteer(sessionId, steerId, fallback, rewrite) {
1778
2330
  let s = null;
1779
2331
  try {
1780
- s = this.deps.store.get(sessionId, { metadataOnly: true });
2332
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
1781
2333
  }
1782
2334
  catch {
1783
2335
  s = null;
@@ -1785,21 +2337,21 @@ export class TurnRunner {
1785
2337
  const streamId = s?.active_stream_id;
1786
2338
  const sidecar = this.deps.sidecar();
1787
2339
  if (!streamId || !sidecar)
1788
- return fallback;
1789
- return this.withSteerChain(streamId, async () => {
2340
+ return fallback('not_running');
2341
+ return this.withSteerChain(streamId, sessionId, async () => {
1790
2342
  const records = this.steers.get(streamId) ?? [];
1791
2343
  const index = records.findIndex((r) => r.steer_id === steerId);
1792
2344
  if (index < 0 || records[index].state !== 'pending')
1793
- return fallback;
2345
+ return fallback('taken');
1794
2346
  // In flight before the sidecar call, so a pending-text report racing its reply is deferred too.
1795
2347
  let finished = () => undefined;
1796
2348
  this.steerRewrites.set(streamId, new Promise((resolve) => { finished = resolve; }));
1797
- let outcome = { result: fallback, changed: false };
2349
+ let outcome = { result: fallback('failed'), changed: false };
1798
2350
  try {
1799
2351
  outcome = await rewrite(sidecar, streamId, records, index);
1800
2352
  }
1801
2353
  catch {
1802
- outcome = { result: fallback, changed: false };
2354
+ outcome = { result: fallback('failed'), changed: false };
1803
2355
  }
1804
2356
  finally {
1805
2357
  this.steerRewrites.delete(streamId);
@@ -1807,7 +2359,7 @@ export class TurnRunner {
1807
2359
  const deferred = this.deferredPendingSteer.get(streamId);
1808
2360
  this.deferredPendingSteer.delete(streamId);
1809
2361
  if (deferred !== undefined && !outcome.changed)
1810
- this.settleConsumedSteers(sessionId, streamId, deferred);
2362
+ (await this.settleConsumedSteers(sessionId, streamId, deferred));
1811
2363
  }
1812
2364
  return outcome.result;
1813
2365
  });
@@ -1822,54 +2374,61 @@ export class TurnRunner {
1822
2374
  * TAL-424 Edit and Cancel: take a pending steer back before the Agent takes it. `withdrawn: false` (and nothing
1823
2375
  * emitted) when it is unknown, already taken or being sent; a taken one then settles as consumed.
1824
2376
  */
1825
- withdrawSteer(sessionId, steerId, reason) {
1826
- 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) => {
1827
2379
  const record = records[index];
1828
2380
  const reply = await sidecar.call('chat.steer_withdraw', { stream_id: streamId, pending: records.map((r) => r.text), index });
1829
2381
  if (!reply.withdrawn)
1830
2382
  return { result: { withdrawn: false }, changed: false };
1831
2383
  this.dropSteer(streamId, record);
1832
- 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 });
1833
2385
  return { result: { withdrawn: true, text: record.display_text }, changed: true };
1834
- });
2386
+ }));
1835
2387
  }
1836
2388
  /**
1837
2389
  * TAL-424 Send now: deliver a pending steer with the Agent's redirect. A model request restarts with it (consumed at
1838
2390
  * once); during tools it goes last on the Agent's queue and stays `sending_now` until taken; with nothing live it
1839
2391
  * stays pending.
1840
2392
  */
1841
- sendSteerNow(sessionId, steerId) {
1842
- 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) => {
1843
2395
  const record = records[index];
1844
2396
  if (!record.can_redirect)
1845
- return { result: { redirected: false }, changed: false };
2397
+ return { result: { redirected: false, outcome: 'failed' }, changed: false };
1846
2398
  const reply = await sidecar.call('chat.steer_now', { stream_id: streamId, pending: records.map((r) => r.text), index });
1847
2399
  if (!reply.withdrawn)
1848
- return { result: { redirected: false }, changed: false };
2400
+ return { result: { redirected: false, outcome: reply.reason === 'not_running' ? 'not_running' : 'taken' }, changed: false };
1849
2401
  if (!reply.redirected) {
1850
2402
  // The Agent's queue changed meanwhile, so it went back last: clients follow the new order.
1851
2403
  if (reply.requeued === 'last') {
1852
2404
  this.dropSteer(streamId, record);
1853
2405
  records.push(record);
1854
- this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
2406
+ (await this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record)));
1855
2407
  }
1856
- return { result: { redirected: false }, changed: true };
2408
+ return { result: { redirected: false, outcome: 'next_step' }, changed: true };
1857
2409
  }
1858
2410
  this.dropSteer(streamId, record);
1859
2411
  if (reply.delivery === 'steer') {
1860
2412
  record.state = 'sending_now';
1861
2413
  records.push(record);
1862
- this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
2414
+ await this.emitToSession(sessionId, 'steer_pending', this.pendingSteerView(record));
1863
2415
  }
1864
2416
  else {
1865
- this.emitToSession(sessionId, 'steer_consumed', this.consumedSteerPayload(record, { redirected: true }));
1866
- this.saveConsumedSteers(sessionId, streamId);
2417
+ await this.emitToSession(sessionId, 'steer_consumed', this.consumedSteerPayload(record, { redirected: true }));
2418
+ await this.saveConsumedSteers(sessionId, streamId);
1867
2419
  }
1868
- return { result: { redirected: true }, changed: true };
1869
- });
2420
+ return { result: { redirected: true, outcome: 'delivered' }, changed: true };
2421
+ }));
1870
2422
  }
1871
2423
  // ── approvals / clarify ──────────────────────────────────────────────────
1872
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));
1873
2432
  // Python `_enable_session_yolo_and_release_pending`: a stale card id while another approval is parked
1874
2433
  // must not commit YOLO for the whole session — fail closed with the relay envelope instead.
1875
2434
  if (enableYolo && approvalId && !this.deps.pending.hasApprovalId(sessionId, approvalId) && this.deps.pending.hasPendingApproval(sessionId)) {
@@ -1882,7 +2441,7 @@ export class TurnRunner {
1882
2441
  const relayFailure = (message) => ({ ok: false, choice, error: `The Agent sidecar did not accept the approval (${message}); retry in a moment.`, _status: 503 });
1883
2442
  let s = null;
1884
2443
  try {
1885
- s = this.deps.store.get(sessionId, { metadataOnly: true });
2444
+ s = (await this.deps.store.get(sessionId, { metadataOnly: true }));
1886
2445
  }
1887
2446
  catch {
1888
2447
  s = null;
@@ -1890,7 +2449,9 @@ export class TurnRunner {
1890
2449
  const profileHome = this.deps.profileHome(s?.profile ?? null);
1891
2450
  let resolved = false;
1892
2451
  let withdrawn = false;
1893
- if (entry) {
2452
+ if (entry?._injected === true)
2453
+ resolved = Boolean(this.deps.pending.resolveApproval(sessionId, str(entry.approval_id)).entry);
2454
+ else if (entry) {
1894
2455
  if (!sidecar)
1895
2456
  return relayFailure('not running');
1896
2457
  try {
@@ -1922,7 +2483,7 @@ export class TurnRunner {
1922
2483
  return relayFailure(str(error.message));
1923
2484
  }
1924
2485
  }
1925
- this.deps.service().setYolo(sessionId, true);
2486
+ await this.deps.service().setYolo(sessionId, true);
1926
2487
  return null;
1927
2488
  });
1928
2489
  if (failure)
@@ -1956,17 +2517,21 @@ export class TurnRunner {
1956
2517
  return;
1957
2518
  }
1958
2519
  if (this.deps.pending.dropApprovals(sessionId, new Set(before.filter((id) => !live.has(id)))).length)
1959
- this.emitToSession(sessionId, ...this.approvalHeadFrame(sessionId));
2520
+ (await this.emitToSession(sessionId, ...this.approvalHeadFrame(sessionId)));
1960
2521
  }
1961
2522
  withYoloLock(sessionId, fn) {
1962
- 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
+ });
1963
2527
  const settled = run.catch(() => undefined).then(() => { if (this.yoloChains.get(sessionId) === settled)
1964
2528
  this.yoloChains.delete(sessionId); });
1965
2529
  this.yoloChains.set(sessionId, settled);
1966
2530
  return run;
1967
2531
  }
1968
2532
  /** Python `set_session_yolo_enabled(False)`: the sidecar must drop its per-session YOLO state before the local flag clears. */
1969
- disableYolo(sessionId) {
2533
+ async disableYolo(sessionId) {
2534
+ sessionId = (await this.deps.service().idForMutation(sessionId));
1970
2535
  return this.withYoloLock(sessionId, async () => {
1971
2536
  const sidecar = this.deps.sidecar();
1972
2537
  if (sidecar) {
@@ -1977,7 +2542,7 @@ export class TurnRunner {
1977
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 };
1978
2543
  }
1979
2544
  }
1980
- return this.deps.service().setYolo(sessionId, false);
2545
+ return (await this.deps.service().setYolo(sessionId, false));
1981
2546
  });
1982
2547
  }
1983
2548
  /**
@@ -1986,12 +2551,26 @@ export class TurnRunner {
1986
2551
  * did not acknowledge (prompt retained).
1987
2552
  */
1988
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));
1989
2559
  const entry = this.deps.pending.peekClarify(sessionId, clarifyId);
1990
2560
  if (!entry)
1991
2561
  return { ok: false, stale: true };
1992
- 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);
1993
2567
  if (response === null)
1994
2568
  return { ok: false, invalid: true };
2569
+ // A test-hook prompt has no parked Agent waiter: it is answered here.
2570
+ if (entry._injected === true) {
2571
+ this.deps.pending.resolveClarify(sessionId, str(entry.clarify_id));
2572
+ return vault ? { ok: true } : { ok: true, response };
2573
+ }
1995
2574
  const sidecar = this.deps.sidecar();
1996
2575
  if (!sidecar)
1997
2576
  return { ok: false, error: 'The Agent sidecar is not running; retry in a moment.' };
@@ -2000,14 +2579,16 @@ export class TurnRunner {
2000
2579
  ok = (await sidecar.call('clarify.respond', { session_id: sessionId, clarify_id: str(entry.clarify_id), response })).ok;
2001
2580
  }
2002
2581
  catch (error) {
2582
+ if (vault)
2583
+ return { ok: false, error: 'The Agent sidecar did not accept the secure answer; retry in a moment.' };
2003
2584
  return { ok: false, error: `The Agent sidecar did not accept the answer (${str(error.message)}); retry in a moment.` };
2004
2585
  }
2005
2586
  if (!ok)
2006
2587
  return { ok: false, stale: true };
2007
2588
  const { head } = this.deps.pending.resolveClarify(sessionId, str(entry.clarify_id));
2008
2589
  if (head)
2009
- this.emitToSession(sessionId, 'clarify', head);
2010
- return { ok: true, response };
2590
+ (await this.emitToSession(sessionId, 'clarify', head));
2591
+ return vault ? { ok: true } : { ok: true, response };
2011
2592
  }
2012
2593
  }
2013
2594
  /** The turn's answer: the last non-error assistant reply with content. */