@kortix/sdk 0.12.7 → 0.13.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 (287) hide show
  1. package/README.md +13 -6
  2. package/dist/browser/session-sync/session-sync-registry.d.ts +7 -2
  3. package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
  4. package/dist/browser/session-sync/session-sync-registry.js +33 -44
  5. package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
  6. package/dist/browser/stores/opencode-compaction-store.d.ts +9 -3
  7. package/dist/browser/stores/opencode-compaction-store.d.ts.map +1 -1
  8. package/dist/browser/stores/opencode-compaction-store.js +5 -12
  9. package/dist/browser/stores/opencode-compaction-store.js.map +1 -1
  10. package/dist/browser/stores/sandbox-connection-store.d.ts +28 -17
  11. package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
  12. package/dist/browser/stores/sandbox-connection-store.js +32 -84
  13. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  14. package/dist/browser/stores/session-working-store.d.ts +73 -0
  15. package/dist/browser/stores/session-working-store.d.ts.map +1 -0
  16. package/dist/browser/stores/session-working-store.js +86 -0
  17. package/dist/browser/stores/session-working-store.js.map +1 -0
  18. package/dist/browser/stores/sync-store/types.d.ts +14 -4
  19. package/dist/browser/stores/sync-store/types.d.ts.map +1 -1
  20. package/dist/browser/stores/sync-store.d.ts +140 -3
  21. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  22. package/dist/browser/stores/sync-store.js +425 -36
  23. package/dist/browser/stores/sync-store.js.map +1 -1
  24. package/dist/core/client/kortix.d.ts +102 -1
  25. package/dist/core/client/kortix.d.ts.map +1 -1
  26. package/dist/core/client/kortix.js +78 -0
  27. package/dist/core/client/kortix.js.map +1 -1
  28. package/dist/core/files/client.d.ts +50 -2
  29. package/dist/core/files/client.d.ts.map +1 -1
  30. package/dist/core/files/client.js +253 -44
  31. package/dist/core/files/client.js.map +1 -1
  32. package/dist/core/files/types.d.ts +11 -0
  33. package/dist/core/files/types.d.ts.map +1 -1
  34. package/dist/core/http/abort-error.d.ts +89 -0
  35. package/dist/core/http/abort-error.d.ts.map +1 -0
  36. package/dist/core/http/abort-error.js +190 -0
  37. package/dist/core/http/abort-error.js.map +1 -0
  38. package/dist/core/http/api-client.d.ts.map +1 -1
  39. package/dist/core/http/api-client.js +28 -4
  40. package/dist/core/http/api-client.js.map +1 -1
  41. package/dist/core/http/impersonation.d.ts +58 -0
  42. package/dist/core/http/impersonation.d.ts.map +1 -0
  43. package/dist/core/http/impersonation.js +174 -0
  44. package/dist/core/http/impersonation.js.map +1 -0
  45. package/dist/core/rest/platform-client/github-app.d.ts +13 -0
  46. package/dist/core/rest/platform-client/github-app.d.ts.map +1 -1
  47. package/dist/core/rest/platform-client/github-app.js +2 -0
  48. package/dist/core/rest/platform-client/github-app.js.map +1 -1
  49. package/dist/core/rest/platform-client/members.d.ts +10 -64
  50. package/dist/core/rest/platform-client/members.d.ts.map +1 -1
  51. package/dist/core/rest/platform-client/members.js +10 -28
  52. package/dist/core/rest/platform-client/members.js.map +1 -1
  53. package/dist/core/rest/projects-client/accounts.d.ts +30 -1
  54. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  55. package/dist/core/rest/projects-client/accounts.js +1 -1
  56. package/dist/core/rest/projects-client/accounts.js.map +1 -1
  57. package/dist/core/rest/projects-client/agent-config.d.ts +21 -0
  58. package/dist/core/rest/projects-client/agent-config.d.ts.map +1 -1
  59. package/dist/core/rest/projects-client/agent-config.js +14 -0
  60. package/dist/core/rest/projects-client/agent-config.js.map +1 -1
  61. package/dist/core/rest/projects-client/apps.d.ts +13 -0
  62. package/dist/core/rest/projects-client/apps.d.ts.map +1 -1
  63. package/dist/core/rest/projects-client/apps.js.map +1 -1
  64. package/dist/core/rest/projects-client/assignments.d.ts +114 -0
  65. package/dist/core/rest/projects-client/assignments.d.ts.map +1 -0
  66. package/dist/core/rest/projects-client/assignments.js +91 -0
  67. package/dist/core/rest/projects-client/assignments.js.map +1 -0
  68. package/dist/core/rest/projects-client/billing.d.ts +80 -1
  69. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  70. package/dist/core/rest/projects-client/billing.js +61 -2
  71. package/dist/core/rest/projects-client/billing.js.map +1 -1
  72. package/dist/core/rest/projects-client/connectors.d.ts +2 -0
  73. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  74. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  75. package/dist/core/rest/projects-client/files.js +2 -2
  76. package/dist/core/rest/projects-client/files.js.map +1 -1
  77. package/dist/core/rest/projects-client/gateway.d.ts +51 -0
  78. package/dist/core/rest/projects-client/gateway.d.ts.map +1 -1
  79. package/dist/core/rest/projects-client/gateway.js.map +1 -1
  80. package/dist/core/rest/projects-client/iam.d.ts +51 -3
  81. package/dist/core/rest/projects-client/iam.d.ts.map +1 -1
  82. package/dist/core/rest/projects-client/iam.js +18 -1
  83. package/dist/core/rest/projects-client/iam.js.map +1 -1
  84. package/dist/core/rest/projects-client/index.d.ts +1 -0
  85. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  86. package/dist/core/rest/projects-client/index.js +1 -0
  87. package/dist/core/rest/projects-client/index.js.map +1 -1
  88. package/dist/core/rest/projects-client/projects.d.ts +1 -1
  89. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  90. package/dist/core/rest/projects-client/projects.js +3 -0
  91. package/dist/core/rest/projects-client/projects.js.map +1 -1
  92. package/dist/core/rest/projects-client/secrets.d.ts +11 -0
  93. package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
  94. package/dist/core/rest/projects-client/secrets.js +1 -1
  95. package/dist/core/rest/projects-client/secrets.js.map +1 -1
  96. package/dist/core/rest/projects-client/session-costs.d.ts +33 -0
  97. package/dist/core/rest/projects-client/session-costs.d.ts.map +1 -1
  98. package/dist/core/rest/projects-client/session-costs.js.map +1 -1
  99. package/dist/core/rest/projects-client/sessions.d.ts +253 -4
  100. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  101. package/dist/core/rest/projects-client/sessions.js +101 -4
  102. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  103. package/dist/core/rest/projects-client/shared.d.ts +10 -1
  104. package/dist/core/rest/projects-client/shared.d.ts.map +1 -1
  105. package/dist/core/rest/projects-client/shared.js.map +1 -1
  106. package/dist/core/rest/projects-client/tokens.d.ts +13 -1
  107. package/dist/core/rest/projects-client/tokens.d.ts.map +1 -1
  108. package/dist/core/rest/projects-client/tokens.js +7 -2
  109. package/dist/core/rest/projects-client/tokens.js.map +1 -1
  110. package/dist/core/rest/projects-client/triggers.d.ts +69 -2
  111. package/dist/core/rest/projects-client/triggers.d.ts.map +1 -1
  112. package/dist/core/rest/projects-client/triggers.js +1 -1
  113. package/dist/core/rest/projects-client/triggers.js.map +1 -1
  114. package/dist/core/session/compaction.d.ts +98 -0
  115. package/dist/core/session/compaction.d.ts.map +1 -0
  116. package/dist/core/session/compaction.js +108 -0
  117. package/dist/core/session/compaction.js.map +1 -0
  118. package/dist/core/session/health.d.ts +22 -0
  119. package/dist/core/session/health.d.ts.map +1 -1
  120. package/dist/core/session/health.js +31 -5
  121. package/dist/core/session/health.js.map +1 -1
  122. package/dist/core/session/index.d.ts +2 -0
  123. package/dist/core/session/index.d.ts.map +1 -1
  124. package/dist/core/session/index.js +2 -0
  125. package/dist/core/session/index.js.map +1 -1
  126. package/dist/core/session/message-queue.d.ts +79 -15
  127. package/dist/core/session/message-queue.d.ts.map +1 -1
  128. package/dist/core/session/message-queue.js +73 -27
  129. package/dist/core/session/message-queue.js.map +1 -1
  130. package/dist/core/session/rewind.d.ts +76 -10
  131. package/dist/core/session/rewind.d.ts.map +1 -1
  132. package/dist/core/session/rewind.js +68 -11
  133. package/dist/core/session/rewind.js.map +1 -1
  134. package/dist/core/session/working.d.ts +241 -0
  135. package/dist/core/session/working.d.ts.map +1 -0
  136. package/dist/core/session/working.js +261 -0
  137. package/dist/core/session/working.js.map +1 -0
  138. package/dist/core/session-sync/session-sync-controller.d.ts +35 -20
  139. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  140. package/dist/core/session-sync/session-sync-controller.js +48 -115
  141. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  142. package/dist/core/stream/event-stream.d.ts +22 -0
  143. package/dist/core/stream/event-stream.d.ts.map +1 -1
  144. package/dist/core/stream/event-stream.js +132 -36
  145. package/dist/core/stream/event-stream.js.map +1 -1
  146. package/dist/core/turns/classify.d.ts +5 -2
  147. package/dist/core/turns/classify.d.ts.map +1 -1
  148. package/dist/core/turns/classify.js +1 -0
  149. package/dist/core/turns/classify.js.map +1 -1
  150. package/dist/core/turns/errors.d.ts +11 -0
  151. package/dist/core/turns/errors.d.ts.map +1 -1
  152. package/dist/core/turns/errors.js +49 -2
  153. package/dist/core/turns/errors.js.map +1 -1
  154. package/dist/core/turns/grouping.d.ts +32 -2
  155. package/dist/core/turns/grouping.d.ts.map +1 -1
  156. package/dist/core/turns/grouping.js +31 -2
  157. package/dist/core/turns/grouping.js.map +1 -1
  158. package/dist/core/turns/index.d.ts +1 -0
  159. package/dist/core/turns/index.d.ts.map +1 -1
  160. package/dist/core/turns/index.js +1 -0
  161. package/dist/core/turns/index.js.map +1 -1
  162. package/dist/core/turns/open-turn.d.ts +81 -0
  163. package/dist/core/turns/open-turn.d.ts.map +1 -0
  164. package/dist/core/turns/open-turn.js +77 -0
  165. package/dist/core/turns/open-turn.js.map +1 -0
  166. package/dist/core/turns/state.d.ts +1 -1
  167. package/dist/core/turns/state.d.ts.map +1 -1
  168. package/dist/core/turns/state.js +29 -27
  169. package/dist/core/turns/state.js.map +1 -1
  170. package/dist/core/turns/types.d.ts +8 -0
  171. package/dist/core/turns/types.d.ts.map +1 -1
  172. package/dist/index.d.ts +3 -1
  173. package/dist/index.d.ts.map +1 -1
  174. package/dist/index.js +3 -1
  175. package/dist/index.js.map +1 -1
  176. package/dist/kortix.esm.min.js +25 -25
  177. package/dist/kortix.global.js +25 -25
  178. package/dist/node/server.d.ts.map +1 -1
  179. package/dist/node/server.js +14 -2
  180. package/dist/node/server.js.map +1 -1
  181. package/dist/react/index.d.ts +3 -1
  182. package/dist/react/index.d.ts.map +1 -1
  183. package/dist/react/index.js +8 -1
  184. package/dist/react/index.js.map +1 -1
  185. package/dist/react/initial-session-pin.d.ts +17 -0
  186. package/dist/react/initial-session-pin.d.ts.map +1 -1
  187. package/dist/react/initial-session-pin.js +58 -0
  188. package/dist/react/initial-session-pin.js.map +1 -1
  189. package/dist/react/opencode.d.ts +6 -2
  190. package/dist/react/opencode.d.ts.map +1 -1
  191. package/dist/react/opencode.js +5 -1
  192. package/dist/react/opencode.js.map +1 -1
  193. package/dist/react/prefetch-session-start.d.ts +10 -4
  194. package/dist/react/prefetch-session-start.d.ts.map +1 -1
  195. package/dist/react/prefetch-session-start.js +10 -4
  196. package/dist/react/prefetch-session-start.js.map +1 -1
  197. package/dist/react/query-keys.d.ts +8 -2
  198. package/dist/react/query-keys.d.ts.map +1 -1
  199. package/dist/react/query-keys.js +8 -2
  200. package/dist/react/query-keys.js.map +1 -1
  201. package/dist/react/session-start-stash.d.ts +25 -0
  202. package/dist/react/session-start-stash.d.ts.map +1 -1
  203. package/dist/react/session-start-stash.js +34 -3
  204. package/dist/react/session-start-stash.js.map +1 -1
  205. package/dist/react/session-title-sync.d.ts +5 -1
  206. package/dist/react/session-title-sync.d.ts.map +1 -1
  207. package/dist/react/session-title-sync.js +16 -4
  208. package/dist/react/session-title-sync.js.map +1 -1
  209. package/dist/react/use-admin-accounts.d.ts +121 -0
  210. package/dist/react/use-admin-accounts.d.ts.map +1 -1
  211. package/dist/react/use-admin-accounts.js +92 -0
  212. package/dist/react/use-admin-accounts.js.map +1 -1
  213. package/dist/react/use-can.d.ts +105 -0
  214. package/dist/react/use-can.d.ts.map +1 -0
  215. package/dist/react/use-can.js +190 -0
  216. package/dist/react/use-can.js.map +1 -0
  217. package/dist/react/use-canonical-opencode-session.d.ts +6 -1
  218. package/dist/react/use-canonical-opencode-session.d.ts.map +1 -1
  219. package/dist/react/use-canonical-opencode-session.js +46 -3
  220. package/dist/react/use-canonical-opencode-session.js.map +1 -1
  221. package/dist/react/use-impersonation.d.ts +54 -0
  222. package/dist/react/use-impersonation.d.ts.map +1 -0
  223. package/dist/react/use-impersonation.js +91 -0
  224. package/dist/react/use-impersonation.js.map +1 -0
  225. package/dist/react/use-opencode-events/handle-event.d.ts +10 -0
  226. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  227. package/dist/react/use-opencode-events/handle-event.js +76 -7
  228. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  229. package/dist/react/use-opencode-events/helpers.d.ts +21 -8
  230. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  231. package/dist/react/use-opencode-events/helpers.js +25 -26
  232. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  233. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  234. package/dist/react/use-opencode-events/index.js +43 -26
  235. package/dist/react/use-opencode-events/index.js.map +1 -1
  236. package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
  237. package/dist/react/use-opencode-events/use-event-stream-refs.js +66 -3
  238. package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
  239. package/dist/react/use-opencode-local.d.ts +17 -0
  240. package/dist/react/use-opencode-local.d.ts.map +1 -1
  241. package/dist/react/use-opencode-local.js +41 -7
  242. package/dist/react/use-opencode-local.js.map +1 -1
  243. package/dist/react/use-opencode-sessions/messages.d.ts +101 -1
  244. package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
  245. package/dist/react/use-opencode-sessions/messages.js +481 -63
  246. package/dist/react/use-opencode-sessions/messages.js.map +1 -1
  247. package/dist/react/use-project-apps.d.ts +24 -1
  248. package/dist/react/use-project-apps.d.ts.map +1 -1
  249. package/dist/react/use-project-apps.js +5 -3
  250. package/dist/react/use-project-apps.js.map +1 -1
  251. package/dist/react/use-runtime-boot-stalled.d.ts +32 -0
  252. package/dist/react/use-runtime-boot-stalled.d.ts.map +1 -0
  253. package/dist/react/use-runtime-boot-stalled.js +48 -0
  254. package/dist/react/use-runtime-boot-stalled.js.map +1 -0
  255. package/dist/react/use-runtime-reconnect.d.ts +67 -1
  256. package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
  257. package/dist/react/use-runtime-reconnect.js +116 -18
  258. package/dist/react/use-runtime-reconnect.js.map +1 -1
  259. package/dist/react/use-session-prompts.d.ts +113 -0
  260. package/dist/react/use-session-prompts.d.ts.map +1 -0
  261. package/dist/react/use-session-prompts.js +242 -0
  262. package/dist/react/use-session-prompts.js.map +1 -0
  263. package/dist/react/use-session-send.d.ts +169 -20
  264. package/dist/react/use-session-send.d.ts.map +1 -1
  265. package/dist/react/use-session-send.js +178 -29
  266. package/dist/react/use-session-send.js.map +1 -1
  267. package/dist/react/use-session-sync.d.ts +35 -2
  268. package/dist/react/use-session-sync.d.ts.map +1 -1
  269. package/dist/react/use-session-sync.js +46 -9
  270. package/dist/react/use-session-sync.js.map +1 -1
  271. package/dist/react/use-session-working.d.ts +57 -0
  272. package/dist/react/use-session-working.d.ts.map +1 -0
  273. package/dist/react/use-session-working.js +178 -0
  274. package/dist/react/use-session-working.js.map +1 -0
  275. package/dist/react/use-session.d.ts +122 -5
  276. package/dist/react/use-session.d.ts.map +1 -1
  277. package/dist/react/use-session.js +343 -69
  278. package/dist/react/use-session.js.map +1 -1
  279. package/package.json +2 -2
  280. package/dist/browser/stores/tab-store.d.ts +0 -91
  281. package/dist/browser/stores/tab-store.d.ts.map +0 -1
  282. package/dist/browser/stores/tab-store.js +0 -369
  283. package/dist/browser/stores/tab-store.js.map +0 -1
  284. package/dist/core/session/send-queue.d.ts +0 -70
  285. package/dist/core/session/send-queue.d.ts.map +0 -1
  286. package/dist/core/session/send-queue.js +0 -116
  287. package/dist/core/session/send-queue.js.map +0 -1
