@kortix/sdk 0.12.8 → 0.13.1

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 (274) hide show
  1. package/README.md +3 -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/session-sync/session-transcript-cache.d.ts +9 -2
  7. package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
  8. package/dist/browser/session-sync/session-transcript-cache.js +15 -5
  9. package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
  10. package/dist/browser/stores/opencode-compaction-store.d.ts +9 -3
  11. package/dist/browser/stores/opencode-compaction-store.d.ts.map +1 -1
  12. package/dist/browser/stores/opencode-compaction-store.js +5 -12
  13. package/dist/browser/stores/opencode-compaction-store.js.map +1 -1
  14. package/dist/browser/stores/sandbox-connection-store.d.ts +28 -17
  15. package/dist/browser/stores/sandbox-connection-store.d.ts.map +1 -1
  16. package/dist/browser/stores/sandbox-connection-store.js +32 -84
  17. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  18. package/dist/browser/stores/session-working-store.d.ts +73 -0
  19. package/dist/browser/stores/session-working-store.d.ts.map +1 -0
  20. package/dist/browser/stores/session-working-store.js +86 -0
  21. package/dist/browser/stores/session-working-store.js.map +1 -0
  22. package/dist/browser/stores/sync-store/types.d.ts +14 -4
  23. package/dist/browser/stores/sync-store/types.d.ts.map +1 -1
  24. package/dist/browser/stores/sync-store.d.ts +179 -4
  25. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  26. package/dist/browser/stores/sync-store.js +777 -65
  27. package/dist/browser/stores/sync-store.js.map +1 -1
  28. package/dist/core/client/kortix.d.ts +95 -0
  29. package/dist/core/client/kortix.d.ts.map +1 -1
  30. package/dist/core/client/kortix.js +78 -0
  31. package/dist/core/client/kortix.js.map +1 -1
  32. package/dist/core/files/client.d.ts +50 -2
  33. package/dist/core/files/client.d.ts.map +1 -1
  34. package/dist/core/files/client.js +253 -44
  35. package/dist/core/files/client.js.map +1 -1
  36. package/dist/core/files/types.d.ts +11 -0
  37. package/dist/core/files/types.d.ts.map +1 -1
  38. package/dist/core/http/abort-error.d.ts +89 -0
  39. package/dist/core/http/abort-error.d.ts.map +1 -0
  40. package/dist/core/http/abort-error.js +190 -0
  41. package/dist/core/http/abort-error.js.map +1 -0
  42. package/dist/core/http/api-client.d.ts.map +1 -1
  43. package/dist/core/http/api-client.js +18 -4
  44. package/dist/core/http/api-client.js.map +1 -1
  45. package/dist/core/http/opencode-errors.d.ts +7 -0
  46. package/dist/core/http/opencode-errors.d.ts.map +1 -1
  47. package/dist/core/http/opencode-errors.js +45 -13
  48. package/dist/core/http/opencode-errors.js.map +1 -1
  49. package/dist/core/rest/platform-client/github-app.d.ts +13 -0
  50. package/dist/core/rest/platform-client/github-app.d.ts.map +1 -1
  51. package/dist/core/rest/platform-client/github-app.js +2 -0
  52. package/dist/core/rest/platform-client/github-app.js.map +1 -1
  53. package/dist/core/rest/platform-client/members.d.ts +10 -64
  54. package/dist/core/rest/platform-client/members.d.ts.map +1 -1
  55. package/dist/core/rest/platform-client/members.js +10 -28
  56. package/dist/core/rest/platform-client/members.js.map +1 -1
  57. package/dist/core/rest/projects-client/accounts.d.ts +30 -5
  58. package/dist/core/rest/projects-client/accounts.d.ts.map +1 -1
  59. package/dist/core/rest/projects-client/accounts.js +1 -1
  60. package/dist/core/rest/projects-client/accounts.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 +10 -1
  69. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  70. package/dist/core/rest/projects-client/billing.js +11 -2
  71. package/dist/core/rest/projects-client/billing.js.map +1 -1
  72. package/dist/core/rest/projects-client/connectors.d.ts +69 -1
  73. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  74. package/dist/core/rest/projects-client/connectors.js +17 -0
  75. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  76. package/dist/core/rest/projects-client/files.js +2 -2
  77. package/dist/core/rest/projects-client/files.js.map +1 -1
  78. package/dist/core/rest/projects-client/gateway.d.ts +51 -0
  79. package/dist/core/rest/projects-client/gateway.d.ts.map +1 -1
  80. package/dist/core/rest/projects-client/gateway.js.map +1 -1
  81. package/dist/core/rest/projects-client/iam.d.ts +51 -3
  82. package/dist/core/rest/projects-client/iam.d.ts.map +1 -1
  83. package/dist/core/rest/projects-client/iam.js +18 -1
  84. package/dist/core/rest/projects-client/iam.js.map +1 -1
  85. package/dist/core/rest/projects-client/index.d.ts +1 -0
  86. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  87. package/dist/core/rest/projects-client/index.js +1 -0
  88. package/dist/core/rest/projects-client/index.js.map +1 -1
  89. package/dist/core/rest/projects-client/projects.d.ts +1 -1
  90. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  91. package/dist/core/rest/projects-client/projects.js +2 -0
  92. package/dist/core/rest/projects-client/projects.js.map +1 -1
  93. package/dist/core/rest/projects-client/secrets.d.ts +19 -1
  94. package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
  95. package/dist/core/rest/projects-client/secrets.js +1 -1
  96. package/dist/core/rest/projects-client/secrets.js.map +1 -1
  97. package/dist/core/rest/projects-client/session-costs.d.ts +33 -0
  98. package/dist/core/rest/projects-client/session-costs.d.ts.map +1 -1
  99. package/dist/core/rest/projects-client/session-costs.js.map +1 -1
  100. package/dist/core/rest/projects-client/sessions.d.ts +261 -2
  101. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  102. package/dist/core/rest/projects-client/sessions.js +106 -4
  103. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  104. package/dist/core/rest/projects-client/shared.d.ts +10 -1
  105. package/dist/core/rest/projects-client/shared.d.ts.map +1 -1
  106. package/dist/core/rest/projects-client/shared.js.map +1 -1
  107. package/dist/core/rest/projects-client/tokens.d.ts +13 -1
  108. package/dist/core/rest/projects-client/tokens.d.ts.map +1 -1
  109. package/dist/core/rest/projects-client/tokens.js +7 -2
  110. package/dist/core/rest/projects-client/tokens.js.map +1 -1
  111. package/dist/core/rest/projects-client/triggers.d.ts +69 -2
  112. package/dist/core/rest/projects-client/triggers.d.ts.map +1 -1
  113. package/dist/core/rest/projects-client/triggers.js +1 -1
  114. package/dist/core/rest/projects-client/triggers.js.map +1 -1
  115. package/dist/core/session/compaction.d.ts +98 -0
  116. package/dist/core/session/compaction.d.ts.map +1 -0
  117. package/dist/core/session/compaction.js +108 -0
  118. package/dist/core/session/compaction.js.map +1 -0
  119. package/dist/core/session/health.d.ts +22 -0
  120. package/dist/core/session/health.d.ts.map +1 -1
  121. package/dist/core/session/health.js +31 -5
  122. package/dist/core/session/health.js.map +1 -1
  123. package/dist/core/session/index.d.ts +2 -0
  124. package/dist/core/session/index.d.ts.map +1 -1
  125. package/dist/core/session/index.js +2 -0
  126. package/dist/core/session/index.js.map +1 -1
  127. package/dist/core/session/message-queue.d.ts +79 -15
  128. package/dist/core/session/message-queue.d.ts.map +1 -1
  129. package/dist/core/session/message-queue.js +73 -27
  130. package/dist/core/session/message-queue.js.map +1 -1
  131. package/dist/core/session/rewind.d.ts +76 -10
  132. package/dist/core/session/rewind.d.ts.map +1 -1
  133. package/dist/core/session/rewind.js +68 -11
  134. package/dist/core/session/rewind.js.map +1 -1
  135. package/dist/core/session/working.d.ts +241 -0
  136. package/dist/core/session/working.d.ts.map +1 -0
  137. package/dist/core/session/working.js +261 -0
  138. package/dist/core/session/working.js.map +1 -0
  139. package/dist/core/session-sync/session-sync-controller.d.ts +35 -20
  140. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  141. package/dist/core/session-sync/session-sync-controller.js +48 -115
  142. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  143. package/dist/core/stream/event-stream.d.ts +22 -0
  144. package/dist/core/stream/event-stream.d.ts.map +1 -1
  145. package/dist/core/stream/event-stream.js +132 -36
  146. package/dist/core/stream/event-stream.js.map +1 -1
  147. package/dist/core/turns/grouping.d.ts +18 -2
  148. package/dist/core/turns/grouping.d.ts.map +1 -1
  149. package/dist/core/turns/grouping.js +45 -2
  150. package/dist/core/turns/grouping.js.map +1 -1
  151. package/dist/core/turns/index.d.ts +1 -0
  152. package/dist/core/turns/index.d.ts.map +1 -1
  153. package/dist/core/turns/index.js +1 -0
  154. package/dist/core/turns/index.js.map +1 -1
  155. package/dist/core/turns/open-turn.d.ts +81 -0
  156. package/dist/core/turns/open-turn.d.ts.map +1 -0
  157. package/dist/core/turns/open-turn.js +77 -0
  158. package/dist/core/turns/open-turn.js.map +1 -0
  159. package/dist/core/turns/state.js +26 -26
  160. package/dist/core/turns/state.js.map +1 -1
  161. package/dist/core/turns/types.d.ts +5 -0
  162. package/dist/core/turns/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +2 -1
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +2 -1
  166. package/dist/index.js.map +1 -1
  167. package/dist/kortix.esm.min.js +25 -25
  168. package/dist/kortix.global.js +25 -25
  169. package/dist/node/server.d.ts.map +1 -1
  170. package/dist/node/server.js +14 -2
  171. package/dist/node/server.js.map +1 -1
  172. package/dist/react/index.d.ts +2 -1
  173. package/dist/react/index.d.ts.map +1 -1
  174. package/dist/react/index.js +7 -1
  175. package/dist/react/index.js.map +1 -1
  176. package/dist/react/initial-session-pin.d.ts +17 -0
  177. package/dist/react/initial-session-pin.d.ts.map +1 -1
  178. package/dist/react/initial-session-pin.js +58 -0
  179. package/dist/react/initial-session-pin.js.map +1 -1
  180. package/dist/react/opencode.d.ts +6 -2
  181. package/dist/react/opencode.d.ts.map +1 -1
  182. package/dist/react/opencode.js +5 -1
  183. package/dist/react/opencode.js.map +1 -1
  184. package/dist/react/prefetch-session-start.d.ts +10 -4
  185. package/dist/react/prefetch-session-start.d.ts.map +1 -1
  186. package/dist/react/prefetch-session-start.js +10 -4
  187. package/dist/react/prefetch-session-start.js.map +1 -1
  188. package/dist/react/query-contracts.d.ts +2 -0
  189. package/dist/react/query-contracts.d.ts.map +1 -1
  190. package/dist/react/query-contracts.js +2 -0
  191. package/dist/react/query-contracts.js.map +1 -1
  192. package/dist/react/query-keys.d.ts +13 -2
  193. package/dist/react/query-keys.d.ts.map +1 -1
  194. package/dist/react/query-keys.js +13 -2
  195. package/dist/react/query-keys.js.map +1 -1
  196. package/dist/react/session-start-stash.d.ts +25 -0
  197. package/dist/react/session-start-stash.d.ts.map +1 -1
  198. package/dist/react/session-start-stash.js +34 -3
  199. package/dist/react/session-start-stash.js.map +1 -1
  200. package/dist/react/session-title-sync.d.ts +5 -1
  201. package/dist/react/session-title-sync.d.ts.map +1 -1
  202. package/dist/react/session-title-sync.js +16 -4
  203. package/dist/react/session-title-sync.js.map +1 -1
  204. package/dist/react/use-can.d.ts +105 -0
  205. package/dist/react/use-can.d.ts.map +1 -0
  206. package/dist/react/use-can.js +190 -0
  207. package/dist/react/use-can.js.map +1 -0
  208. package/dist/react/use-canonical-opencode-session.d.ts +6 -1
  209. package/dist/react/use-canonical-opencode-session.d.ts.map +1 -1
  210. package/dist/react/use-canonical-opencode-session.js +46 -3
  211. package/dist/react/use-canonical-opencode-session.js.map +1 -1
  212. package/dist/react/use-opencode-events/handle-event.d.ts +10 -0
  213. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  214. package/dist/react/use-opencode-events/handle-event.js +76 -7
  215. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  216. package/dist/react/use-opencode-events/helpers.d.ts +21 -8
  217. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  218. package/dist/react/use-opencode-events/helpers.js +25 -26
  219. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  220. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  221. package/dist/react/use-opencode-events/index.js +43 -26
  222. package/dist/react/use-opencode-events/index.js.map +1 -1
  223. package/dist/react/use-opencode-events/use-event-stream-refs.d.ts.map +1 -1
  224. package/dist/react/use-opencode-events/use-event-stream-refs.js +66 -3
  225. package/dist/react/use-opencode-events/use-event-stream-refs.js.map +1 -1
  226. package/dist/react/use-opencode-local.d.ts +17 -0
  227. package/dist/react/use-opencode-local.d.ts.map +1 -1
  228. package/dist/react/use-opencode-local.js +41 -7
  229. package/dist/react/use-opencode-local.js.map +1 -1
  230. package/dist/react/use-opencode-sessions/messages.d.ts +101 -1
  231. package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
  232. package/dist/react/use-opencode-sessions/messages.js +481 -63
  233. package/dist/react/use-opencode-sessions/messages.js.map +1 -1
  234. package/dist/react/use-project-apps.d.ts +24 -1
  235. package/dist/react/use-project-apps.d.ts.map +1 -1
  236. package/dist/react/use-project-apps.js +5 -3
  237. package/dist/react/use-project-apps.js.map +1 -1
  238. package/dist/react/use-runtime-boot-stalled.d.ts +32 -0
  239. package/dist/react/use-runtime-boot-stalled.d.ts.map +1 -0
  240. package/dist/react/use-runtime-boot-stalled.js +48 -0
  241. package/dist/react/use-runtime-boot-stalled.js.map +1 -0
  242. package/dist/react/use-runtime-reconnect.d.ts +67 -1
  243. package/dist/react/use-runtime-reconnect.d.ts.map +1 -1
  244. package/dist/react/use-runtime-reconnect.js +116 -18
  245. package/dist/react/use-runtime-reconnect.js.map +1 -1
  246. package/dist/react/use-session-prompts.d.ts +113 -0
  247. package/dist/react/use-session-prompts.d.ts.map +1 -0
  248. package/dist/react/use-session-prompts.js +250 -0
  249. package/dist/react/use-session-prompts.js.map +1 -0
  250. package/dist/react/use-session-send.d.ts +169 -20
  251. package/dist/react/use-session-send.d.ts.map +1 -1
  252. package/dist/react/use-session-send.js +183 -30
  253. package/dist/react/use-session-send.js.map +1 -1
  254. package/dist/react/use-session-sync.d.ts +35 -2
  255. package/dist/react/use-session-sync.d.ts.map +1 -1
  256. package/dist/react/use-session-sync.js +49 -11
  257. package/dist/react/use-session-sync.js.map +1 -1
  258. package/dist/react/use-session-working.d.ts +57 -0
  259. package/dist/react/use-session-working.d.ts.map +1 -0
  260. package/dist/react/use-session-working.js +178 -0
  261. package/dist/react/use-session-working.js.map +1 -0
  262. package/dist/react/use-session.d.ts +131 -5
  263. package/dist/react/use-session.d.ts.map +1 -1
  264. package/dist/react/use-session.js +358 -69
  265. package/dist/react/use-session.js.map +1 -1
  266. package/package.json +2 -2
  267. package/dist/browser/stores/tab-store.d.ts +0 -91
  268. package/dist/browser/stores/tab-store.d.ts.map +0 -1
  269. package/dist/browser/stores/tab-store.js +0 -369
  270. package/dist/browser/stores/tab-store.js.map +0 -1
  271. package/dist/core/session/send-queue.d.ts +0 -70
  272. package/dist/core/session/send-queue.d.ts.map +0 -1
  273. package/dist/core/session/send-queue.js +0 -116
  274. 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,60 @@ 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
