@maudecode/talaria-web 1.18.0 → 1.20.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 (774) 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 +93 -38
  6. package/dist/api/automation-raw.js.map +1 -1
  7. package/dist/api/automation-router.d.ts +99 -17
  8. package/dist/api/automation-router.js +22 -14
  9. package/dist/api/automation-router.js.map +1 -1
  10. package/dist/api/chat-router.d.ts +1433 -37
  11. package/dist/api/chat-router.js +205 -90
  12. package/dist/api/chat-router.js.map +1 -1
  13. package/dist/api/git-router.d.ts +6 -2
  14. package/dist/api/git-router.js +30 -30
  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 +76 -62
  18. package/dist/api/raw-routes.js.map +1 -1
  19. package/dist/api/router.d.ts +5 -5
  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 +2635 -151
  26. package/dist/api/sessions-router.js +193 -177
  27. package/dist/api/sessions-router.js.map +1 -1
  28. package/dist/api/settings-router.d.ts +450 -11
  29. package/dist/api/settings-router.js +102 -49
  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 +102 -80
  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 +963 -109
  38. package/dist/api/tools-router.js +426 -72
  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 +80 -57
  45. package/dist/app.js.map +1 -1
  46. package/dist/auth/crypto.d.ts +3 -0
  47. package/dist/auth/crypto.js +18 -0
  48. package/dist/auth/crypto.js.map +1 -1
  49. package/dist/auth/gate.js +14 -9
  50. package/dist/auth/gate.js.map +1 -1
  51. package/dist/auth/passkeys.d.ts +15 -14
  52. package/dist/auth/passkeys.js +89 -91
  53. package/dist/auth/passkeys.js.map +1 -1
  54. package/dist/auth/store.d.ts +17 -7
  55. package/dist/auth/store.js +93 -52
  56. package/dist/auth/store.js.map +1 -1
  57. package/dist/bin/talaria-web.js +12 -81
  58. package/dist/bin/talaria-web.js.map +1 -1
  59. package/dist/cli/ctl.js +8 -2
  60. package/dist/cli/ctl.js.map +1 -1
  61. package/dist/cli/dotenv.d.ts +5 -0
  62. package/dist/cli/dotenv.js +14 -2
  63. package/dist/cli/dotenv.js.map +1 -1
  64. package/dist/cli/launcher.js +5 -5
  65. package/dist/cli/launcher.js.map +1 -1
  66. package/dist/cli/main.d.ts +1 -0
  67. package/dist/cli/main.js +90 -0
  68. package/dist/cli/main.js.map +1 -0
  69. package/dist/cli/supervise.d.ts +24 -0
  70. package/dist/cli/supervise.js +147 -6
  71. package/dist/cli/supervise.js.map +1 -1
  72. package/dist/config/agent-config.d.ts +37 -11
  73. package/dist/config/agent-config.js +146 -64
  74. package/dist/config/agent-config.js.map +1 -1
  75. package/dist/config.d.ts +2 -0
  76. package/dist/config.js +14 -5
  77. package/dist/config.js.map +1 -1
  78. package/dist/fs/atomic.d.ts +4 -0
  79. package/dist/fs/atomic.js +19 -2
  80. package/dist/fs/atomic.js.map +1 -1
  81. package/dist/fs/stat.d.ts +7 -0
  82. package/dist/fs/stat.js +27 -0
  83. package/dist/fs/stat.js.map +1 -0
  84. package/dist/http/context.d.ts +28 -9
  85. package/dist/http/context.js +77 -25
  86. package/dist/http/context.js.map +1 -1
  87. package/dist/onboarding.d.ts +2 -2
  88. package/dist/onboarding.js +10 -8
  89. package/dist/onboarding.js.map +1 -1
  90. package/dist/profiles/profiles.d.ts +12 -4
  91. package/dist/profiles/profiles.js +78 -30
  92. package/dist/profiles/profiles.js.map +1 -1
  93. package/dist/projects.d.ts +15 -6
  94. package/dist/projects.js +55 -47
  95. package/dist/projects.js.map +1 -1
  96. package/dist/providers/catalog.d.ts +85 -14
  97. package/dist/providers/catalog.js +502 -146
  98. package/dist/providers/catalog.js.map +1 -1
  99. package/dist/providers/env-file.d.ts +10 -3
  100. package/dist/providers/env-file.js +24 -75
  101. package/dist/providers/env-file.js.map +1 -1
  102. package/dist/providers/runtime-env.d.ts +4 -2
  103. package/dist/providers/runtime-env.js +9 -6
  104. package/dist/providers/runtime-env.js.map +1 -1
  105. package/dist/providers/tables.d.ts +3 -1
  106. package/dist/providers/tables.js +4 -2
  107. package/dist/providers/tables.js.map +1 -1
  108. package/dist/redact.js +56 -23
  109. package/dist/redact.js.map +1 -1
  110. package/dist/release.d.ts +7 -0
  111. package/dist/release.js +19 -3
  112. package/dist/release.js.map +1 -1
  113. package/dist/runtime.d.ts +5 -1
  114. package/dist/runtime.js +303 -96
  115. package/dist/runtime.js.map +1 -1
  116. package/dist/server.js +31 -18
  117. package/dist/server.js.map +1 -1
  118. package/dist/sessions/anchor.d.ts +11 -4
  119. package/dist/sessions/anchor.js +33 -37
  120. package/dist/sessions/anchor.js.map +1 -1
  121. package/dist/sessions/background-events.d.ts +2 -0
  122. package/dist/sessions/background-events.js +10 -0
  123. package/dist/sessions/background-events.js.map +1 -0
  124. package/dist/sessions/background-tasks.d.ts +49 -23
  125. package/dist/sessions/background-tasks.js +161 -89
  126. package/dist/sessions/background-tasks.js.map +1 -1
  127. package/dist/sessions/background-updates.d.ts +15 -8
  128. package/dist/sessions/background-updates.js +101 -25
  129. package/dist/sessions/background-updates.js.map +1 -1
  130. package/dist/sessions/cli-sessions.d.ts +23 -6
  131. package/dist/sessions/cli-sessions.js +84 -84
  132. package/dist/sessions/cli-sessions.js.map +1 -1
  133. package/dist/sessions/completions.d.ts +38 -2
  134. package/dist/sessions/completions.js +348 -77
  135. package/dist/sessions/completions.js.map +1 -1
  136. package/dist/sessions/compress.d.ts +8 -0
  137. package/dist/sessions/compress.js +2 -2
  138. package/dist/sessions/compress.js.map +1 -1
  139. package/dist/sessions/drafts.d.ts +14 -10
  140. package/dist/sessions/drafts.js +38 -43
  141. package/dist/sessions/drafts.js.map +1 -1
  142. package/dist/sessions/export.d.ts +2 -0
  143. package/dist/sessions/export.js +6 -2
  144. package/dist/sessions/export.js.map +1 -1
  145. package/dist/sessions/gateway-sessions.d.ts +86 -0
  146. package/dist/sessions/gateway-sessions.js +914 -0
  147. package/dist/sessions/gateway-sessions.js.map +1 -0
  148. package/dist/sessions/gateway-watcher.d.ts +10 -11
  149. package/dist/sessions/gateway-watcher.js +25 -21
  150. package/dist/sessions/gateway-watcher.js.map +1 -1
  151. package/dist/sessions/goals.d.ts +31 -0
  152. package/dist/sessions/goals.js +75 -0
  153. package/dist/sessions/goals.js.map +1 -0
  154. package/dist/sessions/journal.d.ts +5 -79
  155. package/dist/sessions/journal.js +15 -606
  156. package/dist/sessions/journal.js.map +1 -1
  157. package/dist/sessions/list.d.ts +34 -18
  158. package/dist/sessions/list.js +161 -147
  159. package/dist/sessions/list.js.map +1 -1
  160. package/dist/sessions/merge.d.ts +77 -13
  161. package/dist/sessions/merge.js +315 -65
  162. package/dist/sessions/merge.js.map +1 -1
  163. package/dist/sessions/notices.d.ts +22 -0
  164. package/dist/sessions/notices.js +72 -0
  165. package/dist/sessions/notices.js.map +1 -0
  166. package/dist/sessions/pending.d.ts +10 -4
  167. package/dist/sessions/pending.js +83 -7
  168. package/dist/sessions/pending.js.map +1 -1
  169. package/dist/sessions/prefix-index.d.ts +85 -0
  170. package/dist/sessions/prefix-index.js +243 -0
  171. package/dist/sessions/prefix-index.js.map +1 -0
  172. package/dist/sessions/recovery.d.ts +4 -7
  173. package/dist/sessions/recovery.js +100 -283
  174. package/dist/sessions/recovery.js.map +1 -1
  175. package/dist/sessions/relay.d.ts +20 -20
  176. package/dist/sessions/relay.js +205 -127
  177. package/dist/sessions/relay.js.map +1 -1
  178. package/dist/sessions/service.d.ts +251 -78
  179. package/dist/sessions/service.js +1510 -564
  180. package/dist/sessions/service.js.map +1 -1
  181. package/dist/sessions/session.d.ts +20 -2
  182. package/dist/sessions/session.js +33 -5
  183. package/dist/sessions/session.js.map +1 -1
  184. package/dist/sessions/shares.d.ts +7 -6
  185. package/dist/sessions/shares.js +22 -45
  186. package/dist/sessions/shares.js.map +1 -1
  187. package/dist/sessions/source-kind.js +1 -0
  188. package/dist/sessions/source-kind.js.map +1 -1
  189. package/dist/sessions/state-db-identity.d.ts +18 -0
  190. package/dist/sessions/state-db-identity.js +67 -0
  191. package/dist/sessions/state-db-identity.js.map +1 -0
  192. package/dist/sessions/state-db-worker.js +24 -5
  193. package/dist/sessions/state-db-worker.js.map +1 -1
  194. package/dist/sessions/state-db.d.ts +157 -21
  195. package/dist/sessions/state-db.js +1145 -204
  196. package/dist/sessions/state-db.js.map +1 -1
  197. package/dist/sessions/state-saved.d.ts +1 -1
  198. package/dist/sessions/state-saved.js +35 -11
  199. package/dist/sessions/state-saved.js.map +1 -1
  200. package/dist/sessions/storage/backend.d.ts +171 -0
  201. package/dist/sessions/storage/backend.js +19 -0
  202. package/dist/sessions/storage/backend.js.map +1 -0
  203. package/dist/sessions/storage/conformance.d.ts +20 -0
  204. package/dist/sessions/storage/conformance.js +237 -0
  205. package/dist/sessions/storage/conformance.js.map +1 -0
  206. package/dist/sessions/storage/document.d.ts +16 -0
  207. package/dist/sessions/storage/document.js +44 -0
  208. package/dist/sessions/storage/document.js.map +1 -0
  209. package/dist/sessions/storage/json-documents.d.ts +25 -0
  210. package/dist/sessions/storage/json-documents.js +277 -0
  211. package/dist/sessions/storage/json-documents.js.map +1 -0
  212. package/dist/sessions/storage/json-files.d.ts +23 -0
  213. package/dist/sessions/storage/json-files.js +244 -0
  214. package/dist/sessions/storage/json-files.js.map +1 -0
  215. package/dist/sessions/storage/json-installation.d.ts +3 -0
  216. package/dist/sessions/storage/json-installation.js +31 -0
  217. package/dist/sessions/storage/json-installation.js.map +1 -0
  218. package/dist/sessions/storage/json-journal.d.ts +113 -0
  219. package/dist/sessions/storage/json-journal.js +756 -0
  220. package/dist/sessions/storage/json-journal.js.map +1 -0
  221. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  222. package/dist/sessions/storage/json-recovery.js +195 -0
  223. package/dist/sessions/storage/json-recovery.js.map +1 -0
  224. package/dist/sessions/storage/json.d.ts +82 -0
  225. package/dist/sessions/storage/json.js +350 -0
  226. package/dist/sessions/storage/json.js.map +1 -0
  227. package/dist/sessions/storage/row.d.ts +6 -0
  228. package/dist/sessions/storage/row.js +49 -0
  229. package/dist/sessions/storage/row.js.map +1 -0
  230. package/dist/sessions/storage/search-text.d.ts +4 -0
  231. package/dist/sessions/storage/search-text.js +16 -0
  232. package/dist/sessions/storage/search-text.js.map +1 -0
  233. package/dist/sessions/store.d.ts +67 -73
  234. package/dist/sessions/store.js +341 -607
  235. package/dist/sessions/store.js.map +1 -1
  236. package/dist/sessions/streams.d.ts +12 -7
  237. package/dist/sessions/streams.js +58 -15
  238. package/dist/sessions/streams.js.map +1 -1
  239. package/dist/sessions/titles.js +1 -1
  240. package/dist/sessions/titles.js.map +1 -1
  241. package/dist/sessions/todo.d.ts +5 -0
  242. package/dist/sessions/todo.js +28 -21
  243. package/dist/sessions/todo.js.map +1 -1
  244. package/dist/sessions/tool-display.d.ts +8 -5
  245. package/dist/sessions/tool-display.js +28 -16
  246. package/dist/sessions/tool-display.js.map +1 -1
  247. package/dist/sessions/turn-context.d.ts +10 -1
  248. package/dist/sessions/turn-context.js +13 -6
  249. package/dist/sessions/turn-context.js.map +1 -1
  250. package/dist/sessions/turn.d.ts +110 -22
  251. package/dist/sessions/turn.js +918 -380
  252. package/dist/sessions/turn.js.map +1 -1
  253. package/dist/sessions/wakeup-pause.d.ts +8 -13
  254. package/dist/sessions/wakeup-pause.js +31 -49
  255. package/dist/sessions/wakeup-pause.js.map +1 -1
  256. package/dist/sessions/window.js +13 -12
  257. package/dist/sessions/window.js.map +1 -1
  258. package/dist/settings.d.ts +19 -7
  259. package/dist/settings.js +179 -183
  260. package/dist/settings.js.map +1 -1
  261. package/dist/sidecar/client.d.ts +41 -4
  262. package/dist/sidecar/client.js +192 -42
  263. package/dist/sidecar/client.js.map +1 -1
  264. package/dist/sidecar/discover.d.ts +2 -1
  265. package/dist/sidecar/discover.js +93 -10
  266. package/dist/sidecar/discover.js.map +1 -1
  267. package/dist/sidecar/env.d.ts +4 -0
  268. package/dist/sidecar/env.js +3 -0
  269. package/dist/sidecar/env.js.map +1 -0
  270. package/dist/sidecar/fake.d.ts +6 -2
  271. package/dist/sidecar/fake.js +34 -4
  272. package/dist/sidecar/fake.js.map +1 -1
  273. package/dist/spa.d.ts +9 -4
  274. package/dist/spa.js +36 -12
  275. package/dist/spa.js.map +1 -1
  276. package/dist/startup-timing.d.ts +66 -0
  277. package/dist/startup-timing.js +129 -0
  278. package/dist/startup-timing.js.map +1 -0
  279. package/dist/test/env-file.d.ts +5 -0
  280. package/dist/test/env-file.js +88 -0
  281. package/dist/test/env-file.js.map +1 -0
  282. package/dist/test/fs-handles.d.ts +11 -0
  283. package/dist/test/fs-handles.js +31 -0
  284. package/dist/test/fs-handles.js.map +1 -0
  285. package/dist/test/harness.d.ts +1 -0
  286. package/dist/test/harness.js +1 -1
  287. package/dist/test/harness.js.map +1 -1
  288. package/dist/test/passkeys.d.ts +22 -0
  289. package/dist/test/passkeys.js +62 -0
  290. package/dist/test/passkeys.js.map +1 -0
  291. package/dist/test/storage.d.ts +15 -0
  292. package/dist/test/storage.js +26 -0
  293. package/dist/test/storage.js.map +1 -0
  294. package/dist/tools/commands.d.ts +6 -1
  295. package/dist/tools/commands.js +8 -2
  296. package/dist/tools/commands.js.map +1 -1
  297. package/dist/tools/connectors.d.ts +33 -0
  298. package/dist/tools/connectors.js +121 -0
  299. package/dist/tools/connectors.js.map +1 -0
  300. package/dist/tools/crons.d.ts +46 -5
  301. package/dist/tools/crons.js +100 -26
  302. package/dist/tools/crons.js.map +1 -1
  303. package/dist/tools/dashboard-plugins.d.ts +34 -5
  304. package/dist/tools/dashboard-plugins.js +146 -14
  305. package/dist/tools/dashboard-plugins.js.map +1 -1
  306. package/dist/tools/extensions.d.ts +5 -4
  307. package/dist/tools/extensions.js +213 -207
  308. package/dist/tools/extensions.js.map +1 -1
  309. package/dist/tools/gateway-restart.d.ts +82 -0
  310. package/dist/tools/gateway-restart.js +222 -0
  311. package/dist/tools/gateway-restart.js.map +1 -0
  312. package/dist/tools/health.d.ts +28 -4
  313. package/dist/tools/health.js +171 -50
  314. package/dist/tools/health.js.map +1 -1
  315. package/dist/tools/hygiene.d.ts +4 -3
  316. package/dist/tools/hygiene.js +23 -9
  317. package/dist/tools/hygiene.js.map +1 -1
  318. package/dist/tools/insights.d.ts +1 -1
  319. package/dist/tools/insights.js +2 -2
  320. package/dist/tools/insights.js.map +1 -1
  321. package/dist/tools/kanban.d.ts +4 -2
  322. package/dist/tools/kanban.js +28 -19
  323. package/dist/tools/kanban.js.map +1 -1
  324. package/dist/tools/mcp-health.js +74 -24
  325. package/dist/tools/mcp-health.js.map +1 -1
  326. package/dist/tools/mcp.js +19 -1
  327. package/dist/tools/mcp.js.map +1 -1
  328. package/dist/tools/memory-masks.d.ts +5 -0
  329. package/dist/tools/memory-masks.js +256 -0
  330. package/dist/tools/memory-masks.js.map +1 -0
  331. package/dist/tools/memory.d.ts +29 -3
  332. package/dist/tools/memory.js +69 -27
  333. package/dist/tools/memory.js.map +1 -1
  334. package/dist/tools/plugin-oauth.d.ts +14 -0
  335. package/dist/tools/plugin-oauth.js +53 -0
  336. package/dist/tools/plugin-oauth.js.map +1 -0
  337. package/dist/tools/plugin-settings.d.ts +15 -0
  338. package/dist/tools/plugin-settings.js +90 -0
  339. package/dist/tools/plugin-settings.js.map +1 -0
  340. package/dist/tools/prune-advice.d.ts +50 -0
  341. package/dist/tools/prune-advice.js +114 -0
  342. package/dist/tools/prune-advice.js.map +1 -0
  343. package/dist/tools/skills.d.ts +13 -5
  344. package/dist/tools/skills.js +53 -19
  345. package/dist/tools/skills.js.map +1 -1
  346. package/dist/tools/update-notifications.d.ts +102 -17
  347. package/dist/tools/update-notifications.js +400 -82
  348. package/dist/tools/update-notifications.js.map +1 -1
  349. package/dist/tools/update-rollback.d.ts +11 -0
  350. package/dist/tools/update-rollback.js +28 -0
  351. package/dist/tools/update-rollback.js.map +1 -0
  352. package/dist/tools/updates.d.ts +100 -33
  353. package/dist/tools/updates.js +1085 -345
  354. package/dist/tools/updates.js.map +1 -1
  355. package/dist/util.d.ts +2 -0
  356. package/dist/util.js +5 -0
  357. package/dist/util.js.map +1 -1
  358. package/dist/workspace/git.d.ts +16 -1
  359. package/dist/workspace/git.js +291 -11
  360. package/dist/workspace/git.js.map +1 -1
  361. package/dist/workspace/media.d.ts +12 -9
  362. package/dist/workspace/media.js +98 -38
  363. package/dist/workspace/media.js.map +1 -1
  364. package/dist/workspace/preview.js +4 -14
  365. package/dist/workspace/preview.js.map +1 -1
  366. package/dist/workspace/rollback.d.ts +8 -4
  367. package/dist/workspace/rollback.js +22 -12
  368. package/dist/workspace/rollback.js.map +1 -1
  369. package/dist/workspace/workspaces.d.ts +15 -19
  370. package/dist/workspace/workspaces.js +55 -127
  371. package/dist/workspace/workspaces.js.map +1 -1
  372. package/dist/workspace/worktrees.js +170 -32
  373. package/dist/workspace/worktrees.js.map +1 -1
  374. package/package.json +6 -4
  375. package/sidecar/agent_dependency.json +4 -4
  376. package/sidecar/agent_support.json +29 -0
  377. package/sidecar/scripts/record-fixtures.py +12 -3
  378. package/sidecar/scripts/replay_sidecar.py +8 -0
  379. package/sidecar/scripts/suite_runner.py +458 -0
  380. package/sidecar/scripts/test.sh +11 -5
  381. package/sidecar/talaria_sidecar/__init__.py +6 -1
  382. package/sidecar/talaria_sidecar/__main__.py +13 -4
  383. package/sidecar/talaria_sidecar/agent_mutation.py +1111 -0
  384. package/sidecar/talaria_sidecar/agent_services.py +1116 -0
  385. package/sidecar/talaria_sidecar/context_detail.py +59 -0
  386. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  387. package/sidecar/talaria_sidecar/home.py +133 -14
  388. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  389. package/sidecar/talaria_sidecar/methods/aux.py +211 -41
  390. package/sidecar/talaria_sidecar/methods/chat.py +1378 -200
  391. package/sidecar/talaria_sidecar/methods/checkpoints.py +29 -4
  392. package/sidecar/talaria_sidecar/methods/commands.py +483 -13
  393. package/sidecar/talaria_sidecar/methods/config.py +195 -6
  394. package/sidecar/talaria_sidecar/methods/connectors.py +375 -0
  395. package/sidecar/talaria_sidecar/methods/cron.py +374 -36
  396. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  397. package/sidecar/talaria_sidecar/methods/dashboard_plugins.py +301 -0
  398. package/sidecar/talaria_sidecar/methods/gateway.py +215 -55
  399. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +418 -0
  400. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  401. package/sidecar/talaria_sidecar/methods/kanban.py +82 -30
  402. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  403. package/sidecar/talaria_sidecar/methods/oauth.py +144 -13
  404. package/sidecar/talaria_sidecar/methods/plugin_catalog.py +198 -0
  405. package/sidecar/talaria_sidecar/methods/plugin_oauth.py +234 -0
  406. package/sidecar/talaria_sidecar/methods/process.py +229 -16
  407. package/sidecar/talaria_sidecar/methods/profiles.py +48 -9
  408. package/sidecar/talaria_sidecar/methods/providers.py +196 -26
  409. package/sidecar/talaria_sidecar/methods/runtime.py +71 -1
  410. package/sidecar/talaria_sidecar/methods/skills.py +83 -5
  411. package/sidecar/talaria_sidecar/methods/state_db.py +405 -112
  412. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  413. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  414. package/sidecar/talaria_sidecar/methods/toolsets.py +51 -0
  415. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  416. package/sidecar/talaria_sidecar/methods/worktree.py +145 -19
  417. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  418. package/sidecar/talaria_sidecar/rpc.py +70 -7
  419. package/sidecar/talaria_sidecar/runtime.py +122 -14
  420. package/sidecar/talaria_sidecar/speed.py +47 -0
  421. package/sidecar/talaria_sidecar/startup_timing.py +119 -0
  422. package/static/dist/FILES.txt +242 -215
  423. package/static/dist/THIRD_PARTY_LICENSES.md +6 -22
  424. package/static/dist/assets/AppShell-3isgvoro.js +8 -0
  425. package/static/dist/assets/Button-f4n6dapf.js +1 -0
  426. package/static/dist/assets/ChatPage-k7b6xfax.js +4 -0
  427. package/static/dist/assets/ConnectTargets-juqvt5lm.js +1 -0
  428. package/static/dist/assets/Dialog-hta5utr3.js +1 -0
  429. package/static/dist/assets/ExtensionRoute-zrhjn0wm.js +1 -0
  430. package/static/dist/assets/Field-b6ez48oz.js +1 -0
  431. package/static/dist/assets/HubRoute-iwvso2tz.js +2 -0
  432. package/static/dist/assets/LinkCheckContext-ix6zqqkk.js +1 -0
  433. package/static/dist/assets/Markdown-jdeth7zq.js +2 -0
  434. package/static/dist/assets/MarkdownRenderer-fbswsgld.js +302 -0
  435. package/static/dist/assets/MarkdownRenderer-i9o6nr38.css +1 -0
  436. package/static/dist/assets/OAuthSignIn-bpd8hom3.js +1 -0
  437. package/static/dist/assets/ResourceSwitch-odi6dy2o.js +1 -0
  438. package/static/dist/assets/RightPanel-eu6enlo6.js +1 -0
  439. package/static/dist/assets/Select-kr07928w.js +1 -0
  440. package/static/dist/assets/SessionListPanel-etb2i54l.js +1 -0
  441. package/static/dist/assets/SettingsLayout-eu3xxp23.js +1 -0
  442. package/static/dist/assets/States-icab19yg.js +1 -0
  443. package/static/dist/assets/TerminalPanel-hngmsr8b.js +16 -0
  444. package/static/dist/assets/Toaster-nb2uzjw1.js +1 -0
  445. package/static/dist/assets/_app-ga1moszl.js +1 -0
  446. package/static/dist/assets/_app.ext._extensionId-na62087t.js +1 -0
  447. package/static/dist/assets/_app.index-jypgdqoi.js +1 -0
  448. package/static/dist/assets/_app.insights-kabejdtm.js +1 -0
  449. package/static/dist/assets/_app.kanban-m19kfcmx.js +1 -0
  450. package/static/dist/assets/_app.logs-iw4m96sz.js +1 -0
  451. package/static/dist/assets/_app.memory-jq5frwcg.js +1 -0
  452. package/static/dist/assets/_app.profiles-kwa3m7rp.js +1 -0
  453. package/static/dist/assets/_app.session._sessionId-o5lyeipj.js +1 -0
  454. package/static/dist/assets/_app.settings-ckl8hdll.js +1 -0
  455. package/static/dist/assets/_app.settings._section-dtdjk98a.js +3 -0
  456. package/static/dist/assets/_app.skills-n016rihd.js +1 -0
  457. package/static/dist/assets/_app.tasks-n8zuqsqe.js +1 -0
  458. package/static/dist/assets/_app.todos-btew5v2s.js +1 -0
  459. package/static/dist/assets/_app.workspaces-lnfk95ch.js +1 -0
  460. package/static/dist/assets/abnfDiagram-VCTEODGH-dttheegb.js +1 -0
  461. package/static/dist/assets/{angular-html-nu6l31w3.js → angular-html-l28bd5fo.js} +1 -1
  462. package/static/dist/assets/{angular-ts-fbx4f8mm.js → angular-ts-ffzx1vj0.js} +1 -1
  463. package/static/dist/assets/{apl-bx2xr7dh.js → apl-b7h7mmy4.js} +1 -1
  464. package/static/dist/assets/{arc-7popfhbk.js → arc-fk1b21lm.js} +1 -1
  465. package/static/dist/assets/architecture-7GRP2DOG-ic435919.js +1 -0
  466. package/static/dist/assets/architectureDiagram-5GKGNRK7-8r3glgiq.js +36 -0
  467. package/static/dist/assets/archive-il7c6053.js +1 -0
  468. package/static/dist/assets/{astro-g77i97cb.js → astro-k117fy0t.js} +1 -1
  469. package/static/dist/assets/back-gu3nu279.js +1 -0
  470. package/static/dist/assets/{blade-boo0u92y.js → blade-npefsmn8.js} +1 -1
  471. package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-jenyos6c.js} +4 -4
  472. package/static/dist/assets/{c-cat0w3sc.js → c-2umg6kbn.js} +1 -1
  473. package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-nzlx7yc4.js} +1 -1
  474. package/static/dist/assets/cancel-bsnfo36u.js +1 -0
  475. package/static/dist/assets/channel-dsbtk01y.js +1 -0
  476. package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-g0cru63b.js} +20 -20
  477. package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-7rkswkjw.js} +1 -1
  478. package/static/dist/assets/chunk-DU6HZSFF-fn73jzdo.js +128 -0
  479. package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-oey8e72m.js} +1 -1
  480. package/static/dist/assets/{chunk-FOHPRMQF-opuflp73.js → chunk-FOHPRMQF-ipewusgj.js} +8 -8
  481. package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-l2a32wiy.js} +6 -6
  482. package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-o9dwzh8h.js} +1 -1
  483. package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-eblwj20b.js} +5 -5
  484. package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-o2mufspb.js} +1 -1
  485. package/static/dist/assets/chunk-OSK3NFVY-ijp9pw29.js +10 -0
  486. package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-hs9hqwl4.js} +1 -1
  487. package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-bp9rsqez.js} +1 -1
  488. package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-brkprov6.js} +1 -1
  489. package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-gmpv0pxz.js} +13 -13
  490. package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-oldm08fu.js} +4 -4
  491. package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-ooqilfv7.js} +6 -6
  492. package/static/dist/assets/{chunk-XXDRQBXY-n40pf9vy.js → chunk-XXDRQBXY-jkmnqu65.js} +1 -1
  493. package/static/dist/assets/classDiagram-ZZMXUADV-fvgcv24g.js +1 -0
  494. package/static/dist/assets/classDiagram-v2-VYDZK3BY-fvgcv24g.js +1 -0
  495. package/static/dist/assets/{cobol-go622aep.js → cobol-ssf9hkh7.js} +1 -1
  496. package/static/dist/assets/{coffee-jwo1xjhq.js → coffee-lsi9fwae.js} +1 -1
  497. package/static/dist/assets/copied-bz3gkazh.js +1 -0
  498. package/static/dist/assets/copy-jo006coq.js +1 -0
  499. package/static/dist/assets/cose-bilkent-JH36ORCC-layy2r27.js +1 -0
  500. package/static/dist/assets/{cpp-nre7j0kq.js → cpp-lfrh4y55.js} +1 -1
  501. package/static/dist/assets/{crystal-lviso2je.js → crystal-efh4pte7.js} +1 -1
  502. package/static/dist/assets/{css-ywz9j4hb.js → css-i82qdo89.js} +1 -1
  503. package/static/dist/assets/cynefin-OW5HDTMX-d47op7qs.js +1 -0
  504. package/static/dist/assets/cynefinDiagram-5FMLGOSQ-doa7594y.js +62 -0
  505. package/static/dist/assets/cytoscape.esm-fjn2algp.js +321 -0
  506. package/static/dist/assets/dagre-GXQ25YYZ-os768dp4.js +4 -0
  507. package/static/dist/assets/dagre-o5jmjz46.js +1 -0
  508. package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-fyi81nxd.js} +4 -4
  509. package/static/dist/assets/diagram-UQ7AKVKN-j3exggaq.js +41 -0
  510. package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-omo1en2l.js} +1 -1
  511. package/static/dist/assets/diagram-VX7I27RA-oro2cyu0.js +24 -0
  512. package/static/dist/assets/diagram-Z3DM3KII-byt9vdb7.js +24 -0
  513. package/static/dist/assets/dist-crc46di9.js +7 -0
  514. package/static/dist/assets/dist-etpoyscq.js +44 -0
  515. package/static/dist/assets/{dist-c4c2m3kv.js → dist-nb670eu6.js} +1 -1
  516. package/static/dist/assets/ebnfDiagram-PWID7BFC-ee3haugh.js +1 -0
  517. package/static/dist/assets/{edge-dvv35byi.js → edge-lrob635n.js} +1 -1
  518. package/static/dist/assets/{elixir-i2f1ki1g.js → elixir-kdpxeq93.js} +1 -1
  519. package/static/dist/assets/{elm-cpepyjm4.js → elm-c8irj9uh.js} +1 -1
  520. package/static/dist/assets/erDiagram-RLTQ6QDP-d1e93yff.js +99 -0
  521. package/static/dist/assets/{erb-idlyfheg.js → erb-flwsv146.js} +1 -1
  522. package/static/dist/assets/eventmodeling-NTZA5JFV-i1ihb40c.js +1 -0
  523. package/static/dist/assets/flowDiagram-HODETNUW-jahfpiod.js +1 -0
  524. package/static/dist/assets/folder-plus-ivoglq98.js +1 -0
  525. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-m7g0jud4.js} +1 -1
  526. package/static/dist/assets/{git-rebase-d9lpfpbn.js → git-rebase-bli19aoq.js} +1 -1
  527. package/static/dist/assets/gitGraph-4MIJSDKK-hzdj07yy.js +1 -0
  528. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-dm9agbna.js} +1 -1
  529. package/static/dist/assets/{glimmer-js-39i0urfb.js → glimmer-js-d4icg38p.js} +1 -1
  530. package/static/dist/assets/{glimmer-ts-gdtd6n73.js → glimmer-ts-bk75772u.js} +1 -1
  531. package/static/dist/assets/{glsl-o1djm5pp.js → glsl-lqormp2b.js} +1 -1
  532. package/static/dist/assets/{graphql-kj6rz55t.js → graphql-hw6s5qzd.js} +1 -1
  533. package/static/dist/assets/{hack-pe1qqr2i.js → hack-ozrv6l4d.js} +1 -1
  534. package/static/dist/assets/{haml-cknnbcqs.js → haml-gytfosdt.js} +1 -1
  535. package/static/dist/assets/{handlebars-hwq64orh.js → handlebars-b6g7jffd.js} +1 -1
  536. package/static/dist/assets/highlighted-body-KPVGNVTW-mmgz2czu.js +1 -0
  537. package/static/dist/assets/host-fr6lni1p.js +1 -0
  538. package/static/dist/assets/{html-derivative-h2i2pyjc.js → html-derivative-f1tqcroo.js} +1 -1
  539. package/static/dist/assets/{html-ha05xtvt.js → html-on6oj6yj.js} +1 -1
  540. package/static/dist/assets/{http-cf94zu27.js → http-odhufsl7.js} +1 -1
  541. package/static/dist/assets/{hurl-lslax568.js → hurl-mu680336.js} +1 -1
  542. package/static/dist/assets/index-73649w7o.js +10 -0
  543. package/static/dist/assets/index-lhf1fsdf.css +2 -0
  544. package/static/dist/assets/info-A6RAGUB7-lll1dqmm.js +1 -0
  545. package/static/dist/assets/infoDiagram-27XIBGKW-k7esc639.js +2 -0
  546. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-lgr6k9wo.js} +6 -6
  547. package/static/dist/assets/{java-ndjf2o89.js → java-nxbe8uju.js} +1 -1
  548. package/static/dist/assets/{javascript-coftlwy3.js → javascript-lz3kxcyz.js} +1 -1
  549. package/static/dist/assets/{jinja-etei9wy7.js → jinja-l5pk84gt.js} +1 -1
  550. package/static/dist/assets/{jison-hyekgft1.js → jison-jdutdwyy.js} +1 -1
  551. package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-ndmbxuqk.js} +1 -1
  552. package/static/dist/assets/{json-lok0yaho.js → json-dnge4ssf.js} +1 -1
  553. package/static/dist/assets/{jsx-b64w0nbx.js → jsx-c3lwhkep.js} +1 -1
  554. package/static/dist/assets/{jsx-runtime-eu92a5pu.js → jsx-runtime-o40ukmjl.js} +1 -1
  555. package/static/dist/assets/{julia-jxxzz2yn.js → julia-hwd4rrcb.js} +1 -1
  556. package/static/dist/assets/{just-mc29iw9t.js → just-nka3oxdx.js} +1 -1
  557. package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-mjxym0jp.js} +28 -28
  558. package/static/dist/assets/katex-cbdd1ohw.css +1 -0
  559. package/static/dist/assets/katex-pbij2oo6.js +257 -0
  560. package/static/dist/assets/{latex-pafa03ft.js → latex-iqmr7e40.js} +1 -1
  561. package/static/dist/assets/lazyRouteComponent-cwb6l7xh.js +1 -0
  562. package/static/dist/assets/{line-meizqsvn.js → line-mtr5k6il.js} +1 -1
  563. package/static/dist/assets/linear-jlwrvvy6.js +1 -0
  564. package/static/dist/assets/link-iadw4ghw.js +2 -0
  565. package/static/dist/assets/{liquid-hz5c2pov.js → liquid-okmk9tbp.js} +1 -1
  566. package/static/dist/assets/locale-cs-n41hptse.js +5 -0
  567. package/static/dist/assets/locale-de-k7ht47ew.js +5 -0
  568. package/static/dist/assets/locale-es-ih078lco.js +5 -0
  569. package/static/dist/assets/locale-fr-ojabnf41.js +5 -0
  570. package/static/dist/assets/locale-it-kb96jufy.js +5 -0
  571. package/static/dist/assets/locale-ja-jpsp3xs1.js +7 -0
  572. package/static/dist/assets/locale-ko-cpzuy0n7.js +5 -0
  573. package/static/dist/assets/locale-pl-dbap4leu.js +5 -0
  574. package/static/dist/assets/locale-pt-nci7w72x.js +5 -0
  575. package/static/dist/assets/locale-ru-soghe4cs.js +7 -0
  576. package/static/dist/assets/locale-tr-ks00mu68.js +5 -0
  577. package/static/dist/assets/locale-vi-ij7twfm6.js +5 -0
  578. package/static/dist/assets/locale-zh-Hant-feakwr7a.js +5 -0
  579. package/static/dist/assets/locale-zh-ren0vjyv.js +7 -0
  580. package/static/dist/assets/login-lkhtzfcl.js +1 -0
  581. package/static/dist/assets/{lua-leyf8mlb.js → lua-krx4xj5h.js} +1 -1
  582. package/static/dist/assets/{marko-cn1fzpdv.js → marko-gbvpfehz.js} +1 -1
  583. package/static/dist/assets/{mdc-huhy88df.js → mdc-c21zlyi5.js} +1 -1
  584. package/static/dist/assets/mermaid-HWGCJPDP-jx0q5dm2.js +1 -0
  585. package/static/dist/assets/mermaid-parser.core-k91vdnvi.js +7 -0
  586. package/static/dist/assets/micromark-factory-space-fnmnpi6s.js +1 -0
  587. package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-ehztfea4.js} +28 -28
  588. package/static/dist/assets/{nginx-e7lvhsmn.js → nginx-f3d68ed4.js} +1 -1
  589. package/static/dist/assets/{nim-dun5d8mk.js → nim-dw0jcy70.js} +1 -1
  590. package/static/dist/assets/onboarding-f67z0a2r.js +1 -0
  591. package/static/dist/assets/packet-AYTQ26CC-ok2xrblz.js +1 -0
  592. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  593. package/static/dist/assets/pegDiagram-XKGWAZYB-ec3rpj0m.js +1 -0
  594. package/static/dist/assets/{perl-bj3oqtet.js → perl-3c0r45c8.js} +1 -1
  595. package/static/dist/assets/{php-buzp12r0.js → php-rznhidvp.js} +1 -1
  596. package/static/dist/assets/pie-WAS4IAKB-iwkir23u.js +1 -0
  597. package/static/dist/assets/pieDiagram-E7YTZNPT-fcpqpekh.js +39 -0
  598. package/static/dist/assets/{pug-mjad7he9.js → pug-f2le4f3y.js} +1 -1
  599. package/static/dist/assets/{qml-d1gdp10s.js → qml-e2wbq9cr.js} +1 -1
  600. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-lqsync43.js} +1 -1
  601. package/static/dist/assets/queryKeys-ed4n2egc.js +9 -0
  602. package/static/dist/assets/{r-o3r9c7ao.js → r-epfzuw6q.js} +1 -1
  603. package/static/dist/assets/radar-RG4KPBEZ-hje4jbk6.js +1 -0
  604. package/static/dist/assets/railroad-74A4TZTK-gll9n6qi.js +1 -0
  605. package/static/dist/assets/railroad-abnf-HS5TGJTU-o6h6lpn4.js +1 -0
  606. package/static/dist/assets/railroad-ebnf-LZEXJU2U-juaefj6o.js +1 -0
  607. package/static/dist/assets/railroad-peg-WCYAUIDC-cgfhvpah.js +1 -0
  608. package/static/dist/assets/railroadDiagram-O6MQD6OU-lp3w7awf.js +1 -0
  609. package/static/dist/assets/{razor-ilc585yr.js → razor-pea1u1d0.js} +1 -1
  610. package/static/dist/assets/refresh-cw-iccm7a4e.js +1 -0
  611. package/static/dist/assets/refresh-i9ya9dvl.js +1 -0
  612. package/static/dist/assets/{regexp-mpjtcvwm.js → regexp-hzte0a5x.js} +1 -1
  613. package/static/dist/assets/registry-g7w3353u.js +1 -0
  614. package/static/dist/assets/requirementDiagram-BXWQKSXE-ha8eibcf.js +84 -0
  615. package/static/dist/assets/rolldown-runtime-bnnw9q4a.js +1 -0
  616. package/static/dist/assets/{rst-jrkwjdyi.js → rst-m6tugj5o.js} +1 -1
  617. package/static/dist/assets/{ruby-n4tjcbpz.js → ruby-l19vq3w1.js} +1 -1
  618. package/static/dist/assets/runtime-nlxpnegr.js +1 -0
  619. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-oxf31pr9.js} +1 -1
  620. package/static/dist/assets/{sas-hjo6y93k.js → sas-ktdxo397.js} +1 -1
  621. package/static/dist/assets/{scss-ncidjv48.js → scss-kn3oxfwf.js} +1 -1
  622. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-jiq8lhyo.js} +11 -11
  623. package/static/dist/assets/settings_section_extensions_title-fadc7o65.js +1 -0
  624. package/static/dist/assets/share._token-o76ki0ly.js +2 -0
  625. package/static/dist/assets/{shellscript-m8whjpqd.js → shellscript-l100jtrg.js} +1 -1
  626. package/static/dist/assets/{shellsession-i5bj6syz.js → shellsession-m5qukwow.js} +1 -1
  627. package/static/dist/assets/sizeCapture-INFHLROL-dk1io13w.js +1 -0
  628. package/static/dist/assets/sliders-horizontal-ddcs88av.js +1 -0
  629. package/static/dist/assets/{soy-hhh36ysq.js → soy-mexlscur.js} +1 -1
  630. package/static/dist/assets/{sql-j21bzcl6.js → sql-o0ho5suh.js} +1 -1
  631. package/static/dist/assets/src-h89s0hs4.js +1 -0
  632. package/static/dist/assets/{stata-bbu2onh1.js → stata-be9ck1q3.js} +1 -1
  633. package/static/dist/assets/stateDiagram-D77RDMKH-f3fslnon.js +1 -0
  634. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hlt6yphw.js +1 -0
  635. package/static/dist/assets/{surrealql-l5vczuco.js → surrealql-gk1xkkyy.js} +1 -1
  636. package/static/dist/assets/{svelte-idn422g6.js → svelte-k6ecjilf.js} +1 -1
  637. package/static/dist/assets/swimlanes-42K2YHIH-k1hmk7jf.js +1 -0
  638. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-hd5t9tnt.js +8 -0
  639. package/static/dist/assets/{templ-e22qou3c.js → templ-gf3bftk6.js} +1 -1
  640. package/static/dist/assets/{tex-deqpc5ee.js → tex-d21fqv1z.js} +1 -1
  641. package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-n3ncfy9r.js} +8 -8
  642. package/static/dist/assets/toast-liparz2j.js +56 -0
  643. package/static/dist/assets/treeView-Q6P3EWNA-l3y2pejt.js +1 -0
  644. package/static/dist/assets/treemap-WGGIJYW6-jown9axh.js +1 -0
  645. package/static/dist/assets/{ts-tags-ctnibc27.js → ts-tags-n0g61gr6.js} +1 -1
  646. package/static/dist/assets/{tsx-i163n32g.js → tsx-cvv7wv6q.js} +1 -1
  647. package/static/dist/assets/{twig-x0fi7wm8.js → twig-n1f67204.js} +1 -1
  648. package/static/dist/assets/{typescript-g18c34xz.js → typescript-og7o55cx.js} +1 -1
  649. package/static/dist/assets/untitled-mj86r4gz.js +1 -0
  650. package/static/dist/assets/useDebounced-g8r0ggve.js +1 -0
  651. package/static/dist/assets/useForm-o4uu74mx.js +1 -0
  652. package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-ftsveu84.js} +1 -1
  653. package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-crqueqga.js} +1 -1
  654. package/static/dist/assets/vennDiagram-4TSXK5OY-dh59uq1c.js +34 -0
  655. package/static/dist/assets/{vue-mwu5l1bb.js → vue-ej4xpwqw.js} +1 -1
  656. package/static/dist/assets/{vue-html-nskj3du0.js → vue-html-d48xsjuo.js} +1 -1
  657. package/static/dist/assets/{vue-vine-cqe77smq.js → vue-vine-pev88ip6.js} +1 -1
  658. package/static/dist/assets/wardley-WFR3VGLG-e6d74b0j.js +1 -0
  659. package/static/dist/assets/wardleyDiagram-VM6X3IG4-hnofh78f.js +78 -0
  660. package/static/dist/assets/with-selector-nu1b5z0v.js +1 -0
  661. package/static/dist/assets/{xml-gau7gvrv.js → xml-ifxp6gqb.js} +1 -1
  662. package/static/dist/assets/{xsl-ew09zq8b.js → xsl-fug2cckc.js} +1 -1
  663. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-bpymjelb.js} +6 -6
  664. package/static/dist/assets/{yaml-ofnrxtvf.js → yaml-fjmafnlo.js} +1 -1
  665. package/static/dist/extension-sdk.js +1 -1
  666. package/static/dist/index.html +1 -1
  667. package/static/dist/sw.js +180 -136
  668. package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
  669. package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
  670. package/static/dist/assets/Dialog-g355qxhp.js +0 -1
  671. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
  672. package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
  673. package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
  674. package/static/dist/assets/Markdown-lwviulmg.css +0 -1
  675. package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
  676. package/static/dist/assets/Select-kddq75qc.js +0 -1
  677. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
  678. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
  679. package/static/dist/assets/States-eq6fe29m.js +0 -1
  680. package/static/dist/assets/Toaster-pc247u9f.js +0 -1
  681. package/static/dist/assets/_app-f8n1i0m1.js +0 -1
  682. package/static/dist/assets/_app.ext._extensionId-gfbzpoou.js +0 -1
  683. package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
  684. package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
  685. package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
  686. package/static/dist/assets/_app.logs-i69qds98.js +0 -1
  687. package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
  688. package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
  689. package/static/dist/assets/_app.session._sessionId-fn1bhde6.js +0 -1
  690. package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
  691. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
  692. package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
  693. package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
  694. package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
  695. package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
  696. package/static/dist/assets/abnfDiagram-VCTEODGH-c8kdixke.js +0 -1
  697. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
  698. package/static/dist/assets/architectureDiagram-5GKGNRK7-cku06x1p.js +0 -36
  699. package/static/dist/assets/back-h7aun7ew.js +0 -1
  700. package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
  701. package/static/dist/assets/channel-b2zo73z1.js +0 -1
  702. package/static/dist/assets/chunk-DU6HZSFF-ofajdixh.js +0 -128
  703. package/static/dist/assets/chunk-OSK3NFVY-e7gkikcw.js +0 -10
  704. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
  705. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
  706. package/static/dist/assets/copied-hcplv46p.js +0 -1
  707. package/static/dist/assets/copy-magtofyp.js +0 -1
  708. package/static/dist/assets/cose-bilkent-JH36ORCC-eclcy6ja.js +0 -1
  709. package/static/dist/assets/cynefin-OW5HDTMX-siqlulkl.js +0 -1
  710. package/static/dist/assets/cynefinDiagram-5FMLGOSQ-i648tah9.js +0 -62
  711. package/static/dist/assets/cytoscape.esm-bqu53u12.js +0 -321
  712. package/static/dist/assets/dagre-GXQ25YYZ-mwn6uc17.js +0 -4
  713. package/static/dist/assets/dagre-lkoknbjn.js +0 -1
  714. package/static/dist/assets/diagram-UQ7AKVKN-mekq7zgq.js +0 -41
  715. package/static/dist/assets/diagram-VX7I27RA-xe6r5qfw.js +0 -24
  716. package/static/dist/assets/diagram-Z3DM3KII-nq07dg7k.js +0 -24
  717. package/static/dist/assets/ebnfDiagram-PWID7BFC-c62ipm3g.js +0 -1
  718. package/static/dist/assets/endpoints-klbxod4t.js +0 -64
  719. package/static/dist/assets/erDiagram-RLTQ6QDP-h39kmnzy.js +0 -99
  720. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
  721. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
  722. package/static/dist/assets/gitGraph-4MIJSDKK-fr2i7caa.js +0 -1
  723. package/static/dist/assets/highlighted-body-KPVGNVTW-jwpop9p8.js +0 -1
  724. package/static/dist/assets/host-i828xuo7.js +0 -1
  725. package/static/dist/assets/index-eh8zilnv.css +0 -2
  726. package/static/dist/assets/index-r648f4o1.js +0 -11
  727. package/static/dist/assets/info-A6RAGUB7-h11x0dxo.js +0 -1
  728. package/static/dist/assets/infoDiagram-27XIBGKW-dfh47dw9.js +0 -2
  729. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
  730. package/static/dist/assets/linear-becwlnhk.js +0 -1
  731. package/static/dist/assets/link-oot2yn73.js +0 -1
  732. package/static/dist/assets/login-gu28l9ou.js +0 -1
  733. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
  734. package/static/dist/assets/mermaid-parser.core-ntne45i9.js +0 -7
  735. package/static/dist/assets/not-found-ccnnac39.js +0 -1
  736. package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
  737. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
  738. package/static/dist/assets/packet-AYTQ26CC-l6jdka8p.js +0 -1
  739. package/static/dist/assets/pegDiagram-XKGWAZYB-g1er3bxj.js +0 -1
  740. package/static/dist/assets/pie-WAS4IAKB-i0ylcrt9.js +0 -1
  741. package/static/dist/assets/pieDiagram-E7YTZNPT-fbz1e55z.js +0 -39
  742. package/static/dist/assets/radar-RG4KPBEZ-f9b70aa4.js +0 -1
  743. package/static/dist/assets/railroad-74A4TZTK-lgsuoeda.js +0 -1
  744. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
  745. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
  746. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
  747. package/static/dist/assets/railroadDiagram-O6MQD6OU-eqpy9fnz.js +0 -1
  748. package/static/dist/assets/refresh-abtu93ke.js +0 -1
  749. package/static/dist/assets/refresh-cw-hlwb02qv.js +0 -1
  750. package/static/dist/assets/requirementDiagram-BXWQKSXE-ogf6puhi.js +0 -84
  751. package/static/dist/assets/rolldown-runtime-b9miacsj.js +0 -1
  752. package/static/dist/assets/root-mbklpuv4.js +0 -1
  753. package/static/dist/assets/saved-girnabkf.js +0 -1
  754. package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
  755. package/static/dist/assets/share._token-kl0r7ido.js +0 -2
  756. package/static/dist/assets/sizeCapture-INFHLROL-g612l48j.js +0 -1
  757. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
  758. package/static/dist/assets/src-hjyib4mu.js +0 -1
  759. package/static/dist/assets/sse-iwku0sjo.js +0 -1
  760. package/static/dist/assets/stateDiagram-D77RDMKH-fyoznpcs.js +0 -1
  761. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
  762. package/static/dist/assets/swimlanes-42K2YHIH-oefldhfy.js +0 -1
  763. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
  764. package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
  765. package/static/dist/assets/treeView-Q6P3EWNA-f7hcci5o.js +0 -1
  766. package/static/dist/assets/treemap-WGGIJYW6-nl85071a.js +0 -1
  767. package/static/dist/assets/units-kgxzxfhi.js +0 -1
  768. package/static/dist/assets/untitled-cikfjntn.js +0 -1
  769. package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
  770. package/static/dist/assets/useLocale-o59tbiea.js +0 -1
  771. package/static/dist/assets/vennDiagram-4TSXK5OY-h5hzbk0n.js +0 -34
  772. package/static/dist/assets/wardley-WFR3VGLG-jftvpm3e.js +0 -1
  773. package/static/dist/assets/wardleyDiagram-VM6X3IG4-hedh85ge.js +0 -78
  774. /package/static/dist/assets/{ChatPage-gmu5c7lf.css → TerminalPanel-gmu5c7lf.css} +0 -0
