@maudecode/talaria-web 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (774) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.js +16 -16
  3. package/dist/api/auth-raw.js.map +1 -1
  4. package/dist/api/automation-raw.d.ts +2 -2
  5. package/dist/api/automation-raw.js +93 -38
  6. package/dist/api/automation-raw.js.map +1 -1
  7. package/dist/api/automation-router.d.ts +99 -17
  8. package/dist/api/automation-router.js +22 -14
  9. package/dist/api/automation-router.js.map +1 -1
  10. package/dist/api/chat-router.d.ts +1433 -37
  11. package/dist/api/chat-router.js +205 -90
  12. package/dist/api/chat-router.js.map +1 -1
  13. package/dist/api/git-router.d.ts +6 -2
  14. package/dist/api/git-router.js +30 -30
  15. package/dist/api/git-router.js.map +1 -1
  16. package/dist/api/raw-routes.d.ts +3 -3
  17. package/dist/api/raw-routes.js +76 -62
  18. package/dist/api/raw-routes.js.map +1 -1
  19. package/dist/api/router.d.ts +5 -5
  20. package/dist/api/router.js +22 -22
  21. package/dist/api/router.js.map +1 -1
  22. package/dist/api/session-visibility.d.ts +4 -4
  23. package/dist/api/session-visibility.js +26 -16
  24. package/dist/api/session-visibility.js.map +1 -1
  25. package/dist/api/sessions-router.d.ts +2635 -151
  26. package/dist/api/sessions-router.js +193 -177
  27. package/dist/api/sessions-router.js.map +1 -1
  28. package/dist/api/settings-router.d.ts +450 -11
  29. package/dist/api/settings-router.js +102 -49
  30. package/dist/api/settings-router.js.map +1 -1
  31. package/dist/api/sse-routes.d.ts +2 -2
  32. package/dist/api/sse-routes.js +102 -80
  33. package/dist/api/sse-routes.js.map +1 -1
  34. package/dist/api/tools-raw.d.ts +5 -0
  35. package/dist/api/tools-raw.js +58 -33
  36. package/dist/api/tools-raw.js.map +1 -1
  37. package/dist/api/tools-router.d.ts +963 -109
  38. package/dist/api/tools-router.js +426 -72
  39. package/dist/api/tools-router.js.map +1 -1
  40. package/dist/api/transcribe-stream.d.ts +16 -0
  41. package/dist/api/transcribe-stream.js +172 -0
  42. package/dist/api/transcribe-stream.js.map +1 -0
  43. package/dist/app.d.ts +4 -1
  44. package/dist/app.js +80 -57
  45. package/dist/app.js.map +1 -1
  46. package/dist/auth/crypto.d.ts +3 -0
  47. package/dist/auth/crypto.js +18 -0
  48. package/dist/auth/crypto.js.map +1 -1
  49. package/dist/auth/gate.js +14 -9
  50. package/dist/auth/gate.js.map +1 -1
  51. package/dist/auth/passkeys.d.ts +15 -14
  52. package/dist/auth/passkeys.js +89 -91
  53. package/dist/auth/passkeys.js.map +1 -1
  54. package/dist/auth/store.d.ts +17 -7
  55. package/dist/auth/store.js +93 -52
  56. package/dist/auth/store.js.map +1 -1
  57. package/dist/bin/talaria-web.js +12 -81
  58. package/dist/bin/talaria-web.js.map +1 -1
  59. package/dist/cli/ctl.js +8 -2
  60. package/dist/cli/ctl.js.map +1 -1
  61. package/dist/cli/dotenv.d.ts +5 -0
  62. package/dist/cli/dotenv.js +14 -2
  63. package/dist/cli/dotenv.js.map +1 -1
  64. package/dist/cli/launcher.js +5 -5
  65. package/dist/cli/launcher.js.map +1 -1
  66. package/dist/cli/main.d.ts +1 -0
  67. package/dist/cli/main.js +90 -0
  68. package/dist/cli/main.js.map +1 -0
  69. package/dist/cli/supervise.d.ts +24 -0
  70. package/dist/cli/supervise.js +147 -6
  71. package/dist/cli/supervise.js.map +1 -1
  72. package/dist/config/agent-config.d.ts +37 -11
  73. package/dist/config/agent-config.js +146 -64
  74. package/dist/config/agent-config.js.map +1 -1
  75. package/dist/config.d.ts +2 -0
  76. package/dist/config.js +14 -5
  77. package/dist/config.js.map +1 -1
  78. package/dist/fs/atomic.d.ts +4 -0
  79. package/dist/fs/atomic.js +19 -2
  80. package/dist/fs/atomic.js.map +1 -1
  81. package/dist/fs/stat.d.ts +7 -0
  82. package/dist/fs/stat.js +27 -0
  83. package/dist/fs/stat.js.map +1 -0
  84. package/dist/http/context.d.ts +28 -9
  85. package/dist/http/context.js +77 -25
  86. package/dist/http/context.js.map +1 -1
  87. package/dist/onboarding.d.ts +2 -2
  88. package/dist/onboarding.js +10 -8
  89. package/dist/onboarding.js.map +1 -1
  90. package/dist/profiles/profiles.d.ts +12 -4
  91. package/dist/profiles/profiles.js +78 -30
  92. package/dist/profiles/profiles.js.map +1 -1
  93. package/dist/projects.d.ts +15 -6
  94. package/dist/projects.js +55 -47
  95. package/dist/projects.js.map +1 -1
  96. package/dist/providers/catalog.d.ts +85 -14
  97. package/dist/providers/catalog.js +502 -146
  98. package/dist/providers/catalog.js.map +1 -1
  99. package/dist/providers/env-file.d.ts +10 -3
  100. package/dist/providers/env-file.js +24 -75
  101. package/dist/providers/env-file.js.map +1 -1
  102. package/dist/providers/runtime-env.d.ts +4 -2
  103. package/dist/providers/runtime-env.js +9 -6
  104. package/dist/providers/runtime-env.js.map +1 -1
  105. package/dist/providers/tables.d.ts +3 -1
  106. package/dist/providers/tables.js +4 -2
  107. package/dist/providers/tables.js.map +1 -1
  108. package/dist/redact.js +56 -23
  109. package/dist/redact.js.map +1 -1
  110. package/dist/release.d.ts +7 -0
  111. package/dist/release.js +19 -3
  112. package/dist/release.js.map +1 -1
  113. package/dist/runtime.d.ts +5 -1
  114. package/dist/runtime.js +303 -96
  115. package/dist/runtime.js.map +1 -1
  116. package/dist/server.js +31 -18
  117. package/dist/server.js.map +1 -1
  118. package/dist/sessions/anchor.d.ts +11 -4
  119. package/dist/sessions/anchor.js +33 -37
  120. package/dist/sessions/anchor.js.map +1 -1
  121. package/dist/sessions/background-events.d.ts +2 -0
  122. package/dist/sessions/background-events.js +10 -0
  123. package/dist/sessions/background-events.js.map +1 -0
  124. package/dist/sessions/background-tasks.d.ts +49 -23
  125. package/dist/sessions/background-tasks.js +161 -89
  126. package/dist/sessions/background-tasks.js.map +1 -1
  127. package/dist/sessions/background-updates.d.ts +15 -8
  128. package/dist/sessions/background-updates.js +101 -25
  129. package/dist/sessions/background-updates.js.map +1 -1
  130. package/dist/sessions/cli-sessions.d.ts +23 -6
  131. package/dist/sessions/cli-sessions.js +84 -84
  132. package/dist/sessions/cli-sessions.js.map +1 -1
  133. package/dist/sessions/completions.d.ts +38 -2
  134. package/dist/sessions/completions.js +348 -77
  135. package/dist/sessions/completions.js.map +1 -1
  136. package/dist/sessions/compress.d.ts +8 -0
  137. package/dist/sessions/compress.js +2 -2
  138. package/dist/sessions/compress.js.map +1 -1
  139. package/dist/sessions/drafts.d.ts +14 -10
  140. package/dist/sessions/drafts.js +38 -43
  141. package/dist/sessions/drafts.js.map +1 -1
  142. package/dist/sessions/export.d.ts +2 -0
  143. package/dist/sessions/export.js +6 -2
  144. package/dist/sessions/export.js.map +1 -1
  145. package/dist/sessions/gateway-sessions.d.ts +86 -0
  146. package/dist/sessions/gateway-sessions.js +914 -0
  147. package/dist/sessions/gateway-sessions.js.map +1 -0
  148. package/dist/sessions/gateway-watcher.d.ts +10 -11
  149. package/dist/sessions/gateway-watcher.js +25 -21
  150. package/dist/sessions/gateway-watcher.js.map +1 -1
  151. package/dist/sessions/goals.d.ts +31 -0
  152. package/dist/sessions/goals.js +75 -0
  153. package/dist/sessions/goals.js.map +1 -0
  154. package/dist/sessions/journal.d.ts +5 -79
  155. package/dist/sessions/journal.js +15 -606
  156. package/dist/sessions/journal.js.map +1 -1
  157. package/dist/sessions/list.d.ts +34 -18
  158. package/dist/sessions/list.js +161 -147
  159. package/dist/sessions/list.js.map +1 -1
  160. package/dist/sessions/merge.d.ts +77 -13
  161. package/dist/sessions/merge.js +315 -65
  162. package/dist/sessions/merge.js.map +1 -1
  163. package/dist/sessions/notices.d.ts +22 -0
  164. package/dist/sessions/notices.js +72 -0
  165. package/dist/sessions/notices.js.map +1 -0
  166. package/dist/sessions/pending.d.ts +10 -4
  167. package/dist/sessions/pending.js +83 -7
  168. package/dist/sessions/pending.js.map +1 -1
  169. package/dist/sessions/prefix-index.d.ts +85 -0
  170. package/dist/sessions/prefix-index.js +243 -0
  171. package/dist/sessions/prefix-index.js.map +1 -0
  172. package/dist/sessions/recovery.d.ts +4 -7
  173. package/dist/sessions/recovery.js +100 -283
  174. package/dist/sessions/recovery.js.map +1 -1
  175. package/dist/sessions/relay.d.ts +20 -20
  176. package/dist/sessions/relay.js +205 -127
  177. package/dist/sessions/relay.js.map +1 -1
  178. package/dist/sessions/service.d.ts +251 -78
  179. package/dist/sessions/service.js +1510 -564
  180. package/dist/sessions/service.js.map +1 -1
  181. package/dist/sessions/session.d.ts +20 -2
  182. package/dist/sessions/session.js +33 -5
  183. package/dist/sessions/session.js.map +1 -1
  184. package/dist/sessions/shares.d.ts +7 -6
  185. package/dist/sessions/shares.js +22 -45
  186. package/dist/sessions/shares.js.map +1 -1
  187. package/dist/sessions/source-kind.js +1 -0
  188. package/dist/sessions/source-kind.js.map +1 -1
  189. package/dist/sessions/state-db-identity.d.ts +18 -0
  190. package/dist/sessions/state-db-identity.js +67 -0
  191. package/dist/sessions/state-db-identity.js.map +1 -0
  192. package/dist/sessions/state-db-worker.js +24 -5
  193. package/dist/sessions/state-db-worker.js.map +1 -1
  194. package/dist/sessions/state-db.d.ts +157 -21
  195. package/dist/sessions/state-db.js +1145 -204
  196. package/dist/sessions/state-db.js.map +1 -1
  197. package/dist/sessions/state-saved.d.ts +1 -1
  198. package/dist/sessions/state-saved.js +35 -11
  199. package/dist/sessions/state-saved.js.map +1 -1
  200. package/dist/sessions/storage/backend.d.ts +171 -0
  201. package/dist/sessions/storage/backend.js +19 -0
  202. package/dist/sessions/storage/backend.js.map +1 -0
  203. package/dist/sessions/storage/conformance.d.ts +20 -0
  204. package/dist/sessions/storage/conformance.js +237 -0
  205. package/dist/sessions/storage/conformance.js.map +1 -0
  206. package/dist/sessions/storage/document.d.ts +16 -0
  207. package/dist/sessions/storage/document.js +44 -0
  208. package/dist/sessions/storage/document.js.map +1 -0
  209. package/dist/sessions/storage/json-documents.d.ts +25 -0
  210. package/dist/sessions/storage/json-documents.js +277 -0
  211. package/dist/sessions/storage/json-documents.js.map +1 -0
  212. package/dist/sessions/storage/json-files.d.ts +23 -0
  213. package/dist/sessions/storage/json-files.js +244 -0
  214. package/dist/sessions/storage/json-files.js.map +1 -0
  215. package/dist/sessions/storage/json-installation.d.ts +3 -0
  216. package/dist/sessions/storage/json-installation.js +31 -0
  217. package/dist/sessions/storage/json-installation.js.map +1 -0
  218. package/dist/sessions/storage/json-journal.d.ts +113 -0
  219. package/dist/sessions/storage/json-journal.js +756 -0
  220. package/dist/sessions/storage/json-journal.js.map +1 -0
  221. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  222. package/dist/sessions/storage/json-recovery.js +195 -0
  223. package/dist/sessions/storage/json-recovery.js.map +1 -0
  224. package/dist/sessions/storage/json.d.ts +82 -0
  225. package/dist/sessions/storage/json.js +350 -0
  226. package/dist/sessions/storage/json.js.map +1 -0
  227. package/dist/sessions/storage/row.d.ts +6 -0
  228. package/dist/sessions/storage/row.js +49 -0
  229. package/dist/sessions/storage/row.js.map +1 -0
  230. package/dist/sessions/storage/search-text.d.ts +4 -0
  231. package/dist/sessions/storage/search-text.js +16 -0
  232. package/dist/sessions/storage/search-text.js.map +1 -0
  233. package/dist/sessions/store.d.ts +67 -73
  234. package/dist/sessions/store.js +341 -607
  235. package/dist/sessions/store.js.map +1 -1
  236. package/dist/sessions/streams.d.ts +12 -7
  237. package/dist/sessions/streams.js +58 -15
  238. package/dist/sessions/streams.js.map +1 -1
  239. package/dist/sessions/titles.js +1 -1
  240. package/dist/sessions/titles.js.map +1 -1
  241. package/dist/sessions/todo.d.ts +5 -0
  242. package/dist/sessions/todo.js +28 -21
  243. package/dist/sessions/todo.js.map +1 -1
  244. package/dist/sessions/tool-display.d.ts +8 -5
  245. package/dist/sessions/tool-display.js +28 -16
  246. package/dist/sessions/tool-display.js.map +1 -1
  247. package/dist/sessions/turn-context.d.ts +10 -1
  248. package/dist/sessions/turn-context.js +13 -6
  249. package/dist/sessions/turn-context.js.map +1 -1
  250. package/dist/sessions/turn.d.ts +110 -22
  251. package/dist/sessions/turn.js +918 -380
  252. package/dist/sessions/turn.js.map +1 -1
  253. package/dist/sessions/wakeup-pause.d.ts +8 -13
  254. package/dist/sessions/wakeup-pause.js +31 -49
  255. package/dist/sessions/wakeup-pause.js.map +1 -1
  256. package/dist/sessions/window.js +13 -12
  257. package/dist/sessions/window.js.map +1 -1
  258. package/dist/settings.d.ts +19 -7
  259. package/dist/settings.js +179 -183
  260. package/dist/settings.js.map +1 -1
  261. package/dist/sidecar/client.d.ts +41 -4
  262. package/dist/sidecar/client.js +192 -42
  263. package/dist/sidecar/client.js.map +1 -1
  264. package/dist/sidecar/discover.d.ts +2 -1
  265. package/dist/sidecar/discover.js +93 -10
  266. package/dist/sidecar/discover.js.map +1 -1
  267. package/dist/sidecar/env.d.ts +4 -0
  268. package/dist/sidecar/env.js +3 -0
  269. package/dist/sidecar/env.js.map +1 -0
  270. package/dist/sidecar/fake.d.ts +6 -2
  271. package/dist/sidecar/fake.js +34 -4
  272. package/dist/sidecar/fake.js.map +1 -1
  273. package/dist/spa.d.ts +9 -4
  274. package/dist/spa.js +36 -12
  275. package/dist/spa.js.map +1 -1
  276. package/dist/startup-timing.d.ts +66 -0
  277. package/dist/startup-timing.js +129 -0
  278. package/dist/startup-timing.js.map +1 -0
  279. package/dist/test/env-file.d.ts +5 -0
  280. package/dist/test/env-file.js +88 -0
  281. package/dist/test/env-file.js.map +1 -0
  282. package/dist/test/fs-handles.d.ts +11 -0
  283. package/dist/test/fs-handles.js +31 -0
  284. package/dist/test/fs-handles.js.map +1 -0
  285. package/dist/test/harness.d.ts +1 -0
  286. package/dist/test/harness.js +1 -1
  287. package/dist/test/harness.js.map +1 -1
  288. package/dist/test/passkeys.d.ts +22 -0
  289. package/dist/test/passkeys.js +62 -0
  290. package/dist/test/passkeys.js.map +1 -0
  291. package/dist/test/storage.d.ts +15 -0
  292. package/dist/test/storage.js +26 -0
  293. package/dist/test/storage.js.map +1 -0
  294. package/dist/tools/commands.d.ts +6 -1
  295. package/dist/tools/commands.js +8 -2
  296. package/dist/tools/commands.js.map +1 -1
  297. package/dist/tools/connectors.d.ts +33 -0
  298. package/dist/tools/connectors.js +121 -0
  299. package/dist/tools/connectors.js.map +1 -0
  300. package/dist/tools/crons.d.ts +46 -5
  301. package/dist/tools/crons.js +100 -26
  302. package/dist/tools/crons.js.map +1 -1
  303. package/dist/tools/dashboard-plugins.d.ts +34 -5
  304. package/dist/tools/dashboard-plugins.js +146 -14
  305. package/dist/tools/dashboard-plugins.js.map +1 -1
  306. package/dist/tools/extensions.d.ts +5 -4
  307. package/dist/tools/extensions.js +213 -207
  308. package/dist/tools/extensions.js.map +1 -1
  309. package/dist/tools/gateway-restart.d.ts +82 -0
  310. package/dist/tools/gateway-restart.js +222 -0
  311. package/dist/tools/gateway-restart.js.map +1 -0
  312. package/dist/tools/health.d.ts +28 -4
  313. package/dist/tools/health.js +171 -50
  314. package/dist/tools/health.js.map +1 -1
  315. package/dist/tools/hygiene.d.ts +4 -3
  316. package/dist/tools/hygiene.js +23 -9
  317. package/dist/tools/hygiene.js.map +1 -1
  318. package/dist/tools/insights.d.ts +1 -1
  319. package/dist/tools/insights.js +2 -2
  320. package/dist/tools/insights.js.map +1 -1
  321. package/dist/tools/kanban.d.ts +4 -2
  322. package/dist/tools/kanban.js +28 -19
  323. package/dist/tools/kanban.js.map +1 -1
  324. package/dist/tools/mcp-health.js +74 -24
  325. package/dist/tools/mcp-health.js.map +1 -1
  326. package/dist/tools/mcp.js +19 -1
  327. package/dist/tools/mcp.js.map +1 -1
  328. package/dist/tools/memory-masks.d.ts +5 -0
  329. package/dist/tools/memory-masks.js +256 -0
  330. package/dist/tools/memory-masks.js.map +1 -0
  331. package/dist/tools/memory.d.ts +29 -3
  332. package/dist/tools/memory.js +69 -27
  333. package/dist/tools/memory.js.map +1 -1
  334. package/dist/tools/plugin-oauth.d.ts +14 -0
  335. package/dist/tools/plugin-oauth.js +53 -0
  336. package/dist/tools/plugin-oauth.js.map +1 -0
  337. package/dist/tools/plugin-settings.d.ts +15 -0
  338. package/dist/tools/plugin-settings.js +90 -0
  339. package/dist/tools/plugin-settings.js.map +1 -0
  340. package/dist/tools/prune-advice.d.ts +50 -0
  341. package/dist/tools/prune-advice.js +114 -0
  342. package/dist/tools/prune-advice.js.map +1 -0
  343. package/dist/tools/skills.d.ts +13 -5
  344. package/dist/tools/skills.js +53 -19
  345. package/dist/tools/skills.js.map +1 -1
  346. package/dist/tools/update-notifications.d.ts +102 -17
  347. package/dist/tools/update-notifications.js +400 -82
  348. package/dist/tools/update-notifications.js.map +1 -1
  349. package/dist/tools/update-rollback.d.ts +11 -0
  350. package/dist/tools/update-rollback.js +28 -0
  351. package/dist/tools/update-rollback.js.map +1 -0
  352. package/dist/tools/updates.d.ts +100 -33
  353. package/dist/tools/updates.js +1085 -345
  354. package/dist/tools/updates.js.map +1 -1
  355. package/dist/util.d.ts +2 -0
  356. package/dist/util.js +5 -0
  357. package/dist/util.js.map +1 -1
  358. package/dist/workspace/git.d.ts +16 -1
  359. package/dist/workspace/git.js +291 -11
  360. package/dist/workspace/git.js.map +1 -1
  361. package/dist/workspace/media.d.ts +12 -9
  362. package/dist/workspace/media.js +98 -38
  363. package/dist/workspace/media.js.map +1 -1
  364. package/dist/workspace/preview.js +4 -14
  365. package/dist/workspace/preview.js.map +1 -1
  366. package/dist/workspace/rollback.d.ts +8 -4
  367. package/dist/workspace/rollback.js +22 -12
  368. package/dist/workspace/rollback.js.map +1 -1
  369. package/dist/workspace/workspaces.d.ts +15 -19
  370. package/dist/workspace/workspaces.js +55 -127
  371. package/dist/workspace/workspaces.js.map +1 -1
  372. package/dist/workspace/worktrees.js +170 -32
  373. package/dist/workspace/worktrees.js.map +1 -1
  374. package/package.json +6 -4
  375. package/sidecar/agent_dependency.json +4 -4
  376. package/sidecar/agent_support.json +29 -0
  377. package/sidecar/scripts/record-fixtures.py +12 -3
  378. package/sidecar/scripts/replay_sidecar.py +8 -0
  379. package/sidecar/scripts/suite_runner.py +458 -0
  380. package/sidecar/scripts/test.sh +11 -5
  381. package/sidecar/talaria_sidecar/__init__.py +6 -1
  382. package/sidecar/talaria_sidecar/__main__.py +13 -4
  383. package/sidecar/talaria_sidecar/agent_mutation.py +1111 -0
  384. package/sidecar/talaria_sidecar/agent_services.py +1116 -0
  385. package/sidecar/talaria_sidecar/context_detail.py +59 -0
  386. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  387. package/sidecar/talaria_sidecar/home.py +133 -14
  388. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  389. package/sidecar/talaria_sidecar/methods/aux.py +211 -41
  390. package/sidecar/talaria_sidecar/methods/chat.py +1378 -200
  391. package/sidecar/talaria_sidecar/methods/checkpoints.py +29 -4
  392. package/sidecar/talaria_sidecar/methods/commands.py +483 -13
  393. package/sidecar/talaria_sidecar/methods/config.py +195 -6
  394. package/sidecar/talaria_sidecar/methods/connectors.py +375 -0
  395. package/sidecar/talaria_sidecar/methods/cron.py +374 -36
  396. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  397. package/sidecar/talaria_sidecar/methods/dashboard_plugins.py +301 -0
  398. package/sidecar/talaria_sidecar/methods/gateway.py +215 -55
  399. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +418 -0
  400. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  401. package/sidecar/talaria_sidecar/methods/kanban.py +82 -30
  402. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  403. package/sidecar/talaria_sidecar/methods/oauth.py +144 -13
  404. package/sidecar/talaria_sidecar/methods/plugin_catalog.py +198 -0
  405. package/sidecar/talaria_sidecar/methods/plugin_oauth.py +234 -0
  406. package/sidecar/talaria_sidecar/methods/process.py +229 -16
  407. package/sidecar/talaria_sidecar/methods/profiles.py +48 -9
  408. package/sidecar/talaria_sidecar/methods/providers.py +196 -26
  409. package/sidecar/talaria_sidecar/methods/runtime.py +71 -1
  410. package/sidecar/talaria_sidecar/methods/skills.py +83 -5
  411. package/sidecar/talaria_sidecar/methods/state_db.py +405 -112
  412. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  413. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  414. package/sidecar/talaria_sidecar/methods/toolsets.py +51 -0
  415. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  416. package/sidecar/talaria_sidecar/methods/worktree.py +145 -19
  417. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  418. package/sidecar/talaria_sidecar/rpc.py +70 -7
  419. package/sidecar/talaria_sidecar/runtime.py +122 -14
  420. package/sidecar/talaria_sidecar/speed.py +47 -0
  421. package/sidecar/talaria_sidecar/startup_timing.py +119 -0
  422. package/static/dist/FILES.txt +242 -215
  423. package/static/dist/THIRD_PARTY_LICENSES.md +6 -22
  424. package/static/dist/assets/AppShell-3isgvoro.js +8 -0
  425. package/static/dist/assets/Button-f4n6dapf.js +1 -0
  426. package/static/dist/assets/ChatPage-k7b6xfax.js +4 -0
  427. package/static/dist/assets/ConnectTargets-juqvt5lm.js +1 -0
  428. package/static/dist/assets/Dialog-hta5utr3.js +1 -0
  429. package/static/dist/assets/ExtensionRoute-zrhjn0wm.js +1 -0
  430. package/static/dist/assets/Field-b6ez48oz.js +1 -0
  431. package/static/dist/assets/HubRoute-iwvso2tz.js +2 -0
  432. package/static/dist/assets/LinkCheckContext-ix6zqqkk.js +1 -0
  433. package/static/dist/assets/Markdown-jdeth7zq.js +2 -0
  434. package/static/dist/assets/MarkdownRenderer-fbswsgld.js +302 -0
  435. package/static/dist/assets/MarkdownRenderer-i9o6nr38.css +1 -0
  436. package/static/dist/assets/OAuthSignIn-bpd8hom3.js +1 -0
  437. package/static/dist/assets/ResourceSwitch-odi6dy2o.js +1 -0
  438. package/static/dist/assets/RightPanel-eu6enlo6.js +1 -0
  439. package/static/dist/assets/Select-kr07928w.js +1 -0
  440. package/static/dist/assets/SessionListPanel-etb2i54l.js +1 -0
  441. package/static/dist/assets/SettingsLayout-eu3xxp23.js +1 -0
  442. package/static/dist/assets/States-icab19yg.js +1 -0
  443. package/static/dist/assets/TerminalPanel-hngmsr8b.js +16 -0
  444. package/static/dist/assets/Toaster-nb2uzjw1.js +1 -0
  445. package/static/dist/assets/_app-ga1moszl.js +1 -0
  446. package/static/dist/assets/_app.ext._extensionId-na62087t.js +1 -0
  447. package/static/dist/assets/_app.index-jypgdqoi.js +1 -0
  448. package/static/dist/assets/_app.insights-kabejdtm.js +1 -0
  449. package/static/dist/assets/_app.kanban-m19kfcmx.js +1 -0
  450. package/static/dist/assets/_app.logs-iw4m96sz.js +1 -0
  451. package/static/dist/assets/_app.memory-jq5frwcg.js +1 -0
  452. package/static/dist/assets/_app.profiles-kwa3m7rp.js +1 -0
  453. package/static/dist/assets/_app.session._sessionId-o5lyeipj.js +1 -0
  454. package/static/dist/assets/_app.settings-ckl8hdll.js +1 -0
  455. package/static/dist/assets/_app.settings._section-dtdjk98a.js +3 -0
  456. package/static/dist/assets/_app.skills-n016rihd.js +1 -0
  457. package/static/dist/assets/_app.tasks-n8zuqsqe.js +1 -0
  458. package/static/dist/assets/_app.todos-btew5v2s.js +1 -0
  459. package/static/dist/assets/_app.workspaces-lnfk95ch.js +1 -0
  460. package/static/dist/assets/abnfDiagram-VCTEODGH-dttheegb.js +1 -0
  461. package/static/dist/assets/{angular-html-nu6l31w3.js → angular-html-l28bd5fo.js} +1 -1
  462. package/static/dist/assets/{angular-ts-fbx4f8mm.js → angular-ts-ffzx1vj0.js} +1 -1
  463. package/static/dist/assets/{apl-bx2xr7dh.js → apl-b7h7mmy4.js} +1 -1
  464. package/static/dist/assets/{arc-7popfhbk.js → arc-fk1b21lm.js} +1 -1
  465. package/static/dist/assets/architecture-7GRP2DOG-ic435919.js +1 -0
  466. package/static/dist/assets/architectureDiagram-5GKGNRK7-8r3glgiq.js +36 -0
  467. package/static/dist/assets/archive-il7c6053.js +1 -0
  468. package/static/dist/assets/{astro-g77i97cb.js → astro-k117fy0t.js} +1 -1
  469. package/static/dist/assets/back-gu3nu279.js +1 -0
  470. package/static/dist/assets/{blade-boo0u92y.js → blade-npefsmn8.js} +1 -1
  471. package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-jenyos6c.js} +4 -4
  472. package/static/dist/assets/{c-cat0w3sc.js → c-2umg6kbn.js} +1 -1
  473. package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-nzlx7yc4.js} +1 -1
  474. package/static/dist/assets/cancel-bsnfo36u.js +1 -0
  475. package/static/dist/assets/channel-dsbtk01y.js +1 -0
  476. package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-g0cru63b.js} +20 -20
  477. package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-7rkswkjw.js} +1 -1
  478. package/static/dist/assets/chunk-DU6HZSFF-fn73jzdo.js +128 -0
  479. package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-oey8e72m.js} +1 -1
  480. package/static/dist/assets/{chunk-FOHPRMQF-opuflp73.js → chunk-FOHPRMQF-ipewusgj.js} +8 -8
  481. package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-l2a32wiy.js} +6 -6
  482. package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-o9dwzh8h.js} +1 -1
  483. package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-eblwj20b.js} +5 -5
  484. package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-o2mufspb.js} +1 -1
  485. package/static/dist/assets/chunk-OSK3NFVY-ijp9pw29.js +10 -0
  486. package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-hs9hqwl4.js} +1 -1
  487. package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-bp9rsqez.js} +1 -1
  488. package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-brkprov6.js} +1 -1
  489. package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-gmpv0pxz.js} +13 -13
  490. package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-oldm08fu.js} +4 -4
  491. package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-ooqilfv7.js} +6 -6
  492. package/static/dist/assets/{chunk-XXDRQBXY-n40pf9vy.js → chunk-XXDRQBXY-jkmnqu65.js} +1 -1
  493. package/static/dist/assets/classDiagram-ZZMXUADV-fvgcv24g.js +1 -0
  494. package/static/dist/assets/classDiagram-v2-VYDZK3BY-fvgcv24g.js +1 -0
  495. package/static/dist/assets/{cobol-go622aep.js → cobol-ssf9hkh7.js} +1 -1
  496. package/static/dist/assets/{coffee-jwo1xjhq.js → coffee-lsi9fwae.js} +1 -1
  497. package/static/dist/assets/copied-bz3gkazh.js +1 -0
  498. package/static/dist/assets/copy-jo006coq.js +1 -0
  499. package/static/dist/assets/cose-bilkent-JH36ORCC-layy2r27.js +1 -0
  500. package/static/dist/assets/{cpp-nre7j0kq.js → cpp-lfrh4y55.js} +1 -1
  501. package/static/dist/assets/{crystal-lviso2je.js → crystal-efh4pte7.js} +1 -1
  502. package/static/dist/assets/{css-ywz9j4hb.js → css-i82qdo89.js} +1 -1
  503. package/static/dist/assets/cynefin-OW5HDTMX-d47op7qs.js +1 -0
  504. package/static/dist/assets/cynefinDiagram-5FMLGOSQ-doa7594y.js +62 -0
  505. package/static/dist/assets/cytoscape.esm-fjn2algp.js +321 -0
  506. package/static/dist/assets/dagre-GXQ25YYZ-os768dp4.js +4 -0
  507. package/static/dist/assets/dagre-o5jmjz46.js +1 -0
  508. package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-fyi81nxd.js} +4 -4
  509. package/static/dist/assets/diagram-UQ7AKVKN-j3exggaq.js +41 -0
  510. package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-omo1en2l.js} +1 -1
  511. package/static/dist/assets/diagram-VX7I27RA-oro2cyu0.js +24 -0
  512. package/static/dist/assets/diagram-Z3DM3KII-byt9vdb7.js +24 -0
  513. package/static/dist/assets/dist-crc46di9.js +7 -0
  514. package/static/dist/assets/dist-etpoyscq.js +44 -0
  515. package/static/dist/assets/{dist-c4c2m3kv.js → dist-nb670eu6.js} +1 -1
  516. package/static/dist/assets/ebnfDiagram-PWID7BFC-ee3haugh.js +1 -0
  517. package/static/dist/assets/{edge-dvv35byi.js → edge-lrob635n.js} +1 -1
  518. package/static/dist/assets/{elixir-i2f1ki1g.js → elixir-kdpxeq93.js} +1 -1
  519. package/static/dist/assets/{elm-cpepyjm4.js → elm-c8irj9uh.js} +1 -1
  520. package/static/dist/assets/erDiagram-RLTQ6QDP-d1e93yff.js +99 -0
  521. package/static/dist/assets/{erb-idlyfheg.js → erb-flwsv146.js} +1 -1
  522. package/static/dist/assets/eventmodeling-NTZA5JFV-i1ihb40c.js +1 -0
  523. package/static/dist/assets/flowDiagram-HODETNUW-jahfpiod.js +1 -0
  524. package/static/dist/assets/folder-plus-ivoglq98.js +1 -0
  525. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-m7g0jud4.js} +1 -1
  526. package/static/dist/assets/{git-rebase-d9lpfpbn.js → git-rebase-bli19aoq.js} +1 -1
  527. package/static/dist/assets/gitGraph-4MIJSDKK-hzdj07yy.js +1 -0
  528. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-dm9agbna.js} +1 -1
  529. package/static/dist/assets/{glimmer-js-39i0urfb.js → glimmer-js-d4icg38p.js} +1 -1
  530. package/static/dist/assets/{glimmer-ts-gdtd6n73.js → glimmer-ts-bk75772u.js} +1 -1
  531. package/static/dist/assets/{glsl-o1djm5pp.js → glsl-lqormp2b.js} +1 -1
  532. package/static/dist/assets/{graphql-kj6rz55t.js → graphql-hw6s5qzd.js} +1 -1
  533. package/static/dist/assets/{hack-pe1qqr2i.js → hack-ozrv6l4d.js} +1 -1
  534. package/static/dist/assets/{haml-cknnbcqs.js → haml-gytfosdt.js} +1 -1
  535. package/static/dist/assets/{handlebars-hwq64orh.js → handlebars-b6g7jffd.js} +1 -1
  536. package/static/dist/assets/highlighted-body-KPVGNVTW-mmgz2czu.js +1 -0
  537. package/static/dist/assets/host-fr6lni1p.js +1 -0
  538. package/static/dist/assets/{html-derivative-h2i2pyjc.js → html-derivative-f1tqcroo.js} +1 -1
  539. package/static/dist/assets/{html-ha05xtvt.js → html-on6oj6yj.js} +1 -1
  540. package/static/dist/assets/{http-cf94zu27.js → http-odhufsl7.js} +1 -1
  541. package/static/dist/assets/{hurl-lslax568.js → hurl-mu680336.js} +1 -1
  542. package/static/dist/assets/index-73649w7o.js +10 -0
  543. package/static/dist/assets/index-lhf1fsdf.css +2 -0
  544. package/static/dist/assets/info-A6RAGUB7-lll1dqmm.js +1 -0
  545. package/static/dist/assets/infoDiagram-27XIBGKW-k7esc639.js +2 -0
  546. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-lgr6k9wo.js} +6 -6
  547. package/static/dist/assets/{java-ndjf2o89.js → java-nxbe8uju.js} +1 -1
  548. package/static/dist/assets/{javascript-coftlwy3.js → javascript-lz3kxcyz.js} +1 -1
  549. package/static/dist/assets/{jinja-etei9wy7.js → jinja-l5pk84gt.js} +1 -1
  550. package/static/dist/assets/{jison-hyekgft1.js → jison-jdutdwyy.js} +1 -1
  551. package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-ndmbxuqk.js} +1 -1
  552. package/static/dist/assets/{json-lok0yaho.js → json-dnge4ssf.js} +1 -1
  553. package/static/dist/assets/{jsx-b64w0nbx.js → jsx-c3lwhkep.js} +1 -1
  554. package/static/dist/assets/{jsx-runtime-eu92a5pu.js → jsx-runtime-o40ukmjl.js} +1 -1
  555. package/static/dist/assets/{julia-jxxzz2yn.js → julia-hwd4rrcb.js} +1 -1
  556. package/static/dist/assets/{just-mc29iw9t.js → just-nka3oxdx.js} +1 -1
  557. package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-mjxym0jp.js} +28 -28
  558. package/static/dist/assets/katex-cbdd1ohw.css +1 -0
  559. package/static/dist/assets/katex-pbij2oo6.js +257 -0
  560. package/static/dist/assets/{latex-pafa03ft.js → latex-iqmr7e40.js} +1 -1
  561. package/static/dist/assets/lazyRouteComponent-cwb6l7xh.js +1 -0
  562. package/static/dist/assets/{line-meizqsvn.js → line-mtr5k6il.js} +1 -1
  563. package/static/dist/assets/linear-jlwrvvy6.js +1 -0
  564. package/static/dist/assets/link-iadw4ghw.js +2 -0
  565. package/static/dist/assets/{liquid-hz5c2pov.js → liquid-okmk9tbp.js} +1 -1
  566. package/static/dist/assets/locale-cs-n41hptse.js +5 -0
  567. package/static/dist/assets/locale-de-k7ht47ew.js +5 -0
  568. package/static/dist/assets/locale-es-ih078lco.js +5 -0
  569. package/static/dist/assets/locale-fr-ojabnf41.js +5 -0
  570. package/static/dist/assets/locale-it-kb96jufy.js +5 -0
  571. package/static/dist/assets/locale-ja-jpsp3xs1.js +7 -0
  572. package/static/dist/assets/locale-ko-cpzuy0n7.js +5 -0
  573. package/static/dist/assets/locale-pl-dbap4leu.js +5 -0
  574. package/static/dist/assets/locale-pt-nci7w72x.js +5 -0
  575. package/static/dist/assets/locale-ru-soghe4cs.js +7 -0
  576. package/static/dist/assets/locale-tr-ks00mu68.js +5 -0
  577. package/static/dist/assets/locale-vi-ij7twfm6.js +5 -0
  578. package/static/dist/assets/locale-zh-Hant-feakwr7a.js +5 -0
  579. package/static/dist/assets/locale-zh-ren0vjyv.js +7 -0
  580. package/static/dist/assets/login-lkhtzfcl.js +1 -0
  581. package/static/dist/assets/{lua-leyf8mlb.js → lua-krx4xj5h.js} +1 -1
  582. package/static/dist/assets/{marko-cn1fzpdv.js → marko-gbvpfehz.js} +1 -1
  583. package/static/dist/assets/{mdc-huhy88df.js → mdc-c21zlyi5.js} +1 -1
  584. package/static/dist/assets/mermaid-HWGCJPDP-jx0q5dm2.js +1 -0
  585. package/static/dist/assets/mermaid-parser.core-k91vdnvi.js +7 -0
  586. package/static/dist/assets/micromark-factory-space-fnmnpi6s.js +1 -0
  587. package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-ehztfea4.js} +28 -28
  588. package/static/dist/assets/{nginx-e7lvhsmn.js → nginx-f3d68ed4.js} +1 -1
  589. package/static/dist/assets/{nim-dun5d8mk.js → nim-dw0jcy70.js} +1 -1
  590. package/static/dist/assets/onboarding-f67z0a2r.js +1 -0
  591. package/static/dist/assets/packet-AYTQ26CC-ok2xrblz.js +1 -0
  592. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  593. package/static/dist/assets/pegDiagram-XKGWAZYB-ec3rpj0m.js +1 -0
  594. package/static/dist/assets/{perl-bj3oqtet.js → perl-3c0r45c8.js} +1 -1
  595. package/static/dist/assets/{php-buzp12r0.js → php-rznhidvp.js} +1 -1
  596. package/static/dist/assets/pie-WAS4IAKB-iwkir23u.js +1 -0
  597. package/static/dist/assets/pieDiagram-E7YTZNPT-fcpqpekh.js +39 -0
  598. package/static/dist/assets/{pug-mjad7he9.js → pug-f2le4f3y.js} +1 -1
  599. package/static/dist/assets/{qml-d1gdp10s.js → qml-e2wbq9cr.js} +1 -1
  600. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-lqsync43.js} +1 -1
  601. package/static/dist/assets/queryKeys-ed4n2egc.js +9 -0
  602. package/static/dist/assets/{r-o3r9c7ao.js → r-epfzuw6q.js} +1 -1
  603. package/static/dist/assets/radar-RG4KPBEZ-hje4jbk6.js +1 -0
  604. package/static/dist/assets/railroad-74A4TZTK-gll9n6qi.js +1 -0
  605. package/static/dist/assets/railroad-abnf-HS5TGJTU-o6h6lpn4.js +1 -0
  606. package/static/dist/assets/railroad-ebnf-LZEXJU2U-juaefj6o.js +1 -0
  607. package/static/dist/assets/railroad-peg-WCYAUIDC-cgfhvpah.js +1 -0
  608. package/static/dist/assets/railroadDiagram-O6MQD6OU-lp3w7awf.js +1 -0
  609. package/static/dist/assets/{razor-ilc585yr.js → razor-pea1u1d0.js} +1 -1
  610. package/static/dist/assets/refresh-cw-iccm7a4e.js +1 -0
  611. package/static/dist/assets/refresh-i9ya9dvl.js +1 -0
  612. package/static/dist/assets/{regexp-mpjtcvwm.js → regexp-hzte0a5x.js} +1 -1
  613. package/static/dist/assets/registry-g7w3353u.js +1 -0
  614. package/static/dist/assets/requirementDiagram-BXWQKSXE-ha8eibcf.js +84 -0
  615. package/static/dist/assets/rolldown-runtime-bnnw9q4a.js +1 -0
  616. package/static/dist/assets/{rst-jrkwjdyi.js → rst-m6tugj5o.js} +1 -1
  617. package/static/dist/assets/{ruby-n4tjcbpz.js → ruby-l19vq3w1.js} +1 -1
  618. package/static/dist/assets/runtime-nlxpnegr.js +1 -0
  619. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-oxf31pr9.js} +1 -1
  620. package/static/dist/assets/{sas-hjo6y93k.js → sas-ktdxo397.js} +1 -1
  621. package/static/dist/assets/{scss-ncidjv48.js → scss-kn3oxfwf.js} +1 -1
  622. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-jiq8lhyo.js} +11 -11
  623. package/static/dist/assets/settings_section_extensions_title-fadc7o65.js +1 -0
  624. package/static/dist/assets/share._token-o76ki0ly.js +2 -0
  625. package/static/dist/assets/{shellscript-m8whjpqd.js → shellscript-l100jtrg.js} +1 -1
  626. package/static/dist/assets/{shellsession-i5bj6syz.js → shellsession-m5qukwow.js} +1 -1
  627. package/static/dist/assets/sizeCapture-INFHLROL-dk1io13w.js +1 -0
  628. package/static/dist/assets/sliders-horizontal-ddcs88av.js +1 -0
  629. package/static/dist/assets/{soy-hhh36ysq.js → soy-mexlscur.js} +1 -1
  630. package/static/dist/assets/{sql-j21bzcl6.js → sql-o0ho5suh.js} +1 -1
  631. package/static/dist/assets/src-h89s0hs4.js +1 -0
  632. package/static/dist/assets/{stata-bbu2onh1.js → stata-be9ck1q3.js} +1 -1
  633. package/static/dist/assets/stateDiagram-D77RDMKH-f3fslnon.js +1 -0
  634. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hlt6yphw.js +1 -0
  635. package/static/dist/assets/{surrealql-l5vczuco.js → surrealql-gk1xkkyy.js} +1 -1
  636. package/static/dist/assets/{svelte-idn422g6.js → svelte-k6ecjilf.js} +1 -1
  637. package/static/dist/assets/swimlanes-42K2YHIH-k1hmk7jf.js +1 -0
  638. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-hd5t9tnt.js +8 -0
  639. package/static/dist/assets/{templ-e22qou3c.js → templ-gf3bftk6.js} +1 -1
  640. package/static/dist/assets/{tex-deqpc5ee.js → tex-d21fqv1z.js} +1 -1
  641. package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-n3ncfy9r.js} +8 -8
  642. package/static/dist/assets/toast-liparz2j.js +56 -0
  643. package/static/dist/assets/treeView-Q6P3EWNA-l3y2pejt.js +1 -0
  644. package/static/dist/assets/treemap-WGGIJYW6-jown9axh.js +1 -0
  645. package/static/dist/assets/{ts-tags-ctnibc27.js → ts-tags-n0g61gr6.js} +1 -1
  646. package/static/dist/assets/{tsx-i163n32g.js → tsx-cvv7wv6q.js} +1 -1
  647. package/static/dist/assets/{twig-x0fi7wm8.js → twig-n1f67204.js} +1 -1
  648. package/static/dist/assets/{typescript-g18c34xz.js → typescript-og7o55cx.js} +1 -1
  649. package/static/dist/assets/untitled-mj86r4gz.js +1 -0
  650. package/static/dist/assets/useDebounced-g8r0ggve.js +1 -0
  651. package/static/dist/assets/useForm-o4uu74mx.js +1 -0
  652. package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-ftsveu84.js} +1 -1
  653. package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-crqueqga.js} +1 -1
  654. package/static/dist/assets/vennDiagram-4TSXK5OY-dh59uq1c.js +34 -0
  655. package/static/dist/assets/{vue-mwu5l1bb.js → vue-ej4xpwqw.js} +1 -1
  656. package/static/dist/assets/{vue-html-nskj3du0.js → vue-html-d48xsjuo.js} +1 -1
  657. package/static/dist/assets/{vue-vine-cqe77smq.js → vue-vine-pev88ip6.js} +1 -1
  658. package/static/dist/assets/wardley-WFR3VGLG-e6d74b0j.js +1 -0
  659. package/static/dist/assets/wardleyDiagram-VM6X3IG4-hnofh78f.js +78 -0
  660. package/static/dist/assets/with-selector-nu1b5z0v.js +1 -0
  661. package/static/dist/assets/{xml-gau7gvrv.js → xml-ifxp6gqb.js} +1 -1
  662. package/static/dist/assets/{xsl-ew09zq8b.js → xsl-fug2cckc.js} +1 -1
  663. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-bpymjelb.js} +6 -6
  664. package/static/dist/assets/{yaml-ofnrxtvf.js → yaml-fjmafnlo.js} +1 -1
  665. package/static/dist/extension-sdk.js +1 -1
  666. package/static/dist/index.html +1 -1
  667. package/static/dist/sw.js +180 -136
  668. package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
  669. package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
  670. package/static/dist/assets/Dialog-g355qxhp.js +0 -1
  671. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
  672. package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
  673. package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
  674. package/static/dist/assets/Markdown-lwviulmg.css +0 -1
  675. package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
  676. package/static/dist/assets/Select-kddq75qc.js +0 -1
  677. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
  678. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
  679. package/static/dist/assets/States-eq6fe29m.js +0 -1
  680. package/static/dist/assets/Toaster-pc247u9f.js +0 -1
  681. package/static/dist/assets/_app-f8n1i0m1.js +0 -1
  682. package/static/dist/assets/_app.ext._extensionId-gfbzpoou.js +0 -1
  683. package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
  684. package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
  685. package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
  686. package/static/dist/assets/_app.logs-i69qds98.js +0 -1
  687. package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
  688. package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
  689. package/static/dist/assets/_app.session._sessionId-fn1bhde6.js +0 -1
  690. package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
  691. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
  692. package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
  693. package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
  694. package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
  695. package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
  696. package/static/dist/assets/abnfDiagram-VCTEODGH-c8kdixke.js +0 -1
  697. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
  698. package/static/dist/assets/architectureDiagram-5GKGNRK7-cku06x1p.js +0 -36
  699. package/static/dist/assets/back-h7aun7ew.js +0 -1
  700. package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
  701. package/static/dist/assets/channel-b2zo73z1.js +0 -1
  702. package/static/dist/assets/chunk-DU6HZSFF-ofajdixh.js +0 -128
  703. package/static/dist/assets/chunk-OSK3NFVY-e7gkikcw.js +0 -10
  704. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
  705. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
  706. package/static/dist/assets/copied-hcplv46p.js +0 -1
  707. package/static/dist/assets/copy-magtofyp.js +0 -1
  708. package/static/dist/assets/cose-bilkent-JH36ORCC-eclcy6ja.js +0 -1
  709. package/static/dist/assets/cynefin-OW5HDTMX-siqlulkl.js +0 -1
  710. package/static/dist/assets/cynefinDiagram-5FMLGOSQ-i648tah9.js +0 -62
  711. package/static/dist/assets/cytoscape.esm-bqu53u12.js +0 -321
  712. package/static/dist/assets/dagre-GXQ25YYZ-mwn6uc17.js +0 -4
  713. package/static/dist/assets/dagre-lkoknbjn.js +0 -1
  714. package/static/dist/assets/diagram-UQ7AKVKN-mekq7zgq.js +0 -41
  715. package/static/dist/assets/diagram-VX7I27RA-xe6r5qfw.js +0 -24
  716. package/static/dist/assets/diagram-Z3DM3KII-nq07dg7k.js +0 -24
  717. package/static/dist/assets/ebnfDiagram-PWID7BFC-c62ipm3g.js +0 -1
  718. package/static/dist/assets/endpoints-klbxod4t.js +0 -64
  719. package/static/dist/assets/erDiagram-RLTQ6QDP-h39kmnzy.js +0 -99
  720. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
  721. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
  722. package/static/dist/assets/gitGraph-4MIJSDKK-fr2i7caa.js +0 -1
  723. package/static/dist/assets/highlighted-body-KPVGNVTW-jwpop9p8.js +0 -1
  724. package/static/dist/assets/host-i828xuo7.js +0 -1
  725. package/static/dist/assets/index-eh8zilnv.css +0 -2
  726. package/static/dist/assets/index-r648f4o1.js +0 -11
  727. package/static/dist/assets/info-A6RAGUB7-h11x0dxo.js +0 -1
  728. package/static/dist/assets/infoDiagram-27XIBGKW-dfh47dw9.js +0 -2
  729. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
  730. package/static/dist/assets/linear-becwlnhk.js +0 -1
  731. package/static/dist/assets/link-oot2yn73.js +0 -1
  732. package/static/dist/assets/login-gu28l9ou.js +0 -1
  733. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
  734. package/static/dist/assets/mermaid-parser.core-ntne45i9.js +0 -7
  735. package/static/dist/assets/not-found-ccnnac39.js +0 -1
  736. package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
  737. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
  738. package/static/dist/assets/packet-AYTQ26CC-l6jdka8p.js +0 -1
  739. package/static/dist/assets/pegDiagram-XKGWAZYB-g1er3bxj.js +0 -1
  740. package/static/dist/assets/pie-WAS4IAKB-i0ylcrt9.js +0 -1
  741. package/static/dist/assets/pieDiagram-E7YTZNPT-fbz1e55z.js +0 -39
  742. package/static/dist/assets/radar-RG4KPBEZ-f9b70aa4.js +0 -1
  743. package/static/dist/assets/railroad-74A4TZTK-lgsuoeda.js +0 -1
  744. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
  745. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
  746. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
  747. package/static/dist/assets/railroadDiagram-O6MQD6OU-eqpy9fnz.js +0 -1
  748. package/static/dist/assets/refresh-abtu93ke.js +0 -1
  749. package/static/dist/assets/refresh-cw-hlwb02qv.js +0 -1
  750. package/static/dist/assets/requirementDiagram-BXWQKSXE-ogf6puhi.js +0 -84
  751. package/static/dist/assets/rolldown-runtime-b9miacsj.js +0 -1
  752. package/static/dist/assets/root-mbklpuv4.js +0 -1
  753. package/static/dist/assets/saved-girnabkf.js +0 -1
  754. package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
  755. package/static/dist/assets/share._token-kl0r7ido.js +0 -2
  756. package/static/dist/assets/sizeCapture-INFHLROL-g612l48j.js +0 -1
  757. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
  758. package/static/dist/assets/src-hjyib4mu.js +0 -1
  759. package/static/dist/assets/sse-iwku0sjo.js +0 -1
  760. package/static/dist/assets/stateDiagram-D77RDMKH-fyoznpcs.js +0 -1
  761. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
  762. package/static/dist/assets/swimlanes-42K2YHIH-oefldhfy.js +0 -1
  763. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
  764. package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
  765. package/static/dist/assets/treeView-Q6P3EWNA-f7hcci5o.js +0 -1
  766. package/static/dist/assets/treemap-WGGIJYW6-nl85071a.js +0 -1
  767. package/static/dist/assets/units-kgxzxfhi.js +0 -1
  768. package/static/dist/assets/untitled-cikfjntn.js +0 -1
  769. package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
  770. package/static/dist/assets/useLocale-o59tbiea.js +0 -1
  771. package/static/dist/assets/vennDiagram-4TSXK5OY-h5hzbk0n.js +0 -34
  772. package/static/dist/assets/wardley-WFR3VGLG-jftvpm3e.js +0 -1
  773. package/static/dist/assets/wardleyDiagram-VM6X3IG4-hedh85ge.js +0 -78
  774. /package/static/dist/assets/{ChatPage-gmu5c7lf.css → TerminalPanel-gmu5c7lf.css} +0 -0
