@kortix/sdk 0.13.4 → 0.13.7

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 (380) hide show
  1. package/README.md +63 -6
  2. package/dist/browser/cache/idb-sync-cache.d.ts +19 -1
  3. package/dist/browser/cache/idb-sync-cache.d.ts.map +1 -1
  4. package/dist/browser/cache/idb-sync-cache.js +39 -4
  5. package/dist/browser/cache/idb-sync-cache.js.map +1 -1
  6. package/dist/browser/session-sync/server-transcript-mirror.d.ts +82 -0
  7. package/dist/browser/session-sync/server-transcript-mirror.d.ts.map +1 -0
  8. package/dist/browser/session-sync/server-transcript-mirror.js +143 -0
  9. package/dist/browser/session-sync/server-transcript-mirror.js.map +1 -0
  10. package/dist/browser/session-sync/session-sync-registry.d.ts +11 -1
  11. package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
  12. package/dist/browser/session-sync/session-sync-registry.js +117 -9
  13. package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
  14. package/dist/browser/session-sync/visibility.d.ts +19 -0
  15. package/dist/browser/session-sync/visibility.d.ts.map +1 -0
  16. package/dist/browser/session-sync/visibility.js +16 -0
  17. package/dist/browser/session-sync/visibility.js.map +1 -0
  18. package/dist/browser/stores/sandbox-connection-store.d.ts +13 -1
  19. package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
  20. package/dist/browser/stores/sandbox-connection-store.js +6 -3
  21. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  22. package/dist/browser/stores/session-working-store.d.ts +10 -5
  23. package/dist/browser/stores/session-working-store.d.ts.map +1 -1
  24. package/dist/browser/stores/session-working-store.js +32 -8
  25. package/dist/browser/stores/session-working-store.js.map +1 -1
  26. package/dist/browser/stores/sync-store.d.ts +56 -2
  27. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  28. package/dist/browser/stores/sync-store.js +682 -402
  29. package/dist/browser/stores/sync-store.js.map +1 -1
  30. package/dist/core/auth/app-viewer.d.ts +57 -0
  31. package/dist/core/auth/app-viewer.d.ts.map +1 -0
  32. package/dist/core/auth/app-viewer.js +82 -0
  33. package/dist/core/auth/app-viewer.js.map +1 -0
  34. package/dist/core/auth/session.d.ts +45 -0
  35. package/dist/core/auth/session.d.ts.map +1 -0
  36. package/dist/core/auth/session.js +113 -0
  37. package/dist/core/auth/session.js.map +1 -0
  38. package/dist/core/client/kortix.d.ts +37 -29
  39. package/dist/core/client/kortix.d.ts.map +1 -1
  40. package/dist/core/client/kortix.js +61 -8
  41. package/dist/core/client/kortix.js.map +1 -1
  42. package/dist/core/http/config.d.ts +4 -2
  43. package/dist/core/http/config.d.ts.map +1 -1
  44. package/dist/core/http/config.js.map +1 -1
  45. package/dist/core/http/opencode-errors.d.ts +17 -0
  46. package/dist/core/http/opencode-errors.d.ts.map +1 -1
  47. package/dist/core/http/opencode-errors.js +25 -0
  48. package/dist/core/http/opencode-errors.js.map +1 -1
  49. package/dist/core/rest/platform-client/auth.d.ts +120 -0
  50. package/dist/core/rest/platform-client/auth.d.ts.map +1 -0
  51. package/dist/core/rest/platform-client/auth.js +98 -0
  52. package/dist/core/rest/platform-client/auth.js.map +1 -0
  53. package/dist/core/rest/platform-client/host-boundary.d.ts +5 -0
  54. package/dist/core/rest/platform-client/host-boundary.d.ts.map +1 -1
  55. package/dist/core/rest/platform-client/host-boundary.js.map +1 -1
  56. package/dist/core/rest/projects-client/accounts.d.ts +57 -0
  57. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  58. package/dist/core/rest/projects-client/accounts.js +31 -0
  59. package/dist/core/rest/projects-client/accounts.js.map +1 -1
  60. package/dist/core/rest/projects-client/apps.d.ts +9 -0
  61. package/dist/core/rest/projects-client/apps.d.ts.map +1 -1
  62. package/dist/core/rest/projects-client/apps.js.map +1 -1
  63. package/dist/core/rest/projects-client/billing.d.ts +2 -0
  64. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  65. package/dist/core/rest/projects-client/billing.js.map +1 -1
  66. package/dist/core/rest/projects-client/channels.d.ts +0 -3
  67. package/dist/core/rest/projects-client/channels.d.ts.map +1 -1
  68. package/dist/core/rest/projects-client/channels.js +0 -6
  69. package/dist/core/rest/projects-client/channels.js.map +1 -1
  70. package/dist/core/rest/projects-client/connectors.d.ts +104 -19
  71. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  72. package/dist/core/rest/projects-client/connectors.js +56 -2
  73. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  74. package/dist/core/rest/projects-client/index.d.ts +1 -2
  75. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  76. package/dist/core/rest/projects-client/index.js +1 -2
  77. package/dist/core/rest/projects-client/index.js.map +1 -1
  78. package/dist/core/rest/projects-client/oauth-clients.d.ts +47 -0
  79. package/dist/core/rest/projects-client/oauth-clients.d.ts.map +1 -0
  80. package/dist/core/rest/projects-client/oauth-clients.js +33 -0
  81. package/dist/core/rest/projects-client/oauth-clients.js.map +1 -0
  82. package/dist/core/rest/projects-client/projects.d.ts +23 -6
  83. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  84. package/dist/core/rest/projects-client/projects.js +6 -3
  85. package/dist/core/rest/projects-client/projects.js.map +1 -1
  86. package/dist/core/rest/projects-client/session-sandbox.d.ts +52 -0
  87. package/dist/core/rest/projects-client/session-sandbox.d.ts.map +1 -1
  88. package/dist/core/rest/projects-client/session-sandbox.js.map +1 -1
  89. package/dist/core/rest/projects-client/sessions.d.ts +142 -34
  90. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  91. package/dist/core/rest/projects-client/sessions.js +48 -19
  92. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  93. package/dist/core/runtime/attachment-part.d.ts +26 -0
  94. package/dist/core/runtime/attachment-part.d.ts.map +1 -0
  95. package/dist/core/runtime/attachment-part.js +41 -0
  96. package/dist/core/runtime/attachment-part.js.map +1 -0
  97. package/dist/core/runtime/client.d.ts +4 -2
  98. package/dist/core/runtime/client.d.ts.map +1 -1
  99. package/dist/core/runtime/client.js +3 -2
  100. package/dist/core/runtime/client.js.map +1 -1
  101. package/dist/core/runtime/opencode-v2-event-aliases.d.ts +184 -0
  102. package/dist/core/runtime/opencode-v2-event-aliases.d.ts.map +1 -0
  103. package/dist/core/runtime/opencode-v2-event-aliases.js +2 -0
  104. package/dist/core/runtime/opencode-v2-event-aliases.js.map +1 -0
  105. package/dist/core/runtime/pty.d.ts +11 -1
  106. package/dist/core/runtime/pty.d.ts.map +1 -1
  107. package/dist/core/runtime/pty.js +19 -6
  108. package/dist/core/runtime/pty.js.map +1 -1
  109. package/dist/core/session/connection.d.ts +64 -0
  110. package/dist/core/session/connection.d.ts.map +1 -0
  111. package/dist/core/session/connection.js +36 -0
  112. package/dist/core/session/connection.js.map +1 -0
  113. package/dist/core/session/index.d.ts +2 -0
  114. package/dist/core/session/index.d.ts.map +1 -1
  115. package/dist/core/session/index.js +2 -0
  116. package/dist/core/session/index.js.map +1 -1
  117. package/dist/core/session/open-bundle.d.ts +110 -0
  118. package/dist/core/session/open-bundle.d.ts.map +1 -0
  119. package/dist/core/session/open-bundle.js +183 -0
  120. package/dist/core/session/open-bundle.js.map +1 -0
  121. package/dist/core/session/poll-owner.d.ts +35 -0
  122. package/dist/core/session/poll-owner.d.ts.map +1 -0
  123. package/dist/core/session/poll-owner.js +90 -0
  124. package/dist/core/session/poll-owner.js.map +1 -0
  125. package/dist/core/session/preview-auth.d.ts +25 -0
  126. package/dist/core/session/preview-auth.d.ts.map +1 -1
  127. package/dist/core/session/preview-auth.js +31 -1
  128. package/dist/core/session/preview-auth.js.map +1 -1
  129. package/dist/core/session/preview-config.d.ts +26 -0
  130. package/dist/core/session/preview-config.d.ts.map +1 -0
  131. package/dist/core/session/preview-config.js +95 -0
  132. package/dist/core/session/preview-config.js.map +1 -0
  133. package/dist/core/session/preview-options.d.ts +25 -0
  134. package/dist/core/session/preview-options.d.ts.map +1 -0
  135. package/dist/core/session/preview-options.js +45 -0
  136. package/dist/core/session/preview-options.js.map +1 -0
  137. package/dist/core/session/preview.d.ts +8 -3
  138. package/dist/core/session/preview.d.ts.map +1 -1
  139. package/dist/core/session/preview.js +11 -4
  140. package/dist/core/session/preview.js.map +1 -1
  141. package/dist/core/session/rewind.d.ts +67 -24
  142. package/dist/core/session/rewind.d.ts.map +1 -1
  143. package/dist/core/session/rewind.js +74 -26
  144. package/dist/core/session/rewind.js.map +1 -1
  145. package/dist/core/session/server-store/active.d.ts +10 -12
  146. package/dist/core/session/server-store/active.d.ts.map +1 -1
  147. package/dist/core/session/server-store/active.js +11 -9
  148. package/dist/core/session/server-store/active.js.map +1 -1
  149. package/dist/core/session/server-store/url-helpers.d.ts +11 -2
  150. package/dist/core/session/server-store/url-helpers.d.ts.map +1 -1
  151. package/dist/core/session/server-store/url-helpers.js +11 -2
  152. package/dist/core/session/server-store/url-helpers.js.map +1 -1
  153. package/dist/core/session/static-file-preview.d.ts +50 -0
  154. package/dist/core/session/static-file-preview.d.ts.map +1 -0
  155. package/dist/core/session/static-file-preview.js +91 -0
  156. package/dist/core/session/static-file-preview.js.map +1 -0
  157. package/dist/core/session/url.d.ts +25 -2
  158. package/dist/core/session/url.d.ts.map +1 -1
  159. package/dist/core/session/url.js +87 -11
  160. package/dist/core/session/url.js.map +1 -1
  161. package/dist/core/session/wake-escalation.d.ts +188 -0
  162. package/dist/core/session/wake-escalation.d.ts.map +1 -0
  163. package/dist/core/session/wake-escalation.js +241 -0
  164. package/dist/core/session/wake-escalation.js.map +1 -0
  165. package/dist/core/session/working.d.ts +107 -2
  166. package/dist/core/session/working.d.ts.map +1 -1
  167. package/dist/core/session/working.js +206 -7
  168. package/dist/core/session/working.js.map +1 -1
  169. package/dist/core/session-sync/durable-log.d.ts +96 -0
  170. package/dist/core/session-sync/durable-log.d.ts.map +1 -0
  171. package/dist/core/session-sync/durable-log.js +94 -0
  172. package/dist/core/session-sync/durable-log.js.map +1 -0
  173. package/dist/core/session-sync/fragment.d.ts +27 -0
  174. package/dist/core/session-sync/fragment.d.ts.map +1 -0
  175. package/dist/core/session-sync/fragment.js +24 -0
  176. package/dist/core/session-sync/fragment.js.map +1 -0
  177. package/dist/core/session-sync/session-sync-controller.d.ts +117 -11
  178. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  179. package/dist/core/session-sync/session-sync-controller.js +278 -26
  180. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  181. package/dist/core/stream/keepalive.d.ts +17 -0
  182. package/dist/core/stream/keepalive.d.ts.map +1 -0
  183. package/dist/core/stream/keepalive.js +19 -0
  184. package/dist/core/stream/keepalive.js.map +1 -0
  185. package/dist/core/turns/grouping.d.ts.map +1 -1
  186. package/dist/core/turns/grouping.js +73 -23
  187. package/dist/core/turns/grouping.js.map +1 -1
  188. package/dist/core/turns/types.d.ts +5 -0
  189. package/dist/core/turns/types.d.ts.map +1 -1
  190. package/dist/index.d.ts +16 -1
  191. package/dist/index.d.ts.map +1 -1
  192. package/dist/index.js +19 -0
  193. package/dist/index.js.map +1 -1
  194. package/dist/kortix.esm.min.js +25 -25
  195. package/dist/kortix.global.js +25 -25
  196. package/dist/node/app-viewer.d.ts +41 -0
  197. package/dist/node/app-viewer.d.ts.map +1 -0
  198. package/dist/node/app-viewer.js +150 -0
  199. package/dist/node/app-viewer.js.map +1 -0
  200. package/dist/node/auth.d.ts +84 -0
  201. package/dist/node/auth.d.ts.map +1 -0
  202. package/dist/node/auth.js +414 -0
  203. package/dist/node/auth.js.map +1 -0
  204. package/dist/node/server.d.ts +19 -0
  205. package/dist/node/server.d.ts.map +1 -1
  206. package/dist/node/server.js +13 -1
  207. package/dist/node/server.js.map +1 -1
  208. package/dist/platform/auth-core.d.ts +13 -1
  209. package/dist/platform/auth-core.d.ts.map +1 -1
  210. package/dist/platform/auth-core.js +13 -1
  211. package/dist/platform/auth-core.js.map +1 -1
  212. package/dist/react/bedrock-invokable.d.ts +58 -0
  213. package/dist/react/bedrock-invokable.d.ts.map +1 -0
  214. package/dist/react/bedrock-invokable.js +83 -0
  215. package/dist/react/bedrock-invokable.js.map +1 -0
  216. package/dist/react/index.d.ts +3 -0
  217. package/dist/react/index.d.ts.map +1 -1
  218. package/dist/react/index.js +7 -0
  219. package/dist/react/index.js.map +1 -1
  220. package/dist/react/model-flatten.d.ts +14 -1
  221. package/dist/react/model-flatten.d.ts.map +1 -1
  222. package/dist/react/model-flatten.js +3 -4
  223. package/dist/react/model-flatten.js.map +1 -1
  224. package/dist/react/opencode.d.ts +3 -1
  225. package/dist/react/opencode.d.ts.map +1 -1
  226. package/dist/react/opencode.js +5 -1
  227. package/dist/react/opencode.js.map +1 -1
  228. package/dist/react/provider-selection.d.ts +58 -0
  229. package/dist/react/provider-selection.d.ts.map +1 -1
  230. package/dist/react/provider-selection.js +253 -2
  231. package/dist/react/provider-selection.js.map +1 -1
  232. package/dist/react/session-start-giveup.d.ts +12 -0
  233. package/dist/react/session-start-giveup.d.ts.map +1 -0
  234. package/dist/react/session-start-giveup.js +13 -0
  235. package/dist/react/session-start-giveup.js.map +1 -0
  236. package/dist/react/session-title-sync.d.ts.map +1 -1
  237. package/dist/react/session-title-sync.js +15 -5
  238. package/dist/react/session-title-sync.js.map +1 -1
  239. package/dist/react/sign-in-with-kortix.d.ts +346 -0
  240. package/dist/react/sign-in-with-kortix.d.ts.map +1 -0
  241. package/dist/react/sign-in-with-kortix.js +65 -0
  242. package/dist/react/sign-in-with-kortix.js.map +1 -0
  243. package/dist/react/use-admin-accounts.d.ts +36 -1
  244. package/dist/react/use-admin-accounts.d.ts.map +1 -1
  245. package/dist/react/use-admin-accounts.js +19 -0
  246. package/dist/react/use-admin-accounts.js.map +1 -1
  247. package/dist/react/use-gateway-routing-policy.d.ts +1 -0
  248. package/dist/react/use-gateway-routing-policy.d.ts.map +1 -1
  249. package/dist/react/use-gateway-routing-policy.js +12 -1
  250. package/dist/react/use-gateway-routing-policy.js.map +1 -1
  251. package/dist/react/use-kortix-app-viewer.d.ts +13 -0
  252. package/dist/react/use-kortix-app-viewer.d.ts.map +1 -0
  253. package/dist/react/use-kortix-app-viewer.js +29 -0
  254. package/dist/react/use-kortix-app-viewer.js.map +1 -0
  255. package/dist/react/use-model-defaults.d.ts +7 -0
  256. package/dist/react/use-model-defaults.d.ts.map +1 -1
  257. package/dist/react/use-model-defaults.js +7 -1
  258. package/dist/react/use-model-defaults.js.map +1 -1
  259. package/dist/react/use-model-enablement.d.ts.map +1 -1
  260. package/dist/react/use-model-enablement.js +5 -1
  261. package/dist/react/use-model-enablement.js.map +1 -1
  262. package/dist/react/use-model-store.d.ts +31 -0
  263. package/dist/react/use-model-store.d.ts.map +1 -1
  264. package/dist/react/use-model-store.js +31 -2
  265. package/dist/react/use-model-store.js.map +1 -1
  266. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  267. package/dist/react/use-opencode-events/handle-event.js +26 -0
  268. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  269. package/dist/react/use-opencode-events/helpers.d.ts +49 -5
  270. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  271. package/dist/react/use-opencode-events/helpers.js +54 -6
  272. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  273. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  274. package/dist/react/use-opencode-events/index.js +80 -8
  275. package/dist/react/use-opencode-events/index.js.map +1 -1
  276. package/dist/react/use-opencode-events/rehydrate-targets.d.ts +18 -0
  277. package/dist/react/use-opencode-events/rehydrate-targets.d.ts.map +1 -0
  278. package/dist/react/use-opencode-events/rehydrate-targets.js +20 -0
  279. package/dist/react/use-opencode-events/rehydrate-targets.js.map +1 -0
  280. package/dist/react/use-opencode-events/stream-revival.d.ts +37 -0
  281. package/dist/react/use-opencode-events/stream-revival.d.ts.map +1 -0
  282. package/dist/react/use-opencode-events/stream-revival.js +53 -0
  283. package/dist/react/use-opencode-events/stream-revival.js.map +1 -0
  284. package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
  285. package/dist/react/use-opencode-events/use-event-stream-refs.js +14 -2
  286. package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
  287. package/dist/react/use-opencode-local.d.ts.map +1 -1
  288. package/dist/react/use-opencode-local.js +27 -8
  289. package/dist/react/use-opencode-local.js.map +1 -1
  290. package/dist/react/use-opencode-pty.d.ts +7 -1
  291. package/dist/react/use-opencode-pty.d.ts.map +1 -1
  292. package/dist/react/use-opencode-pty.js +6 -2
  293. package/dist/react/use-opencode-pty.js.map +1 -1
  294. package/dist/react/use-opencode-sessions/agents.d.ts.map +1 -1
  295. package/dist/react/use-opencode-sessions/agents.js +13 -2
  296. package/dist/react/use-opencode-sessions/agents.js.map +1 -1
  297. package/dist/react/use-opencode-sessions/commands.d.ts +12 -0
  298. package/dist/react/use-opencode-sessions/commands.d.ts.map +1 -1
  299. package/dist/react/use-opencode-sessions/commands.js +15 -3
  300. package/dist/react/use-opencode-sessions/commands.js.map +1 -1
  301. package/dist/react/use-opencode-sessions/index.d.ts +1 -0
  302. package/dist/react/use-opencode-sessions/index.d.ts.map +1 -1
  303. package/dist/react/use-opencode-sessions/index.js +1 -0
  304. package/dist/react/use-opencode-sessions/index.js.map +1 -1
  305. package/dist/react/use-opencode-sessions/keys.d.ts +8 -0
  306. package/dist/react/use-opencode-sessions/keys.d.ts.map +1 -1
  307. package/dist/react/use-opencode-sessions/keys.js +8 -0
  308. package/dist/react/use-opencode-sessions/keys.js.map +1 -1
  309. package/dist/react/use-opencode-sessions/providers.d.ts.map +1 -1
  310. package/dist/react/use-opencode-sessions/providers.js +56 -5
  311. package/dist/react/use-opencode-sessions/providers.js.map +1 -1
  312. package/dist/react/use-opencode-sessions/shared.d.ts +23 -0
  313. package/dist/react/use-opencode-sessions/shared.d.ts.map +1 -1
  314. package/dist/react/use-opencode-sessions/shared.js +31 -0
  315. package/dist/react/use-opencode-sessions/shared.js.map +1 -1
  316. package/dist/react/use-opencode-sessions/vcs.d.ts +28 -0
  317. package/dist/react/use-opencode-sessions/vcs.d.ts.map +1 -0
  318. package/dist/react/use-opencode-sessions/vcs.js +42 -0
  319. package/dist/react/use-opencode-sessions/vcs.js.map +1 -0
  320. package/dist/react/use-poll-owner.d.ts +14 -0
  321. package/dist/react/use-poll-owner.d.ts.map +1 -0
  322. package/dist/react/use-poll-owner.js +32 -0
  323. package/dist/react/use-poll-owner.js.map +1 -0
  324. package/dist/react/use-project-llm-gateway.d.ts +27 -0
  325. package/dist/react/use-project-llm-gateway.d.ts.map +1 -0
  326. package/dist/react/use-project-llm-gateway.js +41 -0
  327. package/dist/react/use-project-llm-gateway.js.map +1 -0
  328. package/dist/react/use-project-models.d.ts +9 -0
  329. package/dist/react/use-project-models.d.ts.map +1 -1
  330. package/dist/react/use-project-models.js +24 -1
  331. package/dist/react/use-project-models.js.map +1 -1
  332. package/dist/react/use-runtime-reconnect.d.ts +11 -0
  333. package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
  334. package/dist/react/use-runtime-reconnect.js +34 -3
  335. package/dist/react/use-runtime-reconnect.js.map +1 -1
  336. package/dist/react/use-session-prompts.d.ts +53 -3
  337. package/dist/react/use-session-prompts.d.ts.map +1 -1
  338. package/dist/react/use-session-prompts.js +133 -21
  339. package/dist/react/use-session-prompts.js.map +1 -1
  340. package/dist/react/use-session-send.d.ts +17 -0
  341. package/dist/react/use-session-send.d.ts.map +1 -1
  342. package/dist/react/use-session-send.js +54 -5
  343. package/dist/react/use-session-send.js.map +1 -1
  344. package/dist/react/use-session-start-give-up.d.ts +20 -0
  345. package/dist/react/use-session-start-give-up.d.ts.map +1 -0
  346. package/dist/react/use-session-start-give-up.js +60 -0
  347. package/dist/react/use-session-start-give-up.js.map +1 -0
  348. package/dist/react/use-session-sync.d.ts +35 -1
  349. package/dist/react/use-session-sync.d.ts.map +1 -1
  350. package/dist/react/use-session-sync.js +135 -53
  351. package/dist/react/use-session-sync.js.map +1 -1
  352. package/dist/react/use-session-working.d.ts +55 -0
  353. package/dist/react/use-session-working.d.ts.map +1 -1
  354. package/dist/react/use-session-working.js +114 -13
  355. package/dist/react/use-session-working.js.map +1 -1
  356. package/dist/react/use-session.d.ts +13 -13
  357. package/dist/react/use-session.d.ts.map +1 -1
  358. package/dist/react/use-session.js +74 -53
  359. package/dist/react/use-session.js.map +1 -1
  360. package/dist/react/use-static-file-preview.d.ts +12 -0
  361. package/dist/react/use-static-file-preview.d.ts.map +1 -0
  362. package/dist/react/use-static-file-preview.js +119 -0
  363. package/dist/react/use-static-file-preview.js.map +1 -0
  364. package/dist/react/use-wake-escalation.d.ts +51 -0
  365. package/dist/react/use-wake-escalation.d.ts.map +1 -0
  366. package/dist/react/use-wake-escalation.js +62 -0
  367. package/dist/react/use-wake-escalation.js.map +1 -0
  368. package/package.json +6 -4
  369. package/dist/browser/session-sync/session-transcript-cache.d.ts +0 -79
  370. package/dist/browser/session-sync/session-transcript-cache.d.ts.map +0 -1
  371. package/dist/browser/session-sync/session-transcript-cache.js +0 -91
  372. package/dist/browser/session-sync/session-transcript-cache.js.map +0 -1
  373. package/dist/core/rest/projects-client/public-voice-join.d.ts +0 -59
  374. package/dist/core/rest/projects-client/public-voice-join.d.ts.map +0 -1
  375. package/dist/core/rest/projects-client/public-voice-join.js +0 -64
  376. package/dist/core/rest/projects-client/public-voice-join.js.map +0 -1
  377. package/dist/core/rest/projects-client/transcription.d.ts +0 -5
  378. package/dist/core/rest/projects-client/transcription.d.ts.map +0 -1
  379. package/dist/core/rest/projects-client/transcription.js +0 -14
  380. package/dist/core/rest/projects-client/transcription.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { create } from "zustand";