@@ -1,44 +1,27 @@
1
1
  'use client';
2
- /**
3
- * useSession — the ONE hook a host needs to open a session and stream a chat.
4
- *
5
- * Everything sandbox-shaped is internal: it drives `/start` (server long-poll),
6
- * points the runtime at the session's sandbox, opens the SSE stream, resolves the
7
- * canonical OpenCode id, and syncs messages — exposing one `phase`
8
- * (starting|ready|error) plus messages/send/abort/questions/permissions and the
9
- * server-side capabilities (models/agents/commands/picks). The host imports
10
- * `createKortix` + `useSession` and NOTHING else runtime-related: no server-store,
11
- * no `switchToSessionSandboxAsync`, no health poller, no event-stream provider.
12
- *
13
- * Readiness comes from the SERVER (`stage==='ready'` is only returned after the
14
- * daemon answered), seeded into the connection store on switch — so there is NO
15
- * client health poller, and the first turn streams immediately.
16
- *
17
- * Call this ONCE per session view (like a provider): it owns the SSE subscription
18
- * and the `/start` poll for `(projectId, sessionId)`.
19
- */
20
2
  import { useQuery, useQueryClient } from '@tanstack/react-query';
21
- import { useEffect, useMemo, useRef, useState } from 'react';
22
- import { beginSessionPromptObservation, endSessionPromptObservation, } from '../browser/session-sync/session-sync-registry.js';
3
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
23
4
  import { useOpenCodeCompactionStore } from '../browser/stores/opencode-compaction-store.js';
