@cortexkit/opencode-magic-context 0.31.2 → 0.31.4
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/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +16 -12
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +13 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify-prompt.d.ts +2 -2
- package/dist/features/magic-context/dreamer/classify-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +8 -0
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/lease.d.ts +1 -0
- package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/manifest-parser.d.ts +7 -0
- package/dist/features/magic-context/dreamer/manifest-parser.d.ts.map +1 -0
- package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +2 -3
- package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +8 -0
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +5 -0
- package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify-prompt.d.ts +2 -2
- package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +4 -0
- package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
- package/dist/features/magic-context/memory/storage-memory.d.ts +12 -0
- package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
- package/dist/features/magic-context/memory/verification-paths.d.ts +16 -0
- package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +6 -0
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/capabilities.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +3 -2
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/schedule.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +17 -6
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/types.d.ts +6 -1
- package/dist/features/magic-context/smart-notes/types.d.ts.map +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 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/workspaces.d.ts +8 -0
- package/dist/features/magic-context/workspaces.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts +2 -0
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +1 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/todo-view.d.ts +27 -2
- package/dist/hooks/magic-context/todo-view.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-message-helpers.d.ts +6 -3
- package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
- package/dist/hooks/magic-context/wrapup-orchestrator.d.ts +1 -0
- package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
- package/dist/index.js +1046 -333
- package/dist/shared/assistant-message-extractor.d.ts +1 -0
- package/dist/shared/assistant-message-extractor.d.ts.map +1 -1
- package/dist/shared/redaction.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +21 -16
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +1 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +3 -1
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/tools/ctx-search/types.d.ts +1 -1
- package/dist/tools/ctx-search/types.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +1 -0
- package/dist/tui/data/notification-socket.d.ts.map +1 -1
- package/dist/tui-compiled/badge-contrast.d.ts +46 -0
- package/dist/tui-compiled/badge-contrast.d.ts.map +1 -0
- package/dist/tui-compiled/data/context-db.d.ts +45 -0
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -0
- package/dist/tui-compiled/data/notification-socket.d.ts +40 -0
- package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -0
- package/package.json +7 -4
- package/src/shared/assistant-message-extractor.ts +20 -0
- package/src/shared/redaction.ts +1 -0
- package/src/shared/rpc-client.test.ts +164 -5
- package/src/shared/rpc-notifications.ts +73 -21
- package/src/shared/rpc-server.ts +69 -18
- package/src/shared/rpc-utils.ts +12 -2
- package/src/tui/data/notification-socket.test.ts +131 -0
- package/src/tui/data/notification-socket.ts +81 -16
- package/src/tui/entry.mjs +36 -0
- package/src/tui-compiled/badge-contrast.ts +84 -0
- package/src/tui-compiled/data/context-db.ts +353 -0
- package/src/tui-compiled/data/notification-socket.ts +294 -0
- package/src/tui-compiled/index.tsx +1204 -0
- package/src/tui-compiled/slots/sidebar-content.tsx +1052 -0
- package/src/tui-compiled/types/opencode-plugin-tui.d.ts +232 -0
|
@@ -107,38 +107,90 @@ export function pushNotification(
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
export interface DrainNotificationsOptions {
|
|
111
|
+
/**
|
|
112
|
+
* Cursor for global notifications when a session-scoped client sends separate
|
|
113
|
+
* session and global watermarks.
|
|
114
|
+
*/
|
|
115
|
+
globalLastReceivedId?: number;
|
|
116
|
+
/** Ack/drain only the named session, not global notifications. */
|
|
117
|
+
sessionOnly?: boolean;
|
|
118
|
+
/** Ack/drain only session-less global notifications. */
|
|
119
|
+
globalOnly?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cursor(value: number | undefined): number {
|
|
123
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Return pending notifications after pruning only the scopes the client acked.
|
|
111
127
|
*
|
|
112
|
-
* Session
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
|
|
119
|
-
* so a Desktop-session upgrade-dialog action would otherwise surface in an
|
|
120
|
-
* unrelated TUI session. Each client also tracks its own `lastReceivedId`, so a
|
|
121
|
-
* global watermark prune would let session A's ack drop session B's still-unseen
|
|
122
|
-
* notification — scoping the prune to the acking session prevents that too.
|
|
128
|
+
* Session-scoped and global notifications have independent cursors. A TUI can
|
|
129
|
+
* switch from session A to session B after handling a high id in A; that high
|
|
130
|
+
* watermark must never prune B's lower, still-unseen ids. Global notifications
|
|
131
|
+
* are also tracked separately so a global dialog does not become a session
|
|
132
|
+
* watermark. Legacy callers that omit options keep the original single-cursor
|
|
133
|
+
* behavior.
|
|
123
134
|
*
|
|
124
135
|
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
|
|
125
|
-
* notification stays queued until a later call acks it via
|
|
126
|
-
*
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
* notification stays queued until a later call acks it via the matching scope's
|
|
137
|
+
* cursor, so a dropped WS socket re-delivers unhandled backlog on reconnect. */
|
|
138
|
+
export function drainNotifications(
|
|
139
|
+
lastReceivedId = 0,
|
|
140
|
+
sessionId?: string,
|
|
141
|
+
options: DrainNotificationsOptions = {},
|
|
142
|
+
): RpcNotification[] {
|
|
143
|
+
const sessionCursor = cursor(lastReceivedId);
|
|
144
|
+
|
|
145
|
+
if (options.globalOnly) {
|
|
146
|
+
queue = queue.filter(
|
|
147
|
+
(notification) =>
|
|
148
|
+
notification.sessionId !== undefined || notification.id > sessionCursor,
|
|
149
|
+
);
|
|
150
|
+
return queue.filter(
|
|
151
|
+
(notification) =>
|
|
152
|
+
notification.sessionId === undefined && notification.id > sessionCursor,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (options.sessionOnly) {
|
|
157
|
+
if (sessionId === undefined) return [];
|
|
158
|
+
queue = queue.filter(
|
|
159
|
+
(notification) =>
|
|
160
|
+
notification.sessionId !== sessionId || notification.id > sessionCursor,
|
|
161
|
+
);
|
|
162
|
+
return queue.filter(
|
|
163
|
+
(notification) =>
|
|
164
|
+
notification.sessionId === sessionId && notification.id > sessionCursor,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (sessionId !== undefined && options.globalLastReceivedId !== undefined) {
|
|
169
|
+
const globalCursor = cursor(options.globalLastReceivedId);
|
|
170
|
+
queue = queue.filter((notification) => {
|
|
171
|
+
if (notification.sessionId === undefined) return notification.id > globalCursor;
|
|
172
|
+
if (notification.sessionId === sessionId) return notification.id > sessionCursor;
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
return queue.filter((notification) => {
|
|
176
|
+
if (notification.sessionId === undefined) return notification.id > globalCursor;
|
|
177
|
+
return notification.sessionId === sessionId && notification.id > sessionCursor;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
129
181
|
const matchesClient = (notification: RpcNotification): boolean =>
|
|
130
182
|
sessionId === undefined ||
|
|
131
183
|
notification.sessionId === undefined ||
|
|
132
184
|
notification.sessionId === sessionId;
|
|
133
|
-
if (
|
|
134
|
-
//
|
|
135
|
-
//
|
|
185
|
+
if (sessionCursor > 0) {
|
|
186
|
+
// Legacy single-cursor mode prunes the scopes this client can see. New WS
|
|
187
|
+
// clients pass dual cursors above so cross-session watermarks stay isolated.
|
|
136
188
|
queue = queue.filter(
|
|
137
|
-
(notification) => !(notification.id <=
|
|
189
|
+
(notification) => !(notification.id <= sessionCursor && matchesClient(notification)),
|
|
138
190
|
);
|
|
139
191
|
}
|
|
140
192
|
return queue.filter(
|
|
141
|
-
(notification) => notification.id >
|
|
193
|
+
(notification) => notification.id > sessionCursor && matchesClient(notification),
|
|
142
194
|
);
|
|
143
195
|
}
|
|
144
196
|
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -52,6 +52,15 @@ function tokensMatch(presented: string, expected: string): boolean {
|
|
|
52
52
|
return timingSafeEqual(a, b);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function bearerToken(req: Request): string {
|
|
56
|
+
const auth = req.headers.get("authorization");
|
|
57
|
+
return typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function websocketToken(req: Request, url: URL): string {
|
|
61
|
+
return bearerToken(req) || url.searchParams.get("token") || "";
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
/**
|
|
56
65
|
* Plugin-private localhost RPC server for TUI ↔ server-plugin communication.
|
|
57
66
|
*
|
|
@@ -71,6 +80,7 @@ export class MagicContextRpcServer {
|
|
|
71
80
|
private portFilePath: string;
|
|
72
81
|
private portDir: string;
|
|
73
82
|
private startedAt = Date.now();
|
|
83
|
+
private readonly instanceId = randomBytes(8).toString("hex");
|
|
74
84
|
/** Every authenticated WS socket, so dispose can close them all. */
|
|
75
85
|
private sockets = new Set<ServerWebSocket<WsData>>();
|
|
76
86
|
// Unguessable per-process bearer token, published in the (user-private) port
|
|
@@ -81,7 +91,7 @@ export class MagicContextRpcServer {
|
|
|
81
91
|
private readonly token = randomBytes(32).toString("hex");
|
|
82
92
|
|
|
83
93
|
constructor(storageDir: string, directory: string) {
|
|
84
|
-
this.portFilePath = rpcPortFilePath(storageDir, directory);
|
|
94
|
+
this.portFilePath = rpcPortFilePath(storageDir, directory, process.pid, this.instanceId);
|
|
85
95
|
this.portDir = rpcPortDir(storageDir, directory);
|
|
86
96
|
}
|
|
87
97
|
|
|
@@ -92,6 +102,7 @@ export class MagicContextRpcServer {
|
|
|
92
102
|
|
|
93
103
|
/** Start the server on a random port, write port to disk. */
|
|
94
104
|
async start(): Promise<number> {
|
|
105
|
+
this.startedAt = Date.now();
|
|
95
106
|
const self = this;
|
|
96
107
|
const server = Bun.serve<WsData>({
|
|
97
108
|
port: 0,
|
|
@@ -122,8 +133,8 @@ export class MagicContextRpcServer {
|
|
|
122
133
|
this.server = server;
|
|
123
134
|
this.port = server.port ?? 0;
|
|
124
135
|
|
|
125
|
-
// Write a per-
|
|
126
|
-
// supported: TUI discovery scans all live
|
|
136
|
+
// Write a per-instance port file atomically. Multi-instance OpenCode is
|
|
137
|
+
// supported: TUI discovery scans all live files and picks the most
|
|
127
138
|
// recent instead of cross-wiring via one shared project file.
|
|
128
139
|
try {
|
|
129
140
|
this.warnIfOtherLiveInstance();
|
|
@@ -157,6 +168,7 @@ export class MagicContextRpcServer {
|
|
|
157
168
|
pid: process.pid,
|
|
158
169
|
started_at: this.startedAt,
|
|
159
170
|
token: this.token,
|
|
171
|
+
instance_id: this.instanceId,
|
|
160
172
|
}),
|
|
161
173
|
{ encoding: "utf-8", mode: 0o600 },
|
|
162
174
|
);
|
|
@@ -219,8 +231,12 @@ export class MagicContextRpcServer {
|
|
|
219
231
|
private async handleFetch(req: Request, srv: Server<WsData>): Promise<Response | undefined> {
|
|
220
232
|
const url = new URL(req.url);
|
|
221
233
|
|
|
222
|
-
// WebSocket upgrade — the persistent push channel.
|
|
234
|
+
// WebSocket upgrade — the persistent push channel. Authenticate before
|
|
235
|
+
// `srv.upgrade` so an unauthorized request never becomes a live socket.
|
|
223
236
|
if (url.pathname === "/ws") {
|
|
237
|
+
if (!tokensMatch(websocketToken(req, url), this.token)) {
|
|
238
|
+
return new Response("Unauthorized", { status: 401 });
|
|
239
|
+
}
|
|
224
240
|
const ok = srv.upgrade(req, { data: { authed: false } });
|
|
225
241
|
if (ok) return undefined;
|
|
226
242
|
return new Response("upgrade failed", { status: 400 });
|
|
@@ -237,9 +253,7 @@ export class MagicContextRpcServer {
|
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
// Require the per-process bearer token on every side-effecting call.
|
|
240
|
-
|
|
241
|
-
const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
242
|
-
if (!tokensMatch(presented, this.token)) {
|
|
256
|
+
if (!tokensMatch(bearerToken(req), this.token)) {
|
|
243
257
|
return json({ error: "Unauthorized" }, 401);
|
|
244
258
|
}
|
|
245
259
|
|
|
@@ -274,13 +288,22 @@ export class MagicContextRpcServer {
|
|
|
274
288
|
/** WS message handler: hello (auth + sink registration + backlog drain) and
|
|
275
289
|
* ack (cursor advance → queue prune). All other messages are ignored. */
|
|
276
290
|
private handleWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
|
|
277
|
-
let msg: {
|
|
291
|
+
let msg: {
|
|
292
|
+
type?: string;
|
|
293
|
+
token?: string;
|
|
294
|
+
sessionId?: string;
|
|
295
|
+
lastReceivedId?: number;
|
|
296
|
+
globalLastReceivedId?: number;
|
|
297
|
+
ackScope?: string;
|
|
298
|
+
};
|
|
278
299
|
try {
|
|
279
300
|
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
280
301
|
} catch {
|
|
281
302
|
return;
|
|
282
303
|
}
|
|
283
304
|
|
|
305
|
+
if (msg.type !== "hello" && !ws.data.authed) return;
|
|
306
|
+
|
|
284
307
|
if (msg.type === "hello") {
|
|
285
308
|
if (!tokensMatch(typeof msg.token === "string" ? msg.token : "", this.token)) {
|
|
286
309
|
ws.send(JSON.stringify({ type: "error", error: "unauthorized" }));
|
|
@@ -297,6 +320,11 @@ export class MagicContextRpcServer {
|
|
|
297
320
|
? msg.sessionId
|
|
298
321
|
: undefined;
|
|
299
322
|
|
|
323
|
+
// A session switch re-sends hello on the same socket. Remove the old
|
|
324
|
+
// sink first so the registry has exactly one live sink per socket.
|
|
325
|
+
ws.data.unregister?.();
|
|
326
|
+
ws.data.unregister = undefined;
|
|
327
|
+
|
|
300
328
|
// Register a live sink so future pushes reach this socket immediately.
|
|
301
329
|
const sink: NotificationSink = {
|
|
302
330
|
sessionId: ws.data.sessionId,
|
|
@@ -307,14 +335,28 @@ export class MagicContextRpcServer {
|
|
|
307
335
|
ws.data.unregister = registerNotificationSink(sink);
|
|
308
336
|
this.sockets.add(ws);
|
|
309
337
|
|
|
310
|
-
// Deliver any backlog the client hasn't seen (at-least-once).
|
|
311
|
-
//
|
|
312
|
-
//
|
|
338
|
+
// Deliver any backlog the client hasn't seen (at-least-once). Modern
|
|
339
|
+
// clients send separate session and global cursors; keeping those
|
|
340
|
+
// watermarks independent prevents a high id in one session from pruning
|
|
341
|
+
// lower unseen notifications in another session.
|
|
313
342
|
const lastReceivedId = Number(msg.lastReceivedId ?? 0);
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
343
|
+
const sessionCursor = Number.isFinite(lastReceivedId) ? lastReceivedId : 0;
|
|
344
|
+
const hasGlobalCursor = typeof msg.globalLastReceivedId === "number";
|
|
345
|
+
const globalLastReceivedId = hasGlobalCursor
|
|
346
|
+
? Number.isFinite(msg.globalLastReceivedId)
|
|
347
|
+
? msg.globalLastReceivedId
|
|
348
|
+
: 0
|
|
349
|
+
: 0;
|
|
350
|
+
const backlog =
|
|
351
|
+
ws.data.sessionId === undefined && hasGlobalCursor
|
|
352
|
+
? drainNotifications(globalLastReceivedId, undefined, { globalOnly: true })
|
|
353
|
+
: drainNotifications(
|
|
354
|
+
sessionCursor,
|
|
355
|
+
ws.data.sessionId,
|
|
356
|
+
hasGlobalCursor
|
|
357
|
+
? { globalLastReceivedId: globalLastReceivedId }
|
|
358
|
+
: undefined,
|
|
359
|
+
);
|
|
318
360
|
for (const notification of backlog) {
|
|
319
361
|
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
320
362
|
}
|
|
@@ -323,11 +365,20 @@ export class MagicContextRpcServer {
|
|
|
323
365
|
}
|
|
324
366
|
|
|
325
367
|
if (msg.type === "ack") {
|
|
326
|
-
// Advance the cursor
|
|
327
|
-
//
|
|
368
|
+
// Advance only the cursor scope the client handled. Acking session A
|
|
369
|
+
// must not prune session B, and acking a global notification must not
|
|
370
|
+
// become a session watermark. Cheap, event-driven queue cleanup.
|
|
328
371
|
const lastReceivedId = Number(msg.lastReceivedId ?? 0);
|
|
329
372
|
if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
|
|
330
|
-
|
|
373
|
+
if (msg.ackScope === "global") {
|
|
374
|
+
drainNotifications(lastReceivedId, undefined, { globalOnly: true });
|
|
375
|
+
} else if (typeof msg.sessionId === "string" && msg.sessionId.length > 0) {
|
|
376
|
+
drainNotifications(lastReceivedId, msg.sessionId, { sessionOnly: true });
|
|
377
|
+
} else {
|
|
378
|
+
// Compatibility path for older clients that only know one
|
|
379
|
+
// cursor for their current socket scope.
|
|
380
|
+
drainNotifications(lastReceivedId, ws.data.sessionId);
|
|
381
|
+
}
|
|
331
382
|
}
|
|
332
383
|
}
|
|
333
384
|
}
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface RpcPortFileRecord {
|
|
|
14
14
|
* auth required" only when the server itself didn't set one).
|
|
15
15
|
*/
|
|
16
16
|
token?: string;
|
|
17
|
+
/** Per-server filename nonce; prevents same-process instances from sharing one file. */
|
|
18
|
+
instance_id?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -31,8 +33,14 @@ export function rpcPortDir(storageDir: string, directory: string): string {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** Per-process RPC port file path. */
|
|
34
|
-
export function rpcPortFilePath(
|
|
35
|
-
|
|
36
|
+
export function rpcPortFilePath(
|
|
37
|
+
storageDir: string,
|
|
38
|
+
directory: string,
|
|
39
|
+
pid = process.pid,
|
|
40
|
+
instanceId?: string,
|
|
41
|
+
): string {
|
|
42
|
+
const suffix = instanceId ? `-${instanceId}` : "";
|
|
43
|
+
return join(rpcPortDir(storageDir, directory), `port-${pid}${suffix}.json`);
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/** Legacy single-port file used by v0.18.0 and earlier. */
|
|
@@ -66,6 +74,8 @@ export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileR
|
|
|
66
74
|
pid,
|
|
67
75
|
started_at: Number.isFinite(startedAt) ? startedAt : 0,
|
|
68
76
|
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
77
|
+
instance_id:
|
|
78
|
+
typeof parsed.instance_id === "string" ? parsed.instance_id : undefined,
|
|
69
79
|
};
|
|
70
80
|
} catch {
|
|
71
81
|
return null;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
drainNotifications,
|
|
7
|
+
isTuiConnected,
|
|
8
|
+
pushNotification,
|
|
9
|
+
} from "../../shared/rpc-notifications";
|
|
10
|
+
import { MagicContextRpcServer } from "../../shared/rpc-server";
|
|
11
|
+
import { closeRpc, initRpcClient } from "./context-db";
|
|
12
|
+
import {
|
|
13
|
+
_resetNotificationSocketStateForTesting,
|
|
14
|
+
type SocketNotification,
|
|
15
|
+
startNotificationSocket,
|
|
16
|
+
} from "./notification-socket";
|
|
17
|
+
|
|
18
|
+
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
|
19
|
+
const tempDirs: string[] = [];
|
|
20
|
+
const servers: MagicContextRpcServer[] = [];
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
_resetNotificationSocketStateForTesting();
|
|
24
|
+
closeRpc();
|
|
25
|
+
for (const server of servers.splice(0)) {
|
|
26
|
+
server.stop();
|
|
27
|
+
}
|
|
28
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
29
|
+
for (const dir of tempDirs.splice(0)) {
|
|
30
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
31
|
+
}
|
|
32
|
+
if (originalXdgDataHome === undefined) {
|
|
33
|
+
delete process.env.XDG_DATA_HOME;
|
|
34
|
+
} else {
|
|
35
|
+
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function makeDataHome(): string {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "mc-notification-socket-"));
|
|
41
|
+
tempDirs.push(dir);
|
|
42
|
+
process.env.XDG_DATA_HOME = dir;
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function storageDir(dataHome: string): string {
|
|
47
|
+
return join(dataHome, "cortexkit", "magic-context");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function startServer(dataHome: string, directory: string): Promise<MagicContextRpcServer> {
|
|
51
|
+
const server = new MagicContextRpcServer(storageDir(dataHome), directory);
|
|
52
|
+
await server.start();
|
|
53
|
+
servers.push(server);
|
|
54
|
+
return server;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function waitFor(condition: () => boolean, label: string, timeoutMs = 4_000): Promise<void> {
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
while (Date.now() - start < timeoutMs) {
|
|
60
|
+
if (condition()) return;
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("notification socket", () => {
|
|
67
|
+
test("uses the active session cursor when switching sessions", async () => {
|
|
68
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
69
|
+
const dataHome = makeDataHome();
|
|
70
|
+
const directory = "/repo-session-cursors";
|
|
71
|
+
await startServer(dataHome, directory);
|
|
72
|
+
initRpcClient(directory);
|
|
73
|
+
|
|
74
|
+
let activeSession = "ses_A";
|
|
75
|
+
const received: SocketNotification[] = [];
|
|
76
|
+
startNotificationSocket({
|
|
77
|
+
getSessionId: () => activeSession,
|
|
78
|
+
onNotification: (notification) => {
|
|
79
|
+
received.push(notification);
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await waitFor(() => isTuiConnected("ses_A"), "socket connected to session A");
|
|
85
|
+
pushNotification("for-b", { action: "show-status-dialog" }, "ses_B");
|
|
86
|
+
pushNotification("for-a", { action: "show-status-dialog" }, "ses_A");
|
|
87
|
+
await waitFor(
|
|
88
|
+
() => received.some((notification) => notification.type === "for-a"),
|
|
89
|
+
"session A notification",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
activeSession = "ses_B";
|
|
93
|
+
await waitFor(
|
|
94
|
+
() => received.some((notification) => notification.type === "for-b"),
|
|
95
|
+
"session B backlog after switching sessions",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(received.map((notification) => notification.type)).toContain("for-a");
|
|
99
|
+
expect(received.map((notification) => notification.type)).toContain("for-b");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("resets the cached endpoint after websocket close so reconnect discovers a new server", async () => {
|
|
103
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
104
|
+
const dataHome = makeDataHome();
|
|
105
|
+
const directory = "/repo-reconnect";
|
|
106
|
+
const first = await startServer(dataHome, directory);
|
|
107
|
+
initRpcClient(directory);
|
|
108
|
+
|
|
109
|
+
const received: SocketNotification[] = [];
|
|
110
|
+
startNotificationSocket({
|
|
111
|
+
getSessionId: () => "ses_R",
|
|
112
|
+
onNotification: (notification) => {
|
|
113
|
+
received.push(notification);
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
await waitFor(() => isTuiConnected("ses_R"), "initial websocket connection");
|
|
118
|
+
|
|
119
|
+
const second = await startServer(dataHome, directory);
|
|
120
|
+
expect(second).toBeDefined();
|
|
121
|
+
first.stop();
|
|
122
|
+
await waitFor(() => !isTuiConnected("ses_R"), "old websocket sink removed");
|
|
123
|
+
await waitFor(() => isTuiConnected("ses_R"), "websocket reconnected to replacement server");
|
|
124
|
+
|
|
125
|
+
pushNotification("after-restart", { action: "show-status-dialog" }, "ses_R");
|
|
126
|
+
await waitFor(
|
|
127
|
+
() => received.some((notification) => notification.type === "after-restart"),
|
|
128
|
+
"notification from replacement server",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -51,9 +51,21 @@ let opts: NotificationSocketOptions | null = null;
|
|
|
51
51
|
/** Generation of the rpc client at connect time; a dispose/reinit bumps it and
|
|
52
52
|
* invalidates an in-flight socket so its late callbacks are ignored. */
|
|
53
53
|
let connectGeneration = 0;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
|
|
55
|
+
const GLOBAL_CURSOR_KEY = "global";
|
|
56
|
+
const SESSION_CURSOR_PREFIX = "session:";
|
|
57
|
+
const MAX_DEDUPED_NOTIFICATION_IDS = 500;
|
|
58
|
+
|
|
59
|
+
/**
|
|
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.
|
|
65
|
+
*/
|
|
66
|
+
const lastHandledIdByCursor = new Map<string, number>();
|
|
67
|
+
const handledNotificationIds = new Set<number>();
|
|
68
|
+
const handledNotificationIdOrder: number[] = [];
|
|
57
69
|
|
|
58
70
|
/** Open the persistent notification socket. Idempotent: a second call while open
|
|
59
71
|
* is a no-op. Reconnects on its own after any drop. */
|
|
@@ -118,8 +130,10 @@ async function connect(): Promise<void> {
|
|
|
118
130
|
|
|
119
131
|
let ws: WebSocket;
|
|
120
132
|
try {
|
|
121
|
-
|
|
133
|
+
const tokenQuery = `?token=${encodeURIComponent(endpoint.token ?? "")}`;
|
|
134
|
+
ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}/ws${tokenQuery}`);
|
|
122
135
|
} catch {
|
|
136
|
+
client.reset();
|
|
123
137
|
scheduleReconnect();
|
|
124
138
|
return;
|
|
125
139
|
}
|
|
@@ -137,6 +151,7 @@ async function connect(): Promise<void> {
|
|
|
137
151
|
});
|
|
138
152
|
|
|
139
153
|
const onDown = () => {
|
|
154
|
+
client.reset();
|
|
140
155
|
if (socket === ws) {
|
|
141
156
|
socket = null;
|
|
142
157
|
helloedSession = null;
|
|
@@ -155,7 +170,8 @@ function sendHello(ws: WebSocket, token: string | null): void {
|
|
|
155
170
|
type: "hello",
|
|
156
171
|
token: token ?? "",
|
|
157
172
|
sessionId,
|
|
158
|
-
lastReceivedId:
|
|
173
|
+
lastReceivedId: cursorForKey(cursorKeyForSession(sessionId)),
|
|
174
|
+
globalLastReceivedId: cursorForKey(GLOBAL_CURSOR_KEY),
|
|
159
175
|
}),
|
|
160
176
|
);
|
|
161
177
|
}
|
|
@@ -180,6 +196,11 @@ async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
|
|
|
180
196
|
// session, or a later switch back, should still get it). Just skip.
|
|
181
197
|
return;
|
|
182
198
|
}
|
|
199
|
+
if (handledNotificationIds.has(notification.id)) {
|
|
200
|
+
sendAck(ws, notification);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
183
204
|
let consumed = false;
|
|
184
205
|
try {
|
|
185
206
|
consumed = await Promise.resolve(opts?.onNotification(notification) ?? false);
|
|
@@ -188,17 +209,13 @@ async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
|
|
|
188
209
|
}
|
|
189
210
|
// A dispose/reinit during an awaited dialog handler invalidates this socket.
|
|
190
211
|
if (socket !== ws || getRpcGeneration() !== connectGeneration) return;
|
|
191
|
-
if (consumed
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
ws.send(JSON.stringify({ type: "ack", lastReceivedId: lastHandledId }));
|
|
199
|
-
} catch {
|
|
200
|
-
// best-effort; reconnect hello re-syncs via lastReceivedId
|
|
201
|
-
}
|
|
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);
|
|
202
219
|
}
|
|
203
220
|
return;
|
|
204
221
|
}
|
|
@@ -214,6 +231,54 @@ async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
|
|
|
214
231
|
}
|
|
215
232
|
}
|
|
216
233
|
|
|
234
|
+
function cursorKeyForSession(sessionId: string | null | undefined): string {
|
|
235
|
+
return sessionId ? `${SESSION_CURSOR_PREFIX}${sessionId}` : GLOBAL_CURSOR_KEY;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function notificationCursorKey(notification: SocketNotification): string {
|
|
239
|
+
return notification.sessionId
|
|
240
|
+
? `${SESSION_CURSOR_PREFIX}${notification.sessionId}`
|
|
241
|
+
: GLOBAL_CURSOR_KEY;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function cursorForKey(key: string): number {
|
|
245
|
+
return lastHandledIdByCursor.get(key) ?? 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function advanceCursor(key: string, id: number): void {
|
|
249
|
+
if (id > cursorForKey(key)) lastHandledIdByCursor.set(key, id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function rememberHandledId(id: number): void {
|
|
253
|
+
if (handledNotificationIds.has(id)) return;
|
|
254
|
+
handledNotificationIds.add(id);
|
|
255
|
+
handledNotificationIdOrder.push(id);
|
|
256
|
+
while (handledNotificationIdOrder.length > MAX_DEDUPED_NOTIFICATION_IDS) {
|
|
257
|
+
const evicted = handledNotificationIdOrder.shift();
|
|
258
|
+
if (evicted !== undefined) handledNotificationIds.delete(evicted);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
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 };
|
|
267
|
+
try {
|
|
268
|
+
ws.send(JSON.stringify(ack));
|
|
269
|
+
} catch {
|
|
270
|
+
// best-effort; reconnect hello re-syncs via per-scope cursors
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function _resetNotificationSocketStateForTesting(): void {
|
|
275
|
+
stopNotificationSocket();
|
|
276
|
+
opts = null;
|
|
277
|
+
lastHandledIdByCursor.clear();
|
|
278
|
+
handledNotificationIds.clear();
|
|
279
|
+
handledNotificationIdOrder.length = 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
217
282
|
/** Cheap session-change watcher: re-scope the socket only when the active session
|
|
218
283
|
* actually changes. Reads a property; no network at idle. */
|
|
219
284
|
function watchSession(): void {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Prefer the host OpenTUI runtime registry when it exists. OpenTUI 0.4.x
|
|
2
|
+
// registers these virtual modules process-wide, which lets the precompiled TUI
|
|
3
|
+
// use the host's single Solid/OpenTUI runtime even when this package is loaded
|
|
4
|
+
// from an npm cache under node_modules.
|
|
5
|
+
const runtimeProbe = "opentui:runtime-module:" + encodeURIComponent("@opentui/solid");
|
|
6
|
+
|
|
7
|
+
function isMissingRuntimeRegistry(error) {
|
|
8
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
9
|
+
return /Cannot find|Could not resolve|Module not found|Unable to resolve/.test(message) &&
|
|
10
|
+
message.includes("opentui:runtime-module:");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let mod;
|
|
14
|
+
try {
|
|
15
|
+
await import(runtimeProbe);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (!isMissingRuntimeRegistry(error)) {
|
|
18
|
+
console.error("Magic Context TUI runtime registry probe failed", error);
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
// Older hosts and bare Bun do not provide the virtual registry. Falling back
|
|
22
|
+
// to the raw TSX entry keeps development checkouts and OpenTUI 0.3.x hosts
|
|
23
|
+
// working where the Solid transform still applies to this source path.
|
|
24
|
+
mod = await import("./index.tsx");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!mod) {
|
|
28
|
+
try {
|
|
29
|
+
mod = await import("../tui-compiled/index.tsx");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error("Magic Context compiled TUI failed to load", error);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default mod.default;
|