@kortix/sdk 0.12.4 → 0.12.6

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 (187) hide show
  1. package/README.md +80 -15
  2. package/dist/browser/session-sync/session-transcript-cache.d.ts +17 -0
  3. package/dist/browser/session-sync/session-transcript-cache.d.ts.map +1 -1
  4. package/dist/browser/session-sync/session-transcript-cache.js +14 -2
  5. package/dist/browser/session-sync/session-transcript-cache.js.map +1 -1
  6. package/dist/browser/stores/sync-store.d.ts +86 -2
  7. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  8. package/dist/browser/stores/sync-store.js +386 -33
  9. package/dist/browser/stores/sync-store.js.map +1 -1
  10. package/dist/core/client/kortix.d.ts +87 -54
  11. package/dist/core/client/kortix.d.ts.map +1 -1
  12. package/dist/core/client/kortix.js +67 -21
  13. package/dist/core/client/kortix.js.map +1 -1
  14. package/dist/core/http/api-client.d.ts +19 -1
  15. package/dist/core/http/api-client.d.ts.map +1 -1
  16. package/dist/core/http/api-client.js +79 -21
  17. package/dist/core/http/api-client.js.map +1 -1
  18. package/dist/core/http/auth.d.ts.map +1 -1
  19. package/dist/core/http/auth.js +6 -4
  20. package/dist/core/http/auth.js.map +1 -1
  21. package/dist/core/http/config.d.ts +4 -0
  22. package/dist/core/http/config.d.ts.map +1 -1
  23. package/dist/core/http/config.js.map +1 -1
  24. package/dist/core/rest/platform-client/host-boundary.js +2 -2
  25. package/dist/core/rest/platform-client/host-boundary.js.map +1 -1
  26. package/dist/core/rest/projects-client/access.d.ts +1 -1
  27. package/dist/core/rest/projects-client/agent-config.d.ts +1 -1
  28. package/dist/core/rest/projects-client/agent-config.d.ts.map +1 -1
  29. package/dist/core/rest/projects-client/apps.d.ts +216 -0
  30. package/dist/core/rest/projects-client/apps.d.ts.map +1 -0
  31. package/dist/core/rest/projects-client/apps.js +105 -0
  32. package/dist/core/rest/projects-client/apps.js.map +1 -0
  33. package/dist/core/rest/projects-client/audit.js +1 -1
  34. package/dist/core/rest/projects-client/audit.js.map +1 -1
  35. package/dist/core/rest/projects-client/billing.d.ts +7 -0
  36. package/dist/core/rest/projects-client/billing.d.ts.map +1 -1
  37. package/dist/core/rest/projects-client/billing.js.map +1 -1
  38. package/dist/core/rest/projects-client/channels.d.ts +7 -6
  39. package/dist/core/rest/projects-client/channels.d.ts.map +1 -1
  40. package/dist/core/rest/projects-client/channels.js +12 -2
  41. package/dist/core/rest/projects-client/channels.js.map +1 -1
  42. package/dist/core/rest/projects-client/connectors.d.ts +133 -98
  43. package/dist/core/rest/projects-client/connectors.d.ts.map +1 -1
  44. package/dist/core/rest/projects-client/connectors.js +199 -119
  45. package/dist/core/rest/projects-client/connectors.js.map +1 -1
  46. package/dist/core/rest/projects-client/files.d.ts.map +1 -1
  47. package/dist/core/rest/projects-client/files.js +7 -1
  48. package/dist/core/rest/projects-client/files.js.map +1 -1
  49. package/dist/core/rest/projects-client/index.d.ts +1 -0
  50. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  51. package/dist/core/rest/projects-client/index.js +1 -0
  52. package/dist/core/rest/projects-client/index.js.map +1 -1
  53. package/dist/core/rest/projects-client/policies.js +3 -3
  54. package/dist/core/rest/projects-client/policies.js.map +1 -1
  55. package/dist/core/rest/projects-client/projects.d.ts +47 -1
  56. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  57. package/dist/core/rest/projects-client/projects.js +12 -0
  58. package/dist/core/rest/projects-client/projects.js.map +1 -1
  59. package/dist/core/rest/projects-client/secrets.d.ts +55 -3
  60. package/dist/core/rest/projects-client/secrets.d.ts.map +1 -1
  61. package/dist/core/rest/projects-client/secrets.js +14 -4
  62. package/dist/core/rest/projects-client/secrets.js.map +1 -1
  63. package/dist/core/rest/projects-client/sessions.d.ts +8 -16
  64. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  65. package/dist/core/rest/projects-client/sessions.js +1 -1
  66. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  67. package/dist/core/session/index.d.ts +1 -0
  68. package/dist/core/session/index.d.ts.map +1 -1
  69. package/dist/core/session/index.js +1 -0
  70. package/dist/core/session/index.js.map +1 -1
  71. package/dist/core/session/preview-probe.d.ts +76 -0
  72. package/dist/core/session/preview-probe.d.ts.map +1 -0
  73. package/dist/core/session/preview-probe.js +97 -0
  74. package/dist/core/session/preview-probe.js.map +1 -0
  75. package/dist/core/turns/classify.js +1 -1
  76. package/dist/core/turns/errors.js +1 -1
  77. package/dist/core/turns/errors.js.map +1 -1
  78. package/dist/index.d.ts +1 -1
  79. package/dist/index.js +1 -1
  80. package/dist/kortix.esm.min.js +19 -19
  81. package/dist/kortix.global.js +19 -19
  82. package/dist/platform/auth-core.d.ts +2 -1
  83. package/dist/platform/auth-core.d.ts.map +1 -1
  84. package/dist/platform/auth-core.js +10 -1
  85. package/dist/platform/auth-core.js.map +1 -1
  86. package/dist/react/index.d.ts +8 -0
  87. package/dist/react/index.d.ts.map +1 -1
  88. package/dist/react/index.js +13 -0
  89. package/dist/react/index.js.map +1 -1
  90. package/dist/react/invalidate-project.d.ts +76 -0
  91. package/dist/react/invalidate-project.d.ts.map +1 -0
  92. package/dist/react/invalidate-project.js +72 -0
  93. package/dist/react/invalidate-project.js.map +1 -0
  94. package/dist/react/provider-refresh.d.ts.map +1 -1
  95. package/dist/react/provider-refresh.js +12 -3
  96. package/dist/react/provider-refresh.js.map +1 -1
  97. package/dist/react/query-contracts.d.ts +112 -0
  98. package/dist/react/query-contracts.d.ts.map +1 -0
  99. package/dist/react/query-contracts.js +100 -0
  100. package/dist/react/query-contracts.js.map +1 -0
  101. package/dist/react/query-keys.d.ts +251 -0
  102. package/dist/react/query-keys.d.ts.map +1 -0
  103. package/dist/react/query-keys.js +249 -0
  104. package/dist/react/query-keys.js.map +1 -0
  105. package/dist/react/session-title-sync.d.ts.map +1 -1
  106. package/dist/react/session-title-sync.js +14 -4
  107. package/dist/react/session-title-sync.js.map +1 -1
  108. package/dist/react/use-admin-accounts.d.ts +85 -0
  109. package/dist/react/use-admin-accounts.d.ts.map +1 -1
  110. package/dist/react/use-admin-accounts.js +100 -0
  111. package/dist/react/use-admin-accounts.js.map +1 -1
  112. package/dist/react/use-admin-activity-analytics.d.ts +91 -0
  113. package/dist/react/use-admin-activity-analytics.d.ts.map +1 -0
  114. package/dist/react/use-admin-activity-analytics.js +83 -0
  115. package/dist/react/use-admin-activity-analytics.js.map +1 -0
  116. package/dist/react/use-admin-projects.d.ts +50 -0
  117. package/dist/react/use-admin-projects.d.ts.map +1 -0
  118. package/dist/react/use-admin-projects.js +28 -0
  119. package/dist/react/use-admin-projects.js.map +1 -0
  120. package/dist/react/use-canonical-opencode-session.d.ts.map +1 -1
  121. package/dist/react/use-canonical-opencode-session.js +12 -7
  122. package/dist/react/use-canonical-opencode-session.js.map +1 -1
  123. package/dist/react/use-gateway-catalog-sync.d.ts.map +1 -1
  124. package/dist/react/use-gateway-catalog-sync.js +5 -2
  125. package/dist/react/use-gateway-catalog-sync.js.map +1 -1
  126. package/dist/react/use-model-defaults.d.ts.map +1 -1
  127. package/dist/react/use-model-defaults.js +3 -1
  128. package/dist/react/use-model-defaults.js.map +1 -1
  129. package/dist/react/use-model-enablement.d.ts.map +1 -1
  130. package/dist/react/use-model-enablement.js +6 -2
  131. package/dist/react/use-model-enablement.js.map +1 -1
  132. package/dist/react/use-opencode-events/handle-event.d.ts +6 -0
  133. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  134. package/dist/react/use-opencode-events/handle-event.js +3 -3
  135. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  136. package/dist/react/use-opencode-events/helpers.d.ts +27 -1
  137. package/dist/react/use-opencode-events/helpers.d.ts.map +1 -1
  138. package/dist/react/use-opencode-events/helpers.js +31 -6
  139. package/dist/react/use-opencode-events/helpers.js.map +1 -1
  140. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  141. package/dist/react/use-opencode-events/index.js +8 -0
  142. package/dist/react/use-opencode-events/index.js.map +1 -1
  143. package/dist/react/use-opencode-sessions/agents.d.ts.map +1 -1
  144. package/dist/react/use-opencode-sessions/agents.js +10 -1
  145. package/dist/react/use-opencode-sessions/agents.js.map +1 -1
  146. package/dist/react/use-opencode-sessions/messages.d.ts +19 -3
  147. package/dist/react/use-opencode-sessions/messages.d.ts.map +1 -1
  148. package/dist/react/use-opencode-sessions/messages.js +28 -55
  149. package/dist/react/use-opencode-sessions/messages.js.map +1 -1
  150. package/dist/react/use-opencode-sessions/providers.d.ts.map +1 -1
  151. package/dist/react/use-opencode-sessions/providers.js +9 -2
  152. package/dist/react/use-opencode-sessions/providers.js.map +1 -1
  153. package/dist/react/use-project-apps.d.ts +410 -0
  154. package/dist/react/use-project-apps.d.ts.map +1 -0
  155. package/dist/react/use-project-apps.js +98 -0
  156. package/dist/react/use-project-apps.js.map +1 -0
  157. package/dist/react/use-project-config.d.ts +7 -0
  158. package/dist/react/use-project-config.d.ts.map +1 -1
  159. package/dist/react/use-project-config.js +14 -4
  160. package/dist/react/use-project-config.js.map +1 -1
  161. package/dist/react/use-project-models.d.ts.map +1 -1
  162. package/dist/react/use-project-models.js +8 -2
  163. package/dist/react/use-project-models.js.map +1 -1
  164. package/dist/react/use-project-name.d.ts +23 -0
  165. package/dist/react/use-project-name.d.ts.map +1 -0
  166. package/dist/react/use-project-name.js +44 -0
  167. package/dist/react/use-project-name.js.map +1 -0
  168. package/dist/react/use-project-secrets.d.ts +29 -2
  169. package/dist/react/use-project-secrets.d.ts.map +1 -1
  170. package/dist/react/use-project-secrets.js +8 -2
  171. package/dist/react/use-project-secrets.js.map +1 -1
  172. package/dist/react/use-project-session.d.ts +40 -0
  173. package/dist/react/use-project-session.d.ts.map +1 -0
  174. package/dist/react/use-project-session.js +49 -0
  175. package/dist/react/use-project-session.js.map +1 -0
  176. package/dist/react/use-project-triggers.d.ts +4 -2
  177. package/dist/react/use-project-triggers.d.ts.map +1 -1
  178. package/dist/react/use-project-triggers.js +7 -2
  179. package/dist/react/use-project-triggers.js.map +1 -1
  180. package/dist/react/use-session-sync.d.ts +1 -2
  181. package/dist/react/use-session-sync.d.ts.map +1 -1
  182. package/dist/react/use-session-sync.js +17 -40
  183. package/dist/react/use-session-sync.js.map +1 -1
  184. package/dist/react/use-session.d.ts.map +1 -1
  185. package/dist/react/use-session.js +18 -16
  186. package/dist/react/use-session.js.map +1 -1
  187. package/package.json +2 -2
