@maudecode/talaria-web 1.17.2 → 1.19.0

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