@@ -1,28 +1,33 @@
1
+ import { unavailableContextDetail } from '@maudecode/talaria-web-contracts';
2
+ import { filterAsync } from '../util.js';
3
+ import { statSignature } from '../fs/stat.js';
4
+ import { SessionPrefixIndex } from './prefix-index.js';
1
5
  import { str } from '../util.js';
2
- import { randomUUID } from 'node:crypto';
6
+ import { createHmac, randomUUID } from 'node:crypto';
3
7
  import { rmSync } from 'node:fs';
4
8
  import { buildActiveTurnToken, copyJson, redactSessionData, redactValue, stripPublicInternalFields, withSceneToolDisplay } from '../redact.js';
5
9
  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';
10
+ import { allSessions, buildSessionListPayload, reconcileLocalSessionRows, sessionLineageIds, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches, sessionSearchPreview, sessionSearchTerms } from './list.js';
7
11
  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';
12
+ import { contextUsage, COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, forgetContextFigures, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom, WORKTREE_RETAINED_KEY } from './session.js';
13
+ import { SessionBusy, SessionNotFound } from './store.js';
14
+ import { UNSETTLED_TODO_KEY, attachTodoSnapshot, attachTodoState } from './todo.js';
11
15
  import { isClaudeCodeSessionId } from './claude-code.js';
12
16
  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';
17
+ import { stateDbWebDiagnosticIdsAsync, agentSessionRowsExistingAsync, CANONICAL_READ_ONLY_REASON, OWNERSHIP_UNAVAILABLE_REASON, CONVERSATION_ROUND_THRESHOLD, emptyLineageReport, stateDbSessionReadAsync, stateDbSessionSourcesAsync, stateDbTimestampSeconds, withStateDbListingReads } from './state-db.js';
14
18
  import { completionIncomplete, fallbackHandoffSummary, HANDOFF_SYSTEM_PROMPT, handoffMarker, handoffPayload, handoffTranscript, messageHandoffPayload, sameHandoff } from './handoff.js';
15
19
  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';
20
+ import { chatReadiness, runtimeFailureDetails, SidecarError } from '../sidecar/client.js';
21
+ import { agentSteerText, ApiMessageSummary, appendStateDbIdentityRows, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, withDataUseRequirements, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, newestSettledReply, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withMirrorSources, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
18
22
  import { withBackgroundUpdates } from './background-updates.js';
23
+ import { markedStateDbGeneration, stateDbIdentityKeys, stampStateDbGeneration, stateDbMessageRepresented } from './state-db-identity.js';
19
24
  import { sourceKind } from './source-kind.js';
20
25
  import { withBackgroundLinks } from './background-tasks.js';
21
- import { messagesForLimitedPayload, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
26
+ import { messagesForLimitedPayload, messageCountsAsRenderable, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
22
27
  import { redactText } from '../redact.js';
23
28
  import { workspaceDisplayName } from '../workspace/workspaces.js';
24
29
  import { buildShareSnapshot } from './shares.js';
25
- import { mediaAnchorRoot, mediaRefPath, mediaTarget } from '../workspace/media.js';
30
+ import { mediaAnchorRootResolved, resolveMediaAccess, mediaRefPath, mediaTarget } from '../workspace/media.js';
26
31
  import { projectMediaRefs } from '../workspace/media-refs.js';
27
32
  import { loadGatewaySessionIdentityMap } from './list.js';
28
33
  import { join } from 'node:path';
@@ -38,11 +43,16 @@ export class HttpFailure extends Error {
38
43
  }
39
44
  /**
40
45
  * Python `_agent_runtime_barrier_response`: a stale local Agent checkout is refused with a typed 409 before the caller
41
- * mutates anything. Any other failure of the check is not a verdict and lets the caller proceed.
46
+ * mutates anything. An unavailable connection is refused before dispatch; other check failures are not a verdict
47
+ * and let the caller proceed.
42
48
  */
43
49
  export async function ensureAgentRuntimeCurrent(sidecar) {
44
50
  if (!sidecar)
45
51
  return;
52
+ // Runtime RPCs bypass the client's ready gate and can queue behind an unfinished handshake.
53
+ if (chatReadiness(sidecar).condition === 'sidecar_unavailable') {
54
+ throw new HttpFailure(503, 'The Agent sidecar is not ready; retry once it finishes starting.', { condition: 'sidecar_unavailable' });
55
+ }
46
56
  try {
47
57
  await sidecar.call('runtime.ensure_current', {});
48
58
  }
@@ -51,8 +61,33 @@ export async function ensureAgentRuntimeCurrent(sidecar) {
51
61
  throw staleRuntimeFailure(error);
52
62
  }
53
63
  }
64
+ /** The health probe is also the browser's server-reachability probe (30 s), so the readiness check answers well inside it. */
65
+ const CHAT_READINESS_CHECK_TIMEOUT_MS = 5_000;
66
+ /** TAL-1254: how soon a worktree lock the sidecar could not confirm is tried again. */
67
+ export const WORKTREE_RETAIN_RETRY_MS = 60_000;
68
+ /**
69
+ * TAL-951: whether a chat turn would be admitted now. Ready and source-stale incompatible sidecars both run turn
70
+ * admission's live check: recovery state can change after the handshake. A check
71
+ * that cannot answer fails closed as `sidecar_unavailable`.
72
+ */
73
+ export async function currentChatReadiness(sidecar) {
74
+ const readiness = chatReadiness(sidecar);
75
+ if (!sidecar || (!readiness.available && readiness.condition !== 'agent_runtime_stale'))
76
+ return readiness;
77
+ try {
78
+ await sidecar.call('runtime.ensure_current', {}, { timeoutMs: CHAT_READINESS_CHECK_TIMEOUT_MS });
79
+ }
80
+ catch (error) {
81
+ if (error instanceof SidecarError && error.condition === 'agent_runtime_stale')
82
+ return { available: false, ...runtimeFailureDetails(error.condition, error.data), condition: 'agent_runtime_stale' };
83
+ return { available: false, condition: error instanceof SidecarError && error.condition === 'agent_incompatible' ? 'agent_incompatible' : 'sidecar_unavailable' };
84
+ }
85
+ // The live guard can clear after a rollback, but the client's incompatible admission gate still refuses chat.
86
+ return readiness.available ? readiness : { available: false, ...runtimeFailureDetails('agent_runtime_stale'), condition: 'agent_runtime_stale' };
87
+ }
54
88
  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 } : {}) });