@@ -26,41 +26,288 @@ const optimisticIds = new Map();
26
26
  // match: the server has not been told it exists, so nothing the server returns
27
27
  // can be a copy of it.
28
28
  const dispatchedOptimisticIds = new Map();
29
- function trackId(store, sessionID, messageID) {
29
+ function trackId(store, sessionID, id) {
30
30
  const bucket = store.get(sessionID);
31
31
  if (bucket)
32
- bucket.add(messageID);
32
+ bucket.add(id);
33
33
  else
34
- store.set(sessionID, new Set([messageID]));
34
+ store.set(sessionID, new Set([id]));
35
35
  }
36
- function untrackId(store, sessionID, messageID) {
36
+ function untrackId(store, sessionID, id) {
37
37
  const bucket = store.get(sessionID);
38
38
  if (!bucket)
39
39
  return;
40
- bucket.delete(messageID);
40
+ bucket.delete(id);
41
41
  if (bucket.size === 0)
42
42
  store.delete(sessionID);
43
43
  }
44
- function hasTrackedId(store, sessionID, messageID) {
45
- return store.get(sessionID)?.has(messageID) ?? false;
44
+ function hasTrackedId(store, sessionID, id) {
45
+ return store.get(sessionID)?.has(id) ?? false;
46
46
  }
47
- /** Release every id this session was tracking — called from `clearSession`. */
47
+ /** Release every id this session was tracking — called from `clearSession`
48
+ * and from eviction. */
48
49
  function forgetSessionIds(sessionID) {
49
50
  optimisticIds.delete(sessionID);
50
51
  dispatchedOptimisticIds.delete(sessionID);
52
+ // The joined rows hold the very message and part arrays this session's
53
+ // data is being dropped from. Left behind, they keep the transcript
54
+ // reachable and the drop achieves nothing.
55
+ sessionMessageRows.delete(sessionID);
56
+ // A session cleared without ever going idle/erroring (the only other
57
+ // release points, below) would otherwise leave a dead bucket in
58
+ // deltaActiveParts for the lifetime of the tab — the exact leak class
59
+ // this function exists to prevent for optimisticIds above.
60
+ deltaActiveParts.delete(sessionID);
61
+ // Same leak class, on bridgedPartIds: it used to be released only by
62
+ // reset() (which application code never calls), not by clearSession, so
63
+ // a message id stayed "bridged" forever once bridged once. If a LATER
64
+ // message in a different session ever reused that id, upsertPart's
65
+ // bridge-clearing branch fired for it too, wiping every part already
66
+ // stored under it. See bridgedPartIds below.
67
+ bridgedPartIds.delete(sessionID);
51
68
  }
52
69
  const isOptimistic = (sessionID, messageID) => hasTrackedId(optimisticIds, sessionID, messageID);
53
70
  const isDispatched = (sessionID, messageID) => hasTrackedId(dispatchedOptimisticIds, sessionID, messageID);
54
- // Track message IDs where optimistic parts were bridged to the real message.
55
- // When the first real part arrives for a bridged message, the bridged parts
56
- // are cleared so optimistic and real parts don't co-exist (which would
71
+ // Track message IDs where optimistic parts were bridged to the real message,
72
+ // keyed by session same shape and same reason as optimisticIds above. When
73
+ // the first real part arrives for a bridged message, the bridged parts are
74
+ // cleared so optimistic and real parts don't co-exist (which would
57
75
  // double-render the user's text).
58
- const bridgedPartIds = new Set();
59
- // Track part IDs that have received at least one delta.
60
- // Used by upsertPart to avoid overwriting delta-accumulated text with a
61
- // stale message.part.updated snapshot that arrives in the same event batch.
62
- // Entries are cleared when the streaming session goes idle.
63
- const deltaActiveParts = new Set();
76
+ //
77
+ // It used to be one process-wide Set<string> shared by every session in the
78
+ // tab, cleared only by reset() clearSession never released its entries, so
79
+ // ids accumulated for the lifetime of the tab. A leaked id made upsertPart
80
+ // treat any LATER message that reused it (in any session) as still-bridged:
81
+ // the first real text part for that id wiped every part already stored under
82
+ // it, even though nothing was ever bridged in that session. Keying by session
83
+ // and releasing only the ending session's bucket (via forgetSessionIds)
84
+ // fixes it.
85
+ const bridgedPartIds = new Map();
86
+ // Track part IDs that have received at least one delta, keyed by session —
87
+ // same shape and same reason as optimisticIds above. Used by upsertPart to
88
+ // avoid overwriting delta-accumulated text with a stale message.part.updated
89
+ // snapshot that arrives in the same event batch. A session's entries are
90
+ // cleared when THAT session's stream goes idle or errors.
91
+ //
92
+ // It used to be one process-wide Set<string> shared by every session in the
93
+ // tab, cleared wholesale on session.idle/session.error. Session B going idle
94
+ // wiped session A's tracking while A was still streaming, so the guard below
95
+ // stopped protecting A — a stale snapshot could then overwrite A's
96
+ // delta-accumulated text. Keying by session and releasing only the ending
97
+ // session's bucket fixes it.
98
+ const deltaActiveParts = new Map();
99
+ // ---------------------------------------------------------------------------
100
+ // Session retention — how a transcript ever LEAVES memory again.
101
+ //
102
+ // `messages` and `parts` are keyed by session and nothing removed a key.
103
+ // `clearSession` has one caller (a cache-ownership conflict in
104
+ // use-session-sync.ts) and `reset()` has none in application code, so every
105
+ // session a user opened kept its full transcript and every part for the
106
+ // lifetime of the tab. Open ten long sessions and memory climbs monotonically
107
+ // and never comes back.
108
+ //
109
+ // So: reference-count the mounted consumers of each session and free it once
110
+ // the last one is gone. Same idea as React Query's observer count, but bounded
111
+ // by COUNT rather than by a `gcTime` timer — memory pressure is "how many
112
+ // transcripts are resident", not "how old is this one", and a count needs no
113
+ // timer in a store that has none today and stays deterministic under test.
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * How many sessions keep their transcript resident after their LAST consumer
117
+ * unmounts, most-recently-detached first.
118
+ *
119
+ * Not zero, for two reasons:
120
+ * 1. React StrictMode double-invokes effects (mount → cleanup → mount) in the
121
+ * same commit. Freeing the instant the count hits zero would blank the
122
+ * transcript on every dev mount, and again on every fast refresh.
123
+ * 2. Back-and-forth between two or three sessions is the common navigation.
124
+ * Holding them costs nothing to correctness and skips a disk round trip —
125
+ * and a round trip is the GOOD case: `getCurrentCacheScope()` returns null
126
+ * when unauthenticated, and then nothing was ever written to disk to
127
+ * return to.
128
+ *
129
+ * Small, because memory must be TIGHTER than disk: `idb-sync-cache.ts` bounds
130
+ * the on-disk cache at 50 sessions / 7 days.
131
+ */
132
+ const DETACHED_SESSION_LIMIT = 3;
133
+ /**
134
+ * The joined `MessageWithParts[]` rows, per session — see
135
+ * {@link SyncState.buildSessionMessages}. Bounded, and dropped for real by
136
+ * `forgetSessionIds`.
137
+ */
138
+ const MESSAGE_ROWS_LIMIT = 20;
139
+ const sessionMessageRows = new Map();
140
+ const EMPTY_MESSAGE_ROWS = [];
141
+ /** Insert-or-refresh `sessionID` as the most recently used entry, dropping the
142
+ * least recently used one once the map is over {@link MESSAGE_ROWS_LIMIT}.
143
+ * Re-inserting is what moves a key to the end of a Map's iteration order. */
144
+ function touchSessionMessageRows(sessionID, entry) {
145
+ sessionMessageRows.delete(sessionID);
146
+ sessionMessageRows.set(sessionID, entry);
147
+ if (sessionMessageRows.size > MESSAGE_ROWS_LIMIT) {
148
+ const leastRecentlyUsed = sessionMessageRows.keys().next().value;
149
+ if (leastRecentlyUsed)
150
+ sessionMessageRows.delete(leastRecentlyUsed);
151
+ }
152
+ }
153
+ /** Mounted consumers per session. Absent means "nobody ever retained this" —
154
+ * see the release returned by `retainSession`, which then leaves the session
155
+ * alone entirely. */
156
+ const sessionConsumers = new Map();
157
+ /** Sessions at zero consumers, oldest first (Set preserves insertion order). */
158
+ const detachedSessions = new Set();
159
+ /**
160
+ * Sessions whose transcript eviction freed, and which nothing authoritative has
161
+ * reloaded since. See {@link SyncState.wasTranscriptEvicted} for what reads it
162
+ * and {@link pruneDetachedSessions} for the second thing it is for.
163
+ *
164
+ * Holds ids, not data. It is cleared wholesale by `reset()`, and per-session by
165
+ * every path that re-establishes the session — `hydrate`, `clearSession`,
166
+ * `optimisticAdd`.
167
+ */
168
+ const evictedSessions = new Set();
169
+ /**
170
+ * Free the part buckets belonging to `sessionIDs` that no message points at.
171
+ *
172
+ * `parts` is keyed by messageID, so every sweep that walks a session's
173
+ * `messages` misses the buckets with no message entry to walk from — and the
174
+ * store creates those on purpose. `message.part.delta` stores the part but
175
+ * SKIPS creating the assistant message when the session holds no user message
176
+ * yet (a delta that beats `hydrate()` after a page refresh), so `hydrate` can
177
+ * attach the real one later. Nothing else ever revisits the bucket, so before
178
+ * this it outlived both eviction and `clearSession`: the leak this file's
179
+ * retention work exists to close, left open on the one path that produces it.
180
+ *
181
+ * Attribution is by `Part.sessionID`, which is what those stub parts carry.
182
+ * `every` rather than `some` so a bucket is only freed when the whole thing is
183
+ * this session's; a bucket whose parts arrived without a `sessionID` is
184
+ * unattributable and deliberately left alone rather than guessed at.
185
+ *
186
+ * Mutates `parts` — a caller-owned copy in both call sites.
187
+ */
188
+ function deleteOrphanPartBuckets(parts, sessionIDs) {
189
+ const dropped = new Set(sessionIDs);
190
+ for (const messageID of Object.keys(parts)) {
191
+ const bucket = parts[messageID];
192
+ if (!bucket || bucket.length === 0)
193
+ continue;
194
+ const owner = bucket[0].sessionID;
195
+ if (!owner || !dropped.has(owner))
196
+ continue;
197
+ if (bucket.every((part) => part.sessionID === owner))
198
+ delete parts[messageID];
199
+ }
200
+ }
201
+ /**
202
+ * Drop these sessions' data outright.
203
+ *
204
+ * DELETES the `messages` key rather than emptying it, unlike `clearSession`.
205
+ * The difference is load-bearing: `use-session-sync.ts` reads `sessionId in
206
+ * store.messages` both for `isLoading` and (via `shouldHydrateFromCache`) to
207
+ * decide whether the IndexedDB transcript may repaint. An empty array reads as
208
+ * "loaded, and empty", which would leave a returning user staring at a blank
209
+ * transcript that never repaints.
210
+ *
211
+ * `sessionStatus` is deliberately NOT dropped. It is the one slice read for
212
+ * sessions that are on purpose not resident — a parent's spawn-tool banner
213
+ * reads `sessionStatus[child]` for a child whose transcript the parent never
214
+ * holds (`tool/shared/sub-agent.tsx`, `tool/tools/removed-connector-tool.tsx`).
215
+ * Dropping it bought no memory either: every `session.status` frame, and the
216
+ * connect-time `client.session.status()` poll, re-add an entry for every
217
+ * session on the runtime whether or not it is resident, so the delete was
218
+ * undone on the next frame — while the gap was visible to the user. One small
219
+ * status object per session is not the memory this eviction reclaims; the
220
+ * transcript arrays are.
221
+ */
222
+ function dropSessionData(state, sessionIDs) {
223
+ const messages = { ...state.messages };
224
+ const parts = { ...state.parts };
225
+ const diffs = { ...state.diffs };
226
+ const todos = { ...state.todos };
227
+ for (const sessionID of sessionIDs) {
228
+ for (const message of messages[sessionID] ?? [])
229
+ delete parts[message.id];
230
+ delete messages[sessionID];
231
+ delete diffs[sessionID];
232
+ delete todos[sessionID];
233
+ }
234
+ deleteOrphanPartBuckets(parts, sessionIDs);
235
+ return { messages, parts, sessionStatus: state.sessionStatus, diffs, todos };
236
+ }
237
+ /**
238
+ * Prune the detached window down to {@link DETACHED_SESSION_LIMIT}, freeing the
239
+ * oldest sessions past it, plus any already-evicted session the live stream has
240
+ * since refilled. Returns the ids it freed.
241
+ *
242
+ * The second half is what keeps eviction from being a one-shot. Eviction takes
243
+ * a session OUT of the detach window, so an agent still running in it puts
244
+ * `messages[id]` back — through SSE, with nobody watching — and that data then
245
+ * has no path out of memory again. Re-checking {@link evictedSessions} on every
246
+ * prune gives it one: the next mount of any session sweeps it, exactly like the
247
+ * first time. It stays marked, because the user can still come back to it.
248
+ *
249
+ * Runs when a session is RETAINED, never when one is released. React runs every
250
+ * passive destroy before any passive create in a commit, so pruning on release
251
+ * would let the unmount of B evict A in the very commit that mounts A: visit
252
+ * A → X → Y → B and go back to A, and A is the oldest of four detached
253
+ * sessions at exactly the wrong moment. Pruning after the incoming session has
254
+ * been removed from the window makes that unreachable, and keeps `set()` out of
255
+ * the unmount path entirely.
256
+ *
257
+ * The window therefore holds at most LIMIT + (releases since the last retain),
258
+ * and is back to LIMIT the moment any session mounts.
259
+ *
260
+ * Synchronous on purpose. Deferring it (a microtask, a post-commit hook) so
261
+ * that every retain in a commit lands first would buy nothing, because the
262
+ * ordering it would buy is already guaranteed. The case that motivates
263
+ * deferral is a parent's spawn-tool preview of a child session, and the
264
+ * preview's `useOpenCodeMessages(childId)` is always a React DESCENDANT of the
265
+ * component that retains the parent (`SessionLayout` → the transcript → the
266
+ * tool part). React runs passive effects bottom-up, so in any commit that
267
+ * mounts both, the child's retain lands before the parent's — and therefore
268
+ * before the prune the parent's retain triggers. Deferral would only cost the
269
+ * determinism that keeps unmount out of the eviction path.
270
+ *
271
+ * (The argument this replaces claimed the child's data "arrives a commit or
272
+ * more after the parent mounts". That is not universally true — a parent whose
273
+ * transcript is already resident renders it in the mount commit — and the
274
+ * conclusion never depended on it.)
275
+ *
276
+ * The residual hazard is narrower than it looks, and worth naming exactly.
277
+ * Only a child session the user opened DIRECTLY and then left is ever an
278
+ * eviction candidate; one that was only ever previewed was never retained, and
279
+ * `retainSession` leaves sessions it has never seen alone. What such a child
280
+ * loses is its `messages`/`parts` — `sessionStatus` survives eviction on
281
+ * purpose (see `dropSessionData`), so a preview's retry banner keeps reading.
282
+ * A preview showing a transcript has no repaint path of its own, so closing
283
+ * the rest needs the reference declared before the data is read — either the
284
+ * host retaining child ids when it parses the parent's transcript, or
285
+ * `useOpenCodeMessages` reading the disk cache the way `useSessionSync` does.
286
+ * The second needs the child's `kortixSessionScope` plumbed through from the
287
+ * host: entries written for an opened session are keyed
288
+ * `…:kortix-session:<scope>`, so a scopeless read looks up a different key and
289
+ * misses (idb-sync-cache-key.ts:6-9).
290
+ */
291
+ function pruneDetachedSessions(messages) {
292
+ const evicted = [];
293
+ while (detachedSessions.size > DETACHED_SESSION_LIMIT) {
294
+ const oldest = detachedSessions.values().next().value;
295
+ if (oldest === undefined)
296
+ break;
297
+ detachedSessions.delete(oldest);
298
+ evicted.push(oldest);
299
+ }
300
+ // Refilled since it was last freed, and still nobody's. Gated on the key
301
+ // actually being back so a quiet evicted session never provokes a `set()`
302
+ // that changes nothing and re-renders every consumer of this store.
303
+ for (const sessionID of evictedSessions) {
304
+ if (sessionConsumers.has(sessionID) || detachedSessions.has(sessionID))
305
+ continue;
306
+ if (sessionID in messages)
307
+ evicted.push(sessionID);
308
+ }
309
+ return evicted;
310
+ }
64
311
  // ============================================================================
65
312
  export const useSyncStore = create()((set, get) => ({
66
313
  messages: {},
@@ -116,12 +363,15 @@ export const useSyncStore = create()((set, get) => ({
116
363
  parts: restParts,
117
364
  };
118
365
  }),
119
- upsertPart: (messageID, part) => set((s) => {
366
+ upsertPart: (messageID, part, sessionID) => set((s) => {
367
+ // See the `sessionID` param doc above: prefer the caller-resolved id,
368
+ // fall back to the part's own field only when no caller passed one.
369
+ const partSessionID = sessionID ?? part.sessionID;
120
370
  // If this message had bridged (optimistic) parts, clear them now
121
371
  // that a real part has arrived — prevents double-rendering.
122
372
  let list;
123
373
  let bridgeCleared = false;
124
- if (bridgedPartIds.has(messageID)) {
374
+ if (hasTrackedId(bridgedPartIds, partSessionID, messageID)) {
125
375
  // Only retire the optimistic/bridged user text once a REAL text
126
376
  // part with actual content arrives. A stray non-text part — or an
127
377
  // empty text snapshot — must NOT wipe the bridge, otherwise the
@@ -131,7 +381,7 @@ export const useSyncStore = create()((set, get) => ({
131
381
  typeof incoming.text === "string" &&
132
382
  incoming.text.length > 0;
133
383
  if (incomingIsRealText) {
134
- bridgedPartIds.delete(messageID);
384
+ untrackId(bridgedPartIds, partSessionID, messageID);
135
385
  list = [];
136
386
  bridgeCleared = true;
137
387
  }
@@ -187,7 +437,7 @@ export const useSyncStore = create()((set, get) => ({
187
437
  // text (missing the beginning). Skip the insert — the delta-
188
438
  // created version already exists in the parts list under the
189
439
  // message entry created by the delta handler.
190
- if (deltaActiveParts.has(part.id) && isTextLikePart(part)) {
440
+ if (hasTrackedId(deltaActiveParts, partSessionID, part.id) && isTextLikePart(part)) {
191
441
  // Delta-created part already exists — check all messageID
192
442
  // buckets since the delta handler may have stored it under
193
443
  // a different (stub) message entry.
@@ -217,8 +467,8 @@ export const useSyncStore = create()((set, get) => ({
217
467
  }
218
468
  return { parts: { ...s.parts, [messageID]: next } };
219
469
  }),
220
- applyPartDelta: (messageID, partID, field, delta) => {
221
- deltaActiveParts.add(partID);
470
+ applyPartDelta: (sessionID, messageID, partID, field, delta) => {
471
+ trackId(deltaActiveParts, sessionID, partID);
222
472
  set((s) => {
223
473
  const list = s.parts[messageID];
224
474
  if (!list)
@@ -245,6 +495,13 @@ export const useSyncStore = create()((set, get) => ({
245
495
  })),
246
496
  optimisticAdd: (sessionID, message, messageParts) => {
247
497
  trackId(optimisticIds, sessionID, message.id);
498
+ // The user has typed into this session, so the disk repaint stands down.
499
+ // `hydrate`'s ordinal fallback pairs an in-flight optimistic message with
500
+ // the oldest unclaimed real user message, and EVERY message in a disk
501
+ // copy is unclaimed — a repaint landing mid-send would retire the message
502
+ // that was just typed. The key-presence check used to make that
503
+ // unreachable (this action creates the key); the mark has to.
504
+ evictedSessions.delete(sessionID);
248
505
  set((s) => {
249
506
  const list = s.messages[sessionID] ?? [];
250
507
  // Always append optimistic messages at the end of the list.
@@ -315,10 +572,14 @@ export const useSyncStore = create()((set, get) => ({
315
572
  // Release this session's optimistic tracking along with its messages.
316
573
  // Skipping it is what let ids accumulate for the lifetime of the tab.
317
574
  forgetSessionIds(sessionID);
575
+ // Emptied on purpose (a cache-ownership conflict). The disk copy is
576
+ // exactly what must NOT come back, so drop any eviction mark with it.
577
+ evictedSessions.delete(sessionID);
318
578
  const existingMessages = s.messages[sessionID] ?? [];
319
579
  const nextParts = { ...s.parts };
320
580
  for (const message of existingMessages)
321
581
  delete nextParts[message.id];
582
+ deleteOrphanPartBuckets(nextParts, [sessionID]);
322
583
  return {
323
584
  messages: { ...s.messages, [sessionID]: [] },
324
585
  parts: nextParts,
@@ -327,7 +588,82 @@ export const useSyncStore = create()((set, get) => ({
327
588
  todos: { ...s.todos, [sessionID]: [] },
328
589
  };
329
590
  }),
591
+ retainSession: (sessionID) => {
592
+ if (!sessionID)
593
+ return () => { };
594
+ // Out of the detach window BEFORE pruning it, so mounting a session can
595
+ // never be what evicts it.
596
+ detachedSessions.delete(sessionID);
597
+ sessionConsumers.set(sessionID, (sessionConsumers.get(sessionID) ?? 0) + 1);
598
+ const evicted = pruneDetachedSessions(get().messages);
599
+ if (evicted.length > 0) {
600
+ for (const id of evicted) {
601
+ forgetSessionIds(id);
602
+ evictedSessions.add(id);
603
+ }
604
+ set((s) => dropSessionData(s, evicted));
605
+ }
606
+ let released = false;
607
+ return () => {
608
+ if (released)
609
+ return;
610
+ released = true;
611
+ const held = sessionConsumers.get(sessionID);
612
+ if (held === undefined)
613
+ return;
614
+ // Another consumer still has this session on screen. THE case a raw
615
+ // unmount gets wrong: a transcript and a context modal, a split
616
+ // view, or a parent's spawn-tool preview of a child session.
617
+ if (held > 1) {
618
+ sessionConsumers.set(sessionID, held - 1);
619
+ return;
620
+ }
621
+ sessionConsumers.delete(sessionID);
622
+ // Re-insert so this session is the NEWEST detached one, not wherever
623
+ // a previous detach left it. Nothing is freed here — see
624
+ // `pruneDetachedSessions`.
625
+ detachedSessions.delete(sessionID);
626
+ detachedSessions.add(sessionID);
627
+ };
628
+ },
629
+ wasTranscriptEvicted: (sessionID) => evictedSessions.has(sessionID),
630
+ buildSessionMessages: (sessionID, msgs, parts) => {
631
+ if (!msgs || msgs.length === 0)
632
+ return EMPTY_MESSAGE_ROWS;
633
+ const cached = sessionMessageRows.get(sessionID);
634
+ if (cached && cached.msgs === msgs) {
635
+ let same = cached.partRefs.length === msgs.length;
636
+ if (same) {
637
+ for (let i = 0; i < msgs.length; i++) {
638
+ if (parts[msgs[i].id] !== cached.partRefs[i]) {
639
+ same = false;
640
+ break;
641
+ }
642
+ }
643
+ }
644
+ if (same) {
645
+ // A hit is USE. Without this the order is insertion order, not
646
+ // recency, and a stable session that renders unchanged for
647
+ // minutes is pushed out by other sessions' rebuilds — costing it
648
+ // a needless rebuild and one extra render.
649
+ touchSessionMessageRows(sessionID, cached);
650
+ return cached.result;
651
+ }
652
+ }
653
+ const partRefs = [];
654
+ const result = [];
655
+ for (const info of msgs) {
656
+ const messageParts = parts[info.id];
657
+ partRefs.push(messageParts);
658
+ result.push({ info, parts: messageParts ?? [] });
659
+ }
660
+ touchSessionMessageRows(sessionID, { msgs, partRefs, result });
661
+ return result;
662
+ },
330
663
  hydrate: (sessionID, msgs) => set((s) => {
664
+ // An authoritative load — the disk repaint itself, or a reconcile —
665
+ // re-establishes the session, so its entry is no longer a fragment.
666
+ evictedSessions.delete(sessionID);
331
667
  const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
332
668
  const incoming = msgs
333
669
  .filter((m) => !!m?.info?.id)
@@ -470,7 +806,7 @@ export const useSyncStore = create()((set, get) => ({
470
806
  if (serverHasParts || newParts[echoId]?.length)
471
807
  continue;
472
808
  newParts[echoId] = bridge;
473
- bridgedPartIds.add(echoId);
809
+ trackId(bridgedPartIds, sessionID, echoId);
474
810
  }
475
811
  for (const m of msgs) {
476
812
  if (!m?.info?.id)
@@ -484,8 +820,8 @@ export const useSyncStore = create()((set, get) => ({
484
820
  // If this message still carries bridged optimistic parts, a hydrate
485
821
  // snapshot with real parts should replace them immediately. Otherwise
486
822
  // reconcile-by-extras can keep both copies and duplicate user text.
487
- if (bridgedPartIds.has(mid) && inParts.length > 0) {
488
- bridgedPartIds.delete(mid);
823
+ if (hasTrackedId(bridgedPartIds, sessionID, mid) && inParts.length > 0) {
824
+ untrackId(bridgedPartIds, sessionID, mid);
489
825
  newParts[mid] = inParts;
490
826
  continue;
491
827
  }
@@ -532,6 +868,13 @@ export const useSyncStore = create()((set, get) => ({
532
868
  optimisticIds.clear();
533
869
  dispatchedOptimisticIds.clear();
534
870
  bridgedPartIds.clear();
871
+ deltaActiveParts.clear();
872
+ // The data these holds protected is gone, so the holds are too —
873
+ // otherwise a late unmount could free a session the next page opened.
874
+ sessionConsumers.clear();
875
+ detachedSessions.clear();
876
+ evictedSessions.clear();
877
+ sessionMessageRows.clear();
535
878
  set({
536
879
  messages: {},
537
880
  parts: {},
@@ -649,7 +992,7 @@ export const useSyncStore = create()((set, get) => ({
649
992
  }
650
993
  if (bridge && !newParts[info.id]?.length) {
651
994
  newParts[info.id] = bridge;
652
- bridgedPartIds.add(info.id);
995
+ trackId(bridgedPartIds, info.sessionID, info.id);
653
996
  }
654
997
  return {
655
998
  messages: { ...s.messages, [info.sessionID]: next },
@@ -698,7 +1041,11 @@ export const useSyncStore = create()((set, get) => ({
698
1041
  role: "assistant",
699
1042
  });
700
1043
  }
701
- store.upsertPart(part.messageID, part);
1044
+ // Pass resolvedSessionID explicitly — part.sessionID can be absent
1045
+ // on the wire (the fallback chain above exists for exactly that),
1046
+ // and the deltaActiveParts guard in upsertPart must not silently
1047
+ // no-op just because that field is missing.
1048
+ store.upsertPart(part.messageID, part, resolvedSessionID);
702
1049
  if (isTextLikePart(part)) {
703
1050
  if (!resolvedSessionID)
704
1051
  return;
@@ -749,12 +1096,13 @@ export const useSyncStore = create()((set, get) => ({
749
1096
  }
750
1097
  store.upsertPart(props.messageID, {
751
1098
  id: props.partID,
1099
+ sessionID: props.sessionID,
752
1100
  messageID: props.messageID,
753
1101
  type: "text",
754
1102
  [props.field]: "",
755
1103
  });
756
1104
  }
757
- store.applyPartDelta(props.messageID, props.partID, props.field, props.delta);
1105
+ store.applyPartDelta(props.sessionID, props.messageID, props.partID, props.field, props.delta);
758
1106
  if (props.field === "text") {
759
1107
  const updated = get().parts[props.messageID]?.find((p) => p.id === props.partID);
760
1108
  if (updated && isTextLikePart(updated) && updated.text.length > 0) {
@@ -774,9 +1122,12 @@ export const useSyncStore = create()((set, get) => ({
774
1122
  const sessionID = event.properties.sessionID;
775
1123
  if (sessionID)
776
1124
  store.setStatus(sessionID, { type: "idle" });
777
- // Streaming finished — clear delta tracking so future
778
- // message.part.updated snapshots are accepted normally.
779
- deltaActiveParts.clear();
1125
+ // Streaming finished for THIS session — clear only its own delta
1126
+ // tracking so future message.part.updated snapshots for it are
1127
+ // accepted normally. Never the whole map: another session may
1128
+ // still be streaming (see comment above deltaActiveParts).
1129
+ if (sessionID)
1130
+ deltaActiveParts.delete(sessionID);
780
1131
  return;
781
1132
  }
782
1133
  case "session.error": {
@@ -787,7 +1138,9 @@ export const useSyncStore = create()((set, get) => ({
787
1138
  const error = props.error;
788
1139
  // Mark session idle — errors terminate the response.
789
1140
  store.setStatus(sid, { type: "idle" });
790
- deltaActiveParts.clear();
1141
+ // Clear only this session's delta tracking — see the idle handler
1142
+ // above and the comment above deltaActiveParts.
1143
+ deltaActiveParts.delete(sid);
791
1144
  // Patch the error onto the last assistant message in the sync store.
792
1145
  // If no assistant message exists yet, create a temporary one so the
793
1146
  // error is visible immediately. The event handler in