@cortexkit/opencode-magic-context 0.31.5 → 0.32.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 (144) hide show
  1. package/README.md +3 -3
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +2 -0
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +9 -0
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
  8. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  10. package/dist/features/magic-context/message-index-async.d.ts +6 -2
  11. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  12. package/dist/features/magic-context/message-index.d.ts +7 -4
  13. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  14. package/dist/features/magic-context/migrations.d.ts +8 -16
  15. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  16. package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
  17. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  18. package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
  19. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  20. package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
  21. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  22. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
  23. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  24. package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
  25. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  26. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
  27. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  28. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
  29. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  30. package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
  31. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-clone.d.ts +34 -0
  33. package/dist/features/magic-context/storage-clone.d.ts.map +1 -0
  34. package/dist/features/magic-context/storage-db.d.ts +1 -1
  35. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  36. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -5
  37. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  38. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
  40. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  41. package/dist/features/magic-context/storage-tags.d.ts +11 -0
  42. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  43. package/dist/features/magic-context/storage.d.ts +2 -1
  44. package/dist/features/magic-context/storage.d.ts.map +1 -1
  45. package/dist/features/magic-context/tagger.d.ts +10 -0
  46. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  47. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
  48. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  49. package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
  50. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  51. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/apply-operations.d.ts +24 -0
  53. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
  55. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
  56. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/compartment-trigger.d.ts +7 -1
  58. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/decay-render.d.ts +2 -0
  60. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
  62. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
  64. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
  65. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
  66. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  68. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/hook.d.ts +3 -0
  70. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/inject-compartments.d.ts +8 -9
  72. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/m0-token-breakdown.d.ts +5 -4
  74. package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
  76. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/read-session-db.d.ts +2 -2
  78. package/dist/hooks/magic-context/read-session-raw.d.ts +32 -2
  79. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  80. package/dist/hooks/magic-context/shadow-sender.d.ts +300 -0
  81. package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -0
  82. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
  84. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/temporal-awareness.d.ts +3 -3
  86. package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
  87. package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +32 -0
  89. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/transform.d.ts +4 -1
  91. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +5418 -972
  94. package/dist/plugin/hooks/create-session-hooks.d.ts +48 -175
  95. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  96. package/dist/plugin/rpc-handlers.d.ts +3 -0
  97. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  98. package/dist/shared/models-dev-cache.d.ts +5 -0
  99. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  100. package/dist/shared/rpc-client.d.ts +3 -1
  101. package/dist/shared/rpc-client.d.ts.map +1 -1
  102. package/dist/shared/rpc-notifications.d.ts +11 -6
  103. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  104. package/dist/shared/rpc-server.d.ts +2 -2
  105. package/dist/shared/rpc-server.d.ts.map +1 -1
  106. package/dist/shared/sqlite.d.ts.map +1 -1
  107. package/dist/shared/tag-transcript.d.ts +18 -0
  108. package/dist/shared/tag-transcript.d.ts.map +1 -1
  109. package/dist/shared/tui-config.d.ts +5 -3
  110. package/dist/shared/tui-config.d.ts.map +1 -1
  111. package/dist/tools/ctx-expand/constants.d.ts +1 -1
  112. package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
  113. package/dist/tui/data/context-db.d.ts +9 -2
  114. package/dist/tui/data/context-db.d.ts.map +1 -1
  115. package/dist/tui/data/notification-socket.d.ts +4 -5
  116. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  117. package/dist/tui-compiled/data/context-db.d.ts +9 -2
  118. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  119. package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
  120. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
  121. package/package.json +2 -1
  122. package/src/shared/models-dev-cache.test.ts +5 -0
  123. package/src/shared/models-dev-cache.ts +44 -6
  124. package/src/shared/opencode-compaction-detector.test.ts +11 -2
  125. package/src/shared/rpc-client.test.ts +213 -9
  126. package/src/shared/rpc-client.ts +48 -39
  127. package/src/shared/rpc-notifications.test.ts +26 -5
  128. package/src/shared/rpc-notifications.ts +44 -35
  129. package/src/shared/rpc-server.ts +77 -33
  130. package/src/shared/sqlite.ts +22 -3
  131. package/src/shared/tag-transcript.test.ts +253 -7
  132. package/src/shared/tag-transcript.ts +422 -114
  133. package/src/shared/tui-config.test.ts +4 -12
  134. package/src/shared/tui-config.ts +6 -6
  135. package/src/tui/data/context-db.test.ts +113 -0
  136. package/src/tui/data/context-db.ts +26 -19
  137. package/src/tui/data/notification-socket.test.ts +277 -11
  138. package/src/tui/data/notification-socket.ts +257 -86
  139. package/src/tui/index.tsx +33 -25
  140. package/src/tui/slots/sidebar-content.tsx +62 -24
  141. package/src/tui-compiled/data/context-db.ts +26 -19
  142. package/src/tui-compiled/data/notification-socket.ts +257 -86
  143. package/src/tui-compiled/index.tsx +41 -24
  144. 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
  }