5
+ import { useSessionWorkingStore } from '../browser/stores/session-working-store.js';
6
+ import { compactionExpiryAtMs, projectCompacting, serverCompactionRevalidateAtMs, } from '../core/session/compaction.js';
24
7
  import { useOpenCodePendingStore } from '../browser/stores/opencode-pending-store.js';
25
- import { setOpenCodeHealth, setSandboxStatus } from '../browser/stores/sandbox-connection-store.js';
8
+ import { markRuntimeReadyVerified, setOpenCodeHealth, setSandboxStatus, } from '../browser/stores/sandbox-connection-store.js';
26
9
  import { getSandboxUrlForExternalId } from '../browser/stores/server-store.js';
27
- import { useSyncStore } from '../browser/stores/sync-store.js';
10
+ import { ascendingId, useSyncStore } from '../browser/stores/sync-store.js';
28
11
  import { BillingError, parseBillingError } from '../core/http/api/errors.js';
29
12
  import { isSessionFresh } from '../core/http/fresh-sessions.js';
30
13
  import { formatOpenCodeRuntimeError } from '../core/http/opencode-errors.js';
31
14
  import { isSessionStartError, sessionStartKey, startProjectSession, } from '../core/rest/projects-client/index.js';
32
15
  import { RuntimeNotReadyError, getClient } from '../core/runtime/client.js';