@@ -14,21 +14,29 @@ import contextlib
14
14
  import contextvars
15
15
  import copy
16
16
  import hashlib
17
+ import importlib.util
17
18
  import inspect
18
19
  import json
19
20
  import logging
20
21
  import os
22
+ import queue
23
+ import re
21
24
  import threading
22
25
  import time
23
26
  import uuid
24
27
  from collections import OrderedDict
28
+ from functools import lru_cache
25
29
  from pathlib import Path
26
- from typing import Any
30
+ from typing import Any, Callable
31
+ from types import SimpleNamespace
27
32
 
28
33
  from ..errors import InvalidParams, RpcError
29
- from ..home import profile_home_param, scoped_home
34
+ from ..home import profile_home_param, scoped_home, session_workspace
30
35
  from ..rpc import CallContext
31
36
  from .checkpoints import checkpoint_kwargs
37
+ from .providers import runtime_failure
38
+ from .state_db import release_all, release_home, release_session_db
39
+ from .toolsets import disabled_toolsets
32
40
 
33
41
  log = logging.getLogger("talaria_sidecar.chat")
34
42
 
@@ -37,10 +45,10 @@ _AGENT_CACHE_MAX = 32
37
45
  _AGENT_RELEASE_TIMEOUT = 4.0
38
46
  # Profile deletion waits this long for the profile's memory flushes, under the server's 120 s call timeout.
