@cortexkit/opencode-magic-context 0.32.0 → 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.
- package/README.md +3 -3
- package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
- package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts +4 -1
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +7 -4
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts +8 -16
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
- package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -5
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
- package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +1 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +15 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +24 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/shadow-sender.d.ts +65 -27
- package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +27 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4128 -1207
- package/dist/plugin/rpc-handlers.d.ts +3 -0
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +5 -0
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +3 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +11 -6
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +2 -2
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/tui-config.d.ts +5 -3
- package/dist/shared/tui-config.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +9 -2
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +4 -5
- package/dist/tui/data/notification-socket.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts +9 -2
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
- package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/shared/models-dev-cache.test.ts +5 -0
- package/src/shared/models-dev-cache.ts +44 -6
- package/src/shared/opencode-compaction-detector.test.ts +11 -2
- package/src/shared/rpc-client.test.ts +213 -9
- package/src/shared/rpc-client.ts +48 -39
- package/src/shared/rpc-notifications.test.ts +26 -5
- package/src/shared/rpc-notifications.ts +44 -35
- package/src/shared/rpc-server.ts +69 -33
- package/src/shared/sqlite.ts +22 -3
- package/src/shared/tui-config.test.ts +4 -12
- package/src/shared/tui-config.ts +6 -6
- package/src/tui/data/context-db.test.ts +113 -0
- package/src/tui/data/context-db.ts +26 -19
- package/src/tui/data/notification-socket.test.ts +277 -11
- package/src/tui/data/notification-socket.ts +257 -86
- package/src/tui/index.tsx +33 -25
- package/src/tui/slots/sidebar-content.tsx +62 -24
- package/src/tui-compiled/data/context-db.ts +26 -19
- package/src/tui-compiled/data/notification-socket.ts +257 -86
- package/src/tui-compiled/index.tsx +41 -24
- 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
|
|
34
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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<
|
|
68
|
-
const handledNotificationIdOrder:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
221
|
+
globalLastReceivedId: cursorForKey(cursorKeyForSession(undefined)),
|
|
175
222
|
}),
|
|
176
223
|
);
|
|
177
224
|
}
|
|
178
225
|
|
|
179
|
-
|
|
180
|
-
let msg: {
|
|
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 === "
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
}
|
package/src/tui/index.tsx
CHANGED
|
@@ -389,10 +389,15 @@ async function showRecompDialog(api: TuiPluginApi, targetSessionId = getSessionI
|
|
|
389
389
|
return false
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
-
const
|
|
392
|
+
const countResult = await getCompartmentCount(sessionId)
|
|
393
393
|
// Ack only after the dialog is actually shown for the same active session;
|
|
394
394
|
// route switches while the RPC detail load is in flight must leave it pending.
|
|
395
395
|
if (getSessionId(api) !== sessionId) return false
|
|
396
|
+
if (!countResult.ok) {
|
|
397
|
+
showToast(api, { message: "Unable to load recomp details", variant: "error" })
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
const count = countResult.count
|
|
396
401
|
|
|
397
402
|
api.ui.dialog.replace(() => (
|
|
398
403
|
<api.ui.DialogConfirm
|
|
@@ -407,8 +412,12 @@ async function showRecompDialog(api: TuiPluginApi, targetSessionId = getSessionI
|
|
|
407
412
|
"",
|
|
408
413
|
"Proceed?",
|
|
409
414
|
].join("\n")}
|
|
410
|
-
onConfirm={() => {
|
|
411
|
-
|
|
415
|
+
onConfirm={async () => {
|
|
416
|
+
const requested = await requestRecomp(sessionId)
|
|
417
|
+
if (!requested) {
|
|
418
|
+
showToast(api, { message: "Recomp request failed", variant: "error" })
|
|
419
|
+
return
|
|
420
|
+
}
|
|
412
421
|
kickRecompProgressRefresh()
|
|
413
422
|
showToast(api, { message: "Recomp requested — historian will start shortly", variant: "info" })
|
|
414
423
|
}}
|
|
@@ -465,10 +474,14 @@ function showUpgradeDialog(
|
|
|
465
474
|
<api.ui.DialogConfirm
|
|
466
475
|
title={title}
|
|
467
476
|
message={message}
|
|
468
|
-
onConfirm={() => {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
477
|
+
onConfirm={async () => {
|
|
478
|
+
const started = await requestUpgrade(sessionId)
|
|
479
|
+
if (!started) {
|
|
480
|
+
showToast(api, { message: "Session upgrade request failed", variant: "error" })
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
// The RPC call fires no message event, so start the sidebar's
|
|
484
|
+
// progress poll only after the server accepts the request.
|
|
472
485
|
kickRecompProgressRefresh()
|
|
473
486
|
showToast(api, {
|
|
474
487
|
message: resume
|
|
@@ -476,15 +489,7 @@ function showUpgradeDialog(
|
|
|
476
489
|
: "Session upgrade started — running in the background",
|
|
477
490
|
variant: "info",
|
|
478
491
|
})
|
|
479
|
-
|
|
480
|
-
// actually started. If requestUpgrade() returns false (RPC /
|
|
481
|
-
// server / db / auth failure, restart race), the session stays
|
|
482
|
-
// legacy — dismissing first would set upgradeRemindedAt and
|
|
483
|
-
// suppress all future reminders for a session that never
|
|
484
|
-
// upgraded. (Resume prompts are staging-driven and unaffected.)
|
|
485
|
-
void requestUpgrade(sessionId).then((started) => {
|
|
486
|
-
if (started) void dismissUpgradeReminder(sessionId)
|
|
487
|
-
})
|
|
492
|
+
void dismissUpgradeReminder(sessionId)
|
|
488
493
|
}}
|
|
489
494
|
onCancel={() => {
|
|
490
495
|
// Explicit decline → set the durable stamp so we don't re-prompt
|
|
@@ -724,13 +729,21 @@ async function showStartupAnnouncement(api: TuiPluginApi): Promise<void> {
|
|
|
724
729
|
}
|
|
725
730
|
|
|
726
731
|
const tui: TuiPlugin = async (api, _options, meta) => {
|
|
727
|
-
// Initialize RPC client for server communication
|
|
728
732
|
const directory = api.state.path.directory ?? ""
|
|
733
|
+
// A conflicted installation intentionally has no server. Gate before RPC
|
|
734
|
+
// discovery or socket startup so disabled installs perform no idle work.
|
|
735
|
+
const conflictResult = detectConflicts(directory)
|
|
736
|
+
if (conflictResult.hasConflict) {
|
|
737
|
+
showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts)
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
|
|
729
741
|
initRpcClient(directory)
|
|
730
742
|
await refreshToastDurationMs()
|
|
731
743
|
|
|
732
744
|
// Register sidebar slot
|
|
733
|
-
|
|
745
|
+
const sidebarSlot = createSidebarContentSlot(api)
|
|
746
|
+
api.slots.register(sidebarSlot)
|
|
734
747
|
|
|
735
748
|
// Register TUI command palette entries (no slash field — slash commands
|
|
736
749
|
// are registered server-side so there's only one /ctx-* registration).
|
|
@@ -759,7 +772,7 @@ const tui: TuiPlugin = async (api, _options, meta) => {
|
|
|
759
772
|
// session; global (session-less) ones always apply. Returning false leaves
|
|
760
773
|
// it unacked so a TUI on the right session (or a later switch back) still
|
|
761
774
|
// gets it.
|
|
762
|
-
if (n.sessionId
|
|
775
|
+
if (n.sessionId !== undefined && n.sessionId !== requestedSessionId) {
|
|
763
776
|
return false
|
|
764
777
|
}
|
|
765
778
|
if (n.type === "toast") {
|
|
@@ -825,16 +838,11 @@ const tui: TuiPlugin = async (api, _options, meta) => {
|
|
|
825
838
|
|
|
826
839
|
// Clean up on dispose
|
|
827
840
|
api.lifecycle.onDispose(() => {
|
|
841
|
+
sidebarSlot.dispose()
|
|
828
842
|
stopNotificationSocket()
|
|
829
843
|
closeRpc()
|
|
830
844
|
})
|
|
831
845
|
|
|
832
|
-
const conflictResult = detectConflicts(directory)
|
|
833
|
-
if (conflictResult.hasConflict) {
|
|
834
|
-
showConflictDialog(api, directory, conflictResult.reasons, conflictResult.conflicts)
|
|
835
|
-
return
|
|
836
|
-
}
|
|
837
|
-
|
|
838
846
|
// Show one-shot release announcement after conflict gate.
|
|
839
847
|
// Fire-and-forget: if the server isn't ready or RPC fails, the next TUI
|
|
840
848
|
// launch will retry. Dialog only appears once per ANNOUNCEMENT_VERSION
|