89
+ const { message, ...details } = runtimeFailureDetails(error.condition, error.data);
90
+ return new HttpFailure(409, message ?? error.message, details);
56
91
  }
57
92
  /** TAL-536: the most of a dead run's journal stale-stream cleanup reads (Python's recovery window). */
58
93
  const RECOVERY_JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
@@ -60,29 +95,34 @@ const RECOVERY_JOURNAL_MAX_ROWS = 65536;
60
95
  const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
61
96
  export class SessionService {
62
97
  deps;
98
+ owner = null;
63
99
  /** TAL-255: one manual compression worker per session, shared by every compress route. */
64
100
  compressionJobs;
101
+ /** TAL-1005: the turns now waiting on their session's compression job before they read its history. */
102
+ compactionWaiters = new Set();
103
+ worktreeRetainRetry = null;
104
+ worktreesUnconfirmed = 0;
65
105
  constructor(deps) {
66
106
  this.deps = deps;
67
107
  this.compressionJobs = new CompressionJobs();
68
108
  }
69
109
  get store() { return this.deps.store; }
70
110
  /**
71
- * TAL-577: user-turn admission shared by `chat.start` and server-side wakeups, after `ensureAgentRuntimeCurrent`.
111
+ * User-turn admission shared by `chat.start` and automatic turns through `TurnRunner.startServerTurn`.
72
112
  * A requested workspace must be trusted; the session's own one, when its directory is gone, falls back to the last one.
73
113
  */
74
- turnWorkspace(s, requested) {
114
+ async turnWorkspace(s, requested) {
75
115
  const ws = this.deps.workspaces;
76
116
  if (requested !== undefined && requested !== null && requested !== '') {
77
117
  try {
78
- return ws.resolveTrusted(str(requested), s.profile);
118
+ return (await ws.resolveTrusted(str(requested), s.profile));
79
119
  }
80
120
  catch (error) {
81
121
  throw new HttpFailure(400, error.message);
82
122
  }
83
123
  }
84
124
  try {
85
- const [workspace, recovered] = ws.resolveImplicitWithRecovery(s.workspace, (p) => ws.lastWorkspace(p), s.profile);
125
+ const [workspace, recovered] = (await ws.resolveImplicitWithRecovery(s.workspace, async (p) => (await ws.lastWorkspace(p)), s.profile));
86
126
  if (recovered)
87
127
  s.workspace = workspace;
88
128
  return workspace;
@@ -91,7 +131,7 @@ export class SessionService {
91
131
  throw new HttpFailure(400, error.message);
92
132
  }
93
133
  }
94
- /** A `@provider:` id is split; any other model an explicit pick did not just choose is checked against the catalog (TAL-542). */
134
+ /** Split qualified ids and opportunistically repair against fresh evidence; missing discovery never delays a turn. */
95
135
  turnModel(s, body = {}) {
96
136
  const requestedModel = str(body.model) || s.model;
97
137
  const requestedProvider = 'model_provider' in body ? body.model_provider : s.model_provider;
@@ -105,18 +145,19 @@ export class SessionService {
105
145
  * TAL-186: rewrites a session's media references to their URLs. A local path is rewritten only when `/api/media`
106
146
  * serves it for this session, under the same resolution, so every rewritten reference loads.
107
147
  */
108
- mediaProjector(s) {
148
+ async mediaProjector(s) {
109
149
  const media = this.deps.media;
150
+ const access = media ? await resolveMediaAccess(media.access) : null;
110
151
  const served = new Map();
111
152
  const localUrl = (path) => {
112
- if (!media?.localIo(s.profile))
153
+ if (!media?.localIo(s.profile) || !access)
113
154
  return null;
114
155
  if (served.has(path))
115
156
  return served.get(path) ?? null;
116
157
  let url = null;
117
158
  try {
118
159
  const target = mediaTarget(mediaRefPath(path));
119
- if (mediaAnchorRoot(target, s, media.access))
160
+ if (mediaAnchorRootResolved(target, s, access))
120
161
  url = `./api/media?${new URLSearchParams({ path: target, session_id: s.session_id }).toString()}`;
121
162
  }
122
163
  catch { /* an invalid path stays text */ }
@@ -143,7 +184,7 @@ export class SessionService {
143
184
  * not slip past the profile check into a handler that trims and trusts it. A non-empty id that is still unsafe after
144
185
  * trimming can name no session and is refused here instead of falling through.
145
186
  */
146
- sessionIdVisible(raw) {
187
+ async sessionIdVisible(raw) {
147
188
  if (typeof raw !== 'string' || !raw.trim())
148
189
  return true;
149
190
  const sid = raw.trim();
@@ -151,62 +192,112 @@ export class SessionService {
151
192
  return false;
152
193
  let session;
153
194
  try {
154
- session = this.store.get(sid, { metadataOnly: true });
195
+ session = (await this.store.get(sid, { metadataOnly: true }));
155
196
  }
156
197
  catch {
157
198
  return true;
158
199
  }
159
200
  return this.visibleToActiveProfile(session.profile);
160
201
  }
202
+ localTargets = null;
203
+ /** One receipt-backed physical target for every operation on a local conversation alias, read on the state.db worker. */
204
+ async resolveSessionId(sid) {
205
+ try {
206
+ const path = this.stateDbPath(null);
207
+ const stamp = () => JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]);
208
+ const key = stamp();
209
+ if (this.localTargets?.key !== key)
210
+ this.localTargets = { key, ids: new Map() };
211
+ const cached = this.localTargets.ids.get(sid);
212
+ if (cached)
213
+ return cached;
214
+ const { target, read } = await stateDbSessionReadAsync({ target: sid, read: false }, 'stateDbLocalTargetRead', path, sid);
215
+ // A failed or changing read cannot seed a routing cache.
216
+ const targets = this.localTargets;
217
+ if (read && targets.key === key && stamp() === key) {
218
+ if (targets.ids.size >= 500)
219
+ targets.ids.clear();
220
+ targets.ids.set(sid, target);
221
+ }
222
+ return target;
223
+ }
224
+ catch {
225
+ return sid;
226
+ }
227
+ }
161
228
  /** Python `_lookup_cli_session_metadata(...).get('read_only')` plus the persisted sidecar flag and a not-claimable foreign owner. */
162
- isReadOnlyImport(sid) {
229
+ async isReadOnlyImport(sid, reads = this.sessionGateReads(sid)) {
163
230
  try {
164
- const s = this.store.get(sid, { metadataOnly: true });
165
- if (this.isReadOnly(s))
231
+ const s = (await this.store.get(sid, { metadataOnly: true }));
232
+ if (await this.loadedReadOnlyReason(s, undefined, reads))
166
233
  return true;
167
234
  }
168
235
  catch {
169
236
  // No sidecar: a foreign state.db transcript whose owner refuses claiming is read-only too.
170
- const { session, reason } = this.claimOrSynthesizeCliSession(sid);
237
+ const { session, reason } = (await this.claimOrSynthesizeCliSession(sid, undefined, reads));
171
238
  if (session && reason === 'not_claimable')
172
239
  return true;
173
240
  }
174
- const meta = this.lookupCliMeta(sid);
241
+ const meta = await reads.meta();
175
242
  return meta !== null && Boolean(meta.read_only);
176
243
  }
177
244
  /** Python `_session_is_subagent_view_only`: a delegated child by any signal — the persisted sidecar or the state.db row. */
178
- isSubagentViewOnly(sid) {
245
+ async isSubagentViewOnly(sid, reads = this.sessionGateReads(sid)) {
246
+ let profile = this.deps.activeProfile();
179
247
  try {
180
- const s = this.store.get(sid);
248
+ const s = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false }));
249
+ profile = s.profile || 'default';
181
250
  if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
182
251
  return true;
183
252
  }
184
253
  catch { /* no sidecar */ }
185
- const meta = this.lookupCliMeta(sid);
186
- return meta !== null && str(meta.source_tag || meta.raw_source || meta.source).trim().toLowerCase() === 'subagent';
254
+ // Admission needs authority, not the sidebar's visibility, message counts or enrichment.
255
+ const owners = await reads.owners(profile);
256
+ if (!owners)
257
+ throw new HttpFailure(403, OWNERSHIP_UNAVAILABLE_REASON);
258
+ const owner = owners.get(sid);
259
+ return str(typeof owner === 'object' ? owner.source : owner).trim().toLowerCase() === 'subagent';
260
+ }
261
+ /** TAL-358: batched `sessions.source` owners from the active profile's state.db, read on the state.db worker (TAL-923). */
262
+ stateDbSources = async (ids, profile = this.deps.activeProfile()) => {
263
+ try {
264
+ return await stateDbSessionSourcesAsync(this.stateDbPath(profile), ids);
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ };
270
+ /** The owners of `byProfile`'s ids, read up front, as the synchronous lookup `withOwnerLocks` takes. */
271
+ async ownerSources(byProfile, read = this.stateDbSources) {
272
+ const owners = new Map(await Promise.all([...byProfile].map(async ([profile, ids]) => [profile, ids.size ? await read([...ids], profile) : new Map()])));
273
+ return (_ids, profile = this.deps.activeProfile()) => owners.get(profile) ?? null;
274
+ }
275
+ /** `withOwnerLocks` with the owners read on the state.db worker, so a gate or payload never opens state.db on this thread. */
276
+ async ownerLocked(rows, read) {
277
+ return withOwnerLocks(rows, await this.ownerSources(ownerLockCandidates(rows), read));
187
278
  }
188
- /** 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);
190
279
  /**
191
280
  * TAL-590 (Python #3238): the read-only CLI or API-server imports among `rows` whose active-profile state.db row is
192
281
  * gone, so their source was deleted outside Web and no Web action could remove them. An unreadable state.db proves
193
282
  * nothing (`agentSessionRowsExisting`); another profile's session or one with a run the runtime still holds is never
194
283
  * an orphan, while a stream id a crash left behind does not count as one.
195
284
  */
196
- orphanedImports(rows) {
197
- const candidates = this.orphanCandidates(rows);
285
+ async orphanedImports(rows, existing, owners, reads) {
286
+ const candidates = (await this.orphanCandidates(rows));
198
287
  if (!candidates.length)
199
288
  return new Set();
200
- const existing = agentSessionRowsExisting(this.stateDbPath(null), candidates);
201
- return new Set(candidates.filter((sid) => !existing.has(sid)));
289
+ existing ??= await (reads ? reads.existing() : agentSessionRowsExistingAsync(this.stateDbPath(null), candidates));
290
+ if (owners === undefined)
291
+ owners = await (reads ? reads.owners() : this.stateDbSources(candidates));
292
+ return new Set(candidates.filter((sid) => !existing.has(sid) && owners !== null && typeof owners.get(sid) !== 'object'));
202
293
  }
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)))
294
+ async orphanCandidates(rows) {
295
+ 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
296
  .map((r) => str(r.session_id));
206
297
  }
207
- isOrphanedImport(sid) {
298
+ async isOrphanedImport(sid, reads = this.sessionGateReads(sid)) {
208
299
  try {
209
- return this.orphanedImports([this.store.get(sid, { metadataOnly: true }).compact()]).size > 0;
300
+ return (await this.orphanedImports([(await this.store.get(sid, { metadataOnly: true })).compact()], undefined, undefined, reads)).size > 0;
210
301
  }
211
302
  catch {
212
303
  return false;
@@ -216,51 +307,100 @@ export class SessionService {
216
307
  * The list's owner locks and orphan prune (an orphan's sidecar goes, as nothing else ever removes it), with their
217
308
  * state.db reads made on the listing worker up front so the synchronous build never opens state.db (TAL-920).
218
309
  */
219
- async listStateDbLookups(rows) {
310
+ async listStateDbLookups(rows, cliRowIds) {
220
311
  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));
312
+ const activeProfile = this.deps.activeProfile();
313
+ const byProfile = ownerLockCandidates(rows);
314
+ const activeIds = byProfile.get(activeProfile) ?? new Set();
315
+ for (const id of cliRowIds)
316
+ activeIds.add(id);
317
+ byProfile.set(activeProfile, activeIds);
318
+ const owners = await this.ownerSources(byProfile);
319
+ const checked = new Set((await this.orphanCandidates(rows)));
224
320
  const existing = checked.size ? await agentSessionRowsExistingAsync(dbPath, checked) : new Set();
225
321
  return {
226
- stateDbSources: () => owners,
227
- pruneOrphanedImports: (current) => {
322
+ stateDbSources: owners,
323
+ pruneOrphanedImports: async (current) => {
228
324
  // 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)));
325
+ const orphans = (await this.orphanedImports(current.filter((r) => checked.has(str(r.session_id))), existing, owners([], activeProfile)));
230
326
  for (const sid of orphans)
231
- this.store.deleteFiles(sid);
327
+ (await this.store.delete(sid));
232
328
  return orphans;
233
329
  },
234
330
  };
235
331
  }
236
332
  /** 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);
333
+ async readOnlyReason(s, profile, reads) {
334
+ if (typeof s === 'string') {
335
+ try {
336
+ s = await this.store.get(s, { metadataOnly: true, cacheOnMiss: false, promote: false });
337
+ }
338
+ catch (error) {
339
+ if (!(error instanceof SessionNotFound))
340
+ return OWNERSHIP_UNAVAILABLE_REASON;
341
+ }
342
+ }
343
+ return await this.loadedReadOnlyReason(s, profile, reads);
344
+ }
345
+ async loadedReadOnlyReason(s, profile, reads) {
346
+ let raw = typeof s === 'string' ? { session_id: s, profile: this.deps.activeProfile() }
347
+ : { 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 };
348
+ if (profile !== undefined)
349
+ raw = this.deps.profilesMatch(str(raw.profile) || null, profile) ? { ...raw, profile } : { session_id: raw.session_id, profile };
350
+ const row = (await this.ownerLocked([raw], reads ? (_ids, ownerProfile) => reads.owners(ownerProfile) : undefined))[0];
351
+ return row.read_only ? str(row.read_only_reason) || 'Read-only imported sessions cannot be modified from WebUI' : null;
352
+ }
353
+ /**
354
+ * The final owner check before a protected sidecar dispatch or write: callers dispatch as soon as it resolves. The
355
+ * owner is the Agent's, which can change at any moment, so reading it on the state.db worker (TAL-923) widens no
356
+ * window Web could close; the session lock, where one is held, still serializes Web's own writes.
357
+ */
358
+ async assertWritable(session, profile, reads) {
359
+ const reason = await this.readOnlyReason(session, profile, reads);
360
+ if (reason)
361
+ throw new HttpFailure(403, reason);
362
+ }
363
+ /** Controls can exist without a local session file; use the same id the sidecar will address. */
364
+ async idForMutation(sid) {
365
+ sid = sid.trim();
366
+ if (!sid)
367
+ throw new HttpFailure(400, 'Missing required field(s): session_id');
368
+ await this.assertWritable(sid);
369
+ return sid;
370
+ }
371
+ async isReadOnly(s) { return (await this.readOnlyReason(s)) !== null; }
372
+ /** Recheck after the mutex wait; legacy local metadata/handoff actions keep their existing read-only allowance. */
373
+ withMutationLock(sid, fn, opts = {}) {
374
+ return this.store.withLock(sid, async () => {
375
+ if (opts.allowReadOnly)
376
+ (await this.rejectViewOnly(sid, 'modified'));
377
+ else
378
+ (await this.assertWritable(sid));
379
+ return fn();
380
+ }, opts);
242
381
  }
243
382
  /** 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');
383
+ async getForMutation(sid, reads) {
384
+ let s = (await this.get404(sid, {}, reads));
385
+ s = (await this.store.ensureFull(sid, s));
386
+ await this.assertWritable(s, undefined, reads?.sid === s.session_id ? reads : undefined);
249
387
  if (str(s.source_tag || s.raw_source).trim().toLowerCase() === 'subagent')
250
388
  throw new HttpFailure(403, 'Read-only subagent child session');
251
389
  return s;
252
390
  }
253
- get404(sid, opts = {}) {
391
+ async get404(sid, opts = {}, reads) {
392
+ sid = await this.resolveSessionId(sid);
254
393
  try {
255
- return this.store.get(sid, opts);
394
+ return (await this.store.get(sid, opts));
256
395
  }
257
396
  catch (error) {
258
397
  if (error instanceof SessionNotFound) {
259
398
  // Python `_get_or_materialize_session`: a claimable foreign (CLI/TUI/Desktop) session gains a WebUI sidecar
260
399
  // on its first mutation; a read-only foreign source answers 403 from `getForMutation`, a missing one 404.
261
- const synth = this.claimOrSynthesizeCliSession(sid);
400
+ const synth = (await this.claimOrSynthesizeCliSession(sid, undefined, reads));
262
401
  if (synth.session && synth.reason === 'materialized') {
263
- this.store.save(synth.session);
402
+ await this.store.save(synth.session);
403
+ await this.store.touch(synth.session);
264
404
  this.deps.titleClaimed?.(synth.session);
265
405
  return synth.session;
266
406
  }
@@ -272,16 +412,36 @@ export class SessionService {
272
412
  }
273
413
  }
274
414
  /** The Agent's state.db rows for this session (empty for subagent views, which never merge). */
275
- stateDbRows(s) {
276
- return this.stateDbRead(s).rows;
415
+ async stateDbRows(s) {
416
+ return (await this.stateDbRead(s)).rows;
277
417
  }
278
- /** `stateDbRows` and whether the read succeeded on a state.db whose messages carry ids (TAL-493). */
418
+ /**
419
+ * `stateDbRows` and whether the read succeeded on a state.db whose messages carry ids (TAL-493). TAL-1162: read on the
420
+ * state.db worker; a worker failure is a failed read.
421
+ */
279
422
  stateDbRead(s) {
280
- if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
281
- return { rows: [], idCapable: false, ok: false };
282
- // TAL-529: after a compression rotation the Agent writes to the continuation, so the read follows the recorded lineage.
283
- const lineage = s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null;
284
- return stateDbSessionRead(this.stateDbPath(s.profile), s.session_id, { stitch: false, lineage });
423
+ if (isSubagentView(s))
424
+ return Promise.resolve(failedStateDbRead());
425
+ return stateDbSessionReadAsync(failedStateDbRead(), 'stateDbSessionRead', this.stateDbPath(s.profile), s.session_id, { stitch: false, lineage: stateDbReadLineage(s) });
426
+ }
427
+ /** `stateDbRead`'s projection of the rows past `afterId` alone (the detail's page tail), on the state.db worker. */
428
+ stateDbTailRead(s, afterId, unidentified) {
429
+ return stateDbSessionReadAsync(failedStateDbRead(), 'stateDbSessionTailRead', this.stateDbPath(s.profile), s.session_id, afterId, { lineage: stateDbReadLineage(s), unidentified });
430
+ }
431
+ /** The one read a turn's settlement builds its context from and settles with (`stateDbSettlementRead`). */
432
+ settlementRead(s) {
433
+ const unchanged = { ...failedStateDbRead(), lineage: s.state_db_lineage ?? [s.session_id], rotated: null };
434
+ if (isSubagentView(s))
435
+ return Promise.resolve(unchanged);
436
+ return stateDbSessionReadAsync(unchanged, 'stateDbSettlementRead', this.stateDbPath(s.profile), s.session_id, s.state_db_lineage);
437
+ }
438
+ /**
439
+ * Preserve existing transcript provenance before a turn adds Agent rows from its starting database read. The stamp
440
+ * lands before this returns; the read follows.
441
+ */
442
+ beginStateDbTurn(s) {
443
+ stampStateDbGeneration([...s.messages, ...s.context_messages], markedStateDbGeneration(s));
444
+ return this.stateDbRead(s);
285
445
  }
286
446
  stateDbPath(profile) {
287
447
  return join(this.deps.profileHome(profile ?? this.deps.activeProfile()), 'state.db');
@@ -291,17 +451,21 @@ export class SessionService {
291
451
  * rows (a WebUI conversation continued from the CLI shows the CLI turns). This is the coordinate space `GET
292
452
  * /api/session` exposes, so branching and the next model history slice/extend the same list.
293
453
  */
294
- mergedTranscript(s, local = s.messages, stateRows = this.stateDbRows(s)) {
454
+ mergedTranscript(s, local, stateRows) {
295
455
  if (!stateRows.length)
296
456
  return local;
297
- return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows) });
457
+ return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows), stateDbGeneration: markedStateDbGeneration(s) });
298
458
  }
299
459
  /**
300
460
  * TAL-493: record the highest state.db id this boundary or settled turn read, under the session lock in the same save,
301
461
  * so the merge never replays a covered row and appends every row committed after the read. Call it after the boundary
302
462
  * fields are set: the marker holds only while they and the row it names stay as recorded.
303
463
  */
304
- markStateDbSeen(s, read = this.stateDbRead(s), opts = {}) {
464
+ markStateDbSeen(s, read, opts = {}) {
465
+ // Freeze numeric provenance before replacing the marker, including after a database replacement.
466
+ const priorGeneration = markedStateDbGeneration(s);
467
+ if (priorGeneration !== null)
468
+ stampStateDbGeneration([...s.messages, ...s.context_messages], priorGeneration);
305
469
  // A failed read says nothing. A settlement keeps the marker; a boundary drops it (fail closed): it may cut rows the
306
470
  // marker still admits without moving a field, and the timestamp rules honour its watermark.
307
471
  if (!read.ok) {
@@ -326,22 +490,38 @@ export class SessionService {
326
490
  * turn started from but the transcript lacks (a CLI continuation read into its context) are kept before the turn.
327
491
  */
328
492
  settleStateDb(s, turn) {
329
- 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);
493
+ const read = turn.read;
494
+ const agentAdded = withoutRows(turn.agentRows ?? [], turn.previousContext);
495
+ const contextAdded = withoutRows(s.context_messages, turn.previousContext);
496
+ if (typeof turn.startGeneration === 'string') {
497
+ stampStateDbGeneration(s.messages.filter((m) => m._turn_id !== turn.turnId), markedStateDbGeneration(s));
498
+ stampStateDbGeneration([...s.messages.filter((m) => m._turn_id === turn.turnId), ...contextAdded, ...agentAdded], turn.startGeneration);
499
+ }
500
+ const startGeneration = turn.previousContext.findLast((m) => typeof m._state_db_generation === 'string')?._state_db_generation;
501
+ const generation = turn.startGeneration ?? markedStateDbGeneration(s) ?? (typeof startGeneration === 'string' ? startGeneration : null);
502
+ const readGeneration = read.generation;
503
+ const generationChanged = generation !== null && typeof readGeneration === 'string' && generation !== readGeneration;
504
+ const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
505
+ const identities = (m) => stateDbIdentityKeys(m, includeUids, generation);
506
+ const startedWith = new Set(turn.previousContext.flatMap((m) => identities(m)));
507
+ const wasInContext = (m) => stateDbMessageRepresented(m, startedWith, includeUids, generation);
508
+ const earlier = mergeSessionMessagesAppendOnly(s.messages, read.rows.filter(wasInContext), { stateDbSeenId: -1, stateDbGeneration: generation }).slice(s.messages.length);
332
509
  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;
510
+ const addedIds = new Set(agentAdded.flatMap((m) => identities(m)));
511
+ for (const m of agentAdded) {
512
+ if (!identities(m).length)
513
+ added.set(settledIdentity(m), (added.get(settledIdentity(m)) ?? 0) + 1);
514
+ }
515
+ const ownWorkAfter = turn.agentRows?.length || generationChanged ? null : turn.startId ?? null;
336
516
  // 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;
517
+ const seenId = currentStateDbSeenId(s, read.rows) ?? (generationChanged ? null : turn.startId ?? null);
518
+ 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
519
  const fresh = new Set(shown.slice(s.messages.length));
340
520
  // The turn's own rows the merge already matched to their local copies use up their share before any shown row can.
341
521
  if (seenId !== null) {
342
522
  for (const m of read.rows) {
343
523
  const id = m._state_db_row_id;
344
- if (typeof id !== 'number' || id <= seenId || startedWith.has(id) || fresh.has(m))
524
+ if (typeof id !== 'number' || id <= seenId || wasInContext(m) || fresh.has(m))
345
525
  continue;
346
526
  const left = added.get(settledIdentity(m)) ?? 0;
347
527
  if (left > 0)
@@ -350,7 +530,9 @@ export class SessionService {
350
530
  }
351
531
  const missed = [...fresh].filter((m) => {
352
532
  const id = m._state_db_row_id;
353
- if (typeof id === 'number' && startedWith.has(id))
533
+ if (wasInContext(m))
534
+ return false;
535
+ if (stateDbMessageRepresented(m, addedIds, includeUids, generation))
354
536
  return false;
355
537
  const key = settledIdentity(m);
356
538
  const left = added.get(key) ?? 0;
@@ -367,17 +549,17 @@ export class SessionService {
367
549
  if (missed.length) {
368
550
  s.messages.push(...copyJson(missed));
369
551
  if (s.context_messages.length)
370
- s.context_messages.push(...copyJson(missed));
552
+ s.context_messages = mergeSessionMessagesAppendOnly(s.context_messages, copyJson(missed), { stateDbSeenId: -1, stateDbGeneration: generation });
371
553
  }
372
554
  this.markStateDbSeen(s, read);
373
555
  // TAL-529: a compression rotation moved the Agent to a continuation, whose first rows restate the compressed context.
374
556
  // The read follows the new lineage from here, and everything it holds now is covered.
375
557
  // ponytail: a CLI row committed to the continuation before this settlement is covered with them; telling them apart
376
558
  // needs the Agent to mark the rows it writes at rotation.
377
- const lineage = stateDbCompressionLineage(this.stateDbPath(s.profile), s.session_id);
559
+ const lineage = read.lineage;
378
560
  if (JSON.stringify(lineage) !== JSON.stringify(s.state_db_lineage ?? [s.session_id])) {
379
561
  s.state_db_lineage = lineage.length > 1 ? lineage : null;
380
- this.markStateDbSeen(s);
562
+ this.markStateDbSeen(s, read.rotated ?? failedStateDbRead());
381
563
  }
382
564
  }
383
565
  /**
@@ -411,46 +593,83 @@ export class SessionService {
411
593
  */
412
594
  modelContext(s, stateRows) {
413
595
  const local = s.context_messages.length ? s.context_messages : s.messages.filter((m) => !m._error && !m._partial);
414
- return local.some((m) => isContextCompressionMarker(m)) ? local : this.mergedTranscript(s, local, stateRows ?? this.stateDbRows(s));
596
+ return local.some((m) => isContextCompressionMarker(m)) ? local : this.mergedTranscript(s, local, stateRows);
597
+ }
598
+ /** `modelContext` over a state.db read taken now. */
599
+ async readModelContext(s) {
600
+ return this.modelContext(s, await this.stateDbRows(s));
415
601
  }
416
602
  /**
417
603
  * TAL-518: the model context a `/btw` clone inherits: the chat's history plus its running turn's prompt, which
418
604
  * deferred save keeps out of `messages` and `context_messages` until settlement.
419
605
  */
420
- sideQuestionContext(s) {
421
- const context = structuredClone(this.modelContext(s));
606
+ async sideQuestionContext(s) {
607
+ const context = structuredClone(await this.readModelContext(s));
422
608
  const pending = this.pendingTurn(s);
423
609
  return pending ? withPendingUserTurn(context, pending) : context;
424
610
  }
425
611
  /** Python `_is_messaging_session_id`: from the WebUI metadata or the Agent's row. */
426
- isMessagingSession(sid) {
612
+ async isMessagingSession(sid, reads = this.sessionGateReads(sid)) {
427
613
  try {
428
- if (isMessagingSessionRecord(this.store.get(sid, { metadataOnly: true }).compact()))
614
+ if (isMessagingSessionRecord((await this.store.get(sid, { metadataOnly: true })).compact()))
429
615
  return true;
430
616
  }
431
617
  catch { /* absent */ }
432
- const meta = this.lookupCliMeta(sid);
618
+ const meta = await reads.meta();
433
619
  return meta !== null && isMessagingSessionRecord(meta);
434
620
  }
435
- /** Python `_lookup_cli_session_metadata`: the sidebar row for a state.db session in the active profile. */
436
- lookupCliMeta(sid) {
621
+ sessionGateReads(sid) {
622
+ const profile = this.deps.activeProfile();
623
+ const dbPath = this.stateDbPath(profile);
624
+ const owners = new Map();
625
+ let row;
626
+ let meta;
627
+ let transcript;
628
+ let context;
629
+ let existing;
630
+ return {
631
+ sid,
632
+ owners: (ownerProfile = profile) => {
633
+ let pending = owners.get(ownerProfile);
634
+ if (!pending) {
635
+ pending = this.stateDbSources([sid], ownerProfile);
636
+ owners.set(ownerProfile, pending);
637
+ }
638
+ return pending;
639
+ },
640
+ row: () => row ??= stateDbSessionReadAsync(null, 'stateDbSessionRow', dbPath, sid),
641
+ meta: () => meta ??= this.lookupCliMeta(sid),
642
+ messages: (stitch) => stitch
643
+ ? transcript ??= isClaudeCodeSessionId(sid) ? Promise.resolve(this.deps.claudeCode?.messages(sid) ?? []) : stateDbSessionReadAsync([], 'stateDbSessionMessages', dbPath, sid, { stitch: true })
644
+ : context ??= stateDbSessionReadAsync([], 'stateDbSessionMessages', dbPath, sid, { stitch: false }),
645
+ existing: () => existing ??= agentSessionRowsExistingAsync(dbPath, [sid]),
646
+ };
647
+ }
648
+ /**
649
+ * Python `_lookup_cli_session_metadata`: the sidebar row for a state.db session in the active profile, else its plain
650
+ * row (a segment its chain's row does not name). Both are read by id: the sidebar listing would scan every session to
651
+ * rank its window (TAL-923).
652
+ */
653
+ async lookupCliMeta(sid) {
437
654
  try {
438
655
  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;
656
+ return (await this.claudeCodeRows()).find((r) => str(r.session_id) === sid) ?? null;
657
+ const profile = this.deps.activeProfile();
658
+ return (await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: [sid], sidebarRows: true })).rows.find((r) => str(r.session_id) === sid)
659
+ ?? (await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: [sid] })).rows[0] ?? null;
441
660
  }
442
661
  catch {
443
662
  return null;
444
663
  }
445
664
  }
446
- claudeCodeRows() {
447
- return this.deps.claudeCode?.rows(this.deps.workspaces.lastWorkspace(this.deps.activeProfile())) ?? [];
665
+ async claudeCodeRows() {
666
+ return this.deps.claudeCode?.rows((await this.deps.workspaces.lastWorkspace(this.deps.activeProfile()))) ?? [];
448
667
  }
449
668
  /** Python `_session_index_marks_was_webui`: an index row that once owned a WebUI sidecar (self-heal 404). */
450
- indexMarksWasWebui(sid) {
669
+ async indexMarksWasWebui(sid) {
451
670
  let entries;
452
671
  try {
453
- entries = this.store.readIndexEntries();
672
+ entries = (await this.store.readIndexEntries());
454
673
  }
455
674
  catch {
456
675
  return false;
@@ -472,20 +691,24 @@ export class SessionService {
472
691
  * a WebUI sidecar. `materialized` sessions are writeable (the caller persists the sidecar), `not_claimable` ones are
473
692
  * read-only stubs, `was_webui`/`no_foreign_state`/`invalid_sid` answer 404.
474
693
  */
475
- claimOrSynthesizeCliSession(sid, metaIn) {
694
+ async claimOrSynthesizeCliSession(sid, metaIn, reads) {
476
695
  if (!isSafeSessionId(sid))
477
696
  return { session: null, reason: 'invalid_sid' };
478
697
  const profile = this.deps.activeProfile();
479
- const dbPath = join(this.deps.profileHome(profile), 'state.db');
480
- const row = stateDbSessionRow(dbPath, sid);
698
+ sid = await this.resolveSessionId(sid);
699
+ if (reads?.sid !== sid)
700
+ reads = this.sessionGateReads(sid);
701
+ const [row, owners] = await Promise.all([reads.row(), reads.owners()]);
481
702
  const stateDbSource = str(row?.source).trim().toLowerCase();
482
703
  const subagentChild = stateDbSource === 'subagent';
483
- if ((this.indexMarksWasWebui(sid) || (this.store.wasDeleted(sid) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild)
704
+ const canonical = typeof owners?.get(sid) === 'object';
705
+ if (((await this.indexMarksWasWebui(sid)) || ((await this.store.wasDeleted(sid)) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild && !canonical)
484
706
  return { session: null, reason: 'was_webui' };
485
- const msgs = isClaudeCodeSessionId(sid) ? this.deps.claudeCode?.messages(sid) ?? [] : stateDbSessionMessages(dbPath, sid, { stitch: true });
486
- if (!msgs.length)
707
+ const msgs = await reads.messages(true);
708
+ const meta = { ...(metaIn ?? (await reads.meta()) ?? {}) };
709
+ // An owner receipt proves a session before its first turn (a fresh diagnostic chat, TAL-1040); a bare row does not.
710
+ if (!msgs.length && !(meta._lineage_root_id && meta._lineage_tip_id) && !canonical)
487
711
  return { session: null, reason: 'no_foreign_state' };
488
- const meta = { ...(metaIn ?? this.lookupCliMeta(sid) ?? {}) };
489
712
  if (row) {
490
713
  if (!meta.source_tag && stateDbSource)
491
714
  meta.source_tag = stateDbSource;
@@ -502,12 +725,16 @@ export class SessionService {
502
725
  if (!meta.updated_at && (row.ended_at || row.started_at))
503
726
  meta.updated_at = row.ended_at || row.started_at;
504
727
  }
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);
728
+ // An owner read that fails is an unknown owner, which never releases a claim (TAL-793).
729
+ const claimable = owners !== null && isClaimableCliSource(meta, owners.get(sid) || stateDbSource);
730
+ const workspace = str(meta.workspace || meta.cwd).trim() || (await this.deps.workspaces.lastWorkspace(profile));
731
+ const defaults = (await this.store.deps.defaults(profile));
732
+ const context = Array.isArray(meta._lineage_ids) ? await reads.messages(false) : null;
508
733
  const session = new Session({
509
734
  session_id: sid, title: str(meta.title) || 'CLI Session', workspace, model: str(meta.model) || 'unknown', model_provider: str(meta.model_provider) || null,
510
735
  messages: msgs, created_at: Number(meta.created_at) || 0, updated_at: Number(meta.updated_at) || 0, profile: str(meta.profile) || null,
736
+ pinned: Boolean(meta.pinned), archived: Boolean(meta.archived),
737
+ ...(context ? { context_messages: context } : {}),
511
738
  is_cli_session: claimable ? true : !subagentChild, source_tag: str(meta.source_tag) || null, raw_source: str(meta.raw_source) || null,
512
739
  session_source: str(meta.session_source) || null, source_label: str(meta.source_label) || null, read_only: !claimable,
513
740
  // Python `import_cli_session`: the claimed sidecar keeps the row's lineage, background project and channel identity
@@ -517,9 +744,9 @@ export class SessionService {
517
744
  }, defaults);
518
745
  return { session, reason: claimable ? 'materialized' : 'not_claimable' };
519
746
  }
520
- mutationTarget(sid, verb) {
747
+ async mutationTarget(sid, verb, reads) {
521
748
  try {
522
- return this.getForMutation(sid);
749
+ return (await this.getForMutation(sid, reads));
523
750
  }
524
751
  catch (error) {
525
752
  if (error instanceof SessionNotFound)
@@ -537,49 +764,67 @@ export class SessionService {
537
764
  if (isClaudeCodeSessionId(sid))
538
765
  throw new HttpFailure(400, `Claude Code sessions are view-only and cannot be ${verb} from WebUI`);
539
766
  }
540
- rejectSubagent(sid, verb) {
767
+ async rejectViewOnly(sid, verb, reads) {
768
+ sid = sid.trim();
769
+ if (reads?.sid !== sid)
770
+ reads = this.sessionGateReads(sid);
541
771
  this.rejectClaudeCode(sid, verb);
542
- if (this.isSubagentViewOnly(sid))
772
+ let profile = this.deps.activeProfile();
773
+ try {
774
+ profile = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false })).profile || 'default';
775
+ }
776
+ catch { /* foreign row */ }
777
+ const owners = await reads.owners(profile);
778
+ const owner = owners?.get(sid);
779
+ if (!owners || typeof owner === 'object')
780
+ throw new HttpFailure(403, typeof owner === 'object' ? owner.reason ?? CANONICAL_READ_ONLY_REASON : OWNERSHIP_UNAVAILABLE_REASON);
781
+ if ((await this.isSubagentViewOnly(sid, reads)))
543
782
  throw new HttpFailure(400, `Subagent sessions are view-only and cannot be ${verb} from WebUI`);
783
+ return sid;
784
+ }
785
+ /** All transcript transports use the same live-run and durable-pending evidence. */
786
+ async backgroundUpdates(s, messages, reviewRows) {
787
+ 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
788
  }
545
789
  /** 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) ?? []);
790
+ async backgroundLinked(s, messages) {
791
+ return withBackgroundLinks(messages, (await this.deps.backgroundReceipts?.(s.session_id)) ?? []);
548
792
  }
549
793
  /** `compact()` plus messages, redacted for the wire (Python `_public_session_projection`). */
550
- publicSession(s, withMessages = true) {
551
- const payload = this.wireRow(s);
794
+ async publicSession(s, withMessages = true, messages = s.messages) {
795
+ const payload = (await this.wireRow(s));
552
796
  // Mutation replies replace a client's transcript, so they carry the same server-built scenes as the detail.
553
797
  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) }));
798
+ 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
799
  payload.compression_reference = compressionReference(s, payload.messages);
556
800
  }
557
801
  return redactSessionData(payload, this.deps.redactEnabled());
558
802
  }
803
+ /** Opaque read identity, including context changes that do not alter display message counts. */
804
+ contextDetailKey(s) {
805
+ return createHash('sha256').update(JSON.stringify([s.session_id, s.profile, s.model, s.model_provider, s.workspace, s.updated_at, s.messages.length, s.context_messages, s.post_compression_context_tokens_estimate, s.active_stream_id])).digest('hex');
806
+ }
559
807
  /** `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);
808
+ async wireRow(s) {
809
+ return withSessionWireFlags((await this.ownerLocked([{ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }), context_detail_key: this.contextDetailKey(s), assistant_name: this.assistantName(s), workspace_name: (await this.workspaceNames([s]))(s) }]))[0], this.deps.runtime.activeStreamIds);
562
810
  }
563
811
  /**
564
812
  * TAL-303: the label for a session's workspace, from its own profile's registry (a profileless row is the root's).
565
813
  * One resolver serves one response, so each profile's registry is read once however many rows it names.
566
814
  */
567
- workspaceNames() {
815
+ async workspaceNames(rows) {
568
816
  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);
817
+ for (const profile of new Set(rows.map((row) => str(row.profile) || 'default'))) {
818
+ let entries;
819
+ try {
820
+ entries = await this.deps.workspaces.entries(profile);
580
821
  }
581
- return workspaceDisplayName(str(row.workspace), entries);
582
- };
822
+ catch {
823
+ entries = [];
824
+ }
825
+ registries.set(profile, entries);
826
+ }
827
+ return (row) => workspaceDisplayName(str(row.workspace), registries.get(str(row.profile) || 'default') ?? []);
583
828
  }
584
829
  /** The agent's display name for a session's profile (`assistant_name`). */
585
830
  assistantName(s) {
@@ -590,48 +835,251 @@ export class SessionService {
590
835
  return redactSessionData(s.toDocument(), this.deps.redactEnabled());
591
836
  }
592
837
  // ── detail ──────────────────────────────────────────────────────────────
593
- loadRevision(s) {
594
- return statSignature(this.store.pathFor(s.session_id));
838
+ async loadRevision(s) {
839
+ return (await this.store.revision(s.session_id));
840
+ }
841
+ pageTails = new WeakMap();
842
+ pageContexts = new WeakMap();
843
+ prefixIndex(s, revision) {
844
+ const previous = s.prefixIndex;
845
+ if (previous?.rows === s.messages && previous.revision === revision && previous.length <= s.messages.length) {
846
+ previous.extend();
847
+ return previous;
848
+ }
849
+ return s.prefixIndex = new SessionPrefixIndex(s.messages, revision, markedStateDbGeneration(s), { toolCalls: s.tool_calls });
850
+ }
851
+ /** Existing projections, once per complete turn range, in absolute transcript coordinates. */
852
+ async deriveRange(s, rows, baseIndex, initialTurn, journaled, refs, calls, reviewRows) {
853
+ 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 })));
854
+ }
855
+ async limitedDetail(s, index, limit, before, pending, journaled, retried = false) {
856
+ let prefixEnd = s.messages.length;
857
+ const turnId = pending?.turnId ?? journaled?.turnId;
858
+ if (turnId)
859
+ prefixEnd = Math.min(prefixEnd, index.turns.get(turnId)?.first ?? prefixEnd);
860
+ const prompt = pending ? index.prompts.get(pending.activeTurnToken) : undefined;
861
+ if (prompt !== undefined)
862
+ prefixEnd = Math.min(prefixEnd, prompt);
863
+ const dbPath = this.stateDbPath(s.profile);
864
+ 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 ?? '')]);
865
+ let cached = this.pageTails.get(index);
866
+ if (cached?.key !== key || cached.contextRows !== s.context_messages || cached.contextLength !== s.context_messages.length) {
867
+ const streaming = Boolean(s.active_stream_id && !this.compactionWaiters.has(s.active_stream_id));
868
+ const contextKey = JSON.stringify([index.revision, s.active_stream_id, index.length, streaming]);
869
+ let context = this.pageContexts.get(index);
870
+ if (context?.key !== contextKey || context.contextRows !== s.context_messages || context.contextLength !== s.context_messages.length) {
871
+ const summary = !s.context_messages.length && !s.active_stream_id ? index.model : new ApiMessageSummary(s.active_stream_id, !s.context_messages.length);
872
+ if (summary !== index.model && !streaming) {
873
+ for (const row of s.context_messages.length ? s.context_messages : s.messages) {
874
+ summary.add(row);
875
+ if (summary.enough)
876
+ break;
877
+ }
878
+ summary.finish();
879
+ }
880
+ 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 };
881
+ this.pageContexts.set(index, context);
882
+ }
883
+ let stateRows;
884
+ let effectiveSeenId = null;
885
+ let cacheable = true;
886
+ let appended = null;
887
+ // TAL-1162: the transcript reads run on the state.db worker. A session that changed meanwhile no longer matches this
888
+ // index or the lifecycle snapshots: null asks the caller to take them again, and a second change is answered as
889
+ // read, uncached.
890
+ const [localRows, localCount, updatedAt, seenId] = [s.messages, s.messages.length, s.updated_at, s.state_db_seen_id];
891
+ const changed = () => s.messages !== localRows || s.messages.length !== localCount || s.updated_at !== updatedAt || s.state_db_seen_id !== seenId;
892
+ if (typeof seenId === 'number' && !isSubagentView(s)) {
893
+ const read = await this.stateDbTailRead(s, seenId, [...index.unidentified, ...context.unidentified]);
894
+ if (changed()) {
895
+ if (!retried)
896
+ return null;
897
+ cacheable = false;
898
+ }
899
+ if (!read.ok)
900
+ cacheable = false;
901
+ const markerRows = read.anchor ? [read.anchor, ...read.rows] : read.rows;
902
+ if (read.ok && read.idCapable && currentStateDbSeenId(s, markerRows) !== null) {
903
+ // Covered rows consume unidentified local copies before a new duplicate. Any such match needs the full snapshot.
904
+ const ambiguous = read.requiresFullRead === true;
905
+ if (!ambiguous) {
906
+ const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
907
+ const mode = includeUids ? 1 : 0;
908
+ appended = appendStateDbIdentityRows(read.rows, seenId, index.identities[mode], index.unidentified[mode], index.generation, includeUids);
909
+ stateRows = read.rows;
910
+ effectiveSeenId = seenId;
911
+ }
912
+ }
913
+ }
914
+ if (appended === null) {
915
+ stateRows = await this.stateDbRows(s);
916
+ if (changed()) {
917
+ if (!retried)
918
+ return null;
919
+ cacheable = false;
920
+ }
921
+ effectiveSeenId = currentStateDbSeenId(s, stateRows);
922
+ appended = this.mergedTranscript(s, s.messages, stateRows).slice(s.messages.length);
923
+ }
924
+ let tail = [...s.messages.slice(prefixEnd), ...appended];
925
+ if (pending)
926
+ tail = withPendingUserTurn(tail, { ...pending, localCount: s.messages.length - prefixEnd });
927
+ if (journaled)
928
+ tail = withoutRunningTurnOutput(tail, { ...journaled, localCount: s.messages.length - prefixEnd });
929
+ 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 } });
930
+ 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);
931
+ const owners = new Map();
932
+ const sources = async (ids, profile) => {
933
+ const ownerKey = JSON.stringify([ids, profile]);
934
+ const existing = owners.get(ownerKey);
935
+ if (existing)
936
+ return existing;
937
+ const result = await this.stateDbSources(ids, profile);
938
+ if (result)
939
+ owners.set(ownerKey, result);
940
+ return result;
941
+ };
942
+ cached = { key, tail, index: tailIndex, enoughContext, sources, contextRows: s.context_messages, contextLength: s.context_messages.length,
943
+ 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) } };
944
+ if (cacheable)
945
+ this.pageTails.set(index, cached);
946
+ }
947
+ const { tail, index: tailIndex } = cached;
948
+ const total = prefixEnd + tail.length;
949
+ const end = before === null ? total : Math.max(0, Math.min(before, total));
950
+ 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) : [])];
951
+ const idAt = (at) => at < prefixEnd ? index.turnId(at) : tailIndex.turnId(at);
952
+ const turnSpan = (id) => {
953
+ const prefix = index.turns.get(id), suffix = tailIndex.turns.get(id);
954
+ 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 };
955
+ };
956
+ const spanAt = (at) => {
957
+ const id = idAt(at), own = turnSpan(id);
958
+ const dependencies = [index.dependencies.get(id), tailIndex.dependencies.get(id)];
959
+ const first = Math.min(...dependencies.map((span) => span?.first ?? total));
960
+ const last = Math.max(...dependencies.map((span) => span ? Math.min(span.last, total - 1) : -1));
961
+ return { first: Math.min(own.first, first), last: Math.max(own.last, last + 1) };
962
+ };
963
+ // A notification can refer to a parent turn outside this page. Read that indexed turn, never the whole prefix.
964
+ const reviewTurns = new Map();
965
+ const reviewRows = (id) => {
966
+ let rows = reviewTurns.get(id);
967
+ if (!rows) {
968
+ const span = turnSpan(id);
969
+ 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) : [];
970
+ reviewTurns.set(id, rows);
971
+ }
972
+ return rows;
973
+ };
974
+ const refs = { get: (ref) => index.refCountBefore(ref, prefixEnd) + (tailIndex.refCounts.get(ref) ?? 0) };
975
+ if (cached.newestReply === undefined) {
976
+ let newestReply = null;
977
+ const excluded = new Set(s.active_stream_id ? [s.active_stream_id] : []);
978
+ selection: for (const owner of [tailIndex, index])
979
+ for (const candidate of owner.replyTurns(owner === index ? prefixEnd : total, excluded)) {
980
+ const span = turnSpan(idAt(candidate)), rows = readRange(span.first, span.last);
981
+ 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);
982
+ const newest = newestSettledReply(derived, s.active_stream_id, span.first);
983
+ if (newest !== null) {
984
+ newestReply = newest;
985
+ break selection;
986
+ }
987
+ }
988
+ cached.newestReply = newestReply;
989
+ }
990
+ let start = end ? spanAt(Math.max(0, end - limit)).first : 0;
991
+ if (pending && before === null)
992
+ start = Math.min(start, prefixEnd);
993
+ let stop = end ? spanAt(end - 1).last : 0;
994
+ // Stamped ids can recur non-contiguously. Include every row of a selected turn, as the full derivation does.
995
+ let derived = [];
996
+ for (;;) {
997
+ for (let at = start; at < stop; at += 1) {
998
+ const span = spanAt(at);
999
+ if (span.first < start) {
1000
+ start = span.first;
1001
+ at = start - 1;
1002
+ }
1003
+ stop = Math.max(stop, span.last);
1004
+ }
1005
+ const rows = readRange(start, stop);
1006
+ const calls = index.toolCalls(start, rows.length);
1007
+ derived = (await this.deriveRange(s, rows, start, start ? idAt(start - 1) : 'legacy:start', Boolean(journaled), refs, calls, reviewRows));
1008
+ const [page, relative] = messageWindowForDisplay(derived, limit, end - start);
1009
+ if (relative > 0 || start === 0 || page.filter((m) => messageCountsAsRenderable(m)).length >= limit) {
1010
+ let offset = start + relative;
1011
+ let messages = page;
1012
+ if (pending && before === null) {
1013
+ const at = derived.findIndex((m) => m.role === 'user' && m._active_turn_token === pending.activeTurnToken);
1014
+ if (at >= 0 && start + at < offset) {
1015
+ messages = derived.slice(at, relative + messages.length);
1016
+ offset = start + at;
1017
+ }
1018
+ }
1019
+ return { messages, offset, newestReply: cached.newestReply, ...cached.summary, enoughContext: cached.enoughContext, sources: cached.sources };
1020
+ }
1021
+ start = spanAt(Math.max(0, start - Math.max(limit, stop - start))).first;
1022
+ }
595
1023
  }
596
1024
  /** Python `_handle_session_get` on the WebUI sidecar (state.db merge pending). */
597
- detail(sid, query) {
1025
+ async detail(sid, query) {
598
1026
  if (!sid)
599
1027
  throw new HttpFailure(400, 'session_id is required');
1028
+ sid = await this.resolveSessionId(sid);
600
1029
  const loadMessages = (query.messages ?? '1') !== '0';
601
1030
  const msgLimit = parseMsgLimit(query.msg_limit ?? null);
602
1031
  const msgBeforeRaw = query.msg_before;
603
1032
  const msgBefore = msgBeforeRaw ? (Number.isInteger(Number(msgBeforeRaw)) ? Number(msgBeforeRaw) : null) : null;
604
1033
  let s;
605
1034
  try {
606
- s = this.store.get(sid, { metadataOnly: !loadMessages });
1035
+ s = (await this.store.get(sid, { metadataOnly: !loadMessages }));
607
1036
  }
608
1037
  catch {
609
- return this.foreignSessionDetail(sid);
1038
+ return (await this.foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore));
610
1039
  }
611
- const revisionBefore = this.loadRevision(s);
1040
+ const revisionBefore = (await this.loadRevision(s));
612
1041
  if (!this.visibleToActiveProfile(s.profile)) {
613
1042
  if (s.profile)
614
1043
  throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile: s.profile });
615
1044
  throw new HttpFailure(404, 'Session not found');
616
1045
  }
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) : [];
621
- const pending = loadMessages ? this.pendingTurn(s) : null;
622
- if (pending)
1046
+ await this.clearStaleStreamState(s);
1047
+ return this.detailPayload(s, loadMessages, msgLimit, msgBefore, revisionBefore);
1048
+ }
1049
+ /** Project an already settled session without reloading it or running endpoint recovery/profile gates. */
1050
+ async windowedDetail(s, limit) {
1051
+ return this.detailPayload(s, true, limit, null, await this.loadRevision(s));
1052
+ }
1053
+ async detailPayload(s, loadMessages, msgLimit, msgBefore, revisionBefore) {
1054
+ // TAL-1162: the full transcript read runs on the state.db worker before the lifecycle snapshots are taken, so they
1055
+ // describe the session the payload is built from.
1056
+ const stateRows = loadMessages && msgLimit === null ? await this.stateDbRows(s) : [];
1057
+ let journaled = loadMessages ? (await this.journaledActiveTurn(s)) : null;
1058
+ let pending = loadMessages ? this.pendingTurn(s) : null;
1059
+ let prefix = loadMessages && msgLimit !== null ? this.prefixIndex(s, revisionBefore) : null;
1060
+ let limited = prefix && msgLimit !== null ? (await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled)) : null;
1061
+ if (prefix && msgLimit !== null && !limited) {
1062
+ // The session changed during the page's transcript read: every snapshot is taken again, once.
1063
+ revisionBefore = await this.loadRevision(s);
1064
+ journaled = await this.journaledActiveTurn(s);
1065
+ pending = this.pendingTurn(s);
1066
+ prefix = this.prefixIndex(s, revisionBefore);
1067
+ limited = await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled, true);
1068
+ }
1069
+ let transcript = loadMessages && !limited ? this.mergedTranscript(s, s.messages, stateRows) : [];
1070
+ if (pending && !limited)
623
1071
  transcript = withPendingUserTurn(transcript, pending);