+ // No `time.created`: display order is by the BOX's `time.created`
345
+ // (`compareMessagesForDisplay`), and a stub stamped from the browser's
346
+ // clock sorted ABOVE real messages whenever the box ran behind it. An
347
+ // untimed stub is "the newest thing the user did", on every clock.
348
+ time: {},
349
+ };
350
+ useSyncStore.getState().optimisticAdd(sessionId, info, optimisticParts);
351
+ // No status write. `sessionStatus` is where the runtime's own SSE frames
352
+ // land, and a fabricated `busy` there outranked a real `GET .../turn` read
353
+ // stamped after it. `sendParts` files a `SendReceipt` instead — the claim
354
+ // this tab is actually entitled to make.
355
+ return { messageId, parts: [{ type: 'text', text, id: partId }] };
356
+ }
256
357
  /** The error body, wherever the transport parked it. */
257
358
  function errorBody(error) {
258
359
  if (!error || typeof error !== 'object')
@@ -379,23 +480,12 @@ export function sendStateOnStart(text) {
379
480
  export function sendStateOnError(error) {
380
481
  return { pending: null, sendError: classifySendError(error) };
381
482
  }
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
- }
483
+ /** The submission's tab-local name: the host's own key when it gave one, else
484
+ * the optimistic part id this send is correlated by. Never sent anywhere —
485
+ * it exists so a `working` state can say WHICH send it is standing on. */
486
+ export function sendReceiptId(sessionId, parts, clientMessageId) {
487
+ const identified = parts.find((part) => 'id' in part && typeof part.id === 'string');
488
+ return clientMessageId ?? identified?.id ?? sessionId;
399
489
  }
400
490
  export async function rewindOpenCodeSession(sessionId, messageId) {
401
491
  if (!messageId)
@@ -478,6 +568,7 @@ export function useSession(projectId, sessionId, options = {}) {
478
568
  retryDelay: (failureCount, error) => isSessionStartError(error) && error.status === 404
479
569
  ? FRESH_START_404_RETRY_DELAY_MS
480
570
  : Math.min(1000 * 2 ** failureCount, 5000),
571
+ staleTime: sessionStartStaleTime,
481
572
  ...SESSION_START_POLL_OPTIONS,
482
573
  });
483
574
  const startData = start.data ?? null;
@@ -486,6 +577,41 @@ export function useSession(projectId, sessionId, options = {}) {
486
577
  const sandbox = startData?.sandbox ?? null;
487
578
  const startReady = stage === 'ready';
488
579
  const terminal = stage === 'failed' || stage === 'stopped';
580
+ // T8 defect 1 — `useRuntimeReconnect`'s first-mount
581
+ // `resetForServerSwitch()` (a plain `useEffect`) unconditionally seeds
582
+ // `status:'connecting', healthy:null` UNLESS `markRuntimeReadyVerified()`
583
+ // already told it the server proved this session ready. Task A's staleTime
584
+ // (above) means a remount can render `stage==='ready'` on its VERY FIRST
585
+ // render — before ANY effect, this hook's own switch/health-seed effects
586
+ // included, has had a chance to run — so whichever effect happens to fire
587
+ // first is not something to gamble on.
588
+ //
589
+ // `useLayoutEffect` is what makes this deterministic regardless of
590
+ // component-tree shape: React runs EVERY layout effect, tree-wide, before
591
+ // ANY passive effect (`useEffect`) fires — so this always wins the race
592
+ // against `useRuntimeReconnect`'s reset, whether that hook lives in a
593
+ // parent, a child, or a sibling of this one.
594
+ //
595
+ // Gated on `[projectId, sessionId]` (session identity), not `[startReady]`:
596
+ // it must only ever answer "was this session ALREADY proven ready at the
597
+ // moment I started mounting" — never "did it become ready later". A real
598
+ // network `/start` completing seconds after this mount's own reset has
599
+ // ALREADY run (and cleared whatever the flag held) would just leave a
600
+ // stale "ready" flag sitting in sessionStorage for the NEXT, unrelated
601
+ // session's first mount to wrongly consume. That later-ready case needs no
602
+ // help from this flag anyway: the health-seed effect (step 3, below)
603
+ // writes the connection store directly once `switched` flips, and
604
+ // `resetForServerSwitch()` never runs a second time to undo it — see
605
+ // `use-runtime-reconnect.test.ts`'s "mount-time ordering" tests for both
606
+ // orderings converging.
607
+ useIsomorphicLayoutEffect(() => {
608
+ if (!startEnabled)
609
+ return;
610
+ const cached = queryClient.getQueryState(sessionStartKey(projectId, sessionId));
611
+ if (cachedStartResultIsReady(cached))
612
+ markRuntimeReadyVerified();
613
+ // eslint-disable-next-line react-hooks/exhaustive-deps
614
+ }, [projectId, sessionId]);
489
615
  // Track how long /start has been returning nothing usable — no data, no
490
616
  // error — so `computeStartSettled` can bound the "given up" case (see
491
617
  // START_INCONCLUSIVE_GIVE_UP_MS) instead of waiting on a poll that a
@@ -592,6 +718,30 @@ export function useSession(projectId, sessionId, options = {}) {
592
718
  titleRefreshAbortRef.current?.abort();
593
719
  titleRefreshAbortRef.current = null;
594
720
  }, [projectId, sessionId]);
721
+ // ONE answer to "is this session working?", and it says where it came from.
722
+ //
723
+ // Four machines used to answer it and disagree: a prompt-observation phase
724
+ // machine with a stall timer, a busy override in the sync layer, a 30s
725
+ // safety timeout, and a 5s polling grace. `working` is a pure projection
726
+ // over the server's turn authority, the session's durable prompt inbox, the
727
+ // live stream, and — only until a server source that CAN know about it
728
+ // answers — this tab's own send receipt.
729
+ //
730
+ // Computed BEFORE `useSessionSync` because it is that hook's liveness switch:
731
+ // the transcript poll must follow the projection, not the stream slot a
732
+ // dropped frame can leave idle through a whole turn.
733
+ const working = useSessionWorking(projectId, sessionId, {
734
+ enabled,
735
+ runtimeSessionId: ocSessionId,
736
+ });
737
+ // The receipt lives in a per-session store, not in this component: the
738
+ // composer mounts its own `useSessionWorking` for the same session and they
739
+ // share one `/turn` cache entry. Two private receipts meant the observer
740
+ // without one wrote an uninformed read into that entry and defeated the
741
+ // other's. Nothing clears it on a server answer and nothing needs to — an
742
+ // observation the server could make AFTER accepting the send outranks it —
743
+ // so only the paths that know nothing is coming drop it.
744
+ const { noteSendReceipt, acceptSendReceipt, clearSendReceipt, noteAbortReceipt, settleAbortReceipt } = useSessionWorkingStore.getState();
595
745
  // Always call the hook (rules-of-hooks) so it stays in the same position
596
746
  // every render, but starve it with an empty session id when the chat engine
597
747
  // is off — `useSessionSync('')` fetches/polls nothing (its effects no-op on
@@ -600,22 +750,29 @@ export function useSession(projectId, sessionId, options = {}) {
600
750
  const rawSync = useSessionSync(chatEngine ? ocSessionId : '', {
601
751
  kortixSessionScope: `${projectId}/${sessionId}`,
602
752
  networkEnabled: switched,
753
+ working: working.state === 'working',
603
754
  });
604
755
  const sync = chatEngine ? rawSync : DISABLED_CHAT_ENGINE_SYNC;
605
756
  const runtimePhase = useRuntimePhase();
606
- const [restRewind, setRestRewind] = useState(null);
757
+ // T22 — the revert record lives in the sync store, not component state.
758
+ // Component state made the boundary math wrong in two ways at once: it
759
+ // reset on every remount (stranding an invisible staged revert after a
760
+ // reload — the server still held the pointer, nothing here knew), and it
761
+ // was the ONLY thing to mirror server truth, so a `.staged`/`.cleared`/
762
+ // `.committed` wire event or another tab's action never reached this
763
+ // session's view at all. The store is the single place all of those
764
+ // sources (this hook's own `rewind()`/`restoreRewind()`, the three wire
765
+ // events via `handle-event.ts`, and a `Session.revert` field read on
766
+ // reload) converge — see `sync-store.ts`'s `sessionRevert` doc comment.
767
+ const restRewind = useSyncStore((s) => s.sessionRevert[ocSessionId] ?? null);
607
768
  const [rewindPending, setRewindPending] = useState(false);
608
769
  const [rewindError, setRewindError] = useState(null);
609
770
  const rewindMessageId = restRewind?.staged ? restRewind.messageId : null;
610
- const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind?.messageId ?? null), [sync.messages, restRewind?.messageId]);
771
+ const messages = useMemo(() => messagesBeforeRewind(sync.messages, restRewind), [sync.messages, restRewind]);
611
772
  useEffect(() => {
612
- setRestRewind(null);
613
773
  setRewindPending(false);
614
774
  setRewindError(null);
615
775
  }, [sessionId, ocSessionId]);