33
16
  import { setCurrentRuntime } from '../core/session/current-runtime.js';
34
- import { commitSessionRewind, messagesBeforeRewind, reconcileCommittedSessionRewind, } from '../core/session/rewind.js';
17
+ import { messagesBeforeRewind } from '../core/session/rewind.js';
35
18
  import { extractGatewayErrorDetails } from '../core/turns/errors.js';
36
19
  import { clearStartStash, readStartStash } from './session-start-stash.js';
37
20
  import { reconcileHydratedSessionTitle } from './session-title-sync.js';
38
21
  import { useCanonicalOpenCodeSession } from './use-canonical-opencode-session.js';
39
22
  import { useOpenCodeEventStream } from './use-opencode-events/index.js';
40
23
  import { formatModelString } from './use-opencode-local.js';
41
- import { rejectQuestion as rejectQuestionApi, replyToPermission, replyToQuestion, useAbortOpenCodeSession, useExecuteOpenCodeCommand, useSendOpenCodeMessage, } from './use-opencode-sessions/index.js';
24
+ import { abortInFlightDeliveries, awaitAbortSettlement, opencodeKeys, rejectQuestion as rejectQuestionApi, replyToPermission, replyToQuestion, useAbortOpenCodeSession, useExecuteOpenCodeCommand, useOpenCodeSession, useSendOpenCodeMessage, } from './use-opencode-sessions/index.js';
42
25
  import { unwrap } from './use-opencode-sessions/shared.js';
43
26
  import { usePermissionSelfHeal } from './use-permission-self-heal.js';
44
27
  import { useProjectConfig } from './use-project-config.js';
@@ -48,7 +31,14 @@ import { useRuntimePhase } from './use-runtime-phase.js';
48
31
  import { useSessionPicks } from './use-session-picks.js';
49
32
  import { derivePhase } from './use-session-phase.js';
50
33
  import { useSessionSync } from './use-session-sync.js';
34
+ import { useSessionWorking } from './use-session-working.js';
51
35
  import { useVisibleAgents } from './use-visible-agents.js';
36
+ // `useLayoutEffect` warns during SSR ("useLayoutEffect does nothing on the
37
+ // server"). This file is always `'use client'`, but Next.js still runs an
38
+ // initial SSR pass before hydration — fall back to `useEffect` there. See the
39
+ // mount-time `markRuntimeReadyVerified` seed below for why this hook needs a
40
+ // LAYOUT effect specifically (ordering, not paint timing).
41
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
52
42
  // Grace window for the optimistic create-vs-start race: how long /start keeps
53
43
  // retrying a 404 for a freshly-minted session before treating it as terminal.
54
44
  // ~12 × 800ms ≈ 9.6s, comfortably past the sub-second create POST.
@@ -211,6 +201,63 @@ export function computeStartSettled(input) {
211
201
  return false;
212
202
  return !shouldPollSessionStart(input.error, input.data);
213
203
  }
204
+ /**
205
+ * How long a `ready` /start result stays fresh enough that a REMOUNT renders
206
+ * it straight from the TanStack Query cache with zero network `/start` before
207
+ * first paint — the "user just left this exact session and came right back"
208
+ * case (tab switch, back button, re-opening the same session from the
209
+ * project's session list), which otherwise re-POSTs `/start` (and re-waits
210
+ * its up-to-`waitMs` server long-poll) on every single mount even though the
211
+ * box never went anywhere.
212
+ *
213
+ * The server parks an idle sandbox after ~15 minutes (see the `/start` route
214
+ * this hook drives, in `session-sandbox.ts`), so this window is nowhere near
215
+ * long enough to promise anything about a sandbox that has genuinely been
216
+ * idle — it only needs to outlast the sub-second "switch thrash" case, not
217
+ * survive a real absence. 30s comfortably covers that without risking a
218
+ * materially stale "ready" read surviving into a box the server already
219
+ * parked: `sessionStartStaleTime` only skips the IMMEDIATE refetch, it never
220
+ * cancels one — a background refetch still fires once the cached entry ages
221
+ * past this window (stale-while-revalidate), so a parked box still converges
222
+ * to `stopped` on the very next look.
223
+ */
224
+ export const SESSION_START_FRESH_MS = 30_000;
225
+ /**
226
+ * OUTCOME-AWARE `staleTime` for the `/start` query (TanStack's function
227
+ * form). Only a `stage==='ready'` result may be served from cache without an
228
+ * immediate refetch — every other stage (`provisioning`/`starting`/
229
+ * `stopped`/`failed`, or no cached data yet) represents an in-flight or
230
+ * terminal state that must keep converging on server truth, so it stays
231
+ * maximally stale (`0`, i.e. TanStack's "always refetch on mount") —
232
+ * unchanged from this query's behavior before this staleTime existed.
233
+ *
234
+ * This does NOT change `refetchInterval` (`SESSION_START_POLL_OPTIONS`,
235
+ * below, untouched): a `ready` result already stops that poll
236
+ * (`shouldPollSessionStart`), so this only governs remount behavior, never
237
+ * steady-state polling.
238
+ */
239
+ export function sessionStartStaleTime(query) {
240
+ return query.state.data?.stage === 'ready' ? SESSION_START_FRESH_MS : 0;
241
+ }
242
+ /**
243
+ * Whether a cached `/start` query STATE (as sitting in the TanStack Query
244
+ * cache the moment a session starts mounting) already says `stage==='ready'`
245
+ * — independent of staleness/age: React Query serves whatever is cached
246
+ * synchronously on every mount regardless of `staleTime` (`staleTime` only
247
+ * gates whether a background refetch ALSO fires, never whether the cached
248
+ * data renders) — so a session left seconds or minutes ago can make
249
+ * `switched` true well before this mount's own effects have a chance to run.
250
+ *
251
+ * Pure so the mount-time `markRuntimeReadyVerified()` decision it feeds (the
252
+ * `useIsomorphicLayoutEffect` in {@link useSession}, right after the /start
253
+ * query below) is unit-testable without rendering the hook — this package has
254
+ * no hook-render harness, so pure predicates like this ARE the test surface
255
+ * for effect-gated logic (see `computeStartSettled`/`nextInconclusiveSince`
256
+ * above for the established pattern).
257
+ */
258
+ export function cachedStartResultIsReady(cachedState) {
259
+ return cachedState?.data?.stage === 'ready';
260
+ }
214
261
  /**
215
262
  * TanStack Query pauses interval fetches while the document is hidden unless
216
263
  * this option is true. Session readiness must continue because it gates the
@@ -238,7 +285,7 @@ const RUNTIME_NOT_READY_MARKER = 'Server URL not ready';
238
285
  *
239
286
  * A no-op when the parts carry no ids or belong to no optimistic message.
240
287
  */
