@cortexkit/opencode-magic-context 0.32.0 → 0.32.2

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 (191) hide show
  1. package/README.md +3 -3
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +3 -0
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +82 -2
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/config/transform-mode.d.ts +15 -0
  8. package/dist/config/transform-mode.d.ts.map +1 -0
  9. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
  11. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  13. package/dist/features/magic-context/git-commits/indexer.d.ts.map +1 -1
  14. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  15. package/dist/features/magic-context/memory/embedding-provider.d.ts +6 -0
  16. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  17. package/dist/features/magic-context/memory/embedding-synapse.d.ts +98 -0
  18. package/dist/features/magic-context/memory/embedding-synapse.d.ts.map +1 -0
  19. package/dist/features/magic-context/memory/embedding.d.ts +1 -1
  20. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  21. package/dist/features/magic-context/message-index-async.d.ts +4 -1
  22. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  23. package/dist/features/magic-context/message-index.d.ts +7 -4
  24. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  25. package/dist/features/magic-context/migrations.d.ts +21 -16
  26. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  27. package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
  28. package/dist/features/magic-context/project-embedding-registry.d.ts +27 -0
  29. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  30. package/dist/features/magic-context/search-measurement.d.ts +14 -0
  31. package/dist/features/magic-context/search-measurement.d.ts.map +1 -0
  32. package/dist/features/magic-context/search.d.ts +11 -1
  33. package/dist/features/magic-context/search.d.ts.map +1 -1
  34. package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
  35. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  36. package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
  37. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  38. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
  39. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  40. package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
  41. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  42. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
  43. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
  45. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
  47. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  48. package/dist/features/magic-context/storage-clone.d.ts +4 -0
  49. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  50. package/dist/features/magic-context/storage-db.d.ts +7 -1
  51. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  52. package/dist/features/magic-context/storage-embedding-measurements.d.ts +60 -0
  53. package/dist/features/magic-context/storage-embedding-measurements.d.ts.map +1 -0
  54. package/dist/features/magic-context/storage-meta-persisted.d.ts +10 -5
  55. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  56. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
  58. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  59. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage.d.ts +1 -0
  61. package/dist/features/magic-context/storage.d.ts.map +1 -1
  62. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
  63. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  64. package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
  65. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  66. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/auto-search-runner.d.ts +7 -1
  68. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/channel2-delivery.d.ts +2 -0
  70. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/command-handler.d.ts +5 -0
  72. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +4 -0
  74. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
  76. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
  77. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
  80. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  82. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
  85. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
  86. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
  87. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  89. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/hook-handlers.d.ts +6 -0
  91. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/hook.d.ts +19 -3
  93. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/index.d.ts +1 -1
  95. package/dist/hooks/magic-context/index.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -0
  97. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/lkg-replay.d.ts +51 -0
  99. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -0
  100. package/dist/hooks/magic-context/lkg-slot.d.ts +25 -0
  101. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -0
  102. package/dist/hooks/magic-context/module-state-sync.d.ts +169 -0
  103. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -0
  104. package/dist/hooks/magic-context/module-wire.d.ts +73 -0
  105. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -0
  106. package/dist/hooks/magic-context/pass-outcome.d.ts +15 -0
  107. package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -0
  108. package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
  109. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  110. package/dist/hooks/magic-context/read-session-raw.d.ts +30 -2
  111. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/rust-mode-transform.d.ts +64 -0
  114. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -0
  115. package/dist/hooks/magic-context/shadow-sender.d.ts +84 -49
  116. package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/tag-messages.d.ts +2 -0
  118. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  119. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +56 -0
  120. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  121. package/dist/hooks/magic-context/transform.d.ts +9 -0
  122. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  123. package/dist/index.d.ts.map +1 -1
  124. package/dist/index.js +17355 -10510
  125. package/dist/plugin/boot-quiet.d.ts +12 -0
  126. package/dist/plugin/boot-quiet.d.ts.map +1 -0
  127. package/dist/plugin/dream-timer.d.ts +6 -2
  128. package/dist/plugin/dream-timer.d.ts.map +1 -1
  129. package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
  130. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  131. package/dist/plugin/embedding-routing.d.ts +32 -0
  132. package/dist/plugin/embedding-routing.d.ts.map +1 -0
  133. package/dist/plugin/hooks/create-session-hooks.d.ts +54 -2
  134. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  135. package/dist/plugin/messages-transform.d.ts +4 -3
  136. package/dist/plugin/messages-transform.d.ts.map +1 -1
  137. package/dist/plugin/rpc-handlers.d.ts +19 -2
  138. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  139. package/dist/plugin/rust-tool-backends.d.ts +10 -0
  140. package/dist/plugin/rust-tool-backends.d.ts.map +1 -0
  141. package/dist/plugin/tool-registry.d.ts +2 -0
  142. package/dist/plugin/tool-registry.d.ts.map +1 -1
  143. package/dist/shared/models-dev-cache.d.ts +5 -0
  144. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  145. package/dist/shared/rpc-client.d.ts +3 -1
  146. package/dist/shared/rpc-client.d.ts.map +1 -1
  147. package/dist/shared/rpc-notifications.d.ts +11 -6
  148. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  149. package/dist/shared/rpc-server.d.ts +2 -2
  150. package/dist/shared/rpc-server.d.ts.map +1 -1
  151. package/dist/shared/rpc-types.d.ts +3 -0
  152. package/dist/shared/rpc-types.d.ts.map +1 -1
  153. package/dist/shared/sqlite.d.ts.map +1 -1
  154. package/dist/shared/tui-config.d.ts +5 -3
  155. package/dist/shared/tui-config.d.ts.map +1 -1
  156. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  157. package/dist/tools/ctx-memory/types.d.ts +2 -0
  158. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  159. package/dist/tools/ctx-reduce/tools.d.ts +2 -0
  160. package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
  161. package/dist/tui/data/context-db.d.ts +9 -2
  162. package/dist/tui/data/context-db.d.ts.map +1 -1
  163. package/dist/tui/data/notification-socket.d.ts +4 -5
  164. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  165. package/dist/tui-compiled/data/context-db.d.ts +9 -2
  166. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  167. package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
  168. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
  169. package/package.json +2 -1
  170. package/src/shared/models-dev-cache.test.ts +5 -0
  171. package/src/shared/models-dev-cache.ts +44 -6
  172. package/src/shared/opencode-compaction-detector.test.ts +11 -2
  173. package/src/shared/rpc-client.test.ts +213 -9
  174. package/src/shared/rpc-client.ts +48 -39
  175. package/src/shared/rpc-notifications.test.ts +26 -5
  176. package/src/shared/rpc-notifications.ts +44 -35
  177. package/src/shared/rpc-server.ts +69 -33
  178. package/src/shared/rpc-types.ts +3 -0
  179. package/src/shared/sqlite.ts +22 -3
  180. package/src/shared/tui-config.test.ts +4 -12
  181. package/src/shared/tui-config.ts +6 -6
  182. package/src/tui/data/context-db.test.ts +113 -0
  183. package/src/tui/data/context-db.ts +26 -19
  184. package/src/tui/data/notification-socket.test.ts +277 -11
  185. package/src/tui/data/notification-socket.ts +257 -86
  186. package/src/tui/index.tsx +42 -25
  187. package/src/tui/slots/sidebar-content.tsx +62 -24
  188. package/src/tui-compiled/data/context-db.ts +26 -19
  189. package/src/tui-compiled/data/notification-socket.ts +257 -86
  190. package/src/tui-compiled/index.tsx +83 -30
  191. package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