616
- useEffect(() => {
617
- setRestRewind((current) => reconcileCommittedSessionRewind(sync.messages, current));
618
- }, [sync.messages]);
619
776
  // 5b. Self-heal a missed `question.asked` SSE event (a `question` tool part
620
777
  // rendering as running with nothing in the pending store) — see
621
778
  // `useQuestionSelfHeal` for why this is distinct from the SSE reconnect-gap
@@ -632,7 +789,59 @@ export function useSession(projectId, sessionId, options = {}) {
632
789
  // keyed by request id carrying sessionID). useSessionSync does NOT surface them.
633
790
  const questionMap = useOpenCodePendingStore((s) => s.questions);
634
791
  const permissionMap = useOpenCodePendingStore((s) => s.permissions);
635
- const isCompacting = useOpenCodeCompactionStore((state) => switched && Boolean(state.compactingBySession[ocSessionId]));
792
+ // Is this session COMPACTING? Two inputs, one projection — see
793
+ // `core/session/compaction.ts`. It used to be the raw client-only flag,
794
+ // cleared ONLY by the `session.compacted` SSE frame, so a missed frame pinned
795
+ // the composer for the lifetime of the tab and a compaction started anywhere
796
+ // else was invisible.
797
+ const optimisticCompactionAtMs = useOpenCodeCompactionStore((state) => switched ? (state.compactingBySession[ocSessionId] ?? null) : null);
798
+ // `Session.time.compacting` — the runtime's own record. This query is the
799
+ // same cache entry `session.compacted` and `session.updated` already write
800
+ // (`opencodeKeys.runtimeSession`), so reading it here costs no extra request
801
+ // in a host that mounts the session row anyway.
802
+ const runtimeSessionRow = useOpenCodeSession(switched ? ocSessionId : '');
803
+ const compactionInputs = {
804
+ optimisticAtMs: optimisticCompactionAtMs,
805
+ serverCompactingAtMs: runtimeSessionRow.data?.time?.compacting ?? null,
806
+ nowMs: Date.now(),
807
+ };
808
+ const isCompacting = projectCompacting(compactionInputs);
809
+ // The cap has to apply when NOTHING else re-renders — a lost
810
+ // `session.compacted` frame is exactly the case where no further event
811
+ // arrives — so the expiry instant is armed explicitly. Same mechanism, and
812
+ // the same reason, as `useSessionWorking`'s `workingExpiryAtMs` timer.
813
+ const [, setCompactionTick] = useState(0);
814
+ const compactionExpiry = compactionExpiryAtMs(compactionInputs);
815
+ useEffect(() => {
816
+ if (compactionExpiry === null)
817
+ return;
818
+ const timer = setTimeout(() => setCompactionTick((tick) => tick + 1), Math.max(0, compactionExpiry - Date.now()) + 1);
819
+ return () => clearTimeout(timer);
820
+ }, [compactionExpiry]);
821
+ // The server-observed branch of `projectCompacting` (rule 1) is otherwise
822
+ // unbounded: it stays authoritative until `opencodeKeys.runtimeSession`'s
823
+ // cached row is refreshed, and the only event wired to refresh it
824
+ // (`session.compacted`, in `use-opencode-events/handle-event.ts`) is one
825
+ // SSE frame that can be lost — a backgrounded tab, a stream reconnect, or
826
+ // that handler's own refetch failing. Without this, a lost frame pins
827
+ // `isCompacting` — and therefore the composer and slash-commands
828
+ // (`session-chat.tsx`'s `effectiveBusy`) — for the rest of the tab's life.
829
+ // This forces a REAL, retry-backed re-check (through the same query
830
+ // `useOpenCodeSession` above already runs, so it inherits its 3-attempt
831
+ // exponential-backoff retry) once the observed flag has gone unconfirmed
832
+ // for `SERVER_COMPACTION_REVALIDATE_MS` — independent of whether
833
+ // `session.compacted` ever arrives. If the server still reports
834
+ // compacting, the fresh timestamp re-arms this same timer; once it does
835
+ // not, `serverCompactingAtMs` clears and the projection follows.
836
+ const serverCompactionRevalidateAt = serverCompactionRevalidateAtMs(compactionInputs);
837
+ useEffect(() => {
838
+ if (serverCompactionRevalidateAt === null || !switched || !ocSessionId)
839
+ return;
840
+ const timer = setTimeout(() => {
841
+ void queryClient.invalidateQueries({ queryKey: opencodeKeys.runtimeSession(ocSessionId) });
842
+ }, Math.max(0, serverCompactionRevalidateAt - Date.now()));
843
+ return () => clearTimeout(timer);
844
+ }, [serverCompactionRevalidateAt, switched, ocSessionId, queryClient]);
636
845
  const removeQuestion = useOpenCodePendingStore((s) => s.removeQuestion);
637
846
  const removePermission = useOpenCodePendingStore((s) => s.removePermission);
638
847
  const questions = useMemo(() => (switched ? Object.values(questionMap).filter((q) => q.sessionID === ocSessionId) : []), [questionMap, ocSessionId, switched]);
@@ -669,17 +878,17 @@ export function useSession(projectId, sessionId, options = {}) {
669
878
  const [sendState, setSendState] = useState(IDLE_SEND_STATE);
670
879
  const pending = sendState.pending;
671
880
  const pendingBaseCount = useRef(0);
881
+ // Cleared when the send's echo lands — a real observation. The 30s timeout
882
+ // that used to sit beside this is deleted: no in-repo consumer reads
883
+ // `pending`, and where it could fire it fired wrongly — 30s is shorter than
884
+ // a legitimate inbox path (18.9s Daytona / 24.5s Platinum boot plus the
885
+ // drain). The sync store's optimistic message and the `SendReceipt` carry
886
+ // the state it approximated, each with an honest bound.
672
887
  useEffect(() => {
673
888
  if (pending && userMsgCount > pendingBaseCount.current) {
674
889
  setSendState((s) => (s.pending ? { ...s, pending: null } : s));
675
890
  }
676
891
  }, [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
892
  const sendParts = async (parts, override) => {
684
893
  if (!runtimeActionReady)
685
894
  throw new RuntimeNotReadyError();
@@ -700,19 +909,48 @@ export function useSession(projectId, sessionId, options = {}) {
700
909
  // which is what keeps it on screen for the whole of a slow upload instead
701
910
  // of being deleted by a rehydrate that only carries older turns.
702
911
  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);
912
+ const receipt = {
913
+ messageId: sendReceiptId(ocSessionId, parts, override?.clientMessageId),
914
+ atMs: Date.now(),
915
+ };
916
+ noteSendReceipt(sessionId, receipt);
917
+ try {
918
+ await sendMutation.mutateAsync({
919
+ sessionId: ocSessionId,
920
+ parts,
921
+ ...(Object.keys(opts).length ? { options: opts } : {}),
922
+ ...(override?.clientMessageId ? { clientMessageId: override.clientMessageId } : {}),
923
+ });
924
+ // The server has the prompt. From here — and NOT before — a `/turn` read
925
+ // is able to see it, so one is allowed to answer for it.
926
+ acceptSendReceipt(sessionId, receipt.messageId, Date.now());
927
+ }
928
+ catch (error) {
929
+ // The prompt never reached the server, so there is nothing to wait for.
930
+ // Dropping the receipt here is what stops a refused send from claiming
931
+ // `working` for a minute over a turn that will never start. NAMED, so a
932
+ // slow failure cannot drop a later send's receipt while its own request
933
+ // is still on the wire.
934
+ clearSendReceipt(sessionId, receipt.messageId);
935
+ throw error;
936
+ }
937
+ useSyncStore.getState().commitSessionRevert(ocSessionId);
709
938
  };
710
939
  const send = (text, override) => {
711
940
  if (!runtimeActionReady)
712
941
  return;
713
942
  pendingBaseCount.current = userMsgCount;
714
943
  setSendState(sendStateOnStart(text));
715
- void sendParts([{ type: 'text', text }], override).catch((error) => {
944
+ // T15 — one user bubble: the optimistic message and the outgoing
945
+ // part share one minted id, so the server's echo can be correlated back
946
+ // to it instead of leaving both on screen until the idle sweep.
947
+ const { messageId, parts } = beginOptimisticPlainTextSend(ocSessionId, text);
948
+ void sendParts(parts, override).catch((error) => {
949
+ // The send never reached (or was refused by) the server — nothing to
950
+ // rehydrate, so drop the optimistic message outright rather than leave
951
+ // it stranded forever (mirrors `abandonOptimisticSend`).
952
+ // `sendParts` already dropped this tab's send receipt on this path.
953
+ useSyncStore.getState().optimisticRemove(ocSessionId, messageId);
716
954
  setSendState(sendStateOnError(error));
717
955
  });
718
956
  };
@@ -727,14 +965,14 @@ export function useSession(projectId, sessionId, options = {}) {
727
965
  throw new RuntimeNotReadyError();
728
966
  if (!messageId)
729
967
  throw new Error('Session rewind requires a message id');
730
- if (sync.isBusy || pending || rewindPending) {
968
+ if (working.state === 'working' || rewindPending) {
731
969
  throw new Error('Cannot rewind a busy session');
732
970
  }
733
971
  setRewindPending(true);
734
972
  setRewindError(null);
735
973
  try {
736
974
  await rewindOpenCodeSession(ocSessionId, messageId);
737
- setRestRewind({ messageId, staged: true });
975
+ useSyncStore.getState().stageSessionRevert(ocSessionId, messageId);
738
976
  }
739
977
  catch (error) {
740
978
  const classified = classifySendError(error);
@@ -754,7 +992,7 @@ export function useSession(projectId, sessionId, options = {}) {
754
992
  setRewindError(null);
755
993
  try {
756
994
  await restoreOpenCodeSessionRewind(ocSessionId);
757
- setRestRewind(null);
995
+ useSyncStore.getState().clearSessionRevert(ocSessionId);
758
996
  }
759
997
  catch (error) {
760
998
  const classified = classifySendError(error);
@@ -765,15 +1003,45 @@ export function useSession(projectId, sessionId, options = {}) {
765
1003
  setRewindPending(false);
766
1004
  }
767
1005
  };
768
- // The one true cancel: abort the run AND drop any pending prompt + open prompts.
1006
+ // The one true cancel: abort the run AND drop any pending prompt + open
1007
+ // prompts. Returns a promise that settles once the abort is acknowledged —
1008
+ // the mutation resolved (and, per `abortOpenCodeSession`, the session's
1009
+ // status was re-read to confirm idle), the mutation failed after its own
1010
+ // retries, or a bounded ~5s timeout elapsed. See `AbortSettlement`.
1011
+ //
1012
+ // T9: `abortInFlightDeliveries` runs FIRST, synchronously — a prompt
1013
+ // still retrying its boot/wake backoff when the user hits Stop must never
1014
+ // land after this point and run against the old text. The optimistic UI
1015
+ // (busy → idle, questions/permissions cleared) still updates instantly;
1016
+ // only the returned promise is new — a caller that never awaits it sees
1017
+ // exactly the same synchronous effects as before.
769
1018
  const cancel = () => {
770
1019
  if (runtimeActionReady) {
771
- endRestPromptObservation(ocSessionId);
772
- abortMutation.mutate(ocSessionId);
1020
+ clearSendReceipt(sessionId);
1021
+ // The stop's own receipt. The cancel needs a round trip through the
1022
+ // control plane and the daemon before turn authority is released, so
1023
+ // every `/turn` read issued before it settles still reports the doomed
1024
+ // turn — including the one the optimistic idle frame below triggers.
1025
+ // Without this the composer flipped Send back to Stop ~120ms after the
1026
+ // click and stayed there for the whole abort. See `AbortReceipt`.
1027
+ noteAbortReceipt(sessionId, Date.now());
1028
+ // No fabricated idle frame here: the receipt above IS the optimistic
1029
+ // idle, with provenance and a bound. A fabricated frame outranked the
1030
+ // control plane's `/turn` answer in `projectWorking` for the whole
1031
+ // abort round-trip — the laundering this migration removes.
1032
+ abortInFlightDeliveries(ocSessionId);
773
1033
  }
774
1034
  questions.forEach((q) => removeQuestion(q.id));
775
1035
  permissions.forEach((p) => removePermission(p.id));
776
1036
  setSendState(IDLE_SEND_STATE);
1037
+ if (!runtimeActionReady)
1038
+ return Promise.resolve({ status: 'skipped' });
1039
+ const settlement = awaitAbortSettlement(() => abortMutation.mutateAsync(ocSessionId));
1040
+ // `awaitAbortSettlement` never rejects — it resolves with how the abort
1041
+ // ended (acknowledged, failed, or timed out). Any of those is the instant
1042
+ // from which a server read can see the abort's effect, or fail to.
1043
+ void settlement.then(() => settleAbortReceipt(sessionId, Date.now()));
1044
+ return settlement;
777
1045
  };
778
1046
  const runtimeSessionError = canonicalSession.error;
779
1047
  const phase = derivePhase({
@@ -809,6 +1077,13 @@ export function useSession(projectId, sessionId, options = {}) {
809
1077
  picks.setModel(stash.model);
810
1078
  if (stash.agent)
811
1079
  picks.setAgent(stash.agent);
1080
+ // A picks-only stash (`prompt: ''`) seeds the selections above and sends
1081
+ // NOTHING: apps/web's producers now deliver the first prompt as a durable
1082
+ // inbox row (server-side at create, or `startSessionWithPrompt`), and the
1083
+ // stash carries only the model/agent hand-off. A full stash — the
1084
+ // whitelabel-demo producer — still replays here exactly as before.
1085
+ if (!stash.prompt.trim())
1086
+ return;
812
1087
  send(stash.prompt, {
813
1088
  model: stash.model,
814
1089
  agent: stash.agent,
@@ -840,6 +1115,17 @@ export function useSession(projectId, sessionId, options = {}) {
840
1115
  phase,
841
1116
  /** Raw /start stage (provisioning|starting|ready|stopped|failed), for boot UI. */
842
1117
  stage,
1118
+ /**
1119
+ * The immutable agent this project session was created with, from /start
1120
+ * (`agent_name`). Known from the FIRST /start response — long before the
1121
+ * sandbox is ready — so composers can render the session's real agent
1122
+ * while it boots instead of guessing from the roster. The server
1123
+ * serializes `'default'` when no agent was bound; that is not a real
1124
+ * roster agent, so it surfaces as `null` here.
1125
+ */
1126
+ agentName: startData?.agent_name && startData.agent_name !== 'default'
1127
+ ? startData.agent_name
1128
+ : null,
843
1129
  /** The serialized session_sandboxes row from /start (status, metadata, ids), or null. */
844
1130
  sandbox,
845
1131
  /** True once the runtime is switched in and ready (equivalent to phase==='ready'). */
@@ -852,7 +1138,10 @@ export function useSession(projectId, sessionId, options = {}) {
852
1138
  failure: startData?.failure ?? null,
853
1139
  /** Granular boot phase (connecting|booting|ready|unreachable) for detailed UI. */
854
1140
  runtimePhase,
855
- isBusy: sync.isBusy || !!pending,
1141
+ /** Provenance-tagged working state — WHICH observation decided it, when,
1142
+ * and for which turn. The one answer; `isBusy` is its boolean face. */
1143
+ working,
1144
+ isBusy: working.state === 'working',
856
1145
  isCompacting,
857
1146
  isLoading: sync.isLoading,
858
1147
  isError: terminal || !!startError || !!runtimeSessionError,