241
- function markDispatchedForPartIds(sessionId, parts) {
288
+ export function markDispatchedForPartIds(sessionId, parts) {
242
289
  const partIds = new Set(parts
243
290
  .map((p) => p.id)
244
291
  .filter((id) => typeof id === 'string' && !!id));
@@ -253,6 +300,56 @@ function markDispatchedForPartIds(sessionId, parts) {
253
300
  store.markOptimisticDispatched(sessionId, message.id);
254
301
  }
255
302
  }
303
+ /**
304
+ * Optimistically add the user's message for a plain-text `send()` call, and
305
+ * mint the SAME part id onto both the optimistic part AND the outgoing wire
306
+ * part `send()` goes on to POST.
307
+ *
308
+ * T15 — "one user bubble": `send()` used to build its outgoing part
309
+ * as `{ type: 'text', text }` with no id at all, so — even where a host paired
310
+ * it with its own optimistic add — nothing correlated the two. The sync
311
+ * store's `message.updated` handler matches an incoming echo to an optimistic
312
+ * message primarily by EXACT part id (opencode preserves a client-supplied
313
+ * part id on echo); only when that fails does it fall back to "the oldest
314
+ * DISPATCHED optimistic message" (`markDispatchedForPartIds`, called
315
+ * unconditionally by `sendParts` below, marks it — but only once the part ids
316
+ * actually match, same root cause). With no id on the wire, neither path can
317
+ * ever fire, and the user's text stayed doubled on screen until the next
318
+ * idle sweep (`clearOptimisticMessages`).
319
+ *
320
+ * Mirrors `beginOptimisticSend` (`use-session-send.ts`) exactly — same store
321
+ * calls, same stub-message shape, same "skip parts for empty/whitespace text"
322
+ * rule — kept as a sibling here rather than imported, since
323
+ * `use-session-send.ts` already imports FROM this module (`classifySendError`)
324
+ * and a reverse import would make the two files mutually dependent.
325
+ */
326
+ export function beginOptimisticPlainTextSend(sessionId, text) {
327
+ const messageId = ascendingId('msg');
328
+ const partId = ascendingId('prt');
329
+ const optimisticParts = text.trim()
330
+ ? [
331
+ {
332
+ id: partId,
333
+ sessionID: sessionId,
334
+ messageID: messageId,
335
+ type: 'text',
336
+ text,
337
+ },
338
+ ]
339
+ : [];
340
+ const info = {
341
+ id: messageId,
342
+ sessionID: sessionId,
343
+ role: 'user',
344
+ time: { created: Date.now() },
345
+ };
346
+ useSyncStore.getState().optimisticAdd(sessionId, info, optimisticParts);
347
+ // No status write. `sessionStatus` is where the runtime's own SSE frames
348
+ // land, and a fabricated `busy` there outranked a real `GET .../turn` read
349
+ // stamped after it. `sendParts` files a `SendReceipt` instead — the claim
350
+ // this tab is actually entitled to make.
351
+ return { messageId, parts: [{ type: 'text', text, id: partId }] };
352
+ }
256
353
  /** The error body, wherever the transport parked it. */
257
354
  function errorBody(error) {
258
355
  if (!error || typeof error !== 'object')
@@ -379,23 +476,12 @@ export function sendStateOnStart(text) {
379
476
  export function sendStateOnError(error) {
380
477
  return { pending: null, sendError: classifySendError(error) };
381
478
  }
382
- export function beginRestPromptObservation(sessionId, runtimeScope) {
383
- beginSessionPromptObservation(sessionId, runtimeScope);
384
- useSyncStore.getState().setStatus(sessionId, { type: 'busy' });
385
- }
386
- export function endRestPromptObservation(sessionId, runtimeScope) {
387
- endSessionPromptObservation(sessionId, runtimeScope);
388
- useSyncStore.getState().setStatus(sessionId, { type: 'idle' });
389
- }
390
- export async function sendRestPromptWithObservation(sessionId, runtimeScope, sendPrompt) {
391
- beginRestPromptObservation(sessionId, runtimeScope);
392
- try {
393
- await sendPrompt();
394
- }
395
- catch (error) {
396
- endRestPromptObservation(sessionId, runtimeScope);
397
- throw error;
398
- }
479
+ /** The submission's tab-local name: the host's own key when it gave one, else
480
+ * the optimistic part id this send is correlated by. Never sent anywhere —
481
+ * it exists so a `working` state can say WHICH send it is standing on. */
482
+ export function sendReceiptId(sessionId, parts, clientMessageId) {
483
+ const identified = parts.find((part) => 'id' in part && typeof part.id === 'string');
484
+ return clientMessageId ?? identified?.id ?? sessionId;
399
485
  }
400
486
  export async function rewindOpenCodeSession(sessionId, messageId) {
401
487
  if (!messageId)
@@ -478,6 +564,7 @@ export function useSession(projectId, sessionId, options = {}) {
478
564
  retryDelay: (failureCount, error) => isSessionStartError(error) && error.status === 404
479
565
  ? FRESH_START_404_RETRY_DELAY_MS
480
566
  : Math.min(1000 * 2 ** failureCount, 5000),
567
+ staleTime: sessionStartStaleTime,
481
568
  ...SESSION_START_POLL_OPTIONS,
482
569
  });
483
570
  const startData = start.data ?? null;
@@ -486,6 +573,41 @@ export function useSession(projectId, sessionId, options = {}) {
486
573
  const sandbox = startData?.sandbox ?? null;
487
574
  const startReady = stage === 'ready';
488
575
  const terminal = stage === 'failed' || stage === 'stopped';
576
+ // T8 defect 1 — `useRuntimeReconnect`'s first-mount
577
+ // `resetForServerSwitch()` (a plain `useEffect`) unconditionally seeds
578
+ // `status:'connecting', healthy:null` UNLESS `markRuntimeReadyVerified()`
579
+ // already told it the server proved this session ready. Task A's staleTime
580
+ // (above) means a remount can render `stage==='ready'` on its VERY FIRST
581
+ // render — before ANY effect, this hook's own switch/health-seed effects
582
+ // included, has had a chance to run — so whichever effect happens to fire
583
+ // first is not something to gamble on.
584
+ //
585
+ // `useLayoutEffect` is what makes this deterministic regardless of
586
+ // component-tree shape: React runs EVERY layout effect, tree-wide, before
587
+ // ANY passive effect (`useEffect`) fires — so this always wins the race
588
+ // against `useRuntimeReconnect`'s reset, whether that hook lives in a
589
+ // parent, a child, or a sibling of this one.
590
+ //
591
+ // Gated on `[projectId, sessionId]` (session identity), not `[startReady]`:
592
+ // it must only ever answer "was this session ALREADY proven ready at the
593
+ // moment I started mounting" — never "did it become ready later". A real
594
+ // network `/start` completing seconds after this mount's own reset has
595
+ // ALREADY run (and cleared whatever the flag held) would just leave a
596
+ // stale "ready" flag sitting in sessionStorage for the NEXT, unrelated
597
+ // session's first mount to wrongly consume. That later-ready case needs no
598
+ // help from this flag anyway: the health-seed effect (step 3, below)
599
+ // writes the connection store directly once `switched` flips, and
600
+ // `resetForServerSwitch()` never runs a second time to undo it — see
601
+ // `use-runtime-reconnect.test.ts`'s "mount-time ordering" tests for both
602
+ // orderings converging.
603
+ useIsomorphicLayoutEffect(() => {
604
+ if (!startEnabled)
605
+ return;
606
+ const cached = queryClient.getQueryState(sessionStartKey(projectId, sessionId));
607
+ if (cachedStartResultIsReady(cached))
608
+ markRuntimeReadyVerified();
609
+ // eslint-disable-next-line react-hooks/exhaustive-deps
610
+ }, [projectId, sessionId]);
489
611
  // Track how long /start has been returning nothing usable — no data, no
490
612
  // error — so `computeStartSettled` can bound the "given up" case (see
491
613
  // START_INCONCLUSIVE_GIVE_UP_MS) instead of waiting on a poll that a
@@ -592,6 +714,30 @@ export function useSession(projectId, sessionId, options = {}) {
592
714
  titleRefreshAbortRef.current?.abort();
593
715
  titleRefreshAbortRef.current = null;
594
716
  }, [projectId, sessionId]);
717
+ // ONE answer to "is this session working?", and it says where it came from.
718
+ //
719
+ // Four machines used to answer it and disagree: a prompt-observation phase
720
+ // machine with a stall timer, a busy override in the sync layer, a 30s
721
+ // safety timeout, and a 5s polling grace. `working` is a pure projection
722
+ // over the server's turn authority, the session's durable prompt inbox, the
723
+ // live stream, and — only until a server source that CAN know about it
724
+ // answers — this tab's own send receipt.
725
+ //
726
+ // Computed BEFORE `useSessionSync` because it is that hook's liveness switch:
727
+ // the transcript poll must follow the projection, not the stream slot a
728
+ // dropped frame can leave idle through a whole turn.
729
+ const working = useSessionWorking(projectId, sessionId, {
730
+ enabled,
731
+ runtimeSessionId: ocSessionId,
732
+ });
733
+ // The receipt lives in a per-session store, not in this component: the
734
+ // composer mounts its own `useSessionWorking` for the same session and they
735
+ // share one `/turn` cache entry. Two private receipts meant the observer
736
+ // without one wrote an uninformed read into that entry and defeated the
737
+ // other's. Nothing clears it on a server answer and nothing needs to — an
738
+ // observation the server could make AFTER accepting the send outranks it —
739
+ // so only the paths that know nothing is coming drop it.
740
+ const { noteSendReceipt, acceptSendReceipt, clearSendReceipt, noteAbortReceipt, settleAbortReceipt } = useSessionWorkingStore.getState();
595
741
  // Always call the hook (rules-of-hooks) so it stays in the same position
596
742
  // every render, but starve it with an empty session id when the chat engine
597
743
  // is off — `useSessionSync('')` fetches/polls nothing (its effects no-op on
@@ -600,22 +746,29 @@ export function useSession(projectId, sessionId, options = {}) {
600
746
  const rawSync = useSessionSync(chatEngine ? ocSessionId : '', {
601
747
  kortixSessionScope: `${projectId}/${sessionId}`,
602
748
  networkEnabled: switched,
749
+ working: working.state === 'working',
603
750
  });
604
751
  const sync = chatEngine ? rawSync : DISABLED_CHAT_ENGINE_SYNC;
605
752
  const runtimePhase = useRuntimePhase();
606
- const [restRewind, setRestRewind] = useState(null);
753
+ // T22 — the revert record lives in the sync store, not component state.
754
+ // Component state made the boundary math wrong in two ways at once: it
755
+ // reset on every remount (stranding an invisible staged revert after a
756
+ // reload — the server still held the pointer, nothing here knew), and it
757
+ // was the ONLY thing to mirror server truth, so a `.staged`/`.cleared`/
758
+ // `.committed` wire event or another tab's action never reached this
759
+ // session's view at all. The store is the single place all of those
760
+ // sources (this hook's own `rewind()`/`restoreRewind()`, the three wire
761
+ // events via `handle-event.ts`, and a `Session.revert` field read on
762
+ // reload) converge — see `sync-store.ts`'s `sessionRevert` doc comment.
763
+ const restRewind = useSyncStore((s) => s.sessionRevert[ocSessionId] ?? null);
607
764
  const [rewindPending, setRewindPending] = useState(false);
608
765
  const [rewindError, setRewindError] = useState(null);
609
766
  const rewindMessageId = restRewind?.staged ? restRewind.messageId : null;
610
- const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind?.messageId ?? null), [sync.messages, restRewind?.messageId]);
767
+ const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind), [sync.messages, restRewind]);
611
768
  useEffect(() => {
612
- setRestRewind(null);
613
769
  setRewindPending(false);
614
770
  setRewindError(null);
615
771
  }, [sessionId, ocSessionId]);
616
- useEffect(() => {
617
- setRestRewind((current) => reconcileCommittedSessionRewind(sync.messages, current));
618
- }, [sync.messages]);
619
772
  // 5b. Self-heal a missed `question.asked` SSE event (a `question` tool part
620
773
  // rendering as running with nothing in the pending store) — see
621
774
  // `useQuestionSelfHeal` for why this is distinct from the SSE reconnect-gap
@@ -632,7 +785,59 @@ export function useSession(projectId, sessionId, options = {}) {
632
785
  // keyed by request id carrying sessionID). useSessionSync does NOT surface them.
633
786
  const questionMap = useOpenCodePendingStore((s) => s.questions);
634
787
  const permissionMap = useOpenCodePendingStore((s) => s.permissions);
635
- const isCompacting = useOpenCodeCompactionStore((state) => switched && Boolean(state.compactingBySession[ocSessionId]));
788
+ // Is this session COMPACTING? Two inputs, one projection — see
789
+ // `core/session/compaction.ts`. It used to be the raw client-only flag,
790
+ // cleared ONLY by the `session.compacted` SSE frame, so a missed frame pinned
791
+ // the composer for the lifetime of the tab and a compaction started anywhere
792
+ // else was invisible.
793
+ const optimisticCompactionAtMs = useOpenCodeCompactionStore((state) => switched ? (state.compactingBySession[ocSessionId] ?? null) : null);
794
+ // `Session.time.compacting` — the runtime's own record. This query is the
795
+ // same cache entry `session.compacted` and `session.updated` already write
796
+ // (`opencodeKeys.runtimeSession`), so reading it here costs no extra request
797
+ // in a host that mounts the session row anyway.
798
+ const runtimeSessionRow = useOpenCodeSession(switched ? ocSessionId : '');
799
+ const compactionInputs = {
800
+ optimisticAtMs: optimisticCompactionAtMs,
801
+ serverCompactingAtMs: runtimeSessionRow.data?.time?.compacting ?? null,
802
+ nowMs: Date.now(),
803
+ };
804
+ const isCompacting = projectCompacting(compactionInputs);
805
+ // The cap has to apply when NOTHING else re-renders — a lost
806
+ // `session.compacted` frame is exactly the case where no further event
807
+ // arrives — so the expiry instant is armed explicitly. Same mechanism, and
808
+ // the same reason, as `useSessionWorking`'s `workingExpiryAtMs` timer.
809
+ const [, setCompactionTick] = useState(0);
810
+ const compactionExpiry = compactionExpiryAtMs(compactionInputs);
811
+ useEffect(() => {
812
+ if (compactionExpiry === null)
813
+ return;
814
+ const timer = setTimeout(() => setCompactionTick((tick) => tick + 1), Math.max(0, compactionExpiry - Date.now()) + 1);
815
+ return () => clearTimeout(timer);
816
+ }, [compactionExpiry]);
817
+ // The server-observed branch of `projectCompacting` (rule 1) is otherwise
818
+ // unbounded: it stays authoritative until `opencodeKeys.runtimeSession`'s
819
+ // cached row is refreshed, and the only event wired to refresh it
820
+ // (`session.compacted`, in `use-opencode-events/handle-event.ts`) is one
821
+ // SSE frame that can be lost — a backgrounded tab, a stream reconnect, or
822
+ // that handler's own refetch failing. Without this, a lost frame pins
823
+ // `isCompacting` — and therefore the composer and slash-commands
824
+ // (`session-chat.tsx`'s `effectiveBusy`) — for the rest of the tab's life.
825
+ // This forces a REAL, retry-backed re-check (through the same query
826
+ // `useOpenCodeSession` above already runs, so it inherits its 3-attempt
827
+ // exponential-backoff retry) once the observed flag has gone unconfirmed
828
+ // for `SERVER_COMPACTION_REVALIDATE_MS` — independent of whether
829
+ // `session.compacted` ever arrives. If the server still reports
830
+ // compacting, the fresh timestamp re-arms this same timer; once it does
831
+ // not, `serverCompactingAtMs` clears and the projection follows.
832
+ const serverCompactionRevalidateAt = serverCompactionRevalidateAtMs(compactionInputs);
833
+ useEffect(() => {
834
+ if (serverCompactionRevalidateAt === null || !switched || !ocSessionId)
835
+ return;
836
+ const timer = setTimeout(() => {
837
+ void queryClient.invalidateQueries({ queryKey: opencodeKeys.runtimeSession(ocSessionId) });
838
+ }, Math.max(0, serverCompactionRevalidateAt - Date.now()));
839
+ return () => clearTimeout(timer);
840
+ }, [serverCompactionRevalidateAt, switched, ocSessionId, queryClient]);
636
841
  const removeQuestion = useOpenCodePendingStore((s) => s.removeQuestion);
637
842
  const removePermission = useOpenCodePendingStore((s) => s.removePermission);
638
843
  const questions = useMemo(() => (switched ? Object.values(questionMap).filter((q) => q.sessionID === ocSessionId) : []), [questionMap, ocSessionId, switched]);
@@ -669,17 +874,17 @@ export function useSession(projectId, sessionId, options = {}) {
669
874
  const [sendState, setSendState] = useState(IDLE_SEND_STATE);
670
875
  const pending = sendState.pending;
671
876
  const pendingBaseCount = useRef(0);
877
+ // Cleared when the send's echo lands — a real observation. The 30s timeout
878
+ // that used to sit beside this is deleted: no in-repo consumer reads
879
+ // `pending`, and where it could fire it fired wrongly — 30s is shorter than
880
+ // a legitimate inbox path (18.9s Daytona / 24.5s Platinum boot plus the
881
+ // drain). The sync store's optimistic message and the `SendReceipt` carry
882
+ // the state it approximated, each with an honest bound.
672
883
  useEffect(() => {
673
884
  if (pending && userMsgCount > pendingBaseCount.current) {
674
885
  setSendState((s) => (s.pending ? { ...s, pending: null } : s));
675
886
  }
676
887
  }, [userMsgCount, pending]);
677
- useEffect(() => {
678
- if (!pending)
679
- return;
680
- const t = setTimeout(() => setSendState((s) => (s.pending ? { ...s, pending: null } : s)), 30_000);
681
- return () => clearTimeout(t);
682
- }, [pending]);
683
888
  const sendParts = async (parts, override) => {
684
889
  if (!runtimeActionReady)
685
890
  throw new RuntimeNotReadyError();
@@ -700,19 +905,48 @@ export function useSession(projectId, sessionId, options = {}) {
700
905
  // which is what keeps it on screen for the whole of a slow upload instead
701
906
  // of being deleted by a rehydrate that only carries older turns.
702
907
  markDispatchedForPartIds(ocSessionId, parts);
703
- await sendRestPromptWithObservation(ocSessionId, sandbox?.external_id ?? undefined, () => sendMutation.mutateAsync({
704
- sessionId: ocSessionId,
705
- parts,
706
- ...(Object.keys(opts).length ? { options: opts } : {}),
707
- }));
708
- setRestRewind(commitSessionRewind);
908
+ const receipt = {
909
+ messageId: sendReceiptId(ocSessionId, parts, override?.clientMessageId),
910
+ atMs: Date.now(),
911
+ };
912
+ noteSendReceipt(sessionId, receipt);
913
+ try {
914
+ await sendMutation.mutateAsync({
915
+ sessionId: ocSessionId,
916
+ parts,
917
+ ...(Object.keys(opts).length ? { options: opts } : {}),
918
+ ...(override?.clientMessageId ? { clientMessageId: override.clientMessageId } : {}),
919
+ });
920
+ // The server has the prompt. From here — and NOT before — a `/turn` read
921
+ // is able to see it, so one is allowed to answer for it.
922
+ acceptSendReceipt(sessionId, receipt.messageId, Date.now());
923
+ }
924
+ catch (error) {
925
+ // The prompt never reached the server, so there is nothing to wait for.
926
+ // Dropping the receipt here is what stops a refused send from claiming
927
+ // `working` for a minute over a turn that will never start. NAMED, so a
928
+ // slow failure cannot drop a later send's receipt while its own request
929
+ // is still on the wire.
930
+ clearSendReceipt(sessionId, receipt.messageId);
931
+ throw error;
932
+ }
933
+ useSyncStore.getState().commitSessionRevert(ocSessionId);
709
934
  };
710
935
  const send = (text, override) => {
711
936
  if (!runtimeActionReady)
712
937
  return;
713
938
  pendingBaseCount.current = userMsgCount;
714
939
  setSendState(sendStateOnStart(text));
715
- void sendParts([{ type: 'text', text }], override).catch((error) => {
940
+ // T15 — one user bubble: the optimistic message and the outgoing
941
+ // part share one minted id, so the server's echo can be correlated back
942
+ // to it instead of leaving both on screen until the idle sweep.
943
+ const { messageId, parts } = beginOptimisticPlainTextSend(ocSessionId, text);
944
+ void sendParts(parts, override).catch((error) => {
945
+ // The send never reached (or was refused by) the server — nothing to
946
+ // rehydrate, so drop the optimistic message outright rather than leave
947
+ // it stranded forever (mirrors `abandonOptimisticSend`).
948
+ // `sendParts` already dropped this tab's send receipt on this path.
949
+ useSyncStore.getState().optimisticRemove(ocSessionId, messageId);
716
950
  setSendState(sendStateOnError(error));
717
951
  });
718
952
  };
@@ -727,14 +961,14 @@ export function useSession(projectId, sessionId, options = {}) {
727
961
  throw new RuntimeNotReadyError();
728
962
  if (!messageId)
729
963
  throw new Error('Session rewind requires a message id');
730
- if (sync.isBusy || pending || rewindPending) {
964
+ if (working.state === 'working' || rewindPending) {
731
965
  throw new Error('Cannot rewind a busy session');
732
966
  }
733
967
  setRewindPending(true);
734
968
  setRewindError(null);
735
969
  try {
736
970
  await rewindOpenCodeSession(ocSessionId, messageId);
737
- setRestRewind({ messageId, staged: true });
971
+ useSyncStore.getState().stageSessionRevert(ocSessionId, messageId);
738
972
  }
739
973
  catch (error) {
740
974
  const classified = classifySendError(error);
@@ -754,7 +988,7 @@ export function useSession(projectId, sessionId, options = {}) {
754
988
  setRewindError(null);
755
989
  try {
756
990
  await restoreOpenCodeSessionRewind(ocSessionId);
757
- setRestRewind(null);
991
+ useSyncStore.getState().clearSessionRevert(ocSessionId);
758
992
  }
759
993
  catch (error) {
760
994
  const classified = classifySendError(error);
@@ -765,15 +999,45 @@ export function useSession(projectId, sessionId, options = {}) {
765
999
  setRewindPending(false);
766
1000
  }
767
1001
  };
768
- // The one true cancel: abort the run AND drop any pending prompt + open prompts.
1002
+ // The one true cancel: abort the run AND drop any pending prompt + open
1003
+ // prompts. Returns a promise that settles once the abort is acknowledged —
1004
+ // the mutation resolved (and, per `abortOpenCodeSession`, the session's
1005
+ // status was re-read to confirm idle), the mutation failed after its own
1006
+ // retries, or a bounded ~5s timeout elapsed. See `AbortSettlement`.
1007
+ //
1008
+ // T9: `abortInFlightDeliveries` runs FIRST, synchronously — a prompt
1009
+ // still retrying its boot/wake backoff when the user hits Stop must never
1010
+ // land after this point and run against the old text. The optimistic UI
1011
+ // (busy → idle, questions/permissions cleared) still updates instantly;
1012
+ // only the returned promise is new — a caller that never awaits it sees
1013
+ // exactly the same synchronous effects as before.
769
1014
  const cancel = () => {
770
1015
  if (runtimeActionReady) {
771
- endRestPromptObservation(ocSessionId);
772
- abortMutation.mutate(ocSessionId);
1016
+ clearSendReceipt(sessionId);
1017
+ // The stop's own receipt. The cancel needs a round trip through the
1018
+ // control plane and the daemon before turn authority is released, so
1019
+ // every `/turn` read issued before it settles still reports the doomed
1020
+ // turn — including the one the optimistic idle frame below triggers.
1021
+ // Without this the composer flipped Send back to Stop ~120ms after the
1022
+ // click and stayed there for the whole abort. See `AbortReceipt`.
1023
+ noteAbortReceipt(sessionId, Date.now());
1024
+ // No fabricated idle frame here: the receipt above IS the optimistic
1025
+ // idle, with provenance and a bound. A fabricated frame outranked the
1026
+ // control plane's `/turn` answer in `projectWorking` for the whole
1027
+ // abort round-trip — the laundering this migration removes.
1028
+ abortInFlightDeliveries(ocSessionId);
773
1029
  }
774
1030
  questions.forEach((q) => removeQuestion(q.id));
775
1031
  permissions.forEach((p) => removePermission(p.id));
776
1032
  setSendState(IDLE_SEND_STATE);
1033
+ if (!runtimeActionReady)
1034
+ return Promise.resolve({ status: 'skipped' });
1035
+ const settlement = awaitAbortSettlement(() => abortMutation.mutateAsync(ocSessionId));
1036
+ // `awaitAbortSettlement` never rejects — it resolves with how the abort
1037
+ // ended (acknowledged, failed, or timed out). Any of those is the instant
1038
+ // from which a server read can see the abort's effect, or fail to.
1039
+ void settlement.then(() => settleAbortReceipt(sessionId, Date.now()));
1040
+ return settlement;
777
1041
  };
778
1042
  const runtimeSessionError = canonicalSession.error;
779
1043
  const phase = derivePhase({
@@ -809,6 +1073,13 @@ export function useSession(projectId, sessionId, options = {}) {
809
1073
  picks.setModel(stash.model);
810
1074
  if (stash.agent)
811
1075
  picks.setAgent(stash.agent);
1076
+ // A picks-only stash (`prompt: ''`) seeds the selections above and sends
1077
+ // NOTHING: apps/web's producers now deliver the first prompt as a durable
1078
+ // inbox row (server-side at create, or `startSessionWithPrompt`), and the
1079
+ // stash carries only the model/agent hand-off. A full stash — the
1080
+ // whitelabel-demo producer — still replays here exactly as before.
1081
+ if (!stash.prompt.trim())
1082
+ return;
812
1083
  send(stash.prompt, {
813
1084
  model: stash.model,
814
1085
  agent: stash.agent,
@@ -852,7 +1123,10 @@ export function useSession(projectId, sessionId, options = {}) {
852
1123
  failure: startData?.failure ?? null,
853
1124
  /** Granular boot phase (connecting|booting|ready|unreachable) for detailed UI. */
854
1125
  runtimePhase,
855
- isBusy: sync.isBusy || !!pending,
1126
+ /** Provenance-tagged working state — WHICH observation decided it, when,
1127
+ * and for which turn. The one answer; `isBusy` is its boolean face. */
1128
+ working,
1129
+ isBusy: working.state === 'working',
856
1130
  isCompacting,
857
1131
  isLoading: sync.isLoading,
858
1132
  isError: terminal || !!startError || !!runtimeSessionError,