@maudecode/talaria-web 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (567) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.js +16 -16
  3. package/dist/api/auth-raw.js.map +1 -1
  4. package/dist/api/automation-raw.d.ts +2 -2
  5. package/dist/api/automation-raw.js +32 -32
  6. package/dist/api/automation-raw.js.map +1 -1
  7. package/dist/api/automation-router.d.ts +102 -22
  8. package/dist/api/automation-router.js +15 -13
  9. package/dist/api/automation-router.js.map +1 -1
  10. package/dist/api/chat-router.d.ts +1205 -34
  11. package/dist/api/chat-router.js +158 -78
  12. package/dist/api/chat-router.js.map +1 -1
  13. package/dist/api/git-router.d.ts +2 -2
  14. package/dist/api/git-router.js +29 -29
  15. package/dist/api/git-router.js.map +1 -1
  16. package/dist/api/raw-routes.d.ts +3 -3
  17. package/dist/api/raw-routes.js +63 -61
  18. package/dist/api/raw-routes.js.map +1 -1
  19. package/dist/api/router.d.ts +6 -6
  20. package/dist/api/router.js +22 -22
  21. package/dist/api/router.js.map +1 -1
  22. package/dist/api/session-visibility.d.ts +4 -4
  23. package/dist/api/session-visibility.js +26 -16
  24. package/dist/api/session-visibility.js.map +1 -1
  25. package/dist/api/sessions-router.d.ts +1928 -124
  26. package/dist/api/sessions-router.js +177 -173
  27. package/dist/api/sessions-router.js.map +1 -1
  28. package/dist/api/settings-router.d.ts +427 -9
  29. package/dist/api/settings-router.js +48 -32
  30. package/dist/api/settings-router.js.map +1 -1
  31. package/dist/api/sse-routes.d.ts +2 -2
  32. package/dist/api/sse-routes.js +63 -63
  33. package/dist/api/sse-routes.js.map +1 -1
  34. package/dist/api/tools-raw.d.ts +5 -0
  35. package/dist/api/tools-raw.js +58 -33
  36. package/dist/api/tools-raw.js.map +1 -1
  37. package/dist/api/tools-router.d.ts +663 -91
  38. package/dist/api/tools-router.js +309 -52
  39. package/dist/api/tools-router.js.map +1 -1
  40. package/dist/api/transcribe-stream.d.ts +16 -0
  41. package/dist/api/transcribe-stream.js +172 -0
  42. package/dist/api/transcribe-stream.js.map +1 -0
  43. package/dist/app.d.ts +4 -1
  44. package/dist/app.js +66 -47
  45. package/dist/app.js.map +1 -1
  46. package/dist/auth/gate.js +10 -9
  47. package/dist/auth/gate.js.map +1 -1
  48. package/dist/auth/passkeys.d.ts +15 -14
  49. package/dist/auth/passkeys.js +89 -91
  50. package/dist/auth/passkeys.js.map +1 -1
  51. package/dist/auth/store.d.ts +17 -7
  52. package/dist/auth/store.js +93 -52
  53. package/dist/auth/store.js.map +1 -1
  54. package/dist/bin/talaria-web.js +12 -81
  55. package/dist/bin/talaria-web.js.map +1 -1
  56. package/dist/cli/ctl.js +2 -1
  57. package/dist/cli/ctl.js.map +1 -1
  58. package/dist/cli/dotenv.d.ts +5 -0
  59. package/dist/cli/dotenv.js +14 -2
  60. package/dist/cli/dotenv.js.map +1 -1
  61. package/dist/cli/launcher.js +5 -5
  62. package/dist/cli/launcher.js.map +1 -1
  63. package/dist/cli/main.d.ts +1 -0
  64. package/dist/cli/main.js +85 -0
  65. package/dist/cli/main.js.map +1 -0
  66. package/dist/cli/supervise.d.ts +2 -0
  67. package/dist/cli/supervise.js +67 -4
  68. package/dist/cli/supervise.js.map +1 -1
  69. package/dist/config/agent-config.d.ts +34 -11
  70. package/dist/config/agent-config.js +113 -58
  71. package/dist/config/agent-config.js.map +1 -1
  72. package/dist/config.d.ts +2 -0
  73. package/dist/config.js +14 -5
  74. package/dist/config.js.map +1 -1
  75. package/dist/fs/atomic.d.ts +1 -0
  76. package/dist/fs/atomic.js +4 -1
  77. package/dist/fs/atomic.js.map +1 -1
  78. package/dist/fs/stat.d.ts +1 -0
  79. package/dist/fs/stat.js +11 -0
  80. package/dist/fs/stat.js.map +1 -0
  81. package/dist/http/context.d.ts +14 -9
  82. package/dist/http/context.js +29 -20
  83. package/dist/http/context.js.map +1 -1
  84. package/dist/onboarding.d.ts +2 -2
  85. package/dist/onboarding.js +10 -8
  86. package/dist/onboarding.js.map +1 -1
  87. package/dist/profiles/profiles.d.ts +10 -4
  88. package/dist/profiles/profiles.js +77 -30
  89. package/dist/profiles/profiles.js.map +1 -1
  90. package/dist/projects.d.ts +15 -6
  91. package/dist/projects.js +55 -47
  92. package/dist/projects.js.map +1 -1
  93. package/dist/providers/catalog.d.ts +78 -12
  94. package/dist/providers/catalog.js +445 -141
  95. package/dist/providers/catalog.js.map +1 -1
  96. package/dist/providers/env-file.d.ts +10 -3
  97. package/dist/providers/env-file.js +24 -75
  98. package/dist/providers/env-file.js.map +1 -1
  99. package/dist/providers/runtime-env.d.ts +4 -2
  100. package/dist/providers/runtime-env.js +9 -6
  101. package/dist/providers/runtime-env.js.map +1 -1
  102. package/dist/providers/tables.d.ts +3 -1
  103. package/dist/providers/tables.js +4 -2
  104. package/dist/providers/tables.js.map +1 -1
  105. package/dist/redact.js +18 -9
  106. package/dist/redact.js.map +1 -1
  107. package/dist/release.d.ts +7 -0
  108. package/dist/release.js +14 -0
  109. package/dist/release.js.map +1 -1
  110. package/dist/runtime.d.ts +5 -1
  111. package/dist/runtime.js +233 -90
  112. package/dist/runtime.js.map +1 -1
  113. package/dist/server.js +10 -0
  114. package/dist/server.js.map +1 -1
  115. package/dist/sessions/anchor.d.ts +9 -3
  116. package/dist/sessions/anchor.js +28 -36
  117. package/dist/sessions/anchor.js.map +1 -1
  118. package/dist/sessions/background-events.d.ts +2 -0
  119. package/dist/sessions/background-events.js +10 -0
  120. package/dist/sessions/background-events.js.map +1 -0
  121. package/dist/sessions/background-tasks.d.ts +26 -20
  122. package/dist/sessions/background-tasks.js +88 -86
  123. package/dist/sessions/background-tasks.js.map +1 -1
  124. package/dist/sessions/background-updates.d.ts +15 -8
  125. package/dist/sessions/background-updates.js +101 -25
  126. package/dist/sessions/background-updates.js.map +1 -1
  127. package/dist/sessions/cli-sessions.d.ts +15 -5
  128. package/dist/sessions/cli-sessions.js +62 -43
  129. package/dist/sessions/cli-sessions.js.map +1 -1
  130. package/dist/sessions/completions.d.ts +30 -2
  131. package/dist/sessions/completions.js +294 -53
  132. package/dist/sessions/completions.js.map +1 -1
  133. package/dist/sessions/compress.d.ts +8 -0
  134. package/dist/sessions/compress.js +2 -2
  135. package/dist/sessions/compress.js.map +1 -1
  136. package/dist/sessions/drafts.d.ts +14 -10
  137. package/dist/sessions/drafts.js +38 -43
  138. package/dist/sessions/drafts.js.map +1 -1
  139. package/dist/sessions/gateway-sessions.d.ts +46 -0
  140. package/dist/sessions/gateway-sessions.js +378 -0
  141. package/dist/sessions/gateway-sessions.js.map +1 -0
  142. package/dist/sessions/goals.d.ts +31 -0
  143. package/dist/sessions/goals.js +75 -0
  144. package/dist/sessions/goals.js.map +1 -0
  145. package/dist/sessions/journal.d.ts +5 -79
  146. package/dist/sessions/journal.js +15 -606
  147. package/dist/sessions/journal.js.map +1 -1
  148. package/dist/sessions/list.d.ts +27 -18
  149. package/dist/sessions/list.js +133 -131
  150. package/dist/sessions/list.js.map +1 -1
  151. package/dist/sessions/merge.d.ts +68 -10
  152. package/dist/sessions/merge.js +259 -51
  153. package/dist/sessions/merge.js.map +1 -1
  154. package/dist/sessions/notices.d.ts +22 -0
  155. package/dist/sessions/notices.js +72 -0
  156. package/dist/sessions/notices.js.map +1 -0
  157. package/dist/sessions/pending.d.ts +10 -4
  158. package/dist/sessions/pending.js +83 -7
  159. package/dist/sessions/pending.js.map +1 -1
  160. package/dist/sessions/prefix-index.d.ts +82 -0
  161. package/dist/sessions/prefix-index.js +232 -0
  162. package/dist/sessions/prefix-index.js.map +1 -0
  163. package/dist/sessions/recovery.d.ts +4 -7
  164. package/dist/sessions/recovery.js +97 -281
  165. package/dist/sessions/recovery.js.map +1 -1
  166. package/dist/sessions/relay.d.ts +20 -20
  167. package/dist/sessions/relay.js +205 -127
  168. package/dist/sessions/relay.js.map +1 -1
  169. package/dist/sessions/service.d.ts +162 -59
  170. package/dist/sessions/service.js +1133 -521
  171. package/dist/sessions/service.js.map +1 -1
  172. package/dist/sessions/session.d.ts +18 -2
  173. package/dist/sessions/session.js +29 -5
  174. package/dist/sessions/session.js.map +1 -1
  175. package/dist/sessions/shares.d.ts +7 -6
  176. package/dist/sessions/shares.js +22 -45
  177. package/dist/sessions/shares.js.map +1 -1
  178. package/dist/sessions/source-kind.js +1 -0
  179. package/dist/sessions/source-kind.js.map +1 -1
  180. package/dist/sessions/state-db-identity.d.ts +18 -0
  181. package/dist/sessions/state-db-identity.js +67 -0
  182. package/dist/sessions/state-db-identity.js.map +1 -0
  183. package/dist/sessions/state-db-worker.js +3 -1
  184. package/dist/sessions/state-db-worker.js.map +1 -1
  185. package/dist/sessions/state-db.d.ts +53 -8
  186. package/dist/sessions/state-db.js +576 -50
  187. package/dist/sessions/state-db.js.map +1 -1
  188. package/dist/sessions/storage/backend.d.ts +162 -0
  189. package/dist/sessions/storage/backend.js +19 -0
  190. package/dist/sessions/storage/backend.js.map +1 -0
  191. package/dist/sessions/storage/conformance.d.ts +20 -0
  192. package/dist/sessions/storage/conformance.js +237 -0
  193. package/dist/sessions/storage/conformance.js.map +1 -0
  194. package/dist/sessions/storage/document.d.ts +16 -0
  195. package/dist/sessions/storage/document.js +44 -0
  196. package/dist/sessions/storage/document.js.map +1 -0
  197. package/dist/sessions/storage/json-documents.d.ts +23 -0
  198. package/dist/sessions/storage/json-documents.js +233 -0
  199. package/dist/sessions/storage/json-documents.js.map +1 -0
  200. package/dist/sessions/storage/json-files.d.ts +12 -0
  201. package/dist/sessions/storage/json-files.js +124 -0
  202. package/dist/sessions/storage/json-files.js.map +1 -0
  203. package/dist/sessions/storage/json-installation.d.ts +3 -0
  204. package/dist/sessions/storage/json-installation.js +31 -0
  205. package/dist/sessions/storage/json-installation.js.map +1 -0
  206. package/dist/sessions/storage/json-journal.d.ts +114 -0
  207. package/dist/sessions/storage/json-journal.js +686 -0
  208. package/dist/sessions/storage/json-journal.js.map +1 -0
  209. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  210. package/dist/sessions/storage/json-recovery.js +195 -0
  211. package/dist/sessions/storage/json-recovery.js.map +1 -0
  212. package/dist/sessions/storage/json.d.ts +68 -0
  213. package/dist/sessions/storage/json.js +258 -0
  214. package/dist/sessions/storage/json.js.map +1 -0
  215. package/dist/sessions/storage/row.d.ts +6 -0
  216. package/dist/sessions/storage/row.js +49 -0
  217. package/dist/sessions/storage/row.js.map +1 -0
  218. package/dist/sessions/storage/search-text.d.ts +4 -0
  219. package/dist/sessions/storage/search-text.js +16 -0
  220. package/dist/sessions/storage/search-text.js.map +1 -0
  221. package/dist/sessions/store.d.ts +63 -73
  222. package/dist/sessions/store.js +305 -611
  223. package/dist/sessions/store.js.map +1 -1
  224. package/dist/sessions/streams.d.ts +4 -0
  225. package/dist/sessions/streams.js +9 -1
  226. package/dist/sessions/streams.js.map +1 -1
  227. package/dist/sessions/titles.js +1 -1
  228. package/dist/sessions/titles.js.map +1 -1
  229. package/dist/sessions/todo.d.ts +5 -0
  230. package/dist/sessions/todo.js +28 -21
  231. package/dist/sessions/todo.js.map +1 -1
  232. package/dist/sessions/tool-display.d.ts +5 -4
  233. package/dist/sessions/tool-display.js +22 -16
  234. package/dist/sessions/tool-display.js.map +1 -1
  235. package/dist/sessions/turn-context.d.ts +10 -1
  236. package/dist/sessions/turn-context.js +7 -4
  237. package/dist/sessions/turn-context.js.map +1 -1
  238. package/dist/sessions/turn.d.ts +87 -17
  239. package/dist/sessions/turn.js +795 -347
  240. package/dist/sessions/turn.js.map +1 -1
  241. package/dist/sessions/wakeup-pause.d.ts +8 -13
  242. package/dist/sessions/wakeup-pause.js +31 -49
  243. package/dist/sessions/wakeup-pause.js.map +1 -1
  244. package/dist/sessions/window.js +13 -12
  245. package/dist/sessions/window.js.map +1 -1
  246. package/dist/settings.d.ts +19 -7
  247. package/dist/settings.js +179 -183
  248. package/dist/settings.js.map +1 -1
  249. package/dist/sidecar/client.d.ts +17 -3
  250. package/dist/sidecar/client.js +78 -8
  251. package/dist/sidecar/client.js.map +1 -1
  252. package/dist/sidecar/discover.d.ts +2 -1
  253. package/dist/sidecar/discover.js +93 -10
  254. package/dist/sidecar/discover.js.map +1 -1
  255. package/dist/sidecar/env.d.ts +4 -0
  256. package/dist/sidecar/env.js +3 -0
  257. package/dist/sidecar/env.js.map +1 -0
  258. package/dist/sidecar/fake.js +19 -2
  259. package/dist/sidecar/fake.js.map +1 -1
  260. package/dist/test/env-file.d.ts +5 -0
  261. package/dist/test/env-file.js +88 -0
  262. package/dist/test/env-file.js.map +1 -0
  263. package/dist/test/harness.d.ts +1 -0
  264. package/dist/test/harness.js +1 -1
  265. package/dist/test/harness.js.map +1 -1
  266. package/dist/test/passkeys.d.ts +22 -0
  267. package/dist/test/passkeys.js +62 -0
  268. package/dist/test/passkeys.js.map +1 -0
  269. package/dist/test/storage.d.ts +15 -0
  270. package/dist/test/storage.js +26 -0
  271. package/dist/test/storage.js.map +1 -0
  272. package/dist/tools/commands.d.ts +6 -1
  273. package/dist/tools/commands.js +8 -2
  274. package/dist/tools/commands.js.map +1 -1
  275. package/dist/tools/connectors.d.ts +33 -0
  276. package/dist/tools/connectors.js +121 -0
  277. package/dist/tools/connectors.js.map +1 -0
  278. package/dist/tools/crons.d.ts +38 -1
  279. package/dist/tools/crons.js +82 -15
  280. package/dist/tools/crons.js.map +1 -1
  281. package/dist/tools/dashboard-plugins.d.ts +5 -5
  282. package/dist/tools/dashboard-plugins.js +14 -14
  283. package/dist/tools/dashboard-plugins.js.map +1 -1
  284. package/dist/tools/extensions.d.ts +5 -4
  285. package/dist/tools/extensions.js +213 -207
  286. package/dist/tools/extensions.js.map +1 -1
  287. package/dist/tools/gateway-restart.d.ts +62 -0
  288. package/dist/tools/gateway-restart.js +142 -0
  289. package/dist/tools/gateway-restart.js.map +1 -0
  290. package/dist/tools/health.d.ts +15 -3
  291. package/dist/tools/health.js +97 -34
  292. package/dist/tools/health.js.map +1 -1
  293. package/dist/tools/hygiene.d.ts +4 -3
  294. package/dist/tools/hygiene.js +23 -9
  295. package/dist/tools/hygiene.js.map +1 -1
  296. package/dist/tools/kanban.d.ts +4 -2
  297. package/dist/tools/kanban.js +28 -19
  298. package/dist/tools/kanban.js.map +1 -1
  299. package/dist/tools/memory-masks.d.ts +5 -0
  300. package/dist/tools/memory-masks.js +256 -0
  301. package/dist/tools/memory-masks.js.map +1 -0
  302. package/dist/tools/memory.d.ts +29 -3
  303. package/dist/tools/memory.js +69 -27
  304. package/dist/tools/memory.js.map +1 -1
  305. package/dist/tools/plugin-settings.d.ts +15 -0
  306. package/dist/tools/plugin-settings.js +90 -0
  307. package/dist/tools/plugin-settings.js.map +1 -0
  308. package/dist/tools/skills.d.ts +9 -4
  309. package/dist/tools/skills.js +44 -13
  310. package/dist/tools/skills.js.map +1 -1
  311. package/dist/tools/update-notifications.d.ts +34 -15
  312. package/dist/tools/update-notifications.js +141 -57
  313. package/dist/tools/update-notifications.js.map +1 -1
  314. package/dist/tools/update-rollback.d.ts +11 -0
  315. package/dist/tools/update-rollback.js +28 -0
  316. package/dist/tools/update-rollback.js.map +1 -0
  317. package/dist/tools/updates.d.ts +93 -33
  318. package/dist/tools/updates.js +1051 -345
  319. package/dist/tools/updates.js.map +1 -1
  320. package/dist/util.d.ts +2 -0
  321. package/dist/util.js +5 -0
  322. package/dist/util.js.map +1 -1
  323. package/dist/workspace/git.d.ts +16 -1
  324. package/dist/workspace/git.js +291 -11
  325. package/dist/workspace/git.js.map +1 -1
  326. package/dist/workspace/media.d.ts +12 -9
  327. package/dist/workspace/media.js +31 -20
  328. package/dist/workspace/media.js.map +1 -1
  329. package/dist/workspace/rollback.d.ts +2 -2
  330. package/dist/workspace/rollback.js +5 -5
  331. package/dist/workspace/rollback.js.map +1 -1
  332. package/dist/workspace/workspaces.d.ts +15 -19
  333. package/dist/workspace/workspaces.js +55 -127
  334. package/dist/workspace/workspaces.js.map +1 -1
  335. package/dist/workspace/worktrees.js +170 -32
  336. package/dist/workspace/worktrees.js.map +1 -1
  337. package/package.json +5 -3
  338. package/sidecar/agent_dependency.json +4 -4
  339. package/sidecar/agent_support.json +26 -0
  340. package/sidecar/scripts/record-fixtures.py +12 -3
  341. package/sidecar/scripts/replay_sidecar.py +8 -0
  342. package/sidecar/scripts/suite_runner.py +302 -0
  343. package/sidecar/scripts/test.sh +12 -4
  344. package/sidecar/talaria_sidecar/__init__.py +6 -1
  345. package/sidecar/talaria_sidecar/__main__.py +4 -2
  346. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  347. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  348. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  349. package/sidecar/talaria_sidecar/home.py +43 -1
  350. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  351. package/sidecar/talaria_sidecar/methods/aux.py +139 -31
  352. package/sidecar/talaria_sidecar/methods/chat.py +847 -134
  353. package/sidecar/talaria_sidecar/methods/commands.py +213 -4
  354. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  355. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  356. package/sidecar/talaria_sidecar/methods/cron.py +366 -34
  357. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  358. package/sidecar/talaria_sidecar/methods/gateway.py +194 -54
  359. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  360. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  361. package/sidecar/talaria_sidecar/methods/kanban.py +80 -27
  362. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  363. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  364. package/sidecar/talaria_sidecar/methods/process.py +160 -11
  365. package/sidecar/talaria_sidecar/methods/profiles.py +44 -6
  366. package/sidecar/talaria_sidecar/methods/providers.py +196 -26
  367. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  368. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  369. package/sidecar/talaria_sidecar/methods/state_db.py +277 -70
  370. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  371. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  372. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  373. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  374. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  375. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  376. package/sidecar/talaria_sidecar/rpc.py +45 -4
  377. package/sidecar/talaria_sidecar/runtime.py +81 -12
  378. package/sidecar/talaria_sidecar/speed.py +35 -0
  379. package/static/dist/FILES.txt +126 -127
  380. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  381. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  382. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  383. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  384. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  385. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  386. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  387. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  388. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  389. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  390. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  391. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  392. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  393. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  394. package/static/dist/assets/{_app.ext._extensionId-gfbzpoou.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  395. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  396. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  397. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  398. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  399. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  400. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  401. package/static/dist/assets/{_app.session._sessionId-fn1bhde6.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  402. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  403. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  404. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  405. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  406. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  407. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  408. package/static/dist/assets/{abnfDiagram-VCTEODGH-c8kdixke.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  409. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  410. package/static/dist/assets/{architectureDiagram-5GKGNRK7-cku06x1p.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  411. package/static/dist/assets/archive-b1j004vn.js +1 -0
  412. package/static/dist/assets/back-g74gkozu.js +1 -0
  413. package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  414. package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  415. package/static/dist/assets/channel-es95o4y8.js +1 -0
  416. package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  417. package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  418. package/static/dist/assets/{chunk-DU6HZSFF-ofajdixh.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  419. package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  420. package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  421. package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  422. package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  423. package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  424. package/static/dist/assets/{chunk-OSK3NFVY-e7gkikcw.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  425. package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  426. package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  427. package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  428. package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  429. package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  430. package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  431. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  432. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  433. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  434. package/static/dist/assets/{copy-magtofyp.js → copy-jamtd1pf.js} +1 -1
  435. package/static/dist/assets/{cynefin-OW5HDTMX-siqlulkl.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  436. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-i648tah9.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  437. package/static/dist/assets/{dagre-GXQ25YYZ-mwn6uc17.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  438. package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  439. package/static/dist/assets/{diagram-UQ7AKVKN-mekq7zgq.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  440. package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  441. package/static/dist/assets/{diagram-VX7I27RA-xe6r5qfw.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  442. package/static/dist/assets/{diagram-Z3DM3KII-nq07dg7k.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  443. package/static/dist/assets/{ebnfDiagram-PWID7BFC-c62ipm3g.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  444. package/static/dist/assets/{erDiagram-RLTQ6QDP-h39kmnzy.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  445. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  446. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  447. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  448. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  449. package/static/dist/assets/{gitGraph-4MIJSDKK-fr2i7caa.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  450. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  451. package/static/dist/assets/{highlighted-body-KPVGNVTW-jwpop9p8.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  452. package/static/dist/assets/host-hzm8n85n.js +1 -0
  453. package/static/dist/assets/index-drdcaqeq.css +2 -0
  454. package/static/dist/assets/index-jzm844he.js +11 -0
  455. package/static/dist/assets/{info-A6RAGUB7-h11x0dxo.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  456. package/static/dist/assets/{infoDiagram-27XIBGKW-dfh47dw9.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  457. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  458. package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  459. package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  460. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  461. package/static/dist/assets/{line-meizqsvn.js → line-h0ojzsvs.js} +1 -1
  462. package/static/dist/assets/link-k01ryaf9.js +1 -0
  463. package/static/dist/assets/login-bsae49ew.js +1 -0
  464. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  465. package/static/dist/assets/{mermaid-parser.core-ntne45i9.js → mermaid-parser.core-bnre27u8.js} +3 -3
  466. package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  467. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  468. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  469. package/static/dist/assets/{packet-AYTQ26CC-l6jdka8p.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  470. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  471. package/static/dist/assets/{pegDiagram-XKGWAZYB-g1er3bxj.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  472. package/static/dist/assets/{pie-WAS4IAKB-i0ylcrt9.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  473. package/static/dist/assets/{pieDiagram-E7YTZNPT-fbz1e55z.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  474. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  475. package/static/dist/assets/{radar-RG4KPBEZ-f9b70aa4.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  476. package/static/dist/assets/{railroad-74A4TZTK-lgsuoeda.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  477. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  478. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  479. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  480. package/static/dist/assets/{railroadDiagram-O6MQD6OU-eqpy9fnz.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  481. package/static/dist/assets/{refresh-cw-hlwb02qv.js → refresh-cw-mqbkv2a3.js} +1 -1
  482. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  483. package/static/dist/assets/{requirementDiagram-BXWQKSXE-ogf6puhi.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  484. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  485. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  486. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  487. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  488. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  489. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  490. package/static/dist/assets/{stateDiagram-D77RDMKH-fyoznpcs.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  491. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  492. package/static/dist/assets/{swimlanes-42K2YHIH-oefldhfy.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  493. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  494. package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  495. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  496. package/static/dist/assets/{treeView-Q6P3EWNA-f7hcci5o.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  497. package/static/dist/assets/{treemap-WGGIJYW6-nl85071a.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  498. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  499. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  500. package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-gjc70qru.js} +1 -1
  501. package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-e6ae539u.js} +1 -1
  502. package/static/dist/assets/{vennDiagram-4TSXK5OY-h5hzbk0n.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  503. package/static/dist/assets/{wardley-WFR3VGLG-jftvpm3e.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  504. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-hedh85ge.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  505. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  506. package/static/dist/index.html +1 -1
  507. package/static/dist/sw.js +1 -1
  508. package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
  509. package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
  510. package/static/dist/assets/Dialog-g355qxhp.js +0 -1
  511. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
  512. package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
  513. package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
  514. package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
  515. package/static/dist/assets/Select-kddq75qc.js +0 -1
  516. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
  517. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
  518. package/static/dist/assets/States-eq6fe29m.js +0 -1
  519. package/static/dist/assets/Toaster-pc247u9f.js +0 -1
  520. package/static/dist/assets/_app-f8n1i0m1.js +0 -1
  521. package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
  522. package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
  523. package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
  524. package/static/dist/assets/_app.logs-i69qds98.js +0 -1
  525. package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
  526. package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
  527. package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
  528. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
  529. package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
  530. package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
  531. package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
  532. package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
  533. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
  534. package/static/dist/assets/back-h7aun7ew.js +0 -1
  535. package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
  536. package/static/dist/assets/channel-b2zo73z1.js +0 -1
  537. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
  538. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
  539. package/static/dist/assets/copied-hcplv46p.js +0 -1
  540. package/static/dist/assets/endpoints-klbxod4t.js +0 -64
  541. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
  542. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
  543. package/static/dist/assets/host-i828xuo7.js +0 -1
  544. package/static/dist/assets/index-eh8zilnv.css +0 -2
  545. package/static/dist/assets/index-r648f4o1.js +0 -11
  546. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
  547. package/static/dist/assets/link-oot2yn73.js +0 -1
  548. package/static/dist/assets/login-gu28l9ou.js +0 -1
  549. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
  550. package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
  551. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
  552. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
  553. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
  554. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
  555. package/static/dist/assets/refresh-abtu93ke.js +0 -1
  556. package/static/dist/assets/saved-girnabkf.js +0 -1
  557. package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
  558. package/static/dist/assets/share._token-kl0r7ido.js +0 -2
  559. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
  560. package/static/dist/assets/sse-iwku0sjo.js +0 -1
  561. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
  562. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
  563. package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
  564. package/static/dist/assets/units-kgxzxfhi.js +0 -1
  565. package/static/dist/assets/untitled-cikfjntn.js +0 -1
  566. package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
  567. package/static/dist/assets/useLocale-o59tbiea.js +0 -1
@@ -1,28 +1,32 @@
1
+ import { filterAsync } from '../util.js';
2
+ import { statSignature } from '../fs/stat.js';
3
+ import { SessionPrefixIndex } from './prefix-index.js';
1
4
  import { str } from '../util.js';
2
- import { randomUUID } from 'node:crypto';
5
+ import { createHmac, randomUUID } from 'node:crypto';
3
6
  import { rmSync } from 'node:fs';
4
7
  import { buildActiveTurnToken, copyJson, redactSessionData, redactValue, stripPublicInternalFields, withSceneToolDisplay } from '../redact.js';
5
8
  import { DraftVersionConflict, normalizeDraftVersion } from './drafts.js';
6
- import { allSessions, buildSessionListPayload, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches, sessionSearchMessageText, sessionSearchPreview, sessionSearchTerms } from './list.js';
9
+ import { allSessions, buildSessionListPayload, reconcileLocalSessionRows, sessionLineageIds, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches, sessionSearchPreview, sessionSearchTerms } from './list.js';
7
10
  import { anchorSceneIntOrNull, fullToolResult, hydrateAnchorActivityScenes, normalizeAnchorSceneMessageRef, readAnchorSceneRows, storeAnchorScene, withTurnIds } from './anchor.js';
8
- import { COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom } from './session.js';
9
- import { SessionBusy, SessionNotFound, statSignature } from './store.js';
10
- import { UNSETTLED_TODO_KEY, attachTodoState } from './todo.js';
11
+ import { contextUsage, COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, forgetContextFigures, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom } from './session.js';
12
+ import { SessionBusy, SessionNotFound } from './store.js';
13
+ import { UNSETTLED_TODO_KEY, attachTodoSnapshot, attachTodoState } from './todo.js';
11
14
  import { isClaudeCodeSessionId } from './claude-code.js';
12
15
  import { auditSessionRecovery, repairSafeSessionRecovery } from './recovery.js';
13
- import { agentSessionRowsExisting, agentSessionRowsExistingAsync, CONVERSATION_ROUND_THRESHOLD, countConversationRounds, stateDbCompressionLineage, stateDbTailToolContent, stateDbLineageReport, stateDbSessionMessages, stateDbSessionRead, stateDbSessionRow, stateDbSessionSources, stateDbSessionSourcesAsync, stateDbTimestampSeconds } from './state-db.js';
16
+ import { agentSessionRowsExisting, agentSessionRowsExistingAsync, CANONICAL_READ_ONLY_REASON, OWNERSHIP_UNAVAILABLE_REASON, CONVERSATION_ROUND_THRESHOLD, countConversationRounds, stateDbCompressionLineage, stateDbTailToolContent, stateDbLineageReport, stateDbLocalTarget, stateDbSessionMessages, stateDbSessionRead, stateDbSessionTailRead, stateDbSessionRow, stateDbSessionSources, stateDbSessionSourcesAsync, stateDbTimestampSeconds } from './state-db.js';
14
17
  import { completionIncomplete, fallbackHandoffSummary, HANDOFF_SYSTEM_PROMPT, handoffMarker, handoffPayload, handoffTranscript, messageHandoffPayload, sameHandoff } from './handoff.js';
15
18
  import { anchorMessageKey, anchorSummary, CompressionJobs, compressionReference, visibleMessagesForAnchor } from './compress.js';
16
- import { SidecarError } from '../sidecar/client.js';
17
- import { agentSteerText, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
19
+ import { chatReadiness, runtimeFailureDetails, SidecarError } from '../sidecar/client.js';
20
+ import { agentSteerText, ApiMessageSummary, appendStateDbIdentityRows, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, newestSettledReply, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withMirrorSources, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
18
21
  import { withBackgroundUpdates } from './background-updates.js';
22
+ import { markedStateDbGeneration, stateDbIdentityKeys, stampStateDbGeneration, stateDbMessageRepresented } from './state-db-identity.js';
19
23
  import { sourceKind } from './source-kind.js';
20
24
  import { withBackgroundLinks } from './background-tasks.js';
21
- import { messagesForLimitedPayload, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
25
+ import { messagesForLimitedPayload, messageCountsAsRenderable, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
22
26
  import { redactText } from '../redact.js';
23
27
  import { workspaceDisplayName } from '../workspace/workspaces.js';
24
28
  import { buildShareSnapshot } from './shares.js';
25
- import { mediaAnchorRoot, mediaRefPath, mediaTarget } from '../workspace/media.js';
29
+ import { mediaAnchorRootResolved, resolveMediaAccess, mediaRefPath, mediaTarget } from '../workspace/media.js';
26
30
  import { projectMediaRefs } from '../workspace/media-refs.js';
27
31
  import { loadGatewaySessionIdentityMap } from './list.js';
28
32
  import { join } from 'node:path';
@@ -51,8 +55,31 @@ export async function ensureAgentRuntimeCurrent(sidecar) {
51
55
  throw staleRuntimeFailure(error);
52
56
  }
53
57
  }
58
+ /** The health probe is also the browser's server-reachability probe (30 s), so the readiness check answers well inside it. */
59
+ const CHAT_READINESS_CHECK_TIMEOUT_MS = 5_000;
60
+ /**
61
+ * TAL-951: whether a chat turn would be admitted now. Ready and source-stale incompatible sidecars both run turn
62
+ * admission's live check: recovery state can change after the handshake. A check
63
+ * that cannot answer fails closed as `sidecar_unavailable`.
64
+ */
65
+ export async function currentChatReadiness(sidecar) {
66
+ const readiness = chatReadiness(sidecar);
67
+ if (!sidecar || (!readiness.available && readiness.condition !== 'agent_runtime_stale'))
68
+ return readiness;
69
+ try {
70
+ await sidecar.call('runtime.ensure_current', {}, { timeoutMs: CHAT_READINESS_CHECK_TIMEOUT_MS });
71
+ }
72
+ catch (error) {
73
+ if (error instanceof SidecarError && error.condition === 'agent_runtime_stale')
74
+ return { available: false, ...runtimeFailureDetails(error.condition, error.data), condition: 'agent_runtime_stale' };
75
+ return { available: false, condition: error instanceof SidecarError && error.condition === 'agent_incompatible' ? 'agent_incompatible' : 'sidecar_unavailable' };
76
+ }
77
+ // The live guard can clear after a rollback, but the client's incompatible admission gate still refuses chat.
78
+ return readiness.available ? readiness : { available: false, ...runtimeFailureDetails('agent_runtime_stale'), condition: 'agent_runtime_stale' };
79
+ }
54
80
  function staleRuntimeFailure(error) {
55
- return new HttpFailure(409, error.message, { type: 'agent_runtime_stale', retryable: true, restart_scheduled: false, ...(error.data.agent_update_state !== undefined ? { agent_update_state: error.data.agent_update_state } : {}) });
81
+ const { message, ...details } = runtimeFailureDetails(error.condition, error.data);
82
+ return new HttpFailure(409, message ?? error.message, details);
56
83
  }
57
84
  /** TAL-536: the most of a dead run's journal stale-stream cleanup reads (Python's recovery window). */
58
85
  const RECOVERY_JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
@@ -60,29 +87,32 @@ const RECOVERY_JOURNAL_MAX_ROWS = 65536;
60
87
  const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
61
88
  export class SessionService {
62
89
  deps;
90
+ owner = null;
63
91
  /** TAL-255: one manual compression worker per session, shared by every compress route. */
64
92
  compressionJobs;
93
+ /** TAL-1005: the turns now waiting on their session's compression job before they read its history. */
94
+ compactionWaiters = new Set();
65
95
  constructor(deps) {
66
96
  this.deps = deps;
67
97
  this.compressionJobs = new CompressionJobs();
68
98
  }
69
99
  get store() { return this.deps.store; }
70
100
  /**
71
- * TAL-577: user-turn admission shared by `chat.start` and server-side wakeups, after `ensureAgentRuntimeCurrent`.
101
+ * User-turn admission shared by `chat.start` and automatic turns through `TurnRunner.startServerTurn`.
72
102
  * A requested workspace must be trusted; the session's own one, when its directory is gone, falls back to the last one.
73
103
  */
74
- turnWorkspace(s, requested) {
104
+ async turnWorkspace(s, requested) {
75
105
  const ws = this.deps.workspaces;
76
106
  if (requested !== undefined && requested !== null && requested !== '') {
77
107
  try {
78
- return ws.resolveTrusted(str(requested), s.profile);
108
+ return (await ws.resolveTrusted(str(requested), s.profile));
79
109
  }
80
110
  catch (error) {
81
111
  throw new HttpFailure(400, error.message);
82
112
  }
83
113
  }
84
114
  try {
85
- const [workspace, recovered] = ws.resolveImplicitWithRecovery(s.workspace, (p) => ws.lastWorkspace(p), s.profile);
115
+ const [workspace, recovered] = (await ws.resolveImplicitWithRecovery(s.workspace, async (p) => (await ws.lastWorkspace(p)), s.profile));
86
116
  if (recovered)
87
117
  s.workspace = workspace;
88
118
  return workspace;
@@ -105,18 +135,19 @@ export class SessionService {
105
135
  * TAL-186: rewrites a session's media references to their URLs. A local path is rewritten only when `/api/media`
106
136
  * serves it for this session, under the same resolution, so every rewritten reference loads.
107
137
  */
108
- mediaProjector(s) {
138
+ async mediaProjector(s) {
109
139
  const media = this.deps.media;
140
+ const access = media ? await resolveMediaAccess(media.access) : null;
110
141
  const served = new Map();
111
142
  const localUrl = (path) => {
112
- if (!media?.localIo(s.profile))
143
+ if (!media?.localIo(s.profile) || !access)
113
144
  return null;
114
145
  if (served.has(path))
115
146
  return served.get(path) ?? null;
116
147
  let url = null;
117
148
  try {
118
149
  const target = mediaTarget(mediaRefPath(path));
119
- if (mediaAnchorRoot(target, s, media.access))
150
+ if (mediaAnchorRootResolved(target, s, access))
120
151
  url = `./api/media?${new URLSearchParams({ path: target, session_id: s.session_id }).toString()}`;
121
152
  }
122
153
  catch { /* an invalid path stays text */ }
@@ -143,7 +174,7 @@ export class SessionService {
143
174
  * not slip past the profile check into a handler that trims and trusts it. A non-empty id that is still unsafe after
144
175
  * trimming can name no session and is refused here instead of falling through.
145
176
  */
146
- sessionIdVisible(raw) {
177
+ async sessionIdVisible(raw) {
147
178
  if (typeof raw !== 'string' || !raw.trim())
148
179
  return true;
149
180
  const sid = raw.trim();
@@ -151,62 +182,95 @@ export class SessionService {
151
182
  return false;
152
183
  let session;
153
184
  try {
154
- session = this.store.get(sid, { metadataOnly: true });
185
+ session = (await this.store.get(sid, { metadataOnly: true }));
155
186
  }
156
187
  catch {
157
188
  return true;
158
189
  }
159
190
  return this.visibleToActiveProfile(session.profile);
160
191
  }
192
+ localTargets = null;
193
+ /** One receipt-backed physical target for every operation on a local conversation alias. */
194
+ resolveSessionId(sid) {
195
+ try {
196
+ const path = this.stateDbPath(null);
197
+ const key = JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]);
198
+ if (this.localTargets?.key !== key)
199
+ this.localTargets = { key, ids: new Map() };
200
+ const cached = this.localTargets.ids.get(sid);
201
+ if (cached)
202
+ return cached;
203
+ return stateDbLocalTarget(path, sid, { onRead: (target) => {
204
+ // A failed or changing read cannot seed a routing cache.
205
+ if (key !== JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]))
206
+ return;
207
+ if (this.localTargets.ids.size >= 500)
208
+ this.localTargets.ids.clear();
209
+ this.localTargets.ids.set(sid, target);
210
+ } });
211
+ }
212
+ catch {
213
+ return sid;
214
+ }
215
+ }
161
216
  /** Python `_lookup_cli_session_metadata(...).get('read_only')` plus the persisted sidecar flag and a not-claimable foreign owner. */
162
- isReadOnlyImport(sid) {
217
+ async isReadOnlyImport(sid) {
163
218
  try {
164
- const s = this.store.get(sid, { metadataOnly: true });
165
- if (this.isReadOnly(s))
219
+ const s = (await this.store.get(sid, { metadataOnly: true }));
220
+ if ((await this.isReadOnly(s)))
166
221
  return true;
167
222
  }
168
223
  catch {
169
224
  // No sidecar: a foreign state.db transcript whose owner refuses claiming is read-only too.
170
- const { session, reason } = this.claimOrSynthesizeCliSession(sid);
225
+ const { session, reason } = (await this.claimOrSynthesizeCliSession(sid));
171
226
  if (session && reason === 'not_claimable')
172
227
  return true;
173
228
  }
174
- const meta = this.lookupCliMeta(sid);
229
+ const meta = (await this.lookupCliMeta(sid));
175
230
  return meta !== null && Boolean(meta.read_only);
176
231
  }
177
232
  /** Python `_session_is_subagent_view_only`: a delegated child by any signal — the persisted sidecar or the state.db row. */
178
- isSubagentViewOnly(sid) {
233
+ async isSubagentViewOnly(sid) {
179
234
  try {
180
- const s = this.store.get(sid);
235
+ const s = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false }));
181
236
  if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
182
237
  return true;
183
238
  }
184
239
  catch { /* no sidecar */ }
185
- const meta = this.lookupCliMeta(sid);
240
+ const meta = (await this.lookupCliMeta(sid));
186
241
  return meta !== null && str(meta.source_tag || meta.raw_source || meta.source).trim().toLowerCase() === 'subagent';
187
242
  }
188
243
  /** TAL-358: batched `sessions.source` owners from the active profile's state.db. */
189
- stateDbSources = (ids) => stateDbSessionSources(join(this.deps.profileHome(this.deps.activeProfile()), 'state.db'), ids);
244
+ stateDbSources = (ids, profile = this.deps.activeProfile()) => {
245
+ try {
246
+ return stateDbSessionSources(this.stateDbPath(profile), ids);
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ };
190
252
  /**
191
253
  * TAL-590 (Python #3238): the read-only CLI or API-server imports among `rows` whose active-profile state.db row is
192
254
  * gone, so their source was deleted outside Web and no Web action could remove them. An unreadable state.db proves
193
255
  * nothing (`agentSessionRowsExisting`); another profile's session or one with a run the runtime still holds is never
194
256
  * an orphan, while a stream id a crash left behind does not count as one.
195
257
  */
196
- orphanedImports(rows) {
197
- const candidates = this.orphanCandidates(rows);
258
+ async orphanedImports(rows, existing, owners) {
259
+ const candidates = (await this.orphanCandidates(rows));
198
260
  if (!candidates.length)
199
261
  return new Set();
200
- const existing = agentSessionRowsExisting(this.stateDbPath(null), candidates);
201
- return new Set(candidates.filter((sid) => !existing.has(sid)));
262
+ existing ??= agentSessionRowsExisting(this.stateDbPath(null), candidates);
263
+ if (owners === undefined)
264
+ owners = this.stateDbSources(candidates);
265
+ return new Set(candidates.filter((sid) => !existing.has(sid) && owners !== null && typeof owners.get(sid) !== 'object'));
202
266
  }
203
- orphanCandidates(rows) {
204
- return rows.filter((r) => r.read_only && ['cli', 'api'].includes(sourceKind(r)) && this.visibleToActiveProfile(str(r.profile) || null) && !this.activeRunBlocking(str(r.session_id)))
267
+ async orphanCandidates(rows) {
268
+ return (await filterAsync(rows, async (r) => r.read_only && ['cli', 'api'].includes(sourceKind(r)) && this.visibleToActiveProfile(str(r.profile) || null) && !(await this.activeRunBlocking(str(r.session_id)))))
205
269
  .map((r) => str(r.session_id));
206
270
  }
207
- isOrphanedImport(sid) {
271
+ async isOrphanedImport(sid) {
208
272
  try {
209
- return this.orphanedImports([this.store.get(sid, { metadataOnly: true }).compact()]).size > 0;
273
+ return (await this.orphanedImports([(await this.store.get(sid, { metadataOnly: true })).compact()])).size > 0;
210
274
  }
211
275
  catch {
212
276
  return false;
@@ -216,51 +280,97 @@ export class SessionService {
216
280
  * The list's owner locks and orphan prune (an orphan's sidecar goes, as nothing else ever removes it), with their
217
281
  * state.db reads made on the listing worker up front so the synchronous build never opens state.db (TAL-920).
218
282
  */
219
- async listStateDbLookups(rows) {
283
+ async listStateDbLookups(rows, cliRowIds) {
220
284
  const dbPath = this.stateDbPath(null);
221
- const ownerIds = ownerLockCandidates(rows);
222
- const owners = ownerIds.length ? await stateDbSessionSourcesAsync(dbPath, ownerIds) : new Map();
223
- const checked = new Set(this.orphanCandidates(rows));
285
+ const activeProfile = this.deps.activeProfile();
286
+ const byProfile = ownerLockCandidates(rows);
287
+ const activeIds = byProfile.get(activeProfile) ?? new Set();
288
+ for (const id of cliRowIds)
289
+ activeIds.add(id);
290
+ byProfile.set(activeProfile, activeIds);
291
+ const owners = new Map(await Promise.all([...byProfile].map(async ([profile, ids]) => [profile, ids.size ? await stateDbSessionSourcesAsync(this.stateDbPath(profile), ids) : new Map()])));
292
+ const checked = new Set((await this.orphanCandidates(rows)));
224
293
  const existing = checked.size ? await agentSessionRowsExistingAsync(dbPath, checked) : new Set();
225
294
  return {
226
- stateDbSources: () => owners,
227
- pruneOrphanedImports: (current) => {
295
+ stateDbSources: (_ids, profile = activeProfile) => owners.get(profile) ?? null,
296
+ pruneOrphanedImports: async (current) => {
228
297
  // Only an id the worker checked can be an orphan; a row that became a candidate meanwhile waits for the next list.
229
- const orphans = new Set(this.orphanCandidates(current).filter((sid) => checked.has(sid) && !existing.has(sid)));
298
+ const orphans = (await this.orphanedImports(current.filter((r) => checked.has(str(r.session_id))), existing, owners.get(activeProfile) ?? null));
230
299
  for (const sid of orphans)
231
- this.store.deleteFiles(sid);
300
+ (await this.store.delete(sid));
232
301
  return orphans;
233
302
  },
234
303
  };
235
304
  }
236
305
  /** The persisted flag folded with the state.db owner lock (TAL-358): the one read-only rule for mutation gates and the wire. */
237
- isReadOnly(s) {
238
- if (s.read_only)
239
- return true;
240
- const [row] = withOwnerLocks([{ session_id: s.session_id, source_tag: s.source_tag, raw_source: s.raw_source, session_source: s.session_source }], this.stateDbSources);
241
- return Boolean(row?.read_only);
306
+ async readOnlyReason(s, profile) {
307
+ if (typeof s === 'string') {
308
+ try {
309
+ s = await this.store.get(s, { metadataOnly: true, cacheOnMiss: false, promote: false });
310
+ }
311
+ catch (error) {
312
+ if (!(error instanceof SessionNotFound))
313
+ return OWNERSHIP_UNAVAILABLE_REASON;
314
+ }
315
+ }
316
+ return this.loadedReadOnlyReason(s, profile);
317
+ }
318
+ loadedReadOnlyReason(s, profile) {
319
+ let raw = typeof s === 'string' ? { session_id: s, profile: this.deps.activeProfile() }
320
+ : { session_id: s.session_id, profile: s.profile, source_tag: s.source_tag, raw_source: s.raw_source, session_source: s.session_source, read_only: s.read_only };
321
+ if (profile !== undefined)
322
+ raw = this.deps.profilesMatch(str(raw.profile) || null, profile) ? { ...raw, profile } : { session_id: raw.session_id, profile };
323
+ const row = withOwnerLocks([raw], this.stateDbSources)[0];
324
+ return row.read_only ? str(row.read_only_reason) || 'Read-only imported sessions cannot be modified from WebUI' : null;
325
+ }
326
+ assertWritable(session, profile) {
327
+ const check = (reason) => { if (reason)
328
+ throw new HttpFailure(403, reason); };
329
+ if (typeof session === 'string')
330
+ return this.readOnlyReason(session, profile).then(check);
331
+ check(this.loadedReadOnlyReason(session, profile));
332
+ }
333
+ /** Controls can exist without a local session file; use the same id the sidecar will address. */
334
+ async idForMutation(sid) {
335
+ sid = sid.trim();
336
+ if (!sid)
337
+ throw new HttpFailure(400, 'Missing required field(s): session_id');
338
+ await this.assertWritable(sid);
339
+ return sid;
340
+ }
341
+ async isReadOnly(s) { return (await this.readOnlyReason(s)) !== null; }
342
+ /** Recheck after the mutex wait; legacy local metadata/handoff actions keep their existing read-only allowance. */
343
+ withMutationLock(sid, fn, opts = {}) {
344
+ return this.store.withLock(sid, async () => {
345
+ if (opts.allowReadOnly)
346
+ (await this.rejectViewOnly(sid, 'modified'));
347
+ else
348
+ (await this.assertWritable(sid));
349
+ return fn();
350
+ }, opts);
242
351
  }
243
352
  /** Full session for mutation, refusing read-only imports (Python `_get_or_materialize_session`). */
244
- getForMutation(sid) {
245
- let s = this.get404(sid);
246
- s = this.store.ensureFull(sid, s);
247
- if (this.isReadOnly(s))
248
- throw new HttpFailure(403, 'Read-only imported sessions cannot be modified from WebUI');
353
+ async getForMutation(sid) {
354
+ let s = (await this.get404(sid));
355
+ s = (await this.store.ensureFull(sid, s));
356
+ this.assertWritable(s);
249
357
  if (str(s.source_tag || s.raw_source).trim().toLowerCase() === 'subagent')
250
358
  throw new HttpFailure(403, 'Read-only subagent child session');
251
359
  return s;
252
360
  }
253
- get404(sid, opts = {}) {
361
+ async get404(sid, opts = {}) {
362
+ sid = this.resolveSessionId(sid);
254
363
  try {
255
- return this.store.get(sid, opts);
364
+ return (await this.store.get(sid, opts));
256
365
  }
257
366
  catch (error) {
258
367
  if (error instanceof SessionNotFound) {
259
368
  // Python `_get_or_materialize_session`: a claimable foreign (CLI/TUI/Desktop) session gains a WebUI sidecar
260
369
  // on its first mutation; a read-only foreign source answers 403 from `getForMutation`, a missing one 404.
261
- const synth = this.claimOrSynthesizeCliSession(sid);
370
+ const synth = (await this.claimOrSynthesizeCliSession(sid));
262
371
  if (synth.session && synth.reason === 'materialized') {
263
- this.store.save(synth.session);
372
+ await this.store.save(synth.session);
373
+ await this.store.touch(synth.session);
264
374
  this.deps.titleClaimed?.(synth.session);
265
375
  return synth.session;
266
376
  }
@@ -278,11 +388,16 @@ export class SessionService {
278
388
  /** `stateDbRows` and whether the read succeeded on a state.db whose messages carry ids (TAL-493). */
279
389
  stateDbRead(s) {
280
390
  if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
281
- return { rows: [], idCapable: false, ok: false };
391
+ return { rows: [], idCapable: false, ok: false, reason: null, generation: null };
282
392
  // TAL-529: after a compression rotation the Agent writes to the continuation, so the read follows the recorded lineage.
283
393
  const lineage = s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null;
284
394
  return stateDbSessionRead(this.stateDbPath(s.profile), s.session_id, { stitch: false, lineage });
285
395
  }
396
+ /** Preserve existing transcript provenance before a turn adds Agent rows from its starting database read. */
397
+ beginStateDbTurn(s) {
398
+ stampStateDbGeneration([...s.messages, ...s.context_messages], markedStateDbGeneration(s));
399
+ return this.stateDbRead(s);
400
+ }
286
401
  stateDbPath(profile) {
287
402
  return join(this.deps.profileHome(profile ?? this.deps.activeProfile()), 'state.db');
288
403
  }
@@ -294,7 +409,7 @@ export class SessionService {
294
409
  mergedTranscript(s, local = s.messages, stateRows = this.stateDbRows(s)) {
295
410
  if (!stateRows.length)
296
411
  return local;
297
- return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows) });
412
+ return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows), stateDbGeneration: markedStateDbGeneration(s) });
298
413
  }
299
414
  /**
300
415
  * TAL-493: record the highest state.db id this boundary or settled turn read, under the session lock in the same save,
@@ -302,6 +417,10 @@ export class SessionService {
302
417
  * fields are set: the marker holds only while they and the row it names stay as recorded.
303
418
  */
304
419
  markStateDbSeen(s, read = this.stateDbRead(s), opts = {}) {
420
+ // Freeze numeric provenance before replacing the marker, including after a database replacement.
421
+ const priorGeneration = markedStateDbGeneration(s);
422
+ if (priorGeneration !== null)
423
+ stampStateDbGeneration([...s.messages, ...s.context_messages], priorGeneration);
305
424
  // A failed read says nothing. A settlement keeps the marker; a boundary drops it (fail closed): it may cut rows the
306
425
  // marker still admits without moving a field, and the timestamp rules honour its watermark.
307
426
  if (!read.ok) {
@@ -327,21 +446,37 @@ export class SessionService {
327
446
  */
328
447
  settleStateDb(s, turn) {
329
448
  const read = turn.read ?? this.stateDbRead(s);
330
- const startedWith = new Set(turn.previousContext.flatMap((m) => (typeof m._state_db_row_id === 'number' ? [m._state_db_row_id] : [])));
331
- const earlier = mergeSessionMessagesAppendOnly(s.messages, read.rows.filter((m) => typeof m._state_db_row_id === 'number' && startedWith.has(m._state_db_row_id)), { stateDbSeenId: -1 }).slice(s.messages.length);
449
+ const agentAdded = withoutRows(turn.agentRows ?? [], turn.previousContext);
450
+ const contextAdded = withoutRows(s.context_messages, turn.previousContext);
451
+ if (typeof turn.startGeneration === 'string') {
452
+ stampStateDbGeneration(s.messages.filter((m) => m._turn_id !== turn.turnId), markedStateDbGeneration(s));
453
+ stampStateDbGeneration([...s.messages.filter((m) => m._turn_id === turn.turnId), ...contextAdded, ...agentAdded], turn.startGeneration);
454
+ }
455
+ const startGeneration = turn.previousContext.findLast((m) => typeof m._state_db_generation === 'string')?._state_db_generation;
456
+ const generation = turn.startGeneration ?? markedStateDbGeneration(s) ?? (typeof startGeneration === 'string' ? startGeneration : null);
457
+ const readGeneration = read.generation;
458
+ const generationChanged = generation !== null && typeof readGeneration === 'string' && generation !== readGeneration;
459
+ const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
460
+ const identities = (m) => stateDbIdentityKeys(m, includeUids, generation);
461
+ const startedWith = new Set(turn.previousContext.flatMap((m) => identities(m)));
462
+ const wasInContext = (m) => stateDbMessageRepresented(m, startedWith, includeUids, generation);
463
+ const earlier = mergeSessionMessagesAppendOnly(s.messages, read.rows.filter(wasInContext), { stateDbSeenId: -1, stateDbGeneration: generation }).slice(s.messages.length);
332
464
  const added = new Map();
333
- for (const m of withoutRows(turn.agentRows ?? [], turn.previousContext))
334
- added.set(settledIdentity(m), (added.get(settledIdentity(m)) ?? 0) + 1);
335
- const ownWorkAfter = turn.agentRows?.length ? null : turn.startId ?? null;
465
+ const addedIds = new Set(agentAdded.flatMap((m) => identities(m)));
466
+ for (const m of agentAdded) {
467
+ if (!identities(m).length)
468
+ added.set(settledIdentity(m), (added.get(settledIdentity(m)) ?? 0) + 1);
469
+ }
470
+ const ownWorkAfter = turn.agentRows?.length || generationChanged ? null : turn.startId ?? null;
336
471
  // Without a current marker, the turn's own starting read is the baseline: rows committed after it are judged here.
337
- const seenId = currentStateDbSeenId(s, read.rows) ?? turn.startId ?? null;
338
- const shown = read.rows.length ? mergeSessionMessagesAppendOnly(s.messages, read.rows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: seenId }) : s.messages;
472
+ const seenId = currentStateDbSeenId(s, read.rows) ?? (generationChanged ? null : turn.startId ?? null);
473
+ const shown = read.rows.length ? mergeSessionMessagesAppendOnly(s.messages, read.rows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: seenId, stateDbGeneration: generation }) : s.messages;
339
474
  const fresh = new Set(shown.slice(s.messages.length));
340
475
  // The turn's own rows the merge already matched to their local copies use up their share before any shown row can.
341
476
  if (seenId !== null) {
342
477
  for (const m of read.rows) {
343
478
  const id = m._state_db_row_id;
344
- if (typeof id !== 'number' || id <= seenId || startedWith.has(id) || fresh.has(m))
479
+ if (typeof id !== 'number' || id <= seenId || wasInContext(m) || fresh.has(m))
345
480
  continue;
346
481
  const left = added.get(settledIdentity(m)) ?? 0;
347
482
  if (left > 0)
@@ -350,7 +485,9 @@ export class SessionService {
350
485
  }
351
486
  const missed = [...fresh].filter((m) => {
352
487
  const id = m._state_db_row_id;
353
- if (typeof id === 'number' && startedWith.has(id))
488
+ if (wasInContext(m))
489
+ return false;
490
+ if (stateDbMessageRepresented(m, addedIds, includeUids, generation))
354
491
  return false;
355
492
  const key = settledIdentity(m);
356
493
  const left = added.get(key) ?? 0;
@@ -367,7 +504,7 @@ export class SessionService {
367
504
  if (missed.length) {
368
505
  s.messages.push(...copyJson(missed));
369
506
  if (s.context_messages.length)
370
- s.context_messages.push(...copyJson(missed));
507
+ s.context_messages = mergeSessionMessagesAppendOnly(s.context_messages, copyJson(missed), { stateDbSeenId: -1, stateDbGeneration: generation });
371
508
  }
372
509
  this.markStateDbSeen(s, read);
373
510
  // TAL-529: a compression rotation moved the Agent to a continuation, whose first rows restate the compressed context.
@@ -423,34 +560,36 @@ export class SessionService {
423
560
  return pending ? withPendingUserTurn(context, pending) : context;
424
561
  }
425
562
  /** Python `_is_messaging_session_id`: from the WebUI metadata or the Agent's row. */
426
- isMessagingSession(sid) {
563
+ async isMessagingSession(sid) {
427
564
  try {
428
- if (isMessagingSessionRecord(this.store.get(sid, { metadataOnly: true }).compact()))
565
+ if (isMessagingSessionRecord((await this.store.get(sid, { metadataOnly: true })).compact()))
429
566
  return true;
430
567
  }
431
568
  catch { /* absent */ }
432
- const meta = this.lookupCliMeta(sid);
569
+ const meta = (await this.lookupCliMeta(sid));
433
570
  return meta !== null && isMessagingSessionRecord(meta);
434
571
  }
435
572
  /** Python `_lookup_cli_session_metadata`: the sidebar row for a state.db session in the active profile. */
436
- lookupCliMeta(sid) {
573
+ async lookupCliMeta(sid) {
437
574
  try {
438
575
  if (isClaudeCodeSessionId(sid))
439
- return this.claudeCodeRows().find((r) => str(r.session_id) === sid) ?? null;
440
- return this.deps.cliSessionsSync(this.deps.activeProfile(), { sourceFilter: null }).rows.find((r) => str(r.session_id) === sid) ?? null;
576
+ return (await this.claudeCodeRows()).find((r) => str(r.session_id) === sid) ?? null;
577
+ const profile = this.deps.activeProfile();
578
+ return (await this.deps.cliSessionsSync(profile, { sourceFilter: null })).rows.find((r) => str(r.session_id) === sid)
579
+ ?? (await this.deps.cliSessionsSync(profile, { sourceFilter: null, sessionIds: [sid] })).rows[0] ?? null;
441
580
  }
442
581
  catch {
443
582
  return null;
444
583
  }
445
584
  }
446
- claudeCodeRows() {
447
- return this.deps.claudeCode?.rows(this.deps.workspaces.lastWorkspace(this.deps.activeProfile())) ?? [];
585
+ async claudeCodeRows() {
586
+ return this.deps.claudeCode?.rows((await this.deps.workspaces.lastWorkspace(this.deps.activeProfile()))) ?? [];
448
587
  }
449
588
  /** Python `_session_index_marks_was_webui`: an index row that once owned a WebUI sidecar (self-heal 404). */
450
- indexMarksWasWebui(sid) {
589
+ async indexMarksWasWebui(sid) {
451
590
  let entries;
452
591
  try {
453
- entries = this.store.readIndexEntries();
592
+ entries = (await this.store.readIndexEntries());
454
593
  }
455
594
  catch {
456
595
  return false;
@@ -472,20 +611,23 @@ export class SessionService {
472
611
  * a WebUI sidecar. `materialized` sessions are writeable (the caller persists the sidecar), `not_claimable` ones are
473
612
  * read-only stubs, `was_webui`/`no_foreign_state`/`invalid_sid` answer 404.
474
613
  */
475
- claimOrSynthesizeCliSession(sid, metaIn) {
614
+ async claimOrSynthesizeCliSession(sid, metaIn) {
476
615
  if (!isSafeSessionId(sid))
477
616
  return { session: null, reason: 'invalid_sid' };
478
617
  const profile = this.deps.activeProfile();
479
618
  const dbPath = join(this.deps.profileHome(profile), 'state.db');
619
+ sid = this.resolveSessionId(sid);
480
620
  const row = stateDbSessionRow(dbPath, sid);
481
621
  const stateDbSource = str(row?.source).trim().toLowerCase();
482
622
  const subagentChild = stateDbSource === 'subagent';
483
- if ((this.indexMarksWasWebui(sid) || (this.store.wasDeleted(sid) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild)
623
+ const owners = this.stateDbSources([sid]);
624
+ const canonical = typeof owners?.get(sid) === 'object';
625
+ if (((await this.indexMarksWasWebui(sid)) || ((await this.store.wasDeleted(sid)) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild && !canonical)
484
626
  return { session: null, reason: 'was_webui' };
485
627
  const msgs = isClaudeCodeSessionId(sid) ? this.deps.claudeCode?.messages(sid) ?? [] : stateDbSessionMessages(dbPath, sid, { stitch: true });
486
- if (!msgs.length)
628
+ const meta = { ...(metaIn ?? (await this.lookupCliMeta(sid)) ?? {}) };
629
+ if (!msgs.length && !(meta._lineage_root_id && meta._lineage_tip_id))
487
630
  return { session: null, reason: 'no_foreign_state' };
488
- const meta = { ...(metaIn ?? this.lookupCliMeta(sid) ?? {}) };
489
631
  if (row) {
490
632
  if (!meta.source_tag && stateDbSource)
491
633
  meta.source_tag = stateDbSource;
@@ -502,12 +644,16 @@ export class SessionService {
502
644
  if (!meta.updated_at && (row.ended_at || row.started_at))
503
645
  meta.updated_at = row.ended_at || row.started_at;
504
646
  }
505
- const claimable = isClaimableCliSource(meta, stateDbSource);
506
- const workspace = str(meta.workspace || meta.cwd).trim() || this.deps.workspaces.lastWorkspace(profile);
507
- const defaults = this.store.deps.defaults(profile);
647
+ // An owner read that fails is an unknown owner, which never releases a claim (TAL-793).
648
+ const claimable = owners !== null && isClaimableCliSource(meta, owners.get(sid) || stateDbSource);
649
+ const workspace = str(meta.workspace || meta.cwd).trim() || (await this.deps.workspaces.lastWorkspace(profile));
650
+ const defaults = (await this.store.deps.defaults(profile));
651
+ const context = Array.isArray(meta._lineage_ids) ? stateDbSessionMessages(dbPath, sid, { stitch: false }) : null;
508
652
  const session = new Session({
509
653
  session_id: sid, title: str(meta.title) || 'CLI Session', workspace, model: str(meta.model) || 'unknown', model_provider: str(meta.model_provider) || null,
510
654
  messages: msgs, created_at: Number(meta.created_at) || 0, updated_at: Number(meta.updated_at) || 0, profile: str(meta.profile) || null,
655
+ pinned: Boolean(meta.pinned), archived: Boolean(meta.archived),
656
+ ...(context ? { context_messages: context } : {}),
511
657
  is_cli_session: claimable ? true : !subagentChild, source_tag: str(meta.source_tag) || null, raw_source: str(meta.raw_source) || null,
512
658
  session_source: str(meta.session_source) || null, source_label: str(meta.source_label) || null, read_only: !claimable,
513
659
  // Python `import_cli_session`: the claimed sidecar keeps the row's lineage, background project and channel identity
@@ -517,9 +663,9 @@ export class SessionService {
517
663
  }, defaults);
518
664
  return { session, reason: claimable ? 'materialized' : 'not_claimable' };
519
665
  }
520
- mutationTarget(sid, verb) {
666
+ async mutationTarget(sid, verb) {
521
667
  try {
522
- return this.getForMutation(sid);
668
+ return (await this.getForMutation(sid));
523
669
  }
524
670
  catch (error) {
525
671
  if (error instanceof SessionNotFound)
@@ -537,49 +683,61 @@ export class SessionService {
537
683
  if (isClaudeCodeSessionId(sid))
538
684
  throw new HttpFailure(400, `Claude Code sessions are view-only and cannot be ${verb} from WebUI`);
539
685
  }
540
- rejectSubagent(sid, verb) {
686
+ async rejectViewOnly(sid, verb) {
687
+ sid = sid.trim();
541
688
  this.rejectClaudeCode(sid, verb);
542
- if (this.isSubagentViewOnly(sid))
689
+ let profile = this.deps.activeProfile();
690
+ try {
691
+ profile = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false })).profile || 'default';
692
+ }
693
+ catch { /* foreign row */ }
694
+ const owners = this.stateDbSources([sid], profile);
695
+ const owner = owners?.get(sid);
696
+ if (!owners || typeof owner === 'object')
697
+ throw new HttpFailure(403, typeof owner === 'object' ? owner.reason ?? CANONICAL_READ_ONLY_REASON : OWNERSHIP_UNAVAILABLE_REASON);
698
+ if ((await this.isSubagentViewOnly(sid)))
543
699
  throw new HttpFailure(400, `Subagent sessions are view-only and cannot be ${verb} from WebUI`);
700
+ return sid;
701
+ }
702
+ /** All transcript transports use the same live-run and durable-pending evidence. */
703
+ async backgroundUpdates(s, messages, reviewRows) {
704
+ return withBackgroundUpdates(messages, s, { activeTurnId: this.deps.runtime.activeRunStream(s.session_id), pendingDelegations: (await this.deps.backgroundPending?.(s.session_id)) ?? new Set(), ...(reviewRows ? { reviewRows } : {}) });
544
705
  }
545
706
  /** TAL-372: each delegation row of a turn's scene carries the status of the work it started. */
546
- backgroundLinked(s, messages) {
547
- return withBackgroundLinks(messages, this.deps.backgroundReceipts?.(s.session_id) ?? []);
707
+ async backgroundLinked(s, messages) {
708
+ return withBackgroundLinks(messages, (await this.deps.backgroundReceipts?.(s.session_id)) ?? []);
548
709
  }
549
710
  /** `compact()` plus messages, redacted for the wire (Python `_public_session_projection`). */
550
- publicSession(s, withMessages = true) {
551
- const payload = this.wireRow(s);
711
+ async publicSession(s, withMessages = true, messages = s.messages) {
712
+ const payload = (await this.wireRow(s));
552
713
  // Mutation replies replace a client's transcript, so they carry the same server-built scenes as the detail.
553
714
  if (withMessages) {
554
- payload.messages = this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(withBackgroundUpdates(withMarkerKinds(withTurnIds(s.messages)), s), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !this.journaledActiveTurn(s) }));
715
+ payload.messages = (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(messages), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !(await this.journaledActiveTurn(s)) })));
555
716
  payload.compression_reference = compressionReference(s, payload.messages);
556
717
  }
557
718
  return redactSessionData(payload, this.deps.redactEnabled());
558
719
  }
559
720
  /** `compact()` with the wire streaming/read-only flags (TAL-312), for replies that return the session row. */
560
- wireRow(s) {
561
- return withSessionWireFlags({ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }), read_only: this.isReadOnly(s), assistant_name: this.assistantName(s), workspace_name: this.workspaceNames()(s) }, this.deps.runtime.activeStreamIds);
721
+ async wireRow(s) {
722
+ return withSessionWireFlags(withOwnerLocks([{ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }), assistant_name: this.assistantName(s), workspace_name: (await this.workspaceNames([s]))(s) }], this.stateDbSources)[0], this.deps.runtime.activeStreamIds);
562
723
  }
563
724
  /**
564
725
  * TAL-303: the label for a session's workspace, from its own profile's registry (a profileless row is the root's).
565
726
  * One resolver serves one response, so each profile's registry is read once however many rows it names.
566
727
  */
567
- workspaceNames() {
728
+ async workspaceNames(rows) {
568
729
  const registries = new Map();
569
- return (row) => {
570
- const profile = str(row.profile) || 'default';
571
- let entries = registries.get(profile);
572
- if (!entries) {
573
- try {
574
- entries = this.deps.workspaces.entries(profile);
575
- }
576
- catch {
577
- entries = [];
578
- }
579
- registries.set(profile, entries);
730
+ for (const profile of new Set(rows.map((row) => str(row.profile) || 'default'))) {
731
+ let entries;
732
+ try {
733
+ entries = await this.deps.workspaces.entries(profile);
580
734
  }
581
- return workspaceDisplayName(str(row.workspace), entries);
582
- };
735
+ catch {
736
+ entries = [];
737
+ }
738
+ registries.set(profile, entries);
739
+ }
740
+ return (row) => workspaceDisplayName(str(row.workspace), registries.get(str(row.profile) || 'default') ?? []);
583
741
  }
584
742
  /** The agent's display name for a session's profile (`assistant_name`). */
585
743
  assistantName(s) {
@@ -590,48 +748,219 @@ export class SessionService {
590
748
  return redactSessionData(s.toDocument(), this.deps.redactEnabled());
591
749
  }
592
750
  // ── detail ──────────────────────────────────────────────────────────────
593
- loadRevision(s) {
594
- return statSignature(this.store.pathFor(s.session_id));
751
+ async loadRevision(s) {
752
+ return (await this.store.revision(s.session_id));
753
+ }
754
+ pageTails = new WeakMap();
755
+ pageContexts = new WeakMap();
756
+ prefixIndex(s, revision) {
757
+ const previous = s.prefixIndex;
758
+ if (previous?.rows === s.messages && previous.revision === revision && previous.length <= s.messages.length) {
759
+ previous.extend();
760
+ return previous;
761
+ }
762
+ return s.prefixIndex = new SessionPrefixIndex(s.messages, revision, markedStateDbGeneration(s), { toolCalls: s.tool_calls });
763
+ }
764
+ /** Existing projections, once per complete turn range, in absolute transcript coordinates. */
765
+ async deriveRange(s, rows, baseIndex, initialTurn, journaled, refs, calls, reviewRows) {
766
+ return (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(rows), baseIndex, initialTurn), s), reviewRows)), calls, s.active_stream_id, baseIndex), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: true, baseIndex, refCounts: refs })));
767
+ }
768
+ async limitedDetail(s, index, limit, before, pending, journaled) {
769
+ let prefixEnd = s.messages.length;
770
+ const turnId = pending?.turnId ?? journaled?.turnId;
771
+ if (turnId)
772
+ prefixEnd = Math.min(prefixEnd, index.turns.get(turnId)?.first ?? prefixEnd);
773
+ const prompt = pending ? index.prompts.get(pending.activeTurnToken) : undefined;
774
+ if (prompt !== undefined)
775
+ prefixEnd = Math.min(prefixEnd, prompt);
776
+ const dbPath = this.stateDbPath(s.profile);
777
+ const key = JSON.stringify([index.revision, index.length, statSignature(dbPath), statSignature(`${dbPath}-wal`), s.active_stream_id, pending, journaled, s.state_db_seen_id, s.state_db_seen_boundary, s.state_db_seen_stamp, s.updated_at, this.deps.activeProfile(), s.compression_anchor_summary, s.compression_anchor_message_key, s.compression_anchor_visible_idx, this.compactionWaiters.has(s.active_stream_id ?? '')]);
778
+ let cached = this.pageTails.get(index);
779
+ if (cached?.key !== key || cached.contextRows !== s.context_messages || cached.contextLength !== s.context_messages.length) {
780
+ const streaming = Boolean(s.active_stream_id && !this.compactionWaiters.has(s.active_stream_id));
781
+ const contextKey = JSON.stringify([index.revision, s.active_stream_id, index.length, streaming]);
782
+ let context = this.pageContexts.get(index);
783
+ if (context?.key !== contextKey || context.contextRows !== s.context_messages || context.contextLength !== s.context_messages.length) {
784
+ const summary = !s.context_messages.length && !s.active_stream_id ? index.model : new ApiMessageSummary(s.active_stream_id, !s.context_messages.length);
785
+ if (summary !== index.model && !streaming) {
786
+ for (const row of s.context_messages.length ? s.context_messages : s.messages) {
787
+ summary.add(row);
788
+ if (summary.enough)
789
+ break;
790
+ }
791
+ summary.finish();
792
+ }
793
+ context = { key: contextKey, local: summary.rows, enough: summary.enough, contextRows: s.context_messages, contextLength: s.context_messages.length, marker: summary.marker, unidentified: summary.enough ? [] : summary.unidentified };
794
+ this.pageContexts.set(index, context);
795
+ }
796
+ let stateRows;
797
+ let effectiveSeenId = null;
798
+ let cacheable = true;
799
+ let appended = null;
800
+ if (typeof s.state_db_seen_id === 'number' && str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() !== 'subagent') {
801
+ const read = stateDbSessionTailRead(dbPath, s.session_id, s.state_db_seen_id, { lineage: s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null, unidentified: [...index.unidentified, ...context.unidentified] });
802
+ if (!read.ok)
803
+ cacheable = false;
804
+ const markerRows = read.anchor ? [read.anchor, ...read.rows] : read.rows;
805
+ if (read.ok && read.idCapable && currentStateDbSeenId(s, markerRows) !== null) {
806
+ // Covered rows consume unidentified local copies before a new duplicate. Any such match needs the full snapshot.
807
+ const ambiguous = read.requiresFullRead === true;
808
+ if (!ambiguous) {
809
+ const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
810
+ const mode = includeUids ? 1 : 0;
811
+ appended = appendStateDbIdentityRows(read.rows, s.state_db_seen_id, index.identities[mode], index.unidentified[mode], index.generation, includeUids);
812
+ stateRows = read.rows;
813
+ effectiveSeenId = s.state_db_seen_id;
814
+ }
815
+ }
816
+ }
817
+ if (appended === null) {
818
+ stateRows = this.stateDbRows(s);
819
+ effectiveSeenId = currentStateDbSeenId(s, stateRows);
820
+ appended = this.mergedTranscript(s, s.messages, stateRows).slice(s.messages.length);
821
+ }
822
+ let tail = [...s.messages.slice(prefixEnd), ...appended];
823
+ if (pending)
824
+ tail = withPendingUserTurn(tail, { ...pending, localCount: s.messages.length - prefixEnd });
825
+ if (journaled)
826
+ tail = withoutRunningTurnOutput(tail, { ...journaled, localCount: s.messages.length - prefixEnd });
827
+ const tailIndex = new SessionPrefixIndex(tail, index.revision, index.generation, { toolCalls: index.toolCalls(prefixEnd, tail.length), baseIndex: prefixEnd, initialTurn: prefixEnd ? index.turnId(prefixEnd - 1) : 'legacy:start', timestampFloor: index.summary(prefixEnd).maxTimestamp, todoFloor: index.summary(prefixEnd).maxTodoTimestamp, parent: { index, end: prefixEnd } });
828
+ const enoughContext = !streaming && (context.enough || sanitizeMessagesForApi(context.marker ? context.local : mergeSessionMessagesAppendOnly(context.local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: effectiveSeenId, stateDbGeneration: index.generation }).filter((m) => !s.active_stream_id || m._turn_id !== s.active_stream_id), { maxMessages: 4 }).length >= 4);
829
+ const owners = new Map();
830
+ const sources = (ids, profile) => {
831
+ const ownerKey = JSON.stringify([ids, profile]);
832
+ const existing = owners.get(ownerKey);
833
+ if (existing)
834
+ return existing;
835
+ const result = this.stateDbSources(ids, profile);
836
+ if (result)
837
+ owners.set(ownerKey, result);
838
+ return result;
839
+ };
840
+ cached = { key, tail, index: tailIndex, enoughContext, sources, contextRows: s.context_messages, contextLength: s.context_messages.length,
841
+ summary: { total: prefixEnd + tail.length, last: Math.max(index.summary(prefixEnd).maxTimestamp, tailIndex.summary().maxTimestamp), todo: tailIndex.summary().todo ?? index.summary(prefixEnd).todo, compression: index.compressionReference(s, prefixEnd, tailIndex) } };
842
+ if (cacheable)
843
+ this.pageTails.set(index, cached);
844
+ }
845
+ const { tail, index: tailIndex } = cached;
846
+ const total = prefixEnd + tail.length;
847
+ const end = before === null ? total : Math.max(0, Math.min(before, total));
848
+ const readRange = (start, stop) => start >= prefixEnd ? tail.slice(start - prefixEnd, stop - prefixEnd) : [...s.messages.slice(start, Math.min(stop, prefixEnd)), ...(stop > prefixEnd ? tail.slice(0, stop - prefixEnd) : [])];
849
+ const idAt = (at) => at < prefixEnd ? index.turnId(at) : tailIndex.turnId(at);
850
+ const turnSpan = (id) => {
851
+ const prefix = index.turns.get(id), suffix = tailIndex.turns.get(id);
852
+ return { first: Math.min(prefix && prefix.first < prefixEnd ? prefix.first : total, suffix?.first ?? total), last: Math.max(prefix && prefix.first < prefixEnd ? Math.min(prefix.last, prefixEnd - 1) : -1, suffix?.last ?? -1) + 1 };
853
+ };
854
+ const spanAt = (at) => {
855
+ const id = idAt(at), own = turnSpan(id);
856
+ const dependencies = [index.dependencies.get(id), tailIndex.dependencies.get(id)];
857
+ const first = Math.min(...dependencies.map((span) => span?.first ?? total));
858
+ const last = Math.max(...dependencies.map((span) => span ? Math.min(span.last, total - 1) : -1));
859
+ return { first: Math.min(own.first, first), last: Math.max(own.last, last + 1) };
860
+ };
861
+ // A notification can refer to a parent turn outside this page. Read that indexed turn, never the whole prefix.
862
+ const reviewTurns = new Map();
863
+ const reviewRows = (id) => {
864
+ let rows = reviewTurns.get(id);
865
+ if (!rows) {
866
+ const span = turnSpan(id);
867
+ rows = span.first < span.last ? withTurnIds(readRange(span.first, span.last), span.first, span.first ? idAt(span.first - 1) : 'legacy:start').filter((row) => row._turn_id === id) : [];
868
+ reviewTurns.set(id, rows);
869
+ }
870
+ return rows;
871
+ };
872
+ const refs = { get: (ref) => index.refCountBefore(ref, prefixEnd) + (tailIndex.refCounts.get(ref) ?? 0) };
873
+ if (cached.newestReply === undefined) {
874
+ let newestReply = null;
875
+ const excluded = new Set(s.active_stream_id ? [s.active_stream_id] : []);
876
+ selection: for (const owner of [tailIndex, index])
877
+ for (const candidate of owner.replyTurns(owner === index ? prefixEnd : total, excluded)) {
878
+ const span = turnSpan(idAt(candidate)), rows = readRange(span.first, span.last);
879
+ const derived = await this.deriveRange(s, rows, span.first, span.first ? idAt(span.first - 1) : 'legacy:start', Boolean(journaled), refs, index.toolCalls(span.first, rows.length), reviewRows);
880
+ const newest = newestSettledReply(derived, s.active_stream_id, span.first);
881
+ if (newest !== null) {
882
+ newestReply = newest;
883
+ break selection;
884
+ }
885
+ }
886
+ cached.newestReply = newestReply;
887
+ }
888
+ let start = end ? spanAt(Math.max(0, end - limit)).first : 0;
889
+ if (pending && before === null)
890
+ start = Math.min(start, prefixEnd);
891
+ let stop = end ? spanAt(end - 1).last : 0;
892
+ // Stamped ids can recur non-contiguously. Include every row of a selected turn, as the full derivation does.
893
+ let derived = [];
894
+ for (;;) {
895
+ for (let at = start; at < stop; at += 1) {
896
+ const span = spanAt(at);
897
+ if (span.first < start) {
898
+ start = span.first;
899
+ at = start - 1;
900
+ }
901
+ stop = Math.max(stop, span.last);
902
+ }
903
+ const rows = readRange(start, stop);
904
+ const calls = index.toolCalls(start, rows.length);
905
+ derived = (await this.deriveRange(s, rows, start, start ? idAt(start - 1) : 'legacy:start', Boolean(journaled), refs, calls, reviewRows));
906
+ const [page, relative] = messageWindowForDisplay(derived, limit, end - start);
907
+ if (relative > 0 || start === 0 || page.filter((m) => messageCountsAsRenderable(m)).length >= limit) {
908
+ let offset = start + relative;
909
+ let messages = page;
910
+ if (pending && before === null) {
911
+ const at = derived.findIndex((m) => m.role === 'user' && m._active_turn_token === pending.activeTurnToken);
912
+ if (at >= 0 && start + at < offset) {
913
+ messages = derived.slice(at, relative + messages.length);
914
+ offset = start + at;
915
+ }
916
+ }
917
+ return { messages, offset, newestReply: cached.newestReply, ...cached.summary, enoughContext: cached.enoughContext, sources: cached.sources };
918
+ }
919
+ start = spanAt(Math.max(0, start - Math.max(limit, stop - start))).first;
920
+ }
595
921
  }
596
922
  /** Python `_handle_session_get` on the WebUI sidecar (state.db merge pending). */
597
- detail(sid, query) {
923
+ async detail(sid, query) {
598
924
  if (!sid)
599
925
  throw new HttpFailure(400, 'session_id is required');
926
+ sid = this.resolveSessionId(sid);
600
927
  const loadMessages = (query.messages ?? '1') !== '0';
601
928
  const msgLimit = parseMsgLimit(query.msg_limit ?? null);
602
929
  const msgBeforeRaw = query.msg_before;
603
930
  const msgBefore = msgBeforeRaw ? (Number.isInteger(Number(msgBeforeRaw)) ? Number(msgBeforeRaw) : null) : null;
604
931
  let s;
605
932
  try {
606
- s = this.store.get(sid, { metadataOnly: !loadMessages });
933
+ s = (await this.store.get(sid, { metadataOnly: !loadMessages }));
607
934
  }
608
935
  catch {
609
- return this.foreignSessionDetail(sid);
936
+ return (await this.foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore));
610
937
  }
611
- const revisionBefore = this.loadRevision(s);
938
+ const revisionBefore = (await this.loadRevision(s));
612
939
  if (!this.visibleToActiveProfile(s.profile)) {
613
940
  if (s.profile)
614
941
  throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile: s.profile });
615
942
  throw new HttpFailure(404, 'Session not found');
616
943
  }
617
- this.clearStaleStreamState(s);
618
- const journaled = loadMessages ? this.journaledActiveTurn(s) : null;
619
- const stateRows = loadMessages ? this.stateDbRows(s) : [];
620
- let transcript = loadMessages ? this.mergedTranscript(s, s.messages, stateRows) : [];
944
+ await this.clearStaleStreamState(s);
945
+ const journaled = loadMessages ? (await this.journaledActiveTurn(s)) : null;
621
946
  const pending = loadMessages ? this.pendingTurn(s) : null;
622
- if (pending)
947
+ const prefix = loadMessages && msgLimit !== null ? this.prefixIndex(s, revisionBefore) : null;
948
+ const limited = prefix && msgLimit !== null ? (await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled)) : null;
949
+ const stateRows = loadMessages && !limited ? this.stateDbRows(s) : [];
950
+ let transcript = loadMessages && !limited ? this.mergedTranscript(s, s.messages, stateRows) : [];
951
+ if (pending && !limited)
623
952
  transcript = withPendingUserTurn(transcript, pending);
624
- if (journaled)
953
+ if (journaled && !limited)
625
954
  transcript = withoutRunningTurnOutput(transcript, { ...journaled, localCount: s.messages.length });
626
955
  // Turn ids, tool outcomes and scenes are computed over the full transcript, so every window reports the same values.
627
- const all = loadMessages ? this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(withBackgroundUpdates(withMarkerKinds(withTurnIds(withAttachmentObjects(transcript))), s), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: msgLimit !== null })) : [];
628
- let truncated = [];
629
- let offset = 0;
630
- let summaryCount = null;
631
- let summaryLast = null;
956
+ const all = loadMessages && !limited ? (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(transcript)), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: msgLimit !== null }))) : [];
957
+ let truncated = limited?.messages ?? [];
958
+ let offset = limited?.offset ?? 0;
959
+ let summaryCount = limited?.total ?? null;
960
+ let summaryLast = limited?.last ?? null;
632
961
  if (loadMessages) {
633
- ;
634
- [truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
962
+ if (!limited)
963
+ [truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
635
964
  // TAL-368: a tail window starts no later than the running turn's prompt, so a turn whose output stays in the
636
965
  // transcript (no journal to replay it) keeps its prompt; older pages end before it, so it is never sent twice.
637
966
  // ponytail: the window grows with that turn's persisted rows; clip the turn instead if degraded runs get long.
@@ -641,7 +970,7 @@ export class SessionService {
641
970
  offset = prompt;
642
971
  }
643
972
  // Per-row display fields (TAL-186 media, TAL-456 excerpts cut from that display text) only for the rows sent.
644
- truncated = withBodyExcerpts(withDisplayMedia(truncated, this.mediaProjector(s)), s.active_stream_id);
973
+ truncated = withBodyExcerpts(withDisplayMedia(truncated, (await this.mediaProjector(s))), s.active_stream_id, limited ? limited.newestReply : newestSettledReply(all, s.active_stream_id), offset);
645
974
  if (msgLimit !== null)
646
975
  truncated = messagesForLimitedPayload(truncated);
647
976
  }
@@ -649,10 +978,10 @@ export class SessionService {
649
978
  summaryCount = s.metadataMessageCount ?? s.messages.length;
650
979
  summaryLast = lastMessageTimestamp(s.messages) ?? 0;
651
980
  }
652
- const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < all.length);
981
+ const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < (limited?.total ?? all.length));
653
982
  let toolCalls = loadMessages ? s.tool_calls : [];
654
983
  if (windowed)
655
- toolCalls = toolCallsForMessageWindow(toolCalls, offset, truncated.length);
984
+ toolCalls = prefix ? prefix.toolCalls(offset, truncated.length, true) : toolCallsForMessageWindow(toolCalls, offset, truncated.length);
656
985
  const mergedCount = summaryCount ?? all.length;
657
986
  let mergedLast = summaryLast ?? 0;
658
987
  if (summaryLast === null && all.length) {
@@ -660,7 +989,7 @@ export class SessionService {
660
989
  }
661
990
  const activeStreamIds = this.deps.runtime.activeStreamIds;
662
991
  const raw = {
663
- ...s.compact({ includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
992
+ ...s.compact({ ...(prefix ? { transcriptSummary: prefix.summary() } : {}), includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
664
993
  messages: truncated,
665
994
  message_count: mergedCount,
666
995
  tool_calls: toolCalls,
@@ -674,10 +1003,12 @@ export class SessionService {
674
1003
  };
675
1004
  if (loadMessages)
676
1005
  raw.transcript_seq = journaled ? { stream_id: journaled.turnId, seq: 0 } : null;
677
- if (loadMessages)
1006
+ if (limited)
1007
+ attachTodoSnapshot(raw, limited.todo, s.extra[UNSETTLED_TODO_KEY]);
1008
+ else if (loadMessages)
678
1009
  attachTodoState(raw, all, s.extra[UNSETTLED_TODO_KEY]);
679
1010
  if (loadMessages)
680
- raw.compression_reference = compressionReference(s, all);
1011
+ raw.compression_reference = limited ? limited.compression : compressionReference(s, all);
681
1012
  if (mergedLast) {
682
1013
  raw.last_message_at = Math.max(Number(raw.last_message_at ?? 0) || 0, mergedLast);
683
1014
  raw.updated_at = Math.max(Number(raw.updated_at ?? 0) || 0, mergedLast);
@@ -686,30 +1017,39 @@ export class SessionService {
686
1017
  raw._messages_truncated = isTruncated;
687
1018
  raw._messages_offset = offset;
688
1019
  raw._msg_limit_max = MAX_MSG_LIMIT;
689
- const revisionAfter = this.loadRevision(s);
1020
+ const revisionAfter = (await this.loadRevision(s));
690
1021
  raw._load_revision = revisionBefore !== null && revisionBefore === revisionAfter ? hashRevision(revisionBefore) : `unstable-${randomUUID().replace(/-/g, '')}`;
691
- raw.read_only = this.isReadOnly(s);
1022
+ Object.assign(raw, withOwnerLocks([raw], limited?.sources ?? this.stateDbSources)[0]);
692
1023
  raw.assistant_name = this.assistantName(s);
693
- raw.workspace_name = this.workspaceNames()(s);
694
- raw.branched_from = this.branchedFrom(s);
1024
+ raw.workspace_name = (await this.workspaceNames([s]))(s);
1025
+ raw.branched_from = (await this.branchedFrom(s));
695
1026
  withSessionWireFlags(raw, activeStreamIds);
696
1027
  // A metadata-only read lacks the history the gate counts, so it carries no flag.
697
1028
  if (loadMessages)
698
- raw.can_compress = this.canCompress(s, raw.read_only === true, stateRows);
1029
+ raw.can_compress = limited ? raw.read_only !== true && !isClaudeCodeSessionId(s.session_id) && (!s.active_stream_id || this.compactionWaiters.has(s.active_stream_id)) && limited.enoughContext : this.canCompress(s, raw.read_only === true, stateRows);
1030
+ raw.confirms_deep_truncate = true;
699
1031
  raw.pending_steers = raw.active_stream_id ? (this.deps.runtime.pendingSteers?.(str(raw.active_stream_id)) ?? []) : [];
700
- return redactSessionData(raw, this.deps.redactEnabled());
1032
+ // TAL-1006: the compression a client shows on load: the running manual job's, else the live run's open span.
1033
+ const job = this.compressionJobs.get(s.session_id);
1034
+ raw.run_status = this.currentRunStatus(s.session_id, str(raw.active_stream_id) || null);
1035
+ raw.compression_started_at = job?.status === 'running' ? job.started_at : raw.active_stream_id ? (this.deps.runtime.compressionStartedAt?.(str(raw.active_stream_id)) ?? null) : null;
1036
+ raw.automatic_turns_held = (await this.deps.runtime.automaticTurnsHeld?.(s.session_id)) ?? false;
1037
+ raw.notices = this.deps.runtime.sessionNotices?.(s.session_id) ?? [];
1038
+ raw.goal = this.deps.runtime.sessionGoal?.(s.session_id) ?? null;
1039
+ const publicRow = redactSessionData(raw, this.deps.redactEnabled());
1040
+ return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
701
1041
  }
702
1042
  /**
703
1043
  * TAL-454: the chat `/branch` copied, while it still loads (archived included). A compression continuation is also a
704
1044
  * fork of its parent but not a branch, so it links nowhere.
705
1045
  */
706
- branchedFrom(s) {
1046
+ async branchedFrom(s) {
707
1047
  const parentId = s.parent_session_id;
708
1048
  if (!parentId || str(s.session_source).trim().toLowerCase() !== 'fork' || s.compression_recovery_source_session_id)
709
1049
  return null;
710
1050
  let title;
711
1051
  try {
712
- const parent = this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false });
1052
+ const parent = (await this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false }));
713
1053
  if (!this.visibleToActiveProfile(parent.profile))
714
1054
  return null;
715
1055
  title = parent.title;
@@ -719,7 +1059,7 @@ export class SessionService {
719
1059
  // has transcript rows and is not a deleted chat (`claimOrSynthesizeCliSession`'s `was_webui` and `no_foreign_state`).
720
1060
  const dbPath = this.stateDbPath(null);
721
1061
  const row = stateDbSessionRow(dbPath, parentId);
722
- if (!row || this.store.wasDeleted(parentId) || this.indexMarksWasWebui(parentId))
1062
+ if (!row || (await this.store.wasDeleted(parentId)) || (await this.indexMarksWasWebui(parentId)))
723
1063
  return null;
724
1064
  if (!stateDbSessionMessages(dbPath, parentId, { stitch: true }).length)
725
1065
  return null;
@@ -731,12 +1071,12 @@ export class SessionService {
731
1071
  * TAL-316: the active run whose journal can replay its output from the start. The detail then leaves that output to the
732
1072
  * replay; with no active run, no identifiable prompt, or no complete journal, it returns the persisted transcript unchanged.
733
1073
  */
734
- journaledActiveTurn(s) {
1074
+ async journaledActiveTurn(s) {
735
1075
  const turnId = str(s.active_stream_id).trim();
736
1076
  const activeTurnToken = buildActiveTurnToken(turnId, s.pending_started_at);
737
1077
  if (!turnId || !activeTurnToken || this.deps.runtime.journalDegraded(turnId))
738
1078
  return null;
739
- const summary = this.deps.journal?.findRunSummary(turnId);
1079
+ const summary = (await this.deps.journal?.findRunSummary(turnId));
740
1080
  if (summary?.session_id !== s.session_id || summary.journal_pruned)
741
1081
  return null;
742
1082
  return { turnId, startedAt: Number(s.pending_started_at), activeTurnToken };
@@ -752,8 +1092,9 @@ export class SessionService {
752
1092
  return { turnId, startedAt, activeTurnToken, localCount: s.messages.length, prompt: pendingUserRow(text, s.pending_attachments, startedAt, s.pending_user_source || 'webui', turnId) };
753
1093
  }
754
1094
  /** A session without a sidecar, synthesized from state.db for this profile; 404/409 like the detail. */
755
- foreignSession(sid) {
756
- const meta = this.lookupCliMeta(sid);
1095
+ async foreignSession(sid) {
1096
+ sid = this.resolveSessionId(sid);
1097
+ const meta = (await this.lookupCliMeta(sid));
757
1098
  const profile = str(meta?.profile) || null;
758
1099
  const profileAgnostic = str(meta?.source_tag || meta?.raw_source).trim().toLowerCase() === 'claude_code';
759
1100
  if (!profileAgnostic && !this.visibleToActiveProfile(profile)) {
@@ -761,16 +1102,19 @@ export class SessionService {
761
1102
  throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile });
762
1103
  throw new HttpFailure(404, 'Session not found');
763
1104
  }
764
- const { session: synth, reason } = this.claimOrSynthesizeCliSession(sid, meta);
1105
+ const { session: synth, reason } = (await this.claimOrSynthesizeCliSession(sid, meta));
765
1106
  if (!synth || reason === 'was_webui')
766
1107
  throw new HttpFailure(404, 'Session not found');
767
1108
  return { synth, meta };
768
1109
  }
769
1110
  /** Python `_handle_session_get` without a sidecar: the state.db transcript as a (read-only or claimable) foreign stub. */
770
- foreignSessionDetail(sid) {
771
- const { synth, meta } = this.foreignSession(sid);
1111
+ foreignMessages(messages, session) {
1112
+ return hydrateAnchorActivityScenes(withToolCallOutcomes(withMarkerKinds(withTurnIds(messages), session), [], null), {});
1113
+ }
1114
+ async foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore) {
1115
+ const { synth, meta } = (await this.foreignSession(sid));
772
1116
  // The same turn projection as a WebUI session: turn ids, tool outcomes, then each completed turn's scene.
773
- const msgs = hydrateAnchorActivityScenes(withToolCallOutcomes(withMarkerKinds(withTurnIds(synth.messages)), [], null), {});
1117
+ const msgs = this.foreignMessages(synth.messages, synth);
774
1118
  const lastTs = Number(msgs[msgs.length - 1]?.timestamp ?? 0) || 0;
775
1119
  const sess = {
776
1120
  session_id: synth.session_id, title: synth.title, workspace: synth.workspace, model: synth.model, message_count: msgs.length,
@@ -778,14 +1122,15 @@ export class SessionService {
778
1122
  pinned: synth.pinned, archived: synth.archived, project_id: synth.project_id ?? null, profile: synth.profile,
779
1123
  is_cli_session: synth.is_cli_session, source_tag: synth.source_tag, raw_source: synth.raw_source, session_source: synth.session_source,
780
1124
  source_label: synth.source_label, read_only: synth.read_only, can_duplicate: false, messages: msgs, tool_calls: [], transcript_seq: null,
781
- assistant_name: this.assistantName(synth), workspace_name: this.workspaceNames()(synth),
1125
+ assistant_name: this.assistantName(synth), workspace_name: (await this.workspaceNames([synth]))(synth), confirms_deep_truncate: true,
782
1126
  };
783
1127
  attachTodoState(sess, msgs);
784
- const merged = withSessionWireFlags(meta ? mergeCliSidebarMetadata(sess, meta) : sess, this.deps.runtime.activeStreamIds);
785
- return redactSessionData(merged, this.deps.redactEnabled());
1128
+ const merged = withSessionWireFlags(withOwnerLocks([meta ? mergeCliSidebarMetadata(sess, meta) : sess], this.stateDbSources)[0], this.deps.runtime.activeStreamIds);
1129
+ const publicRow = redactSessionData(merged, this.deps.redactEnabled());
1130
+ return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
786
1131
  }
787
1132
  /** Clear persisted streaming flags when no live stream backs them (Python `_clear_stale_stream_state`). */
788
- clearStaleStreamState(session) {
1133
+ async clearStaleStreamState(session) {
789
1134
  const streamId = session.active_stream_id;
790
1135
  if (!streamId)
791
1136
  return false;
@@ -796,9 +1141,12 @@ export class SessionService {
796
1141
  const pendingAge = session.pending_started_at ? this.deps.now() - session.pending_started_at : null;
797
1142
  if (session.hasPendingPrompt && pendingAge !== null && pendingAge < 30)
798
1143
  return false;
1144
+ // Reading a display-only session must not settle the previous Talaria worker's transcript.
1145
+ if ((await this.isReadOnly(session)))
1146
+ return false;
799
1147
  let target = session;
800
1148
  if (session.loadedMetadataOnly) {
801
- const full = this.store.load(session.session_id);
1149
+ const full = (await this.store.load(session.session_id));
802
1150
  if (!full)
803
1151
  return false;
804
1152
  target = full;
@@ -816,7 +1164,7 @@ export class SessionService {
816
1164
  // run that never finished with an answer, so a dead stream never silently drops what the user sent or what streamed.
817
1165
  const turnId = str(target.active_stream_id);
818
1166
  // ponytail: one bounded tail read (Python's recovery window); a journal past it recovers its latest output only.
819
- const events = this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS).events ?? [];
1167
+ const events = (await this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS))?.events ?? [];
820
1168
  const { rows: output, answered } = journalOutputRows(events, turnId);
821
1169
  const pendingText = str(target.pending_user_message);
822
1170
  const attachments = [...target.pending_attachments];
@@ -843,7 +1191,7 @@ export class SessionService {
843
1191
  if (target.hasPendingPrompt || output.length) {
844
1192
  target.messages.push(...output);
845
1193
  if (!answered)
846
- target.messages.push({ role: 'assistant', content: '**Interrupted:** The reply was interrupted before it could be saved.', timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: turnId });
1194
+ target.messages.push({ role: 'assistant', content: '**Interrupted:** The reply was interrupted before it could be saved.', _terminal_state: 'interrupted', timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: turnId });
847
1195
  // Settled like a turn: the Agent's own rows are covered by the recovered turn; without them, every row past the run's start is.
848
1196
  this.settleStateDb(target, { turnId, previousContext, agentRows: ownRows, startId: stateDbSeenId(before) ?? (read.idCapable ? 0 : null), read });
849
1197
  }
@@ -853,7 +1201,7 @@ export class SessionService {
853
1201
  target.pending_started_at = null;
854
1202
  target.pending_user_source = null;
855
1203
  try {
856
- this.store.save(target, { touchUpdatedAt: false });
1204
+ (await this.store.save(target, { touchUpdatedAt: false }));
857
1205
  }
858
1206
  catch {
859
1207
  return false;
@@ -864,33 +1212,52 @@ export class SessionService {
864
1212
  session.pending_attachments = [];
865
1213
  session.pending_started_at = null;
866
1214
  session.pending_user_source = null;
867
- this.store.touch(target);
1215
+ await this.store.touch(target);
868
1216
  }
869
1217
  return true;
870
1218
  }
871
1219
  // ── list / search ────────────────────────────────────────────────────────
1220
+ /** Expand saved aliases in one worker query independently of the bounded native sidebar window. */
1221
+ async persistedAliasMetadata(rows, existing = []) {
1222
+ const profile = this.deps.activeProfile();
1223
+ const represented = new Set(existing.flatMap((row) => [...sessionLineageIds(row)]));
1224
+ const ids = rows.filter((row) => this.deps.profilesMatch(str(row.profile) || null, profile) && !isClaudeCodeSessionId(str(row.session_id)) && !represented.has(str(row.session_id))).map((row) => str(row.session_id));
1225
+ if (!ids.length)
1226
+ return { rows: existing };
1227
+ const extra = await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: ids });
1228
+ return { rows: [...new Map([...existing, ...extra.rows].map((row) => [str(row.session_id), row])).values()], storage: extra.storage };
1229
+ }
872
1230
  async list(params) {
873
1231
  const activeProfile = this.deps.activeProfile();
1232
+ const webuiRows = (await allSessions(this.store, { sidebarMetadataOnly: true }));
874
1233
  const wantState = params.showCliSessions || params.showCronSessions || params.showWebhookSessions || params.showKanbanSessions;
875
1234
  // Python reads every profile's state.db under all_profiles; this port projects the active profile only.
876
1235
  const cliRead = wantState ? await this.deps.cliSessions(activeProfile, { sourceFilter: params.sourceFilter ?? null }) : undefined;
877
- const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? this.claudeCodeRows() : [];
878
- const cliRows = cliRead ? [...cliRead.rows, ...claudeCodeRows] : undefined;
1236
+ const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? (await this.claudeCodeRows()) : [];
1237
+ let cliRows = cliRead ? [...cliRead.rows, ...claudeCodeRows] : undefined;
879
1238
  const gatewayIdentity = loadGatewaySessionIdentityMap(join(this.deps.profileHome(activeProfile), 'sessions', 'sessions.json'));
880
1239
  const truncatedSources = cliRead?.truncated;
881
- const webuiRows = allSessions(this.store, { sidebarMetadataOnly: true });
882
- const lookups = await this.listStateDbLookups(webuiRows);
883
- const payload = buildSessionListPayload(this.store, { ...params, ...(cliRows ? { cliRows } : {}), ...(truncatedSources ? { truncatedSources } : {}), gatewayIdentity, ...lookups, webuiRows, activeProfile, isolatedProfileMode: this.deps.isolatedProfileMode(), profilesMatch: this.deps.profilesMatch });
884
- return sessionListResponse(payload, this.deps.runtime, this.deps.redactEnabled(), this.deps.now(), this.workspaceNames());
885
- }
886
- search(q, opts) {
1240
+ let agentStorage = cliRead?.storage;
1241
+ if (cliRows) {
1242
+ const expanded = await this.persistedAliasMetadata(webuiRows, cliRows);
1243
+ cliRows = expanded.rows;
1244
+ if (expanded.storage && (expanded.storage.state === 'corrupt' || agentStorage?.state === 'ok'))
1245
+ agentStorage = expanded.storage;
1246
+ }
1247
+ const lookups = await this.listStateDbLookups(webuiRows, (cliRows ?? []).map((r) => str(r.session_id)));
1248
+ const payload = (await buildSessionListPayload(this.store, { ...params, ...(cliRows ? { cliRows } : {}), ...(truncatedSources ? { truncatedSources } : {}), ...(agentStorage ? { agentStorage } : {}), gatewayIdentity, ...lookups, webuiRows, activeProfile, isolatedProfileMode: this.deps.isolatedProfileMode(), profilesMatch: this.deps.profilesMatch }));
1249
+ return sessionListResponse(payload, this.deps.runtime, this.deps.redactEnabled(), this.deps.now(), await this.workspaceNames([...payload.sessions, ...payload.sidebar_reference_sessions]));
1250
+ }
1251
+ async search(q, opts) {
887
1252
  const activeProfile = this.deps.activeProfile();
888
- let sessions = allSessions(this.store);
1253
+ let sessions = (await allSessions(this.store));
889
1254
  if (!opts.allProfiles)
890
1255
  sessions = sessions.filter((r) => this.deps.profilesMatch(str(r.profile) || null, activeProfile));
1256
+ const metadata = await this.persistedAliasMetadata(sessions);
1257
+ sessions = reconcileLocalSessionRows(sessions, metadata.rows, this.deps.profilesMatch);
891
1258
  sessions = withOwnerLocks(sessions, this.stateDbSources);
892
1259
  const redact = this.deps.redactEnabled();
893
- const workspaceName = this.workspaceNames();
1260
+ const workspaceName = await this.workspaceNames(sessions);
894
1261
  const redactRow = (item) => {
895
1262
  withSessionWireFlags(item, this.deps.runtime.activeStreamIds);
896
1263
  item.workspace_name = workspaceName(item);
@@ -905,17 +1272,16 @@ export class SessionService {
905
1272
  if (!query)
906
1273
  return { sessions: sessions.map((s) => redactRow({ ...s })), all_profiles: opts.allProfiles, active_profile: activeProfile };
907
1274
  const terms = sessionSearchTerms(query);
1275
+ const matches = opts.content ? await this.store.searchMessages(sessions.filter((s) => !sessionSearchMatches(str(s.title), terms)).map((s) => str(s.session_id)), terms, opts.depth) : new Map();
908
1276
  const results = [];
909
1277
  for (const s of sessions) {
910
1278
  if (sessionSearchMatches(str(s.title), terms)) {
911
1279
  results.push(redactRow({ ...s, match_type: 'title' }));
912
1280
  continue;
913
1281
  }
914
- if (!opts.content)
915
- continue;
916
- const hit = this.contentMatch(str(s.session_id), terms, opts.depth);
1282
+ const hit = matches.get(str(s.session_id));
917
1283
  if (hit)
918
- results.push(redactRow({ ...s, ...hit }));
1284
+ results.push(redactRow({ ...s, ...this.contentMatch(hit.text, terms) }));
919
1285
  }
920
1286
  return { sessions: results, query, count: results.length, all_profiles: opts.allProfiles, active_profile: activeProfile };
921
1287
  }
@@ -935,46 +1301,35 @@ export class SessionService {
935
1301
  if (!query)
936
1302
  return { ...base, sessions: rows };
937
1303
  const terms = sessionSearchTerms(query);
1304
+ const metadataMatch = (row) => ['workspace', 'workspace_name', 'model', 'model_provider', 'profile', 'source_label'].some((k) => sessionSearchMatches(str(row[k]), terms));
1305
+ const matches = opts.content ? await this.store.searchMessages(rows.filter((row) => !sessionSearchMatches(str(row.title), terms) && !metadataMatch(row)).map((row) => str(row.session_id)), terms, opts.depth) : new Map();
938
1306
  const results = [];
939
1307
  for (const row of rows) {
940
1308
  if (sessionSearchMatches(str(row.title), terms))
941
1309
  results.push({ ...row, match_type: 'title' });
942
- else if (['workspace', 'workspace_name', 'model', 'model_provider', 'profile', 'source_label'].some((k) => sessionSearchMatches(str(row[k]), terms)))
1310
+ else if (metadataMatch(row))
943
1311
  results.push({ ...row, match_type: 'metadata' });
944
- else if (opts.content) {
945
- const hit = this.contentMatch(str(row.session_id), terms, opts.depth);
1312
+ else {
1313
+ const hit = matches.get(str(row.session_id));
946
1314
  if (hit)
947
- results.push({ ...row, ...hit });
1315
+ results.push({ ...row, ...this.contentMatch(hit.text, terms) });
948
1316
  }
949
1317
  }
950
1318
  return { ...base, sessions: results, query, count: results.length };
951
1319
  }
952
1320
  /** The first of a stored session's first `depth` messages (0: all) containing every term, with its redacted excerpt. */
953
- contentMatch(sid, terms, depth) {
954
- let sess;
955
- try {
956
- sess = this.store.get(sid, { promote: false, cacheOnMiss: false });
957
- }
958
- catch {
959
- return null;
960
- }
961
- for (const m of depth ? sess.messages.slice(0, depth) : sess.messages) {
962
- const c = sessionSearchMessageText(m);
963
- if (!sessionSearchMatches(c, terms))
964
- continue;
965
- const item = { match_type: 'content' };
966
- const preview = sessionSearchPreview(c, terms);
967
- if (preview)
968
- item.match_preview = redactText(preview, this.deps.redactEnabled());
969
- return item;
970
- }
971
- return null;
1321
+ contentMatch(text, terms) {
1322
+ const item = { match_type: 'content' };
1323
+ const preview = sessionSearchPreview(text, terms);
1324
+ if (preview)
1325
+ item.match_preview = redactText(preview, this.deps.redactEnabled());
1326
+ return item;
972
1327
  }
973
1328
  // ── status / usage ───────────────────────────────────────────────────────
974
- status(sid) {
975
- const s = this.get404(sid, { metadataOnly: true });
976
- this.clearStaleStreamState(s);
977
- const full = this.get404(sid);
1329
+ async status(sid) {
1330
+ const s = (await this.get404(sid, { metadataOnly: true }));
1331
+ await this.clearStaleStreamState(s);
1332
+ const full = (await this.get404(sid));
978
1333
  const inp = Math.trunc(full.input_tokens) || 0;
979
1334
  const out = Math.trunc(full.output_tokens) || 0;
980
1335
  const profile = full.profile || 'default';
@@ -985,37 +1340,37 @@ export class SessionService {
985
1340
  catch {
986
1341
  hermesHome = '';
987
1342
  }
988
- const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, active_turn_origin: origin } = this.wireRow(full);
1343
+ const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, read_only_reason: readOnlyReason, active_turn_origin: origin } = (await this.wireRow(full));
989
1344
  return {
990
1345
  session_id: full.session_id, title: full.title, model: full.model, profile, hermes_home: hermesHome, workspace: full.workspace, personality: full.personality,
991
- message_count: full.messages.length, created_at: full.created_at, updated_at: full.updated_at, agent_running: streaming, is_streaming: streaming, active_stream_id: live, active_turn_origin: origin, read_only: readOnly,
1346
+ message_count: full.messages.length, created_at: full.created_at, updated_at: full.updated_at, agent_running: streaming, is_streaming: streaming, active_stream_id: live, active_turn_origin: origin, read_only: readOnly, read_only_reason: readOnlyReason,
992
1347
  input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: full.estimated_cost,
993
1348
  };
994
1349
  }
995
- usage(sid) {
996
- const s = this.get404(sid);
1350
+ async usage(sid) {
1351
+ const s = (await this.get404(sid));
997
1352
  const inp = Math.trunc(s.input_tokens) || 0;
998
1353
  const out = Math.trunc(s.output_tokens) || 0;
999
1354
  return { input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: s.estimated_cost, model: s.model };
1000
1355
  }
1001
1356
  // ── creation ─────────────────────────────────────────────────────────────
1002
- resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
1357
+ async resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
1003
1358
  const candidate = body.workspace;
1004
1359
  if (!candidate)
1005
1360
  return null;
1006
1361
  const value = str(candidate);
1007
1362
  if (body.workspace_inherited_from_prev_session !== true || !visiblePrevSessionId)
1008
- return this.deps.workspaces.resolveTrusted(value, profile);
1363
+ return (await this.deps.workspaces.resolveTrusted(value, profile));
1009
1364
  let previous;
1010
1365
  try {
1011
- previous = this.store.get(visiblePrevSessionId, { metadataOnly: true });
1366
+ previous = (await this.store.get(visiblePrevSessionId, { metadataOnly: true }));
1012
1367
  }
1013
1368
  catch {
1014
- return this.deps.workspaces.resolveTrusted(value, profile);
1369
+ return (await this.deps.workspaces.resolveTrusted(value, profile));
1015
1370
  }
1016
1371
  if (previous.workspace !== value)
1017
- return this.deps.workspaces.resolveTrusted(value, profile);
1018
- const [workspace] = this.deps.workspaces.resolveImplicitWithRecovery(value, (p) => this.deps.workspaces.profileDefaultWorkspaceForBoot(p), profile);
1372
+ return (await this.deps.workspaces.resolveTrusted(value, profile));
1373
+ const [workspace] = (await this.deps.workspaces.resolveImplicitWithRecovery(value, async (p) => (await this.deps.workspaces.profileDefaultWorkspaceForBoot(p)), profile));
1019
1374
  return workspace;
1020
1375
  }
1021
1376
  /** The server owns the names' normalization (TAL-631): trimmed, blanks dropped, and nothing left is the profile's defaults. */
@@ -1029,25 +1384,25 @@ export class SessionService {
1029
1384
  const names = toolsets.map((t) => t.trim()).filter(Boolean);
1030
1385
  return names.length ? names : null;
1031
1386
  }
1032
- create(body, opts = {}) {
1387
+ async create(body, opts = {}) {
1033
1388
  // Python `new_session`: a body without a profile takes the request's active profile (cookie first).
1034
1389
  const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
1035
1390
  let prevSessionId = typeof body.prev_session_id === 'string' && body.prev_session_id ? body.prev_session_id : null;
1036
- if (prevSessionId && !this.sessionIdVisible(prevSessionId))
1391
+ if (prevSessionId && !(await this.sessionIdVisible(prevSessionId)))
1037
1392
  prevSessionId = null;
1038
1393
  // Python: leaving a session for a new one flushes the previous session's memory in the background (W6).
1039
1394
  if (prevSessionId)
1040
- this.deps.commitSessionMemory?.(prevSessionId);
1395
+ await this.deps.commitSessionMemory?.(prevSessionId);
1041
1396
  let workspace;
1042
1397
  try {
1043
- workspace = this.resolveNewSessionWorkspace(body, prevSessionId, profile);
1398
+ workspace = (await this.resolveNewSessionWorkspace(body, prevSessionId, profile));
1044
1399
  }
1045
1400
  catch (error) {
1046
1401
  throw new HttpFailure(400, error.message);
1047
1402
  }
1048
1403
  const [model, provider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
1049
1404
  const toolsets = this.validateToolsetsShape(body.enabled_toolsets);
1050
- const s = this.store.newSession({ workspace, model, modelProvider: provider, profile, projectId: typeof body.project_id === 'string' && body.project_id ? body.project_id : null, worktree: opts.worktree ?? null, enabledToolsets: toolsets });
1405
+ const s = (await this.store.newSession({ workspace, model, modelProvider: provider, profile, projectId: typeof body.project_id === 'string' && body.project_id ? body.project_id : null, worktree: opts.worktree ?? null, enabledToolsets: toolsets }));
1051
1406
  if (opts.worktree)
1052
1407
  this.publish('session_new', s.profile, s.session_id);
1053
1408
  return s;
@@ -1058,17 +1413,17 @@ export class SessionService {
1058
1413
  const title = nextTitle.trim().slice(0, 80) || 'Untitled';
1059
1414
  let current;
1060
1415
  try {
1061
- current = this.store.get(sid);
1416
+ current = (await this.store.get(sid));
1062
1417
  }
1063
1418
  catch {
1064
1419
  throw new HttpFailure(404, 'Session not found');
1065
1420
  }
1066
- if (this.isReadOnly(current))
1421
+ if ((await this.isReadOnly(current)))
1067
1422
  throw new HttpFailure(403, `Session ${sid} is read-only`);
1068
- await this.store.withLock(sid, () => {
1423
+ await this.withMutationLock(sid, async () => {
1069
1424
  current.title = title;
1070
1425
  markSessionTitleGenerated(current);
1071
- this.store.save(current, { touchUpdatedAt: false });
1426
+ await this.store.save(current, { touchUpdatedAt: false });
1072
1427
  });
1073
1428
  // An explicit regenerate is user-requested, so state.db takes it over a manual name.
1074
1429
  await this.deps.syncTitle(current, true);
@@ -1076,29 +1431,29 @@ export class SessionService {
1076
1431
  return current;
1077
1432
  }
1078
1433
  async rename(sid, rawTitle) {
1079
- const s = this.mutationTarget(sid, 'renamed');
1080
- await this.store.withLock(sid, () => {
1434
+ const s = (await this.mutationTarget(sid, 'renamed'));
1435
+ await this.withMutationLock(sid, async () => {
1081
1436
  applySessionTitleRename(s, rawTitle);
1082
- this.store.save(s);
1437
+ await this.store.save(s);
1083
1438
  });
1084
1439
  await this.deps.syncTitle(s, true);
1085
1440
  this.publish('session_rename', s.profile, s.session_id);
1086
- return { session: this.wireRow(s) };
1441
+ return { session: (await this.wireRow(s)) };
1087
1442
  }
1088
1443
  // The pin quota is counted across sessions, so the count-and-save transaction runs on one process-wide chain;
1089
1444
  // per-session locks alone would let two pins of different sessions both see the last free slot.
1090
1445
  pinChain = Promise.resolve();
1091
1446
  pin(sid, pinRequested) {
1092
- const run = this.pinChain.then(() => this.pinUnserialized(sid, pinRequested));
1447
+ const run = this.pinChain.then(async () => (await this.pinUnserialized(sid, pinRequested)));
1093
1448
  this.pinChain = run.catch(() => undefined);
1094
1449
  return run;
1095
1450
  }
1096
1451
  async pinUnserialized(sid, pinRequested) {
1097
- this.rejectSubagent(sid, 'modified');
1098
- let s = this.get404(sid);
1099
- s = this.store.ensureFull(sid, s);
1452
+ await this.rejectViewOnly(sid, 'modified');
1453
+ let s = (await this.get404(sid));
1454
+ s = (await this.store.ensureFull(sid, s));
1100
1455
  if (pinRequested && !s.pinned) {
1101
- const rows = allSessions(this.store);
1456
+ const rows = (await allSessions(this.store));
1102
1457
  const byId = new Map(rows.filter((r) => r.session_id).map((r) => [str(r.session_id), r]));
1103
1458
  byId.set(s.session_id, s.compact());
1104
1459
  const targetLineage = lineageRootId(s.compact(), byId);
@@ -1108,38 +1463,38 @@ export class SessionService {
1108
1463
  if (pinnedLineages.size >= limit)
1109
1464
  throw new HttpFailure(400, `Up to ${limit} sessions can be pinned. Unpin one before pinning another.`);
1110
1465
  }
1111
- await this.store.withLock(sid, () => {
1466
+ await this.withMutationLock(sid, async () => {
1112
1467
  s.pinned = pinRequested;
1113
- this.store.save(s);
1114
- });
1468
+ await this.store.save(s);
1469
+ }, { allowReadOnly: true });
1115
1470
  this.publish('session_pin', s.profile, s.session_id);
1116
- return { ok: true, session: this.wireRow(s) };
1471
+ return { ok: true, session: (await this.wireRow(s)) };
1117
1472
  }
1118
1473
  async archive(sid, archived) {
1119
- this.rejectSubagent(sid, 'archived');
1120
- let s = this.get404(sid);
1121
- s = this.store.ensureFull(sid, s);
1122
- await this.store.withLock(sid, () => {
1474
+ await this.rejectViewOnly(sid, 'archived');
1475
+ let s = (await this.get404(sid));
1476
+ s = (await this.store.ensureFull(sid, s));
1477
+ await this.withMutationLock(sid, async () => {
1123
1478
  s.archived = archived;
1124
1479
  if (archived)
1125
1480
  s.pinned = false;
1126
- this.store.save(s, { touchUpdatedAt: false });
1127
- });
1481
+ await this.store.save(s, { touchUpdatedAt: false });
1482
+ }, { allowReadOnly: true });
1128
1483
  this.publish('session_archive', s.profile, s.session_id);
1129
- return { ok: true, session: this.wireRow(s), ...worktreeRetainedPayload(s) };
1484
+ return { ok: true, session: (await this.wireRow(s)), ...worktreeRetainedPayload(s) };
1130
1485
  }
1131
1486
  async move(sid, targetProjectId) {
1132
- const s = this.mutationTarget(sid, 'moved');
1487
+ const s = (await this.mutationTarget(sid, 'moved'));
1133
1488
  if (targetProjectId) {
1134
1489
  const sessionProfile = s.profile || this.deps.activeProfile();
1135
- const target = this.deps.projects.load().find((p) => p.project_id === targetProjectId);
1490
+ const target = (await this.deps.projects.load()).find((p) => p.project_id === targetProjectId);
1136
1491
  if (!target || !this.deps.profilesMatch(target.profile ?? null, sessionProfile))
1137
1492
  throw new HttpFailure(404, 'Project not found');
1138
1493
  }
1139
1494
  try {
1140
- await this.store.withLock(sid, () => {
1495
+ await this.withMutationLock(sid, async () => {
1141
1496
  s.project_id = targetProjectId;
1142
- this.store.save(s);
1497
+ await this.store.save(s);
1143
1498
  }, { timeoutMs: 5000 });
1144
1499
  }
1145
1500
  catch (error) {
@@ -1148,67 +1503,108 @@ export class SessionService {
1148
1503
  throw error;
1149
1504
  }
1150
1505
  this.publish('session_move', s.profile, s.session_id);
1151
- return { ok: true, session: this.wireRow(s) };
1152
- }
1153
- async update(sid, body) {
1154
- const s = this.mutationTarget(sid, 'updated');
1155
- const oldWs = s.workspace;
1156
- const oldModel = s.model;
1157
- const oldProvider = s.model_provider;
1158
- let newWs;
1506
+ return { ok: true, session: (await this.wireRow(s)) };
1507
+ }
1508
+ modelConfirmationKey = randomUUID();
1509
+ async confirmModelSwitch(s, model, provider, token, retry = false) {
1510
+ const profile = s.profile ?? this.deps.activeProfile();
1511
+ const home = this.deps.profileHome(profile);
1512
+ const context = contextUsage(s).context_used_tokens;
1513
+ const messages = this.modelContext(s);
1514
+ const before = JSON.stringify([s.model, s.model_provider, context, s.context_length, messages]);
1515
+ let decision;
1159
1516
  try {
1160
- newWs = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
1517
+ const sidecar = this.deps.sidecar?.();
1518
+ if (!sidecar)
1519
+ throw new Error('Agent sidecar is unavailable');
1520
+ decision = await sidecar.call('models.selection_warnings', { profile_home: home, session_id: s.session_id, model, provider, current_model: s.model, current_provider: s.model_provider, context_tokens: context, messages });
1161
1521
  }
1162
1522
  catch (error) {
1163
- throw new HttpFailure(400, error.message);
1523
+ throw new HttpFailure(503, `Cannot check model selection warnings: ${error.message}`);
1164
1524
  }
1165
- await this.store.withLock(sid, async () => {
1525
+ if (before !== JSON.stringify([s.model, s.model_provider, contextUsage(s).context_used_tokens, s.context_length, this.modelContext(s)])) {
1526
+ if (retry)
1527
+ throw new HttpFailure(503, 'The session changed while checking model warnings. Try the selection again.');
1528
+ return this.confirmModelSwitch(s, model, provider, null, true);
1529
+ }
1530
+ if (!decision.warnings.length)
1531
+ return;
1532
+ const signature = JSON.stringify([profile, home, s.session_id, s.model, s.model_provider, model, provider, context, s.context_length, messages, decision]);
1533
+ const confirmToken = createHmac('sha256', this.modelConfirmationKey).update(signature).digest('hex');
1534
+ if (token !== confirmToken)
1535
+ throw new HttpFailure(409, decision.warnings.map((w) => w.message).join('\n\n'), { type: 'model_confirmation_required', warnings: decision.warnings, confirm_token: confirmToken });
1536
+ }
1537
+ async update(sid, body) {
1538
+ let s = (await this.mutationTarget(sid, 'updated'));
1539
+ let oldWs = s.workspace;
1540
+ let newWs = oldWs;
1541
+ await this.withMutationLock(sid, async () => {
1542
+ s = await this.mutationTarget(sid, 'updated');
1543
+ oldWs = s.workspace;
1544
+ try {
1545
+ newWs = await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
1546
+ }
1547
+ catch (error) {
1548
+ throw new HttpFailure(400, error.message);
1549
+ }
1550
+ const oldModel = s.model;
1551
+ const oldProvider = s.model_provider;
1552
+ const hasModel = 'model' in body || 'model_provider' in body;
1553
+ const [model, provider] = hasModel ? this.deps.modelStateFromRequest('model' in body ? body.model : s.model, 'model_provider' in body ? body.model_provider : undefined, s.model_provider) : [s.model, s.model_provider];
1554
+ const nextModel = model ?? s.model;
1555
+ const changed = hasModel && (str(oldModel) !== str(nextModel) || str(oldProvider) !== str(provider));
1556
+ const contextLength = changed ? (this.deps.resolveContextLength ? await this.deps.resolveContextLength(nextModel, provider, s.profile) : this.deps.contextLengthFor(nextModel, provider)) : s.context_length;
1557
+ if (changed)
1558
+ await this.confirmModelSwitch(s, nextModel ?? '', provider, body.confirm_token);
1559
+ this.assertWritable(s);
1166
1560
  s.workspace = newWs;
1167
- if ('model' in body || 'model_provider' in body) {
1168
- const [model, provider] = this.deps.modelStateFromRequest('model' in body ? body.model : s.model, 'model_provider' in body ? body.model_provider : undefined, s.model_provider);
1169
- if (model !== null)
1170
- s.model = model;
1561
+ if (hasModel) {
1562
+ s.model = nextModel;
1171
1563
  s.model_provider = provider;
1172
- if (str(oldModel) !== str(s.model) || str(oldProvider) !== str(s.model_provider)) {
1173
- s.context_length = this.deps.resolveContextLength ? await this.deps.resolveContextLength(s.model, s.model_provider, s.profile) : this.deps.contextLengthFor(s.model, s.model_provider);
1174
- s.threshold_tokens = 0;
1175
- s.last_prompt_tokens = 0;
1176
- this.deps.runtime.evictAgent(sid);
1564
+ if (changed) {
1565
+ s.context_length = contextLength;
1566
+ forgetContextFigures(s);
1567
+ await this.deps.runtime.evictAgent(sid);
1177
1568
  }
1178
1569
  }
1179
- this.store.save(s);
1570
+ await this.store.save(s);
1180
1571
  });
1181
1572
  if (str(oldWs) !== str(newWs))
1182
1573
  this.deps.runtime.closeTerminal(sid);
1183
- this.deps.workspaces.setLastWorkspace(newWs, s.profile);
1184
- return { session: this.publicSession(s) };
1574
+ await this.deps.workspaces.setLastWorkspace(newWs, s.profile);
1575
+ return { session: (await this.publicSession(s)) };
1185
1576
  }
1186
1577
  async setToolsets(sid, toolsets) {
1187
- this.rejectSubagent(sid, 'modified');
1578
+ await this.rejectViewOnly(sid, 'modified');
1188
1579
  const cleaned = this.validateToolsetsShape(toolsets);
1189
- const s = this.mutationTarget(sid, 'modified');
1190
- await this.store.withLock(sid, () => {
1580
+ const s = (await this.mutationTarget(sid, 'modified'));
1581
+ await this.withMutationLock(sid, async () => {
1191
1582
  s.enabled_toolsets = cleaned;
1192
- this.store.save(s);
1583
+ await this.store.save(s);
1193
1584
  });
1194
1585
  return { ok: true, enabled_toolsets: s.enabled_toolsets };
1195
1586
  }
1196
1587
  /** Python `/api/personality/set` persistence: the name only; the prompt is resolved by the caller. */
1197
1588
  async setPersonality(sid, name) {
1198
- this.rejectSubagent(sid, 'modified');
1199
- const s = this.mutationTarget(sid, 'modified');
1200
- await this.store.withLock(sid, () => {
1589
+ await this.rejectViewOnly(sid, 'modified');
1590
+ const s = (await this.mutationTarget(sid, 'modified'));
1591
+ await this.withMutationLock(sid, async () => {
1201
1592
  s.personality = name;
1202
- this.store.save(s);
1593
+ await this.store.save(s);
1203
1594
  });
1204
1595
  return typeof s.personality === 'string' ? s.personality : null;
1205
1596
  }
1206
1597
  // ── transcript mutations ─────────────────────────────────────────────────
1207
- async truncate(sid, keepRaw) {
1208
- this.rejectSubagent(sid, 'modified');
1598
+ /**
1599
+ * TAL-929: under a `hold`, a cut that discards more than one user turn changes nothing and answers with its counts,
1600
+ * unless `confirmToken` is the token of the rows it would remove now: a transcript that changed since asks again.
1601
+ * Without one (a released App, which asked on the device) the cut proceeds as before.
1602
+ */
1603
+ async truncate(sid, keepRaw, hold = null) {
1604
+ await this.rejectViewOnly(sid, 'modified');
1209
1605
  if (keepRaw === null || keepRaw === undefined)
1210
1606
  throw new HttpFailure(400, 'Missing required field(s): keep_count');
1211
- const s = this.mutationTarget(sid, 'modified');
1607
+ const s = (await this.mutationTarget(sid, 'modified'));
1212
1608
  // Python `int(body["keep_count"])`: a float truncates, a non-integer string is rejected.
1213
1609
  let keep;
1214
1610
  if (typeof keepRaw === 'number')
@@ -1221,26 +1617,61 @@ export class SessionService {
1221
1617
  throw new HttpFailure(400, 'keep_count must be an integer');
1222
1618
  if (keep < 0)
1223
1619
  throw new HttpFailure(400, 'keep_count must be non-negative');
1224
- await this.store.withLock(sid, () => {
1620
+ const refused = await this.withMutationLock(sid, async () => {
1621
+ // One state.db read decides and marks the boundary, so a row committed meanwhile is neither counted nor hidden.
1622
+ const read = this.stateDbRead(s);
1623
+ if (hold) {
1624
+ // A failed read cannot say whether a CLI continuation would go too: fail closed. The Agent keeps a state.db for every
1625
+ // profile it has run in, so a missing one is never evidence of a session without state.db rows.
1626
+ if (!read.ok)
1627
+ throw new HttpFailure(503, read.reason ?? 'The session history could not be read. Please try again in a moment.');
1628
+ const held = (await this.heldCut(s, keep, read.rows));
1629
+ if (held && held.confirm_token !== hold.confirmToken)
1630
+ return held;
1631
+ }
1632
+ // A cut inside the state.db continuation keeps the continuation's rows before it: the boundary below hides every
1633
+ // row this read saw, so those rows join the stored transcript and model history first.
1634
+ if (keep > s.messages.length) {
1635
+ if (s.context_messages.length)
1636
+ s.context_messages = this.modelContext(s, read.rows);
1637
+ s.messages = this.mergedTranscript(s, s.messages, read.rows);
1638
+ }
1225
1639
  truncateSessionAtKeep(s, keep);
1226
- this.markStateDbSeen(s, undefined, { boundary: true });
1227
- this.store.save(s);
1640
+ this.markStateDbSeen(s, read, { boundary: true });
1641
+ await this.store.save(s);
1642
+ return null;
1228
1643
  });
1229
- this.deps.runtime.evictAgent(sid);
1230
- return { ok: true, session: this.publicSession(s) };
1644
+ if (refused)
1645
+ return refused;
1646
+ await this.deps.runtime.evictAgent(sid);
1647
+ return { ok: true, session: (await this.publicSession(s)) };
1648
+ }
1649
+ /**
1650
+ * TAL-929: the confirmation a cut at `keep` needs, or null for one that removes at most one user turn. Counted on the rows
1651
+ * the detail shows, state.db continuation included, since `keep` indexes them: the user turns the cut removes, and the
1652
+ * messages after the row it starts at.
1653
+ */
1654
+ async heldCut(s, keep, stateRows) {
1655
+ const transcript = this.mergedTranscript(s, s.messages, stateRows);
1656
+ const cut = ((await this.publicSession(s, true, transcript)).messages ?? []).map((m, i) => ({ m, i })).filter(({ m, i }) => i >= keep && m._display === 'row');
1657
+ const userTurns = cut.filter(({ m }) => m.role === 'user').length;
1658
+ if (userTurns <= 1)
1659
+ return null;
1660
+ const token = createHash('sha256').update(JSON.stringify(transcript.slice(keep))).digest('hex').slice(0, 32);
1661
+ return { ok: false, confirm_required: true, discard_user_turns: userTurns, discard_messages: cut.filter(({ i }) => i > keep).length, confirm_token: token };
1231
1662
  }
1232
1663
  // ── manual compression (TAL-255) ────────────────────────────────────────
1233
1664
  /**
1234
1665
  * Python `_handle_session_compress_start`: validate, then join the session's running job or start a fresh one (a
1235
1666
  * finished job is replaced). The stale-runtime refusal comes before a job exists; a running job is joined without it.
1236
1667
  */
1237
- async startCompression(sid, focusRaw) {
1668
+ async startCompression(sid, focusRaw, opts = {}) {
1238
1669
  const running = () => { const job = this.compressionJobs.get(sid); return job?.status === 'running' ? job : undefined; };
1239
1670
  // A running job is joined first: once it has installed its short context, the guards below would refuse a new one.
1240
1671
  const existing = running();
1241
1672
  if (existing)
1242
1673
  return existing;
1243
- const { s } = this.compressionTarget(sid);
1674
+ const { s } = (await this.compressionTarget(sid));
1244
1675
  const focusTopic = str(focusRaw).trim().slice(0, 500) || null;
1245
1676
  await ensureAgentRuntimeCurrent(this.deps.sidecar?.() ?? null);
1246
1677
  // Another start may have admitted a job while the runtime check awaited.
@@ -1253,8 +1684,8 @@ export class SessionService {
1253
1684
  throw new HttpFailure(409, `Profile '${str(profile)}' is being deleted.`);
1254
1685
  const release = this.deps.profileActivity?.(profile);
1255
1686
  const now = this.deps.now();
1256
- const job = { session_id: sid, focus_topic: focusTopic, status: 'running', started_at: now, updated_at: now, done: Promise.resolve() };
1257
- job.done = this.compressSession(sid, focusTopic).finally(() => { release?.(); this.compressionJobs.expireLater(job); }).then((result) => { Object.assign(job, { status: 'done', result, updated_at: this.deps.now() }); }, (error) => {
1687
+ const job = { session_id: sid, focus_topic: focusTopic, status: 'running', started_at: now, updated_at: now, turns: new Set(), automatic: opts.automatic === true, done: Promise.resolve() };
1688
+ job.done = this.compressSession(job).finally(() => { release?.(); this.compressionJobs.expireLater(job); }).then((result) => { Object.assign(job, { status: 'done', result, updated_at: this.deps.now() }); }, (error) => {
1258
1689
  const known = error instanceof HttpFailure;
1259
1690
  if (!known)
1260
1691
  this.deps.log(`[webui] Manual compression worker failed for session ${sid}: ${error.message}`);
@@ -1263,28 +1694,121 @@ export class SessionService {
1263
1694
  this.compressionJobs.set(job);
1264
1695
  return job;
1265
1696
  }
1697
+ /**
1698
+ * TAL-1005: compact the session in its joinable compression job before the next turn reads its history. A running job
1699
+ * is always joined; a new one starts when the session is `due` (`compactionDue`, read from the store when omitted).
1700
+ * `turn` is the waiting turn's stream: the job leaves its rows alone. `onWait` runs once a job is being waited on; an abort of `signal` (the turn's Stop) ends the wait but not the
1701
+ * job. True when the job installed a compressed context. Never throws: a failed job leaves the Agent's own preflight.
1702
+ */
1703
+ async ensureCompacted(sid, opts = {}) {
1704
+ const { turn, signal } = opts;
1705
+ if (turn)
1706
+ this.compactionWaiters.add(turn);
1707
+ try {
1708
+ let job = this.compressionJobs.get(sid);
1709
+ if (job?.status !== 'running')
1710
+ job = (opts.due ?? (await this.storedCompactionDue(sid))) ? await this.startCompression(sid, null, { automatic: true }) : undefined;
1711
+ if (!job || signal?.aborted)
1712
+ return false;
1713
+ if (turn)
1714
+ job.turns.add(turn);
1715
+ await opts.onWait?.();
1716
+ const aborted = new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve(); }, { once: true }); });
1717
+ await Promise.race([job.done, aborted]);
1718
+ return !signal?.aborted && job.status === 'done';
1719
+ }
1720
+ catch (error) {
1721
+ this.deps.log(`[webui] compaction before the next turn of ${sid} did not run: ${error.message}`);
1722
+ return false;
1723
+ }
1724
+ finally {
1725
+ if (turn)
1726
+ this.compactionWaiters.delete(turn);
1727
+ }
1728
+ }
1729
+ /**
1730
+ * TAL-1005: measure the model context again when it holds a post-compression estimate the rows after it may have
1731
+ * outgrown (a waiting turn's prompt, a stopped or failed turn's work). Skipped while a turn owns the session, which
1732
+ * settles its own figures, or once the estimate changed meanwhile. True when it stored a new estimate.
1733
+ */
1734
+ async remeasureContext(sid) {
1735
+ const sidecar = this.deps.sidecar?.();
1736
+ let s;
1737
+ try {
1738
+ s = (await this.store.get(sid));
1739
+ }
1740
+ catch {
1741
+ return false;
1742
+ }
1743
+ const before = s.post_compression_context_tokens_estimate;
1744
+ if (before === null || s.active_stream_id || !sidecar)
1745
+ return false;
1746
+ let tokens;
1747
+ try {
1748
+ tokens = (await sidecar.call('models.estimate_tokens', { messages: this.modelContext(s) })).tokens;
1749
+ }
1750
+ catch {
1751
+ return false;
1752
+ }
1753
+ return this.store.withLock(sid, async () => {
1754
+ let live;
1755
+ try {
1756
+ live = (await this.store.get(sid));
1757
+ }
1758
+ catch {
1759
+ return false;
1760
+ }
1761
+ if (live.active_stream_id || live.post_compression_context_tokens_estimate !== before || (await this.isReadOnly(live)))
1762
+ return false;
1763
+ live.post_compression_context_tokens_estimate = Math.max(1, tokens);
1764
+ await this.store.save(live, { touchUpdatedAt: false });
1765
+ return true;
1766
+ });
1767
+ }
1768
+ async storedCompactionDue(sid) {
1769
+ try {
1770
+ return this.compactionDue((await this.store.get(sid, { metadataOnly: true })));
1771
+ }
1772
+ catch {
1773
+ return false;
1774
+ }
1775
+ }
1776
+ /** Whether the session's context (the post-compression estimate, else the last prompt) reached the next turn's threshold. */
1777
+ compactionDue(s) {
1778
+ const positive = (v) => { const n = Math.trunc(Number(v ?? 0)); return Number.isFinite(n) && n > 0 ? n : null; };
1779
+ const used = positive(s.post_compression_context_tokens_estimate) ?? positive(s.last_prompt_tokens);
1780
+ const threshold = positive(s.next_turn_threshold_tokens) ?? positive(s.threshold_tokens);
1781
+ return used !== null && threshold !== null && used >= threshold;
1782
+ }
1266
1783
  /** The session a compression may run on now, and the model history it compresses: the context a turn would send, so a
1267
- * repeat compression builds on the previous summary instead of re-reading the whole display transcript. */
1268
- compressionTarget(sid) {
1269
- this.rejectSubagent(sid, 'compressed');
1270
- const s = this.mutationTarget(sid, 'compressed');
1271
- return { s, history: this.compressionHistory(s) };
1272
- }
1273
- /** The loaded session's own compression guards: no running turn and enough model history. */
1274
- compressionHistory(s, stateRows) {
1275
- if (s.active_stream_id)
1784
+ * repeat compression builds on the previous summary instead of re-reading the whole display transcript. `context` is
1785
+ * that history unsanitized and `stateRows` the state.db read it came from. */
1786
+ async compressionTarget(sid) {
1787
+ await this.rejectViewOnly(sid, 'compressed');
1788
+ const s = (await this.mutationTarget(sid, 'compressed'));
1789
+ const stateRows = this.stateDbRows(s);
1790
+ const context = this.compressionContext(s, stateRows);
1791
+ return { s, history: sanitizeMessagesForApi(context), context, stateRows };
1792
+ }
1793
+ /**
1794
+ * The loaded session's own compression guards: no running turn and enough model history. TAL-1005: a turn waiting on
1795
+ * the compression has not read its history yet, so it does not block; the context leaves out its rows, as it does.
1796
+ */
1797
+ compressionContext(s, stateRows) {
1798
+ const turn = s.active_stream_id;
1799
+ if (turn && !this.compactionWaiters.has(turn))
1276
1800
  throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
1277
- const history = sanitizeMessagesForApi(this.modelContext(s, stateRows));
1278
- if (history.length < 4)
1801
+ const context = this.modelContext(s, stateRows).filter((m) => !turn || m._turn_id !== turn);
1802
+ if (sanitizeMessagesForApi(context).length < 4)
1279
1803
  throw new HttpFailure(400, 'Not enough conversation to compress (need at least 4 messages).');
1280
- return history;
1804
+ return context;
1281
1805
  }
1282
1806
  /** TAL-614: whether a manual compression would start now: `compressionTarget`'s gates, with `readOnly` the wire flag. */
1283
1807
  canCompress(s, readOnly, stateRows) {
1284
1808
  if (readOnly || isClaudeCodeSessionId(s.session_id))
1285
1809
  return false;
1286
1810
  try {
1287
- this.compressionHistory(s, stateRows);
1811
+ this.compressionContext(s, stateRows);
1288
1812
  return true;
1289
1813
  }
1290
1814
  catch (error) {
@@ -1294,18 +1818,19 @@ export class SessionService {
1294
1818
  }
1295
1819
  }
1296
1820
  /** TAL-258: the session's WebUI record (absent for a CLI or gateway session) and the profile whose state.db holds its rows. */
1297
- handoffTarget(sid) {
1821
+ async handoffTarget(sid) {
1822
+ sid = sid.trim();
1298
1823
  let local = null;
1299
1824
  try {
1300
- local = this.store.get(sid);
1825
+ local = (await this.store.get(sid));
1301
1826
  }
1302
1827
  catch { /* a foreign session without a WebUI record */ }
1303
1828
  const profile = local?.profile ?? this.deps.activeProfile();
1304
1829
  return { local, profile, dbPath: this.stateDbPath(profile) };
1305
1830
  }
1306
1831
  /** Python `_handle_conversation_rounds` (TAL-258): the session's rounds and whether the handoff dock is due. */
1307
- conversationRounds(sid, since) {
1308
- const rounds = countConversationRounds(this.handoffTarget(sid).dbPath, sid, since);
1832
+ async conversationRounds(sid, since) {
1833
+ const rounds = countConversationRounds((await this.handoffTarget(sid)).dbPath, sid, since);
1309
1834
  return { ok: true, rounds, threshold: CONVERSATION_ROUND_THRESHOLD, should_show: rounds >= CONVERSATION_ROUND_THRESHOLD };
1310
1835
  }
1311
1836
  /**
@@ -1314,8 +1839,8 @@ export class SessionService {
1314
1839
  * back to a deterministic local summary rather than an error.
1315
1840
  */
1316
1841
  async handoffSummary(sid, since) {
1317
- this.rejectSubagent(sid, 'summarized');
1318
- const { local, profile, dbPath } = this.handoffTarget(sid);
1842
+ sid = (await this.rejectViewOnly(sid, 'summarized'));
1843
+ const { local, profile, dbPath } = (await this.handoffTarget(sid));
1319
1844
  const rounds = countConversationRounds(dbPath, sid, since);
1320
1845
  if (rounds < CONVERSATION_ROUND_THRESHOLD)
1321
1846
  throw new HttpFailure(400, 'Not enough conversation rounds to generate a summary.');
@@ -1328,7 +1853,7 @@ export class SessionService {
1328
1853
  throw new HttpFailure(400, 'Not enough messages to summarize.');
1329
1854
  const sidecar = this.deps.sidecar?.() ?? null;
1330
1855
  await ensureAgentRuntimeCurrent(sidecar);
1331
- const meta = local ? null : this.lookupCliMeta(sid);
1856
+ const meta = local ? null : (await this.lookupCliMeta(sid));
1332
1857
  const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages);
1333
1858
  const pick = (row, ...keys) => { for (const key of keys) {
1334
1859
  const v = str(row?.[key]).trim();
@@ -1336,7 +1861,7 @@ export class SessionService {
1336
1861
  return v;
1337
1862
  } return ''; };
1338
1863
  // Python `get_cli_sessions` never listed WebUI's own state.db rows, so they name no channel.
1339
- const cliRow = meta ?? this.lookupCliMeta(sid);
1864
+ const cliRow = meta ?? (await this.lookupCliMeta(sid));
1340
1865
  const channel = pick(local, 'source_label', 'raw_source', 'source_tag', 'session_source') || (str(cliRow?.raw_source || cliRow?.source).trim().toLowerCase() === 'webui' ? '' : pick(cliRow, 'source_label', 'raw_source', 'source_tag', 'source')) || null;
1341
1866
  await this.persistHandoffMarker(sid, profile, handoffMarker(sid, generated.summary, channel, rounds, generated.fallback, this.deps.now()));
1342
1867
  return { ok: true, summary: generated.summary, message_count: messages.length, rounds, fallback: generated.fallback, ...(generated.warning ? { warning: generated.warning } : {}) };
@@ -1372,6 +1897,7 @@ export class SessionService {
1372
1897
  async persistHandoffMarker(sid, profile, marker) {
1373
1898
  const card = messageHandoffPayload(marker);
1374
1899
  const toStateDb = async () => {
1900
+ await this.rejectViewOnly(sid, 'summarized');
1375
1901
  if (sameHandoff(handoffPayload(stateDbTailToolContent(this.stateDbPath(profile), sid)), card))
1376
1902
  return true;
1377
1903
  const sidecar = this.deps.sidecar?.();
@@ -1384,35 +1910,41 @@ export class SessionService {
1384
1910
  return false;
1385
1911
  }
1386
1912
  };
1387
- await this.store.withLock(sid, async () => {
1913
+ await this.withMutationLock(sid, async () => {
1388
1914
  let live = null;
1389
1915
  try {
1390
- live = this.store.get(sid);
1916
+ live = (await this.store.get(sid));
1391
1917
  }
1392
1918
  catch { /* no WebUI record */ }
1393
1919
  // A turn that started while the summary was generated would drop the card when it settles.
1394
1920
  if (live?.active_stream_id)
1395
1921
  throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
1396
- const toLocal = () => {
1922
+ const toLocal = async () => {
1923
+ await this.rejectViewOnly(sid, 'summarized');
1397
1924
  if (!live)
1398
1925
  return false;
1399
1926
  if (sameHandoff(messageHandoffPayload(live.messages.at(-1)), card))
1400
1927
  return true;
1401
1928
  live.messages.push(marker);
1402
- this.store.save(live);
1929
+ await this.store.save(live);
1403
1930
  return true;
1404
1931
  };
1405
- const messaging = this.isMessagingSession(sid);
1406
- const saved = messaging ? [await toStateDb(), toLocal()].some(Boolean) : toLocal() || await toStateDb();
1932
+ const messaging = (await this.isMessagingSession(sid));
1933
+ const saved = messaging ? [await toStateDb(), (await toLocal())].some(Boolean) : (await toLocal()) || await toStateDb();
1407
1934
  // The caller may switch away on success, so a card saved nowhere is an error, not a summary.
1408
1935
  if (!saved)
1409
1936
  throw new HttpFailure(503, 'The handoff summary could not be saved; please retry.');
1410
- });
1937
+ }, { allowReadOnly: true });
1938
+ }
1939
+ /** The same live presentation for session detail and journal snapshot recovery. */
1940
+ currentRunStatus(sid, streamId) {
1941
+ const job = this.compressionJobs.get(sid);
1942
+ return job?.status === 'running' ? { phase: 'compressing' } : streamId ? (this.deps.runtime.runStatus?.(streamId) ?? null) : null;
1411
1943
  }
1412
1944
  /** The session's compression job; a deleted session's finished result is dropped rather than served. */
1413
- compressionJob(sid) {
1945
+ async compressionJob(sid) {
1414
1946
  try {
1415
- this.store.get(sid, { metadataOnly: true });
1947
+ (await this.store.get(sid, { metadataOnly: true }));
1416
1948
  }
1417
1949
  catch {
1418
1950
  this.compressionJobs.delete(sid);
@@ -1422,18 +1954,22 @@ export class SessionService {
1422
1954
  }
1423
1955
  /**
1424
1956
  * Python `_handle_session_compress`: compress the sanitized transcript in the sidecar outside the lock, then under the
1425
- * session lock refuse a result the session moved past (stream state or transcript changed) and install it as the
1957
+ * session lock refuse a result the session moved past (another stream, or a transcript changed) and install it as the
1426
1958
  * model context with the manual anchor and the #4836 boundary that keeps state.db from replaying compressed rows.
1427
- * The guards run again first: a stream may have started while the caller awaited the runtime check.
1959
+ * The guards run again first: a stream may have started while the caller awaited the runtime check. TAL-1005: rows
1960
+ * the job's waiting turns added meanwhile (a prompt, a Stop) stay after the compressed context.
1428
1961
  */
1429
- async compressSession(sid, focusTopic) {
1430
- const { s, history } = this.compressionTarget(sid);
1431
- // The display transcript and the model context must both be where the compression found them; the key starts from
1432
- // the exact history sent, so a state.db row that lands after this read fails the commit instead of being dropped.
1433
- const transcriptKey = (x, context) => JSON.stringify([sanitizeMessagesForApi(x.messages), context]);
1434
- const historyKey = transcriptKey(s, history);
1435
- const streamState = (x) => JSON.stringify([x.active_stream_id ?? null, x.pending_user_message ?? null, x.pending_attachments ?? null, x.pending_started_at ?? null]);
1436
- const streamBefore = streamState(s);
1962
+ async compressSession(job) {
1963
+ const { session_id: sid, focus_topic: focusTopic } = job;
1964
+ const { s, history, context, stateRows: seenRows } = (await this.compressionTarget(sid));
1965
+ const ownRows = (m) => !s.active_stream_id || m._turn_id !== s.active_stream_id;
1966
+ // The display transcript and the model context must both still start where the compression found them, followed
1967
+ // only by the waiting turns' rows; the context is the exact history sent, so a state.db row that lands after this
1968
+ // read fails the commit instead of being dropped.
1969
+ // A turn still waiting to join (it started its own start meanwhile) joins this job, so it counts as one of its turns.
1970
+ const waited = (id) => job.turns.has(str(id)) || this.compactionWaiters.has(str(id));
1971
+ const sameRows = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1972
+ const turnTail = (rows, base) => (sameRows(rows.slice(0, base.length), base) && rows.slice(base.length).every((m) => waited(m._turn_id)) ? rows.slice(base.length) : null);
1437
1973
  const sidecar = this.deps.sidecar?.();
1438
1974
  if (!sidecar)
1439
1975
  throw new HttpFailure(400, 'Compression failed: the Agent sidecar is not running');
@@ -1457,44 +1993,51 @@ export class SessionService {
1457
1993
  throw new HttpFailure(result.status === 'lock_skipped' ? 409 : 400, result.message ?? 'Nothing to compress yet.');
1458
1994
  const summary = result.summary ?? {};
1459
1995
  let committed = false;
1996
+ let keptTurnRows = false;
1460
1997
  try {
1461
- const current = await this.store.withLock(sid, () => {
1998
+ const current = await this.withMutationLock(sid, async () => {
1462
1999
  let live;
1463
2000
  try {
1464
- live = this.store.get(sid);
2001
+ live = (await this.store.get(sid));
1465
2002
  }
1466
2003
  catch {
1467
2004
  throw new HttpFailure(404, 'Session not found');
1468
2005
  }
1469
- if (streamState(live) !== streamBefore)
2006
+ const turn = live.active_stream_id;
2007
+ if (turn && !waited(turn))
1470
2008
  throw new HttpFailure(409, 'Session stream state changed during compression; please retry.');
1471
2009
  // One state.db read serves the check, the kept display rows, and the boundary.
1472
2010
  const read = this.stateDbRead(live);
1473
2011
  const stateRows = read.rows;
1474
- if (transcriptKey(live, sanitizeMessagesForApi(this.modelContext(live, stateRows))) !== historyKey)
2012
+ const tail = turnTail(this.modelContext(live, stateRows).filter((m) => !turn || m._turn_id !== turn), context);
2013
+ if (!tail || !turnTail(live.messages, s.messages))
1475
2014
  throw new HttpFailure(409, 'Session was modified during compression; please retry.');
1476
2015
  // Rows the sidecar returns without a timestamp take the newest one this compression saw, never the clock: the
1477
2016
  // boundary then hides no state.db row the append-only merge would still show (it already drops rows at or
1478
2017
  // before the newest local row), so a CLI row committed after the read follows the compressed context.
1479
- const seen = [...live.messages, ...live.context_messages, ...stateRows].map((m) => Number(m.timestamp)).filter(Number.isFinite);
2018
+ const seen = [...s.messages, ...s.context_messages, ...seenRows].filter(ownRows).map((m) => Number(m.timestamp)).filter(Number.isFinite);
1480
2019
  const stamp = seen.length ? Math.max(...seen) : this.deps.now();
1481
2020
  const compressed = copyJson(result.messages);
1482
2021
  for (const m of compressed)
1483
2022
  m.timestamp ??= stamp;
1484
- live.context_messages = compressed;
1485
- live.active_stream_id = null;
1486
- live.pending_user_message = null;
1487
- live.pending_attachments = [];
1488
- live.pending_started_at = null;
1489
- live.pending_user_source = null;
2023
+ live.context_messages = [...compressed, ...copyJson(tail)];
2024
+ keptTurnRows = tail.length > 0;
2025
+ if (!turn) {
2026
+ live.pending_user_message = null;
2027
+ live.pending_attachments = [];
2028
+ live.pending_started_at = null;
2029
+ live.pending_user_source = null;
2030
+ }
1490
2031
  // The compressed history included any state.db-only continuation; the new boundary would hide those rows from
1491
2032
  // the display, so the transcript keeps them before it is applied (and the anchor counts them).
1492
2033
  const display = this.mergedTranscript(live, live.messages, stateRows);
1493
2034
  if (display.length > live.messages.length)
1494
2035
  live.messages = copyJson(display);
2036
+ // The anchor is the newest row the compression covered: never a waiting turn's own.
1495
2037
  const visible = visibleMessagesForAnchor(live.messages);
1496
- live.compression_anchor_visible_idx = visible.length ? visible.length - 1 : null;
1497
- live.compression_anchor_message_key = anchorMessageKey(visible.at(-1));
2038
+ const anchor = visible.findLast((m) => !waited(m._turn_id));
2039
+ live.compression_anchor_visible_idx = anchor ? visible.lastIndexOf(anchor) : null;
2040
+ live.compression_anchor_message_key = anchorMessageKey(anchor);
1498
2041
  live.compression_anchor_summary = anchorSummary(summary, compressed);
1499
2042
  live.compression_anchor_mode = 'manual';
1500
2043
  // #4836: an intentional-shrink boundary, so append-only state.db reconciliation does not replay compressed rows.
@@ -1504,15 +2047,22 @@ export class SessionService {
1504
2047
  this.markStateDbSeen(live, read, { boundary: true });
1505
2048
  live.last_prompt_tokens = result.after_tokens;
1506
2049
  live.post_compression_context_tokens_estimate = result.after_tokens;
1507
- this.store.save(live);
2050
+ // An automatic job is maintenance, not activity: it leaves the session's place in the list alone.
2051
+ await this.store.save(live, { touchUpdatedAt: !job.automatic });
1508
2052
  // A backup from before the compression would restore the uncompressed context.
1509
2053
  try {
1510
- rmSync(`${this.store.pathFor(sid)}.bak`, { force: true });
2054
+ await this.store.shrinkBackup.discard(sid);
1511
2055
  }
1512
2056
  catch { /* ignore */ }
1513
2057
  return live;
1514
2058
  });
1515
2059
  committed = true;
2060
+ // The estimate covers the compressed rows only; a stopped turn's kept prompt is measured with them (a waiting turn
2061
+ // settles its own figures instead).
2062
+ if (keptTurnRows)
2063
+ await this.remeasureContext(sid);
2064
+ // Open chats refetch the session for its new context figures; the list order stays (no recency change).
2065
+ this.publish('session_compress', current.profile, sid);
1516
2066
  // The cached turn agent still carries the uncompressed state; the next turn builds a fresh one (gateway parity).
1517
2067
  // Awaited, so a turn sent right after the reply cannot reuse the old agent.
1518
2068
  try {
@@ -1521,31 +2071,57 @@ export class SessionService {
1521
2071
  catch (error) {
1522
2072
  this.deps.log(`[webui] agent eviction after compression of ${sid} failed: ${error.message}`);
1523
2073
  }
1524
- return { ok: true, session: this.publicSession(current), summary, focus_topic: focusTopic };
2074
+ return { ok: true, session: (await this.publicSession(current)), summary, focus_topic: focusTopic };
1525
2075
  }
1526
2076
  finally {
1527
2077
  // Second phase: the Agent's context-engine notification fires only for a result the session now holds.
1528
2078
  if (result.commit_token) {
2079
+ const readOnly = committed ? (await this.readOnlyReason(s)) : null;
2080
+ if (readOnly)
2081
+ committed = false;
1529
2082
  try {
1530
2083
  await sidecar.call('chat.compress_finalize', { commit_token: result.commit_token, committed });
1531
2084
  }
1532
2085
  catch (error) {
1533
2086
  this.deps.log(`[webui] compression finalize for ${sid} failed: ${error.message}`);
1534
2087
  }
2088
+ if (readOnly)
2089
+ throw new HttpFailure(403, readOnly);
1535
2090
  }
1536
2091
  }
1537
2092
  }
2093
+ /** Clear and delete share the profile-scoped, best-effort Agent session boundary. */
2094
+ async finalizeSession(sid, profile, reason) {
2095
+ const sidecar = this.deps.sidecar?.();
2096
+ if (!sidecar || this.deps.profileDeleting?.(profile))
2097
+ return [];
2098
+ const release = this.deps.profileActivity?.(profile);
2099
+ try {
2100
+ return (await sidecar.call('chat.finalize_session', {
2101
+ session_id: sid, profile_home: this.deps.profileHome(profile ?? this.deps.activeProfile()), reason,
2102
+ }, { timeoutMs: 12_000 })).messages;
2103
+ }
2104
+ catch (error) {
2105
+ this.deps.log(`[webui] session finalize (${reason}) failed: ${error.message}`);
2106
+ return [];
2107
+ }
2108
+ finally {
2109
+ release?.();
2110
+ }
2111
+ }
1538
2112
  async clear(sid) {
1539
- this.rejectSubagent(sid, 'modified');
1540
- const s = this.mutationTarget(sid, 'modified');
1541
- await this.store.withLock(sid, () => {
2113
+ await this.rejectViewOnly(sid, 'modified');
2114
+ const s = (await this.mutationTarget(sid, 'modified'));
2115
+ await this.withMutationLock(sid, async () => {
2116
+ const messages = await this.finalizeSession(sid, s.profile, 'clear');
1542
2117
  const hadMessages = s.messages.length > 0;
1543
2118
  truncateSessionAtKeep(s, 0);
2119
+ s.messages = messages.map((content) => ({ role: 'local_notice', content, _notice_kind: 'session_finalize', timestamp: this.deps.now() }));
1544
2120
  s.tool_calls = [];
1545
2121
  if (s.parent_session_id) {
1546
2122
  let parentIsSnapshot = false;
1547
2123
  try {
1548
- parentIsSnapshot = this.store.get(s.parent_session_id, { metadataOnly: true }).pre_compression_snapshot;
2124
+ parentIsSnapshot = (await this.store.get(s.parent_session_id, { metadataOnly: true })).pre_compression_snapshot;
1549
2125
  }
1550
2126
  catch {
1551
2127
  parentIsSnapshot = false;
@@ -1564,21 +2140,21 @@ export class SessionService {
1564
2140
  s.clear_generation = hadMessages ? randomUUID().replace(/-/g, '') : null;
1565
2141
  applySessionTitleRename(s, 'Untitled');
1566
2142
  this.markStateDbSeen(s, undefined, { boundary: true });
1567
- this.store.save(s);
2143
+ await this.store.save(s);
1568
2144
  if (hadMessages) {
1569
2145
  try {
1570
- rmSync(`${this.store.pathFor(sid)}.bak`, { force: true });
2146
+ await this.store.shrinkBackup.discard(sid);
1571
2147
  }
1572
2148
  catch { /* ignore */ }
1573
2149
  }
1574
2150
  });
1575
- this.deps.runtime.evictAgent(sid, true);
1576
- return { ok: true, session: this.wireRow(s) };
2151
+ await this.deps.runtime.evictAgent(sid, true);
2152
+ return { ok: true, session: (await this.wireRow(s)) };
1577
2153
  }
1578
2154
  async retry(sid) {
1579
- this.rejectSubagent(sid, 'modified');
1580
- return this.store.withLock(sid, () => {
1581
- const s = this.mutationTarget(sid, 'modified');
2155
+ await this.rejectViewOnly(sid, 'modified');
2156
+ return this.withMutationLock(sid, async () => {
2157
+ const s = (await this.mutationTarget(sid, 'modified'));
1582
2158
  const history = s.messages;
1583
2159
  const lastUser = findLastUserIndex(history);
1584
2160
  if (lastUser === null)
@@ -1595,14 +2171,14 @@ export class SessionService {
1595
2171
  const removed = history.length - lastUser;
1596
2172
  shrinkTo(s, lastUser);
1597
2173
  this.markStateDbSeen(s, undefined, { boundary: true });
1598
- this.store.save(s);
2174
+ await this.store.save(s);
1599
2175
  return { ok: true, last_user_text: lastUserText, last_user_prompt: lastUserPrompt, last_user_attachments: lastUserAttachments, removed_count: removed };
1600
2176
  });
1601
2177
  }
1602
2178
  async undo(sid) {
1603
- this.rejectSubagent(sid, 'modified');
1604
- return this.store.withLock(sid, () => {
1605
- const s = this.mutationTarget(sid, 'modified');
2179
+ await this.rejectViewOnly(sid, 'modified');
2180
+ return this.withMutationLock(sid, async () => {
2181
+ const s = (await this.mutationTarget(sid, 'modified'));
1606
2182
  const history = s.messages;
1607
2183
  const lastUser = findLastUserIndex(history);
1608
2184
  if (lastUser === null)
@@ -1611,14 +2187,14 @@ export class SessionService {
1611
2187
  const removed = history.length - lastUser;
1612
2188
  shrinkTo(s, lastUser);
1613
2189
  this.markStateDbSeen(s, undefined, { boundary: true });
1614
- this.store.save(s);
2190
+ await this.store.save(s);
1615
2191
  const preview = removedText.length > 40 ? `${removedText.slice(0, 40)}...` : removedText;
1616
2192
  return { ok: true, removed_count: removed, removed_preview: preview };
1617
2193
  });
1618
2194
  }
1619
- duplicate(sid) {
1620
- this.rejectSubagent(sid, 'duplicated');
1621
- const session = this.store.load(sid);
2195
+ async duplicate(sid) {
2196
+ await this.rejectViewOnly(sid, 'duplicated');
2197
+ const session = (await this.store.load(sid));
1622
2198
  if (!session)
1623
2199
  throw new HttpFailure(404, 'Session not found');
1624
2200
  const now = this.deps.now();
@@ -1626,7 +2202,7 @@ export class SessionService {
1626
2202
  const watermark = session.truncation_watermark_compressed ? { truncation_watermark: session.truncation_watermark, truncation_boundary: session.truncation_boundary, truncation_watermark_compressed: true } : {};
1627
2203
  const copied = new Session({
1628
2204
  title: `${session.title || 'Untitled'} (copy)`, workspace: session.workspace, model: session.model, model_provider: session.model_provider,
1629
- messages: copyJson(session.messages), tool_calls: copyJson(session.tool_calls), pinned: false, archived: false, project_id: session.project_id, profile: session.profile,
2205
+ messages: withMirrorSources(copyJson(session.messages), session), tool_calls: copyJson(session.tool_calls), pinned: false, archived: false, project_id: session.project_id, profile: session.profile,
1630
2206
  input_tokens: session.input_tokens, output_tokens: session.output_tokens, estimated_cost: session.estimated_cost, cache_read_tokens: session.cache_read_tokens, cache_write_tokens: session.cache_write_tokens,
1631
2207
  personality: session.personality, enabled_toolsets: session.enabled_toolsets, context_length: session.context_length, threshold_tokens: session.threshold_tokens,
1632
2208
  ...watermark, context_messages: copyJson(session.context_messages),
@@ -1634,16 +2210,15 @@ export class SessionService {
1634
2210
  manual_title: session.manual_title, composer_draft: copyJson(session.composer_draft), context_engine: session.context_engine, context_engine_state: copyJson(session.context_engine_state),
1635
2211
  created_at: now, updated_at: now,
1636
2212
  }, { workspace: session.workspace, model: session.model });
1637
- this.store.touch(copied);
1638
- this.store.save(copied);
2213
+ await this.store.touch(copied);
2214
+ await this.store.save(copied);
1639
2215
  this.publish('session_duplicate', copied.profile, copied.session_id);
1640
- return { session: this.publicSession(copied) };
2216
+ return { session: (await this.publicSession(copied)) };
1641
2217
  }
1642
- branch(sid, body) {
1643
- if (this.isSubagentViewOnly(sid))
1644
- throw new HttpFailure(400, 'Subagent sessions are view-only and cannot be branched from WebUI');
1645
- const source = this.get404(sid);
1646
- if (this.isReadOnly(source)) {
2218
+ async branch(sid, body) {
2219
+ await this.rejectViewOnly(sid, 'branched');
2220
+ const source = (await this.get404(sid));
2221
+ if ((await this.isReadOnly(source))) {
1647
2222
  if (str(source.source_tag || source.raw_source).trim().toLowerCase() !== 'cron')
1648
2223
  throw new HttpFailure(403, 'Read-only sessions cannot be branched from WebUI');
1649
2224
  source.branchSourceReadonly = true;
@@ -1659,7 +2234,7 @@ export class SessionService {
1659
2234
  const customTitle = body.title ? str(body.title).trim().slice(0, 80) || null : null;
1660
2235
  if (!source.branchSourceReadonly) {
1661
2236
  try {
1662
- this.store.save(source);
2237
+ (await this.store.save(source));
1663
2238
  }
1664
2239
  catch { /* ignore */ }
1665
2240
  }
@@ -1674,14 +2249,14 @@ export class SessionService {
1674
2249
  const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages) : source.context_messages;
1675
2250
  const forkedContext = copyJson(truncateContextForDisplayKeep(sourceContext, sourceMessages, forkKeep));
1676
2251
  const branch = new Session({
1677
- workspace: source.workspace, model: source.model, model_provider: source.model_provider, profile: source.profile, title, messages: forked, project_id: source.project_id,
2252
+ workspace: source.workspace, model: source.model, model_provider: source.model_provider, profile: source.profile, title, messages: withMirrorSources(forked, source), project_id: source.project_id,
1678
2253
  personality: source.personality, enabled_toolsets: source.enabled_toolsets, context_length: source.context_length, threshold_tokens: source.threshold_tokens,
1679
2254
  context_messages: forkedContext, gateway_routing: copyJson(source.gateway_routing), context_engine: source.context_engine, context_engine_state: copyJson(source.context_engine_state),
1680
2255
  parent_session_id: source.session_id, session_source: 'fork',
1681
2256
  }, { workspace: source.workspace, model: source.model });
1682
- this.store.touch(branch);
2257
+ await this.store.touch(branch);
1683
2258
  if (forked.length) {
1684
- this.store.save(branch);
2259
+ await this.store.save(branch);
1685
2260
  this.publish('session_branch', branch.profile, branch.session_id);
1686
2261
  }
1687
2262
  return { session_id: branch.session_id, title, parent_session_id: source.session_id };
@@ -1691,14 +2266,14 @@ export class SessionService {
1691
2266
  return stateDbLineageReport(this.stateDbPath(null), sid);
1692
2267
  }
1693
2268
  /** TAL-259: recovery reads the active profile's state.db, and a recovered sidecar joins that profile. */
1694
- recoveryDeps() {
1695
- return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: this.deps.recoveryStampingSince(), log: this.deps.log };
2269
+ async recoveryDeps() {
2270
+ return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: await this.deps.recoveryStampingSince(), isReadOnly: (sid, profile) => Boolean(withOwnerLocks([{ session_id: sid, profile }], this.stateDbSources)[0]?.read_only), log: this.deps.log };
1696
2271
  }
1697
- recoveryAudit() {
1698
- return auditSessionRecovery(this.recoveryDeps());
2272
+ async recoveryAudit() {
2273
+ return (await auditSessionRecovery(await this.recoveryDeps()));
1699
2274
  }
1700
- recoveryRepairSafe() {
1701
- return repairSafeSessionRecovery(this.recoveryDeps());
2275
+ async recoveryRepairSafe() {
2276
+ return repairSafeSessionRecovery(await this.recoveryDeps());
1702
2277
  }
1703
2278
  /**
1704
2279
  * Python `_handle_session_compression_recovery_start`: open a compression-exhausted session's focused continuation, an
@@ -1706,18 +2281,18 @@ export class SessionService {
1706
2281
  * already started from it in the same profile; the source's lock makes concurrent starts converge on one child.
1707
2282
  */
1708
2283
  async compressionRecoveryStart(sid) {
1709
- if (this.isSubagentViewOnly(sid))
2284
+ if ((await this.isSubagentViewOnly(sid)))
1710
2285
  throw new HttpFailure(400, 'Subagent sessions are view-only and cannot start compression recovery from WebUI');
1711
2286
  const action = COMPRESSION_RECOVERY_ACTION_START_FOCUSED;
1712
2287
  let created = false;
1713
- const child = await this.store.withLock(sid, () => {
2288
+ const child = await this.withMutationLock(sid, async () => {
1714
2289
  // Validated under the lock: a delete that won it leaves no source to fork.
1715
- const source = this.store.get(sid);
2290
+ const source = (await this.store.get(sid));
1716
2291
  if (!this.visibleToActiveProfile(source.profile))
1717
2292
  throw new HttpFailure(404, 'Session not found');
1718
2293
  if (!compressionRecoveryPayload(source))
1719
2294
  throw new HttpFailure(409, 'Session does not have a compression recovery action.');
1720
- const existing = this.compressionRecoveryChild(sid, action, source.profile);
2295
+ const existing = (await this.compressionRecoveryChild(sid, action, source.profile));
1721
2296
  if (existing)
1722
2297
  return existing;
1723
2298
  const base = (source.title || 'Untitled').trim() || 'Untitled';
@@ -1731,49 +2306,49 @@ export class SessionService {
1731
2306
  messages: [], context_messages: [], composer_draft: { text: '', files: [] },
1732
2307
  }, { workspace: source.workspace, model: source.model });
1733
2308
  try {
1734
- this.store.save(fresh);
2309
+ await this.store.save(fresh);
1735
2310
  }
1736
2311
  catch (error) {
1737
2312
  throw new HttpFailure(500, `Failed to start compression recovery: ${sanitizePaths(error)}`);
1738
2313
  }
1739
- this.store.touch(fresh);
2314
+ await this.store.touch(fresh);
1740
2315
  created = true;
1741
2316
  return fresh;
1742
- });
2317
+ }, { allowReadOnly: true });
1743
2318
  if (created)
1744
2319
  this.publish('session_compression_recovery', child.profile, child.session_id);
1745
2320
  return {
1746
- ok: true, session: this.publicSession(child), source_session_id: sid, recommended_recovery_action: action,
2321
+ ok: true, session: (await this.publicSession(child)), source_session_id: sid, recommended_recovery_action: action,
1747
2322
  message: created ? 'Started a focused continuation. Describe the next narrow task to continue.' : 'Opened the existing focused continuation for this exhausted session.',
1748
2323
  };
1749
2324
  }
1750
2325
  /** Python `find_compression_recovery_session`: the oldest same-profile continuation started from `sid`, cached or persisted. */
1751
- compressionRecoveryChild(sid, action, profile) {
2326
+ async compressionRecoveryChild(sid, action, profile) {
1752
2327
  const matches = (s) => s.compression_recovery_source_session_id === sid && s.compression_recovery_action === action && this.deps.profilesMatch(s.profile, profile);
1753
2328
  const found = new Map();
1754
2329
  for (const s of this.store.sessions.values())
1755
2330
  if (matches(s))
1756
2331
  found.set(s.session_id, s);
1757
- for (const id of this.store.persistedIds()) {
2332
+ for (const id of (await this.store.persistedIds())) {
1758
2333
  if (found.has(id) || this.store.sessions.has(id))
1759
2334
  continue;
1760
2335
  try {
1761
- const meta = this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false });
2336
+ const meta = (await this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false }));
1762
2337
  if (matches(meta))
1763
2338
  found.set(id, meta);
1764
2339
  }
1765
2340
  catch { /* unreadable: not a match */ }
1766
2341
  }
1767
2342
  const [oldest] = [...found.values()].sort((a, b) => (a.created_at || 0) - (b.created_at || 0) || (a.updated_at || 0) - (b.updated_at || 0) || (a.session_id < b.session_id ? -1 : 1));
1768
- return oldest ? this.store.get(oldest.session_id) : null;
2343
+ return oldest ? (await this.store.get(oldest.session_id)) : null;
1769
2344
  }
1770
2345
  /** The run that keeps `delete` answering 409, if any; stream status reports it so a draining client can wait on it (TAL-622). */
1771
- activeRunBlocking(sid) {
2346
+ async activeRunBlocking(sid) {
1772
2347
  const live = this.deps.runtime.activeRunStream(sid);
1773
2348
  if (live)
1774
2349
  return live;
1775
2350
  try {
1776
- const candidate = str(this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false }).active_stream_id).trim();
2351
+ const candidate = str((await this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false })).active_stream_id).trim();
1777
2352
  if (candidate && this.deps.runtime.activeStreamIds.has(candidate))
1778
2353
  return candidate;
1779
2354
  }
@@ -1785,116 +2360,141 @@ export class SessionService {
1785
2360
  throw new HttpFailure(400, 'session_id is required');
1786
2361
  if (!isSafeSessionId(sid))
1787
2362
  throw new HttpFailure(400, 'Invalid session_id');
2363
+ await this.rejectViewOnly(sid, 'deleted');
1788
2364
  // Python: a read-only import (persisted sidecar flag, sidebar metadata, or a foreign state.db owner such as a
1789
2365
  // Claude Code session) is never deleted — that would erase the owner's authoritative transcript. An orphan has none.
1790
- const orphaned = this.isOrphanedImport(sid);
1791
- if (!orphaned && this.isReadOnlyImport(sid))
2366
+ const orphaned = (await this.isOrphanedImport(sid));
2367
+ if (!orphaned && (await this.isReadOnlyImport(sid)))
1792
2368
  throw new HttpFailure(400, 'Read-only imported sessions cannot be deleted from WebUI');
1793
- if (this.isSubagentViewOnly(sid))
2369
+ if ((await this.isSubagentViewOnly(sid)))
1794
2370
  throw new HttpFailure(400, 'Subagent sessions are view-only and cannot be deleted from WebUI');
1795
- const retained = (() => { try {
1796
- return worktreeRetainedPayload(this.store.get(sid, { metadataOnly: true }));
2371
+ const retained = (await (async () => { try {
2372
+ return worktreeRetainedPayload((await this.store.get(sid, { metadataOnly: true })));
1797
2373
  }
1798
2374
  catch {
1799
2375
  return {};
1800
- } })();
2376
+ } })());
1801
2377
  let eventProfile = null;
1802
- let hadSidecar = true;
1803
2378
  try {
1804
- eventProfile = this.store.get(sid, { metadataOnly: true }).profile;
2379
+ eventProfile = (await this.store.get(sid, { metadataOnly: true })).profile;
1805
2380
  }
1806
2381
  catch {
1807
2382
  eventProfile = null;
1808
- hadSidecar = false;
1809
2383
  }
1810
2384
  // Python `_is_messaging_session_id`: decided before the JSON is gone, from WebUI metadata or the Agent's row.
1811
- const isMessaging = this.isMessagingSession(sid);
1812
- if (this.activeRunBlocking(sid))
1813
- throw new HttpFailure(409, 'Session has an active run; stop it before deleting');
2385
+ const isMessaging = (await this.isMessagingSession(sid));
2386
+ const knownIds = !isMessaging && !orphaned ? stateDbCompressionLineage(this.stateDbPath(eventProfile), sid) : [sid];
2387
+ const assertIdle = async () => { for (const id of knownIds)
2388
+ if (await this.activeRunBlocking(id))
2389
+ throw new HttpFailure(409, 'Session has an active run; stop it before deleting'); };
2390
+ await assertIdle();
2391
+ let stateDbCleanupFailed = false;
2392
+ let removedIds = [sid];
1814
2393
  try {
1815
- await this.store.withLock(sid, () => {
1816
- if (this.activeRunBlocking(sid))
1817
- throw new HttpFailure(409, 'Session has an active run; stop it before deleting');
1818
- // A public share outlives its session file, and revokeShare needs the session, so revoke it first.
1819
- try {
1820
- this.deps.shares.revoke(this.store.get(sid, { metadataOnly: true }));
2394
+ await this.withMutationLock(sid, async () => {
2395
+ await assertIdle();
2396
+ // TAL-792: state.db goes first, so another Agent process running or compressing the session refuses the delete
2397
+ // before anything of Talaria's is removed. Python: the Agent/CLI transcript goes too (else a claimable CLI row
2398
+ // resurfaces in the sidebar), but a messaging channel's memory is never erased from the WebUI.
2399
+ if (!isMessaging && !orphaned) {
2400
+ const result = await this.deleteStateDbRows(eventProfile, sid);
2401
+ stateDbCleanupFailed = !result.ok;
2402
+ removedIds = [...new Set([sid, ...(result.deleted_ids ?? (result.ok ? knownIds : []))])];
1821
2403
  }
1822
- catch (error) {
1823
- if (!(error instanceof SessionNotFound))
1824
- throw error;
2404
+ if (removedIds.some((id) => !isSafeSessionId(id)))
2405
+ throw new HttpFailure(503, 'Session cleanup could not finish because the Agent returned invalid session IDs.');
2406
+ removedIds = (await filterAsync(removedIds, async (id) => {
2407
+ try {
2408
+ return this.deps.profilesMatch((await this.store.get(id, { metadataOnly: true })).profile, eventProfile ?? this.deps.activeProfile());
2409
+ }
2410
+ catch {
2411
+ return true;
2412
+ }
2413
+ }));
2414
+ for (const id of removedIds) {
2415
+ // Revoke before removing each persisted alias; UI cleanup follows the Agent's committed target set.
2416
+ try {
2417
+ const session = (await this.store.get(id, { metadataOnly: true }));
2418
+ if (!Object.keys(retained).length)
2419
+ Object.assign(retained, worktreeRetainedPayload(session));
2420
+ await this.deps.shares.revoke(session);
2421
+ await this.deps.clearRelayCompletions?.(id, session.profile);
2422
+ }
2423
+ catch (error) {
2424
+ if (!(error instanceof SessionNotFound))
2425
+ throw error;
2426
+ }
2427
+ const finalize = id === sid && !(await this.store.wasDeleted(id));
2428
+ if (!(await this.store.delete(id)))
2429
+ throw new HttpFailure(500, 'Failed to delete session data');
2430
+ if (finalize)
2431
+ await this.finalizeSession(sid, eventProfile, 'delete');
1825
2432
  }
1826
- if (!this.store.deleteFiles(sid))
1827
- throw new HttpFailure(500, 'Failed to delete session data');
1828
- }, { timeoutMs: 5000 });
2433
+ }, { timeoutMs: 5000, allowReadOnly: orphaned });
1829
2434
  }
1830
2435
  catch (error) {
1831
2436
  if (error instanceof SessionBusy)
1832
2437
  throw new HttpFailure(503, 'Session busy, try again');
1833
2438
  throw error;
1834
2439
  }
1835
- this.deps.runtime.evictAgent(sid, true);
1836
- try {
1837
- rmSync(this.deps.attachmentDir(sid), { recursive: true, force: true });
1838
- }
1839
- catch { /* ignore */ }
1840
- // Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
1841
- try {
1842
- this.deps.journal?.deleteSession(sid);
1843
- }
1844
- catch { /* ignore */ }
1845
- this.deps.runtime.closeTerminal(sid);
1846
- // Python: the Agent/CLI transcript in state.db goes too (else a claimable CLI row resurfaces in the sidebar), but a
1847
- // messaging channel's memory is never erased from the WebUI; the actual outcome is reported.
1848
- let stateDbCleanupFailed = false;
1849
- if (!isMessaging && !orphaned) {
1850
- // TAL-529: the compression continuations go too, tip first; the Agent's delete keeps and detaches them otherwise.
1851
- let lineage = [];
2440
+ for (const id of removedIds) {
2441
+ await this.deps.runtime.evictAgent(id, true);
1852
2442
  try {
1853
- lineage = stateDbCompressionLineage(this.stateDbPath(eventProfile), sid);
1854
- }
1855
- catch {
1856
- stateDbCleanupFailed = true;
2443
+ rmSync(this.deps.attachmentDir(id), { recursive: true, force: true });
1857
2444
  }
1858
- for (const id of lineage.reverse()) {
1859
- try {
1860
- if (!(await this.deps.runtime.deleteCliSession(eventProfile, id)))
1861
- stateDbCleanupFailed = true;
1862
- }
1863
- catch {
1864
- stateDbCleanupFailed = true;
1865
- }
2445
+ catch { /* ignore */ }
2446
+ // Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
2447
+ try {
2448
+ (await this.deps.journal?.deleteSession(id));
1866
2449
  }
2450
+ catch { /* ignore */ }
2451
+ this.deps.runtime.closeTerminal(id);
1867
2452
  }
1868
2453
  this.publish('session_delete', eventProfile);
1869
- // Only a sidecar session is ever published to the relay.
1870
- if (hadSidecar)
1871
- this.deps.clearRelayCompletions?.(sid, eventProfile);
1872
2454
  return { ok: true, state_db_cleanup_failed: stateDbCleanupFailed, ...retained };
1873
2455
  }
1874
- cleanup(zeroOnly) {
2456
+ /** The committed Agent deletion targets and cleanup outcome; throws 409 `refused` while an Agent process holds a row. */
2457
+ async deleteStateDbRows(profile, sid) {
2458
+ // TAL-529: the compression continuations go too; the Agent's delete keeps and detaches them otherwise. TAL-792: the
2459
+ // sidecar resolves them inside its delete transaction, so a guard on any of them refuses before anything is removed.
2460
+ let result;
2461
+ try {
2462
+ result = await this.deps.runtime.deleteCliSession(profile, sid);
2463
+ }
2464
+ catch {
2465
+ return { ok: false };
2466
+ }
2467
+ if (result.refused) {
2468
+ const message = result.refused === 'runtime_history_unsupported'
2469
+ ? 'This Agent cannot retire the session\'s retained runtime history. Use an Agent that supports retiring this history, or explicitly restore a pre-upgrade backup before deleting it.'
2470
+ : 'In use by another Agent process. Try again later.';
2471
+ throw new HttpFailure(409, message, { refused: result.refused });
2472
+ }
2473
+ return result;
2474
+ }
2475
+ async cleanup(zeroOnly) {
1875
2476
  let cleaned = 0;
1876
2477
  const removed = new Set();
1877
- for (const s of this.store.scanAll()) {
2478
+ for (const s of (await this.store.scanAll())) {
1878
2479
  const shouldDelete = zeroOnly ? s.messages.length === 0 : s.title === 'Untitled' && s.messages.length === 0;
1879
- if (!shouldDelete)
2480
+ if (!shouldDelete || (await this.isReadOnly(s)))
1880
2481
  continue;
1881
2482
  // A cleared session keeps its share; keep the session (and its revoke route) if the share cannot be revoked.
1882
2483
  try {
1883
- this.deps.shares.revoke(s);
2484
+ (await this.deps.shares.revoke(s));
1884
2485
  }
1885
2486
  catch {
1886
2487
  continue;
1887
2488
  }
1888
2489
  this.store.sessions.delete(s.session_id);
1889
- rmSync(this.store.pathFor(s.session_id), { force: true });
2490
+ await this.store.delete(s.session_id, { tombstone: false });
1890
2491
  cleaned += 1;
1891
2492
  removed.add(s.session_id);
1892
2493
  }
1893
- this.store.invalidatePersistedIds();
1894
2494
  let rewroteIndex = false;
1895
2495
  try {
1896
- const rows = this.store.readIndexEntries();
1897
- const live = this.store.persistedIds();
2496
+ const rows = (await this.store.readIndexEntries());
2497
+ const live = (await this.store.persistedIds());
1898
2498
  const survivors = rows.filter((entry) => {
1899
2499
  const id = str(entry.session_id);
1900
2500
  if (!id || live.has(id) || this.store.sessions.has(id))
@@ -1905,20 +2505,20 @@ export class SessionService {
1905
2505
  return false;
1906
2506
  });
1907
2507
  if (survivors.length < rows.length) {
1908
- this.store.writeIndex();
2508
+ await this.store.writeIndex();
1909
2509
  rewroteIndex = true;
1910
2510
  }
1911
2511
  }
1912
2512
  catch { /* corrupt index is rebuilt below */ }
1913
2513
  if (removed.size && !rewroteIndex) {
1914
2514
  try {
1915
- this.store.writeIndex();
2515
+ (await this.store.writeIndex());
1916
2516
  }
1917
2517
  catch { /* ignore */ }
1918
2518
  }
1919
2519
  return { ok: true, cleaned };
1920
2520
  }
1921
- import(body) {
2521
+ async import(body) {
1922
2522
  const messages = stripPublicInternalFields(body.messages, { messageRecords: true });
1923
2523
  if (!Array.isArray(messages))
1924
2524
  throw new HttpFailure(400, 'JSON must contain a "messages" array');
@@ -1927,48 +2527,48 @@ export class SessionService {
1927
2527
  throw new HttpFailure(400, 'JSON "tool_calls" must be an array');
1928
2528
  let workspace;
1929
2529
  try {
1930
- workspace = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null);
2530
+ workspace = (await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null));
1931
2531
  }
1932
2532
  catch (error) {
1933
2533
  throw new HttpFailure(400, error.message);
1934
2534
  }
1935
- const defaults = this.store.deps.defaults(this.deps.activeProfile());
2535
+ const defaults = (await this.store.deps.defaults(this.deps.activeProfile()));
1936
2536
  const s = new Session({ title: typeof body.title === 'string' ? body.title : 'Imported session', workspace, model: typeof body.model === 'string' ? body.model : defaults.model, messages: messages, tool_calls: stripPublicInternalFields(rawToolCalls), profile: this.deps.activeProfile() }, defaults);
1937
2537
  s.pinned = Boolean(body.pinned);
1938
- this.store.touch(s);
1939
- this.store.save(s);
2538
+ await this.store.touch(s);
2539
+ await this.store.save(s);
1940
2540
  this.publish('session_import');
1941
- return { ok: true, session: this.publicSession(s) };
2541
+ return { ok: true, session: (await this.publicSession(s)) };
1942
2542
  }
1943
2543
  // ── drafts ───────────────────────────────────────────────────────────────
1944
- readDraft(sid) {
2544
+ async readDraft(sid) {
1945
2545
  if (!sid)
1946
2546
  throw new HttpFailure(400, 'session_id is required');
1947
- let hasSidecar;
1948
2547
  try {
1949
- hasSidecar = this.deps.drafts.exists(sid);
2548
+ this.deps.drafts.validate(sid);
1950
2549
  }
1951
2550
  catch {
1952
2551
  throw new HttpFailure(400, 'Invalid session_id');
1953
2552
  }
2553
+ const hasSidecar = await this.deps.drafts.exists(sid);
1954
2554
  if (hasSidecar) {
1955
2555
  const cached = this.store.sessions.get(sid);
1956
- if (!cached && !existsSessionFile(this.store, sid))
2556
+ if (!cached && !(await existsSessionFile(this.store, sid)))
1957
2557
  throw new HttpFailure(404, 'Session not found');
1958
- const [draft, version] = this.deps.drafts.readState(sid);
2558
+ const [draft, version] = (await this.deps.drafts.readState(sid));
1959
2559
  if (cached)
1960
2560
  cached.composer_draft = draft;
1961
2561
  return { draft, draft_version: version };
1962
2562
  }
1963
- const s = this.get404(sid, { metadataOnly: true });
1964
- const [draft, version] = this.deps.drafts.readState(sid, s.composer_draft);
2563
+ const s = (await this.get404(sid, { metadataOnly: true }));
2564
+ const [draft, version] = (await this.deps.drafts.readState(sid, s.composer_draft));
1965
2565
  return { draft, draft_version: version };
1966
2566
  }
1967
2567
  async writeDraft(body) {
1968
2568
  const sid = str(body.session_id);
1969
2569
  if (!sid)
1970
2570
  throw new HttpFailure(400, 'Missing required field(s): session_id');
1971
- if (this.isSubagentViewOnly(sid))
2571
+ if ((await this.isSubagentViewOnly(sid)))
1972
2572
  throw new HttpFailure(400, 'Subagent sessions are view-only and cannot store a draft from WebUI');
1973
2573
  let text = body.text;
1974
2574
  let files = body.files;
@@ -1981,7 +2581,7 @@ export class SessionService {
1981
2581
  if (Array.isArray(files) && files.length > 50)
1982
2582
  files = files.slice(0, 50);
1983
2583
  try {
1984
- this.deps.drafts.path(sid);
2584
+ this.deps.drafts.validate(sid);
1985
2585
  }
1986
2586
  catch {
1987
2587
  throw new HttpFailure(400, 'Invalid session_id');
@@ -1998,18 +2598,18 @@ export class SessionService {
1998
2598
  if (BigInt(draftVersion) > maxVersion)
1999
2599
  throw new HttpFailure(400, 'draft_version is too far in the future');
2000
2600
  }
2001
- return this.store.withLock(sid, () => {
2601
+ return this.store.withLock(sid, async () => {
2002
2602
  let s = this.store.sessions.get(sid) ?? null;
2003
2603
  if (s && s.session_id !== sid)
2004
2604
  s = null;
2005
- const sessionExists = existsSessionFile(this.store, sid);
2605
+ const sessionExists = (await existsSessionFile(this.store, sid));
2006
2606
  if (!s) {
2007
2607
  if (!sessionExists)
2008
2608
  throw new HttpFailure(404, 'Session not found');
2009
- if (!this.deps.drafts.exists(sid))
2010
- s = this.get404(sid, { metadataOnly: true });
2609
+ if (!(await this.deps.drafts.exists(sid)))
2610
+ s = (await this.get404(sid, { metadataOnly: true }));
2011
2611
  }
2012
- const [currentDraft, currentVersion] = this.deps.drafts.readState(sid, s?.composer_draft);
2612
+ const [currentDraft, currentVersion] = (await this.deps.drafts.readState(sid, s?.composer_draft));
2013
2613
  const next = { ...currentDraft };
2014
2614
  if (text !== null && text !== undefined)
2015
2615
  next.text = text;
@@ -2032,10 +2632,10 @@ export class SessionService {
2032
2632
  throw new HttpFailure(404, 'Session not found');
2033
2633
  if (!sessionExists && s) {
2034
2634
  s.composer_draft = next;
2035
- this.store.save(s, { touchUpdatedAt: false });
2635
+ await this.store.save(s, { touchUpdatedAt: false });
2036
2636
  }
2037
2637
  try {
2038
- saved = this.deps.drafts.write(sid, next, draftVersion);
2638
+ saved = (await this.deps.drafts.write(sid, next, draftVersion));
2039
2639
  }
2040
2640
  catch (error) {
2041
2641
  if (error instanceof DraftVersionConflict)
@@ -2060,7 +2660,7 @@ export class SessionService {
2060
2660
  throw new HttpFailure(400, 'Missing required field(s): scene');
2061
2661
  let s;
2062
2662
  try {
2063
- s = this.getForMutation(sid);
2663
+ s = (await this.getForMutation(sid));
2064
2664
  }
2065
2665
  catch (error) {
2066
2666
  if (error instanceof SessionNotFound)
@@ -2071,7 +2671,7 @@ export class SessionService {
2071
2671
  }
2072
2672
  if (!this.visibleToActiveProfile(s.profile))
2073
2673
  throw new HttpFailure(404, 'Session not found');
2074
- return this.store.withLock(sid, () => {
2674
+ return this.withMutationLock(sid, async () => {
2075
2675
  let placement;
2076
2676
  try {
2077
2677
  placement = storeAnchorScene(s, body, this.deps.now());
@@ -2081,16 +2681,16 @@ export class SessionService {
2081
2681
  }
2082
2682
  if (!placement)
2083
2683
  throw new HttpFailure(404, 'Assistant message not found');
2084
- this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
2684
+ await this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
2085
2685
  return { ok: true, ...placement };
2086
2686
  });
2087
2687
  }
2088
2688
  /** The session and the full transcript its detail is built from; a state.db-only session gives its synthesized one. */
2089
- fullTranscript(sid) {
2689
+ async fullTranscript(sid) {
2090
2690
  try {
2091
- let session = this.store.get(sid);
2691
+ let session = (await this.store.get(sid));
2092
2692
  if (session.loadedMetadataOnly)
2093
- session = this.store.load(sid) ?? session;
2693
+ session = (await this.store.load(sid)) ?? session;
2094
2694
  if (!this.visibleToActiveProfile(session.profile))
2095
2695
  throw new HttpFailure(404, 'Session not found');
2096
2696
  return { session, transcript: this.mergedTranscript(session), stored: true };
@@ -2098,17 +2698,17 @@ export class SessionService {
2098
2698
  catch (error) {
2099
2699
  if (error instanceof HttpFailure)
2100
2700
  throw error;
2101
- const session = this.foreignSession(sid).synth;
2701
+ const session = (await this.foreignSession(sid)).synth;
2102
2702
  return { session, transcript: session.messages, stored: false };
2103
2703
  }
2104
2704
  }
2105
- readAnchorScene(query) {
2705
+ async readAnchorScene(query) {
2106
2706
  const sid = str(query.session_id).trim();
2107
2707
  const messageRef = normalizeAnchorSceneMessageRef(query.message_ref);
2108
2708
  const messageIndex = anchorSceneIntOrNull(query.message_index);
2109
2709
  if (!sid || (!messageRef && messageIndex === null))
2110
2710
  throw new HttpFailure(400, 'session_id and message_ref or message_index are required');
2111
- const { session, transcript, stored } = this.fullTranscript(sid);
2711
+ const { session, transcript, stored } = (await this.fullTranscript(sid));
2112
2712
  const result = readAnchorSceneRows(session, { messageRef, messageIndex, before: anchorSceneIntOrNull(query.before), limit: anchorSceneIntOrNull(query.limit) }, withToolCallOutcomes(withTurnIds(transcript), session.tool_calls, session.active_stream_id));
2113
2713
  if (!result)
2114
2714
  throw new HttpFailure(404, 'Anchor activity scene not found');
@@ -2117,26 +2717,26 @@ export class SessionService {
2117
2717
  const redacted = redactValue(result, enabled);
2118
2718
  const rows = withSceneToolDisplay(result.rows, redacted.rows, enabled);
2119
2719
  // `/api/media` serves stored sessions only, so a state.db-only session's rows stay as written, as its detail does.
2120
- return { ...redacted, rows: stored ? withSceneRowMedia(rows, this.mediaProjector(session)) : rows };
2720
+ return { ...redacted, rows: stored ? withSceneRowMedia(rows, (await this.mediaProjector(session))) : rows };
2121
2721
  }
2122
2722
  /** TAL-331: one tool call's whole result, which a limited response clipped (`result_truncated`), redacted like the detail. */
2123
- readToolResult(query) {
2723
+ async readToolResult(query) {
2124
2724
  const sid = query.session_id.trim();
2125
2725
  const id = query.tool_call_id.trim();
2126
2726
  if (!sid || !id)
2127
2727
  throw new HttpFailure(400, 'session_id and tool_call_id are required');
2128
- const full = fullToolResult(this.fullTranscript(sid).transcript, id);
2728
+ const full = fullToolResult((await this.fullTranscript(sid)).transcript, id);
2129
2729
  if (full === null)
2130
2730
  throw new HttpFailure(404, 'Tool result not found');
2131
2731
  return { tool_call_id: id, ...redactValue(full, this.deps.redactEnabled()) };
2132
2732
  }
2133
2733
  // ── shares ───────────────────────────────────────────────────────────────
2134
- createShare(sid) {
2734
+ async createShare(sid) {
2135
2735
  this.rejectClaudeCode(sid, 'shared');
2136
- let s = this.get404(sid);
2736
+ let s = (await this.get404(sid));
2137
2737
  if (!this.visibleToActiveProfile(s.profile))
2138
2738
  throw new HttpFailure(404, 'Session not found');
2139
- s = this.store.ensureFull(sid, s);
2739
+ s = (await this.store.ensureFull(sid, s));
2140
2740
  let snapshot;
2141
2741
  try {
2142
2742
  snapshot = buildShareSnapshot(s, s.messages, { hermesHome: this.deps.hermesHome, attachmentRoot: this.deps.attachmentDir('').replace(/\/[^/]*$/, ''), home: this.deps.home });
@@ -2144,31 +2744,31 @@ export class SessionService {
2144
2744
  catch (error) {
2145
2745
  throw new HttpFailure(400, error.message);
2146
2746
  }
2147
- const meta = this.deps.shares.createOrRefresh(s, snapshot);
2747
+ const meta = (await this.deps.shares.createOrRefresh(s, snapshot));
2148
2748
  s.share_token = meta.share_token;
2149
2749
  s.share_created_at = meta.share_created_at;
2150
- this.store.save(s, { touchUpdatedAt: false });
2750
+ await this.store.save(s, { touchUpdatedAt: false });
2151
2751
  this.publish('session_share_create', s.profile, sid);
2152
2752
  return {
2153
2753
  ok: true,
2154
2754
  share: { token: meta.share_token, url: sharePath(meta.share_token) ?? '', title: meta.share_title, message_count: meta.share_message_count, created_at: meta.share_created_at, updated_at: meta.share_updated_at },
2155
- session: this.publicSession(s),
2755
+ session: (await this.publicSession(s)),
2156
2756
  };
2157
2757
  }
2158
- revokeShare(sid) {
2159
- let s = this.get404(sid);
2758
+ async revokeShare(sid) {
2759
+ let s = (await this.get404(sid));
2160
2760
  if (!this.visibleToActiveProfile(s.profile))
2161
2761
  throw new HttpFailure(404, 'Session not found');
2162
- s = this.store.ensureFull(sid, s);
2163
- this.deps.shares.revoke(s);
2762
+ s = (await this.store.ensureFull(sid, s));
2763
+ await this.deps.shares.revoke(s);
2164
2764
  s.share_token = null;
2165
2765
  s.share_created_at = null;
2166
- this.store.save(s, { touchUpdatedAt: false });
2766
+ await this.store.save(s, { touchUpdatedAt: false });
2167
2767
  this.publish('session_share_revoke', s.profile, sid);
2168
- return { ok: true, session: this.publicSession(s) };
2768
+ return { ok: true, session: (await this.publicSession(s)) };
2169
2769
  }
2170
- loadShare(token) {
2171
- const share = this.deps.shares.load(token);
2770
+ async loadShare(token) {
2771
+ const share = (await this.deps.shares.load(token));
2172
2772
  if (!share)
2173
2773
  throw new HttpFailure(404, 'Shared conversation not found');
2174
2774
  return { share };
@@ -2179,15 +2779,16 @@ export class SessionService {
2179
2779
  throw new HttpFailure(400, 'Missing session_id');
2180
2780
  return { yolo_enabled: this.deps.yolo.isEnabled(sid) };
2181
2781
  }
2182
- setYolo(sid, enabled) {
2782
+ async setYolo(sid, enabled) {
2183
2783
  if (!sid)
2184
2784
  throw new HttpFailure(400, 'Missing required field(s): session_id');
2785
+ await this.assertWritable(sid);
2185
2786
  this.deps.yolo.set(sid, enabled);
2186
2787
  return { ok: true, yolo_enabled: this.deps.yolo.isEnabled(sid) };
2187
2788
  }
2188
2789
  }
2189
- function existsSessionFile(store, sid) {
2190
- return store.persistedIds().has(sid);
2790
+ async function existsSessionFile(store, sid) {
2791
+ return (await store.persistedIds()).has(sid);
2191
2792
  }
2192
2793
  import { createHash } from 'node:crypto';
2193
2794
  function hashRevision(signature) {
@@ -2231,13 +2832,24 @@ function runStateDbRows(rows, prompt) {
2231
2832
  function settledIdentity(m) {
2232
2833
  return messageIdentity(m, Infinity) ?? JSON.stringify([m.role ?? null, m.content ?? null, reasoningFieldsText(m)]);
2233
2834
  }
2234
- /** `rows` minus one occurrence of each row of `base`, by `settledIdentity`. */
2835
+ /** `rows` minus one occurrence of each base row, retaining its numeric provenance on the Agent's history copy. */
2235
2836
  function withoutRows(rows, base) {
2236
2837
  const left = new Map();
2237
- for (const m of base)
2238
- left.set(settledIdentity(m), (left.get(settledIdentity(m)) ?? 0) + 1);
2239
- return rows.filter((m) => { const key = settledIdentity(m); const n = left.get(key) ?? 0; if (n > 0)
2240
- left.set(key, n - 1); return n === 0; });
2838
+ for (const m of base) {
2839
+ const key = settledIdentity(m);
2840
+ const entry = left.get(key) ?? { rows: [], at: 0 };
2841
+ entry.rows.push(m);
2842
+ left.set(key, entry);
2843
+ }
2844
+ return rows.filter((m) => {
2845
+ const entry = left.get(settledIdentity(m));
2846
+ if (!entry || entry.at === entry.rows.length)
2847
+ return true;
2848
+ const original = entry.rows[entry.at++];
2849
+ if (!('_state_db_generation' in m) && '_state_db_generation' in original)
2850
+ m._state_db_generation = original._state_db_generation;
2851
+ return false;
2852
+ });
2241
2853
  }
2242
2854
  /**
2243
2855
  * TAL-493: what the state.db marker was recorded under: the boundary fields every boundary writer sets (an older release