@@ -807,17 +807,32 @@ async function showRecompDialog(api, targetSessionId = getSessionId(api)) {
807
807
  });
808
808
  return false;
809
809
  }
810
- const count = await getCompartmentCount(sessionId);
810
+ const countResult = await getCompartmentCount(sessionId);
811
811
  // Ack only after the dialog is actually shown for the same active session;
812
812
  // route switches while the RPC detail load is in flight must leave it pending.
813
813
  if (getSessionId(api) !== sessionId) return false;
814
+ if (!countResult.ok) {
815
+ showToast(api, {
816
+ message: "Unable to load recomp details",
817
+ variant: "error"
818
+ });
819
+ return false;
820
+ }
821
+ const count = countResult.count;
814
822
  api.ui.dialog.replace(() => _$createComponent(api.ui.DialogConfirm, {
815
823
  title: "\u26A0\uFE0F Recomp Confirmation",
816
824
  get message() {
817
825
  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
826
  },
819
- onConfirm: () => {
820
- void requestRecomp(sessionId);
827
+ onConfirm: async () => {
828
+ const requested = await requestRecomp(sessionId);
829
+ if (!requested) {
830
+ showToast(api, {
831
+ message: "Recomp request failed",
832
+ variant: "error"
833
+ });
834
+ return;
835
+ }
821
836
  kickRecompProgressRefresh();
822
837
  showToast(api, {
823
838
  message: "Recomp requested — historian will start shortly",
@@ -848,24 +863,23 @@ function showUpgradeDialog(api, resume, targetSessionId = getSessionId(api)) {
848
863
  api.ui.dialog.replace(() => _$createComponent(api.ui.DialogConfirm, {
849
864
  title: title,
850
865
  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.
866
+ onConfirm: async () => {
867
+ const started = await requestUpgrade(sessionId);
868
+ if (!started) {
869
+ showToast(api, {
870
+ message: "Session upgrade request failed",
871
+ variant: "error"
872
+ });
873
+ return;
874
+ }
875
+ // The RPC call fires no message event, so start the sidebar's
876
+ // progress poll only after the server accepts the request.
855
877
  kickRecompProgressRefresh();
856
878
  showToast(api, {
857
879
  message: resume ? "Resuming session upgrade — running in the background" : "Session upgrade started — running in the background",
858
880
  variant: "info"
859
881
  });
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
- });
882
+ void dismissUpgradeReminder(sessionId);
869
883
  },
870
884
  onCancel: () => {
871
885
  // Explicit decline → set the durable stamp so we don't re-prompt
@@ -1089,13 +1103,20 @@ async function showStartupAnnouncement(api) {
1089
1103
  }
1090
1104
  }
1091
1105
  const tui = async (api, _options, meta) => {
1092
- // Initialize RPC client for server communication
1093
1106
  const directory = api.state.path.directory ?? "";
1107
+ // A conflicted installation intentionally has no server. Gate before RPC
1108
+ // discovery or socket startup so disabled installs perform no idle work.
1109
+ const conflictResult = detectConflicts(directory);
1110
+ if (conflictResult.hasConflict) {
1111
+ showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts);
1112
+ return;
1113
+ }
1094
1114
  initRpcClient(directory);
1095
1115
  await refreshToastDurationMs();
1096
1116
 
1097
1117
  // Register sidebar slot
1098
- api.slots.register(createSidebarContentSlot(api));
1118
+ const sidebarSlot = createSidebarContentSlot(api);
1119
+ api.slots.register(sidebarSlot);
1099
1120
 
1100
1121
  // Register TUI command palette entries (no slash field — slash commands
1101
1122
  // are registered server-side so there's only one /ctx-* registration).
@@ -1124,7 +1145,7 @@ const tui = async (api, _options, meta) => {
1124
1145
  // session; global (session-less) ones always apply. Returning false leaves
1125
1146
  // it unacked so a TUI on the right session (or a later switch back) still
1126
1147
  // gets it.
1127
- if (n.sessionId && requestedSessionId && n.sessionId !== requestedSessionId) {
1148
+ if (n.sessionId !== undefined && n.sessionId !== requestedSessionId) {
1128
1149
  return false;
1129
1150
  }
1130
1151
  if (n.type === "toast") {
@@ -1182,14 +1203,10 @@ const tui = async (api, _options, meta) => {
1182
1203
 
1183
1204
  // Clean up on dispose
1184
1205
  api.lifecycle.onDispose(() => {
1206
+ sidebarSlot.dispose();
1185
1207
  stopNotificationSocket();
1186
1208
  closeRpc();
1187
1209
  });
1188
- const conflictResult = detectConflicts(directory);
1189
- if (conflictResult.hasConflict) {
1190
- showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts);
1191
- return;
1192
- }
1193
1210
 
1194
1211
  // Show one-shot release announcement after conflict gate.
1195
1212
  // Fire-and-forget: if the server isn't ready or RPC fails, the next TUI