@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.
- package/README.md +3 -3
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +2 -0
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +9 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- 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 +6 -2
- 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-clone.d.ts +34 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -0
- 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 +11 -0
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +2 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +10 -0
- package/dist/features/magic-context/tagger.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/apply-operations.d.ts +24 -0
- package/dist/hooks/magic-context/apply-operations.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/compartment-trigger.d.ts +7 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-render.d.ts +2 -0
- 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/hook.d.ts +3 -0
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +8 -9
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts +5 -4
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts +2 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts +32 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/shadow-sender.d.ts +300 -0
- package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -0
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/temporal-awareness.d.ts +3 -3
- package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
- package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +32 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5418 -972
- package/dist/plugin/hooks/create-session-hooks.d.ts +48 -175
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- 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/tag-transcript.d.ts +18 -0
- package/dist/shared/tag-transcript.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/tools/ctx-expand/constants.d.ts +1 -1
- package/dist/tools/ctx-expand/constants.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 +77 -33
- package/src/shared/sqlite.ts +22 -3
- package/src/shared/tag-transcript.test.ts +253 -7
- package/src/shared/tag-transcript.ts +422 -114
- 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
|
}
|
|
@@ -807,17 +807,32 @@ async function showRecompDialog(api, targetSessionId = getSessionId(api)) {
|
|
|
807
807
|
});
|
|
808
808
|
return false;
|
|
809
809
|
}
|
|
810
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|