39
47
  _PROFILE_RELEASE_TIMEOUT = 60.0
48
+ _SESSION_FINALIZE_TIMEOUT = 10.0
40
49
  _TOOL_RESULT_SNIPPET_MAX = 4000
41
50
  # Predecessor ``_TOOL_ARG_CONTENT_KEYS`` (#4928): card content / diff-reconstruction inputs keep the long cap.
42
51
  _TOOL_ARG_CONTENT_KEYS = frozenset({"command", "cmd", "script", "code", "patch", "diff", "old_string", "new_string", "content", "path", "file_path"})
43
- _CLARIFY_FALLBACK = "The user did not provide a response within the time limit. Use your best judgement to make the choice and proceed."
44
52
 
45
53
 
46
54
  class _Run:
@@ -56,6 +64,11 @@ class _Run:
56
64
  self.lock = threading.Lock()
57
65
  # The Agent's transcript list from before this turn: a Stop while the Agent still holds it has no checkpoint.
58
66
  self.prior_messages = None
67
+ # TAL-897: the session ended (clear/delete) or the sidecar is draining, set under both cache and run locks; an
68
+ # agent this run binds afterwards forks no review and stays out of the cache.
69
+ self.session_ended = False
70
+ self.home = None # the profile home its agent runs under, set by ``start``
71
+ self.mcp_discovery_outcome = "ok"
59
72
 
60
73
 
61
74
  class _ClarifyEntry:
@@ -64,6 +77,7 @@ class _ClarifyEntry:
64
77
  self.data = data
65
78
  self.event = threading.Event()
66
79
  self.result: str | None = None
80
+ self.deadline: float | None = None
67
81
 
68
82
 
69
83
  _RUNS: dict[str, _Run] = {}
@@ -76,13 +90,22 @@ _RUNS_LOCK = threading.Lock()
76
90
  # session id -> (agent, signature, profile home).
77
91
  _AGENT_CACHE: "OrderedDict[str, tuple[Any, str, Any]]" = OrderedDict()
78
92
  _AGENT_CACHE_LOCK = threading.Lock()
79
- # Running release thread -> the profile home it runs under.
80
- _RELEASES: dict[threading.Thread, Any] = {}
93
+ # Running or queued release work -> the profile home it holds.
94
+ _RELEASES: dict[threading.Thread | _ArchiveCallback, Any] = {}
81
95
  _RELEASES_LOCK = threading.Lock()
82
- # Agents inside ``chat.commit_memory`` (guarded by ``_AGENT_CACHE_LOCK``): like a turn, a commit defers their release.
96
+ _ARCHIVE_MAX_WORKERS = 4
97
+ _ARCHIVE_QUEUE: queue.Queue[_ArchiveCallback | None] = queue.Queue(maxsize=256)
98
+ _ARCHIVE_WORKERS: list[threading.Thread] = []
99
+ # Non-turn ``(agent, home)`` holders for memory commits, refine, context reads and MCP refresh (under
100
+ # ``_AGENT_CACHE_LOCK``): each defers the agent's release, and deleting ``home`` waits for it.
83
101
  _COMMITTING: list = []
84
102
  # Set (under ``_RUNS_LOCK``) when shutdown drains: no turn is admitted after the drain's snapshot of ``_RUNS``.
85
103
  _DRAINING = threading.Event()
104
+ _MCP_DISCOVERIES: dict[str, threading.Event] = {}
105
+ _MCP_DISCOVERY_LOCK = threading.Lock()
106
+ _MCP_CLOSING_HOMES: set[str] = set()
107
+ _MCP_REDISCOVERY_WAIT = 2.0
108
+ _MCP_PENDING_KEY = "mcp_discovery_pending"
86
109
 
87
110
 