@@ -30,8 +30,8 @@ export interface SocketNotification {
30
30
  interface NotificationSocketOptions {
31
31
  /** Current active session id (re-read cheaply to follow session switches). */
32
32
  getSessionId: () => string | null;
33
- /** Handle one delivered notification. Returns true if it was consumed (so its
34
- * id can advance the ack cursor). Async because dialog handlers await. */
33
+ /** Handle one delivered notification. Returns true only after it is fully
34
+ * consumed and can be acknowledged. Async because dialog handlers await. */
35
35
  onNotification: (notification: SocketNotification) => boolean | Promise<boolean>;
36
36
  }
37
37
 
@@ -48,40 +48,51 @@ let reconnectAttempt = 0;
48
48
  let closed = false;
49
49
  let helloedSession: string | null = null;
50
50
  let opts: NotificationSocketOptions | null = null;
51
- /** Generation of the rpc client at connect time; a dispose/reinit bumps it and
52
- * invalidates an in-flight socket so its late callbacks are ignored. */
51
+ let activeToken: string | null = null;
52
+ /** Generation of the rpc client used by the active socket. */
53
53
  let connectGeneration = 0;
54
+ /** Exactly one endpoint lookup may be active. A monotonically increasing id lets
55
+ * stop/restart invalidate a late lookup before it can publish a socket. */
56
+ let nextAttemptId = 0;
57
+ let inFlightAttemptId: number | null = null;
54
58
 
55
59
  const GLOBAL_CURSOR_KEY = "global";
56
60
  const SESSION_CURSOR_PREFIX = "session:";
57
61
  const MAX_DEDUPED_NOTIFICATION_IDS = 500;
62
+ const LEGACY_INSTANCE_ID = "legacy";
63
+ type NotificationProtocolMode = "legacy" | "v2";
58
64
 
59
65
  /**
60
- * Notification ids are process-global, but acknowledgement cursors are scoped. A
61
- * high id consumed in session A must not become the prune watermark for session B;
62
- * global notifications also carry their own cursor so they cannot skip session
63
- * backlog. The id set de-dupes at-least-once re-delivery when a reconnect sends
64
- * a session cursor that is intentionally lower than the global cursor.
66
+ * Notification ids restart with every server instance. Epoch-prefixing cursor keys
67
+ * and deduplication ids prevents a surviving TUI from applying a replaced server's
68
+ * high watermark or remembered ids to the replacement's fresh queue.
65
69
  */
70
+ let activeInstanceId: string | null = null;
71
+ let notificationProtocolMode: NotificationProtocolMode | null = null;
72
+ const bufferedNotifications: SocketNotification[] = [];
66
73
  const lastHandledIdByCursor = new Map<string, number>();
67
- const handledNotificationIds = new Set<number>();
68
- const handledNotificationIdOrder: number[] = [];
74
+ const handledNotificationIds = new Set<string>();
75
+ const handledNotificationIdOrder: string[] = [];
76
+ const legacyUnconsumedIdsByCursor = new Map<string, Set<number>>();
77
+ const legacyConsumedIdsByCursor = new Map<string, Set<number>>();
78
+ /** Dialog actions share UI state, so notification handlers must never overlap. */
79
+ let notificationHandlingChain: Promise<void> = Promise.resolve();
69
80
 
70
- /** Open the persistent notification socket. Idempotent: a second call while open
71
- * is a no-op. Reconnects on its own after any drop. */
81
+ /** Open the persistent notification socket. Reconnects on its own after a drop. */
72
82
  export function startNotificationSocket(options: NotificationSocketOptions): void {
73
83
  opts = options;
74
84
  closed = false;
75
- connectGeneration = getRpcGeneration();
76
- connect();
85
+ if (!socket && inFlightAttemptId === null) void connect();
77
86
  if (!sessionWatchTimer) {
78
87
  sessionWatchTimer = setInterval(watchSession, SESSION_WATCH_MS);
79
88
  }
80
89
  }
81
90
 
82
- /** Close the socket and stop reconnecting. Call on TUI dispose. */
91
+ /** Close the socket and release all state owned by this TUI initialization. */
83
92
  export function stopNotificationSocket(): void {
84
93
  closed = true;
94
+ nextAttemptId += 1;
95
+ inFlightAttemptId = null;
85
96
  if (reconnectTimer) {
86
97
  clearTimeout(reconnectTimer);
87
98
  reconnectTimer = undefined;
@@ -96,8 +107,19 @@ export function stopNotificationSocket(): void {
96
107
  // best-effort
97
108
  }
98
109
  socket = null;
110
+ opts = null;
111
+ activeToken = null;
99
112
  helloedSession = null;
100
113
  reconnectAttempt = 0;
114
+ activeInstanceId = null;
115
+ notificationProtocolMode = null;
116
+ bufferedNotifications.length = 0;
117
+ lastHandledIdByCursor.clear();
118
+ handledNotificationIds.clear();
119
+ handledNotificationIdOrder.length = 0;
120
+ legacyUnconsumedIdsByCursor.clear();
121
+ legacyConsumedIdsByCursor.clear();
122
+ notificationHandlingChain = Promise.resolve();
101
123
  }
102
124
 
103
125
  function scheduleReconnect(): void {
@@ -112,17 +134,22 @@ function scheduleReconnect(): void {
112
134
  }
113
135
 
114
136
  async function connect(): Promise<void> {
115
- if (closed) return;
116
- if (socket) return; // already connected/connecting
137
+ if (closed || socket || inFlightAttemptId !== null) return;
117
138
 
118
139
  const client = getRpcClient();
119
140
  if (!client) {
120
141
  scheduleReconnect();
121
142
  return;
122
143
  }
144
+
145
+ const attemptId = ++nextAttemptId;
146
+ const rpcGeneration = getRpcGeneration();
147
+ inFlightAttemptId = attemptId;
123
148
  const endpoint = await client.resolveEndpoint();
124
- // The generation may have bumped (dispose/reinit) while resolving — abandon.
125
- if (closed || getRpcGeneration() !== connectGeneration) return;
149
+ if (closed || inFlightAttemptId !== attemptId || getRpcGeneration() !== rpcGeneration) {
150
+ return;
151
+ }
152
+ inFlightAttemptId = null;
126
153
  if (!endpoint) {
127
154
  scheduleReconnect();
128
155
  return;
@@ -130,32 +157,48 @@ async function connect(): Promise<void> {
130
157
 
131
158
  let ws: WebSocket;
132
159
  try {
133
- const tokenQuery = `?token=${encodeURIComponent(endpoint.token ?? "")}`;
134
- ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}/ws${tokenQuery}`);
160
+ ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}/ws`, {
161
+ headers: endpoint.token ? { Authorization: `Bearer ${endpoint.token}` } : {},
162
+ });
135
163
  } catch {
136
164
  client.reset();
137
165
  scheduleReconnect();
138
166
  return;
139
167
  }
168
+
169
+ if (closed || getRpcGeneration() !== rpcGeneration || socket) {
170
+ ws.close();
171
+ return;
172
+ }
173
+ connectGeneration = rpcGeneration;
174
+ activeToken = endpoint.token;
175
+ notificationProtocolMode = null;
176
+ bufferedNotifications.length = 0;
177
+ switchNotificationEpoch(endpoint.instanceId ?? LEGACY_INSTANCE_ID);
140
178
  socket = ws;
141
179
 
142
180
  ws.addEventListener("open", () => {
143
- if (socket !== ws) return;
181
+ if (socket !== ws || getRpcGeneration() !== connectGeneration) {
182
+ ws.close();
183
+ return;
184
+ }
144
185
  reconnectAttempt = 0;
145
186
  sendHello(ws, endpoint.token);
146
187
  });
147
188
 
148
189
  ws.addEventListener("message", (event) => {
149
190
  if (socket !== ws) return;
150
- void handleSocketMessage(ws, String((event as MessageEvent).data));
191
+ handleSocketMessage(ws, String((event as MessageEvent).data), endpoint.token);
151
192
  });
152
193
 
153
194
  const onDown = () => {
195
+ if (socket !== ws) return;
154
196
  client.reset();
155
- if (socket === ws) {
156
- socket = null;
157
- helloedSession = null;
158
- }
197
+ socket = null;
198
+ activeToken = null;
199
+ helloedSession = null;
200
+ notificationProtocolMode = null;
201
+ bufferedNotifications.length = 0;
159
202
  scheduleReconnect();
160
203
  };
161
204
  ws.addEventListener("close", onDown);
@@ -168,54 +211,55 @@ function sendHello(ws: WebSocket, token: string | null): void {
168
211
  ws.send(
169
212
  JSON.stringify({
170
213
  type: "hello",
214
+ protocol: 2,
215
+ instanceId: activeInstanceId,
171
216
  token: token ?? "",
172
217
  sessionId,
218
+ // Older servers still read these scoped cursors. Protocol 2 servers
219
+ // rely only on exact acknowledgements and do not prune from them.
173
220
  lastReceivedId: cursorForKey(cursorKeyForSession(sessionId)),
174
- globalLastReceivedId: cursorForKey(GLOBAL_CURSOR_KEY),
221
+ globalLastReceivedId: cursorForKey(cursorKeyForSession(undefined)),
175
222
  }),
176
223
  );
177
224
  }
178
225
 
179
- async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
180
- let msg: { type?: string; notification?: SocketNotification; error?: string };
226
+ function handleSocketMessage(ws: WebSocket, raw: string, token: string | null): void {
227
+ let msg: {
228
+ type?: string;
229
+ notification?: SocketNotification;
230
+ error?: string;
231
+ instanceId?: string;
232
+ };
181
233
  try {
182
234
  msg = JSON.parse(raw);
183
235
  } catch {
184
236
  return;
185
237
  }
186
238
 
187
- if (msg.type === "notification" && msg.notification) {
188
- const notification = msg.notification;
189
- // Client-side session filter mirrors the old poller's per-message re-check:
190
- // a session-scoped notification is only acted on while the TUI is actually
191
- // viewing that session (the active session can change between queueing and
192
- // delivery). Global (session-less) notifications always apply.
193
- const active = opts?.getSessionId() ?? null;
194
- if (notification.sessionId && active && notification.sessionId !== active) {
195
- // Not for the session we're viewing — do NOT ack it (a TUI on the right
196
- // session, or a later switch back, should still get it). Just skip.
197
- return;
198
- }
199
- if (handledNotificationIds.has(notification.id)) {
200
- sendAck(ws, notification);
201
- return;
239
+ if (msg.type === "hello-ack") {
240
+ if (typeof msg.instanceId === "string") {
241
+ notificationProtocolMode = "v2";
242
+ if (msg.instanceId !== activeInstanceId) {
243
+ switchNotificationEpoch(msg.instanceId);
244
+ // The server does not prune protocol 2 backlog from hello cursors, so a
245
+ // corrected hello safely establishes fresh epoch-scoped state.
246
+ sendHello(ws, token);
247
+ }
248
+ } else {
249
+ // A hello-ack without an instance id is the frozen v0.32 shape. Its
250
+ // server ignores exact-id acks, so cursors must remain gap-safe.
251
+ notificationProtocolMode = "legacy";
252
+ switchNotificationEpoch(LEGACY_INSTANCE_ID);
202
253
  }
254
+ flushBufferedNotifications(ws);
255
+ return;
256
+ }
203
257
 
204
- let consumed = false;
205
- try {
206
- consumed = await Promise.resolve(opts?.onNotification(notification) ?? false);
207
- } catch {
208
- consumed = false;
209
- }
210
- // A dispose/reinit during an awaited dialog handler invalidates this socket.
211
- if (socket !== ws || getRpcGeneration() !== connectGeneration) return;
212
- if (consumed) {
213
- rememberHandledId(notification.id);
214
- advanceCursor(notificationCursorKey(notification), notification.id);
215
- // Ack only the notification's own cursor scope. A dropped ack is safe:
216
- // the next hello sends the same per-scope cursors and duplicates are
217
- // ignored locally.
218
- sendAck(ws, notification);
258
+ if (msg.type === "notification" && msg.notification) {
259
+ if (notificationProtocolMode === null) {
260
+ bufferedNotifications.push(msg.notification);
261
+ } else {
262
+ queueNotification(ws, msg.notification);
219
263
  }
220
264
  return;
221
265
  }
@@ -231,14 +275,82 @@ async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
231
275
  }
232
276
  }
233
277
 
278
+ function flushBufferedNotifications(ws: WebSocket): void {
279
+ const pending = bufferedNotifications.splice(0);
280
+ for (const notification of pending) queueNotification(ws, notification);
281
+ }
282
+
283
+ function queueNotification(ws: WebSocket, notification: SocketNotification): void {
284
+ const deliveryInstanceId = activeInstanceId ?? LEGACY_INSTANCE_ID;
285
+ const deliveryMode = notificationProtocolMode;
286
+ if (deliveryMode === null) return;
287
+ // A single promise chain prevents two dialog actions from replacing each
288
+ // other's UI while either handler is still awaiting user input.
289
+ notificationHandlingChain = notificationHandlingChain
290
+ .then(() => handleNotification(ws, notification, deliveryInstanceId, deliveryMode))
291
+ .catch(() => {});
292
+ }
293
+
294
+ async function handleNotification(
295
+ ws: WebSocket,
296
+ notification: SocketNotification,
297
+ deliveryInstanceId: string,
298
+ deliveryMode: NotificationProtocolMode,
299
+ ): Promise<void> {
300
+ if (
301
+ socket !== ws ||
302
+ getRpcGeneration() !== connectGeneration ||
303
+ activeInstanceId !== deliveryInstanceId ||
304
+ notificationProtocolMode !== deliveryMode
305
+ ) {
306
+ return;
307
+ }
308
+ // Client-side session filtering follows session switches that happen between
309
+ // queueing and delivery. Global notifications always apply.
310
+ const active = opts?.getSessionId() ?? null;
311
+ if (notification.sessionId !== undefined && notification.sessionId !== active) return;
312
+
313
+ if (deliveryMode === "legacy") markLegacyUnconsumed(notification);
314
+ if (handledNotificationIds.has(notificationDedupKey(notification.id, deliveryInstanceId))) {
315
+ if (deliveryMode === "legacy") markLegacyConsumed(notification);
316
+ sendAck(ws, notification, deliveryMode);
317
+ return;
318
+ }
319
+
320
+ let consumed = false;
321
+ try {
322
+ consumed = await Promise.resolve(opts?.onNotification(notification) ?? false);
323
+ } catch {
324
+ consumed = false;
325
+ }
326
+ // A dispose, reconnect, or epoch correction during an awaited dialog invalidates
327
+ // the delivery. The server retains it for the current socket to redeliver.
328
+ if (
329
+ socket !== ws ||
330
+ getRpcGeneration() !== connectGeneration ||
331
+ activeInstanceId !== deliveryInstanceId ||
332
+ notificationProtocolMode !== deliveryMode
333
+ ) {
334
+ return;
335
+ }
336
+ if (consumed) {
337
+ rememberHandledId(notification.id, deliveryInstanceId);
338
+ if (deliveryMode === "legacy") {
339
+ markLegacyConsumed(notification);
340
+ } else {
341
+ advanceCursor(notificationCursorKey(notification), notification.id);
342
+ }
343
+ sendAck(ws, notification, deliveryMode);
344
+ }
345
+ }
346
+
234
347
  function cursorKeyForSession(sessionId: string | null | undefined): string {
235
- return sessionId ? `${SESSION_CURSOR_PREFIX}${sessionId}` : GLOBAL_CURSOR_KEY;
348
+ const scope = sessionId ? `${SESSION_CURSOR_PREFIX}${sessionId}` : GLOBAL_CURSOR_KEY;
349
+ return `${activeInstanceId ?? LEGACY_INSTANCE_ID}:${scope}`;
236
350
  }
237
351
 
238
352
  function notificationCursorKey(notification: SocketNotification): string {
239
- return notification.sessionId
240
- ? `${SESSION_CURSOR_PREFIX}${notification.sessionId}`
241
- : GLOBAL_CURSOR_KEY;
353
+ return cursorKeyForSession(notification.sessionId);
242
354
  }
243
355
 
244
356
  function cursorForKey(key: string): number {
@@ -249,34 +361,96 @@ function advanceCursor(key: string, id: number): void {
249
361
  if (id > cursorForKey(key)) lastHandledIdByCursor.set(key, id);
250
362
  }
251
363
 
252
- function rememberHandledId(id: number): void {
253
- if (handledNotificationIds.has(id)) return;
254
- handledNotificationIds.add(id);
255
- handledNotificationIdOrder.push(id);
364
+ function idsForCursor(map: Map<string, Set<number>>, key: string): Set<number> {
365
+ let ids = map.get(key);
366
+ if (!ids) {
367
+ ids = new Set<number>();
368
+ map.set(key, ids);
369
+ }
370
+ return ids;
371
+ }
372
+
373
+ function markLegacyUnconsumed(notification: SocketNotification): void {
374
+ const key = notificationCursorKey(notification);
375
+ if (idsForCursor(legacyConsumedIdsByCursor, key).has(notification.id)) return;
376
+ idsForCursor(legacyUnconsumedIdsByCursor, key).add(notification.id);
377
+ }
378
+
379
+ function markLegacyConsumed(notification: SocketNotification): void {
380
+ const key = notificationCursorKey(notification);
381
+ idsForCursor(legacyUnconsumedIdsByCursor, key).delete(notification.id);
382
+ const consumedIds = idsForCursor(legacyConsumedIdsByCursor, key);
383
+ consumedIds.add(notification.id);
384
+
385
+ let safeCursor = Math.max(cursorForKey(key), ...consumedIds);
386
+ const unconsumedIds = legacyUnconsumedIdsByCursor.get(key);
387
+ if (unconsumedIds && unconsumedIds.size > 0) {
388
+ safeCursor = Math.min(safeCursor, Math.min(...unconsumedIds) - 1);
389
+ }
390
+ advanceCursor(key, safeCursor);
391
+ for (const id of consumedIds) {
392
+ if (id <= cursorForKey(key)) consumedIds.delete(id);
393
+ }
394
+ }
395
+
396
+ function notificationDedupKey(
397
+ id: number,
398
+ instanceId = activeInstanceId ?? LEGACY_INSTANCE_ID,
399
+ ): string {
400
+ return `${instanceId}:${id}`;
401
+ }
402
+
403
+ function rememberHandledId(id: number, instanceId: string): void {
404
+ const key = notificationDedupKey(id, instanceId);
405
+ if (handledNotificationIds.has(key)) return;
406
+ handledNotificationIds.add(key);
407
+ handledNotificationIdOrder.push(key);
256
408
  while (handledNotificationIdOrder.length > MAX_DEDUPED_NOTIFICATION_IDS) {
257
409
  const evicted = handledNotificationIdOrder.shift();
258
410
  if (evicted !== undefined) handledNotificationIds.delete(evicted);
259
411
  }
260
412
  }
261
413
 
262
- function sendAck(ws: WebSocket, notification: SocketNotification): void {
263
- const lastReceivedId = cursorForKey(notificationCursorKey(notification));
264
- const ack = notification.sessionId
265
- ? { type: "ack", sessionId: notification.sessionId, lastReceivedId }
266
- : { type: "ack", ackScope: "global", lastReceivedId };
414
+ function switchNotificationEpoch(instanceId: string): void {
415
+ if (activeInstanceId === instanceId) return;
416
+ activeInstanceId = instanceId;
417
+ lastHandledIdByCursor.clear();
418
+ handledNotificationIds.clear();
419
+ handledNotificationIdOrder.length = 0;
420
+ legacyUnconsumedIdsByCursor.clear();
421
+ legacyConsumedIdsByCursor.clear();
422
+ }
423
+
424
+ function sendAck(
425
+ ws: WebSocket,
426
+ notification: SocketNotification,
427
+ mode: NotificationProtocolMode,
428
+ ): void {
267
429
  try {
268
- ws.send(JSON.stringify(ack));
430
+ if (mode === "legacy") {
431
+ const cursor = cursorForKey(notificationCursorKey(notification));
432
+ ws.send(
433
+ JSON.stringify({
434
+ type: "ack",
435
+ cursor,
436
+ ...(notification.sessionId
437
+ ? { sessionId: notification.sessionId }
438
+ : { ackScope: "global" }),
439
+ }),
440
+ );
441
+ return;
442
+ }
443
+ // Exact ids avoid deleting an earlier notification whose handler failed
444
+ // while a later notification was consumed successfully.
445
+ ws.send(JSON.stringify({ type: "ack", ids: [notification.id] }));
269
446
  } catch {
270
- // best-effort; reconnect hello re-syncs via per-scope cursors
447
+ // Best-effort: an unacknowledged row is safely deduplicated and re-acked
448
+ // when the server delivers it again after reconnecting.
271
449
  }
272
450
  }
273
451
 
274
452
  export function _resetNotificationSocketStateForTesting(): void {
275
453
  stopNotificationSocket();
276
- opts = null;
277
- lastHandledIdByCursor.clear();
278
- handledNotificationIds.clear();
279
- handledNotificationIdOrder.length = 0;
280
454
  }
281
455
 
282
456
  /** Cheap session-change watcher: re-scope the socket only when the active session
@@ -285,10 +459,7 @@ function watchSession(): void {
285
459
  if (closed || !socket || socket.readyState !== WebSocket.OPEN) return;
286
460
  const current = opts?.getSessionId() ?? null;
287
461
  if (current === helloedSession) return;
288
- // Re-hello with the new session; the server replaces this socket's sink scope.
289
- const client = getRpcClient();
290
- void client?.resolveEndpoint().then((endpoint) => {
291
- if (!socket || socket.readyState !== WebSocket.OPEN) return;
292
- sendHello(socket, endpoint?.token ?? null);
293
- });
462
+ // Re-hello with the token authenticated by this socket; no rediscovery or
463
+ // network request is needed for a local route change.
464
+ sendHello(socket, activeToken);
294
465
  }
@@ -676,6 +676,42 @@ const StatusDialog = props => {
676
676
  _$setProp(_el$45, "marginTop", 1);
677
677
  _$insertNode(_el$46, _el$47);
678
678
  _$insertNode(_el$47, _$createTextNode(`History Compression`));
679
+ _$insert(_el$37, (() => {
680
+ var _c$3 = _$memo(() => typeof s().boundaryPresent === "boolean");
681
+ return () => _c$3() && _$createComponent(R, {
682
+ get t() {
683
+ return t();
684
+ },
685
+ l: "Boundary",
686
+ get v() {
687
+ return s().boundaryPresent ? "present" : "absent";
688
+ }
689
+ });
690
+ })(), null);
691
+ _$insert(_el$37, (() => {
692
+ var _c$4 = _$memo(() => s().coverageOrdinal !== undefined);
693
+ return () => _c$4() && _$createComponent(R, {
694
+ get t() {
695
+ return t();
696
+ },
697
+ l: "Coverage ordinal",
698
+ get v() {
699
+ return _$memo(() => s().coverageOrdinal == null)() ? "none" : String(s().coverageOrdinal);
700
+ }
701
+ });
702
+ })(), null);
703
+ _$insert(_el$37, (() => {
704
+ var _c$5 = _$memo(() => typeof s().boundaryPresent === "boolean");
705
+ return () => _c$5() && _$createComponent(R, {
706
+ get t() {
707
+ return t();
708
+ },
709
+ l: "Compartments",
710
+ get v() {
711
+ return String(s().compartmentCount);
712
+ }
713
+ });
714
+ })(), null);
679
715
  _$insert(_el$37, _$createComponent(R, {
680
716
  get t() {
681
717
  return t();
@@ -686,8 +722,8 @@ const StatusDialog = props => {
686
722
  }
687
723
  }), null);
688
724
  _$insert(_el$37, (() => {
689
- var _c$3 = _$memo(() => s().compressionBudget != null);
690
- return () => _c$3() && _$createComponent(R, {
725
+ var _c$6 = _$memo(() => s().compressionBudget != null);
726
+ return () => _c$6() && _$createComponent(R, {
691
727
  get t() {
692
728
  return t();
693
729
  },
@@ -698,8 +734,8 @@ const StatusDialog = props => {
698
734
  });
699
735
  })(), null);
700
736
  _$insert(_el$37, (() => {
701
- var _c$4 = _$memo(() => !!s().lastDreamerRunAt);
702
- return () => _c$4() && _$createComponent(R, {
737
+ var _c$7 = _$memo(() => !!s().lastDreamerRunAt);
738
+ return () => _c$7() && _$createComponent(R, {
703
739
  get t() {
704
740
  return t();
705
741
  },
@@ -713,8 +749,8 @@ const StatusDialog = props => {
713
749
  });
714
750
  })(), null);
715
751
  _$insert(_el$4, (() => {
716
- var _c$5 = _$memo(() => !!s().lastTransformError);
717
- return () => _c$5() && (() => {
752
+ var _c$8 = _$memo(() => !!s().lastTransformError);
753
+ return () => _c$8() && (() => {
718
754
  var _el$62 = _$createElement("box"),
719
755
  _el$63 = _$createElement("text"),
720
756
  _el$64 = _$createTextNode(`⚠ `);
@@ -807,17 +843,32 @@ async function showRecompDialog(api, targetSessionId = getSessionId(api)) {
807
843
  });
808
844
  return false;
809
845
  }
810
- const count = await getCompartmentCount(sessionId);
846
+ const countResult = await getCompartmentCount(sessionId);
811
847
  // Ack only after the dialog is actually shown for the same active session;
812
848
  // route switches while the RPC detail load is in flight must leave it pending.
813
849
  if (getSessionId(api) !== sessionId) return false;
850
+ if (!countResult.ok) {
851
+ showToast(api, {
852
+ message: "Unable to load recomp details",
853
+ variant: "error"
854
+ });
855
+ return false;
856
+ }
857
+ const count = countResult.count;
814
858
  api.ui.dialog.replace(() => _$createComponent(api.ui.DialogConfirm, {
815
859
  title: "\u26A0\uFE0F Recomp Confirmation",
816
860
  get message() {
817
861
  return [count === 0 ? "This session has no compartments yet — recomp will build them from raw history." : `You have ${count} compartments.`, "", "Recomp will regenerate all compartments and facts from raw history.", "This may take a long time and consume significant tokens.", "", "Proceed?"].join("\n");
818
862
  },
819
- onConfirm: () => {
820
- void requestRecomp(sessionId);
863
+ onConfirm: async () => {
864
+ const requested = await requestRecomp(sessionId);
865
+ if (!requested) {
866
+ showToast(api, {
867
+ message: "Recomp request failed",
868
+ variant: "error"
869
+ });
870
+ return;
871
+ }
821
872
  kickRecompProgressRefresh();
822
873
  showToast(api, {
823
874
  message: "Recomp requested — historian will start shortly",
@@ -848,24 +899,23 @@ function showUpgradeDialog(api, resume, targetSessionId = getSessionId(api)) {
848
899
  api.ui.dialog.replace(() => _$createComponent(api.ui.DialogConfirm, {
849
900
  title: title,
850
901
  message: message,
851
- onConfirm: () => {
852
- // Start the sidebar's recomp self-poll immediately — the RPC
853
- // call fires no message event, so without this the progress
854
- // bar wouldn't appear until the upgrade finished.
902
+ onConfirm: async () => {
903
+ const started = await requestUpgrade(sessionId);
904
+ if (!started) {
905
+ showToast(api, {
906
+ message: "Session upgrade request failed",
907
+ variant: "error"
908
+ });
909
+ return;
910
+ }
911
+ // The RPC call fires no message event, so start the sidebar's
912
+ // progress poll only after the server accepts the request.
855
913
  kickRecompProgressRefresh();
856
914
  showToast(api, {
857
915
  message: resume ? "Resuming session upgrade — running in the background" : "Session upgrade started — running in the background",
858
916
  variant: "info"
859
917
  });
860
- // Dismiss the durable reminder ONLY after the upgrade request
861
- // actually started. If requestUpgrade() returns false (RPC /
862
- // server / db / auth failure, restart race), the session stays
863
- // legacy — dismissing first would set upgradeRemindedAt and
864
- // suppress all future reminders for a session that never
865
- // upgraded. (Resume prompts are staging-driven and unaffected.)
866
- void requestUpgrade(sessionId).then(started => {
867
- if (started) void dismissUpgradeReminder(sessionId);
868
- });
918
+ void dismissUpgradeReminder(sessionId);
869
919
  },
870
920
  onCancel: () => {
871
921
  // Explicit decline → set the durable stamp so we don't re-prompt
@@ -1089,13 +1139,20 @@ async function showStartupAnnouncement(api) {
1089
1139
  }
1090
1140
  }
1091
1141
  const tui = async (api, _options, meta) => {
1092
- // Initialize RPC client for server communication
1093
1142
  const directory = api.state.path.directory ?? "";
1143
+ // A conflicted installation intentionally has no server. Gate before RPC
1144
+ // discovery or socket startup so disabled installs perform no idle work.
1145
+ const conflictResult = detectConflicts(directory);
1146
+ if (conflictResult.hasConflict) {
1147
+ showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts);
1148
+ return;
1149
+ }
1094
1150
  initRpcClient(directory);
1095
1151
  await refreshToastDurationMs();
1096
1152
 
1097
1153
  // Register sidebar slot
1098
- api.slots.register(createSidebarContentSlot(api));
1154
+ const sidebarSlot = createSidebarContentSlot(api);
1155
+ api.slots.register(sidebarSlot);
1099
1156
 
1100
1157
  // Register TUI command palette entries (no slash field — slash commands
1101
1158
  // are registered server-side so there's only one /ctx-* registration).
@@ -1124,7 +1181,7 @@ const tui = async (api, _options, meta) => {
1124
1181
  // session; global (session-less) ones always apply. Returning false leaves
1125
1182
  // it unacked so a TUI on the right session (or a later switch back) still
1126
1183
  // gets it.
1127
- if (n.sessionId && requestedSessionId && n.sessionId !== requestedSessionId) {
1184
+ if (n.sessionId !== undefined && n.sessionId !== requestedSessionId) {
1128
1185
  return false;
1129
1186
  }
1130
1187
  if (n.type === "toast") {
@@ -1182,14 +1239,10 @@ const tui = async (api, _options, meta) => {
1182
1239
 
1183
1240
  // Clean up on dispose
1184
1241
  api.lifecycle.onDispose(() => {
1242
+ sidebarSlot.dispose();
1185
1243
  stopNotificationSocket();
1186
1244
  closeRpc();
1187
1245
  });
1188
- const conflictResult = detectConflicts(directory);
1189
- if (conflictResult.hasConflict) {
1190
- showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts);
1191
- return;
1192
- }
1193
1246
 
1194
1247
  // Show one-shot release announcement after conflict gate.
1195
1248
  // Fire-and-forget: if the server isn't ready or RPC fails, the next TUI