3
3
  import { commitSessionRewind, isWithinRewindWindow, stageSessionRewind, } from "../../core/session/rewind.js";
4
+ import { isRetryableTurnError } from "../../core/turns/open-turn.js";
4
5
  import { ascendingId } from "./sync-store/ascending-id.js";
5
6
  import { Binary } from "./sync-store/binary.js";
6
7
  import { writeStreamCache } from "./sync-store/stream-cache.js";
@@ -32,10 +33,85 @@ export function sameSessionStatus(a, b) {
32
33
  return true;
33
34
  return a.attempt === b.attempt && a.message === b.message && a.next === b.next;
34
35
  }
36
+ /**
37
+ * WHO minted a status-bearing event handed to `applyEvent`. A tab-synthesized
38
+ * event (`markSessionIdleLocally`, `markSessionAbortedLocally`, the hydrate
39
+ * snapshot fill) carries `synthetic: true` — a field no wire `Event` has — and
40
+ * its status write lands with `'local'` origin so it can never contradict the
41
+ * control plane's turn authority (`WorkingStreamInput.origin`). Everything off
42
+ * the wire stays `'wire'`.
43
+ */
44
+ function syntheticEventOrigin(event) {
45
+ return event?.synthetic === true
46
+ ? "local"
47
+ : "wire";
48
+ }
35
49
  function isTextLikePart(part) {
36
50
  return part.type === "text" || part.type === "reasoning";
37
51
  }