88
111
  def _snippet(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> str:
@@ -101,7 +124,17 @@ def _snippet(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> str:
101
124
 
102
125
 
103
126
  _RAW_RESULT_MAX_KEYS = 64
104
- _RAW_RESULT_OUTCOME_KEYS = ("error", "exit_code", "exitCode", "success")
127
+ _RAW_RESULT_OUTCOME_KEYS = ("error", "exit_code", "exitCode", "success", "status", "results")
128
+ # TAL-1261: a ``results`` batch keeps up to 64 items, each projected like the result with 16 fields of 400 characters.
129
+ _RAW_RESULT_ITEM_KEYS = 16
130
+ _RAW_RESULT_ITEM_MAX = 400
131
+
132
+
133
+ def _without_error(item: Any) -> bool:
134
+ """An item the server's ``presentError`` reads as error-free: not an object, or its ``error`` is blank text, ``false``,
135
+ ``null``, a number, or an empty object or list."""
136
+ error = item.get("error") if isinstance(item, dict) else None
137
+ return not (error is True or (isinstance(error, str) and error.strip()) or (isinstance(error, (dict, list)) and error))
105
138
 
106
139
 
107
140
  def _result_diff(raw: Any) -> str | None:
@@ -114,10 +147,36 @@ def _result_diff(raw: Any) -> str | None:
114
147
  return diff if isinstance(diff, str) else None
115
148
 
116
149
 
117
- def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
150
+ def _edit_snapshot(name: str, args: Any, task_id: str) -> Any:
151
+ """TAL-909: the Agent's before-state of a file edit, through the task's terminal; None for a remote backend (the
152
+ Agent never diffs an unrelated local file), a non-edit tool, or any failure."""
153
+ try:
154
+ from agent.display import capture_local_edit_snapshot
155
+
156
+ return capture_local_edit_snapshot(name, args, task_id=task_id)
157
+ except Exception: # noqa: BLE001
158
+ return None
159
+
160
+
161
+ def _edit_metadata(name: str, args: Any, result: Any, snapshot: Any) -> dict:
162
+ """TAL-909: a tool-result row's ``display_metadata``: an edit's unified ``diff`` from its snapshot, which the server
163
+ turns into ``edit_diff``. Empty when the result carries its own ``diff`` (``patch``), which the server already reads."""
164
+ if snapshot is None or _result_diff(result) is not None:
165
+ return {}
166
+ try:
167
+ from agent.display import extract_edit_diff
168
+
169
+ diff = extract_edit_diff(name, result, function_args=args, snapshot=snapshot)
170
+ except Exception: # noqa: BLE001
171
+ return {}
172
+ return {"diff": diff} if diff else {}
173
+
174
+
175
+ def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX, max_keys: int = _RAW_RESULT_MAX_KEYS, items: bool = True) -> Any:
118
176
  """The tool result as the server's outcome rule reads it, bounded: a dict (or JSON-object text) keeps its first
119
- ``_RAW_RESULT_MAX_KEYS`` top-level fields plus its outcome fields, scalars as they are, text and non-empty nested
120
- values as capped (JSON) text; anything else is the capped text. The sidecar decides nothing; the server does."""
177
+ ``max_keys`` top-level fields plus its outcome fields, scalars as they are, text and non-empty nested values as capped
178
+ (JSON) text, except a ``results`` list, whose object items keep the same shape one level down (TAL-1261); anything
179
+ else is the capped text. The sidecar decides nothing; the server does."""
121
180
  try:
122
181
  data = raw if isinstance(raw, dict) else json.loads(str(raw or ""))
123
182
  except Exception: # noqa: BLE001
@@ -126,9 +185,15 @@ def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
126
185
  return str(raw if raw is not None else "")[:limit]
127
186
  out: dict = {}
128
187
  # The server's outcome rule reads these, wherever they sit in the result.
129
- kept = list(data.items())[:_RAW_RESULT_MAX_KEYS] + [(k, data[k]) for k in _RAW_RESULT_OUTCOME_KEYS if k in data]
188
+ kept = list(data.items())[:max_keys] + [(k, data[k]) for k in _RAW_RESULT_OUTCOME_KEYS if k in data]
130
189
  for key, value in kept:
131
- if value is None or isinstance(value, (bool, int)) or (isinstance(value, float) and value == value and abs(value) != float("inf")):
190
+ if items and key == "results" and isinstance(value, list) and value:
191
+ # Past the cap, the first item without an error rides along, so a long batch is judged like the whole one.
192
+ batch = value[:_RAW_RESULT_MAX_KEYS] + [item for item in value[_RAW_RESULT_MAX_KEYS:] if _without_error(item)][:1]
193
+ # A non-object item stays text, even JSON-object text: only object items count toward the batch's outcome.
194
+ out[key] = [_raw_result(item, _RAW_RESULT_ITEM_MAX, _RAW_RESULT_ITEM_KEYS, False) if isinstance(item, dict)
195
+ else (item if isinstance(item, str) else json.dumps(item, default=str))[:_RAW_RESULT_ITEM_MAX] for item in batch]
196
+ elif value is None or isinstance(value, (bool, int)) or (isinstance(value, float) and value == value and abs(value) != float("inf")):
132
197
  out[str(key)] = value
133
198
  elif isinstance(value, (dict, list)) and not value:
134
199
  out[str(key)] = {} if isinstance(value, dict) else []
@@ -138,6 +203,18 @@ def _raw_result(raw: Any, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> Any:
138
203
  return out
139
204
 
140
205
 
206
+ _TODO_TOOL_NAMES = frozenset({"todo", "todo_list"})
207
+
208
+
209
+ def _is_todo_tool(name: Any) -> bool:
210
+ """TAL-889: the Agent's own todo-tool predicate (canonical ``todo_list`` and legacy aliases); older Agents lack it."""
211
+ try:
212
+ from tools.todo_tool import is_todo_tool_name
213
+ except Exception: # noqa: BLE001 - older Agent without the predicate
214
+ return isinstance(name, str) and name in _TODO_TOOL_NAMES
215
+ return bool(is_todo_tool_name(name))
216
+
217
+
141
218
  def _delegation_cost_usd(name: Any, raw: Any):
142
219
  """Predecessor ``_delegation_cost_usd``: total spend a ``delegate_task`` result reports, or None."""
143
220
  if str(name or "") != "delegate_task":
@@ -255,9 +332,29 @@ def _context_length(agent: Any) -> dict:
255
332
  value = getattr(cc, key, None)
256
333
  if isinstance(value, (int, float)):
257
334
  out[key] = int(value)
335
+ threshold = _next_turn_threshold(agent, cc)
336
+ if threshold:
337
+ out["next_turn_threshold_tokens"] = threshold
258
338
  return out
259
339
 
260
340
 
341
+ def _next_turn_threshold(agent: Any, cc: Any) -> int | None:
342
+ """TAL-1005: the compression threshold the next turn runs under. A turn that ended on a fallback leaves the
343
+ fallback's threshold on the compressor, but the Agent restores the primary runtime at the next turn's start
344
+ (``restore_primary_runtime``), so this is the trigger the compressor installs for that runtime's snapshot."""
345
+ value = getattr(cc, "threshold_tokens", None)
346
+ primary = getattr(agent, "_primary_runtime", None)
347
+ if getattr(agent, "_fallback_activated", False) is True and isinstance(primary, dict):
348
+ value = primary.get("compressor_threshold_tokens")
349
+ preview = getattr(cc, "preview_threshold_tokens", None)
350
+ if callable(preview) and isinstance(primary.get("compressor_context_length"), int):
351
+ try:
352
+ value = preview(str(primary.get("compressor_model") or ""), primary["compressor_context_length"], str(primary.get("compressor_provider") or ""))
353
+ except Exception: # noqa: BLE001
354
+ log.debug("primary threshold preview failed", exc_info=True)
355
+ return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 else None
356
+
357
+
261
358
  # TAL-539: the hard cap on raw tool output kept in a compressed context (predecessor ``_hard_prune_post_compression_*``).
262
359
  _TAIL_TOOL_BUDGET_TOKENS = 4096
263
360
  _TAIL_TOOL_MIN_SNIPPET_TOKENS = 256
@@ -280,25 +377,111 @@ def _rough_tokens(text: str) -> int:
280
377
  return -(-len(text) // _ROUGH_TOKEN_CHARS)
281
378
 
282
379
 
380
+ def _tool_failure_outcome(text: str) -> str:
381
+ """`` FAILED: <error>`` for a JSON tool payload with a top-level ``error`` / ``success: false``, `` FAILED: exit N`` for
382
+ a non-zero ``exit_code`` / ``exitCode`` the agent did not kill itself, else ``""``: the Agent's compression-stub rule
383
+ (``_payload_failure_suffix``, ``_sum_process_manage``). Payloads put these fields after ``output``, so a cut drops them."""
384
+ try:
385
+ payload = json.loads(text)
386
+ except (ValueError, RecursionError):
387
+ return ""
388
+ if not isinstance(payload, dict):
389
+ return ""
390
+ error = payload.get("error")
391
+ if not error and payload.get("success") is not False:
392
+ code = payload.get("exit_code")
393
+ code = payload.get("exitCode") if code is None else code
394
+ if not isinstance(code, int) or isinstance(code, bool) or code == 0 or payload.get("completion_reason") == "killed":
395
+ return ""
396
+ error = f"exit {code}"
397
+ preview = " ".join(str(error).split())[:80] if error else ""
398
+ return f" FAILED: {preview}" if preview else " FAILED"
399
+
400
+
283
401
  def _capped_tool_result(text: str, tokens: int, keep_tokens: int) -> str:
284
- """The kept head of an over-budget tool result plus a note on what the model no longer sees."""
285
- prefix = "[Talaria context budget: omitted "
286
- suffix = f" (~{tokens} rough tokens) from this tool result; the full output remains in the visible transcript.]"
402
+ """The kept head of an over-budget tool result plus a note on what the model no longer sees, ending on the outcome.
403
+ TAL-827: the Agent's elision marker marks the cut, so its dispatch guard rejects a model that copies the marker
404
+ into a durable write; Talaria's note follows on its own line."""
405
+ outcome = _tool_failure_outcome(text)
406
+ try:
407
+ from agent.compression_marker import elide_middle
408
+ except ImportError: # An Agent without the shared marker keeps Talaria's own.
409
+ prefix = "[Talaria context budget: omitted "
410
+ suffix = f" (~{tokens} rough tokens) from this tool result; the full output remains in the visible transcript.{outcome}]"
411
+ note_only, widest = f"{prefix}{len(text)} chars{suffix}", f"{prefix}{len(text)} of {len(text)} chars{suffix}"
412
+
413
+ def cut(snippet: str) -> str:
414
+ return f"{snippet}\n\n{prefix}{len(text) - len(snippet)} of {len(text)} chars{suffix}"
415
+ else:
416
+ note = f"\n[Talaria context budget: this tool result is ~{tokens} rough tokens; the full output remains in the visible transcript.{outcome}]"
417
+ # With nothing kept the marker counts every char, its widest form for this text.
418
+ note_only = widest = elide_middle(text, 0, 0) + note
419
+
420
+ def cut(snippet: str) -> str:
421
+ return elide_middle(text, len(snippet), 0) + note
287
422
  if keep_tokens < _TAIL_TOOL_MIN_SNIPPET_TOKENS:
288
- return f"{prefix}{len(text)} chars{suffix}"
289
- room = keep_tokens - _rough_tokens(f"{prefix}{len(text)} of {len(text)} chars{suffix}") - 1
423
+ return note_only
424
+ room = keep_tokens - _rough_tokens(widest) - 1
290
425
  snippet = text[: len(text) * room // tokens]
291
426
  # Density varies along the text: shrink until the kept head fits the remaining tokens.
292
427
  while snippet and _rough_tokens(snippet) > room:
293
428
  snippet = snippet[: len(snippet) * 3 // 4]
294
429
  snippet = snippet.rstrip()
295
- if not snippet:
296
- return f"{prefix}{len(text)} chars{suffix}"
297
- return f"{snippet}\n\n{prefix}{len(text) - len(snippet)} of {len(text)} chars{suffix}"
430
+ return cut(snippet) if snippet else note_only
298
431
 
299
432
 
300
- def _cap_tool_results(messages: list, compressor: Any) -> list:
301
- """Newest tool results spend one shared budget; the first one over it is cut to the remainder, older ones to a note."""
433
+ def _skill_result_stubs(messages: list) -> dict[int, str]:
434
+ """Recovery metadata for paired skill loads, captured before the Agent can prune their success/failure payloads.
435
+ Extends the Agent's call-id and marker helpers; its older summaries omit small-skill markers and failure verdicts."""
436
+ try:
437
+ from agent.context_compressor import _tool_calls_by_id
438
+ except ImportError:
439
+ return {}
440
+ try:
441
+ from agent.context_compressor import _skill_pruned_marker
442
+ except ImportError:
443
+ def _skill_pruned_marker(name: str) -> str:
444
+ return f"[SKILL_PRUNED: content lost in compression; reload with skill_view(name='{name}')]"
445
+
446
+ calls = _tool_calls_by_id(messages)
447
+ stubs = {}
448
+ for i, msg in enumerate(messages):
449
+ if msg.get("role") != "tool" or not msg.get("tool_call_id"):
450
+ continue
451
+ tool, raw_args = calls.get(msg["tool_call_id"], (None, None))
452
+ if tool != "skill_view":
453
+ continue
454
+ try:
455
+ args = json.loads(raw_args)
456
+ except (TypeError, ValueError, RecursionError):
457
+ continue
458
+ name = args.get("name") if isinstance(args, dict) else None
459
+ if not isinstance(name, str) or not name or args.get("file_path"):
460
+ continue
461
+ text = _content_text(msg.get("content"))
462
+ outcome = _tool_failure_outcome(text)
463
+ if text.startswith("[skill_view] ") and " FAILED" in text.split(" (", 1)[0]:
464
+ outcome = " FAILED"
465
+ if outcome:
466
+ stubs[i] = f"[skill_view] name={name}{outcome}"
467
+ continue
468
+ try:
469
+ payload = json.loads(text)
470
+ except (ValueError, RecursionError):
471
+ payload = None
472
+ successful = isinstance(payload, dict) and payload.get("success") is True and isinstance(payload.get("content"), str)
473
+ marker = _skill_pruned_marker(name)
474
+ # Lean-tail stubs predate the current cap and may have been persisted by a different supported Agent.
475
+ demoted = text.startswith(("[skill_view output demoted at compaction ", "[tool output demoted at compaction ")) and text.endswith("] " + marker)
476
+ if successful or text.startswith(f"[skill_view] name={name} (") or text == marker or demoted:
477
+ stubs[i] = marker
478
+ return stubs
479
+
480
+
481
+ def _cap_tool_results(messages: list, compressor: Any, original_messages: list | None = None) -> list:
482
+ """Newest bodies spend one shared budget. Skill reload/failure stubs, like omission notes, survive exhaustion."""
483
+ originals = messages if original_messages is None else original_messages
484
+ skill_stubs = _skill_result_stubs(originals)
302
485
  budget = getattr(compressor, "tail_token_budget", None)
303
486
  budget = budget if isinstance(budget, int) and budget > 0 else _TAIL_TOOL_BUDGET_TOKENS
304
487
  threshold = getattr(compressor, "threshold_tokens", None)
@@ -312,13 +495,17 @@ def _cap_tool_results(messages: list, compressor: Any) -> list:
312
495
  if msg.get("role") != "tool":
313
496
  continue
314
497
  text = _content_text(msg.get("content"))
498
+ stub = skill_stubs.get(i)
499
+ if stub and text != _content_text(originals[i].get("content")):
500
+ text = stub
501
+ out[i] = {**msg, "content": text}
315
502
  if not text.strip():
316
503
  continue
317
504
  tokens = _rough_tokens(text)
318
505
  if tokens <= budget - spent:
319
506
  spent += tokens
320
507
  continue
321
- out[i] = {**msg, "content": _capped_tool_result(text, tokens, budget - spent)}
508
+ out[i] = {**msg, "content": stub or _capped_tool_result(text, tokens, budget - spent)}
322
509
  spent = budget
323
510
  return out
324
511
 
@@ -337,7 +524,7 @@ def _post_compression_context(agent: Any, messages: list, system_message: Any) -
337
524
  context = pruned
338
525
  except Exception: # noqa: BLE001 - the hard cap below still bounds the context
339
526
  log.debug("post-compression tool-result pruning failed", exc_info=True)
340
- context = _cap_tool_results(context, compressor)
527
+ context = _cap_tool_results(context, compressor, messages)
341
528
  estimate = None
342
529
  try:
343
530
  from agent.model_metadata import estimate_request_tokens_rough
@@ -350,6 +537,40 @@ def _post_compression_context(agent: Any, messages: list, system_message: Any) -
350
537
  return context, estimate if isinstance(estimate, int) and estimate > 0 else None
351
538
 
352
539
 
540
+ # TAL-887: the Agent's password-manager prompts, answered like a clarify; the waits are the Agent's own surfaces' (its TUI).
541
+ _VAULT_PROMPT_TIMEOUTS = {"vault_unlock": 120, "vault_save_login": 180, "vault_code": 180, "skill_secret": 120}
542
+
543
+
544
+ def _set_vault_session(session_id) -> None:
545
+ """Bind the session an unlock made on this thread belongs to (released with it)."""
546
+ try:
547
+ from agent.vault_backends import unlock
548
+ except Exception: # noqa: BLE001 - an Agent without the credential vault
549
+ return
550
+ unlock.set_current_session_id(session_id)
551
+
552
+
553
+ def _release_vault_session(session_id) -> None:
554
+ """Lock the password managers ``session_id`` unlocked; other sessions keep theirs."""
555
+ try:
556
+ from agent.vault_backends.unlock import release_session
557
+ except Exception: # noqa: BLE001 - an Agent without the credential vault
558
+ return
559
+ release_session(session_id)
560
+
561
+
562
+ def _set_vault_prompts(session_id, unlock_cb, save_login_cb, code_cb) -> None:
563
+ """Bind (or with ``None``s clear) the Agent's per-thread vault prompts and the session an unlock belongs to."""
564
+ try:
565
+ from agent.vault_backends import unlock
566
+ except Exception: # noqa: BLE001 - an Agent without the credential vault
567
+ return
568
+ _set_vault_session(session_id)
569
+ unlock.set_unlock_prompt_callback(unlock_cb)
570
+ unlock.set_save_login_prompt_callback(save_login_cb)
571
+ unlock.set_code_prompt_callback(code_cb)
572
+
573
+
353
574
  def _clarify_timeout(params: dict) -> int:
354
575
  """Predecessor ``_clarify_timeout_seconds``: an explicit request value wins, else the Agent's resolver over config."""
355
576
  explicit = params.get("clarify_timeout_seconds")
@@ -481,7 +702,7 @@ def _resolve_runtime(provider: str | None, model: str) -> dict:
481
702
  try:
482
703
  runtime = resolve_runtime_provider(requested=provider or None, target_model=model or None)
483
704
  except Exception as exc: # noqa: BLE001
484
- raise RpcError(str(exc), condition="credential_missing", data={"error_type": type(exc).__name__}) from exc
705
+ raise runtime_failure(exc, provider) from exc
485
706
  if not isinstance(runtime, dict):
486
707
  runtime = dict(vars(runtime)) if hasattr(runtime, "__dict__") else {}
487
708
  return runtime
@@ -520,14 +741,8 @@ def _turn_identity(session_id: str, workspace: str):
520
741
  except Exception: # noqa: BLE001
521
742
  log.debug("per-turn session context bind failed", exc_info=True)
522
743
  try:
523
- from agent.runtime_cwd import _SESSION_CWD
524
-
525
- tok = _SESSION_CWD.set(str(workspace))
526
- resets.append(lambda: _SESSION_CWD.reset(tok))
527
- except Exception: # noqa: BLE001
528
- log.debug("per-turn session cwd bind failed", exc_info=True)
529
- try:
530
- yield
744
+ with session_workspace(workspace):
745
+ yield
531
746
  finally:
532
747
  for reset in reversed(resets):
533
748
  try:
@@ -541,64 +756,309 @@ def _turn_identity(session_id: str, workspace: str):
541
756
  _LEGACY_TOOLSET_ALIASES = {"hermes": ("hermes-cli", "hermes-api-server")}
542
757
 
543
758
 
544
- def _turn_toolsets(toolsets) -> list[str]:
759
+ def _turn_toolsets(toolsets, cfg: dict | None = None) -> list[str]:
545
760
  """The session's override as sent, else the profile's configured toolsets. ``None`` would hand the Agent every
546
761
  registered toolset."""
547
- return toolsets if isinstance(toolsets, list) else _profile_toolsets()
762
+ return toolsets if isinstance(toolsets, list) else _profile_toolsets(cfg)
548
763
 
549
764
 
550
- def _profile_toolsets() -> list[str]:
765
+ def _profile_toolsets(cfg: dict | None = None) -> list[str]:
551
766
  """The profile's ``platform_toolsets.cli`` through the Agent's own resolver (default-off toolsets stay off), like
552
767
  ``hermes chat``. Runs under the call's ``scoped_home``."""
553
768
  from hermes_cli.config import load_config
554
769
  from hermes_cli.tools_config import _get_platform_tools
555
770
 
556
- resolved = sorted(_get_platform_tools(load_config() or {}, "cli"))
771
+ resolved = sorted(_get_platform_tools((load_config() or {}) if cfg is None else cfg, "cli"))
557
772
  return list(dict.fromkeys(name for raw in resolved for name in _LEGACY_TOOLSET_ALIASES.get(raw, (raw,))))
558
773
 
559
774
 
560
- def _discover_mcp_tools(run: _Run) -> None:
561
- """Connect the profile's MCP servers before the turn's agent snapshots its tools, like the predecessor's turn start.
562
- The Agent keys connections by profile scope and skips live ones, so later turns are cheap. Runs under the call's
563
- ``scoped_home``; a failure leaves the turn without MCP tools rather than failing it. A Stop does not wait for a slow
564
- server: the turn moves on to its cancelled end while the connect finishes in the background."""
565
- from .commands import retire_unscoped_launch_mcp_servers
775
+ def _discover_mcp_tools(run: _Run) -> threading.Event | None:
776
+ """Wait for the profile's first discovery; later turns give a shared background retry at most two seconds."""
777
+ from .commands import discover_profile_mcp_tools, profile_mcp_tools_discovered
566
778
 
567
- done = threading.Event()
779
+ profile_home = run.home
780
+ home = os.path.realpath(profile_home)
781
+ deadline = time.monotonic() + _MCP_REDISCOVERY_WAIT if profile_mcp_tools_discovered(home) else None
568
782
 
569
783
  def discover() -> None:
570
784
  try:
571
- from tools.mcp_tool_discovery import discover_mcp_tools
572
-
573
- retire_unscoped_launch_mcp_servers()
574
- discover_mcp_tools()
785
+ discovered = discover_profile_mcp_tools()
786
+ refresh_profile_agents_mcp_tools(profile_home)
787
+ done.startup_outcome = "ok" if discovered else "noop"
575
788
  except Exception: # noqa: BLE001
789
+ done.startup_outcome = "error"
576
790
  log.warning("MCP discovery failed", exc_info=True)
577
791
  finally:
792
+ with _RELEASES_LOCK:
793
+ _RELEASES.pop(worker, None)
578
794
  done.set()
579
795
 
580
- # The copied context carries the turn's home override and secret scope into the worker.
581
- threading.Thread(target=contextvars.copy_context().run, args=(discover,), daemon=True, name=f"chat-mcp-{run.stream_id[:8]}").start()
582
- while not done.wait(0.25):
796
+ with _MCP_DISCOVERY_LOCK:
797
+ if _DRAINING.is_set():
798
+ raise RpcError("The sidecar is shutting down.", condition="sidecar_unavailable")
799
+ if home in _MCP_CLOSING_HOMES:
800
+ raise RpcError("The profile is being deleted.", condition="profile_unavailable")
801
+ done = _MCP_DISCOVERIES.get(home)
802
+ if done is None or done.is_set():
803
+ done = threading.Event()
804
+ done.startup_outcome = "unknown"
805
+ _MCP_DISCOVERIES[home] = done
806
+ # The copied context carries the turn's home override and secret scope into the worker.
807
+ worker = threading.Thread(target=contextvars.copy_context().run, args=(discover,), daemon=True, name=f"chat-mcp-{run.stream_id[:8]}")
808
+ worker.failed = False
809
+ # Existing profile/shutdown custody includes this worker after its originating turn has ended.
810
+ with _RELEASES_LOCK:
811
+ _RELEASES[worker] = profile_home
812
+ try:
813
+ worker.start()
814
+ except Exception:
815
+ with _RELEASES_LOCK:
816
+ _RELEASES.pop(worker, None)
817
+ done.set()
818
+ raise
819
+ while not done.is_set():
583
820
  if run.cancel.is_set() or run.ctx.cancelled:
821
+ run.mcp_discovery_outcome = "cancelled"
822
+ return None
823
+ remaining = None if deadline is None else deadline - time.monotonic()
824
+ if remaining is not None and remaining <= 0:
825
+ run.mcp_discovery_outcome = "timeout"
826
+ run.ctx.emit("notice", {"key": _MCP_PENDING_KEY, "level": "info", "kind": "sticky",
827
+ "text": "Some tools are still connecting. Using available tools for this turn.",
828
+ "lapses_with_agent": False})
829
+ return done
830
+ done.wait(0.25 if remaining is None else min(0.25, remaining))
831
+ run.mcp_discovery_outcome = getattr(done, "startup_outcome", "ok")
832
+ return None
833
+
834
+
835
+ # Serializes every hook change the sidecar makes, so two turns or a turn and a profile deletion never interleave a
836
+ # drop with a registration.
837
+ _HOOKS_LOCK = threading.Lock()
838
+ _HOOK_CALLBACK_PREFIXES = {"agent.shell_hooks": "shell_hook[", "agent.outbound_webhooks": "outbound_webhook["}
839
+
840
+
841
+ def _drop_home_callbacks(*modules) -> None:
842
+ """Forget this home's registrations in each Agent hook module and remove their callbacks from the home's plugin
843
+ manager; the Agent has no teardown for either registry. Runs under the call's ``scoped_home`` and ``_HOOKS_LOCK``."""
844
+ from agent import shell_hooks
845
+ from hermes_cli.plugins import get_plugin_manager
846
+
847
+ prefixes = tuple(_HOOK_CALLBACK_PREFIXES[module.__name__] for module in modules)
848
+ for module in modules:
849
+ shell_hooks._forget_home_registrations(module._registered, module._registered_lock)
850
+ manager = get_plugin_manager()
851
+ with shell_hooks._registered_lock:
852
+ for event, callbacks in list(manager._hooks.items()):
853
+ # Replaced, never edited in place: a concurrent invoke_hook keeps iterating the old list.
854
+ manager._hooks[event] = [cb for cb in callbacks if not getattr(cb, "__name__", "").startswith(prefixes)]
855
+
856
+
857
+ def _hook_state_trusted(shell_hooks, cfg: dict) -> bool:
858
+ """``sync_from_config``'s removal guard for Agents without it: an empty (failed-open) config, an unreadable
859
+ allowlist or a ``hooks:`` entry the Agent could not parse must never disarm a registered guard."""
860
+ if not cfg:
861
+ return False
862
+ try:
863
+ allowlist = json.loads(shell_hooks.allowlist_path().read_text(encoding="utf-8-sig"))
864
+ except FileNotFoundError:
865
+ allowlist = {"approvals": []}
866
+ except (OSError, ValueError):
867
+ return False
868
+ if not isinstance(allowlist, dict) or not isinstance(allowlist.get("approvals"), list):
869
+ return False
870
+ hooks = cfg.get("hooks")
871
+ if hooks is None:
872
+ return True
873
+ if not isinstance(hooks, dict):
874
+ return False
875
+ events = [entries for event, entries in hooks.items() if event not in ("output_spill", "outbound") and entries is not None]
876
+ if not all(isinstance(entries, list) for entries in events):
877
+ return False
878
+ return len(shell_hooks.iter_configured_hooks(cfg)) == sum(len(entries) for entries in events)
879
+
880
+
881
+ def _replace_shell_hooks(shell_hooks, cfg: dict) -> None:
882
+ """Agents without ``sync_from_config``: wire the home's consented hooks from ``cfg`` and drop the rest, so a hook
883
+ removed from config or revoked stops, as it does on v0.22.3. The stale callbacks go only after the current set is
884
+ wired, so a concurrent turn's tool call always meets its guard. Add-only when the hook state is not trusted."""
885
+ if not _hook_state_trusted(shell_hooks, cfg):
886
+ shell_hooks.register_from_config(cfg, accept_hooks=False)
887
+ return
888
+ from hermes_cli.plugins import get_plugin_manager
889
+
890
+ manager = get_plugin_manager()
891
+ stale = [cb for callbacks in manager._hooks.values() for cb in callbacks if getattr(cb, "__name__", "").startswith("shell_hook[")]
892
+ shell_hooks._forget_home_registrations(shell_hooks._registered, shell_hooks._registered_lock)
893
+ shell_hooks.register_from_config(cfg, accept_hooks=False)
894
+ with shell_hooks._registered_lock:
895
+ for event, callbacks in list(manager._hooks.items()):
896
+ manager._hooks[event] = [cb for cb in callbacks if not any(cb is old for old in stale)]
897
+
898
+
899
+ def _register_profile_hooks(home: Path) -> None:
900
+ """Wire the profile's consented shell hooks and current outbound webhooks before the turn's tools run, like the
901
+ gateway on every session open (TAL-1258). Hooks added or approved since the last call join this one; hooks removed
902
+ from config or revoked stop, through ``sync_from_config`` or its sidecar twin for older Agents. Outbound webhooks
903
+ are add-only on every Agent, so each call replaces the home's set with the configured one (a concurrent turn's
904
+ notification can miss that instant; webhooks only observe). Consent comes from the allowlist or an opt-in in
905
+ force: stdin is the RPC pipe, so nothing prompts. A failure is logged and never blocks the turn."""
906
+ try:
907
+ from agent import outbound_webhooks, shell_hooks
908
+ from hermes_cli.config import load_config
909
+
910
+ with _HOOKS_LOCK:
911
+ sync = getattr(shell_hooks, "sync_from_config", None)
912
+ if sync is not None:
913
+ sync(load_config)
914
+ else:
915
+ _replace_shell_hooks(shell_hooks, load_config())
916
+ _drop_home_callbacks(outbound_webhooks)
917
+ outbound_webhooks.register_from_config(load_config())
918
+ except Exception: # noqa: BLE001
919
+ log.warning("shell-hook sync failed; keeping the registered hooks", exc_info=True)
920
+
921
+
922
+ def forget_profile_hooks(home: Path) -> None:
923
+ """Profile deletion: drop the home's shell hooks and outbound webhooks. The Agent keys both registries and its
924
+ plugin manager by path, so a profile recreated at that path would otherwise fire hooks its new config and allowlist
925
+ never approved. Raises rather than let the profile go with its hooks still wired."""
926
+ try:
927
+ from agent import outbound_webhooks, shell_hooks
928
+ except ImportError: # no Agent, so no hook was ever registered
929
+ return
930
+ with _HOOKS_LOCK, scoped_home(home):
931
+ _drop_home_callbacks(shell_hooks, outbound_webhooks)
932
+
933
+
934
+ @contextlib.contextmanager
935
+ def agent_scope(home: Path):
936
+ """``scoped_home`` for a call that runs Agent work: the profile's hooks are wired before any of it (TAL-1258), so
937
+ the call's first LLM request, tool call or lifecycle event already sees them, even as a session's first work after
938
+ a sidecar restart."""
939
+ with scoped_home(home):
940
+ _register_profile_hooks(home)
941
+ yield
942
+
943
+
944
+ class _ArchiveCallback:
945
+ """Queued work shares the release threads' join/liveness contract without owning an OS thread."""
946
+ name = "session-archive"
947
+ failed = False
948
+
949
+ def __init__(self, callback: Callable[[], None]):
950
+ self.callback = callback
951
+ self.done = threading.Event()
952
+
953
+ def run(self) -> None:
954
+ try:
955
+ self.callback()
956
+ finally:
957
+ self.done.set()
958
+
959
+ def join(self, timeout=None) -> None:
960
+ self.done.wait(timeout)
961
+
962
+ def is_alive(self) -> bool:
963
+ return not self.done.is_set()
964
+
965
+
966
+ def _archive_worker(pending) -> None:
967
+ while True:
968
+ work = pending.get()
969
+ try:
970
+ if work is None:
971
+ return
972
+ try:
973
+ work.run()
974
+ except BaseException: # a plugin must not kill a shared worker
975
+ log.warning("Session archive notification worker failed")
976
+ finally:
977
+ pending.task_done()
978
+ del work
979
+
980
+
981
+ def _schedule_archive(work: _ArchiveCallback, home: Path) -> None:
982
+ # Admission shares the profile-deletion/shutdown fence; queued work uses the existing release custody roster.
983
+ with _MCP_DISCOVERY_LOCK:
984
+ if _DRAINING.is_set() or os.path.realpath(home) in _MCP_CLOSING_HOMES:
985
+ log.warning("Session archive notification unavailable during profile or sidecar shutdown")
584
986
  return
987
+ if len(_ARCHIVE_WORKERS) < _ARCHIVE_MAX_WORKERS:
988
+ thread = threading.Thread(target=_archive_worker, args=(_ARCHIVE_QUEUE,), name="session-archive-worker", daemon=True)
989
+ try:
990
+ thread.start()
991
+ except RuntimeError:
992
+ log.warning("Session archive notification worker could not start")
993
+ if not _ARCHIVE_WORKERS:
994
+ return
995
+ else:
996
+ _ARCHIVE_WORKERS.append(thread)
997
+ with _RELEASES_LOCK:
998
+ _RELEASES[work] = home
999
+ try:
1000
+ _ARCHIVE_QUEUE.put_nowait(work)
1001
+ except queue.Full:
1002
+ _RELEASES.pop(work, None)
1003
+ log.warning("Session archive notification queue is full")
585
1004
 
586
1005
 
587
- def _profile_fallback_chain() -> list[dict] | None:
1006
+ def _notify_archive(home: Path, session_id: str, lineage: list[str] | None) -> None:
1007
+ import hermes_state_common
1008
+
1009
+ emit = getattr(hermes_state_common, "emit_session_deleted", None)
1010
+ if emit is None:
1011
+ return
1012
+ # Upstream hook warnings can include arbitrary callback exceptions. Keep one payload-free diagnostic.
1013
+ thread_id, warned = threading.get_ident(), False
1014
+
1015
+ def bounded_diagnostic(record):
1016
+ nonlocal warned
1017
+ if record.thread != thread_id:
1018
+ return True
1019
+ warned = warned or record.levelno >= logging.WARNING
1020
+ return False
1021
+
1022
+ handlers = list(logging.getLogger().handlers)
1023
+ for handler in handlers:
1024
+ handler.addFilter(bounded_diagnostic)
1025
+ try:
1026
+ # The emitter only needs db_path for profile identity. Opening SessionDB would migrate an owner's store.
1027
+ emit(SimpleNamespace(db_path=home / "state.db"), [session_id, *(lineage or [])],
1028
+ reason="archive", primary=session_id, sessions_dir=home / "sessions")
1029
+ finally:
1030
+ for handler in handlers:
1031
+ handler.removeFilter(bounded_diagnostic)
1032
+ if warned:
1033
+ log.warning("Session archive notification reported a callback failure")
1034
+
1035
+
1036
+ def _profile_fallback_chain(cfg: dict | None = None) -> list[dict] | None:
588
1037
  """The profile's fallback routes (``fallback_providers`` first, then legacy ``fallback_model``, deduplicated)
589
1038
  through the Agent's own resolver, like the CLI and gateway. Runs under the call's ``scoped_home``."""
590
1039
  from hermes_cli.config import load_config
591
1040
  from hermes_cli.fallback_config import get_fallback_chain
592
1041
 
593
- return get_fallback_chain(load_config() or {}) or None
1042
+ return copy.deepcopy(get_fallback_chain((load_config() or {}) if cfg is None else cfg)) or None
1043
+
1044
+
1045
+ def _profile_prefill_messages(cfg: dict | None = None) -> list[dict] | None:
1046
+ """The profile's few-shot prefill (``HERMES_PREFILL_MESSAGES_FILE``, then ``prefill_messages_file``, then legacy
1047
+ ``agent.prefill_messages_file``; relative to the profile) through the Agent's own loader, like the CLI, gateway and
1048
+ Desktop. A missing or empty file is None. Runs under the call's ``scoped_home``."""
1049
+ from hermes_cli.cli_config_load import _load_prefill_messages, _resolve_prefill_messages_file
1050
+ from hermes_cli.config import load_config
1051
+ from hermes_constants import get_hermes_home
1052
+
1053
+ return _load_prefill_messages(_resolve_prefill_messages_file((load_config() or {}) if cfg is None else cfg), get_hermes_home()) or None
594
1054
 
595
1055
 
596
- def _main_model_request_overrides(model: str, provider) -> dict | None:
1056
+ def _main_model_request_overrides(model: str, provider, base_url: str = "", cfg: dict | None = None) -> dict | None:
597
1057
  """Predecessor ``_main_model_request_overrides``: the profile's ``model.service_tier`` and ``model.extra_body``
598
1058
  (written by ``/api/model/set``) apply only to turns on that configured main model. Runs under ``scoped_home``."""
599
1059
  from hermes_cli.config import load_config
600
1060
 
601
- model_cfg = (load_config() or {}).get("model")
1061
+ model_cfg = ((load_config() or {}) if cfg is None else cfg).get("model")
602
1062
  if not isinstance(model_cfg, dict):
603
1063
  return None
604
1064
  main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
@@ -606,17 +1066,56 @@ def _main_model_request_overrides(model: str, provider) -> dict | None:
606
1066
  if not main_model or main_model != model.strip():
607
1067
  return None
608
1068
  # An unset or ``auto`` main provider resolves at runtime, so the model id alone identifies the main model.
609
- if main_provider not in ("", "auto") and main_provider != str(provider or "").strip().lower():
1069
+ from hermes_cli.models import normalize_provider
1070
+
1071
+ if main_provider not in ("", "auto") and normalize_provider(main_provider) != normalize_provider(str(provider or "").strip().lower()):
610
1072
  return None
611
- overrides: dict = {}
612
- if str(model_cfg.get("service_tier") or "").strip().lower() == "priority":
613
- overrides["service_tier"] = "priority"
1073
+ from ..speed import fast_overrides
1074
+
1075
+ overrides = fast_overrides(model, str(provider or ""), base_url, str(model_cfg.get("service_tier") or "").strip().lower()) or {}
614
1076
  extra_body = model_cfg.get("extra_body")
615
1077
  if isinstance(extra_body, dict) and extra_body:
616
- overrides["extra_body"] = extra_body
1078
+ overrides["extra_body"] = copy.deepcopy(extra_body)
617
1079
  return overrides or None
618
1080
 
619
1081
 
1082
+ def _request_overrides(model: str, provider, runtime: dict, cfg: dict | None = None) -> dict | None:
1083
+ """The resolver's ``request_overrides`` (a custom provider entry's ``extra_body``) under the main-model ones, whose
1084
+ ``extra_body`` wins key by key, the Agent's own merge order. Runs under ``scoped_home``."""
1085
+ merged = dict(runtime.get("request_overrides") or {})
1086
+ main = _main_model_request_overrides(model, provider, str(runtime.get("base_url") or ""), cfg) or {}
1087
+ extra_body = {**(merged.get("extra_body") or {}), **(main.get("extra_body") or {})}
1088
+ merged.update(main)
1089
+ if extra_body:
1090
+ merged["extra_body"] = extra_body
1091
+ return merged or None
1092
+
1093
+
1094
+ def _provider_routing_kwargs(cfg: dict | None = None) -> dict:
1095
+ """The profile's OpenRouter ``provider_routing`` and ``openrouter.min_coding_score`` as ``AIAgent`` kwargs, with the
1096
+ Agent's own mapping (``gateway/session_worker_construct.routing_agent_kwargs``). The score is coerced like the CLI's
1097
+ (``cli_init_mixin``), so a quoted number counts and a value outside 0..1 stays unset. Runs under ``scoped_home``."""
1098
+ from hermes_cli.config import load_config
1099
+
1100
+ if cfg is None:
1101
+ cfg = load_config() or {}
1102
+ routing = cfg.get("provider_routing")
1103
+ routing = routing if isinstance(routing, dict) else {}
1104
+ openrouter = cfg.get("openrouter")
1105
+ raw_score = openrouter.get("min_coding_score") if isinstance(openrouter, dict) else None
1106
+ try:
1107
+ score = float(raw_score) if raw_score not in (None, "") else None
1108
+ except (TypeError, ValueError):
1109
+ score = None
1110
+ return copy.deepcopy({
1111
+ "providers_allowed": routing.get("only"), "providers_ignored": routing.get("ignore"),
1112
+ "providers_order": routing.get("order"), "provider_sort": routing.get("sort"),
1113
+ "provider_require_parameters": routing.get("require_parameters", False),
1114
+ "provider_data_collection": routing.get("data_collection"),
1115
+ "openrouter_min_coding_score": score if score is not None and 0.0 <= score <= 1.0 else None,
1116
+ })
1117
+
1118
+
620
1119
  def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, kwargs: dict) -> str:
621
1120
  """Cache identity of an ``AIAgent``: everything its constructor bound from the resolved runtime, so a rotated key,
622
1121
  a different API mode, ACP command, or credential pool never reuses an agent built for the old bundle. The key
@@ -628,18 +1127,95 @@ def _agent_signature(model: str, provider, runtime: dict, toolsets, home: str, k
628
1127
  bundle = {
629
1128
  "model": model, "provider": provider, "base_url": runtime.get("base_url"), "api_key": key_digest,
630
1129
  "api_mode": runtime.get("api_mode"), "acp_command": runtime.get("acp_command"), "acp_args": runtime.get("acp_args"),
631
- "credential_pool": pool_identity, "toolsets": toolsets, "home": home,
1130
+ "credential_pool": pool_identity, "toolsets": toolsets, "home": home, "cwd": kwargs.get("cwd"),
1131
+ "disabled_toolsets": kwargs.get("disabled_toolsets"),
632
1132
  "max_iterations": kwargs.get("max_iterations"), "max_tokens": kwargs.get("max_tokens"),
633
1133
  # Bound at construction too: a reasoning-effort change from the composer must build a fresh agent.
634
1134
  "reasoning_config": kwargs.get("reasoning_config"),
635
1135
  "request_overrides": kwargs.get("request_overrides"),
1136
+ "provider_routing": [kwargs.get(name) for name in (
1137
+ "providers_allowed", "providers_ignored", "providers_order", "provider_sort", "provider_require_parameters",
1138
+ "provider_data_collection", "openrouter_min_coding_score")],
636
1139
  "checkpoints": [kwargs.get(name) for name in ("checkpoints_enabled", "checkpoint_max_snapshots", "checkpoint_max_total_size_mb", "checkpoint_max_file_size_mb")],
637
1140
  # Entries may carry their own ``api_key``.
638
1141
  "fallback_model": hashlib.sha256(json.dumps(kwargs.get("fallback_model"), sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16],
1142
+ # Read each turn, so an edited prefill file builds a fresh agent.
1143
+ "prefill_messages": hashlib.sha256(json.dumps(kwargs.get("prefill_messages"), sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16],
639
1144
  }
640
1145
  return json.dumps(bundle, sort_keys=True, default=str)
641
1146
 
642
1147
 
1148
+ def _agent_kwargs(AIAgent, params: dict, session_id: str, model: str, provider, runtime: dict, toolsets, cfg: dict | None = None) -> dict:
1149
+ """The ``AIAgent`` arguments of a session's agent apart from a turn's callbacks: the resolved runtime, the session's
1150
+ toolsets and budgets, and the profile's fallback chain, request overrides, prefill and checkpoints. Everything
1151
+ ``_agent_signature`` reads is here, so an agent ``/refine`` builds (TAL-902) is the one the next turn reuses. Runs under
1152
+ the call's ``scoped_home``."""
1153
+ kwargs: dict = dict(
1154
+ model=model,
1155
+ provider=provider,
1156
+ base_url=runtime.get("base_url"),
1157
+ api_key=runtime.get("api_key"),
1158
+ platform="webui",
1159
+ quiet_mode=True,
1160
+ enabled_toolsets=toolsets,
1161
+ disabled_toolsets=disabled_toolsets(cfg),
1162
+ session_id=session_id,
1163
+ )
1164
+ for name, value in (
1165
+ ("api_mode", runtime.get("api_mode")),
1166
+ ("acp_command", runtime.get("acp_command")),
1167
+ ("acp_args", runtime.get("acp_args")),
1168
+ ("credential_pool", runtime.get("credential_pool")),
1169
+ ("gateway_session_key", session_id),
1170
+ # TAL-903: anchors subdirectory AGENTS.md discovery (and the memory provider) at the session workspace, when the
1171
+ # server says it is a host path; a remote terminal backend's target-side path must never be read on the host.
1172
+ ("cwd", params.get("workspace") if params.get("workspace_local") is True else None),
1173
+ ):
1174
+ if _supported(AIAgent, name) and value is not None:
1175
+ kwargs[name] = value
1176
+ if isinstance(params.get("max_iterations"), int) and params["max_iterations"] > 0 and _supported(AIAgent, "max_iterations"):
1177
+ kwargs["max_iterations"] = params["max_iterations"]
1178
+ if isinstance(params.get("max_tokens"), int) and params["max_tokens"] > 0 and _supported(AIAgent, "max_tokens"):
1179
+ kwargs["max_tokens"] = params["max_tokens"]
1180
+ # Predecessor: `agent.reasoning_effort` (already coerced for the model) → AIAgent ``reasoning_config``.
1181
+ reasoning_config = params.get("reasoning_config")
1182
+ if isinstance(reasoning_config, dict) and _supported(AIAgent, "reasoning_config"):
1183
+ kwargs["reasoning_config"] = reasoning_config
1184
+ fallback_chain = _profile_fallback_chain(cfg)
1185
+ if fallback_chain and _supported(AIAgent, "fallback_model"):
1186
+ kwargs["fallback_model"] = fallback_chain
1187
+ request_overrides = _request_overrides(model, provider, runtime, cfg)
1188
+ if request_overrides and _supported(AIAgent, "request_overrides"):
1189
+ kwargs["request_overrides"] = request_overrides
1190
+ prefill_messages = _profile_prefill_messages(cfg)
1191
+ if prefill_messages and _supported(AIAgent, "prefill_messages"):
1192
+ kwargs["prefill_messages"] = prefill_messages
1193
+ # TAL-976: the profile's OpenRouter provider routing, as the Agent's own CLI and gateway turns read it.
1194
+ for name, value in _provider_routing_kwargs(cfg).items():
1195
+ if _supported(AIAgent, name):
1196
+ kwargs[name] = value
1197
+ # TAL-913: the profile's ``checkpoints`` section, as the Agent's own CLI and gateway turns read it.
1198
+ for name, value in checkpoint_kwargs(cfg).items():
1199
+ if _supported(AIAgent, name):
1200
+ kwargs[name] = value
1201
+ return kwargs
1202
+
1203
+
1204
+ def _build_agent(AIAgent, kwargs: dict, home, session_id: str, state_db: bool = True):
1205
+ """A new session agent on the profile's ``state.db`` (unless ``state_db`` is false), which it releases with it."""
1206
+ session_db = _acquire_session_db(home) if state_db and _supported(AIAgent, "session_db") else None
1207
+ if session_db is not None:
1208
+ kwargs["session_db"] = session_db
1209
+ kwargs["session_id"] = _execution_session_id(session_db, session_id)
1210
+ try:
1211
+ agent = AIAgent(**kwargs)
1212
+ except BaseException:
1213
+ release_session_db(session_db)
1214
+ raise
1215
+ agent._talaria_session_db = session_db # released with the agent (``_release_agent``)
1216
+ return agent
1217
+
1218
+
643
1219
  def _evict_idle_agents_locked() -> None:
644
1220
  """Trim the agent LRU (caller holds ``_AGENT_CACHE_LOCK``), never dropping a session whose turn is still running."""
645
1221
  if len(_AGENT_CACHE) <= _AGENT_CACHE_MAX:
@@ -686,7 +1262,7 @@ def _release_agent(agent, home) -> threading.Thread:
686
1262
  if not released:
687
1263
  # The memory flush needs the profile; the LLM clients and the Codex child close regardless.
688
1264
  call("release_clients")
689
- _release_session_db(getattr(agent, "_talaria_session_db", None))
1265
+ release_session_db(getattr(agent, "_talaria_session_db", None))
690
1266
  with _RELEASES_LOCK:
691
1267
  _RELEASES.pop(thread, None)
692
1268
 
@@ -702,7 +1278,7 @@ def _release_agents_locked(entries) -> list[threading.Thread]:
702
1278
  """Release cache entries that just left the cache (caller holds ``_AGENT_CACHE_LOCK``). An agent a turn still holds
703
1279
  keeps its memory provider and clients; that turn releases it when it ends."""
704
1280
  with _RUNS_LOCK:
705
- held = [run.agent for run in _RUNS.values()] + _COMMITTING
1281
+ held = [run.agent for run in _RUNS.values()] + [agent for agent, _home in _COMMITTING]
706
1282
  return [_release_agent(agent, home) for agent, _signature, home in entries if not any(agent is other for other in held)]
707
1283
 
708
1284
 
@@ -718,6 +1294,73 @@ def _join(threads, deadline: float) -> None:
718
1294
  thread.join(max(0.0, deadline - time.monotonic()))
719
1295
 
720
1296
 
1297
+ def _cancel_background_reviews(entries, message: str) -> list[threading.Thread]:
1298
+ """TAL-897: stop the post-turn memory/skill review fork of each ``(agent, home)``, which no turn interrupt or release
1299
+ reaches, so a deleted session or a stopping sidecar no longer pays for it. Each cancel then holds the agent's review
1300
+ slot (a run token from the Agent's ``prepare_background_review_run``, never finished), so the Agent admits no later
1301
+ review: not from a turn still running (a clear does not stop it), a spawn already under way, or a review deferred to
1302
+ or requeued on the idle queue (managed local runtime). ``skip_background_review`` spares the turn finalizer queueing
1303
+ one at all. Only a turn's start waits on a held slot, and an ended agent runs no further turn.
1304
+
1305
+ A cancel lasts until its fork acknowledges, which the Agent's own wait does not bound, and runs as a release under
1306
+ ``home`` (``_RELEASES``): shutdown waits for it, and profile deletion, retries included, refuses while it runs or
1307
+ once it failed."""
1308
+ unique = {id(agent): (agent, home) for agent, home in entries if agent is not None}
1309
+ for agent, _home in unique.values():
1310
+ try:
1311
+ agent.skip_background_review = True
1312
+ except AttributeError: # an object without instance attributes forks no reviews
1313
+ pass
1314
+ try:
1315
+ from agent.background_review import cancel_background_review_for_live_turn, prepare_background_review_run
1316
+ except ImportError:
1317
+ return []
1318
+
1319
+ def cancel(agent) -> None:
1320
+ thread = threading.current_thread()
1321
+ try:
1322
+ try:
1323
+ # One cancel per agent at a time, so none mistakes another's freshly held slot for a review to cancel.
1324
+ lock = vars(agent).setdefault("_talaria_review_cancel_lock", threading.Lock())
1325
+ except TypeError:
1326
+ return # no instance attributes, so no review slot either
1327
+ with lock:
1328
+ # A spawn that slipped in between the cancel and the slot claim is cancelled on the next pass.
1329
+ for _ in range(3):
1330
+ if getattr(agent, "_talaria_review_slot", None) is not None:
1331
+ return
1332
+ cancel_background_review_for_live_turn(agent, message=message, tool_reason="session ended")
1333
+ slot = prepare_background_review_run(agent)
1334
+ if slot is not None:
1335
+ agent._talaria_review_slot = slot
1336
+ return
1337
+ current = getattr(agent, "_background_review_run", None)
1338
+ if current is None:
1339
+ return # not an Agent with a review slot
1340
+ if current.cancel_requested.is_set():
1341
+ # A cancelled fork past the Agent's wait may still write: wait for it to acknowledge, then claim
1342
+ # the slot before the Agent can requeue it.
1343
+ current.request_done.wait()
1344
+ log.warning("a background review kept restarting after its session ended")
1345
+ thread.failed = True
1346
+ except Exception: # noqa: BLE001 - teardown continues without it
1347
+ log.warning("cancelling a background review failed", exc_info=True)
1348
+ thread.failed = True
1349
+ finally:
1350
+ with _RELEASES_LOCK:
1351
+ _RELEASES.pop(thread, None)
1352
+
1353
+ threads = []
1354
+ for agent, home in unique.values():
1355
+ thread = threading.Thread(target=cancel, args=(agent,), daemon=True, name="chat-review-cancel")
1356
+ thread.failed = False # type: ignore[attr-defined] - read by profile deletion
1357
+ with _RELEASES_LOCK:
1358
+ _RELEASES[thread] = home
1359
+ thread.start()
1360
+ threads.append(thread)
1361
+ return threads
1362
+
1363
+
721
1364
  def evict_all_agents() -> int:
722
1365
  """Drop every cached agent (credentials or environment changed underneath them)."""
723
1366
  with _AGENT_CACHE_LOCK:
@@ -731,10 +1374,15 @@ def drain_agents(timeout: float = _AGENT_RELEASE_TIMEOUT) -> None:
731
1374
  """Shutdown: release every cached agent, stop running turns (like a Stop) so each releases the agent it holds, and
732
1375
  wait, bounded, for those turns and every release still running."""
733
1376
  deadline = time.monotonic() + timeout
734
- evict_all_agents()
735
- with _RUNS_LOCK:
1377
+ with _MCP_DISCOVERY_LOCK, _AGENT_CACHE_LOCK, _RUNS_LOCK:
1378
+ # Admission closes with the snapshot, so no later turn forks a review outside it.
736
1379
  _DRAINING.set()
737
1380
  runs = list(_RUNS.values())
1381
+ owners = [(entry[0], entry[2]) for entry in _AGENT_CACHE.values()] + [(run.agent, run.home) for run in runs]
1382
+ for run in runs:
1383
+ run.session_ended = True
1384
+ _cancel_background_reviews(owners, "sidecar shutting down")
1385
+ evict_all_agents()
738
1386
  for run in runs:
739
1387
  run.cancel.set()
740
1388
  while time.monotonic() < deadline:
@@ -747,22 +1395,83 @@ def drain_agents(timeout: float = _AGENT_RELEASE_TIMEOUT) -> None:
747
1395
  with _RELEASES_LOCK:
748
1396
  threads = list(_RELEASES)
749
1397
  _join(threads, deadline)
1398
+ # TAL-1191: the state.db handles state_db writes keep between turns; each final release checkpoints once.
1399
+ release_all()
1400
+
750
1401
 
1402
+ def refresh_profile_agents_mcp_tools(home) -> None:
1403
+ """MCP reload: republish the live registry's tools to the profile's cached agents, like the gateway's
1404
+ ``_mcp_reload_refresh_cached_agents``. ``content_aware`` publishes a same-name schema change on every supported
1405
+ Agent (0.22.0 implies it for an explicit reload, 0.21.x does not). Each agent keeps its build-time toolset
1406
+ selection. An agent whose refresh fails leaves the cache, so its session's next turn builds one from the live
1407
+ registry instead of keeping stale schemas; the rest still refresh."""
1408
+ from tools.mcp_tool_agent import refresh_agent_mcp_tools
751
1409
 
752
- def release_profile_agents(home) -> None:
753
- """Profile deletion: release the profile's cached agents and wait for every release running under its home. Fails
754
- rather than let the home go while a memory flush still runs under it."""
755
1410
  target = os.path.realpath(home)
756
1411
  with _AGENT_CACHE_LOCK:
757
- entries = [_AGENT_CACHE.pop(sid) for sid, entry in list(_AGENT_CACHE.items()) if os.path.realpath(entry[2]) == target]
758
- _release_agents_locked(entries)
759
- with _RELEASES_LOCK:
760
- threads = [thread for thread, owner in _RELEASES.items() if os.path.realpath(owner) == target]
761
- _join(threads, time.monotonic() + _PROFILE_RELEASE_TIMEOUT)
762
- if any(thread.is_alive() for thread in threads):
763
- raise RpcError("The profile is still saving memory from its chats. Retry in a moment.")
764
- if any(thread.failed for thread in threads):
765
- raise RpcError("Memory from the profile's chats could not be saved; see the sidecar log. Retry to delete the profile anyway.")
1412
+ entries = [(sid, entry) for sid, entry in _AGENT_CACHE.items() if os.path.realpath(entry[2]) == target]
1413
+ _COMMITTING.extend((entry[0], entry[2]) for _sid, entry in entries)
1414
+ try:
1415
+ for session_id, (agent, _signature, _home) in entries:
1416
+ try:
1417
+ refresh_agent_mcp_tools(agent, quiet_mode=True, content_aware=True)
1418
+ except Exception: # noqa: BLE001
1419
+ log.warning("Failed to refresh MCP tools for session %s", session_id, exc_info=True)
1420
+ with _AGENT_CACHE_LOCK:
1421
+ cached = _AGENT_CACHE.get(session_id)
1422
+ # A turn may have replaced or evicted this agent while its refresh ran.
1423
+ if cached is not None and cached[0] is agent:
1424
+ _AGENT_CACHE.pop(session_id)
1425
+ finally:
1426
+ with _AGENT_CACHE_LOCK:
1427
+ for _sid, (agent, _signature, owner) in entries:
1428
+ _COMMITTING.remove((agent, owner))
1429
+ _release_if_dropped_locked(agent, owner)
1430
+
1431
+
1432
+ @contextlib.contextmanager
1433
+ def release_profile_agents(home):
1434
+ """Fence discovery and drain this profile's agents and background work before the caller removes its home."""
1435
+ from .commands import forget_profile_mcp_tools
1436
+
1437
+ target = os.path.realpath(home)
1438
+ with _MCP_DISCOVERY_LOCK:
1439
+ if target in _MCP_CLOSING_HOMES:
1440
+ raise RpcError("The profile is already being deleted.", condition="profile_unavailable")
1441
+ _MCP_CLOSING_HOMES.add(target)
1442
+ try:
1443
+ with _AGENT_CACHE_LOCK:
1444
+ entries = [_AGENT_CACHE.pop(sid) for sid, entry in list(_AGENT_CACHE.items()) if os.path.realpath(entry[2]) == target]
1445
+ started = _release_agents_locked(entries)
1446
+ started += _cancel_background_reviews([(entry[0], entry[2]) for entry in entries], "profile deleted")
1447
+ deadline = time.monotonic() + _PROFILE_RELEASE_TIMEOUT
1448
+ # Non-turn holders keep their agent past the cache; its release starts when the last holder lets go.
1449
+ while (holding := _holding_home(target)) and time.monotonic() < deadline:
1450
+ time.sleep(0.05)
1451
+ # The threads started here count even once they left ``_RELEASES``: a fast failure must still block the delete.
1452
+ with _RELEASES_LOCK:
1453
+ threads = list(dict.fromkeys(started + [thread for thread, owner in _RELEASES.items() if owner is not None and os.path.realpath(owner) == target]))
1454
+ _join(threads, deadline)
1455
+ if any(thread.is_alive() and thread.name.startswith("chat-mcp-") for thread in threads):
1456
+ raise RpcError("The profile is still connecting MCP tools. Retry in a moment.")
1457
+ if holding or any(thread.is_alive() for thread in threads):
1458
+ raise RpcError("The profile is still saving memory from its chats. Retry in a moment.")
1459
+ if any(thread.failed for thread in threads):
1460
+ raise RpcError("Memory from the profile's chats could not be saved; see the sidecar log. Retry to delete the profile anyway.")
1461
+ release_home(target)
1462
+ yield
1463
+ # The old generation has no worker left that could restore its readiness after deletion.
1464
+ forget_profile_mcp_tools(target)
1465
+ with _MCP_DISCOVERY_LOCK:
1466
+ _MCP_DISCOVERIES.pop(target, None)
1467
+ finally:
1468
+ with _MCP_DISCOVERY_LOCK:
1469
+ _MCP_CLOSING_HOMES.discard(target)
1470
+
1471
+
1472
+ def _holding_home(target: str) -> bool:
1473
+ with _AGENT_CACHE_LOCK:
1474
+ return any(os.path.realpath(home) == target for _agent, home in _COMMITTING)
766
1475
 
767
1476
 
768
1477
  def _acquire_session_db(home):
@@ -778,17 +1487,6 @@ def _acquire_session_db(home):
778
1487
  return None
779
1488
 
780
1489
 
781
- def _release_session_db(db) -> None:
782
- if db is None:
783
- return
784
- try:
785
- from hermes_state_registry import release_or_close
786
-
787
- release_or_close(db)
788
- except Exception: # noqa: BLE001
789
- log.warning("releasing an agent's state.db handle failed", exc_info=True)
790
-
791
-
792
1490
  def _execution_session_id(db, session_id: str) -> str:
793
1491
  """Predecessor ``_resolve_agent_execution_session_id``: a new agent for a session the Agent rotated by compression
794
1492
  (the old agent went with a sidecar restart or a runtime change) runs on the live tip, with the server's history,
@@ -814,6 +1512,20 @@ def _agent_class():
814
1512
  return AIAgent
815
1513
 
816
1514
 
1515
+ def _require_data_use_support(history: list) -> frozenset[str]:
1516
+ """A downgraded Agent must never send history whose connector privacy requirements it cannot enforce."""
1517
+ if not any(isinstance(row, dict) and row.get("data_use") for row in history):
1518
+ return frozenset()
1519
+ try:
1520
+ from agent.message_metadata import DATA_USE, data_use_requirement
1521
+ except ImportError:
1522
+ pass
1523
+ else:
1524
+ if DATA_USE == "data_use" and callable(data_use_requirement):
1525
+ return data_use_requirement(history)
1526
+ raise RpcError("This Agent release cannot enforce connector privacy requirements. Update Hermes Agent before continuing, compressing or refining this chat.", condition="agent_incompatible")
1527
+
1528
+
817
1529
  def _max_iterations_summary_request() -> str:
818
1530
  """The user row the Agent appends to ask for a summary once its tool budget runs out; the server drops it."""
819
1531
  try:
@@ -823,6 +1535,14 @@ def _max_iterations_summary_request() -> str:
823
1535
  return str(MAX_ITERATIONS_SUMMARY_REQUEST)
824
1536
 
825
1537
 
1538
+ def _has_voice_turn_route() -> bool:
1539
+ """Whether the Agent routes a marked turn to ``auxiliary.voice_chat`` (``agent.voice_turn_route``, v0.21.6)."""
1540
+ try:
1541
+ return importlib.util.find_spec("agent.voice_turn_route") is not None
1542
+ except (ImportError, ValueError):
1543
+ return False
1544
+
1545
+
826
1546
  def _supported(cls, name: str) -> bool:
827
1547
  """Whether ``cls.__init__`` accepts ``name`` (named, or through ``**kwargs``)."""
828
1548
  try:
@@ -832,6 +1552,15 @@ def _supported(cls, name: str) -> bool:
832
1552
  return name in parameters or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values())
833
1553
 
834
1554
 
1555
+ @lru_cache(maxsize=32)
1556
+ def _run_conversation_parameters(cls) -> frozenset[str]:
1557
+ """Probe explicit turn capabilities once per Agent class; unknown signatures keep legacy arguments."""
1558
+ try:
1559
+ return frozenset(inspect.signature(cls.run_conversation).parameters)
1560
+ except (TypeError, ValueError):
1561
+ return frozenset()
1562
+
1563
+
835
1564
  def _approval_notifier(session_id: str, emit):
836
1565
  """The Agent's gateway notify callback for one turn: emits each parked approval, then ``approval_resolved``
837
1566
  (id plus the Agent's reason) once its wait ends by any path, an ``approvals.timeout`` expiry included (TAL-514)."""
@@ -876,20 +1605,51 @@ def _is_compression_start(lower: str) -> bool:
876
1605
 
877
1606
 
878
1607
  def _status_events(kind, text: str) -> list[str]:
879
- """Events for one Agent status line: the compressing card, a fallback warning, and the turn's terminal error."""
1608
+ """Events for one Agent status line: compression, provider diagnostics, and the turn's terminal error."""
880
1609
  lower = text.lower()
881
1610
  events = ["terminal_error"] if "non-retryable error" in lower else []
882
- if str(kind or "").strip().lower() == "lifecycle":
1611
+ kind = str(kind or "").strip().lower()
1612
+ # TAL-1006: the Agent's one terminal edge of a started compaction (``_emit_compaction_done``); an aborted attempt
1613
+ # sends none and ends with the turn.
1614
+ if kind == "compacted":
1615
+ return ["compression_finished"]
1616
+ if kind == "lifecycle":
883
1617
  if _is_compression_start(lower):
884
1618
  return events + ["compressing"]
885
1619
  # The Agent's own compression retries ("Compressed N → M messages, retrying...") are not provider fallbacks.
886
1620
  if "compress" in lower or "context reduced to" in lower:
887
1621
  return events
1622
+ # Buffered diagnostics change wording across Agent releases; their severity glyph stays stable.
1623
+ if not events and text.startswith(("⚠", "❌")):
1624
+ return ["warning"]
888
1625
  if "fallback" in lower or "rate limit" in lower or ("retry" in lower and "non-retryable" not in lower):
889
1626
  events.append("warning")
890
1627
  return events
891
1628
 
892
1629
 
1630
+ #: TAL-821: the Agent's live status line during a rate-limit backoff (``turn_recovery.compute_error_backoff``), e.g.
1631
+ #: "⏳ the free model is busy — resets in ~17m, retrying in 600s (attempt 1/3)". The Agent's other waits are not known
1632
+ #: rate limits: "waiting on provider" covers a 5xx retry as well as a 429 without a reset, and "provider overloaded" is a
1633
+ #: Z.AI overload. ``webui`` is not an attended source, so the Agent sits out a free-tier cooldown instead of ending.
1634
+ _RATE_LIMIT_WAIT_RE = re.compile(r"^⏳ (?:rate limited|the free model is busy) —.* retrying in (\d+)s\b", re.IGNORECASE)
1635
+ #: The line's reset window, in the Agent's ``format_duration_compact`` units ("45s", "17m", "1h 5m", "1.5d").
1636
+ _RESET_IN_RE = re.compile(r"resets in ~((?:\d+(?:\.\d+)?[dhms] ?)+)")
1637
+ _DURATION_UNITS = {"d": 86400, "h": 3600, "m": 60, "s": 1}
1638
+
1639
+
1640
+ def _rate_limit_wait(text: str) -> tuple[float, str] | None:
1641
+ """``(seconds, "reset")`` until the provider's limit resets, else ``(seconds, "retry")`` until the Agent retries, for a
1642
+ rate-limit wait line; None for any other line. The reset is the provider's own ``reset_at`` as the Agent read it from
1643
+ the error; the credential pool's exhausted mark is no better, since without that field it is a fixed one-hour guess."""
1644
+ wait = _RATE_LIMIT_WAIT_RE.match(text)
1645
+ if not wait:
1646
+ return None
1647
+ reset = _RESET_IN_RE.search(text)
1648
+ if not reset:
1649
+ return float(wait.group(1)), "retry"
1650
+ return sum(float(amount) * _DURATION_UNITS[unit] for amount, unit in re.findall(r"(\d+(?:\.\d+)?)([dhms])", reset.group(1))), "reset"
1651
+
1652
+
893
1653
  def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn, one function
894
1654
  session_id = str(params.get("session_id") or "").strip()
895
1655
  stream_id = str(params.get("stream_id") or "").strip()
@@ -905,10 +1665,16 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
905
1665
  history = []
906
1666
  if not isinstance(history, list):
907
1667
  raise InvalidParams("conversation_history must be a list")
908
- toolsets = _turn_toolsets(params.get("enabled_toolsets"))
1668
+ _require_data_use_support(history)
1669
+ from hermes_cli.config import load_config_readonly
1670
+
1671
+ # Borrow one snapshot per turn; helpers copy values retained by the Agent.
1672
+ cfg = load_config_readonly() or {}
1673
+ toolsets = _turn_toolsets(params.get("enabled_toolsets"), cfg)
909
1674
  system_message = params.get("system_message")
910
1675
  home = profile_home_param(params)
911
1676
  run = _Run(stream_id, session_id, ctx)
1677
+ run.home = home
912
1678
  with _RUNS_LOCK:
913
1679
  if _DRAINING.is_set():
914
1680
  raise RpcError("The sidecar is shutting down.", condition="sidecar_unavailable")
@@ -916,13 +1682,22 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
916
1682
  # A previous turn for this session that never returned (a tool ignoring its interrupt) still owns the cached
917
1683
  # agent; this turn must not share, rebind, or un-interrupt that live instance.
918
1684
  session_busy = prior is not None and not prior.finished.is_set()
1685
+ # TAL-829: a second agent beside a turn nobody stopped would wait on that turn's durable turn lease (up to 30
1686
+ # minutes) and then write the same session. A stopped turn only lingers until the Agent abandons the wedged
1687
+ # tool, which lets its lease go within seconds.
1688
+ if session_busy and not (prior.cancel.is_set() or prior.ctx.cancelled):
1689
+ raise RpcError("This chat is still answering the previous message. Stop it or wait for it to finish.", condition="session_busy")
919
1690
  _RUNS[stream_id] = run
920
1691
  _RUNS_BY_SESSION[session_id] = stream_id
921
1692
  raw_emit = ctx.emit
922
1693
  steer_state = {"last": None}
923
1694
  usage_state = {"last": None}
1695
+ pending_mcp = False
1696
+ token_parts: list[str] = []
1697
+ last_token_flush: float | None = None
1698
+ emit_lock = threading.Lock()
924
1699
 
925
- def emit(event, data=None):
1700
+ def emit_frame(event, data=None):
926
1701
  """Predecessor ``_webui_steer_events_before``: before content frames, report the Agent's pending steer text
927
1702
  whenever it changed so the server can mark consumed steers live rather than only after ``done``. TAL-397: the
928
1703
  Agent's session counters ride the same boundary, so the server meters cost and tokens while the turn runs."""
@@ -936,15 +1711,46 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
936
1711
  usage_state["last"] = usage
937
1712
  raw_emit("usage", usage)
938
1713
  raw_emit(event, data)
1714
+
1715
+ def flush_tokens():
1716
+ nonlocal last_token_flush
1717
+ if token_parts:
1718
+ text = "".join(token_parts)
1719
+ token_parts.clear()
1720
+ last_token_flush = time.monotonic()
1721
+ emit_frame("token", {"text": text})
1722
+
1723
+ def emit(event, data=None):
1724
+ # Batch before snapshots/serialization; the lock keeps tool-worker and watcher frames in order.
1725
+ with emit_lock:
1726
+ if event == "token":
1727
+ token_parts.append(data["text"])
1728
+ if last_token_flush is None or time.monotonic() - last_token_flush >= 0.016:
1729
+ flush_tokens()
1730
+ else:
1731
+ flush_tokens()
1732
+ emit_frame(event, data)
1733
+
1734
+ timing = getattr(ctx, "timing", None)
939
1735
  try:
940
- _discover_mcp_tools(run)
941
- runtime = _resolve_runtime(provider, model)
1736
+ if timing:
1737
+ timing.begin("mcp_discovery")
1738
+ mcp_discovery = _discover_mcp_tools(run)
1739
+ pending_mcp = mcp_discovery is not None
1740
+ if timing:
1741
+ timing.end("mcp_discovery", "cancelled" if ctx.cancelled or run.cancel.is_set() else "timeout" if pending_mcp else run.mcp_discovery_outcome)
1742
+ with timing.span("runtime_resolution") if timing else contextlib.nullcontext():
1743
+ runtime = _resolve_runtime(provider, model)
1744
+ if timing:
1745
+ timing.begin("agent_binding")
942
1746
  resolved_model = model or str(runtime.get("model") or "")
943
1747
  resolved_provider = provider or runtime.get("provider")
944
1748
  AIAgent = _agent_class()
945
1749
  live_tool_calls: list[dict] = []
946
1750
  started_ids: set[str] = set()
947
1751
  completed_ids: set[str] = set()
1752
+ edit_snapshots: dict[str, Any] = {}
1753
+ prepared_metadata: dict[str, dict] = {}
948
1754
  token_sent = [False]
949
1755
 
950
1756
  def on_token(text):
@@ -970,6 +1776,9 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
970
1776
  return
971
1777
  if tid:
972
1778
  started_ids.add(tid)
1779
+ snapshot = _edit_snapshot(name, args, session_id)
1780
+ if snapshot is not None:
1781
+ edit_snapshots[tid] = snapshot
973
1782
  live_tool_calls.append({"name": name, "args": args if isinstance(args, dict) else {}, "tid": tid, "done": False})
974
1783
  emit("tool", {"event_type": "tool.started", "name": name, "preview": None, "args": _args_snapshot(args), "tid": tid})
975
1784
 
@@ -988,8 +1797,12 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
988
1797
  call["snippet"] = snippet
989
1798
  break
990
1799
  payload = {"event_type": "tool.completed", "name": name, "preview": snippet, "args": _args_snapshot(args), "tid": tid, "raw_result": _raw_result(function_result)}
1800
+ # TAL-909: the metadata the Agent persisted on the result row, or, from an Agent without the callback, live only.
1801
+ metadata = prepared_metadata.pop(tid) if tid in prepared_metadata else _edit_metadata(name, args, function_result, edit_snapshots.pop(tid, None))
1802
+ if metadata:
1803
+ payload["display_metadata"] = metadata
991
1804
  # TAL-397: ``raw_result`` caps nested values, so the todo tool's list travels whole for the server's ``todo_state``.
992
- if name == "todo" and function_result is not None:
1805
+ if function_result is not None and _is_todo_tool(name):
993
1806
  payload["todo_result"] = function_result if isinstance(function_result, str) else json.dumps(function_result, default=str)
994
1807
  # TAL-448: likewise a result's unified ``diff``, so the server's ``edit_diff`` counts cover the whole change.
995
1808
  diff = _result_diff(function_result)
@@ -1004,8 +1817,21 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1004
1817
  break
1005
1818
  emit("tool_complete", payload)
1006
1819
 
1820
+ def on_tool_result_metadata(tool_call_id, name, args, function_result):
1821
+ """TAL-909: called before the Agent appends and flushes the tool-result row, which keeps the returned dict as
1822
+ its ``display_metadata``; the completion frame that follows carries the same dict."""
1823
+ tid = str(tool_call_id or "")
1824
+ metadata = _edit_metadata(name, args, function_result, edit_snapshots.pop(tid, None))
1825
+ prepared_metadata[tid] = metadata
1826
+ return metadata
1827
+
1007
1828
  def on_tool(*cb_args, **cb_kwargs):
1008
- # Structured callbacks carry tool cards; the progress callback only feeds reasoning text on older builds.
1829
+ # Structured callbacks carry tool cards; the Agent already throttles MCP progress.
1830
+ if len(cb_args) >= 3 and cb_args[0] == "tool.progress":
1831
+ emit("tool_progress", {"tool_call_id": str(cb_kwargs.get("tool_call_id") or ""), "message": cb_args[2],
1832
+ "progress": cb_kwargs.get("progress"), "total": cb_kwargs.get("total"),
1833
+ "percent": cb_kwargs.get("percent")})
1834
+ return
1009
1835
  if len(cb_args) >= 3 and cb_args[0] in ("reasoning.available", "_thinking"):
1010
1836
  text = cb_args[2] if cb_args[0] == "reasoning.available" else cb_args[1]
1011
1837
  if text:
@@ -1016,43 +1842,172 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1016
1842
  for event in _status_events(kind, text):
1017
1843
  if event == "compressing":
1018
1844
  emit("compressing", {"session_id": session_id, "message": "Compressing context"})
1845
+ elif event == "compression_finished":
1846
+ emit("compression_finished", {"session_id": session_id})
1019
1847
  elif event == "warning":
1020
1848
  emit("warning", {"type": "fallback", "message": text})
1021
1849
  else:
1022
1850
  emit("status", {"kind": "terminal_error", "message": text})
1023
1851
 
1024
- def clarify_callback(question, choices, multi_select=False, questions=None):
1025
- choices_list = [str(c) for c in (choices or [])]
1026
- # Predecessor `_clarify_timeout_seconds`: the Agent's own resolver over this profile's config
1027
- # (``clarify.timeout`` else ``agent.clarify_timeout`` else 3600); ``<= 0`` waits until answered or cancelled.
1028
- # The advertised ``timeout_seconds`` is what the clients count down, so it is the same number.
1029
- timeout = _clarify_timeout(params)
1030
- data = {"question": str(question or ""), "choices_offered": choices_list, "session_id": session_id, "kind": "clarify", "requested_at": time.time(), "timeout_seconds": timeout}
1031
- if multi_select:
1032
- data["multi_select"] = True
1033
- # Batch prompts (the Agent's ``_run_batch``) carry no top-level question and expect the ``{"answers": {...}}`` reply.
1034
- if isinstance(questions, list) and questions:
1035
- data["questions"] = questions
1852
+ wait_state = {"active": False, "prompt": False}
1853
+
1854
+ def on_thinking(text):
1855
+ """Normalize provider waits and prompt-reading progress from the Agent's live status callback."""
1856
+ line = str(text or "").strip()
1857
+ progress = re.fullmatch(r"⚙ processing prompt(?: — (\d+(?:\.\d+)?)%)?", line)
1858
+ if progress:
1859
+ wait_state["prompt"] = True
1860
+ emit("provider_phase", {"phase": {"phase": "prompt_reading", "progress_percent": min(100, float(progress[1])) if progress[1] else None}})
1861
+ elif wait_state["prompt"]:
1862
+ wait_state["prompt"] = False
1863
+ emit("provider_phase", {"phase": None})
1864
+ wait = _rate_limit_wait(line)
1865
+ if wait is not None:
1866
+ wait_state["active"] = True
1867
+ emit("rate_limit_wait", {"provider": str(getattr(run.agent, "provider", None) or resolved_provider or "") or None,
1868
+ "until": time.time() + wait[0], "until_kind": wait[1]})
1869
+ elif wait_state["active"]:
1870
+ wait_state["active"] = False
1871
+ emit("rate_limit_wait", {"provider": None, "until": None})
1872
+
1873
+ # TAL-803: the Agent's sticky ``AgentNotice``s (a startup warning such as a memory provider that failed to load,
1874
+ # credit and usage bands) and their clears; the server keeps the session's active set.
1875
+ def on_notice(notice):
1876
+ text = str(getattr(notice, "text", "") or "").strip()
1877
+ if not text:
1878
+ return
1879
+ ttl_ms = getattr(notice, "ttl_ms", None)
1880
+ key = getattr(notice, "key", None)
1881
+ # The Agent raises a startup warning (memory provider unavailable) once per process, so a rebuilt agent never
1882
+ # repeats it: it outlives the agent that raised it. Credit notices are per agent and re-raised by the next one.
1883
+ emit("notice", {"id": getattr(notice, "id", None), "key": key, "level": str(getattr(notice, "level", "") or "info"),
1884
+ "kind": str(getattr(notice, "kind", "") or "sticky"), "ttl_ms": ttl_ms if isinstance(ttl_ms, int) else None, "text": text,
1885
+ "lapses_with_agent": not str(key or "").startswith("startup-warning.")})
1886
+
1887
+ def on_notice_clear(key):
1888
+ if key:
1889
+ emit("notice_clear", {"key": str(key)})
1890
+
1891
+ def ask(data: dict, timeout: int) -> str | None:
1892
+ """Park on a prompt the server relays until it is answered, the turn is cancelled or ends, or ``timeout``
1893
+ passes (``<= 0``: no limit). The answer, or None, goes back to the Agent and is never emitted."""
1036
1894
  entry = _ClarifyEntry(data)
1037
1895
  entry.data["clarify_id"] = entry.clarify_id
1896
+ entry.deadline = None if timeout <= 0 else time.monotonic() + float(timeout)
1038
1897
  with run.lock:
1039
1898
  run.clarify_entries[entry.clarify_id] = entry
1040
1899
  emit("clarify", dict(entry.data))
1041
- deadline = None if timeout <= 0 else time.monotonic() + float(timeout)
1042
1900
  while not entry.event.is_set():
1043
- if run.cancel.is_set() or run.finished.is_set():
1901
+ if run.cancel.is_set() or run.finished.is_set() or (data.get("kind") == "skill_secret" and (ctx.cancelled or run.session_ended)):
1044
1902
  break
1045
- if deadline is None:
1903
+ if entry.deadline is None:
1046
1904
  entry.event.wait(1.0)
1047
1905
  continue
1048
- remaining = deadline - time.monotonic()
1906
+ remaining = entry.deadline - time.monotonic()
1049
1907
  if remaining <= 0:
1050
1908
  break
1051
1909
  entry.event.wait(min(1.0, remaining))
1052
1910
  with run.lock:
1053
1911
  run.clarify_entries.pop(entry.clarify_id, None)
1054
1912
  emit("clarify_resolved", {"clarify_id": entry.clarify_id, "session_id": session_id})
1055
- return entry.result or _CLARIFY_FALLBACK
1913
+ return entry.result
1914
+
1915
+ def ask_vault(kind: str, fields: dict) -> str:
1916
+ """TAL-887: a password-manager prompt; an empty answer is the Agent's decline, as are expiry and cancel."""
1917
+ timeout = _VAULT_PROMPT_TIMEOUTS[kind]
1918
+ data = {"kind": kind, "session_id": session_id, "requested_at": time.time(), "timeout_seconds": timeout, **fields}
1919
+ return ask(data, timeout) or ""
1920
+
1921
+ def vault_unlock_prompt(backend, display_name):
1922
+ # The Agent stores the unlock right after this returns, on the thread that called it: a tool worker
1923
+ # inherits this callback but not the turn's session, so bind it here too, or closing the session could
1924
+ # not lock the manager again.
1925
+ _set_vault_session(session_id)
1926
+ return ask_vault("vault_unlock", {"backend": str(backend), "display_name": str(display_name)})
1927
+
1928
+ def vault_save_login_prompt(origin, site):
1929
+ # The server sends ``{"identifier", "password"}``; the Agent stores it in its vault and fills the page.
1930
+ raw = ask_vault("vault_save_login", {"origin": str(origin), "site": str(site)})
1931
+ try:
1932
+ answer = json.loads(raw) if raw else None
1933
+ except ValueError:
1934
+ return None
1935
+ return answer if isinstance(answer, dict) and answer.get("password") else None
1936
+
1937
+ def vault_code_prompt(site, hint):
1938
+ return ask_vault("vault_code", {"site": str(site), "hint": str(hint or "")})
1939
+
1940
+ def skill_secret_prompt(env_var, prompt, metadata=None):
1941
+ skipped = {"success": True, "stored_as": env_var, "validated": False, "skipped": True}
1942
+ if not isinstance(env_var, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_var):
1943
+ return skipped
1944
+ metadata = metadata if isinstance(metadata, dict) else {}
1945
+ fields = {"env_var": env_var, "prompt": str(prompt), **{key: str(metadata.get(key) or "") for key in ("skill_name", "help", "required_for")}}
1946
+ value = ask_vault("skill_secret", fields)
1947
+ if not value.strip():
1948
+ return skipped
1949
+ # The native writer strips these bytes, and reports non-ASCII characters to stderr. Refuse rather
1950
+ # than change the credential or expose any of its characters in logs.
1951
+ if not value.isascii() or any(char in value for char in ("\r", "\n", "\0")):
1952
+ return {**skipped, "success": False}
1953
+ try:
1954
+ from hermes_cli.config import save_env_value_secure
1955
+ from hermes_constants import get_hermes_home
1956
+
1957
+ # Keep the inherited secret scope so setup can use the value immediately. Fence the write against
1958
+ # eviction/replacement under the same lock that changes the run's identity and session lifetime.
1959
+ with _RUNS_LOCK:
1960
+ if (ctx.cancelled or run.cancel.is_set() or run.finished.is_set() or run.session_ended
1961
+ or _RUNS_BY_SESSION.get(session_id) != stream_id or _RUNS.get(stream_id) is not run
1962
+ or get_hermes_home() != home):
1963
+ return skipped
1964
+ saved = save_env_value_secure(env_var, value)
1965
+ return {"success": saved.get("success") is True, "stored_as": env_var, "validated": saved.get("validated") is True, "skipped": False}
1966
+ except Exception: # noqa: BLE001 - never expose an exception that could include the submitted value
1967
+ return {**skipped, "success": False}
1968
+
1969
+ def clarify_callback(question, choices=None, multi_select=False, questions=None):
1970
+ # TAL-351: remove the legacy signature path when v0.21.6 is the oldest supported Agent.
1971
+ structured = isinstance(question, list)
1972
+ if structured:
1973
+ questions, question = question, ""
1974
+ choices_list = [str(c) for c in (choices or [])]
1975
+ # Predecessor `_clarify_timeout_seconds`: the Agent's own resolver over this profile's config
1976
+ # (``clarify.timeout`` else ``agent.clarify_timeout`` else 3600); ``<= 0`` waits until answered or cancelled.
1977
+ # The advertised ``timeout_seconds`` is what the clients count down, so it is the same number.
1978
+ timeout = _clarify_timeout(params)
1979
+ data = {"question": str(question or ""), "choices_offered": choices_list, "session_id": session_id, "kind": "clarify", "requested_at": time.time(), "timeout_seconds": timeout}
1980
+ if multi_select:
1981
+ data["multi_select"] = True
1982
+ # Batch prompts (the Agent's ``_run_batch``) carry no top-level question and expect the ``{"answers": {...}}`` reply.
1983
+ if isinstance(questions, list) and questions:
1984
+ data["questions"] = questions
1985
+ answer = ask(data, timeout)
1986
+ if answer is not None:
1987
+ if structured:
1988
+ return {"answers": json.loads(answer)["answers"], "outcome": "submitted"}
1989
+ return answer
1990
+ cancelled = run.cancel.is_set() or run.finished.is_set()
1991
+ if structured:
1992
+ return {"answers": {}, "outcome": "cancelled" if cancelled else "timed_out",
1993
+ "notice": "The user cancelled the clarification." if cancelled else "The clarification timed out."}
1994
+ # Older tools have no outcome field. A batch understands timed_out; a single needs an empty answer.
1995
+ if questions:
1996
+ return json.dumps({"answers": {}, "timed_out": not cancelled})
1997
+ return ""
1998
+
1999
+ # TAL-818: a ``manage_connections`` card (a hosted connect, an MCP install) opened; the server turns the frame into
2000
+ # the chat's connect card, which reads and answers the operation through ``connectors.operation`` / ``respond``.
2001
+ # Only a client that draws the card asks for it: without the callback the Agent answers with plain links at once.
2002
+ connect_card = params.get("connect_card") is True
2003
+
2004
+ def on_connection(payload):
2005
+ from .connectors import remember_chat_operation
2006
+
2007
+ view = remember_chat_operation(home, payload)
2008
+ if view is not None:
2009
+ emit("connection", view)
2010
+ return None
1056
2011
 
1057
2012
  # Approval bridge: the Agent parks dangerous commands in its gateway queue and calls back here.
1058
2013
  try:
@@ -1061,51 +2016,21 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1061
2016
  register_gateway_notify = unregister_gateway_notify = None
1062
2017
  approval_cb = _approval_notifier(session_id, emit) if register_gateway_notify is not None else None
1063
2018
 
1064
- kwargs: dict = dict(
1065
- model=resolved_model,
1066
- provider=resolved_provider,
1067
- base_url=runtime.get("base_url"),
1068
- api_key=runtime.get("api_key"),
1069
- platform="webui",
1070
- quiet_mode=True,
1071
- enabled_toolsets=toolsets,
1072
- session_id=session_id,
1073
- stream_delta_callback=on_token,
1074
- reasoning_callback=on_reasoning,
1075
- tool_progress_callback=on_tool,
1076
- clarify_callback=clarify_callback,
1077
- )
2019
+ kwargs = _agent_kwargs(AIAgent, params, session_id, resolved_model, resolved_provider, runtime, toolsets, cfg)
2020
+ kwargs.update(stream_delta_callback=on_token, reasoning_callback=on_reasoning, tool_progress_callback=on_tool, clarify_callback=clarify_callback)
1078
2021
  for name, value in (
1079
2022
  ("interim_assistant_callback", on_interim),
1080
2023
  ("tool_start_callback", on_tool_start),
1081
2024
  ("tool_complete_callback", on_tool_complete),
2025
+ ("tool_result_metadata_callback", on_tool_result_metadata),
1082
2026
  ("status_callback", on_status),
1083
- ("api_mode", runtime.get("api_mode")),
1084
- ("acp_command", runtime.get("acp_command")),
1085
- ("acp_args", runtime.get("acp_args")),
1086
- ("credential_pool", runtime.get("credential_pool")),
1087
- ("gateway_session_key", session_id),
2027
+ ("thinking_callback", on_thinking),
2028
+ ("notice_callback", on_notice),
2029
+ ("notice_clear_callback", on_notice_clear),
2030
+ ("connection_callback", on_connection if connect_card else None),
1088
2031
  ):
1089
2032
  if _supported(AIAgent, name) and value is not None:
1090
2033
  kwargs[name] = value
1091
- if isinstance(params.get("max_iterations"), int) and params["max_iterations"] > 0 and _supported(AIAgent, "max_iterations"):
1092
- kwargs["max_iterations"] = params["max_iterations"]
1093
- if isinstance(params.get("max_tokens"), int) and params["max_tokens"] > 0 and _supported(AIAgent, "max_tokens"):
1094
- kwargs["max_tokens"] = params["max_tokens"]
1095
- # Predecessor: `agent.reasoning_effort` (already coerced for the model) → AIAgent ``reasoning_config``.
1096
- reasoning_config = params.get("reasoning_config")
1097
- if isinstance(reasoning_config, dict) and _supported(AIAgent, "reasoning_config"):
1098
- kwargs["reasoning_config"] = reasoning_config
1099
- fallback_chain = _profile_fallback_chain()
1100
- if fallback_chain and _supported(AIAgent, "fallback_model"):
1101
- kwargs["fallback_model"] = fallback_chain
1102
- request_overrides = _main_model_request_overrides(resolved_model, resolved_provider)
1103
- if request_overrides and _supported(AIAgent, "request_overrides"):
1104
- kwargs["request_overrides"] = request_overrides
1105
- # TAL-913: the profile's ``checkpoints`` section, as the Agent's own CLI and gateway turns read it.
1106
- for name, value in checkpoint_kwargs().items():
1107
- if _supported(AIAgent, name):
1108
- kwargs[name] = value
1109
2034
  signature = _agent_signature(resolved_model, resolved_provider, runtime, toolsets, str(params.get("profile_home")), kwargs)
1110
2035
  agent = None
1111
2036
 
@@ -1113,6 +2038,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1113
2038
  """Claim the agent for this run under ``_AGENT_CACHE_LOCK``, so an eviction never releases it mid-turn."""
1114
2039
  run.prior_messages = getattr(bound, "_session_messages", None)
1115
2040
  run.agent = bound
2041
+ if run.session_ended:
2042
+ bound.skip_background_review = True
1116
2043
 
1117
2044
  if not session_busy:
1118
2045
  with _AGENT_CACHE_LOCK:
@@ -1121,28 +2048,36 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1121
2048
  agent = cached[0]
1122
2049
  _AGENT_CACHE.move_to_end(session_id)
1123
2050
  bind(agent)
2051
+ cached_agent = agent is not None
1124
2052
  if agent is not None:
1125
- for name in ("stream_delta_callback", "reasoning_callback", "tool_progress_callback", "clarify_callback", "interim_assistant_callback", "tool_start_callback", "tool_complete_callback", "status_callback"):
2053
+ for name in ("stream_delta_callback", "reasoning_callback", "tool_progress_callback", "clarify_callback", "interim_assistant_callback", "tool_start_callback", "tool_complete_callback", "tool_result_metadata_callback", "status_callback", "thinking_callback", "notice_callback", "notice_clear_callback"):
1126
2054
  if name in kwargs and hasattr(agent, name):
1127
2055
  setattr(agent, name, kwargs[name])
2056
+ # A cached agent keeps the last turn's card callback; this turn's client decides afresh (TAL-818).
2057
+ if hasattr(agent, "connection_callback"):
2058
+ agent.connection_callback = on_connection if connect_card else None
2059
+ # TAL-902: an agent ``/refine`` built runs its first turn here. Like a new one, it lapses the notices of the
2060
+ # agent it replaced and raises the startup notices it parked while it had no callback.
2061
+ if vars(agent).pop("_talaria_unannounced", False):
2062
+ emit("notices_reset", {})
2063
+ replay = getattr(agent, "_replay_startup_warnings", None)
2064
+ if callable(replay):
2065
+ replay()
1128
2066
  else:
2067
+ # TAL-803: the notices a replaced agent raised lapse with it (``lapses_with_agent``); this one raises its own.
2068
+ emit("notices_reset", {})
1129
2069
  # A ``/btw`` side question's throwaway session stays out of state.db.
1130
- session_db = _acquire_session_db(home) if not params.get("ephemeral") and _supported(AIAgent, "session_db") else None
1131
- if session_db is not None:
1132
- kwargs["session_db"] = session_db
1133
- kwargs["session_id"] = _execution_session_id(session_db, session_id)
1134
- try:
1135
- agent = AIAgent(**kwargs)
1136
- except BaseException:
1137
- _release_session_db(session_db)
1138
- raise
1139
- agent._talaria_session_db = session_db # released with the agent (``_release_agent``)
2070
+ agent = _build_agent(AIAgent, kwargs, home, session_id, state_db=not params.get("ephemeral"))
1140
2071
  with _AGENT_CACHE_LOCK:
1141
2072
  bind(agent)
1142
- replaced = _AGENT_CACHE.pop(session_id, None)
1143
- _AGENT_CACHE[session_id] = (agent, signature, home)
1144
- _release_agents_locked([replaced] if replaced else [])
1145
- _evict_idle_agents_locked()
2073
+ if not run.session_ended:
2074
+ replaced = _AGENT_CACHE.pop(session_id, None)
2075
+ _AGENT_CACHE[session_id] = (agent, signature, home)
2076
+ _release_agents_locked([replaced] if replaced else [])
2077
+ _evict_idle_agents_locked()
2078
+ if timing:
2079
+ timing.end("agent_binding", "cache_hit" if cached_agent else "ok")
2080
+ timing.begin("conversation_setup")
1146
2081
  # Predecessor ``agent.ephemeral_system_prompt``: personality, surface context, progress guidance, and delivery
1147
2082
  # hints travel as runtime instructions that are never persisted to history.
1148
2083
  ephemeral = params.get("ephemeral_system_prompt")
@@ -1168,7 +2103,19 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1168
2103
  log.debug("agent.clear_interrupt failed", exc_info=True)
1169
2104
 
1170
2105
  def _watch_cancel():
2106
+ nonlocal pending_mcp
1171
2107
  while not run.cancel.wait(0.25):
2108
+ if pending_mcp and mcp_discovery.is_set():
2109
+ # Discovery can finish while this agent is being built, after the worker's cache refresh.
2110
+ try:
2111
+ with scoped_home(home):
2112
+ refresh_profile_agents_mcp_tools(home)
2113
+ except Exception: # noqa: BLE001 - refresh failure must not disable Stop
2114
+ log.warning("Late MCP refresh failed", exc_info=True)
2115
+ with run.lock:
2116
+ if pending_mcp:
2117
+ emit("notice_clear", {"key": _MCP_PENDING_KEY})
2118
+ pending_mcp = False
1172
2119
  if run.finished.is_set():
1173
2120
  return
1174
2121
  if ctx.cancelled:
@@ -1189,27 +2136,44 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1189
2136
  watcher = threading.Thread(target=_watch_cancel, daemon=True, name=f"chat-cancel-{stream_id[:8]}")
1190
2137
  watcher.start()
1191
2138
  run_kwargs: dict = {"user_message": user_message, "conversation_history": history, "task_id": session_id}
1192
- try:
1193
- run_params = set(inspect.signature(agent.run_conversation).parameters)
1194
- except (TypeError, ValueError):
1195
- run_params = set()
2139
+ run_params = _run_conversation_parameters(type(agent))
1196
2140
  if system_message and ("system_message" in run_params or not run_params):
1197
2141
  run_kwargs["system_message"] = system_message
2142
+ # Talaria generates and syncs the title; suppress the Agent's duplicate auxiliary request.
2143
+ if "title_user_message" in run_params:
2144
+ run_kwargs["title_user_message"] = ""
1198
2145
  # The server's clean user text (no workspace prefix) is what state.db stores; the Agent keeps the sent bytes beside it.
1199
2146
  persist = params.get("persist_user_message")
1200
2147
  if not (isinstance(persist, str) and persist.strip()):
1201
2148
  persist = user_message if isinstance(user_message, str) else None
1202
2149
  if "persist_user_message" in run_params and persist is not None:
1203
2150
  run_kwargs["persist_user_message"] = persist
2151
+ # TAL-801: a spoken turn runs on the profile's ``auxiliary.voice_chat`` slot; the Agent consumes the mark at turn
2152
+ # setup. Every turn sets it, as the Agent's own surfaces do, so a mark a failed turn never consumed cannot leak.
2153
+ if _has_voice_turn_route():
2154
+ agent._voice_turn_pending = params.get("voice_turn") is True
1204
2155
  error: str | None = None
1205
2156
  result: dict = {}
2157
+ # The Agent reads its vault prompts from the thread running the turn (and copies them onto tool workers).
2158
+ _set_vault_prompts(session_id, vault_unlock_prompt, vault_save_login_prompt, vault_code_prompt)
1206
2159
  try:
1207
- raw = agent.run_conversation(**run_kwargs)
2160
+ try:
2161
+ from tools.skills_tool import bind_turn_secret_capture
2162
+ except ImportError: # Older Agents and v0.22.2 have only an unsafe process-global callback slot.
2163
+ bind_turn_secret_capture = None
2164
+ log.debug("Agent lacks turn-local skill secret capture; secure setup is unavailable")
2165
+ with bind_turn_secret_capture(skill_secret_prompt) if bind_turn_secret_capture and params.get("skill_secret") is True else contextlib.nullcontext():
2166
+ if timing:
2167
+ timing.end("conversation_setup")
2168
+ timing.observe("conversation_entry")
2169
+ timing.emit("conversation_entry")
2170
+ raw = agent.run_conversation(**run_kwargs)
1208
2171
  result = raw if isinstance(raw, dict) else {}
1209
2172
  except Exception as exc: # noqa: BLE001
1210
2173
  log.warning("agent turn failed for %s: %s", session_id, exc)
1211
2174
  error = f"{type(exc).__name__}: {exc}"
1212
2175
  finally:
2176
+ _set_vault_prompts(None, None, None, None)
1213
2177
  run.finished.set()
1214
2178
  watcher.join(timeout=2)
1215
2179
  # A successor turn for the same session (admitted past the cancel-unwind ceiling) may have registered
@@ -1255,7 +2219,8 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1255
2219
  "max_iterations_summary_request": _max_iterations_summary_request(),
1256
2220
  "usage": _usage(agent),
1257
2221
  "context": _context_length(agent),
1258
- "model": str(getattr(agent, "model", None) or resolved_model),
2222
+ # TAL-801: the Agent's result names the model the turn ran on; a voice turn's route is undone by now.
2223
+ "model": str(result.get("model") or getattr(agent, "model", None) or resolved_model),
1259
2224
  "provider": str(resolved_provider or ""),
1260
2225
  "compressed": compressed,
1261
2226
  "agent_session_id": str(getattr(agent, "session_id", None) or session_id),
@@ -1264,11 +2229,21 @@ def start(ctx: CallContext, params: dict) -> dict: # noqa: PLR0915 - one turn,
1264
2229
  "live_tool_calls": live_tool_calls,
1265
2230
  }
1266
2231
  finally:
2232
+ with emit_lock:
2233
+ flush_tokens()
2234
+ with run.lock:
2235
+ if pending_mcp:
2236
+ emit("notice_clear", {"key": _MCP_PENDING_KEY})
2237
+ pending_mcp = False
1267
2238
  with _AGENT_CACHE_LOCK:
1268
2239
  with _RUNS_LOCK:
1269
2240
  _RUNS.pop(stream_id, None)
1270
2241
  if _RUNS_BY_SESSION.get(session_id) == stream_id:
1271
2242
  _RUNS_BY_SESSION.pop(session_id, None)
2243
+ # TAL-887: a session ended mid-turn (``chat.evict_agent`` marks the run under these locks) released its
2244
+ # unlocks then; one the Agent stored after that is released now.
2245
+ if run.session_ended:
2246
+ _release_vault_session(session_id)
1272
2247
  # An agent evicted or replaced while this turn held it is released now that the turn is over.
1273
2248
  _release_if_dropped_locked(run.agent, home)
1274
2249
 
@@ -1333,6 +2308,7 @@ def compress(ctx: CallContext, params: dict) -> dict:
1333
2308
  history = params.get("conversation_history")
1334
2309
  if not isinstance(history, list):
1335
2310
  raise InvalidParams("conversation_history must be a list")
2311
+ _require_data_use_support(history)
1336
2312
  model = str(params.get("model") or "").strip()
1337
2313
  provider = str(params.get("model_provider") or "").strip() or None
1338
2314
  focus_topic = str(params.get("focus_topic") or "").strip()[:500] or None
@@ -1351,6 +2327,7 @@ def compress(ctx: CallContext, params: dict) -> dict:
1351
2327
  platform="webui",
1352
2328
  quiet_mode=True,
1353
2329
  enabled_toolsets=toolsets,
2330
+ disabled_toolsets=disabled_toolsets(),
1354
2331
  session_id=session_id,
1355
2332
  )
1356
2333
  for name, value in (
@@ -1360,6 +2337,8 @@ def compress(ctx: CallContext, params: dict) -> dict:
1360
2337
  ("credential_pool", runtime.get("credential_pool")),
1361
2338
  ("gateway_session_key", session_id),
1362
2339
  ("skip_memory", not _checkpoint_required()),
2340
+ ("request_overrides", _request_overrides(kwargs["model"], kwargs["provider"], runtime)),
2341
+ ("prefill_messages", _profile_prefill_messages()),
1363
2342
  ):
1364
2343
  if _supported(AIAgent, name) and value is not None:
1365
2344
  kwargs[name] = value
@@ -1398,6 +2377,94 @@ def compress(ctx: CallContext, params: dict) -> dict:
1398
2377
  _release_compression(agent, committed=False)
1399
2378
 
1400
2379
 
2380
+ _REFINE_BUSY = "Agent is running — wait for the turn to finish, then /refine."
2381
+
2382
+
2383
+ def _session_running_locked(session_id: str) -> bool:
2384
+ """Whether a turn for ``session_id`` has not returned yet, or no turn can start (caller holds ``_RUNS_LOCK``)."""
2385
+ run = _RUNS.get(_RUNS_BY_SESSION.get(session_id) or "")
2386
+ return _DRAINING.is_set() or (run is not None and not run.finished.is_set())
2387
+
2388
+
2389
+ def _refine_agent(params: dict, session_id: str, home):
2390
+ """The session's cached agent, else one built the way its turn builds it and cached for that turn, held in
2391
+ ``_COMMITTING`` until the caller lets go; None when a turn started meanwhile. Runs under the call's ``scoped_home``."""
2392
+ with _AGENT_CACHE_LOCK:
2393
+ with _RUNS_LOCK:
2394
+ # Checked again with the claim: a turn admitted since ``refine``'s check holds the cached agent.
2395
+ if _session_running_locked(session_id):
2396
+ return None
2397
+ cached = _AGENT_CACHE.get(session_id)
2398
+ if cached is not None:
2399
+ _COMMITTING.append((cached[0], home))
2400
+ return cached[0]
2401
+ model = str(params.get("model") or "").strip()
2402
+ provider = str(params.get("model_provider") or "").strip() or None
2403
+ toolsets = _turn_toolsets(params.get("enabled_toolsets"))
2404
+ # Like a turn: the profile's MCP servers connect before the agent snapshots its tools, which its next turn keeps.
2405
+ try:
2406
+ from .commands import discover_profile_mcp_tools
2407
+
2408
+ discover_profile_mcp_tools()
2409
+ except Exception: # noqa: BLE001
2410
+ log.warning("MCP discovery failed", exc_info=True)
2411
+ runtime = _resolve_runtime(provider, model)
2412
+ resolved_model = model or str(runtime.get("model") or "")
2413
+ resolved_provider = provider or runtime.get("provider")
2414
+ AIAgent = _agent_class()
2415
+ kwargs = _agent_kwargs(AIAgent, params, session_id, resolved_model, resolved_provider, runtime, toolsets)
2416
+ signature = _agent_signature(resolved_model, resolved_provider, runtime, toolsets, str(params.get("profile_home")), kwargs)
2417
+ built = _build_agent(AIAgent, kwargs, home, session_id)
2418
+ with _AGENT_CACHE_LOCK:
2419
+ with _RUNS_LOCK:
2420
+ running = _session_running_locked(session_id)
2421
+ cached = _AGENT_CACHE.get(session_id)
2422
+ agent = None if running else cached[0] if cached else built
2423
+ if agent is not None:
2424
+ _COMMITTING.append((agent, home))
2425
+ if agent is built:
2426
+ built._talaria_unannounced = True # its first turn announces it (``start``)
2427
+ _AGENT_CACHE[session_id] = (built, signature, home)
2428
+ _evict_idle_agents_locked()
2429
+ else:
2430
+ _release_agents_locked([(built, None, home)])
2431
+ return agent
2432
+
2433
+
2434
+ def refine(params: dict) -> str:
2435
+ """TAL-902: ``/refine [focus]`` like the Agent's live-session handler (``_format_live_refine_output``): the memory/skill
2436
+ review fork of ``messages`` (the server's model context for the session) on the session's agent, never while its turn
2437
+ runs. The fork carries this call's ``scoped_home``, so its memory and skill writes land in the session's profile."""
2438
+ session_id = str(params.get("session_id") or "").strip()
2439
+ messages = params.get("messages")
2440
+ if not session_id or not isinstance(messages, list):
2441
+ raise InvalidParams("session_id and messages are required")
2442
+ _require_data_use_support(messages)
2443
+ focus = str(params.get("focus") or "").strip()
2444
+ with _RUNS_LOCK:
2445
+ if _session_running_locked(session_id):
2446
+ return _REFINE_BUSY
2447
+ if not messages:
2448
+ return "Nothing to refine yet — the conversation is empty."
2449
+ home = profile_home_param(params)
2450
+ agent = _refine_agent(params, session_id, home)
2451
+ if agent is None:
2452
+ return _REFINE_BUSY
2453
+ try:
2454
+ agent._spawn_background_review(messages_snapshot=list(messages), review_memory=True,
2455
+ review_skills="skill_manage" in (getattr(agent, "valid_tool_names", None) or set()),
2456
+ focus=focus or None, explicit=True)
2457
+ except Exception as exc: # noqa: BLE001 - the Agent's own wording for a fork that could not start
2458
+ log.warning("/refine failed to start for session %s", session_id, exc_info=True)
2459
+ return f"/refine failed to start: {exc}"
2460
+ finally:
2461
+ with _AGENT_CACHE_LOCK:
2462
+ _COMMITTING.remove((agent, home))
2463
+ _release_if_dropped_locked(agent, home)
2464
+ tail = f" (focus: {focus})" if focus else ""
2465
+ return f"⚗ Reviewing this conversation in the background{tail} — any memory/skill updates will be reported when done."
2466
+
2467
+
1401
2468
  def _run_for(params: dict) -> _Run | None:
1402
2469
  stream_id = str(params.get("stream_id") or "").strip()
1403
2470
  session_id = str(params.get("session_id") or "").strip()
@@ -1417,15 +2484,20 @@ def register(registry) -> None:
1417
2484
  workspace = params.get("workspace")
1418
2485
  if not isinstance(workspace, str) or not workspace.strip():
1419
2486
  raise InvalidParams("workspace is required")
1420
- with scoped_home(profile_home_param(params)):
2487
+ with agent_scope(profile_home_param(params)):
1421
2488
  with _turn_identity(session_id, workspace):
1422
2489
  return start(ctx, params)
1423
2490
 
1424
2491
  @registry.method("chat.compress")
1425
2492
  def compress_(ctx: CallContext, params: dict) -> dict:
1426
- with scoped_home(profile_home_param(params)):
2493
+ with agent_scope(profile_home_param(params)):
1427
2494
  return compress(ctx, params)
1428
2495
 
2496
+ @registry.method("chat.refine")
2497
+ def refine_(ctx: CallContext, params: dict) -> dict:
2498
+ with agent_scope(profile_home_param(params)):
2499
+ return {"output": refine(params)}
2500
+
1429
2501
  @registry.method("chat.compress_finalize", requires_agent=False)
1430
2502
  def compress_finalize_(ctx: CallContext, params: dict) -> dict:
1431
2503
  token = str(params.get("commit_token") or "").strip()
@@ -1487,36 +2559,133 @@ def register(registry) -> None:
1487
2559
  pending, index = _steer_queue_params(params)
1488
2560
  run = _run_for(params)
1489
2561
  if run is None or run.agent is None:
1490
- return {"redirected": False, "withdrawn": False}
2562
+ return {"redirected": False, "withdrawn": False, "reason": "not_running"}
1491
2563
  return steer_now(run.agent, pending, index)
1492
2564
 
2565
+ @registry.method("chat.finalize_session", requires_agent=False)
2566
+ def finalize_(ctx: CallContext, params: dict) -> dict:
2567
+ session_id = str(params.get("session_id") or "").strip()
2568
+ reason = params.get("reason")
2569
+ if not session_id or reason not in ("clear", "delete", "archive"):
2570
+ raise InvalidParams("session_id and reason (clear/delete/archive) are required")
2571
+ lineage = params.get("lineage")
2572
+ if lineage is not None and (not isinstance(lineage, list) or len(lineage) > 10_000
2573
+ or any(not isinstance(sid, str) or not sid for sid in lineage)):
2574
+ raise InvalidParams("lineage must contain session ids")
2575
+ home = profile_home_param(params)
2576
+ messages: list[str] = []
2577
+
2578
+ def finalize() -> None:
2579
+ try:
2580
+ # Scope inside the worker: a timed-out plugin keeps its own profile until it finishes.
2581
+ with agent_scope(home):
2582
+ if reason == "archive":
2583
+ _notify_archive(home, session_id, lineage)
2584
+ return
2585
+ from hermes_cli import lifecycle
2586
+
2587
+ callback = getattr(lifecycle, "finalize_session", None)
2588
+ if callback is None:
2589
+ return
2590
+ results = callback(session_id=session_id, platform="webui", reason=reason)
2591
+ extract = getattr(lifecycle, "session_end_messages", None)
2592
+ if extract is not None:
2593
+ messages.extend(extract(results))
2594
+ except Exception: # noqa: BLE001 - hooks must never block the boundary
2595
+ log.warning("Session lifecycle callback failed (%s)", reason)
2596
+ finally:
2597
+ with _RELEASES_LOCK:
2598
+ _RELEASES.pop(worker, None)
2599
+
2600
+ if reason == "archive":
2601
+ worker = _ArchiveCallback(finalize)
2602
+ _schedule_archive(worker, home)
2603
+ return {"messages": []}
2604
+ worker = threading.Thread(target=finalize, name="session-finalize", daemon=True)
2605
+ worker.failed = False # type: ignore[attr-defined] - hook failures do not prevent profile deletion
2606
+ with _RELEASES_LOCK:
2607
+ _RELEASES[worker] = home
2608
+ worker.start()
2609
+ worker.join(_SESSION_FINALIZE_TIMEOUT)
2610
+ if worker.is_alive():
2611
+ log.warning("Session lifecycle callback exceeded deadline (%s)", reason)
2612
+ return {"messages": []}
2613
+ return {"messages": messages}
2614
+
1493
2615
  @registry.method("chat.evict_agent", requires_agent=False)
1494
2616
  def evict_(ctx: CallContext, params: dict) -> dict:
1495
2617
  """Drop a session's cached agent. Only delete/clear (``clear_session``) end its approval grants, parked
1496
- approvals and kernels; a model switch, truncate or compress leaves a running turn's agent in place."""
2618
+ approvals, kernels and background review; a model switch, truncate or compress leaves a running turn's agent
2619
+ in place."""
1497
2620
  session_id = str(params.get("session_id") or "").strip()
1498
2621
  end_session = params.get("clear_session") is True
1499
2622
  with _AGENT_CACHE_LOCK:
1500
2623
  with _RUNS_LOCK:
1501
2624
  run = _RUNS.get(_RUNS_BY_SESSION.get(session_id) or "")
2625
+ if end_session and run is not None:
2626
+ run.session_ended = True
1502
2627
  if not end_session and run is not None and not run.finished.is_set():
1503
2628
  return {"evicted": False}
1504
2629
  entry = _AGENT_CACHE.pop(session_id, None)
1505
2630
  _release_agents_locked([entry] if entry else [])
1506
2631
  if end_session:
2632
+ # The server does not wait for an eviction; the cancels finish on their own.
2633
+ _cancel_background_reviews(([(entry[0], entry[2])] if entry else []) + ([(run.agent, run.home)] if run else []), "session deleted or cleared")
1507
2634
  try:
1508
2635
  from tools.approval import clear_session
1509
2636
  clear_session(session_id)
1510
2637
  except Exception: # noqa: BLE001
1511
2638
  pass
2639
+ # TAL-887: a password manager this session unlocked locks again with it; other sessions keep theirs.
2640
+ _release_vault_session(session_id)
1512
2641
  return {"evicted": entry is not None}
1513
2642
 
2643
+ @registry.method("chat.context_detail", requires_agent=False)
2644
+ def context_detail_(ctx: CallContext, params: dict) -> dict:
2645
+ from ..context_detail import project, unavailable
2646
+ home = profile_home_param(params).resolve()
2647
+ sid = str(params.get("session_id") or "")
2648
+ with _AGENT_CACHE_LOCK:
2649
+ cached = _AGENT_CACHE.get(sid)
2650
+ if cached is None:
2651
+ return unavailable("not_built")
2652
+ agent = cached[0]
2653
+ if (Path(cached[2]).resolve() != home or getattr(agent, "model", None) != params.get("model")
2654
+ or getattr(agent, "provider", None) != params.get("model_provider")):
2655
+ return unavailable("changed")
2656
+ def marks():
2657
+ comp = getattr(agent, "context_compressor", None)
2658
+ return (getattr(agent, "model", None), getattr(agent, "provider", None),
2659
+ getattr(comp, "compression_count", None), getattr(comp, "last_prompt_tokens", None),
2660
+ len(getattr(agent, "_session_messages", None) or []))
2661
+ before = marks()
2662
+ # Pin the cache entry across the read just like refine/memory, so eviction defers its release.
2663
+ _COMMITTING.append((agent, cached[2]))
2664
+ try:
2665
+ from agent.context_breakdown import compute_session_context_breakdown
2666
+ from agent.context_file_sources import context_file_sources_for_agent
2667
+ with scoped_home(home), session_workspace(params.get("workspace")):
2668
+ payload = compute_session_context_breakdown(agent, list(getattr(agent, "_session_messages", None) or []))
2669
+ payload['context_files'] = context_file_sources_for_agent(agent)
2670
+ with _AGENT_CACHE_LOCK:
2671
+ if _AGENT_CACHE.get(sid) is not cached or before != marks():
2672
+ return unavailable("changed")
2673
+ return project(payload)
2674
+ except ImportError:
2675
+ return unavailable("unsupported")
2676
+ except Exception: # never expose Agent diagnostics, prompt contents or paths
2677
+ return unavailable()
2678
+ finally:
2679
+ with _AGENT_CACHE_LOCK:
2680
+ _COMMITTING.remove((agent, cached[2]))
2681
+ _release_if_dropped_locked(agent, cached[2])
2682
+
1514
2683
  @registry.method("chat.commit_memory", requires_agent=False)
1515
2684
  def commit_memory_(ctx: CallContext, params: dict) -> dict:
1516
2685
  """Predecessor ``commit_session_memory``: flush the cached Agent's memory for a session the user just left.
1517
- Providers get the session's transcript and run under its profile's ``scoped_home``."""
2686
+ Providers get the session's transcript and run under the ``scoped_home`` the Agent was built in, which v0.22.3
2687
+ binds them to (like ``_release_agent``), not the request's ``profile_home``."""
1518
2688
  session_id = str(params.get("session_id") or "").strip()
1519
- home = profile_home_param(params)
1520
2689
  with _AGENT_CACHE_LOCK:
1521
2690
  cached = _AGENT_CACHE.get(session_id)
1522
2691
  with _RUNS_LOCK:
@@ -1525,16 +2694,16 @@ def register(registry) -> None:
1525
2694
  commit = getattr(agent, "commit_memory_session", None) if agent is not None else None
1526
2695
  if commit is None or busy:
1527
2696
  return {"committed": False}
1528
- _COMMITTING.append(agent)
2697
+ _COMMITTING.append((agent, cached[2]))
1529
2698
  try:
1530
- with scoped_home(home):
2699
+ with scoped_home(cached[2]):
1531
2700
  commit(list(getattr(agent, "_session_messages", None) or []))
1532
2701
  except Exception as exc: # noqa: BLE001
1533
2702
  log.warning("commit_memory_session() failed for session %s: %s", session_id, exc)
1534
2703
  return {"committed": False}
1535
2704
  finally:
1536
2705
  with _AGENT_CACHE_LOCK:
1537
- _COMMITTING.remove(agent)
2706
+ _COMMITTING.remove((agent, cached[2]))
1538
2707
  _release_if_dropped_locked(agent, cached[2])
1539
2708
  return {"committed": True}
1540
2709
 
@@ -1551,7 +2720,7 @@ def register(registry) -> None:
1551
2720
  raise RpcError(f"approval module unavailable: {exc}", condition="agent_unavailable") from exc
1552
2721
  with scoped_home(profile_home_param(params)):
1553
2722
  entries = approval_mod.list_gateway_approvals(session_id)
1554
- targets = [e for e in entries if not request_id or e.get("request_id") == request_id]
2723
+ targets = [e for e in entries if e.get("request_id") == request_id] if request_id else entries[:1]
1555
2724
  keys = []
1556
2725
  for entry in targets:
1557
2726
  keys.extend(entry.get("pattern_keys") or [entry.get("pattern_key", "")])
@@ -1559,17 +2728,21 @@ def register(registry) -> None:
1559
2728
  if choice == "session":
1560
2729
  for k in keys:
1561
2730
  approval_mod.approve_session(session_id, k)
1562
- elif choice == "always":
2731
+ elif choice == "always" and keys:
2732
+ permanent_set = getattr(approval_mod, "_permanent_set", None)
2733
+ baseline_key = getattr(approval_mod, "_baseline_key", None)
2734
+ baselines = getattr(approval_mod, "_permanent_baseline_by_home", None)
2735
+ if not callable(permanent_set) or not callable(baseline_key) or not isinstance(baselines, dict):
2736
+ raise RpcError("Agent cannot safely persist command approvals", condition="agent_incompatible")
1563
2737
  for k in keys:
1564
2738
  approval_mod.approve_session(session_id, k)
1565
2739
  approval_mod.approve_permanent(k)
1566
2740
  try:
1567
- # Persist the set that governs THIS home (per-profile under the home override), not the launch
1568
- # profile's module-level set, which would overwrite a named profile's allowlist with the wrong keys.
1569
- permanent_set = getattr(approval_mod, "_permanent_set", None)
2741
+ # Older retained Agents' _persist_choice saves full snapshots. Capture only unsaved grants
2742
+ # against this home's baseline, so a concurrent revocation cannot become fresh approval intent.
1570
2743
  with approval_mod._lock:
1571
- snapshot = set(permanent_set()) if callable(permanent_set) else set(approval_mod._permanent_approved)
1572
- approval_mod.save_permanent_allowlist(snapshot)
2744
+ grants = permanent_set() - baselines.get(baseline_key(), set())
2745
+ approval_mod.save_permanent_allowlist(grants)
1573
2746
  except Exception: # noqa: BLE001
1574
2747
  log.debug("save_permanent_allowlist failed", exc_info=True)
1575
2748
  resolved = approval_mod.resolve_gateway_approval(session_id, choice, request_id=request_id)
@@ -1600,22 +2773,27 @@ def register(registry) -> None:
1600
2773
 
1601
2774
  @registry.method("clarify.respond", requires_agent=False)
1602
2775
  def clarify_respond_(ctx: CallContext, params: dict) -> dict:
1603
- response = str(params.get("response") or "").strip()
2776
+ response = str(params.get("response") or "")
1604
2777
  clarify_id = str(params.get("clarify_id") or "").strip()
1605
- if not response:
1606
- raise InvalidParams("response is required")
1607
2778
  run = _run_for(params)
1608
2779
  if run is None:
1609
2780
  return {"ok": False}
2781
+ if params.get("session_id") and str(params["session_id"]) != run.session_id:
2782
+ return {"ok": False}
1610
2783
  with run.lock:
1611
2784
  entries = list(run.clarify_entries.values())
1612
- target = None
1613
- if clarify_id:
1614
- target = next((e for e in entries if e.clarify_id == clarify_id), None)
1615
- elif entries:
1616
- target = entries[0]
1617
- if target is None:
1618
- return {"ok": False}
1619
- target.result = response
1620
- target.event.set()
1621
- return {"ok": True, "clarify_id": target.clarify_id}
2785
+ target = next((e for e in entries if e.clarify_id == clarify_id), None) if clarify_id else (entries[0] if entries else None)
2786
+ if target is None or target.event.is_set() or (target.deadline is not None and time.monotonic() >= target.deadline):
2787
+ return {"ok": False}
2788
+ skill_secret = target.data.get("kind") == "skill_secret"
2789
+ if skill_secret and (not clarify_id or ctx.cancelled or run.ctx.cancelled or run.cancel.is_set() or run.finished.is_set() or run.session_ended):
2790
+ return {"ok": False}
2791
+ # Secure answers are as typed; an empty reply is their decline. Only the first answer releases a waiter.
2792
+ secure = str(target.data.get("kind") or "").startswith("vault_") or skill_secret
2793
+ if not secure:
2794
+ response = response.strip()
2795
+ if not response and not secure:
2796
+ raise InvalidParams("response is required")
2797
+ target.result = response
2798
+ target.event.set()
2799
+ return {"ok": True, "clarify_id": target.clarify_id}