624
- if (journaled)
1072
+ if (journaled && !limited)
625
1073
  transcript = withoutRunningTurnOutput(transcript, { ...journaled, localCount: s.messages.length });
626
1074
  // 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;
1075
+ 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 }))) : [];
1076
+ let truncated = limited?.messages ?? [];
1077
+ let offset = limited?.offset ?? 0;
1078
+ let summaryCount = limited?.total ?? null;
1079
+ let summaryLast = limited?.last ?? null;
632
1080
  if (loadMessages) {
633
- ;
634
- [truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
1081
+ if (!limited)
1082
+ [truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
635
1083
  // TAL-368: a tail window starts no later than the running turn's prompt, so a turn whose output stays in the
636
1084
  // transcript (no journal to replay it) keeps its prompt; older pages end before it, so it is never sent twice.
637
1085
  // ponytail: the window grows with that turn's persisted rows; clip the turn instead if degraded runs get long.
@@ -641,7 +1089,7 @@ export class SessionService {
641
1089
  offset = prompt;
642
1090
  }
643
1091
  // 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);
1092
+ truncated = withBodyExcerpts(withDisplayMedia(truncated, (await this.mediaProjector(s))), s.active_stream_id, limited ? limited.newestReply : newestSettledReply(all, s.active_stream_id), offset);
645
1093
  if (msgLimit !== null)
646
1094
  truncated = messagesForLimitedPayload(truncated);
647
1095
  }
@@ -649,10 +1097,12 @@ export class SessionService {
649
1097
  summaryCount = s.metadataMessageCount ?? s.messages.length;
650
1098
  summaryLast = lastMessageTimestamp(s.messages) ?? 0;
651
1099
  }
652
- const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < all.length);
1100
+ const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < (limited?.total ?? all.length));
653
1101
  let toolCalls = loadMessages ? s.tool_calls : [];
654
1102
  if (windowed)
655
- toolCalls = toolCallsForMessageWindow(toolCalls, offset, truncated.length);
1103
+ toolCalls = prefix ? prefix.toolCalls(offset, truncated.length, true) : toolCallsForMessageWindow(toolCalls, offset, truncated.length);
1104
+ if (loadMessages && s.active_stream_id && msgBefore === null)
1105
+ toolCalls = [...toolCalls, ...(this.deps.runtime.liveToolProgress?.(s.active_stream_id) ?? [])];
656
1106
  const mergedCount = summaryCount ?? all.length;
657
1107
  let mergedLast = summaryLast ?? 0;