38
52
  // ============================================================================
53
+ // Locating things in a list that is NOT id-sorted.
54
+ //
55
+ // `Binary.search` requires ascending ids. None of this store's lists are
56
+ // reliably ascending, for two independent reasons:
57
+ //
58
+ // 1. optimistic messages are appended out of order on purpose (see
59
+ // `optimisticRemove`, which says so), and
60
+ // 2. OpenCode 1.18.15 retired the invariant that ids ascend with time
61
+ // altogether — `MessageV2.latest()` orders by `time.created`, with the id
62
+ // only as a tie-break, and `MessageV2.page()` (every transcript read this
63
+ // store performs) has ALWAYS ordered by `time_created`.
64
+ //
65
+ // A binary HIT is still trustworthy: `Binary.search` reports `found` only on an
66
+ // exact id match. A binary MISS is not — on an unsorted list it is a coin
67
+ // flip, and treating it as proof of absence is how `upsertPart` spliced a
68
+ // duplicate part (restarting the visible stream), `removePart` no-opped (a
69
+ // cancelled tool call stayed on screen), and `applyPartDelta` dropped deltas
70
+ // (the assistant appeared to hang mid-sentence).
71
+ // ============================================================================
72
+ /**
73
+ * How coarsely `sessionActivityAt` is stamped.
74
+ *
75
+ * One second: far finer than any consumer needs (the projection compares it
76
+ * against observations bounded in tens of seconds) and coarse enough that a
77
+ * runtime streaming at ~140ms per frame updates it once, not seven times.
78
+ */
79
+ const ACTIVITY_STAMP_RESOLUTION_MS = 1_000;
80
+ /** The index of `id` in `list`, or `-1`. Binary first, linear on a miss. */
81
+ function indexOfId(list, id, idOf) {
82
+ const result = Binary.search(list, id, idOf);
83
+ if (result.found && list[result.index] !== undefined && idOf(list[result.index]) === id) {
84
+ return result.index;
85
+ }
86
+ return list.findIndex((item) => idOf(item) === id);
87
+ }
88
+ /**
89
+ * Where a message the current snapshot did not contain belongs: the first
90
+ * position holding a strictly newer message, or the end.
91
+ *
92
+ * Ordered by `time.created` with the id as the ONLY tie-break — the same order
93
+ * the server's own `MessageV2.latest()` uses, and the key `MessageV2.page()`
94
+ * pages by. That keeps a locally-known message (an SSE arrival, a re-keyed
95
+ * stub) in the sequence the next page read will produce. A message with no
96
+ * readable `time` — every `session.error` stub this store mints — cannot be
97
+ * dated and goes last, which is where the newest thing we know about belongs.
98
+ */
99
+ function insertIndexByTime(list, message) {
100
+ const created = message.time?.created;
101
+ if (created === undefined)
102
+ return list.length;
103
+ for (let index = 0; index < list.length; index++) {
104
+ const other = list[index].time?.created;
105
+ if (other === undefined)
106
+ continue;
107
+ if (other > created)
108
+ return index;
109
+ if (other === created && list[index].id > message.id)
110
+ return index;
111
+ }
112
+ return list.length;
113
+ }
114
+ // ============================================================================
39
115
  // Store Implementation
40
116
  // Track optimistic message IDs so we can remove them when the server sends
41
117
  // the real user message (which has a different, server-generated ID).
@@ -271,26 +347,52 @@ function untrackStub(sessionID, stubId) {
271
347
  }
272
348
  /** The stub id for the turn `userId` opened.
273
349
  *
274
- * Derived from the user message's own id, for two reasons. It is IDEMPOTENT —
275
- * a second `session.error` for the same turn finds the stub it already made
276
- * instead of appending another. And it SORTS immediately after that user
277
- * message: every id the store handles has the same length, so an id that
278
- * extends `userId` byte for byte precedes every id greater than `userId` and
279
- * follows `userId` itself. Position and `parentID` therefore agree, whichever
280
- * of the two a reader uses. */
350
+ * Derived from the user message's own id so it is IDEMPOTENT — a second
351
+ * `session.error` for the same turn finds the stub it already made instead of
352
+ * appending another. The stub is always PLACED directly after its user
353
+ * message (see `rekeyStubParent` and `hydrate`), and carries `parentID`;
354
+ * neither its position nor its linkage is inferred from the id. */
281
355
  const stubIdFor = (userId) => `${userId}_error`;
282
356
  /** A stub id as seen from a list that has no tracking map to hand (`hydrate`'s
283
357
  * incoming server snapshot, which can never legitimately contain one). */
284
358
  const isStubShaped = (id) => id.endsWith("_error");
359
+ /** The index in `messages` of the assistant message answering the turn
360
+ * `parentId` opened, or `-1`.
361
+ *
362
+ * `parentID` is the linkage `groupMessagesIntoTurns` reads and the only
363
+ * authoritative one. A wire assistant message that carries none is matched by
364
+ * TIME instead — `time.created` at or after the prompt's. It used to be
365
+ * matched by `m.id > parentId`, on the rationale "ids ascend, so a greater id
366
+ * is a later message". OpenCode 1.18.15 retired that invariant (turn exit now
367
+ * reads `lastAssistant.parentID === lastUser.id`, and `MessageV2.latest()`
368
+ * orders by `time.created`), and the comparison failed both ways: a real
369
+ * reply with a lower id left a stale `session.error` stub beside it, and an
370
+ * EARLIER turn's parentless reply with a higher id deleted the only record
371
+ * that this turn failed (the 2026-08-19 `ModelNotFound` report).
372
+ *
373
+ * A parentless candidate that cannot be dated — because it carries no `time`,
374
+ * or because the prompt itself is not in `messages` — is not a match: keeping
375
+ * a stub that turns out to be redundant is recoverable on the next snapshot;
376
+ * deleting the only evidence of a failed turn is not.
377
+ *
378
+ * With no parent turn to speak of, any assistant message answers. */
379
+ function findAssistantForTurn(messages, parentId) {
380
+ const parentCreated = parentId
381
+ ? messages.find((m) => m.id === parentId)?.time?.created
382
+ : undefined;
383
+ return messages.findIndex((m) => m.role === "assistant" &&
384
+ !isStubShaped(m.id) &&
385
+ (!parentId ||
386
+ m.parentID === parentId ||
387
+ (!m.parentID &&
388
+ parentCreated !== undefined &&
389
+ m.time?.created !== undefined &&
390
+ m.time.created >= parentCreated)));
391
+ }
285
392
  /** Does `messages` hold an assistant message answering the turn `parentId`
286
- * opened? `parentID` is the linkage `groupMessagesIntoTurns` reads; the id
287
- * comparison covers a wire assistant message that carries none (ids ascend,
288
- * so a greater id is a later message). With no parent turn to speak of, any
289
- * assistant message answers. */
393
+ * opened? See {@link findAssistantForTurn}. */
290
394
  function hasAssistantForTurn(messages, parentId) {
291
- return messages.some((m) => m.role === "assistant" &&
292
- !isStubShaped(m.id) &&
293
- (!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
395
+ return findAssistantForTurn(messages, parentId) !== -1;
294
396
  }
295
397
  /** Move this session's `session.error` stubs from the optimistic user message
296
398
  * `fromId` onto the server's own copy of that prompt, `toId`.
@@ -543,6 +645,9 @@ export const useSyncStore = create()((set, get) => ({
543
645
  messages: {},
544
646
  parts: {},
545
647
  sessionStatus: {},
648
+ sessionStatusOrigin: {},
649
+ sessionStatusAt: {},
650
+ sessionActivityAt: {},
546
651
  diffs: {},
547
652
  todos: {},
548
653
  sessionRevert: {},
@@ -571,9 +676,15 @@ export const useSyncStore = create()((set, get) => ({
571
676
  next[linearIdx] = message;
572
677
  return { messages: { ...s.messages, [sessionID]: next } };
573
678
  }
574
- // New message — insert at sorted position via binary search.
679
+ // New message — placed by `time.created`, the key the server's own
680
+ // `MessageV2.page()` orders by. It used to go in at the binary index,
681
+ // which was only ever meaningful while `hydrate` re-sorted the
682
+ // transcript by id. It no longer does (the page order IS the order),
683
+ // and ids stopped ascending with time in OpenCode 1.18.15 — so an
684
+ // id-sorted insert dropped a brand-new SSE message into the MIDDLE of
685
+ // the transcript. See `insertIndexByTime`.
575
686
  const next = [...list];
576
- next.splice(result.index, 0, message);
687
+ next.splice(insertIndexByTime(next, message), 0, message);
577
688
  return { messages: { ...s.messages, [sessionID]: next } };
578
689
  }),
579
690
  removeMessage: (sessionID, messageID) => set((s) => {
@@ -739,16 +850,32 @@ export const useSyncStore = create()((set, get) => ({
739
850
  return { parts: { ...s.parts, [messageID]: next } };
740
851
  });
741
852
  },
742
- setStatus: (sessionID, status) => set((s) => {
853
+ setStatus: (sessionID, status, origin = "wire") => set((s) => {
743
854
  // A value that did not change is not news. `useSessionWorking` stamps
744
855
  // its stream observation from this object's IDENTITY, so an
745
856
  // equal-valued rewrite that minted a new object re-started
746
857
  // `STREAM_OBSERVATION_MAX_MS` from zero on every write — the bound
747
858
  // that stops a dead stream from deciding was never reached. See
748
859
  // `sameSessionStatus`.
749
- if (sameSessionStatus(s.sessionStatus[sessionID], status))
750
- return s;
751
- return { sessionStatus: { ...s.sessionStatus, [sessionID]: status } };
860
+ if (sameSessionStatus(s.sessionStatus[sessionID], status)) {
861
+ // The VALUE is not news, but who said it can be: a wire frame
862
+ // landing over a fabricated one (or the reverse) changes what the
863
+ // frame is allowed to decide. Update only the origin — the status
864
+ // object keeps its identity so nothing re-stamps a stale value.
865
+ // The arrival stamp DOES move: an origin flip is a new observation
866
+ // (`use-session-working` re-stamps on it for the same reason).
867
+ if ((s.sessionStatusOrigin[sessionID] ?? "wire") === origin)
868
+ return s;
869
+ return {
870
+ sessionStatusOrigin: { ...s.sessionStatusOrigin, [sessionID]: origin },
871
+ sessionStatusAt: { ...s.sessionStatusAt, [sessionID]: Date.now() },
872
+ };
873
+ }
874
+ return {
875
+ sessionStatus: { ...s.sessionStatus, [sessionID]: status },
876
+ sessionStatusOrigin: { ...s.sessionStatusOrigin, [sessionID]: origin },
877
+ sessionStatusAt: { ...s.sessionStatusAt, [sessionID]: Date.now() },
878
+ };
752
879
  }),
753
880
  setDiff: (sessionID, diffs) => set((s) => ({
754
881
  diffs: { ...s.diffs, [sessionID]: diffs },
@@ -779,12 +906,20 @@ export const useSyncStore = create()((set, get) => ({
779
906
  return s;
780
907
  return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
781
908
  }),
782
- applyCommittedRevert: (sessionId, boundaryId, watermark) => set((s) => {
909
+ applyCommittedRevert: (sessionId, boundaryId, watermark, hiddenIds) => set((s) => {
783
910
  const list = s.messages[sessionId];
784
911
  if (!list || list.length === 0) {
785
912
  return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
786
913
  }
787
- const rewind = { messageId: boundaryId, watermark, staged: false };
914
+ // `hiddenIds` is the set captured when the rewind was staged, and it
915
+ // is what decides. `watermark` only still answers for a caller that
916
+ // has no captured set — see `isWithinRewindWindow`.
917
+ const rewind = {
918
+ messageId: boundaryId,
919
+ watermark,
920
+ ...(hiddenIds ? { hiddenIds } : {}),
921
+ staged: false,
922
+ };
788
923
  const removedIds = [];
789
924
  const kept = list.filter((m) => {
790
925
  if (isWithinRewindWindow(m.id, rewind)) {
@@ -883,11 +1018,27 @@ export const useSyncStore = create()((set, get) => ({
883
1018
  get().removeMessage(sessionID, messageID);
884
1019
  },
885
1020
  registerOptimisticEcho: (sessionID, optimisticID, echoID) => {
886
- if (!isOptimistic(sessionID, optimisticID))
887
- return;
888
1021
  if (optimisticID === echoID)
889
1022
  return;
890
- recordOptimisticEcho(sessionID, optimisticID, echoID);
1023
+ // The caller always names the id the ROW reports as this prompt's origin
1024
+ // — its `wire_message_id`, the id this tab minted and painted. That id
1025
+ // stops being optimistic the moment the FIRST echo supersedes it, and
1026
+ // after a Stop takes the prompt back out of the runtime the bubble on
1027
+ // screen is that echo (`reclaimRemovedMessage` re-marks it optimistic),
1028
+ // not the wire id. Registering against the wire id alone therefore did
1029
+ // nothing on every second and later re-mint, and the drain re-mints on
1030
+ // every one of them: the re-delivery's echo then matched no alias, and
1031
+ // with more than one send in flight the ordinal fallback correctly
1032
+ // refuses to guess — so the echo was dropped and the prompt stayed on
1033
+ // screen as a bubble nothing would ever confirm.
1034
+ //
1035
+ // Follow the chain to the id that IS on screen and register from there.
1036
+ const live = isOptimistic(sessionID, optimisticID)
1037
+ ? optimisticID
1038
+ : optimisticEchoes.get(sessionID)?.get(optimisticID);
1039
+ if (!live || live === echoID || !isOptimistic(sessionID, live))
1040
+ return;
1041
+ recordOptimisticEcho(sessionID, live, echoID);
891
1042
  },
892
1043
  reclaimRemovedMessage: (sessionID, messageID) => {
893
1044
  // See `controlPlaneOwnedIds`: a removal of a message the control plane
@@ -933,6 +1084,18 @@ export const useSyncStore = create()((set, get) => ({
933
1084
  };
934
1085
  });
935
1086
  },
1087
+ noteSessionActivity: (sessionID, atMs) => {
1088
+ if (!sessionID)
1089
+ return;
1090
+ const now = atMs ?? Date.now();
1091
+ const previous = get().sessionActivityAt[sessionID];
1092
+ // Quantized: a busy runtime emits parts every few tens of milliseconds and
1093
+ // nothing reads this to the millisecond. Without the floor, every
1094
+ // subscriber re-renders at the stream's own rate for a timestamp.
1095
+ if (previous !== undefined && now - previous < ACTIVITY_STAMP_RESOLUTION_MS)
1096
+ return;
1097
+ set((s) => ({ sessionActivityAt: { ...s.sessionActivityAt, [sessionID]: now } }));
1098
+ },
936
1099
  clearOptimisticMessages: (sessionID) => {
937
1100
  set((s) => {
938
1101
  const list = s.messages[sessionID];
@@ -970,10 +1133,18 @@ export const useSyncStore = create()((set, get) => ({
970
1133
  for (const message of existingMessages)
971
1134
  delete nextParts[message.id];
972
1135
  deleteOrphanPartBuckets(nextParts, [sessionID]);
1136
+ const nextActivity = { ...s.sessionActivityAt };
1137
+ delete nextActivity[sessionID];
973
1138
  return {
974
1139
  messages: { ...s.messages, [sessionID]: [] },
975
1140
  parts: nextParts,
1141
+ sessionActivityAt: nextActivity,
976
1142
  sessionStatus: { ...s.sessionStatus, [sessionID]: { type: "idle" } },
1143
+ // Fabricated, not observed — a cache handoff says nothing about
1144
+ // whether the runtime is running. Marked local so it can never
1145
+ // veto the control plane's open turn (`WorkingStreamInput.origin`).
1146
+ sessionStatusOrigin: { ...s.sessionStatusOrigin, [sessionID]: "local" },
1147
+ sessionStatusAt: { ...s.sessionStatusAt, [sessionID]: Date.now() },
977
1148
  diffs: { ...s.diffs, [sessionID]: [] },
978
1149
  todos: { ...s.todos, [sessionID]: [] },
979
1150
  sessionRevert: { ...s.sessionRevert, [sessionID]: null },
@@ -1055,394 +1226,464 @@ export const useSyncStore = create()((set, get) => ({
1055
1226
  touchSessionMessageRows(sessionID, { msgs, partRefs, result });
1056
1227
  return result;
1057
1228
  },
1058
- hydrate: (sessionID, msgs, opts) => set((s) => {
1059
- // An authoritative load the disk repaint itself, or a reconcile
1060
- // re-establishes the session, so its entry is no longer a fragment.
1061
- evictedSessions.delete(sessionID);
1062
- const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1063
- const tombstones = cancelledMessageIds.get(sessionID);
1064
- const incoming = msgs
1065
- .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
1066
- .map((m) => m.info)
1067
- .sort((a, b) => cmp(a.id, b.id));
1068
- const fromCache = opts?.source === "cache";
1069
- if (fromCache) {
1070
- for (const m of incoming)
1071
- trackId(cacheSourcedIds, sessionID, m.id);
1072
- }
1073
- // A RUNTIME read settles the cache's provisional messages: the ones
1074
- // it contains are real; the ones it lacks but whose position it
1075
- // COVERS (at or above the oldest message it returned) never existed
1076
- // there and are dropped. Older ones are history the bounded tail did
1077
- // not reach kept, still provisional.
1078
- let droppedPhantoms = null;
1079
- const provisional = fromCache ? undefined : cacheSourcedIds.get(sessionID);
1080
- if (provisional && provisional.size > 0 && incoming.length > 0) {
1081
- const oldestIncoming = incoming[0].id;
1082
- const incomingIds = new Set(incoming.map((m) => m.id));
1083
- for (const id of [...provisional]) {
1084
- if (incomingIds.has(id)) {
1085
- untrackId(cacheSourcedIds, sessionID, id);
1086
- continue;
1087
- }
1088
- if (cmp(id, oldestIncoming) >= 0 && !isOptimistic(sessionID, id)) {
1089
- untrackId(cacheSourcedIds, sessionID, id);
1090
- (droppedPhantoms ??= new Set()).add(id);
1229
+ hydrate: (sessionID, msgs, opts) => {
1230
+ // Set inside the updater below when a RUNTIME read shows the transcript
1231
+ // moved while its tail is still open the runtime's own output reaching
1232
+ // this tab by pull instead of push. Stamped after the set() commits.
1233
+ let stampRuntimeActivity = false;
1234
+ set((s) => {
1235
+ // An authoritative load — the disk repaint itself, or a reconcile —
1236
+ // re-establishes the session, so its entry is no longer a fragment.
1237
+ evictedSessions.delete(sessionID);
1238
+ const tombstones = cancelledMessageIds.get(sessionID);
1239
+ // NOT re-sorted. `MessageV2.page()` orders by `time_created`
1240
+ // server-side and always has, so the snapshot arrives in the only
1241
+ // order that is true. Re-deriving one from id strings was wrong the
1242
+ // moment OpenCode 1.18.15 retired "ids ascend with time" — and the
1243
+ // mobile store did the same re-sort with `localeCompare`, which is
1244
+ // not even byte order, so the two hosts disagreed on identical data.
1245
+ const incoming = msgs
1246
+ .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
1247
+ .map((m) => m.info);
1248
+ const fromCache = opts?.source === "cache";
1249
+ if (fromCache) {
1250
+ for (const m of incoming)
1251
+ trackId(cacheSourcedIds, sessionID, m.id);
1252
+ }
1253
+ // A RUNTIME read settles the cache's provisional messages: the ones
1254
+ // it contains are real; the ones it lacks but whose position it
1255
+ // COVERS (at or above the oldest message it returned) never existed
1256
+ // there and are dropped. Older ones are history the bounded tail did
1257
+ // not reach — kept, still provisional.
1258
+ //
1259
+ // The bound is derived by VALUE, not by position: `incoming` is now the
1260
+ // server's own page order, which is not an id order, so `incoming[0].id`
1261
+ // is no longer the smallest id in the page. (That this comparison is an
1262
+ // id comparison at all is a separate open question — see B-note in
1263
+ // deliberately left as-is here.)
1264
+ let droppedPhantoms = null;
1265
+ const provisional = fromCache ? undefined : cacheSourcedIds.get(sessionID);
1266
+ if (provisional && provisional.size > 0 && incoming.length > 0) {
1267
+ let oldestIncoming = incoming[0].id;
1268
+ for (const m of incoming)
1269
+ if (m.id < oldestIncoming)
1270
+ oldestIncoming = m.id;
1271
+ const incomingIds = new Set(incoming.map((m) => m.id));
1272
+ for (const id of [...provisional]) {
1273
+ if (incomingIds.has(id)) {
1274
+ untrackId(cacheSourcedIds, sessionID, id);
1275
+ continue;
1276
+ }
1277
+ if (id >= oldestIncoming && !isOptimistic(sessionID, id)) {
1278
+ untrackId(cacheSourcedIds, sessionID, id);
1279
+ (droppedPhantoms ??= new Set()).add(id);
1280
+ }
1091
1281
  }
1092
1282
  }
1093
- }
1094
- // T16 a `session.error` stub assistant message is reconciled
1095
- // PER TURN, after the optimistic correlation below has run (see
1096
- // `pendingStubs`). It only ever stood in for the assistant message
1097
- // its own turn never produced, so the question is not "does this
1098
- // session have an assistant message" an earlier turn's answer is
1099
- // no answer at all but "does the server now answer THAT turn".
1100
- // Re-adopt a stub that arrives IN the snapshot. The transcript cache
1101
- // mirrors `messages[sessionID]` to IndexedDB, stubs included, so the
1102
- // first paint after a reload brings one back — while
1103
- // `stubAssistantIds` (module state) did not survive the reload. An
1104
- // unadopted stub is unreconcilable: it would sit beside the server's
1105
- // own reply forever. Only the runtime ever produces these ids, so
1106
- // nothing else can be mistaken for one.
1107
- for (const info of incoming) {
1108
- if (info.role !== "assistant" || !isStubShaped(info.id))
1109
- continue;
1110
- if (!info.error)
1111
- continue;
1112
- if (stubAssistantIds.get(sessionID)?.has(info.id))
1113
- continue;
1114
- trackStub(sessionID, info.id, info.parentID ?? null);
1115
- }
1116
- const trackedStubIds = stubAssistantIds.get(sessionID);
1117
- /** Stubs held back from the merge below until their parent turn is
1118
- * known (an optimistic parent may be superseded by the server's own
1119
- * copy in this very snapshot). */
1120
- const pendingStubs = [];
1121
- // Merge incoming messages with existing ones — never delete messages
1122
- // that exist in the sync store but are missing from the fetch (they may
1123
- // be from a newer turn that the server hasn't persisted yet).
1124
- const existingAll = s.messages[sessionID] ?? [];
1125
- const existing = droppedPhantoms
1126
- ? existingAll.filter((m) => !droppedPhantoms.has(m.id))
1127
- : existingAll;
1128
- const merged = [];
1129
- const seen = new Set();
1130
- // Which optimistic user messages has the server actually echoed?
1131
- //
1132
- // This test used to be EXISTENTIAL: "if this page holds any real user
1133
- // message at all, every optimistic user message is a duplicate". In an
1134
- // ongoing conversation that is always true — the page is full of
1135
- // earlier turns — so a message sent one second ago, which the server
1136
- // had provably never received, was deleted along with its text. The
1137
- // window was wide open by construction: the rehydrate that calls this
1138
- // only runs for sessions whose status is `busy`, and the optimistic
1139
- // add is what sets `busy`. Sending armed the thing that deleted the
1140
- // send.
1141
- //
1142
- // The rule is now identity-based, in two passes.
1143
- const existingIds = new Set(existing.map((m) => m.id));
1144
- // The echo may arrive under the optimistic message's OWN id: the host
1145
- // minted the wire id and painted the bubble with it. That is a
1146
- // confirmation, not a duplicate — release the marks so the message is
1147
- // ordinary from here (the sweep leaves it alone, real parts win), and
1148
- // bridge its optimistic parts until real ones land.
1149
- for (const m of incoming) {
1150
- if (m.role !== "user" || !isOptimistic(sessionID, m.id))
1151
- continue;
1152
- releaseConfirmedOptimisticId(sessionID, m.id, m.id);
1153
- const optimisticParts = s.parts[m.id];
1154
- const entry = msgs.find((x) => x.info.id === m.id);
1155
- if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
1156
- trackId(bridgedPartIds, sessionID, m.id);
1283
+ // T16 — a `session.error` stub assistant message is reconciled
1284
+ // PER TURN, after the optimistic correlation below has run (see
1285
+ // `pendingStubs`). It only ever stood in for the assistant message
1286
+ // its own turn never produced, so the question is not "does this
1287
+ // session have an assistant message" an earlier turn's answer is
1288
+ // no answer at all — but "does the server now answer THAT turn".
1289
+ // Re-adopt a stub that arrives IN the snapshot. The transcript cache
1290
+ // mirrors `messages[sessionID]` to IndexedDB, stubs included, so the
1291
+ // first paint after a reload brings one back — while
1292
+ // `stubAssistantIds` (module state) did not survive the reload. An
1293
+ // unadopted stub is unreconcilable: it would sit beside the server's
1294
+ // own reply forever. Only the runtime ever produces these ids, so
1295
+ // nothing else can be mistaken for one.
1296
+ for (const info of incoming) {
1297
+ if (info.role !== "assistant" || !isStubShaped(info.id))
1298
+ continue;
1299
+ if (!info.error)
1300
+ continue;
1301
+ if (stubAssistantIds.get(sessionID)?.has(info.id))
1302
+ continue;
1303
+ trackStub(sessionID, info.id, info.parentID ?? null);
1157
1304
  }
1158
- }
1159
- // Pass 0 candidate echoes. A real user message we have NEVER seen
1160
- // before may be the server's copy of something still in flight. One we
1161
- // already held is history, and history cannot supersede the present.
1162
- // This single condition is what fixes the reported bug.
1163
- const candidateEchoes = incoming.filter((m) => m.role === "user" &&
1164
- !isOptimistic(sessionID, m.id) &&
1165
- !existingIds.has(m.id));
1166
- const unclaimedEchoes = new Set(candidateEchoes.map((m) => m.id));
1167
- // Pass 1 — exact correlation by part id. Hosts generate the text part
1168
- // id up front and send it WITH the prompt precisely so the echo
1169
- // updates the same part, which makes this an identity match rather
1170
- // than a guess.
1171
- const echoByPartId = new Map();
1172
- for (const echo of candidateEchoes) {
1173
- const entry = msgs.find((m) => m.info.id === echo.id);
1174
- for (const p of entry?.parts ?? []) {
1175
- if (p?.id)
1176
- echoByPartId.set(p.id, echo.id);
1305
+ const trackedStubIds = stubAssistantIds.get(sessionID);
1306
+ /** Stubs held back from the merge below until their parent turn is
1307
+ * known (an optimistic parent may be superseded by the server's own
1308
+ * copy in this very snapshot). */
1309
+ const pendingStubs = [];
1310
+ // Merge incoming messages with existing ones — never delete messages
1311
+ // that exist in the sync store but are missing from the fetch (they may
1312
+ // be from a newer turn that the server hasn't persisted yet).
1313
+ const existingAll = s.messages[sessionID] ?? [];
1314
+ const existing = droppedPhantoms
1315
+ ? existingAll.filter((m) => !droppedPhantoms.has(m.id))
1316
+ : existingAll;
1317
+ const merged = [];
1318
+ const seen = new Set();
1319
+ // Which optimistic user messages has the server actually echoed?
1320
+ //
1321
+ // This test used to be EXISTENTIAL: "if this page holds any real user
1322
+ // message at all, every optimistic user message is a duplicate". In an
1323
+ // ongoing conversation that is always true — the page is full of
1324
+ // earlier turns — so a message sent one second ago, which the server
1325
+ // had provably never received, was deleted along with its text. The
1326
+ // window was wide open by construction: the rehydrate that calls this
1327
+ // only runs for sessions whose status is `busy`, and the optimistic
1328
+ // add is what sets `busy`. Sending armed the thing that deleted the
1329
+ // send.
1330
+ //
1331
+ // The rule is now identity-based, in two passes.
1332
+ const existingIds = new Set(existing.map((m) => m.id));
1333
+ // ---- Runtime-read activity evidence. ----
1334
+ // Movement on a transcript this tab ALREADY held, with the tail still
1335
+ // open, observed by a runtime read: the runtime is producing output
1336
+ // right now, whatever the stream's last status frame claimed. Guards,
1337
+ // each load-bearing (asserted in the test file): an initial fill is
1338
+ // history, not movement; a completed tail is a finished turn and must
1339
+ // not paint busy on a returning tab; a cache repaint is this tab's own
1340
+ // disk, not the runtime speaking.
1341
+ if (!fromCache && existingAll.length > 0) {
1342
+ const tail = incoming[incoming.length - 1];
1343
+ const tailOpen = !!tail &&
1344
+ tail.role === "assistant" &&
1345
+ !tail.time?.completed &&
1346
+ !tail.error;
1347
+ if (tailOpen) {
1348
+ stampRuntimeActivity =
1349
+ incoming.some((m) => !existingIds.has(m.id)) ||
1350
+ msgs.some((entry) => {
1351
+ const mid = entry?.info?.id;
1352
+ if (!mid || isOptimistic(sessionID, mid) || !existingIds.has(mid)) {
1353
+ return false;
1354
+ }
1355
+ const prev = s.parts[mid];
1356
+ if (!prev || prev.length === 0)
1357
+ return (entry.parts?.length ?? 0) > 0;
1358
+ const prevById = new Map(prev.map((p) => [p.id, p]));
1359
+ return (entry.parts ?? []).some((p) => {
1360
+ if (!p?.id)
1361
+ return false;
1362
+ const prevPart = prevById.get(p.id);
1363
+ if (!prevPart)
1364
+ return true;
1365
+ return (isTextLikePart(p) &&
1366
+ isTextLikePart(prevPart) &&
1367
+ (p.text?.length ?? 0) > (prevPart.text?.length ?? 0));
1368
+ });
1369
+ });
1370
+ }
1177
1371
  }
1178
- }
1179
- // Start with all incoming messages
1180
- for (const m of incoming) {
1181
- merged.push(m);
1182
- seen.add(m.id);
1183
- }
1184
- // Add any existing messages not in incoming (optimistic or from live SSE).
1185
- // Optimistic messages go at the end to avoid clock-skew sorting issues;
1186
- // non-optimistic ones are inserted at their sorted position.
1187
- const deferredOptimistic = [];
1188
- const supersededOptimistic = [];
1189
- /** optimistic id → the incoming message that superseded it. */
1190
- const supersededBy = new Map();
1191
- const unmatchedOptimisticUsers = [];
1192
- for (const m of existing) {
1193
- if (!seen.has(m.id)) {
1194
- if (trackedStubIds?.has(m.id)) {
1195
- // Placed (or dropped) after the correlation passes below,
1196
- // because where it belongs depends on what happened to the
1197
- // user message it is parented to.
1198
- pendingStubs.push(m);
1372
+ // The echo may arrive under the optimistic message's OWN id: the host
1373
+ // minted the wire id and painted the bubble with it. That is a
1374
+ // confirmation, not a duplicate — release the marks so the message is
1375
+ // ordinary from here (the sweep leaves it alone, real parts win), and
1376
+ // bridge its optimistic parts until real ones land.
1377
+ for (const m of incoming) {
1378
+ if (m.role !== "user" || !isOptimistic(sessionID, m.id))
1199
1379
  continue;
1380
+ releaseConfirmedOptimisticId(sessionID, m.id, m.id);
1381
+ const optimisticParts = s.parts[m.id];
1382
+ const entry = msgs.find((x) => x.info.id === m.id);
1383
+ if ((entry?.parts?.length ?? 0) === 0 && optimisticParts?.length) {
1384
+ trackId(bridgedPartIds, sessionID, m.id);
1200
1385
  }
1201
- if (isOptimistic(sessionID, m.id)) {
1202
- if (m.role !== "user") {
1203
- deferredOptimistic.push(m);
1386
+ }
1387
+ // Pass 0 — candidate echoes. A real user message we have NEVER seen
1388
+ // before may be the server's copy of something still in flight. One we
1389
+ // already held is history, and history cannot supersede the present.
1390
+ // This single condition is what fixes the reported bug.
1391
+ const candidateEchoes = incoming.filter((m) => m.role === "user" &&
1392
+ !isOptimistic(sessionID, m.id) &&
1393
+ !existingIds.has(m.id));
1394
+ const unclaimedEchoes = new Set(candidateEchoes.map((m) => m.id));
1395
+ // Pass 1 — exact correlation by part id. Hosts generate the text part
1396
+ // id up front and send it WITH the prompt precisely so the echo
1397
+ // updates the same part, which makes this an identity match rather
1398
+ // than a guess.
1399
+ const echoByPartId = new Map();
1400
+ for (const echo of candidateEchoes) {
1401
+ const entry = msgs.find((m) => m.info.id === echo.id);
1402
+ for (const p of entry?.parts ?? []) {
1403
+ if (p?.id)
1404
+ echoByPartId.set(p.id, echo.id);
1405
+ }
1406
+ }
1407
+ // Start with all incoming messages
1408
+ for (const m of incoming) {
1409
+ merged.push(m);
1410
+ seen.add(m.id);
1411
+ }
1412
+ // Add any existing messages not in incoming (optimistic or from live SSE).
1413
+ // Optimistic messages go at the end to avoid clock-skew sorting issues;
1414
+ // non-optimistic ones are inserted at their sorted position.
1415
+ const deferredOptimistic = [];
1416
+ const supersededOptimistic = [];
1417
+ /** optimistic id → the incoming message that superseded it. */
1418
+ const supersededBy = new Map();
1419
+ const unmatchedOptimisticUsers = [];
1420
+ for (const m of existing) {
1421
+ if (!seen.has(m.id)) {
1422
+ if (trackedStubIds?.has(m.id)) {
1423
+ // Placed (or dropped) after the correlation passes below,
1424
+ // because where it belongs depends on what happened to the
1425
+ // user message it is parented to.
1426
+ pendingStubs.push(m);
1204
1427
  continue;
1205
1428
  }
1206
- const echoId = (s.parts[m.id] ?? [])
1207
- .map((p) => echoByPartId.get(p.id))
1208
- .find((id) => !!id);
1209
- if (echoId) {
1210
- supersededOptimistic.push(m.id);
1211
- supersededBy.set(m.id, echoId);
1212
- unclaimedEchoes.delete(echoId);
1429
+ if (isOptimistic(sessionID, m.id)) {
1430
+ if (m.role !== "user") {
1431
+ deferredOptimistic.push(m);
1432
+ continue;
1433
+ }
1434
+ const echoId = (s.parts[m.id] ?? [])
1435
+ .map((p) => echoByPartId.get(p.id))
1436
+ .find((id) => !!id);
1437
+ if (echoId) {
1438
+ supersededOptimistic.push(m.id);
1439
+ supersededBy.set(m.id, echoId);
1440
+ unclaimedEchoes.delete(echoId);
1441
+ }
1442
+ else {
1443
+ // Undecided until pass 2 — it may still pair with an
1444
+ // echo whose parts the server has not persisted yet.
1445
+ unmatchedOptimisticUsers.push(m);
1446
+ }
1213
1447
  }
1214
1448
  else {
1215
- // Undecided until pass 2 it may still pair with an
1216
- // echo whose parts the server has not persisted yet.
1217
- unmatchedOptimisticUsers.push(m);
1449
+ // By TIME, not by a binary index over ids the snapshot never
1450
+ // ordered that way see `insertIndexByTime`.
1451
+ merged.splice(insertIndexByTime(merged, m), 0, m);
1218
1452
  }
1219
1453
  }
1220
- else {
1221
- const r = Binary.search(merged, m.id, (x) => x.id);
1222
- merged.splice(r.index, 0, m);
1223
- }
1224
- }
1225
- }
1226
- // Pass 2 — ordinal fallback, for an echo the server accepted but whose
1227
- // parts have not landed yet (no part ids to match on). Restricted to
1228
- // DISPATCHED messages: one that has not been POSTed cannot be a
1229
- // duplicate of anything the server holds, so it is never eligible.
1230
- // That restriction is what keeps a message sent from another tab from
1231
- // consuming this tab's in-flight bubble.
1232
- const claimed = new Set();
1233
- for (const m of unmatchedOptimisticUsers) {
1234
- // Alias first: the inbox row announced this message's echo id.
1235
- const alias = optimisticEchoes.get(sessionID)?.get(m.id);
1236
- if (alias && unclaimedEchoes.has(alias) && !claimed.has(alias)) {
1237
- claimed.add(alias);
1238
- supersededOptimistic.push(m.id);
1239
- supersededBy.set(m.id, alias);
1240
1454
  }
1241
- }
1242
- const claimable = candidateEchoes.filter((m) => unclaimedEchoes.has(m.id) && !claimed.has(m.id));
1243
- let next = 0;
1244
- for (const m of unmatchedOptimisticUsers) {
1245
- if (supersededBy.has(m.id))
1246
- continue;
1247
- const echo = isDispatched(sessionID, m.id) &&
1248
- // Known-different echo never consume someone else's.
1249
- !optimisticEchoes.get(sessionID)?.get(m.id)
1250
- ? claimable[next]
1251
- : undefined;
1252
- if (echo) {
1253
- next += 1;
1254
- supersededOptimistic.push(m.id);
1255
- supersededBy.set(m.id, echo.id);
1455
+ // Pass 2 — ordinal fallback, for an echo the server accepted but whose
1456
+ // parts have not landed yet (no part ids to match on). Restricted to
1457
+ // DISPATCHED messages: one that has not been POSTed cannot be a
1458
+ // duplicate of anything the server holds, so it is never eligible.
1459
+ // That restriction is what keeps a message sent from another tab from
1460
+ // consuming this tab's in-flight bubble.
1461
+ const claimed = new Set();
1462
+ for (const m of unmatchedOptimisticUsers) {
1463
+ // Alias first: the inbox row announced this message's echo id.
1464
+ const alias = optimisticEchoes.get(sessionID)?.get(m.id);
1465
+ if (alias && unclaimedEchoes.has(alias) && !claimed.has(alias)) {
1466
+ claimed.add(alias);
1467
+ supersededOptimistic.push(m.id);
1468
+ supersededBy.set(m.id, alias);
1469
+ }
1256
1470
  }
1257
- else {
1258
- deferredOptimistic.push(m);
1471
+ const claimable = candidateEchoes.filter((m) => unclaimedEchoes.has(m.id) && !claimed.has(m.id));
1472
+ let next = 0;
1473
+ for (const m of unmatchedOptimisticUsers) {
1474
+ if (supersededBy.has(m.id))
1475
+ continue;
1476
+ const echo = isDispatched(sessionID, m.id) &&
1477
+ // Known-different echo → never consume someone else's.
1478
+ !optimisticEchoes.get(sessionID)?.get(m.id)
1479
+ ? claimable[next]
1480
+ : undefined;
1481
+ if (echo) {
1482
+ next += 1;
1483
+ supersededOptimistic.push(m.id);
1484
+ supersededBy.set(m.id, echo.id);
1485
+ }
1486
+ else {
1487
+ deferredOptimistic.push(m);
1488
+ }
1259
1489
  }
1260
- }
1261
- // Clean up superseded optimistic IDs, remembering which runtime id
1262
- // each one became.
1263
- for (const id of supersededOptimistic) {
1264
- const echoId = supersededBy.get(id);
1265
- if (echoId) {
1266
- releaseConfirmedOptimisticId(sessionID, id, echoId);
1267
- recordOptimisticEcho(sessionID, id, echoId);
1490
+ // Clean up superseded optimistic IDs, remembering which runtime id
1491
+ // each one became.
1492
+ for (const id of supersededOptimistic) {
1493
+ const echoId = supersededBy.get(id);
1494
+ if (echoId) {
1495
+ releaseConfirmedOptimisticId(sessionID, id, echoId);
1496
+ recordOptimisticEcho(sessionID, id, echoId);
1497
+ }
1498
+ else {
1499
+ releaseOptimisticId(sessionID, id);
1500
+ }
1268
1501
  }
1269
- else {
1270
- releaseOptimisticId(sessionID, id);
1502
+ // Append surviving optimistic messages at the end
1503
+ for (const m of deferredOptimistic) {
1504
+ merged.push(m);
1271
1505
  }
1272
- }
1273
- // Append surviving optimistic messages at the end
1274
- for (const m of deferredOptimistic) {
1275
- merged.push(m);
1276
- }
1277
- // Now place (or retire) each `session.error` stub, with its turn
1278
- // finally settled. Three outcomes, in order:
1279
- //
1280
- // - its user message was superseded by the server's own copy in this
1281
- // snapshot the stub follows it, re-keyed onto the real id;
1282
- // - the server now holds a real assistant message for that turn →
1283
- // the stub was a stand-in for exactly that message, so it goes;
1284
- // - otherwise it stays, directly after its user message. This is
1285
- // the case the reported bug turned on: the `reconcileTail` that
1286
- // every `session.error` triggers returns a transcript with NO
1287
- // reply for the failed turn, and dropping the stub there left the
1288
- // user staring at their own prompt and nothing else.
1289
- for (const stub of pendingStubs) {
1290
- const trackedParent = trackedStubIds?.get(stub.id) ?? null;
1291
- const parentId = (trackedParent && supersededBy.get(trackedParent)) || trackedParent;
1292
- untrackStub(sessionID, stub.id);
1293
- // The server DOES hold a reply for this turn — the stub stood in
1294
- // for exactly that message, so it goes. Its error moves onto the
1295
- // real message first, unless the server's copy carries one of its
1296
- // own: `session.error` is the runtime's report of THIS turn
1297
- // failing, and the transcript read that follows it does not always
1298
- // carry the failure back (the same race
1299
- // `use-opencode-events`'s cache patch exists for). Dropping the
1300
- // stub against an error-free server copy would erase the only
1301
- // evidence the turn failed.
1302
- if (hasAssistantForTurn(incoming, parentId)) {
1303
- const replyIdx = merged.findIndex((m) => m.role === "assistant" &&
1304
- !isStubShaped(m.id) &&
1305
- (!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
1306
- const reply = replyIdx === -1 ? undefined : merged[replyIdx];
1307
- const stubError = stub.error;
1308
- if (reply && !reply.error && stubError) {
1309
- merged[replyIdx] = { ...reply, error: stubError };
1506
+ // Now place (or retire) each `session.error` stub, with its turn
1507
+ // finally settled. Three outcomes, in order:
1508
+ //
1509
+ // - its user message was superseded by the server's own copy in this
1510
+ // snapshot → the stub follows it, re-keyed onto the real id;
1511
+ // - the server now holds a real assistant message for that turn
1512
+ // the stub was a stand-in for exactly that message, so it goes;
1513
+ // - otherwise → it stays, directly after its user message. This is
1514
+ // the case the reported bug turned on: the `reconcileTail` that
1515
+ // every `session.error` triggers returns a transcript with NO
1516
+ // reply for the failed turn, and dropping the stub there left the
1517
+ // user staring at their own prompt and nothing else.
1518
+ for (const stub of pendingStubs) {
1519
+ const trackedParent = trackedStubIds?.get(stub.id) ?? null;
1520
+ const parentId = (trackedParent && supersededBy.get(trackedParent)) || trackedParent;
1521
+ untrackStub(sessionID, stub.id);
1522
+ // The server DOES hold a reply for this turn — the stub stood in
1523
+ // for exactly that message, so it goes. Its error moves onto the
1524
+ // real message first, unless the server's copy carries one of its
1525
+ // own: `session.error` is the runtime's report of THIS turn
1526
+ // failing, and the transcript read that follows it does not always
1527
+ // carry the failure back (the same race
1528
+ // `use-opencode-events`'s cache patch exists for). Dropping the
1529
+ // stub against an error-free server copy would erase the only
1530
+ // evidence the turn failed.
1531
+ if (hasAssistantForTurn(incoming, parentId)) {
1532
+ const replyIdx = findAssistantForTurn(merged, parentId);
1533
+ const reply = replyIdx === -1 ? undefined : merged[replyIdx];
1534
+ const stubError = stub.error;
1535
+ if (reply && !reply.error && stubError) {
1536
+ merged[replyIdx] = { ...reply, error: stubError };
1537
+ }
1538
+ continue;
1539
+ }
1540
+ const stubId = parentId ? stubIdFor(parentId) : stub.id;
1541
+ const placed = stubId === stub.id && stub.parentID === (parentId ?? undefined)
1542
+ ? stub
1543
+ : { ...stub, id: stubId, ...(parentId ? { parentID: parentId } : {}) };
1544
+ trackStub(sessionID, stubId, parentId);
1545
+ const parentIdx = parentId ? merged.findIndex((m) => m.id === parentId) : -1;
1546
+ if (parentIdx !== -1) {
1547
+ merged.splice(parentIdx + 1, 0, placed);
1548
+ continue;
1549
+ }
1550
+ // Its user message is not in the transcript at all. Linear on a
1551
+ // miss so a re-keyed stub never lands twice (`stubIdFor(parentId)`
1552
+ // can name a stub already merged), and by time otherwise — a stub
1553
+ // carries no `time`, so it goes last.
1554
+ if (indexOfId(merged, placed.id, (x) => x.id) === -1) {
1555
+ merged.splice(insertIndexByTime(merged, placed), 0, placed);
1310
1556
  }
1311
- continue;
1312
- }
1313
- const stubId = parentId ? stubIdFor(parentId) : stub.id;
1314
- const placed = stubId === stub.id && stub.parentID === (parentId ?? undefined)
1315
- ? stub
1316
- : { ...stub, id: stubId, ...(parentId ? { parentID: parentId } : {}) };
1317
- trackStub(sessionID, stubId, parentId);
1318
- const parentIdx = parentId ? merged.findIndex((m) => m.id === parentId) : -1;
1319
- if (parentIdx !== -1) {
1320
- merged.splice(parentIdx + 1, 0, placed);
1321
- continue;
1322
1557
  }
1323
- const r = Binary.search(merged, placed.id, (x) => x.id);
1324
- if (!r.found)
1325
- merged.splice(r.index, 0, placed);
1326
- }
1327
- // Merge parts: for each message, reconcile by part ID.
1328
- // If a message is optimistic (still in optimisticIds), keep existing
1329
- // parts entirely — they're from the client and shouldn't be overwritten.
1330
- // Otherwise, incoming parts win (server is authoritative), but keep
1331
- // any extra parts from SSE that aren't in the fetch response.
1332
- const newParts = { ...s.parts };
1333
- if (droppedPhantoms)
1334
- for (const id of droppedPhantoms)
1558
+ // Merge parts: for each message, reconcile by part ID.
1559
+ // If a message is optimistic (still in optimisticIds), keep existing
1560
+ // parts entirely — they're from the client and shouldn't be overwritten.
1561
+ // Otherwise, incoming parts win (server is authoritative), but keep
1562
+ // any extra parts from SSE that aren't in the fetch response.
1563
+ const newParts = { ...s.parts };
1564
+ if (droppedPhantoms)
1565
+ for (const id of droppedPhantoms)
1566
+ delete newParts[id];
1567
+ // When superseding an optimistic user message, bridge its parts to
1568
+ // the real user message ID if the server hasn't sent parts yet.
1569
+ // Mirrors the message.updated SSE handler (see above) without
1570
+ // this, a fetch/hydrate that races ahead of parts persistence
1571
+ // would drop the user's text and render an empty bubble.
1572
+ // Bridge along the pairing established above, not to "whichever real
1573
+ // user message came first". With more than one message in flight the
1574
+ // first-match version could graft one message's text onto another's
1575
+ // bubble; `supersededBy` names the exact echo each optimistic message
1576
+ // lost to.
1577
+ for (const id of supersededOptimistic) {
1578
+ const echoId = supersededBy.get(id);
1579
+ const bridge = newParts[id];
1335
1580
  delete newParts[id];
1336
- // When superseding an optimistic user message, bridge its parts to
1337
- // the real user message ID if the server hasn't sent parts yet.
1338
- // Mirrors the message.updated SSE handler (see above) — without
1339
- // this, a fetch/hydrate that races ahead of parts persistence
1340
- // would drop the user's text and render an empty bubble.
1341
- // Bridge along the pairing established above, not to "whichever real
1342
- // user message came first". With more than one message in flight the
1343
- // first-match version could graft one message's text onto another's
1344
- // bubble; `supersededBy` names the exact echo each optimistic message
1345
- // lost to.
1346
- for (const id of supersededOptimistic) {
1347
- const echoId = supersededBy.get(id);
1348
- const bridge = newParts[id];
1349
- delete newParts[id];
1350
- if (!echoId || !bridge?.length)
1351
- continue;
1352
- const echoEntry = msgs.find((m) => m.info.id === echoId);
1353
- const serverHasParts = (echoEntry?.parts?.length ?? 0) > 0;
1354
- if (serverHasParts || newParts[echoId]?.length)
1355
- continue;
1356
- newParts[echoId] = bridge;
1357
- trackId(bridgedPartIds, sessionID, echoId);
1358
- }
1359
- for (const m of msgs) {
1360
- if (!m?.info?.id)
1361
- continue;
1362
- const mid = m.info.id;
1363
- if (isOptimistic(sessionID, mid))
1364
- continue; // Don't touch optimistic parts
1365
- const inParts = m.parts
1366
- .filter((p) => !!p?.id)
1367
- .sort((a, b) => cmp(a.id, b.id));
1368
- // If this message still carries bridged optimistic parts, a hydrate
1369
- // snapshot with real parts should replace them immediately. Otherwise
1370
- // reconcile-by-extras can keep both copies and duplicate user text.
1371
- if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
1372
- untrackId(bridgedPartIds, sessionID, mid);
1373
- newParts[mid] = inParts;
1374
- continue;
1375
- }
1376
- const exParts = newParts[mid];
1377
- if (!exParts || exParts.length === 0) {
1378
- newParts[mid] = inParts;
1379
- continue;
1581
+ if (!echoId || !bridge?.length)
1582
+ continue;
1583
+ const echoEntry = msgs.find((m) => m.info.id === echoId);
1584
+ const serverHasParts = (echoEntry?.parts?.length ?? 0) > 0;
1585
+ if (serverHasParts || newParts[echoId]?.length)
1586
+ continue;
1587
+ newParts[echoId] = bridge;
1588
+ trackId(bridgedPartIds, sessionID, echoId);
1380
1589
  }
1381
- // Reconcile by key: incoming parts are generally authoritative,
1382
- // but for text/reasoning parts during active streaming, SSE-accumulated
1383
- // parts may have MORE content than the server snapshot (the
1384
- // server may return empty/stale text for in-progress parts).
1385
- // In that case, prefer the existing (SSE) version.
1386
- const exById = new Map(exParts.map((p) => [p.id, p]));
1387
- const inIds = new Set(inParts.map((p) => p.id));
1388
- const extras = exParts.filter((p) => !inIds.has(p.id));
1389
- const reconciled = inParts.map((inP) => {
1390
- const exP = exById.get(inP.id);
1391
- if (!exP)
1590
+ for (const m of msgs) {
1591
+ if (!m?.info?.id)
1592
+ continue;
1593
+ const mid = m.info.id;
1594
+ if (isOptimistic(sessionID, mid))
1595
+ continue; // Don't touch optimistic parts
1596
+ // Parts, not messages: this sort is untouched by the message-order
1597
+ // work and keeps its own byte-order comparison.
1598
+ const inParts = m.parts
1599
+ .filter((p) => !!p?.id)
1600
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1601
+ // If this message still carries bridged optimistic parts, a hydrate
1602
+ // snapshot with real parts should replace them immediately. Otherwise
1603
+ // reconcile-by-extras can keep both copies and duplicate user text.
1604
+ if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
1605
+ untrackId(bridgedPartIds, sessionID, mid);
1606
+ newParts[mid] = inParts;
1607
+ continue;
1608
+ }
1609
+ const exParts = newParts[mid];
1610
+ if (!exParts || exParts.length === 0) {
1611
+ newParts[mid] = inParts;
1612
+ continue;
1613
+ }
1614
+ // Reconcile by key: incoming parts are generally authoritative,
1615
+ // but for text/reasoning parts during active streaming, SSE-accumulated
1616
+ // parts may have MORE content than the server snapshot (the
1617
+ // server may return empty/stale text for in-progress parts).
1618
+ // In that case, prefer the existing (SSE) version.
1619
+ const exById = new Map(exParts.map((p) => [p.id, p]));
1620
+ const inIds = new Set(inParts.map((p) => p.id));
1621
+ const extras = exParts.filter((p) => !inIds.has(p.id));
1622
+ const reconciled = inParts.map((inP) => {
1623
+ const exP = exById.get(inP.id);
1624
+ if (!exP)
1625
+ return inP;
1626
+ // For text/reasoning parts: prefer whichever has more text content.
1627
+ // This prevents hydrate from clobbering SSE-streamed content
1628
+ // with an empty/stale server snapshot during active streaming.
1629
+ if (isTextLikePart(inP) &&
1630
+ isTextLikePart(exP) &&
1631
+ exP.text.length > inP.text.length) {
1632
+ return exP;
1633
+ }
1392
1634
  return inP;
1393
- // For text/reasoning parts: prefer whichever has more text content.
1394
- // This prevents hydrate from clobbering SSE-streamed content
1395
- // with an empty/stale server snapshot during active streaming.
1396
- if (isTextLikePart(inP) &&
1397
- isTextLikePart(exP) &&
1398
- exP.text.length > inP.text.length) {
1399
- return exP;
1635
+ });
1636
+ // T16 dedupe extras by content identity. An "extra" is an
1637
+ // existing part whose id the incoming snapshot no longer has — the
1638
+ // server may simply not have persisted it yet (kept, as before), OR
1639
+ // the server RE-ISSUED the same content under a NEW part id (a real
1640
+ // defect: the old SSE-accumulated twin stayed in `exParts` forever,
1641
+ // duplicating the text inside this one message). Distinguish the two
1642
+ // conservatively: drop an extra only when it is text-like AND its own
1643
+ // accumulated text is a PREFIX of (or equal to) some incoming
1644
+ // text-like part of the SAME type — i.e. the incoming copy confidently
1645
+ // re-issues it, not merely resembles it. Non-text-like extras (tool,
1646
+ // permission, file, step, …) are never dropped by content — they carry
1647
+ // distinct identity per id and a coincidental text match doesn't apply
1648
+ // to them at all.
1649
+ //
1650
+ // F1 review finding: an extra still tracked in `deltaActiveParts` (this
1651
+ // session is actively applying deltas to it right now) is EXEMPT from
1652
+ // this filter regardless of what it prefixes. The heuristic above
1653
+ // assumes a text-prefix match means the server re-issued the SAME
1654
+ // content under a new id and the extra is an abandoned twin — but a
1655
+ // live streaming target is never abandoned, and dropping it here also
1656
+ // permanently blocks its later deltas (their event ids would already
1657
+ // be recorded as applied — see `applyPartDelta`'s not-found path).
1658
+ const survivingExtras = extras.filter((extra) => {
1659
+ if (hasTrackedId(deltaActiveParts, sessionID, extra.id))
1660
+ return true;
1661
+ if (!isTextLikePart(extra) || extra.text.length === 0)
1662
+ return true;
1663
+ return !inParts.some((inP) => inP.type === extra.type &&
1664
+ isTextLikePart(inP) &&
1665
+ inP.text.startsWith(extra.text));
1666
+ });
1667
+ for (const ep of survivingExtras) {
1668
+ const r = Binary.search(reconciled, ep.id, (p) => p.id);
1669
+ if (!r.found)
1670
+ reconciled.splice(r.index, 0, ep);
1400
1671
  }
1401
- return inP;
1402
- });
1403
- // T16 — dedupe extras by content identity. An "extra" is an
1404
- // existing part whose id the incoming snapshot no longer has — the
1405
- // server may simply not have persisted it yet (kept, as before), OR
1406
- // the server RE-ISSUED the same content under a NEW part id (a real
1407
- // defect: the old SSE-accumulated twin stayed in `exParts` forever,
1408
- // duplicating the text inside this one message). Distinguish the two
1409
- // conservatively: drop an extra only when it is text-like AND its own
1410
- // accumulated text is a PREFIX of (or equal to) some incoming
1411
- // text-like part of the SAME type — i.e. the incoming copy confidently
1412
- // re-issues it, not merely resembles it. Non-text-like extras (tool,
1413
- // permission, file, step, …) are never dropped by content — they carry
1414
- // distinct identity per id and a coincidental text match doesn't apply
1415
- // to them at all.
1416
- //
1417
- // F1 review finding: an extra still tracked in `deltaActiveParts` (this
1418
- // session is actively applying deltas to it right now) is EXEMPT from
1419
- // this filter regardless of what it prefixes. The heuristic above
1420
- // assumes a text-prefix match means the server re-issued the SAME
1421
- // content under a new id and the extra is an abandoned twin — but a
1422
- // live streaming target is never abandoned, and dropping it here also
1423
- // permanently blocks its later deltas (their event ids would already
1424
- // be recorded as applied — see `applyPartDelta`'s not-found path).
1425
- const survivingExtras = extras.filter((extra) => {
1426
- if (hasTrackedId(deltaActiveParts, sessionID, extra.id))
1427
- return true;
1428
- if (!isTextLikePart(extra) || extra.text.length === 0)
1429
- return true;
1430
- return !inParts.some((inP) => inP.type === extra.type &&
1431
- isTextLikePart(inP) &&
1432
- inP.text.startsWith(extra.text));
1433
- });
1434
- for (const ep of survivingExtras) {
1435
- const r = Binary.search(reconciled, ep.id, (p) => p.id);
1436
- if (!r.found)
1437
- reconciled.splice(r.index, 0, ep);
1672
+ newParts[mid] = reconciled;
1438
1673
  }
1439
- newParts[mid] = reconciled;
1440
- }
1441
- return {
1442
- messages: { ...s.messages, [sessionID]: merged },
1443
- parts: newParts,
1444
- };
1445
- }),
1674
+ return {
1675
+ messages: { ...s.messages, [sessionID]: merged },
1676
+ parts: newParts,
1677
+ };
1678
+ });
1679
+ // Outside the updater: `noteSessionActivity` runs its own set(), and the
1680
+ // stamp is quantized there. This is the evidence `projectWorking`'s
1681
+ // content-first rule needs to outrank a stale wire idle frame whose veto
1682
+ // would otherwise silence every fresh `/turn` read for the rest of a turn
1683
+ // the SSE stream stopped reporting (prod, 2026-08-26).
1684
+ if (stampRuntimeActivity)
1685
+ get().noteSessionActivity(sessionID);
1686
+ },
1446
1687
  reset: () => {
1447
1688
  optimisticIds.clear();
1448
1689
  dispatchedOptimisticIds.clear();
@@ -1463,6 +1704,9 @@ export const useSyncStore = create()((set, get) => ({
1463
1704
  messages: {},
1464
1705
  parts: {},
1465
1706
  sessionStatus: {},
1707
+ sessionStatusOrigin: {},
1708
+ sessionStatusAt: {},
1709
+ sessionActivityAt: {},
1466
1710
  diffs: {},
1467
1711
  todos: {},
1468
1712
  sessionRevert: {},
@@ -1491,6 +1735,12 @@ export const useSyncStore = create()((set, get) => ({
1491
1735
  const store = get();
1492
1736
  switch (event.type) {
1493
1737
  case "message.updated": {
1738
+ {
1739
+ const info = event.properties?.info;
1740
+ const sid = info?.sessionID ?? event.properties?.sessionID;
1741
+ if (sid)
1742
+ get().noteSessionActivity(sid);
1743
+ }
1494
1744
  const info = event.properties.info;
1495
1745
  if (!info?.sessionID)
1496
1746
  return;
@@ -1589,14 +1839,17 @@ export const useSyncStore = create()((set, get) => ({
1589
1839
  const list = s.messages[info.sessionID] ?? [];
1590
1840
  // Remove all optimistic user messages
1591
1841
  const without = list.filter((m) => !optIds.includes(m.id));
1592
- // Insert the real message at sorted position
1593
- const r = Binary.search(without, info.id, (m) => m.id);
1842
+ // Place the real message. Linear on a miss, then by TIME: a
1843
+ // false miss spliced a SECOND copy of a message already in
1844
+ // the transcript, at a binary index that means nothing on a
1845
+ // list the server never ordered by id.
1846
+ const at = indexOfId(without, info.id, (m) => m.id);
1594
1847
  let next = [...without];
1595
- if (r.found) {
1596
- next[r.index] = info;
1848
+ if (at !== -1) {
1849
+ next[at] = info;
1597
1850
  }
1598
1851
  else {
1599
- next.splice(r.index, 0, info);
1852
+ next.splice(insertIndexByTime(next, info), 0, info);
1600
1853
  }
1601
1854
  // A turn that failed before this confirmation arrived
1602
1855
  // has a `session.error` stub keyed to the optimistic id
@@ -1645,6 +1898,14 @@ export const useSyncStore = create()((set, get) => ({
1645
1898
  const part = event.properties.part;
1646
1899
  if (!part?.messageID)
1647
1900
  return;
1901
+ // The runtime just produced output. This is the evidence
1902
+ // `projectWorking` trusts above every observer — see
1903
+ // `sessionActivityAt`.
1904
+ {
1905
+ const sid = part.sessionID ?? event.properties?.sessionID;
1906
+ if (sid)
1907
+ get().noteSessionActivity(sid);
1908
+ }
1648
1909
  const eventSessionID = event.properties?.sessionID;
1649
1910
  let resolvedSessionID = part.sessionID ?? eventSessionID;
1650
1911
  if (!resolvedSessionID) {
@@ -1659,9 +1920,10 @@ export const useSyncStore = create()((set, get) => ({
1659
1920
  const existingMsgs = resolvedSessionID
1660
1921
  ? get().messages[resolvedSessionID]
1661
1922
  : undefined;
1662
- const bsResult = existingMsgs && Binary.search(existingMsgs, part.messageID, (m) => m.id);
1663
- const exists = existingMsgs && ((bsResult && bsResult.found && existingMsgs[bsResult.index]?.id === part.messageID) ||
1664
- existingMsgs.some((m) => m.id === part.messageID));
1923
+ // The `.some()` alone is authoritative; the binary search that used
1924
+ // to sit beside it as a first disjunct could only agree with it or
1925
+ // miss on a list that is not id-sorted.
1926
+ const exists = existingMsgs?.some((m) => m.id === part.messageID);
1665
1927
  if (!exists && resolvedSessionID) {
1666
1928
  store.upsertMessage(resolvedSessionID, {
1667
1929
  id: part.messageID,
@@ -1746,13 +2008,13 @@ export const useSyncStore = create()((set, get) => ({
1746
2008
  case "session.status": {
1747
2009
  const props = event.properties;
1748
2010
  if (props.sessionID && props.status)
1749
- store.setStatus(props.sessionID, props.status);
2011
+ store.setStatus(props.sessionID, props.status, syntheticEventOrigin(event));
1750
2012
  return;
1751
2013
  }
1752
2014
  case "session.idle": {
1753
2015
  const sessionID = event.properties.sessionID;
1754
2016
  if (sessionID)
1755
- store.setStatus(sessionID, { type: "idle" });
2017
+ store.setStatus(sessionID, { type: "idle" }, syntheticEventOrigin(event));
1756
2018
  // Streaming finished for THIS session — clear only its own delta
1757
2019
  // tracking so future message.part.updated snapshots for it are
1758
2020
  // accepted normally. Never the whole map: another session may
@@ -1772,8 +2034,23 @@ export const useSyncStore = create()((set, get) => ({
1772
2034
  return;
1773
2035
  const sid = props.sessionID;
1774
2036
  const error = props.error;
1775
- // Mark session idle — errors terminate the response.
1776
- store.setStatus(sid, { type: "idle" });
2037
+ // Most errors terminate the response. A RETRYABLE one does not:
2038
+ // OpenCode stamps `data.isRetryable === true` and keeps writing the
2039
+ // SAME assistant message, and apps/api reaches the same conclusion
2040
+ // from this same event (`isTerminalTurnEnd`,
2041
+ // apps/api/src/projects/sandbox-deadline-policy.ts:295). Writing
2042
+ // `idle` here made a live turn byte-identical to a finished one, and
2043
+ // `endedByRuntime` (core/session/working.ts) then vetoed the still-open
2044
+ // ledger row with no time bound — the Stop button disappeared mid-turn
2045
+ // and the partial answer read as complete.
2046
+ //
2047
+ // The status slot is LEFT ALONE rather than set to `retry`: the frame
2048
+ // already there is the runtime's own last word, and overwriting it
2049
+ // would re-stamp its arrival time and restart every freshness window
2050
+ // that depends on it.
2051
+ if (!isRetryableTurnError(error)) {
2052
+ store.setStatus(sid, { type: "idle" }, syntheticEventOrigin(event));
2053
+ }
1777
2054
  // Clear only this session's delta tracking — see the idle handler
1778
2055
  // above and the comment above deltaActiveParts.
1779
2056
  deltaActiveParts.delete(sid);
@@ -1871,7 +2148,10 @@ export const useSyncStore = create()((set, get) => ({
1871
2148
  if (props.sessionID && props.messageID) {
1872
2149
  const tracked = get().sessionRevert[props.sessionID];
1873
2150
  if (tracked) {
1874
- store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark);
2151
+ // The captured set only describes the boundary it was staged for.
2152
+ // A `.committed` naming a different one falls back to the legacy
2153
+ // range rather than deleting the wrong trajectory.
2154
+ store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark, tracked.messageId === props.messageID ? tracked.hiddenIds : undefined);
1875
2155
  }
1876
2156
  else {
1877
2157
  // F2 — no tracked local record (fresh mount / second tab that