658
1108
  if (summaryLast === null && all.length) {
@@ -660,7 +1110,7 @@ export class SessionService {
660
1110
  }
661
1111
  const activeStreamIds = this.deps.runtime.activeStreamIds;
662
1112
  const raw = {
663
- ...s.compact({ includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
1113
+ ...s.compact({ ...(prefix ? { transcriptSummary: prefix.summary() } : {}), includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
664
1114
  messages: truncated,
665
1115
  message_count: mergedCount,
666
1116
  tool_calls: toolCalls,
@@ -674,10 +1124,12 @@ export class SessionService {
674
1124
  };
675
1125
  if (loadMessages)
676
1126
  raw.transcript_seq = journaled ? { stream_id: journaled.turnId, seq: 0 } : null;
677
- if (loadMessages)
1127
+ if (limited)
1128
+ attachTodoSnapshot(raw, limited.todo, s.extra[UNSETTLED_TODO_KEY]);
1129
+ else if (loadMessages)
678
1130
  attachTodoState(raw, all, s.extra[UNSETTLED_TODO_KEY]);
679
1131
  if (loadMessages)
680
- raw.compression_reference = compressionReference(s, all);
1132
+ raw.compression_reference = limited ? limited.compression : compressionReference(s, all);
681
1133
  if (mergedLast) {
682
1134
  raw.last_message_at = Math.max(Number(raw.last_message_at ?? 0) || 0, mergedLast);
683
1135
  raw.updated_at = Math.max(Number(raw.updated_at ?? 0) || 0, mergedLast);
@@ -686,30 +1138,39 @@ export class SessionService {
686
1138
  raw._messages_truncated = isTruncated;
687
1139
  raw._messages_offset = offset;
688
1140
  raw._msg_limit_max = MAX_MSG_LIMIT;
689
- const revisionAfter = this.loadRevision(s);
1141
+ const revisionAfter = (await this.loadRevision(s));
690
1142
  raw._load_revision = revisionBefore !== null && revisionBefore === revisionAfter ? hashRevision(revisionBefore) : `unstable-${randomUUID().replace(/-/g, '')}`;
691
- raw.read_only = this.isReadOnly(s);
1143
+ Object.assign(raw, (await this.ownerLocked([raw], limited?.sources))[0]);
692
1144
  raw.assistant_name = this.assistantName(s);
693
- raw.workspace_name = this.workspaceNames()(s);
694
- raw.branched_from = this.branchedFrom(s);
1145
+ raw.workspace_name = (await this.workspaceNames([s]))(s);
1146
+ raw.branched_from = (await this.branchedFrom(s));
695
1147
  withSessionWireFlags(raw, activeStreamIds);
696
1148
  // A metadata-only read lacks the history the gate counts, so it carries no flag.
697
1149
  if (loadMessages)
698
- raw.can_compress = this.canCompress(s, raw.read_only === true, stateRows);
1150
+ 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);
1151
+ raw.confirms_deep_truncate = true;
699
1152
  raw.pending_steers = raw.active_stream_id ? (this.deps.runtime.pendingSteers?.(str(raw.active_stream_id)) ?? []) : [];
700
- return redactSessionData(raw, this.deps.redactEnabled());
1153
+ // TAL-1006: the compression a client shows on load: the running manual job's, else the live run's open span.
1154
+ const job = this.compressionJobs.get(s.session_id);
1155
+ raw.run_status = this.currentRunStatus(s.session_id, str(raw.active_stream_id) || null);
1156
+ 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;
1157
+ raw.automatic_turns_held = (await this.deps.runtime.automaticTurnsHeld?.(s.session_id)) ?? false;
1158
+ raw.notices = this.deps.runtime.sessionNotices?.(s.session_id) ?? [];
1159
+ raw.goal = this.deps.runtime.sessionGoal?.(s.session_id) ?? null;
1160
+ const publicRow = redactSessionData(raw, this.deps.redactEnabled());
1161
+ return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
701
1162
  }
702
1163
  /**
703
1164
  * TAL-454: the chat `/branch` copied, while it still loads (archived included). A compression continuation is also a
704
1165
  * fork of its parent but not a branch, so it links nowhere.
705
1166
  */
706
- branchedFrom(s) {
1167
+ async branchedFrom(s) {
707
1168
  const parentId = s.parent_session_id;
708
1169
  if (!parentId || str(s.session_source).trim().toLowerCase() !== 'fork' || s.compression_recovery_source_session_id)
709
1170
  return null;
710
1171
  let title;
711
1172
  try {
712
- const parent = this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false });
1173
+ const parent = (await this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false }));
713
1174
  if (!this.visibleToActiveProfile(parent.profile))
714
1175
  return null;
715
1176
  title = parent.title;
@@ -718,10 +1179,10 @@ export class SessionService {
718
1179
  // A read-only foreign parent (a cron run) has no sidecar and opens from the active profile's state.db while it
719
1180
  // has transcript rows and is not a deleted chat (`claimOrSynthesizeCliSession`'s `was_webui` and `no_foreign_state`).
720
1181
  const dbPath = this.stateDbPath(null);
721
- const row = stateDbSessionRow(dbPath, parentId);
722
- if (!row || this.store.wasDeleted(parentId) || this.indexMarksWasWebui(parentId))
1182
+ const row = await stateDbSessionReadAsync(null, 'stateDbSessionRow', dbPath, parentId);
1183
+ if (!row || (await this.store.wasDeleted(parentId)) || (await this.indexMarksWasWebui(parentId)))
723
1184
  return null;
724
- if (!stateDbSessionMessages(dbPath, parentId, { stitch: true }).length)
1185
+ if (!(await stateDbSessionReadAsync(false, 'stateDbSessionHasMessages', dbPath, parentId)))
725
1186
  return null;
726
1187
  title = row.title;
727
1188
  }
@@ -731,12 +1192,12 @@ export class SessionService {
731
1192
  * TAL-316: the active run whose journal can replay its output from the start. The detail then leaves that output to the
732
1193
  * replay; with no active run, no identifiable prompt, or no complete journal, it returns the persisted transcript unchanged.
733
1194
  */
734
- journaledActiveTurn(s) {
1195
+ async journaledActiveTurn(s) {
735
1196
  const turnId = str(s.active_stream_id).trim();
736
1197
  const activeTurnToken = buildActiveTurnToken(turnId, s.pending_started_at);
737
1198
  if (!turnId || !activeTurnToken || this.deps.runtime.journalDegraded(turnId))
738
1199
  return null;
739
- const summary = this.deps.journal?.findRunSummary(turnId);
1200
+ const summary = (await this.deps.journal?.findRunSummary(turnId));
740
1201
  if (summary?.session_id !== s.session_id || summary.journal_pruned)
741
1202
  return null;
742
1203
  return { turnId, startedAt: Number(s.pending_started_at), activeTurnToken };
@@ -752,8 +1213,9 @@ export class SessionService {
752
1213
  return { turnId, startedAt, activeTurnToken, localCount: s.messages.length, prompt: pendingUserRow(text, s.pending_attachments, startedAt, s.pending_user_source || 'webui', turnId) };
753
1214
  }
754
1215
  /** 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);
1216
+ async foreignSession(sid) {
1217
+ sid = await this.resolveSessionId(sid);
1218
+ const meta = (await this.lookupCliMeta(sid));
757
1219
  const profile = str(meta?.profile) || null;
758
1220
  const profileAgnostic = str(meta?.source_tag || meta?.raw_source).trim().toLowerCase() === 'claude_code';
759
1221
  if (!profileAgnostic && !this.visibleToActiveProfile(profile)) {
@@ -761,16 +1223,19 @@ export class SessionService {
761
1223
  throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile });
762
1224
  throw new HttpFailure(404, 'Session not found');
763
1225
  }
764
- const { session: synth, reason } = this.claimOrSynthesizeCliSession(sid, meta);
1226
+ const { session: synth, reason } = (await this.claimOrSynthesizeCliSession(sid, meta));
765
1227
  if (!synth || reason === 'was_webui')
766
1228
  throw new HttpFailure(404, 'Session not found');
767
1229
  return { synth, meta };
768
1230
  }
769
1231
  /** 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);
1232
+ foreignMessages(messages, session) {
1233
+ return hydrateAnchorActivityScenes(withToolCallOutcomes(withMarkerKinds(withTurnIds(messages), session), [], null), {});
1234
+ }
1235
+ async foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore) {
1236
+ const { synth, meta } = (await this.foreignSession(sid));
772
1237
  // 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), {});
1238
+ const msgs = this.foreignMessages(synth.messages, synth);
774
1239
  const lastTs = Number(msgs[msgs.length - 1]?.timestamp ?? 0) || 0;
775
1240
  const sess = {
776
1241
  session_id: synth.session_id, title: synth.title, workspace: synth.workspace, model: synth.model, message_count: msgs.length,
@@ -778,14 +1243,15 @@ export class SessionService {
778
1243
  pinned: synth.pinned, archived: synth.archived, project_id: synth.project_id ?? null, profile: synth.profile,
779
1244
  is_cli_session: synth.is_cli_session, source_tag: synth.source_tag, raw_source: synth.raw_source, session_source: synth.session_source,
780
1245
  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),
1246
+ assistant_name: this.assistantName(synth), workspace_name: (await this.workspaceNames([synth]))(synth), confirms_deep_truncate: true,
782
1247
  };
783
1248
  attachTodoState(sess, msgs);
784
- const merged = withSessionWireFlags(meta ? mergeCliSidebarMetadata(sess, meta) : sess, this.deps.runtime.activeStreamIds);
785
- return redactSessionData(merged, this.deps.redactEnabled());
1249
+ const merged = withSessionWireFlags((await this.ownerLocked([meta ? mergeCliSidebarMetadata(sess, meta) : sess]))[0], this.deps.runtime.activeStreamIds);
1250
+ const publicRow = redactSessionData(merged, this.deps.redactEnabled());
1251
+ return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
786
1252
  }
787
1253
  /** Clear persisted streaming flags when no live stream backs them (Python `_clear_stale_stream_state`). */
788
- clearStaleStreamState(session) {
1254
+ async clearStaleStreamState(session) {
789
1255
  const streamId = session.active_stream_id;
790
1256
  if (!streamId)
791
1257
  return false;
@@ -796,9 +1262,12 @@ export class SessionService {
796
1262
  const pendingAge = session.pending_started_at ? this.deps.now() - session.pending_started_at : null;
797
1263
  if (session.hasPendingPrompt && pendingAge !== null && pendingAge < 30)
798
1264
  return false;
1265
+ // Reading a display-only session must not settle the previous Talaria worker's transcript.
1266
+ if ((await this.isReadOnly(session)))
1267
+ return false;
799
1268
  let target = session;
800
1269
  if (session.loadedMetadataOnly) {
801
- const full = this.store.load(session.session_id);
1270
+ const full = (await this.store.load(session.session_id));
802
1271
  if (!full)
803
1272
  return false;
804
1273
  target = full;
@@ -816,13 +1285,13 @@ export class SessionService {
816
1285
  // run that never finished with an answer, so a dead stream never silently drops what the user sent or what streamed.
817
1286
  const turnId = str(target.active_stream_id);
818
1287
  // 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 ?? [];
1288
+ const events = (await this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS))?.events ?? [];
820
1289
  const { rows: output, answered } = journalOutputRows(events, turnId);
821
1290
  const pendingText = str(target.pending_user_message);
822
1291
  const attachments = [...target.pending_attachments];
823
1292
  // The run's starting read died with its worker: the state.db rows from before it started stand in for it. Its own
824
1293
  // rows open with its prompt and run to the next prompt; anything else another client wrote meanwhile stays.
825
- const read = this.stateDbRead(target);
1294
+ const read = await this.settlementRead(target);
826
1295
  const runStart = target.pending_started_at || events[0]?.created_at || this.deps.now();
827
1296
  const before = read.rows.filter((m) => Number(m.timestamp) < runStart);
828
1297
  const runRows = read.rows.filter((m) => Number(m.timestamp) >= runStart);
@@ -843,7 +1312,7 @@ export class SessionService {
843
1312
  if (target.hasPendingPrompt || output.length) {
844
1313
  target.messages.push(...output);
845
1314
  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 });
1315
+ 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
1316
  // 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
1317
  this.settleStateDb(target, { turnId, previousContext, agentRows: ownRows, startId: stateDbSeenId(before) ?? (read.idCapable ? 0 : null), read });
849
1318
  }
@@ -853,7 +1322,7 @@ export class SessionService {
853
1322
  target.pending_started_at = null;
854
1323
  target.pending_user_source = null;
855
1324
  try {
856
- this.store.save(target, { touchUpdatedAt: false });
1325
+ (await this.store.save(target, { touchUpdatedAt: false }));
857
1326
  }
858
1327
  catch {
859
1328
  return false;
@@ -864,33 +1333,60 @@ export class SessionService {
864
1333
  session.pending_attachments = [];
865
1334
  session.pending_started_at = null;
866
1335
  session.pending_user_source = null;
867
- this.store.touch(target);
1336
+ await this.store.touch(target);
868
1337
  }
869
1338
  return true;
870
1339
  }
871
1340
  // ── list / search ────────────────────────────────────────────────────────
1341
+ /** Expand saved aliases in one worker query independently of the bounded native sidebar window. */
1342
+ async persistedAliasMetadata(rows, existing = []) {
1343
+ const profile = this.deps.activeProfile();
1344
+ const represented = new Set(existing.flatMap((row) => [...sessionLineageIds(row)]));
1345
+ 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));
1346
+ if (!ids.length)
1347
+ return { rows: existing };
1348
+ const extra = await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: ids });
1349
+ return { rows: [...new Map([...existing, ...extra.rows].map((row) => [str(row.session_id), row])).values()], storage: extra.storage };
1350
+ }
872
1351
  async list(params) {
873
1352
  const activeProfile = this.deps.activeProfile();
1353
+ const webuiRows = (await allSessions(this.store, { sidebarMetadataOnly: true }));
874
1354
  const wantState = params.showCliSessions || params.showCronSessions || params.showWebhookSessions || params.showKanbanSessions;
875
1355
  // Python reads every profile's state.db under all_profiles; this port projects the active profile only.
876
1356
  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;
1357
+ const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? (await this.claudeCodeRows()) : [];
1358
+ let cliRows = cliRead ? [...cliRead.rows, ...claudeCodeRows] : undefined;
1359
+ let agentStorage = cliRead?.storage;
1360
+ // TAL-1040: diagnostic chats Web started are Web chats, read even when external Agent sessions are hidden.
1361
+ const webDiagnosticIds = new Set(params.sourceFilter ? [] : await stateDbWebDiagnosticIdsAsync(join(this.deps.profileHome(activeProfile), 'state.db')));
1362
+ const unread = [...webDiagnosticIds].filter((id) => !cliRows?.some((row) => sessionLineageIds(row).has(id)));
1363
+ if (unread.length) {
1364
+ const extra = await this.deps.cliSessions(activeProfile, { sourceFilter: null, sessionIds: unread, keepEmpty: true });
1365
+ cliRows = [...(cliRows ?? []), ...extra.rows];
1366
+ agentStorage ??= extra.storage;
1367
+ }
879
1368
  const gatewayIdentity = loadGatewaySessionIdentityMap(join(this.deps.profileHome(activeProfile), 'sessions', 'sessions.json'));
880
1369
  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());
1370
+ if (cliRows) {
1371
+ const expanded = await this.persistedAliasMetadata(webuiRows, cliRows);
1372
+ cliRows = expanded.rows;
1373
+ if (expanded.storage && (expanded.storage.state === 'corrupt' || agentStorage?.state === 'ok'))
1374
+ agentStorage = expanded.storage;
1375
+ }
1376
+ const lookups = await withStateDbListingReads(() => this.listStateDbLookups(webuiRows, (cliRows ?? []).map((r) => str(r.session_id))));
1377
+ const payload = (await buildSessionListPayload(this.store, { ...params, ...(cliRows ? { cliRows } : {}), ...(truncatedSources ? { truncatedSources } : {}), ...(agentStorage ? { agentStorage } : {}), gatewayIdentity, ...lookups, webuiRows, webDiagnosticIds, activeProfile, isolatedProfileMode: this.deps.isolatedProfileMode(), profilesMatch: this.deps.profilesMatch }));
1378
+ return sessionListResponse(payload, this.deps.runtime, this.deps.redactEnabled(), this.deps.now(), await this.workspaceNames([...payload.sessions, ...payload.sidebar_reference_sessions]));
885
1379
  }
886
- search(q, opts) {
1380
+ async search(q, opts) {
887
1381
  const activeProfile = this.deps.activeProfile();
888
- let sessions = allSessions(this.store);
1382
+ let sessions = (await allSessions(this.store));
889
1383
  if (!opts.allProfiles)
890
1384
  sessions = sessions.filter((r) => this.deps.profilesMatch(str(r.profile) || null, activeProfile));
891
- sessions = withOwnerLocks(sessions, this.stateDbSources);
1385
+ const metadata = await this.persistedAliasMetadata(sessions);
1386
+ sessions = reconcileLocalSessionRows(sessions, metadata.rows, this.deps.profilesMatch);
1387
+ sessions = await this.ownerLocked(sessions);
892
1388
  const redact = this.deps.redactEnabled();
893
- const workspaceName = this.workspaceNames();
1389
+ const workspaceName = await this.workspaceNames(sessions);
894
1390
  const redactRow = (item) => {
895
1391
  withSessionWireFlags(item, this.deps.runtime.activeStreamIds);
896
1392
  item.workspace_name = workspaceName(item);
@@ -905,17 +1401,16 @@ export class SessionService {
905
1401
  if (!query)
906
1402
  return { sessions: sessions.map((s) => redactRow({ ...s })), all_profiles: opts.allProfiles, active_profile: activeProfile };
907
1403
  const terms = sessionSearchTerms(query);
1404
+ 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
1405
  const results = [];
909
1406
  for (const s of sessions) {
910
1407
  if (sessionSearchMatches(str(s.title), terms)) {
911
1408
  results.push(redactRow({ ...s, match_type: 'title' }));
912
1409
  continue;
913
1410
  }
914
- if (!opts.content)
915
- continue;
916
- const hit = this.contentMatch(str(s.session_id), terms, opts.depth);
1411
+ const hit = matches.get(str(s.session_id));
917
1412
  if (hit)
918
- results.push(redactRow({ ...s, ...hit }));
1413
+ results.push(redactRow({ ...s, ...this.contentMatch(hit.text, terms) }));
919
1414
  }
920
1415
  return { sessions: results, query, count: results.length, all_profiles: opts.allProfiles, active_profile: activeProfile };
921
1416
  }
@@ -935,46 +1430,35 @@ export class SessionService {
935
1430
  if (!query)
936
1431
  return { ...base, sessions: rows };
937
1432
  const terms = sessionSearchTerms(query);
1433
+ const metadataMatch = (row) => ['workspace', 'workspace_name', 'model', 'model_provider', 'profile', 'source_label'].some((k) => sessionSearchMatches(str(row[k]), terms));
1434
+ 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
1435
  const results = [];
939
1436
  for (const row of rows) {
940
1437
  if (sessionSearchMatches(str(row.title), terms))
941
1438
  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)))
1439
+ else if (metadataMatch(row))
943
1440
  results.push({ ...row, match_type: 'metadata' });
944
- else if (opts.content) {
945
- const hit = this.contentMatch(str(row.session_id), terms, opts.depth);
1441
+ else {
1442
+ const hit = matches.get(str(row.session_id));
946
1443
  if (hit)
947
- results.push({ ...row, ...hit });
1444
+ results.push({ ...row, ...this.contentMatch(hit.text, terms) });
948
1445
  }
949
1446
  }
950
1447
  return { ...base, sessions: results, query, count: results.length };
951
1448
  }
952
1449
  /** 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;
1450
+ contentMatch(text, terms) {
1451
+ const item = { match_type: 'content' };
1452
+ const preview = sessionSearchPreview(text, terms);
1453
+ if (preview)
1454
+ item.match_preview = redactText(preview, this.deps.redactEnabled());
1455
+ return item;
972
1456
  }
973
1457
  // ── status / usage ───────────────────────────────────────────────────────
974
- status(sid) {
975
- const s = this.get404(sid, { metadataOnly: true });
976
- this.clearStaleStreamState(s);
977
- const full = this.get404(sid);
1458
+ async status(sid) {
1459
+ const s = (await this.get404(sid, { metadataOnly: true }));
1460
+ await this.clearStaleStreamState(s);
1461
+ const full = (await this.get404(sid));
978
1462
  const inp = Math.trunc(full.input_tokens) || 0;
979
1463
  const out = Math.trunc(full.output_tokens) || 0;
980
1464
  const profile = full.profile || 'default';
@@ -985,37 +1469,60 @@ export class SessionService {
985
1469
  catch {
986
1470
  hermesHome = '';
987
1471
  }
988
- const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, active_turn_origin: origin } = this.wireRow(full);
1472
+ const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, read_only_reason: readOnlyReason, active_turn_origin: origin } = (await this.wireRow(full));
989
1473
  return {
990
1474
  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,
1475
+ 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
1476
  input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: full.estimated_cost,
993
1477
  };
994
1478
  }
995
- usage(sid) {
996
- const s = this.get404(sid);
1479
+ async contextDetail(sid, expectedKey) {
1480
+ if (await this.owner?.isOwned(sid))
1481
+ return this.owner.contextDetail(sid, expectedKey);
1482
+ const session = await this.get404(sid);
1483
+ const profile = this.deps.activeProfile();
1484
+ if (session.profile && session.profile !== profile || await this.loadedReadOnlyReason(session))
1485
+ return unavailableContextDetail();
1486
+ const sidecar = this.deps.sidecar?.();
1487
+ if (!sidecar)
1488
+ return unavailableContextDetail();
1489
+ // The read owns no cache. Compression and model/workspace replacement invalidate its response.
1490
+ const marks = (current = session) => this.contextDetailKey(current);
1491
+ const before = marks();
1492
+ if (before !== expectedKey)
1493
+ return unavailableContextDetail('changed');
1494
+ try {
1495
+ const detail = await sidecar.call('chat.context_detail', { session_id: sid, profile_home: this.deps.profileHome(profile), model: session.model, model_provider: session.model_provider, workspace: session.workspace });
1496
+ return this.deps.activeProfile() === profile && before === marks(await this.get404(sid)) ? detail : unavailableContextDetail('changed');
1497
+ }
1498
+ catch {
1499
+ return unavailableContextDetail();
1500
+ }
1501
+ }
1502
+ async usage(sid) {
1503
+ const s = (await this.get404(sid));
997
1504
  const inp = Math.trunc(s.input_tokens) || 0;
998
1505
  const out = Math.trunc(s.output_tokens) || 0;
999
1506
  return { input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: s.estimated_cost, model: s.model };
1000
1507
  }
1001
1508
  // ── creation ─────────────────────────────────────────────────────────────
1002
- resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
1509
+ async resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
1003
1510
  const candidate = body.workspace;
1004
1511
  if (!candidate)
1005
1512
  return null;
1006
1513
  const value = str(candidate);
1007
1514
  if (body.workspace_inherited_from_prev_session !== true || !visiblePrevSessionId)
1008
- return this.deps.workspaces.resolveTrusted(value, profile);
1515
+ return (await this.deps.workspaces.resolveTrusted(value, profile));
1009
1516
  let previous;
1010
1517
  try {
1011
- previous = this.store.get(visiblePrevSessionId, { metadataOnly: true });
1518
+ previous = (await this.store.get(visiblePrevSessionId, { metadataOnly: true }));
1012
1519
  }
1013
1520
  catch {
1014
- return this.deps.workspaces.resolveTrusted(value, profile);
1521
+ return (await this.deps.workspaces.resolveTrusted(value, profile));
1015
1522
  }
1016
1523
  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);
1524
+ return (await this.deps.workspaces.resolveTrusted(value, profile));
1525
+ const [workspace] = (await this.deps.workspaces.resolveImplicitWithRecovery(value, async (p) => (await this.deps.workspaces.profileDefaultWorkspaceForBoot(p)), profile));
1019
1526
  return workspace;
1020
1527
  }
1021
1528
  /** The server owns the names' normalization (TAL-631): trimmed, blanks dropped, and nothing left is the profile's defaults. */
@@ -1029,46 +1536,76 @@ export class SessionService {
1029
1536
  const names = toolsets.map((t) => t.trim()).filter(Boolean);
1030
1537
  return names.length ? names : null;
1031
1538
  }
1032
- create(body, opts = {}) {
1539
+ async create(body, opts = {}) {
1033
1540
  // Python `new_session`: a body without a profile takes the request's active profile (cookie first).
1034
1541
  const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
1035
1542
  let prevSessionId = typeof body.prev_session_id === 'string' && body.prev_session_id ? body.prev_session_id : null;
1036
- if (prevSessionId && !this.sessionIdVisible(prevSessionId))
1543
+ if (prevSessionId && !(await this.sessionIdVisible(prevSessionId)))
1037
1544
  prevSessionId = null;
1038
1545
  // Python: leaving a session for a new one flushes the previous session's memory in the background (W6).
1039
1546
  if (prevSessionId)
1040
- this.deps.commitSessionMemory?.(prevSessionId);
1547
+ await this.deps.commitSessionMemory?.(prevSessionId);
1041
1548
  let workspace;
1042
1549
  try {
1043
- workspace = this.resolveNewSessionWorkspace(body, prevSessionId, profile);
1550
+ workspace = (await this.resolveNewSessionWorkspace(body, prevSessionId, profile));
1044
1551
  }
1045
1552
  catch (error) {
1046
1553
  throw new HttpFailure(400, error.message);
1047
1554
  }
1048
1555
  const [model, provider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
1049
1556
  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 });
1557
+ 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
1558
  if (opts.worktree)
1052
1559
  this.publish('session_new', s.profile, s.session_id);
1053
1560
  return s;
1054
1561
  }
1562
+ /**
1563
+ * TAL-1040: an `ignore_config` / `safe` chat starts through the active profile's Agent owner, which freezes the mode before
1564
+ * any Agent import. That owner reads no profile default, so the launch route is explicit: the requested model, else this
1565
+ * profile's default as a normal chat starts, with that provider's configured endpoint.
1566
+ */
1567
+ async createDiagnostic(body, mode) {
1568
+ const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
1569
+ if (profile !== this.deps.activeProfile())
1570
+ throw new HttpFailure(400, 'A diagnostic chat starts in the active profile');
1571
+ if (!this.owner)
1572
+ throw new HttpFailure(409, 'The Agent cannot start a chat in this mode', { code: 'execution_mode_unavailable', reason: 'gateway_unavailable' });
1573
+ const defaults = await this.store.deps.defaults(profile);
1574
+ let workspace;
1575
+ try {
1576
+ workspace = await this.resolveNewSessionWorkspace(body, null, profile);
1577
+ }
1578
+ catch (error) {
1579
+ throw new HttpFailure(400, error.message);
1580
+ }
1581
+ const [requested, requestedProvider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
1582
+ const model = requested ?? defaults.model;
1583
+ const provider = requested ? requestedProvider : requestedProvider ?? defaults.modelProvider ?? null;
1584
+ if (!model)
1585
+ throw new HttpFailure(400, 'Choose a model before starting a diagnostic chat');
1586
+ const toolsets = this.validateToolsetsShape(body.enabled_toolsets);
1587
+ const route = provider ? this.deps.profileLaunchRoute?.(profile, provider) ?? { provider, baseUrl: null } : null;
1588
+ const sid = await this.owner.create(profile, typeof body.request_id === 'string' ? body.request_id : null, { execution_mode: mode, cwd: workspace ?? defaults.workspace, model,
1589
+ ...(route ? { provider: route.provider } : {}), ...(route?.baseUrl ? { base_url: route.baseUrl } : {}), ...(toolsets ? { toolsets } : {}) });
1590
+ return this.detail(sid, {});
1591
+ }
1055
1592
  // ── simple metadata mutations ────────────────────────────────────────────
1056
1593
  /** Python `_persist_generated_session_title`: title + generated/manual flags under the session lock, then insights sync. */
1057
1594
  async persistGeneratedTitle(sid, nextTitle, eventReason) {
1058
1595
  const title = nextTitle.trim().slice(0, 80) || 'Untitled';
1059
1596
  let current;
1060
1597
  try {
1061
- current = this.store.get(sid);
1598
+ current = (await this.store.get(sid));
1062
1599
  }
1063
1600
  catch {
1064
1601
  throw new HttpFailure(404, 'Session not found');
1065
1602
  }
1066
- if (this.isReadOnly(current))
1603
+ if ((await this.isReadOnly(current)))
1067
1604
  throw new HttpFailure(403, `Session ${sid} is read-only`);
1068
- await this.store.withLock(sid, () => {
1605
+ await this.withMutationLock(sid, async () => {
1069
1606
  current.title = title;
1070
1607
  markSessionTitleGenerated(current);
1071
- this.store.save(current, { touchUpdatedAt: false });
1608
+ await this.store.save(current, { touchUpdatedAt: false });
1072
1609
  });
1073
1610
  // An explicit regenerate is user-requested, so state.db takes it over a manual name.
1074
1611
  await this.deps.syncTitle(current, true);
@@ -1076,29 +1613,31 @@ export class SessionService {
1076
1613
  return current;
1077
1614
  }
1078
1615
  async rename(sid, rawTitle) {
1079
- const s = this.mutationTarget(sid, 'renamed');
1080
- await this.store.withLock(sid, () => {
1616
+ const reads = this.sessionGateReads(sid);
1617
+ const s = (await this.mutationTarget(sid, 'renamed', reads));
1618
+ await this.withMutationLock(sid, async () => {
1081
1619
  applySessionTitleRename(s, rawTitle);
1082
- this.store.save(s);
1620
+ await this.store.save(s);
1083
1621
  });
1084
1622
  await this.deps.syncTitle(s, true);
1085
1623
  this.publish('session_rename', s.profile, s.session_id);
1086
- return { session: this.wireRow(s) };
1624
+ return { session: (await this.wireRow(s)) };
1087
1625
  }
1088
1626
  // The pin quota is counted across sessions, so the count-and-save transaction runs on one process-wide chain;
1089
1627
  // per-session locks alone would let two pins of different sessions both see the last free slot.
1090
1628
  pinChain = Promise.resolve();
1091
1629
  pin(sid, pinRequested) {
1092
- const run = this.pinChain.then(() => this.pinUnserialized(sid, pinRequested));
1630
+ const run = this.pinChain.then(async () => (await this.pinUnserialized(sid, pinRequested)));
1093
1631
  this.pinChain = run.catch(() => undefined);
1094
1632
  return run;
1095
1633
  }
1096
1634
  async pinUnserialized(sid, pinRequested) {
1097
- this.rejectSubagent(sid, 'modified');
1098
- let s = this.get404(sid);
1099
- s = this.store.ensureFull(sid, s);
1635
+ const reads = this.sessionGateReads(sid);
1636
+ await this.rejectViewOnly(sid, 'modified', reads);
1637
+ let s = (await this.get404(sid, {}, reads));
1638
+ s = (await this.store.ensureFull(sid, s));
1100
1639
  if (pinRequested && !s.pinned) {
1101
- const rows = allSessions(this.store);
1640
+ const rows = (await allSessions(this.store));
1102
1641
  const byId = new Map(rows.filter((r) => r.session_id).map((r) => [str(r.session_id), r]));
1103
1642
  byId.set(s.session_id, s.compact());
1104
1643
  const targetLineage = lineageRootId(s.compact(), byId);
@@ -1108,38 +1647,165 @@ export class SessionService {
1108
1647
  if (pinnedLineages.size >= limit)
1109
1648
  throw new HttpFailure(400, `Up to ${limit} sessions can be pinned. Unpin one before pinning another.`);
1110
1649
  }
1111
- await this.store.withLock(sid, () => {
1650
+ await this.withMutationLock(sid, async () => {
1112
1651
  s.pinned = pinRequested;
1113
- this.store.save(s);
1114
- });
1652
+ await this.store.save(s);
1653
+ }, { allowReadOnly: true });
1115
1654
  this.publish('session_pin', s.profile, s.session_id);
1116
- return { ok: true, session: this.wireRow(s) };
1655
+ return { ok: true, session: (await this.wireRow(s)) };
1656
+ }
1657
+ /**
1658
+ * TAL-1254: relock the worktrees of every unarchived worktree chat (or just `only`) for the running sidecar, so the
1659
+ * Agent's worktree sweep reads them as live, and flag each one git no longer has as retained. Runs before a
1660
+ * (re)started sidecar reports ready and when a chat is unarchived. The Agent's sweep runs outside Talaria, so a lock
1661
+ * the sidecar could not confirm is retried until git takes it; meanwhile that tree keeps its flag.
1662
+ */
1663
+ async retainWorktrees(call, only) {
1664
+ const { rows, unread } = only ? { rows: [only.compact()], unread: 0 } : await this.worktreeSessionRows();
1665
+ const targets = rows.filter((row) => !row.archived && typeof row.worktree_path === 'string' && row.worktree_path && typeof row.session_id === 'string');
1666
+ if (!targets.length) {
1667
+ if (unread)
1668
+ this.retryWorktreeRetention();
1669
+ return;
1670
+ }
1671
+ let results;
1672
+ try {
1673
+ ({ results } = await call('worktree.retain', { paths: [...new Set(targets.map((row) => String(row.worktree_path)))] }));
1674
+ }
1675
+ catch (error) {
1676
+ this.retryWorktreeRetention();
1677
+ throw error;
1678
+ }
1679
+ const unconfirmed = targets.filter((row) => !results[String(row.worktree_path)]).length + unread;
1680
+ if (!only && unconfirmed !== this.worktreesUnconfirmed)
1681
+ this.deps.log(`[webui] ${String(unconfirmed)} worktree lock(s) not yet held by the sidecar${unconfirmed ? '; retrying' : ''}`);
1682
+ if (!only)
1683
+ this.worktreesUnconfirmed = unconfirmed;
1684
+ if (unconfirmed)
1685
+ this.retryWorktreeRetention();
1686
+ for (const row of targets) {
1687
+ const state = results[String(row.worktree_path)];
1688
+ const sid = String(row.session_id);
1689
+ if (!state)
1690
+ continue;
1691
+ try {
1692
+ // `extra` follows the transcript in the file, past what a metadata-only read sees.
1693
+ if (row._unread)
1694
+ continue;
1695
+ const s = await this.store.ensureFull(sid, await this.store.get(sid, { metadataOnly: true }));
1696
+ if ((state === 'missing') === (s.extra[WORKTREE_RETAINED_KEY] === true))
1697
+ continue;
1698
+ await this.withMutationLock(sid, async () => {
1699
+ s.extra = Object.fromEntries(Object.entries(s.extra).filter(([key]) => key !== WORKTREE_RETAINED_KEY));
1700
+ if (state === 'missing')
1701
+ s.extra[WORKTREE_RETAINED_KEY] = true;
1702
+ await this.store.save(s, { touchUpdatedAt: false });
1703
+ }, { allowReadOnly: true });
1704
+ this.publish('session_worktree', s.profile, sid);
1705
+ }
1706
+ catch (error) {
1707
+ if (error instanceof SessionNotFound)
1708
+ continue;
1709
+ this.deps.log(`[webui] could not record the worktree state of ${sid}: ${error.message}`);
1710
+ this.retryWorktreeRetention();
1711
+ }
1712
+ }
1713
+ }
1714
+ /**
1715
+ * Every persisted worktree chat, read from its own file: the index only narrows the files to read, since a crash
1716
+ * between a save and its index update can leave a session out of the index or with a stale `archived`. A file that
1717
+ * cannot be read now, or a session directory that cannot be listed, falls back to the index rows (`_unread`, so their
1718
+ * flags are left alone) and counts in `unread`, which keeps retention retrying until every file has been read.
1719
+ */
1720
+ async worktreeSessionRows() {
1721
+ let indexed;
1722
+ try {
1723
+ indexed = new Map((await this.store.readIndexEntries()).map((row) => [str(row.session_id), row]));
1724
+ }
1725
+ catch {
1726
+ indexed = new Map();
1727
+ }
1728
+ let persisted;
1729
+ try {
1730
+ persisted = await this.store.persistedIds({ strict: true });
1731
+ }
1732
+ catch {
1733
+ // The session directory cannot be listed now: retain what the index names and retry until it can.
1734
+ const rows = [...indexed.values()].filter((row) => row.worktree_path).map((row) => ({ ...row, archived: false, _unread: true }));
1735
+ return { rows, unread: Math.max(1, rows.length) };
1736
+ }
1737
+ const candidates = [...persisted].filter((sid) => !indexed.has(sid) || indexed.get(sid)?.worktree_path);
1738
+ let unread = 0;
1739
+ const rows = await Promise.all(candidates.map(async (sid) => {
1740
+ try {
1741
+ return (await this.store.get(sid, { metadataOnly: true })).compact();
1742
+ }
1743
+ catch (error) {
1744
+ if (error instanceof SessionNotFound)
1745
+ return null;
1746
+ unread += 1;
1747
+ const row = indexed.get(sid);
1748
+ return row?.worktree_path ? { ...row, archived: false, _unread: true } : null;
1749
+ }
1750
+ }));
1751
+ return { rows: rows.filter((row) => row !== null), unread };
1752
+ }
1753
+ /** One pending retry at a time; a sidecar that is not ready by then retains everything in its next ready gate instead. */
1754
+ retryWorktreeRetention() {
1755
+ if (this.worktreeRetainRetry)
1756
+ return;
1757
+ this.worktreeRetainRetry = setTimeout(() => {
1758
+ this.worktreeRetainRetry = null;
1759
+ const sidecar = this.deps.sidecar?.();
1760
+ if (sidecar?.status !== 'ready')
1761
+ return;
1762
+ this.retainWorktrees((method, params, opts) => sidecar.call(method, params, opts)).catch((error) => { this.deps.log(`[webui] worktree lock retry failed: ${String(error)}`); });
1763
+ }, WORKTREE_RETAIN_RETRY_MS);
1764
+ this.worktreeRetainRetry.unref();
1117
1765
  }
1118
1766
  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, () => {
1767
+ const reads = this.sessionGateReads(sid);
1768
+ await this.rejectViewOnly(sid, 'archived', reads);
1769
+ let s = (await this.get404(sid, {}, reads));
1770
+ s = (await this.store.ensureFull(sid, s));
1771
+ await this.withMutationLock(sid, async () => {
1772
+ s = await this.store.ensureFull(sid, await this.store.get(sid));
1773
+ const previous = { archived: s.archived, pinned: s.pinned };
1774
+ const wasArchived = (await this.store.loadMetadataOnly(sid))?.archived ?? false;
1123
1775
  s.archived = archived;
1124
1776
  if (archived)
1125
1777
  s.pinned = false;
1126
- this.store.save(s, { touchUpdatedAt: false });
1127
- });
1778
+ try {
1779
+ await this.store.save(s, { touchUpdatedAt: false });
1780
+ const saved = await this.store.loadMetadataOnly(sid);
1781
+ if (saved?.archived !== archived || saved.pinned !== s.pinned)
1782
+ throw new HttpFailure(409, 'Archive was not saved; reload the chat and retry');
1783
+ }
1784
+ catch (error) {
1785
+ Object.assign(s, previous);
1786
+ throw error;
1787
+ }
1788
+ if (archived && !wasArchived)
1789
+ await this.finalizeSession(sid, s.profile, 'archive', stateDbReadLineage(s) ?? undefined);
1790
+ }, { allowReadOnly: true });
1791
+ const sidecar = this.deps.sidecar?.();
1792
+ if (!archived && sidecar)
1793
+ await this.retainWorktrees((method, params, opts) => sidecar.call(method, params, opts), s).catch(() => undefined);
1128
1794
  this.publish('session_archive', s.profile, s.session_id);
1129
- return { ok: true, session: this.wireRow(s), ...worktreeRetainedPayload(s) };
1795
+ return { ok: true, session: (await this.wireRow(s)), ...worktreeRetainedPayload(s) };
1130
1796
  }
1131
1797
  async move(sid, targetProjectId) {
1132
- const s = this.mutationTarget(sid, 'moved');
1798
+ const s = (await this.mutationTarget(sid, 'moved'));
1133
1799
  if (targetProjectId) {
1134
1800
  const sessionProfile = s.profile || this.deps.activeProfile();
1135
- const target = this.deps.projects.load().find((p) => p.project_id === targetProjectId);
1801
+ const target = (await this.deps.projects.load()).find((p) => p.project_id === targetProjectId);
1136
1802
  if (!target || !this.deps.profilesMatch(target.profile ?? null, sessionProfile))
1137
1803
  throw new HttpFailure(404, 'Project not found');
1138
1804
  }
1139
1805
  try {
1140
- await this.store.withLock(sid, () => {
1806
+ await this.withMutationLock(sid, async () => {
1141
1807
  s.project_id = targetProjectId;
1142
- this.store.save(s);
1808
+ await this.store.save(s);
1143
1809
  }, { timeoutMs: 5000 });
1144
1810
  }
1145
1811
  catch (error) {
@@ -1148,67 +1814,111 @@ export class SessionService {
1148
1814
  throw error;
1149
1815
  }
1150
1816
  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;
1817
+ return { ok: true, session: (await this.wireRow(s)) };
1818
+ }
1819
+ modelConfirmationKey = randomUUID();
1820
+ async confirmModelSwitch(s, model, provider, token, retry = false) {
1821
+ const stateRows = await this.stateDbRows(s);
1822
+ const profile = s.profile ?? this.deps.activeProfile();
1823
+ const home = this.deps.profileHome(profile);
1824
+ const context = contextUsage(s).context_used_tokens;
1825
+ const messages = this.modelContext(s, stateRows);
1826
+ const before = JSON.stringify([s.model, s.model_provider, context, s.context_length, messages]);
1827
+ let decision;
1159
1828
  try {
1160
- newWs = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
1829
+ const sidecar = this.deps.sidecar?.();
1830
+ if (!sidecar)
1831
+ throw new Error('Agent sidecar is unavailable');
1832
+ 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
1833
  }
1162
1834
  catch (error) {
1163
- throw new HttpFailure(400, error.message);
1835
+ throw new HttpFailure(503, `Cannot check model selection warnings: ${error.message}`);
1164
1836
  }
1165
- await this.store.withLock(sid, async () => {
1837
+ const stateRowsAfter = await this.stateDbRows(s);
1838
+ if (before !== JSON.stringify([s.model, s.model_provider, contextUsage(s).context_used_tokens, s.context_length, this.modelContext(s, stateRowsAfter)])) {
1839
+ if (retry)
1840
+ throw new HttpFailure(503, 'The session changed while checking model warnings. Try the selection again.');
1841
+ return this.confirmModelSwitch(s, model, provider, null, true);
1842
+ }
1843
+ if (!decision.warnings.length)
1844
+ return;
1845
+ const signature = JSON.stringify([profile, home, s.session_id, s.model, s.model_provider, model, provider, context, s.context_length, messages, decision]);
1846
+ const confirmToken = createHmac('sha256', this.modelConfirmationKey).update(signature).digest('hex');
1847
+ if (token !== confirmToken)
1848
+ throw new HttpFailure(409, decision.warnings.map((w) => w.message).join('\n\n'), { type: 'model_confirmation_required', warnings: decision.warnings, confirm_token: confirmToken });
1849
+ }
1850
+ async update(sid, body) {
1851
+ let s = (await this.mutationTarget(sid, 'updated'));
1852
+ let oldWs = s.workspace;
1853
+ let newWs = oldWs;
1854
+ await this.withMutationLock(sid, async () => {
1855
+ s = await this.mutationTarget(sid, 'updated');
1856
+ oldWs = s.workspace;
1857
+ try {
1858
+ newWs = await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
1859
+ }
1860
+ catch (error) {
1861
+ throw new HttpFailure(400, error.message);
1862
+ }
1863
+ const oldModel = s.model;
1864
+ const oldProvider = s.model_provider;
1865
+ const hasModel = 'model' in body || 'model_provider' in body;
1866
+ 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];
1867
+ const nextModel = model ?? s.model;
1868
+ const changed = hasModel && (str(oldModel) !== str(nextModel) || str(oldProvider) !== str(provider));
1869
+ const contextLength = changed ? (this.deps.resolveContextLength ? await this.deps.resolveContextLength(nextModel, provider, s.profile) : this.deps.contextLengthFor(nextModel, provider)) : s.context_length;
1870
+ if (changed)
1871
+ await this.confirmModelSwitch(s, nextModel ?? '', provider, body.confirm_token);
1872
+ await this.assertWritable(s);
1166
1873
  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;
1874
+ if (hasModel) {
1875
+ s.model = nextModel;
1171
1876
  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);
1877
+ if (changed) {
1878
+ s.context_length = contextLength;
1879
+ forgetContextFigures(s);
1177
1880
  }
1178
1881
  }
1179
- this.store.save(s);
1882
+ if (changed || oldWs !== newWs)
1883
+ await this.deps.runtime.evictAgent(sid);
1884
+ await this.store.save(s);
1180
1885
  });
1181
1886
  if (str(oldWs) !== str(newWs))
1182
1887
  this.deps.runtime.closeTerminal(sid);
1183
- this.deps.workspaces.setLastWorkspace(newWs, s.profile);
1184
- return { session: this.publicSession(s) };
1888
+ await this.deps.workspaces.setLastWorkspace(newWs, s.profile);
1889
+ return { session: (await this.publicSession(s)) };
1185
1890
  }
1186
1891
  async setToolsets(sid, toolsets) {
1187
- this.rejectSubagent(sid, 'modified');
1892
+ await this.rejectViewOnly(sid, 'modified');
1188
1893
  const cleaned = this.validateToolsetsShape(toolsets);
1189
- const s = this.mutationTarget(sid, 'modified');
1190
- await this.store.withLock(sid, () => {
1894
+ const s = (await this.mutationTarget(sid, 'modified'));
1895
+ await this.withMutationLock(sid, async () => {
1191
1896
  s.enabled_toolsets = cleaned;
1192
- this.store.save(s);
1897
+ await this.store.save(s);
1193
1898
  });
1194
1899
  return { ok: true, enabled_toolsets: s.enabled_toolsets };
1195
1900
  }
1196
1901
  /** Python `/api/personality/set` persistence: the name only; the prompt is resolved by the caller. */
1197
1902
  async setPersonality(sid, name) {
1198
- this.rejectSubagent(sid, 'modified');
1199
- const s = this.mutationTarget(sid, 'modified');
1200
- await this.store.withLock(sid, () => {
1903
+ await this.rejectViewOnly(sid, 'modified');
1904
+ const s = (await this.mutationTarget(sid, 'modified'));
1905
+ await this.withMutationLock(sid, async () => {
1201
1906
  s.personality = name;
1202
- this.store.save(s);
1907
+ await this.store.save(s);
1203
1908
  });
1204
1909
  return typeof s.personality === 'string' ? s.personality : null;
1205
1910
  }
1206
1911
  // ── transcript mutations ─────────────────────────────────────────────────
1207
- async truncate(sid, keepRaw) {
1208
- this.rejectSubagent(sid, 'modified');
1912
+ /**
1913
+ * TAL-929: under a `hold`, a cut that discards more than one user turn changes nothing and answers with its counts,
1914
+ * unless `confirmToken` is the token of the rows it would remove now: a transcript that changed since asks again.
1915
+ * Without one (a released App, which asked on the device) the cut proceeds as before.
1916
+ */
1917
+ async truncate(sid, keepRaw, hold = null) {
1918
+ await this.rejectViewOnly(sid, 'modified');
1209
1919
  if (keepRaw === null || keepRaw === undefined)
1210
1920
  throw new HttpFailure(400, 'Missing required field(s): keep_count');
1211
- const s = this.mutationTarget(sid, 'modified');
1921
+ const s = (await this.mutationTarget(sid, 'modified'));
1212
1922
  // Python `int(body["keep_count"])`: a float truncates, a non-integer string is rejected.
1213
1923
  let keep;
1214
1924
  if (typeof keepRaw === 'number')
@@ -1221,26 +1931,61 @@ export class SessionService {
1221
1931
  throw new HttpFailure(400, 'keep_count must be an integer');
1222
1932
  if (keep < 0)
1223
1933
  throw new HttpFailure(400, 'keep_count must be non-negative');
1224
- await this.store.withLock(sid, () => {
1934
+ const refused = await this.withMutationLock(sid, async () => {
1935
+ // One state.db read decides and marks the boundary, so a row committed meanwhile is neither counted nor hidden.
1936
+ const read = await this.stateDbRead(s);
1937
+ if (hold) {
1938
+ // A failed read cannot say whether a CLI continuation would go too: fail closed. The Agent keeps a state.db for every
1939
+ // profile it has run in, so a missing one is never evidence of a session without state.db rows.
1940
+ if (!read.ok)
1941
+ throw new HttpFailure(503, read.reason ?? 'The session history could not be read. Please try again in a moment.');
1942
+ const held = (await this.heldCut(s, keep, read.rows));
1943
+ if (held && held.confirm_token !== hold.confirmToken)
1944
+ return held;
1945
+ }
1946
+ // A cut inside the state.db continuation keeps the continuation's rows before it: the boundary below hides every
1947
+ // row this read saw, so those rows join the stored transcript and model history first.
1948
+ if (keep > s.messages.length) {
1949
+ if (s.context_messages.length)
1950
+ s.context_messages = this.modelContext(s, read.rows);
1951
+ s.messages = this.mergedTranscript(s, s.messages, read.rows);
1952
+ }
1225
1953
  truncateSessionAtKeep(s, keep);
1226
- this.markStateDbSeen(s, undefined, { boundary: true });
1227
- this.store.save(s);
1954
+ this.markStateDbSeen(s, read, { boundary: true });
1955
+ await this.store.save(s);
1956
+ return null;
1228
1957
  });
1229
- this.deps.runtime.evictAgent(sid);
1230
- return { ok: true, session: this.publicSession(s) };
1958
+ if (refused)
1959
+ return refused;
1960
+ await this.deps.runtime.evictAgent(sid);
1961
+ return { ok: true, session: (await this.publicSession(s)) };
1962
+ }
1963
+ /**
1964
+ * TAL-929: the confirmation a cut at `keep` needs, or null for one that removes at most one user turn. Counted on the rows
1965
+ * the detail shows, state.db continuation included, since `keep` indexes them: the user turns the cut removes, and the
1966
+ * messages after the row it starts at.
1967
+ */
1968
+ async heldCut(s, keep, stateRows) {
1969
+ const transcript = this.mergedTranscript(s, s.messages, stateRows);
1970
+ const cut = ((await this.publicSession(s, true, transcript)).messages ?? []).map((m, i) => ({ m, i })).filter(({ m, i }) => i >= keep && m._display === 'row');
1971
+ const userTurns = cut.filter(({ m }) => m.role === 'user').length;
1972
+ if (userTurns <= 1)
1973
+ return null;
1974
+ const token = createHash('sha256').update(JSON.stringify(transcript.slice(keep))).digest('hex').slice(0, 32);
1975
+ return { ok: false, confirm_required: true, discard_user_turns: userTurns, discard_messages: cut.filter(({ i }) => i > keep).length, confirm_token: token };
1231
1976
  }
1232
1977
  // ── manual compression (TAL-255) ────────────────────────────────────────
1233
1978
  /**
1234
1979
  * Python `_handle_session_compress_start`: validate, then join the session's running job or start a fresh one (a
1235
1980
  * finished job is replaced). The stale-runtime refusal comes before a job exists; a running job is joined without it.
1236
1981
  */
1237
- async startCompression(sid, focusRaw) {
1982
+ async startCompression(sid, focusRaw, opts = {}) {
1238
1983
  const running = () => { const job = this.compressionJobs.get(sid); return job?.status === 'running' ? job : undefined; };
1239
1984
  // A running job is joined first: once it has installed its short context, the guards below would refuse a new one.
1240
1985
  const existing = running();
1241
1986
  if (existing)
1242
1987
  return existing;
1243
- const { s } = this.compressionTarget(sid);
1988
+ const { s } = (await this.compressionTarget(sid));
1244
1989
  const focusTopic = str(focusRaw).trim().slice(0, 500) || null;
1245
1990
  await ensureAgentRuntimeCurrent(this.deps.sidecar?.() ?? null);
1246
1991
  // Another start may have admitted a job while the runtime check awaited.
@@ -1253,8 +1998,8 @@ export class SessionService {
1253
1998
  throw new HttpFailure(409, `Profile '${str(profile)}' is being deleted.`);
1254
1999
  const release = this.deps.profileActivity?.(profile);
1255
2000
  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) => {
2001
+ 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() };
2002
+ 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
2003
  const known = error instanceof HttpFailure;
1259
2004
  if (!known)
1260
2005
  this.deps.log(`[webui] Manual compression worker failed for session ${sid}: ${error.message}`);
@@ -1263,28 +2008,121 @@ export class SessionService {
1263
2008
  this.compressionJobs.set(job);
1264
2009
  return job;
1265
2010
  }
2011
+ /**
2012
+ * TAL-1005: compact the session in its joinable compression job before the next turn reads its history. A running job
2013
+ * is always joined; a new one starts when the session is `due` (`compactionDue`, read from the store when omitted).
2014
+ * `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
2015
+ * job. True when the job installed a compressed context. Never throws: a failed job leaves the Agent's own preflight.
2016
+ */
2017
+ async ensureCompacted(sid, opts = {}) {
2018
+ const { turn, signal } = opts;
2019
+ if (turn)
2020
+ this.compactionWaiters.add(turn);
2021
+ try {
2022
+ let job = this.compressionJobs.get(sid);
2023
+ if (job?.status !== 'running')
2024
+ job = (opts.due ?? (await this.storedCompactionDue(sid))) ? await this.startCompression(sid, null, { automatic: true }) : undefined;
2025
+ if (!job || signal?.aborted)
2026
+ return false;
2027
+ if (turn)
2028
+ job.turns.add(turn);
2029
+ await opts.onWait?.();
2030
+ const aborted = new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve(); }, { once: true }); });
2031
+ await Promise.race([job.done, aborted]);
2032
+ return !signal?.aborted && job.status === 'done';
2033
+ }
2034
+ catch (error) {
2035
+ this.deps.log(`[webui] compaction before the next turn of ${sid} did not run: ${error.message}`);
2036
+ return false;
2037
+ }
2038
+ finally {
2039
+ if (turn)
2040
+ this.compactionWaiters.delete(turn);
2041
+ }
2042
+ }
2043
+ /**
2044
+ * TAL-1005: measure the model context again when it holds a post-compression estimate the rows after it may have
2045
+ * outgrown (a waiting turn's prompt, a stopped or failed turn's work). Skipped while a turn owns the session, which
2046
+ * settles its own figures, or once the estimate changed meanwhile. True when it stored a new estimate.
2047
+ */
2048
+ async remeasureContext(sid) {
2049
+ const sidecar = this.deps.sidecar?.();
2050
+ let s;
2051
+ try {
2052
+ s = (await this.store.get(sid));
2053
+ }
2054
+ catch {
2055
+ return false;
2056
+ }
2057
+ const before = s.post_compression_context_tokens_estimate;
2058
+ if (before === null || s.active_stream_id || !sidecar)
2059
+ return false;
2060
+ let tokens;
2061
+ try {
2062
+ tokens = (await sidecar.call('models.estimate_tokens', { messages: await this.readModelContext(s) })).tokens;
2063
+ }
2064
+ catch {
2065
+ return false;
2066
+ }
2067
+ return this.store.withLock(sid, async () => {
2068
+ let live;
2069
+ try {
2070
+ live = (await this.store.get(sid));
2071
+ }
2072
+ catch {
2073
+ return false;
2074
+ }
2075
+ if (live.active_stream_id || live.post_compression_context_tokens_estimate !== before || (await this.isReadOnly(live)))
2076
+ return false;
2077
+ live.post_compression_context_tokens_estimate = Math.max(1, tokens);
2078
+ await this.store.save(live, { touchUpdatedAt: false });
2079
+ return true;
2080
+ });
2081
+ }
2082
+ async storedCompactionDue(sid) {
2083
+ try {
2084
+ return this.compactionDue((await this.store.get(sid, { metadataOnly: true })));
2085
+ }
2086
+ catch {
2087
+ return false;
2088
+ }
2089
+ }
2090
+ /** Whether the session's context (the post-compression estimate, else the last prompt) reached the next turn's threshold. */
2091
+ compactionDue(s) {
2092
+ const positive = (v) => { const n = Math.trunc(Number(v ?? 0)); return Number.isFinite(n) && n > 0 ? n : null; };
2093
+ const used = positive(s.post_compression_context_tokens_estimate) ?? positive(s.last_prompt_tokens);
2094
+ const threshold = positive(s.next_turn_threshold_tokens) ?? positive(s.threshold_tokens);
2095
+ return used !== null && threshold !== null && used >= threshold;
2096
+ }
1266
2097
  /** 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)
2098
+ * repeat compression builds on the previous summary instead of re-reading the whole display transcript. `context` is
2099
+ * that history unsanitized and `stateRows` the state.db read it came from. */
2100
+ async compressionTarget(sid) {
2101
+ await this.rejectViewOnly(sid, 'compressed');
2102
+ const s = (await this.mutationTarget(sid, 'compressed'));
2103
+ const stateRows = await this.stateDbRows(s);
2104
+ const context = this.compressionContext(s, stateRows);
2105
+ return { s, history: sanitizeMessagesForApi(context), context, stateRows };
2106
+ }
2107
+ /**
2108
+ * The loaded session's own compression guards: no running turn and enough model history. TAL-1005: a turn waiting on
2109
+ * the compression has not read its history yet, so it does not block; the context leaves out its rows, as it does.
2110
+ */
2111
+ compressionContext(s, stateRows) {
2112
+ const turn = s.active_stream_id;
2113
+ if (turn && !this.compactionWaiters.has(turn))
1276
2114
  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)
2115
+ const context = this.modelContext(s, stateRows).filter((m) => !turn || m._turn_id !== turn);
2116
+ if (sanitizeMessagesForApi(context).length < 4)
1279
2117
  throw new HttpFailure(400, 'Not enough conversation to compress (need at least 4 messages).');
1280
- return history;
2118
+ return context;
1281
2119
  }
1282
2120
  /** TAL-614: whether a manual compression would start now: `compressionTarget`'s gates, with `readOnly` the wire flag. */
1283
2121
  canCompress(s, readOnly, stateRows) {
1284
2122
  if (readOnly || isClaudeCodeSessionId(s.session_id))
1285
2123
  return false;
1286
2124
  try {
1287
- this.compressionHistory(s, stateRows);
2125
+ this.compressionContext(s, stateRows);
1288
2126
  return true;
1289
2127
  }
1290
2128
  catch (error) {
@@ -1294,18 +2132,19 @@ export class SessionService {
1294
2132
  }
1295
2133
  }
1296
2134
  /** 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) {
2135
+ async handoffTarget(sid) {
2136
+ sid = sid.trim();
1298
2137
  let local = null;
1299
2138
  try {
1300
- local = this.store.get(sid);
2139
+ local = (await this.store.get(sid));
1301
2140
  }
1302
2141
  catch { /* a foreign session without a WebUI record */ }
1303
2142
  const profile = local?.profile ?? this.deps.activeProfile();
1304
2143
  return { local, profile, dbPath: this.stateDbPath(profile) };
1305
2144
  }
1306
2145
  /** 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);
2146
+ async conversationRounds(sid, since) {
2147
+ const rounds = await stateDbSessionReadAsync(0, 'countConversationRounds', (await this.handoffTarget(sid)).dbPath, sid, since);
1309
2148
  return { ok: true, rounds, threshold: CONVERSATION_ROUND_THRESHOLD, should_show: rounds >= CONVERSATION_ROUND_THRESHOLD };
1310
2149
  }
1311
2150
  /**
@@ -1314,40 +2153,41 @@ export class SessionService {
1314
2153
  * back to a deterministic local summary rather than an error.
1315
2154
  */
1316
2155
  async handoffSummary(sid, since) {
1317
- this.rejectSubagent(sid, 'summarized');
1318
- const { local, profile, dbPath } = this.handoffTarget(sid);
1319
- const rounds = countConversationRounds(dbPath, sid, since);
2156
+ const reads = this.sessionGateReads(sid.trim());
2157
+ sid = (await this.rejectViewOnly(sid, 'summarized', reads));
2158
+ const { local, profile, dbPath } = (await this.handoffTarget(sid));
2159
+ const rounds = await stateDbSessionReadAsync(0, 'countConversationRounds', dbPath, sid, since);
1320
2160
  if (rounds < CONVERSATION_ROUND_THRESHOLD)
1321
2161
  throw new HttpFailure(400, 'Not enough conversation rounds to generate a summary.');
1322
2162
  // A running turn settles the transcript it started from, which would drop a card appended meanwhile.
1323
2163
  if (local?.active_stream_id)
1324
2164
  throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
1325
- const all = stateDbSessionMessages(dbPath, sid, { stitch: true });
2165
+ const all = await stateDbSessionReadAsync([], 'stateDbSessionMessages', dbPath, sid, { stitch: true });
1326
2166
  const messages = (since === null ? all : all.filter((m) => { const ts = stateDbTimestampSeconds(m.timestamp); return ts !== null && ts > since; })).slice(-50);
1327
2167
  if (messages.length < 2)
1328
2168
  throw new HttpFailure(400, 'Not enough messages to summarize.');
1329
2169
  const sidecar = this.deps.sidecar?.() ?? null;
1330
2170
  await ensureAgentRuntimeCurrent(sidecar);
1331
- const meta = local ? null : this.lookupCliMeta(sid);
1332
- const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages);
2171
+ const meta = local ? null : (await reads.meta());
2172
+ const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages, [...all, ...(local?.messages ?? []), ...(local?.context_messages ?? [])]);
1333
2173
  const pick = (row, ...keys) => { for (const key of keys) {
1334
2174
  const v = str(row?.[key]).trim();
1335
2175
  if (v)
1336
2176
  return v;
1337
2177
  } return ''; };
1338
2178
  // Python `get_cli_sessions` never listed WebUI's own state.db rows, so they name no channel.
1339
- const cliRow = meta ?? this.lookupCliMeta(sid);
2179
+ const cliRow = meta ?? (await reads.meta());
1340
2180
  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
2181
  await this.persistHandoffMarker(sid, profile, handoffMarker(sid, generated.summary, channel, rounds, generated.fallback, this.deps.now()));
1342
2182
  return { ok: true, summary: generated.summary, message_count: messages.length, rounds, fallback: generated.fallback, ...(generated.warning ? { warning: generated.warning } : {}) };
1343
2183
  }
1344
2184
  /** The main model's summary at 700 tokens, once more at 1400 when it was cut off, else the local fallback. */
1345
- async generateHandoffSummary(sidecar, profile, local, meta, messages) {
2185
+ async generateHandoffSummary(sidecar, profile, local, meta, messages, source) {
1346
2186
  const fallback = (warning) => ({ summary: fallbackHandoffSummary(messages), fallback: true, ...(warning ? { warning: `Summary generation used local fallback: ${warning}` } : {}) });
1347
2187
  if (!sidecar)
1348
2188
  return fallback('the Agent sidecar is not running');
1349
2189
  const [model, provider] = local ? this.deps.modelStateFromRequest(local.model, local.model_provider, local.model_provider) : [str(meta?.model).trim() || null, null];
1350
- const prompt = [{ role: 'system', content: HANDOFF_SYSTEM_PROMPT }, { role: 'user', content: `Conversation transcript:\n${handoffTranscript(messages)}` }];
2190
+ const prompt = withDataUseRequirements([{ role: 'system', content: HANDOFF_SYSTEM_PROMPT }, { role: 'user', content: `Conversation transcript:\n${handoffTranscript(messages)}` }], source);
1351
2191
  const ask = (maxTokens) => sidecar.call('aux.complete', { profile_home: this.deps.profileHome(profile), task: 'handoff_summary', messages: prompt, model: model ?? '', provider, max_tokens: maxTokens, temperature: 0.2 }, { timeoutMs: 60_000 });
1352
2192
  try {
1353
2193
  let result = await ask(700);
@@ -1372,7 +2212,8 @@ export class SessionService {
1372
2212
  async persistHandoffMarker(sid, profile, marker) {
1373
2213
  const card = messageHandoffPayload(marker);
1374
2214
  const toStateDb = async () => {
1375
- if (sameHandoff(handoffPayload(stateDbTailToolContent(this.stateDbPath(profile), sid)), card))
2215
+ await this.rejectViewOnly(sid, 'summarized');
2216
+ if (sameHandoff(handoffPayload(await stateDbSessionReadAsync(null, 'stateDbTailToolContent', this.stateDbPath(profile), sid)), card))
1376
2217
  return true;
1377
2218
  const sidecar = this.deps.sidecar?.();
1378
2219
  if (!sidecar)
@@ -1384,35 +2225,41 @@ export class SessionService {
1384
2225
  return false;
1385
2226
  }
1386
2227
  };
1387
- await this.store.withLock(sid, async () => {
2228
+ await this.withMutationLock(sid, async () => {
1388
2229
  let live = null;
1389
2230
  try {
1390
- live = this.store.get(sid);
2231
+ live = (await this.store.get(sid));
1391
2232
  }
1392
2233
  catch { /* no WebUI record */ }
1393
2234
  // A turn that started while the summary was generated would drop the card when it settles.
1394
2235
  if (live?.active_stream_id)
1395
2236
  throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
1396
- const toLocal = () => {
2237
+ const toLocal = async () => {
2238
+ await this.rejectViewOnly(sid, 'summarized');
1397
2239
  if (!live)
1398
2240
  return false;
1399
2241
  if (sameHandoff(messageHandoffPayload(live.messages.at(-1)), card))
1400
2242
  return true;
1401
2243
  live.messages.push(marker);
1402
- this.store.save(live);
2244
+ await this.store.save(live);
1403
2245
  return true;
1404
2246
  };
1405
- const messaging = this.isMessagingSession(sid);
1406
- const saved = messaging ? [await toStateDb(), toLocal()].some(Boolean) : toLocal() || await toStateDb();
2247
+ const messaging = (await this.isMessagingSession(sid));
2248
+ const saved = messaging ? [await toStateDb(), (await toLocal())].some(Boolean) : (await toLocal()) || await toStateDb();
1407
2249
  // The caller may switch away on success, so a card saved nowhere is an error, not a summary.
1408
2250
  if (!saved)
1409
2251
  throw new HttpFailure(503, 'The handoff summary could not be saved; please retry.');
1410
- });
2252
+ }, { allowReadOnly: true });
2253
+ }
2254
+ /** The same live presentation for session detail and journal snapshot recovery. */
2255
+ currentRunStatus(sid, streamId) {
2256
+ const job = this.compressionJobs.get(sid);
2257
+ return job?.status === 'running' ? { phase: 'compressing' } : streamId ? (this.deps.runtime.runStatus?.(streamId) ?? null) : null;
1411
2258
  }
1412
2259
  /** The session's compression job; a deleted session's finished result is dropped rather than served. */
1413
- compressionJob(sid) {
2260
+ async compressionJob(sid) {
1414
2261
  try {
1415
- this.store.get(sid, { metadataOnly: true });
2262
+ (await this.store.get(sid, { metadataOnly: true }));
1416
2263
  }
1417
2264
  catch {
1418
2265
  this.compressionJobs.delete(sid);
@@ -1422,18 +2269,22 @@ export class SessionService {
1422
2269
  }
1423
2270
  /**
1424
2271
  * 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
2272
+ * session lock refuse a result the session moved past (another stream, or a transcript changed) and install it as the
1426
2273
  * 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.
2274
+ * The guards run again first: a stream may have started while the caller awaited the runtime check. TAL-1005: rows
2275
+ * the job's waiting turns added meanwhile (a prompt, a Stop) stay after the compressed context.
1428
2276
  */
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);
2277
+ async compressSession(job) {
2278
+ const { session_id: sid, focus_topic: focusTopic } = job;
2279
+ const { s, history, context, stateRows: seenRows } = (await this.compressionTarget(sid));
2280
+ const ownRows = (m) => !s.active_stream_id || m._turn_id !== s.active_stream_id;
2281
+ // The display transcript and the model context must both still start where the compression found them, followed
2282
+ // only by the waiting turns' rows; the context is the exact history sent, so a state.db row that lands after this
2283
+ // read fails the commit instead of being dropped.
2284
+ // A turn still waiting to join (it started its own start meanwhile) joins this job, so it counts as one of its turns.
2285
+ const waited = (id) => job.turns.has(str(id)) || this.compactionWaiters.has(str(id));
2286
+ const sameRows = (a, b) => JSON.stringify(a) === JSON.stringify(b);
2287
+ 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
2288
  const sidecar = this.deps.sidecar?.();
1438
2289
  if (!sidecar)
1439
2290
  throw new HttpFailure(400, 'Compression failed: the Agent sidecar is not running');
@@ -1457,44 +2308,51 @@ export class SessionService {
1457
2308
  throw new HttpFailure(result.status === 'lock_skipped' ? 409 : 400, result.message ?? 'Nothing to compress yet.');
1458
2309
  const summary = result.summary ?? {};
1459
2310
  let committed = false;
2311
+ let keptTurnRows = false;
1460
2312
  try {
1461
- const current = await this.store.withLock(sid, () => {
2313
+ const current = await this.withMutationLock(sid, async () => {
1462
2314
  let live;
1463
2315
  try {
1464
- live = this.store.get(sid);
2316
+ live = (await this.store.get(sid));
1465
2317
  }
1466
2318
  catch {
1467
2319
  throw new HttpFailure(404, 'Session not found');
1468
2320
  }
1469
- if (streamState(live) !== streamBefore)
2321
+ // One state.db read serves the check, the kept display rows, and the boundary; every gate below decides after it.
2322
+ const read = await this.stateDbRead(live);
2323
+ const turn = live.active_stream_id;
2324
+ if (turn && !waited(turn))
1470
2325
  throw new HttpFailure(409, 'Session stream state changed during compression; please retry.');
1471
- // One state.db read serves the check, the kept display rows, and the boundary.
1472
- const read = this.stateDbRead(live);
1473
2326
  const stateRows = read.rows;
1474
- if (transcriptKey(live, sanitizeMessagesForApi(this.modelContext(live, stateRows))) !== historyKey)
2327
+ const tail = turnTail(this.modelContext(live, stateRows).filter((m) => !turn || m._turn_id !== turn), context);
2328
+ if (!tail || !turnTail(live.messages, s.messages))
1475
2329
  throw new HttpFailure(409, 'Session was modified during compression; please retry.');
1476
2330
  // Rows the sidecar returns without a timestamp take the newest one this compression saw, never the clock: the
1477
2331
  // boundary then hides no state.db row the append-only merge would still show (it already drops rows at or
1478
2332
  // 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);
2333
+ const seen = [...s.messages, ...s.context_messages, ...seenRows].filter(ownRows).map((m) => Number(m.timestamp)).filter(Number.isFinite);
1480
2334
  const stamp = seen.length ? Math.max(...seen) : this.deps.now();
1481
2335
  const compressed = copyJson(result.messages);
1482
2336
  for (const m of compressed)
1483
2337
  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;
2338
+ live.context_messages = [...compressed, ...copyJson(tail)];
2339
+ keptTurnRows = tail.length > 0;
2340
+ if (!turn) {
2341
+ live.pending_user_message = null;
2342
+ live.pending_attachments = [];
2343
+ live.pending_started_at = null;
2344
+ live.pending_user_source = null;
2345
+ }
1490
2346
  // The compressed history included any state.db-only continuation; the new boundary would hide those rows from
1491
2347
  // the display, so the transcript keeps them before it is applied (and the anchor counts them).
1492
2348
  const display = this.mergedTranscript(live, live.messages, stateRows);
1493
2349
  if (display.length > live.messages.length)
1494
2350
  live.messages = copyJson(display);
2351
+ // The anchor is the newest row the compression covered: never a waiting turn's own.
1495
2352
  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));
2353
+ const anchor = visible.findLast((m) => !waited(m._turn_id));
2354
+ live.compression_anchor_visible_idx = anchor ? visible.lastIndexOf(anchor) : null;
2355
+ live.compression_anchor_message_key = anchorMessageKey(anchor);
1498
2356
  live.compression_anchor_summary = anchorSummary(summary, compressed);
1499
2357
  live.compression_anchor_mode = 'manual';
1500
2358
  // #4836: an intentional-shrink boundary, so append-only state.db reconciliation does not replay compressed rows.
@@ -1504,15 +2362,22 @@ export class SessionService {
1504
2362
  this.markStateDbSeen(live, read, { boundary: true });
1505
2363
  live.last_prompt_tokens = result.after_tokens;
1506
2364
  live.post_compression_context_tokens_estimate = result.after_tokens;
1507
- this.store.save(live);
2365
+ // An automatic job is maintenance, not activity: it leaves the session's place in the list alone.
2366
+ await this.store.save(live, { touchUpdatedAt: !job.automatic });
1508
2367
  // A backup from before the compression would restore the uncompressed context.
1509
2368
  try {
1510
- rmSync(`${this.store.pathFor(sid)}.bak`, { force: true });
2369
+ await this.store.shrinkBackup.discard(sid);
1511
2370
  }
1512
2371
  catch { /* ignore */ }
1513
2372
  return live;
1514
2373
  });
1515
2374
  committed = true;
2375
+ // The estimate covers the compressed rows only; a stopped turn's kept prompt is measured with them (a waiting turn
2376
+ // settles its own figures instead).
2377
+ if (keptTurnRows)
2378
+ await this.remeasureContext(sid);
2379
+ // Open chats refetch the session for its new context figures; the list order stays (no recency change).
2380
+ this.publish('session_compress', current.profile, sid);
1516
2381
  // The cached turn agent still carries the uncompressed state; the next turn builds a fresh one (gateway parity).
1517
2382
  // Awaited, so a turn sent right after the reply cannot reuse the old agent.
1518
2383
  try {
@@ -1521,31 +2386,60 @@ export class SessionService {
1521
2386
  catch (error) {
1522
2387
  this.deps.log(`[webui] agent eviction after compression of ${sid} failed: ${error.message}`);
1523
2388
  }
1524
- return { ok: true, session: this.publicSession(current), summary, focus_topic: focusTopic };
2389
+ return { ok: true, session: (await this.publicSession(current)), summary, focus_topic: focusTopic };
1525
2390
  }
1526
2391
  finally {
1527
2392
  // Second phase: the Agent's context-engine notification fires only for a result the session now holds.
1528
2393
  if (result.commit_token) {
2394
+ const readOnly = committed ? (await this.readOnlyReason(s)) : null;
2395
+ if (readOnly)
2396
+ committed = false;
1529
2397
  try {
1530
2398
  await sidecar.call('chat.compress_finalize', { commit_token: result.commit_token, committed });
1531
2399
  }
1532
2400
  catch (error) {
1533
2401
  this.deps.log(`[webui] compression finalize for ${sid} failed: ${error.message}`);
1534
2402
  }
2403
+ if (readOnly)
2404
+ throw new HttpFailure(403, readOnly);
1535
2405
  }
1536
2406
  }
1537
2407
  }
2408
+ /** Profile-scoped, best-effort lifecycle delivery; archive only notifies plugins. */
2409
+ async finalizeSession(sid, profile, reason, lineage) {
2410
+ const sidecar = this.deps.sidecar?.();
2411
+ if (!sidecar || this.deps.profileDeleting?.(profile))
2412
+ return [];
2413
+ const release = this.deps.profileActivity?.(profile);
2414
+ try {
2415
+ return (await sidecar.call('chat.finalize_session', {
2416
+ session_id: sid, profile_home: this.deps.profileHome(profile ?? this.deps.activeProfile()), reason,
2417
+ ...(lineage ? { lineage } : {}),
2418
+ }, { timeoutMs: 12_000 })).messages;
2419
+ }
2420
+ catch {
2421
+ this.deps.log(`[webui] session lifecycle (${reason}) failed`);
2422
+ return [];
2423
+ }
2424
+ finally {
2425
+ release?.();
2426
+ }
2427
+ }
1538
2428
  async clear(sid) {
1539
- this.rejectSubagent(sid, 'modified');
1540
- const s = this.mutationTarget(sid, 'modified');
1541
- await this.store.withLock(sid, () => {
2429
+ await this.rejectViewOnly(sid, 'modified');
2430
+ const s = (await this.mutationTarget(sid, 'modified'));
2431
+ await this.withMutationLock(sid, async () => {
2432
+ const messages = await this.finalizeSession(sid, s.profile, 'clear');
2433
+ // The boundary covers every row this read saw, the session's finalization rows included.
2434
+ const read = await this.stateDbRead(s);
1542
2435
  const hadMessages = s.messages.length > 0;
1543
2436
  truncateSessionAtKeep(s, 0);
2437
+ s.messages = messages.map((content) => ({ role: 'local_notice', content, _notice_kind: 'session_finalize', timestamp: this.deps.now() }));
1544
2438
  s.tool_calls = [];
1545
2439
  if (s.parent_session_id) {
1546
2440
  let parentIsSnapshot = false;
1547
2441
  try {
1548
- parentIsSnapshot = this.store.get(s.parent_session_id, { metadataOnly: true }).pre_compression_snapshot;
2442
+ parentIsSnapshot = (await this.store.get(s.parent_session_id, { metadataOnly: true })).pre_compression_snapshot;
1549
2443
  }
1550
2444
  catch {
1551
2445
  parentIsSnapshot = false;
@@ -1563,22 +2457,23 @@ export class SessionService {
1563
2457
  s.pending_user_source = null;
1564
2458
  s.clear_generation = hadMessages ? randomUUID().replace(/-/g, '') : null;
1565
2459
  applySessionTitleRename(s, 'Untitled');
1566
- this.markStateDbSeen(s, undefined, { boundary: true });
1567
- this.store.save(s);
2460
+ this.markStateDbSeen(s, read, { boundary: true });
2461
+ await this.store.save(s);
1568
2462
  if (hadMessages) {
1569
2463
  try {
1570
- rmSync(`${this.store.pathFor(sid)}.bak`, { force: true });
2464
+ await this.store.shrinkBackup.discard(sid);
1571
2465
  }
1572
2466
  catch { /* ignore */ }
1573
2467
  }
1574
2468
  });
1575
- this.deps.runtime.evictAgent(sid, true);
1576
- return { ok: true, session: this.wireRow(s) };
2469
+ await this.deps.runtime.evictAgent(sid, true);
2470
+ return { ok: true, session: (await this.wireRow(s)) };
1577
2471
  }
1578
2472
  async retry(sid) {
1579
- this.rejectSubagent(sid, 'modified');
1580
- return this.store.withLock(sid, () => {
1581
- const s = this.mutationTarget(sid, 'modified');
2473
+ await this.rejectViewOnly(sid, 'modified');
2474
+ return this.withMutationLock(sid, async () => {
2475
+ const s = (await this.mutationTarget(sid, 'modified'));
2476
+ const read = await this.stateDbRead(s);
1582
2477
  const history = s.messages;
1583
2478
  const lastUser = findLastUserIndex(history);
1584
2479
  if (lastUser === null)
@@ -1594,15 +2489,16 @@ export class SessionService {
1594
2489
  return { error: 'The last message has nothing to resend.' };
1595
2490
  const removed = history.length - lastUser;
1596
2491
  shrinkTo(s, lastUser);
1597
- this.markStateDbSeen(s, undefined, { boundary: true });
1598
- this.store.save(s);
2492
+ this.markStateDbSeen(s, read, { boundary: true });
2493
+ await this.store.save(s);
1599
2494
  return { ok: true, last_user_text: lastUserText, last_user_prompt: lastUserPrompt, last_user_attachments: lastUserAttachments, removed_count: removed };
1600
2495
  });
1601
2496
  }
1602
2497
  async undo(sid) {
1603
- this.rejectSubagent(sid, 'modified');
1604
- return this.store.withLock(sid, () => {
1605
- const s = this.mutationTarget(sid, 'modified');
2498
+ await this.rejectViewOnly(sid, 'modified');
2499
+ return this.withMutationLock(sid, async () => {
2500
+ const s = (await this.mutationTarget(sid, 'modified'));
2501
+ const read = await this.stateDbRead(s);
1606
2502
  const history = s.messages;
1607
2503
  const lastUser = findLastUserIndex(history);
1608
2504
  if (lastUser === null)
@@ -1610,15 +2506,15 @@ export class SessionService {
1610
2506
  const removedText = extractText(history[lastUser]?.content);
1611
2507
  const removed = history.length - lastUser;
1612
2508
  shrinkTo(s, lastUser);
1613
- this.markStateDbSeen(s, undefined, { boundary: true });
1614
- this.store.save(s);
2509
+ this.markStateDbSeen(s, read, { boundary: true });
2510
+ await this.store.save(s);
1615
2511
  const preview = removedText.length > 40 ? `${removedText.slice(0, 40)}...` : removedText;
1616
2512
  return { ok: true, removed_count: removed, removed_preview: preview };
1617
2513
  });
1618
2514
  }
1619
- duplicate(sid) {
1620
- this.rejectSubagent(sid, 'duplicated');
1621
- const session = this.store.load(sid);
2515
+ async duplicate(sid) {
2516
+ await this.rejectViewOnly(sid, 'duplicated');
2517
+ const session = (await this.store.load(sid));
1622
2518
  if (!session)
1623
2519
  throw new HttpFailure(404, 'Session not found');
1624
2520
  const now = this.deps.now();
@@ -1626,7 +2522,7 @@ export class SessionService {
1626
2522
  const watermark = session.truncation_watermark_compressed ? { truncation_watermark: session.truncation_watermark, truncation_boundary: session.truncation_boundary, truncation_watermark_compressed: true } : {};
1627
2523
  const copied = new Session({
1628
2524
  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,
2525
+ 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
2526
  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
2527
  personality: session.personality, enabled_toolsets: session.enabled_toolsets, context_length: session.context_length, threshold_tokens: session.threshold_tokens,
1632
2528
  ...watermark, context_messages: copyJson(session.context_messages),
@@ -1634,16 +2530,15 @@ export class SessionService {
1634
2530
  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
2531
  created_at: now, updated_at: now,
1636
2532
  }, { workspace: session.workspace, model: session.model });
1637
- this.store.touch(copied);
1638
- this.store.save(copied);
2533
+ await this.store.touch(copied);
2534
+ await this.store.save(copied);
1639
2535
  this.publish('session_duplicate', copied.profile, copied.session_id);
1640
- return { session: this.publicSession(copied) };
2536
+ return { session: (await this.publicSession(copied)) };
1641
2537
  }
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)) {
2538
+ async branch(sid, body) {
2539
+ await this.rejectViewOnly(sid, 'branched');
2540
+ const source = (await this.get404(sid));
2541
+ if ((await this.isReadOnly(source))) {
1647
2542
  if (str(source.source_tag || source.raw_source).trim().toLowerCase() !== 'cron')
1648
2543
  throw new HttpFailure(403, 'Read-only sessions cannot be branched from WebUI');
1649
2544
  source.branchSourceReadonly = true;
@@ -1659,46 +2554,46 @@ export class SessionService {
1659
2554
  const customTitle = body.title ? str(body.title).trim().slice(0, 80) || null : null;
1660
2555
  if (!source.branchSourceReadonly) {
1661
2556
  try {
1662
- this.store.save(source);
2557
+ (await this.store.save(source));
1663
2558
  }
1664
2559
  catch { /* ignore */ }
1665
2560
  }
1666
2561
  // Python: `keep_count` indexes the merged display transcript, never the raw sidecar array.
1667
- const sourceMessages = this.mergedTranscript(source);
2562
+ const stateRows = await this.stateDbRows(source);
2563
+ const sourceMessages = this.mergedTranscript(source, source.messages, stateRows);
1668
2564
  const forked = keepCount !== null ? sourceMessages.slice(0, keepCount) : [...sourceMessages];
1669
2565
  const title = customTitle ?? `${source.title || 'Untitled'} (fork)`;
1670
2566
  const forkKeep = keepCount ?? sourceMessages.length;
1671
2567
  // The branch's model context must carry the retained state.db rows too: the new id has no state.db rows of its
1672
2568
  // own, and the runner prefers a non-empty context over the displayed messages.
1673
- const stateRows = this.stateDbRows(source);
1674
- const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages) : source.context_messages;
2569
+ const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages, stateRows) : source.context_messages;
1675
2570
  const forkedContext = copyJson(truncateContextForDisplayKeep(sourceContext, sourceMessages, forkKeep));
1676
2571
  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,
2572
+ 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
2573
  personality: source.personality, enabled_toolsets: source.enabled_toolsets, context_length: source.context_length, threshold_tokens: source.threshold_tokens,
1679
2574
  context_messages: forkedContext, gateway_routing: copyJson(source.gateway_routing), context_engine: source.context_engine, context_engine_state: copyJson(source.context_engine_state),
1680
2575
  parent_session_id: source.session_id, session_source: 'fork',
1681
2576
  }, { workspace: source.workspace, model: source.model });
1682
- this.store.touch(branch);
2577
+ await this.store.touch(branch);
1683
2578
  if (forked.length) {
1684
- this.store.save(branch);
2579
+ await this.store.save(branch);
1685
2580
  this.publish('session_branch', branch.profile, branch.session_id);
1686
2581
  }
1687
2582
  return { session_id: branch.session_id, title, parent_session_id: source.session_id };
1688
2583
  }
1689
2584
  /** Python `read_session_lineage_report` on the active profile's state.db. */
1690
2585
  lineageReport(sid) {
1691
- return stateDbLineageReport(this.stateDbPath(null), sid);
2586
+ return stateDbSessionReadAsync(emptyLineageReport(sid.trim()), 'stateDbLineageReport', this.stateDbPath(null), sid);
1692
2587
  }
1693
2588
  /** 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 };
2589
+ async recoveryDeps() {
2590
+ return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: await this.deps.recoveryStampingSince(), isReadOnly: async (sid, profile) => Boolean((await this.ownerLocked([{ session_id: sid, profile }]))[0]?.read_only), log: this.deps.log };
1696
2591
  }
1697
- recoveryAudit() {
1698
- return auditSessionRecovery(this.recoveryDeps());
2592
+ async recoveryAudit() {
2593
+ return (await auditSessionRecovery(await this.recoveryDeps()));
1699
2594
  }
1700
- recoveryRepairSafe() {
1701
- return repairSafeSessionRecovery(this.recoveryDeps());
2595
+ async recoveryRepairSafe() {
2596
+ return repairSafeSessionRecovery(await this.recoveryDeps());
1702
2597
  }
1703
2598
  /**
1704
2599
  * Python `_handle_session_compression_recovery_start`: open a compression-exhausted session's focused continuation, an
@@ -1706,18 +2601,18 @@ export class SessionService {
1706
2601
  * already started from it in the same profile; the source's lock makes concurrent starts converge on one child.
1707
2602
  */
1708
2603
  async compressionRecoveryStart(sid) {
1709
- if (this.isSubagentViewOnly(sid))
2604
+ if ((await this.isSubagentViewOnly(sid)))
1710
2605
  throw new HttpFailure(400, 'Subagent sessions are view-only and cannot start compression recovery from WebUI');
1711
2606
  const action = COMPRESSION_RECOVERY_ACTION_START_FOCUSED;
1712
2607
  let created = false;
1713
- const child = await this.store.withLock(sid, () => {
2608
+ const child = await this.withMutationLock(sid, async () => {
1714
2609
  // Validated under the lock: a delete that won it leaves no source to fork.
1715
- const source = this.store.get(sid);
2610
+ const source = (await this.store.get(sid));
1716
2611
  if (!this.visibleToActiveProfile(source.profile))
1717
2612
  throw new HttpFailure(404, 'Session not found');
1718
2613
  if (!compressionRecoveryPayload(source))
1719
2614
  throw new HttpFailure(409, 'Session does not have a compression recovery action.');
1720
- const existing = this.compressionRecoveryChild(sid, action, source.profile);
2615
+ const existing = (await this.compressionRecoveryChild(sid, action, source.profile));
1721
2616
  if (existing)
1722
2617
  return existing;
1723
2618
  const base = (source.title || 'Untitled').trim() || 'Untitled';
@@ -1726,54 +2621,55 @@ export class SessionService {
1726
2621
  project_id: source.project_id, profile: source.profile, session_source: 'fork', personality: source.personality, enabled_toolsets: copyJson(source.enabled_toolsets),
1727
2622
  context_length: source.context_length, threshold_tokens: source.threshold_tokens, gateway_routing: copyJson(source.gateway_routing), gateway_routing_history: copyJson(source.gateway_routing_history),
1728
2623
  parent_session_id: source.session_id, worktree_path: source.worktree_path, worktree_branch: source.worktree_branch, worktree_repo_root: source.worktree_repo_root, worktree_created_at: source.worktree_created_at,
2624
+ ...(source.extra[WORKTREE_RETAINED_KEY] === true ? { [WORKTREE_RETAINED_KEY]: true } : {}),
1729
2625
  compression_recovery_source_session_id: sid, compression_recovery_action: action,
1730
2626
  // An empty model-facing transcript: the focused follow-up must not replay the exhausted context.
1731
2627
  messages: [], context_messages: [], composer_draft: { text: '', files: [] },
1732
2628
  }, { workspace: source.workspace, model: source.model });
1733
2629
  try {
1734
- this.store.save(fresh);
2630
+ await this.store.save(fresh);
1735
2631
  }
1736
2632
  catch (error) {
1737
2633
  throw new HttpFailure(500, `Failed to start compression recovery: ${sanitizePaths(error)}`);
1738
2634
  }
1739
- this.store.touch(fresh);
2635
+ await this.store.touch(fresh);
1740
2636
  created = true;
1741
2637
  return fresh;
1742
- });
2638
+ }, { allowReadOnly: true });
1743
2639
  if (created)
1744
2640
  this.publish('session_compression_recovery', child.profile, child.session_id);
1745
2641
  return {
1746
- ok: true, session: this.publicSession(child), source_session_id: sid, recommended_recovery_action: action,
2642
+ ok: true, session: (await this.publicSession(child)), source_session_id: sid, recommended_recovery_action: action,
1747
2643
  message: created ? 'Started a focused continuation. Describe the next narrow task to continue.' : 'Opened the existing focused continuation for this exhausted session.',
1748
2644
  };
1749
2645
  }
1750
2646
  /** Python `find_compression_recovery_session`: the oldest same-profile continuation started from `sid`, cached or persisted. */
1751
- compressionRecoveryChild(sid, action, profile) {
2647
+ async compressionRecoveryChild(sid, action, profile) {
1752
2648
  const matches = (s) => s.compression_recovery_source_session_id === sid && s.compression_recovery_action === action && this.deps.profilesMatch(s.profile, profile);
1753
2649
  const found = new Map();
1754
2650
  for (const s of this.store.sessions.values())
1755
2651
  if (matches(s))
1756
2652
  found.set(s.session_id, s);
1757
- for (const id of this.store.persistedIds()) {
2653
+ for (const id of (await this.store.persistedIds())) {
1758
2654
  if (found.has(id) || this.store.sessions.has(id))
1759
2655
  continue;
1760
2656
  try {
1761
- const meta = this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false });
2657
+ const meta = (await this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false }));
1762
2658
  if (matches(meta))
1763
2659
  found.set(id, meta);
1764
2660
  }
1765
2661
  catch { /* unreadable: not a match */ }
1766
2662
  }
1767
2663
  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;
2664
+ return oldest ? (await this.store.get(oldest.session_id)) : null;
1769
2665
  }
1770
2666
  /** 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) {
2667
+ async activeRunBlocking(sid) {
1772
2668
  const live = this.deps.runtime.activeRunStream(sid);
1773
2669
  if (live)
1774
2670
  return live;
1775
2671
  try {
1776
- const candidate = str(this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false }).active_stream_id).trim();
2672
+ const candidate = str((await this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false })).active_stream_id).trim();
1777
2673
  if (candidate && this.deps.runtime.activeStreamIds.has(candidate))
1778
2674
  return candidate;
1779
2675
  }
@@ -1785,116 +2681,142 @@ export class SessionService {
1785
2681
  throw new HttpFailure(400, 'session_id is required');
1786
2682
  if (!isSafeSessionId(sid))
1787
2683
  throw new HttpFailure(400, 'Invalid session_id');
2684
+ const reads = this.sessionGateReads(sid);
2685
+ await this.rejectViewOnly(sid, 'deleted', reads);
1788
2686
  // Python: a read-only import (persisted sidecar flag, sidebar metadata, or a foreign state.db owner such as a
1789
2687
  // 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))
2688
+ const orphaned = (await this.isOrphanedImport(sid, reads));
2689
+ if (!orphaned && (await this.isReadOnlyImport(sid, reads)))
1792
2690
  throw new HttpFailure(400, 'Read-only imported sessions cannot be deleted from WebUI');
1793
- if (this.isSubagentViewOnly(sid))
2691
+ if ((await this.isSubagentViewOnly(sid, reads)))
1794
2692
  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 }));
2693
+ const retained = (await (async () => { try {
2694
+ return worktreeRetainedPayload((await this.store.get(sid, { metadataOnly: true })));
1797
2695
  }
1798
2696
  catch {
1799
2697
  return {};
1800
- } })();
2698
+ } })());
1801
2699
  let eventProfile = null;
1802
- let hadSidecar = true;
1803
2700
  try {
1804
- eventProfile = this.store.get(sid, { metadataOnly: true }).profile;
2701
+ eventProfile = (await this.store.get(sid, { metadataOnly: true })).profile;
1805
2702
  }
1806
2703
  catch {
1807
2704
  eventProfile = null;
1808
- hadSidecar = false;
1809
2705
  }
1810
2706
  // 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');
2707
+ const isMessaging = (await this.isMessagingSession(sid, reads));
2708
+ const knownIds = !isMessaging && !orphaned ? await stateDbSessionReadAsync([sid], 'stateDbCompressionLineage', this.stateDbPath(eventProfile), sid) : [sid];
2709
+ const assertIdle = async () => { for (const id of knownIds)
2710
+ if (await this.activeRunBlocking(id))
2711
+ throw new HttpFailure(409, 'Session has an active run; stop it before deleting'); };
2712
+ await assertIdle();
2713
+ let stateDbCleanupFailed = false;
2714
+ let removedIds = [sid];
1814
2715
  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 }));
2716
+ await this.withMutationLock(sid, async () => {
2717
+ await assertIdle();
2718
+ // TAL-792: state.db goes first, so another Agent process running or compressing the session refuses the delete
2719
+ // before anything of Talaria's is removed. Python: the Agent/CLI transcript goes too (else a claimable CLI row
2720
+ // resurfaces in the sidebar), but a messaging channel's memory is never erased from the WebUI.
2721
+ if (!isMessaging && !orphaned) {
2722
+ const result = await this.deleteStateDbRows(eventProfile, sid);
2723
+ stateDbCleanupFailed = !result.ok;
2724
+ removedIds = [...new Set([sid, ...(result.deleted_ids ?? (result.ok ? knownIds : []))])];
1821
2725
  }
1822
- catch (error) {
1823
- if (!(error instanceof SessionNotFound))
1824
- throw error;
2726
+ if (removedIds.some((id) => !isSafeSessionId(id)))
2727
+ throw new HttpFailure(503, 'Session cleanup could not finish because the Agent returned invalid session IDs.');
2728
+ removedIds = (await filterAsync(removedIds, async (id) => {
2729
+ try {
2730
+ return this.deps.profilesMatch((await this.store.get(id, { metadataOnly: true })).profile, eventProfile ?? this.deps.activeProfile());
2731
+ }
2732
+ catch {
2733
+ return true;
2734
+ }
2735
+ }));
2736
+ for (const id of removedIds) {
2737
+ // Revoke before removing each persisted alias; UI cleanup follows the Agent's committed target set.
2738
+ try {
2739
+ const session = (await this.store.get(id, { metadataOnly: true }));
2740
+ if (!Object.keys(retained).length)
2741
+ Object.assign(retained, worktreeRetainedPayload(session));
2742
+ await this.deps.shares.revoke(session);
2743
+ await this.deps.clearRelayCompletions?.(id, session.profile);
2744
+ }
2745
+ catch (error) {
2746
+ if (!(error instanceof SessionNotFound))
2747
+ throw error;
2748
+ }
2749
+ const finalize = id === sid && !(await this.store.wasDeleted(id));
2750
+ if (!(await this.store.delete(id)))
2751
+ throw new HttpFailure(500, 'Failed to delete session data');
2752
+ if (finalize)
2753
+ await this.finalizeSession(sid, eventProfile, 'delete');
1825
2754
  }
1826
- if (!this.store.deleteFiles(sid))
1827
- throw new HttpFailure(500, 'Failed to delete session data');
1828
- }, { timeoutMs: 5000 });
2755
+ }, { timeoutMs: 5000, allowReadOnly: orphaned });
1829
2756
  }
1830
2757
  catch (error) {
1831
2758
  if (error instanceof SessionBusy)
1832
2759
  throw new HttpFailure(503, 'Session busy, try again');
1833
2760
  throw error;
1834
2761
  }
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 = [];
2762
+ for (const id of removedIds) {
2763
+ await this.deps.runtime.evictAgent(id, true);
1852
2764
  try {
1853
- lineage = stateDbCompressionLineage(this.stateDbPath(eventProfile), sid);
2765
+ rmSync(this.deps.attachmentDir(id), { recursive: true, force: true });
1854
2766
  }
1855
- catch {
1856
- stateDbCleanupFailed = true;
1857
- }
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
- }
2767
+ catch { /* ignore */ }
2768
+ // Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
2769
+ try {
2770
+ (await this.deps.journal?.deleteSession(id));
1866
2771
  }
2772
+ catch { /* ignore */ }
2773
+ this.deps.runtime.closeTerminal(id);
1867
2774
  }
1868
2775
  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
2776
  return { ok: true, state_db_cleanup_failed: stateDbCleanupFailed, ...retained };
1873
2777
  }
1874
- cleanup(zeroOnly) {
2778
+ /** The committed Agent deletion targets and cleanup outcome; throws 409 `refused` while an Agent process holds a row. */
2779
+ async deleteStateDbRows(profile, sid) {
2780
+ // TAL-529: the compression continuations go too; the Agent's delete keeps and detaches them otherwise. TAL-792: the
2781
+ // sidecar resolves them inside its delete transaction, so a guard on any of them refuses before anything is removed.
2782
+ let result;
2783
+ try {
2784
+ result = await this.deps.runtime.deleteCliSession(profile, sid);
2785
+ }
2786
+ catch {
2787
+ return { ok: false };
2788
+ }
2789
+ if (result.refused) {
2790
+ const message = result.refused === 'runtime_history_unsupported'
2791
+ ? '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.'
2792
+ : 'In use by another Agent process. Try again later.';
2793
+ throw new HttpFailure(409, message, { refused: result.refused });
2794
+ }
2795
+ return result;
2796
+ }
2797
+ async cleanup(zeroOnly) {
1875
2798
  let cleaned = 0;
1876
2799
  const removed = new Set();
1877
- for (const s of this.store.scanAll()) {
2800
+ for (const s of (await this.store.scanAll())) {
1878
2801
  const shouldDelete = zeroOnly ? s.messages.length === 0 : s.title === 'Untitled' && s.messages.length === 0;
1879
- if (!shouldDelete)
2802
+ if (!shouldDelete || (await this.isReadOnly(s)))
1880
2803
  continue;
1881
2804
  // A cleared session keeps its share; keep the session (and its revoke route) if the share cannot be revoked.
1882
2805
  try {
1883
- this.deps.shares.revoke(s);
2806
+ (await this.deps.shares.revoke(s));
1884
2807
  }
1885
2808
  catch {
1886
2809
  continue;
1887
2810
  }
1888
2811
  this.store.sessions.delete(s.session_id);
1889
- rmSync(this.store.pathFor(s.session_id), { force: true });
2812
+ await this.store.delete(s.session_id, { tombstone: false });
1890
2813
  cleaned += 1;
1891
2814
  removed.add(s.session_id);
1892
2815
  }
1893
- this.store.invalidatePersistedIds();
1894
2816
  let rewroteIndex = false;
1895
2817
  try {
1896
- const rows = this.store.readIndexEntries();
1897
- const live = this.store.persistedIds();
2818
+ const rows = (await this.store.readIndexEntries());
2819
+ const live = (await this.store.persistedIds());
1898
2820
  const survivors = rows.filter((entry) => {
1899
2821
  const id = str(entry.session_id);
1900
2822
  if (!id || live.has(id) || this.store.sessions.has(id))
@@ -1905,20 +2827,20 @@ export class SessionService {
1905
2827
  return false;
1906
2828
  });
1907
2829
  if (survivors.length < rows.length) {
1908
- this.store.writeIndex();
2830
+ await this.store.writeIndex();
1909
2831
  rewroteIndex = true;
1910
2832
  }
1911
2833
  }
1912
2834
  catch { /* corrupt index is rebuilt below */ }
1913
2835
  if (removed.size && !rewroteIndex) {
1914
2836
  try {
1915
- this.store.writeIndex();
2837
+ (await this.store.writeIndex());
1916
2838
  }
1917
2839
  catch { /* ignore */ }
1918
2840
  }
1919
2841
  return { ok: true, cleaned };
1920
2842
  }
1921
- import(body) {
2843
+ async import(body) {
1922
2844
  const messages = stripPublicInternalFields(body.messages, { messageRecords: true });
1923
2845
  if (!Array.isArray(messages))
1924
2846
  throw new HttpFailure(400, 'JSON must contain a "messages" array');
@@ -1927,48 +2849,48 @@ export class SessionService {
1927
2849
  throw new HttpFailure(400, 'JSON "tool_calls" must be an array');
1928
2850
  let workspace;
1929
2851
  try {
1930
- workspace = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null);
2852
+ workspace = (await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null));
1931
2853
  }
1932
2854
  catch (error) {
1933
2855
  throw new HttpFailure(400, error.message);
1934
2856
  }
1935
- const defaults = this.store.deps.defaults(this.deps.activeProfile());
2857
+ const defaults = (await this.store.deps.defaults(this.deps.activeProfile()));
1936
2858
  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
2859
  s.pinned = Boolean(body.pinned);
1938
- this.store.touch(s);
1939
- this.store.save(s);
2860
+ await this.store.touch(s);
2861
+ await this.store.save(s);
1940
2862
  this.publish('session_import');
1941
- return { ok: true, session: this.publicSession(s) };
2863
+ return { ok: true, session: (await this.publicSession(s)) };
1942
2864
  }
1943
2865
  // ── drafts ───────────────────────────────────────────────────────────────
1944
- readDraft(sid) {
2866
+ async readDraft(sid) {
1945
2867
  if (!sid)
1946
2868
  throw new HttpFailure(400, 'session_id is required');
1947
- let hasSidecar;
1948
2869
  try {
1949
- hasSidecar = this.deps.drafts.exists(sid);
2870
+ this.deps.drafts.validate(sid);
1950
2871
  }
1951
2872
  catch {
1952
2873
  throw new HttpFailure(400, 'Invalid session_id');
1953
2874
  }
2875
+ const hasSidecar = await this.deps.drafts.exists(sid);
1954
2876
  if (hasSidecar) {
1955
2877
  const cached = this.store.sessions.get(sid);
1956
- if (!cached && !existsSessionFile(this.store, sid))
2878
+ if (!cached && !(await existsSessionFile(this.store, sid)))
1957
2879
  throw new HttpFailure(404, 'Session not found');
1958
- const [draft, version] = this.deps.drafts.readState(sid);
2880
+ const [draft, version] = (await this.deps.drafts.readState(sid));
1959
2881
  if (cached)
1960
2882
  cached.composer_draft = draft;
1961
2883
  return { draft, draft_version: version };
1962
2884
  }
1963
- const s = this.get404(sid, { metadataOnly: true });
1964
- const [draft, version] = this.deps.drafts.readState(sid, s.composer_draft);
2885
+ const s = (await this.get404(sid, { metadataOnly: true }));
2886
+ const [draft, version] = (await this.deps.drafts.readState(sid, s.composer_draft));
1965
2887
  return { draft, draft_version: version };
1966
2888
  }
1967
2889
  async writeDraft(body) {
1968
2890
  const sid = str(body.session_id);
1969
2891
  if (!sid)
1970
2892
  throw new HttpFailure(400, 'Missing required field(s): session_id');
1971
- if (this.isSubagentViewOnly(sid))
2893
+ if ((await this.isSubagentViewOnly(sid)))
1972
2894
  throw new HttpFailure(400, 'Subagent sessions are view-only and cannot store a draft from WebUI');
1973
2895
  let text = body.text;
1974
2896
  let files = body.files;
@@ -1981,7 +2903,7 @@ export class SessionService {
1981
2903
  if (Array.isArray(files) && files.length > 50)
1982
2904
  files = files.slice(0, 50);
1983
2905
  try {
1984
- this.deps.drafts.path(sid);
2906
+ this.deps.drafts.validate(sid);
1985
2907
  }
1986
2908
  catch {
1987
2909
  throw new HttpFailure(400, 'Invalid session_id');
@@ -1998,18 +2920,18 @@ export class SessionService {
1998
2920
  if (BigInt(draftVersion) > maxVersion)
1999
2921
  throw new HttpFailure(400, 'draft_version is too far in the future');
2000
2922
  }
2001
- return this.store.withLock(sid, () => {
2923
+ return this.store.withLock(sid, async () => {
2002
2924
  let s = this.store.sessions.get(sid) ?? null;
2003
2925
  if (s && s.session_id !== sid)
2004
2926
  s = null;
2005
- const sessionExists = existsSessionFile(this.store, sid);
2927
+ const sessionExists = (await existsSessionFile(this.store, sid));
2006
2928
  if (!s) {
2007
2929
  if (!sessionExists)
2008
2930
  throw new HttpFailure(404, 'Session not found');
2009
- if (!this.deps.drafts.exists(sid))
2010
- s = this.get404(sid, { metadataOnly: true });
2931
+ if (!(await this.deps.drafts.exists(sid)))
2932
+ s = (await this.get404(sid, { metadataOnly: true }));
2011
2933
  }
2012
- const [currentDraft, currentVersion] = this.deps.drafts.readState(sid, s?.composer_draft);
2934
+ const [currentDraft, currentVersion] = (await this.deps.drafts.readState(sid, s?.composer_draft));
2013
2935
  const next = { ...currentDraft };
2014
2936
  if (text !== null && text !== undefined)
2015
2937
  next.text = text;
@@ -2032,10 +2954,10 @@ export class SessionService {
2032
2954
  throw new HttpFailure(404, 'Session not found');
2033
2955
  if (!sessionExists && s) {
2034
2956
  s.composer_draft = next;
2035
- this.store.save(s, { touchUpdatedAt: false });
2957
+ await this.store.save(s, { touchUpdatedAt: false });
2036
2958
  }
2037
2959
  try {
2038
- saved = this.deps.drafts.write(sid, next, draftVersion);
2960
+ saved = (await this.deps.drafts.write(sid, next, draftVersion));
2039
2961
  }
2040
2962
  catch (error) {
2041
2963
  if (error instanceof DraftVersionConflict)
@@ -2060,7 +2982,7 @@ export class SessionService {
2060
2982
  throw new HttpFailure(400, 'Missing required field(s): scene');
2061
2983
  let s;
2062
2984
  try {
2063
- s = this.getForMutation(sid);
2985
+ s = (await this.getForMutation(sid));
2064
2986
  }
2065
2987
  catch (error) {
2066
2988
  if (error instanceof SessionNotFound)
@@ -2071,7 +2993,7 @@ export class SessionService {
2071
2993
  }
2072
2994
  if (!this.visibleToActiveProfile(s.profile))
2073
2995
  throw new HttpFailure(404, 'Session not found');
2074
- return this.store.withLock(sid, () => {
2996
+ return this.withMutationLock(sid, async () => {
2075
2997
  let placement;
2076
2998
  try {
2077
2999
  placement = storeAnchorScene(s, body, this.deps.now());
@@ -2081,34 +3003,34 @@ export class SessionService {
2081
3003
  }
2082
3004
  if (!placement)
2083
3005
  throw new HttpFailure(404, 'Assistant message not found');
2084
- this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
3006
+ await this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
2085
3007
  return { ok: true, ...placement };
2086
3008
  });
2087
3009
  }
2088
3010
  /** The session and the full transcript its detail is built from; a state.db-only session gives its synthesized one. */
2089
- fullTranscript(sid) {
3011
+ async fullTranscript(sid) {
2090
3012
  try {
2091
- let session = this.store.get(sid);
3013
+ let session = (await this.store.get(sid));
2092
3014
  if (session.loadedMetadataOnly)
2093
- session = this.store.load(sid) ?? session;
3015
+ session = (await this.store.load(sid)) ?? session;
2094
3016
  if (!this.visibleToActiveProfile(session.profile))
2095
3017
  throw new HttpFailure(404, 'Session not found');
2096
- return { session, transcript: this.mergedTranscript(session), stored: true };
3018
+ return { session, transcript: this.mergedTranscript(session, session.messages, await this.stateDbRows(session)), stored: true };
2097
3019
  }
2098
3020
  catch (error) {
2099
3021
  if (error instanceof HttpFailure)
2100
3022
  throw error;
2101
- const session = this.foreignSession(sid).synth;
3023
+ const session = (await this.foreignSession(sid)).synth;
2102
3024
  return { session, transcript: session.messages, stored: false };
2103
3025
  }
2104
3026
  }
2105
- readAnchorScene(query) {
3027
+ async readAnchorScene(query) {
2106
3028
  const sid = str(query.session_id).trim();
2107
3029
  const messageRef = normalizeAnchorSceneMessageRef(query.message_ref);
2108
3030
  const messageIndex = anchorSceneIntOrNull(query.message_index);
2109
3031
  if (!sid || (!messageRef && messageIndex === null))
2110
3032
  throw new HttpFailure(400, 'session_id and message_ref or message_index are required');
2111
- const { session, transcript, stored } = this.fullTranscript(sid);
3033
+ const { session, transcript, stored } = (await this.fullTranscript(sid));
2112
3034
  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
3035
  if (!result)
2114
3036
  throw new HttpFailure(404, 'Anchor activity scene not found');
@@ -2117,26 +3039,26 @@ export class SessionService {
2117
3039
  const redacted = redactValue(result, enabled);
2118
3040
  const rows = withSceneToolDisplay(result.rows, redacted.rows, enabled);
2119
3041
  // `/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 };
3042
+ return { ...redacted, rows: stored ? withSceneRowMedia(rows, (await this.mediaProjector(session))) : rows };
2121
3043
  }
2122
3044
  /** TAL-331: one tool call's whole result, which a limited response clipped (`result_truncated`), redacted like the detail. */
2123
- readToolResult(query) {
3045
+ async readToolResult(query) {
2124
3046
  const sid = query.session_id.trim();
2125
3047
  const id = query.tool_call_id.trim();
2126
3048
  if (!sid || !id)
2127
3049
  throw new HttpFailure(400, 'session_id and tool_call_id are required');
2128
- const full = fullToolResult(this.fullTranscript(sid).transcript, id);
3050
+ const full = fullToolResult((await this.fullTranscript(sid)).transcript, id);
2129
3051
  if (full === null)
2130
3052
  throw new HttpFailure(404, 'Tool result not found');
2131
3053
  return { tool_call_id: id, ...redactValue(full, this.deps.redactEnabled()) };
2132
3054
  }
2133
3055
  // ── shares ───────────────────────────────────────────────────────────────
2134
- createShare(sid) {
3056
+ async createShare(sid) {
2135
3057
  this.rejectClaudeCode(sid, 'shared');
2136
- let s = this.get404(sid);
3058
+ let s = (await this.get404(sid));
2137
3059
  if (!this.visibleToActiveProfile(s.profile))
2138
3060
  throw new HttpFailure(404, 'Session not found');
2139
- s = this.store.ensureFull(sid, s);
3061
+ s = (await this.store.ensureFull(sid, s));
2140
3062
  let snapshot;
2141
3063
  try {
2142
3064
  snapshot = buildShareSnapshot(s, s.messages, { hermesHome: this.deps.hermesHome, attachmentRoot: this.deps.attachmentDir('').replace(/\/[^/]*$/, ''), home: this.deps.home });
@@ -2144,31 +3066,31 @@ export class SessionService {
2144
3066
  catch (error) {
2145
3067
  throw new HttpFailure(400, error.message);
2146
3068
  }
2147
- const meta = this.deps.shares.createOrRefresh(s, snapshot);
3069
+ const meta = (await this.deps.shares.createOrRefresh(s, snapshot));
2148
3070
  s.share_token = meta.share_token;
2149
3071
  s.share_created_at = meta.share_created_at;
2150
- this.store.save(s, { touchUpdatedAt: false });
3072
+ await this.store.save(s, { touchUpdatedAt: false });
2151
3073
  this.publish('session_share_create', s.profile, sid);
2152
3074
  return {
2153
3075
  ok: true,
2154
3076
  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),
3077
+ session: (await this.publicSession(s)),
2156
3078
  };
2157
3079
  }
2158
- revokeShare(sid) {
2159
- let s = this.get404(sid);
3080
+ async revokeShare(sid) {
3081
+ let s = (await this.get404(sid));
2160
3082
  if (!this.visibleToActiveProfile(s.profile))
2161
3083
  throw new HttpFailure(404, 'Session not found');
2162
- s = this.store.ensureFull(sid, s);
2163
- this.deps.shares.revoke(s);
3084
+ s = (await this.store.ensureFull(sid, s));
3085
+ await this.deps.shares.revoke(s);
2164
3086
  s.share_token = null;
2165
3087
  s.share_created_at = null;
2166
- this.store.save(s, { touchUpdatedAt: false });
3088
+ await this.store.save(s, { touchUpdatedAt: false });
2167
3089
  this.publish('session_share_revoke', s.profile, sid);
2168
- return { ok: true, session: this.publicSession(s) };
3090
+ return { ok: true, session: (await this.publicSession(s)) };
2169
3091
  }
2170
- loadShare(token) {
2171
- const share = this.deps.shares.load(token);
3092
+ async loadShare(token) {
3093
+ const share = (await this.deps.shares.load(token));
2172
3094
  if (!share)
2173
3095
  throw new HttpFailure(404, 'Shared conversation not found');
2174
3096
  return { share };
@@ -2179,15 +3101,16 @@ export class SessionService {
2179
3101
  throw new HttpFailure(400, 'Missing session_id');
2180
3102
  return { yolo_enabled: this.deps.yolo.isEnabled(sid) };
2181
3103
  }
2182
- setYolo(sid, enabled) {
3104
+ async setYolo(sid, enabled) {
2183
3105
  if (!sid)
2184
3106
  throw new HttpFailure(400, 'Missing required field(s): session_id');
3107
+ await this.assertWritable(sid);
2185
3108
  this.deps.yolo.set(sid, enabled);
2186
3109
  return { ok: true, yolo_enabled: this.deps.yolo.isEnabled(sid) };
2187
3110
  }
2188
3111
  }
2189
- function existsSessionFile(store, sid) {
2190
- return store.persistedIds().has(sid);
3112
+ async function existsSessionFile(store, sid) {
3113
+ return (await store.persistedIds()).has(sid);
2191
3114
  }
2192
3115
  import { createHash } from 'node:crypto';
2193
3116
  function hashRevision(signature) {
@@ -2218,6 +3141,18 @@ function findLastUserIndex(history) {
2218
3141
  export function sanitizePaths(error) {
2219
3142
  return str(error?.message ?? error).replace(/(?:(?:\/[a-zA-Z0-9_.-]+)+|(?:[A-Z]:\\[^\s]+))/g, '<path>');
2220
3143
  }
3144
+ /** A failed state.db read says nothing (TAL-493). */
3145
+ function failedStateDbRead() {
3146
+ return { rows: [], idCapable: false, ok: false, reason: null, generation: null };
3147
+ }
3148
+ /** TAL-529: after a compression rotation the Agent writes to the continuation, so a read follows the recorded lineage. */
3149
+ function stateDbReadLineage(s) {
3150
+ return s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null;
3151
+ }
3152
+ /** A delegated child's view, which never merges state.db rows. */
3153
+ function isSubagentView(s) {
3154
+ return str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent';
3155
+ }
2221
3156
  /** TAL-536: a dead run's own state.db rows: from its prompt to the next prompt (tool results and applied steers stay in it). */
2222
3157
  function runStateDbRows(rows, prompt) {
2223
3158
  const at = rows.findIndex((m) => looksLikeCurrentUserTurn(m, prompt));
@@ -2231,13 +3166,24 @@ function runStateDbRows(rows, prompt) {
2231
3166
  function settledIdentity(m) {
2232
3167
  return messageIdentity(m, Infinity) ?? JSON.stringify([m.role ?? null, m.content ?? null, reasoningFieldsText(m)]);
2233
3168
  }
2234
- /** `rows` minus one occurrence of each row of `base`, by `settledIdentity`. */
3169
+ /** `rows` minus one occurrence of each base row, retaining its numeric provenance on the Agent's history copy. */
2235
3170
  function withoutRows(rows, base) {
2236
3171
  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; });
3172
+ for (const m of base) {
3173
+ const key = settledIdentity(m);
3174
+ const entry = left.get(key) ?? { rows: [], at: 0 };
3175
+ entry.rows.push(m);
3176
+ left.set(key, entry);
3177
+ }
3178
+ return rows.filter((m) => {
3179
+ const entry = left.get(settledIdentity(m));
3180
+ if (!entry || entry.at === entry.rows.length)
3181
+ return true;
3182
+ const original = entry.rows[entry.at++];
3183
+ if (!('_state_db_generation' in m) && '_state_db_generation' in original)
3184
+ m._state_db_generation = original._state_db_generation;
3185
+ return false;
3186
+ });
2241
3187
  }
2242
3188
  /**
2243
3189
  * TAL-493: what the state.db marker was recorded under: the boundary fields every boundary writer sets (an older release