@cortexkit/opencode-magic-context 0.31.3 → 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.
Files changed (99) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/config/project-security.d.ts +16 -12
  3. package/dist/config/project-security.d.ts.map +1 -1
  4. package/dist/features/magic-context/dreamer/classify-prompt.d.ts +2 -2
  5. package/dist/features/magic-context/dreamer/classify-prompt.d.ts.map +1 -1
  6. package/dist/features/magic-context/dreamer/classify.d.ts +8 -0
  7. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/lease.d.ts +1 -0
  10. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/manifest-parser.d.ts +7 -0
  12. package/dist/features/magic-context/dreamer/manifest-parser.d.ts.map +1 -0
  13. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +2 -3
  14. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/map-memories.d.ts +8 -0
  16. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +5 -0
  19. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +2 -2
  21. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  23. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +4 -0
  24. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  25. package/dist/features/magic-context/memory/storage-memory.d.ts +12 -0
  26. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  27. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  28. package/dist/features/magic-context/message-index.d.ts +6 -0
  29. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  30. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  31. package/dist/features/magic-context/search.d.ts.map +1 -1
  32. package/dist/features/magic-context/smart-notes/capabilities.d.ts +1 -0
  33. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  34. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  35. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  36. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  37. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +3 -2
  38. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  39. package/dist/features/magic-context/smart-notes/schedule.d.ts.map +1 -1
  40. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +17 -6
  41. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  42. package/dist/features/magic-context/smart-notes/types.d.ts +6 -1
  43. package/dist/features/magic-context/smart-notes/types.d.ts.map +1 -1
  44. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  45. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -0
  46. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  47. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  48. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  49. package/dist/features/magic-context/storage.d.ts +1 -1
  50. package/dist/features/magic-context/storage.d.ts.map +1 -1
  51. package/dist/features/magic-context/workspaces.d.ts +8 -0
  52. package/dist/features/magic-context/workspaces.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/compartment-runner.d.ts +2 -0
  55. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/tag-messages.d.ts +1 -0
  60. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/todo-view.d.ts +27 -2
  62. package/dist/hooks/magic-context/todo-view.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/transform-message-helpers.d.ts +6 -3
  64. package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts +1 -0
  66. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  67. package/dist/index.js +1039 -331
  68. package/dist/shared/assistant-message-extractor.d.ts +1 -0
  69. package/dist/shared/assistant-message-extractor.d.ts.map +1 -1
  70. package/dist/shared/redaction.d.ts.map +1 -1
  71. package/dist/shared/rpc-notifications.d.ts +21 -16
  72. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  73. package/dist/shared/rpc-server.d.ts +1 -0
  74. package/dist/shared/rpc-server.d.ts.map +1 -1
  75. package/dist/shared/rpc-utils.d.ts +3 -1
  76. package/dist/shared/rpc-utils.d.ts.map +1 -1
  77. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  78. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  79. package/dist/tools/ctx-search/types.d.ts +1 -1
  80. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  81. package/dist/tui/data/notification-socket.d.ts +1 -0
  82. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  83. package/dist/tui-compiled/badge-contrast.d.ts +46 -0
  84. package/dist/tui-compiled/badge-contrast.d.ts.map +1 -0
  85. package/dist/tui-compiled/data/context-db.d.ts +45 -0
  86. package/dist/tui-compiled/data/context-db.d.ts.map +1 -0
  87. package/dist/tui-compiled/data/notification-socket.d.ts +40 -0
  88. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -0
  89. package/package.json +3 -2
  90. package/src/shared/assistant-message-extractor.ts +20 -0
  91. package/src/shared/redaction.ts +1 -0
  92. package/src/shared/rpc-client.test.ts +164 -5
  93. package/src/shared/rpc-notifications.ts +73 -21
  94. package/src/shared/rpc-server.ts +69 -18
  95. package/src/shared/rpc-utils.ts +12 -2
  96. package/src/tui/data/notification-socket.test.ts +131 -0
  97. package/src/tui/data/notification-socket.ts +81 -16
  98. package/src/tui/entry.mjs +23 -3
  99. package/src/tui-compiled/data/notification-socket.ts +81 -16
@@ -107,38 +107,90 @@ export function pushNotification(
107
107
  }
108
108
  }
109
109
 
110
- /** Return pending notifications after acking the client's last received id.
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 scoping: when `sessionId` is provided, only notifications tagged for
113
- * that session (or session-less/global ones) are returned and pruned — a
114
- * notification tagged for a DIFFERENT session is never handed to this client
115
- * and is never pruned by this client's ack. This matters because the in-memory
116
- * queue is per-process but a TUI can end up bound to a process that also serves
117
- * OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
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 a higher
126
- * `lastReceivedId`, so a dropped WS socket re-delivers the backlog on reconnect
127
- * (the client sends its `lastReceivedId` in the hello). */
128
- export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
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 (lastReceivedId > 0) {
134
- // Prune only notifications THIS client both owns (session-matched) and has
135
- // acked (id <= lastReceivedId). Other sessions' notifications survive.
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 <= lastReceivedId && matchesClient(notification)),
189
+ (notification) => !(notification.id <= sessionCursor && matchesClient(notification)),
138
190
  );
139
191
  }
140
192
  return queue.filter(
141
- (notification) => notification.id > lastReceivedId && matchesClient(notification),
193
+ (notification) => notification.id > sessionCursor && matchesClient(notification),
142
194
  );
143
195
  }
144
196
 
@@ -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-process port file atomically. Multi-instance OpenCode is
126
- // supported: TUI discovery scans all live pid files and picks the most
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
- const auth = req.headers.get("authorization");
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: { type?: string; token?: string; sessionId?: string; lastReceivedId?: number };
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). The
311
- // client sends its highest-handled id in the hello; reconnects after a
312
- // dropped socket re-deliver from here.
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 backlog = drainNotifications(
315
- Number.isFinite(lastReceivedId) ? lastReceivedId : 0,
316
- ws.data.sessionId,
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 → prune acked notifications from the queue so it
327
- // doesn't grow during a long-lived connection. Cheap, event-driven.
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
- drainNotifications(lastReceivedId, ws.data.sessionId);
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
  }
@@ -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(storageDir: string, directory: string, pid = process.pid): string {
35
- return join(rpcPortDir(storageDir, directory), `port-${pid}.json`);
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
- /** Highest handled notification id (sent in hello so a reconnect re-delivers the
55
- * unhandled backlog; sent in ack so the server prunes the queue). */
56
- let lastHandledId = 0;
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
- ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}/ws`);
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: lastHandledId,
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 && notification.id > lastHandledId) {
192
- lastHandledId = notification.id;
193
- // Ack so the server prunes the queue during this long-lived connection.
194
- // (The id also rides the next reconnect hello via lastReceivedId, so a
195
- // dropped ack just re-delivers an already-consumed item, which the
196
- // handlers are idempotent against.)
197
- try {
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 {
package/src/tui/entry.mjs CHANGED
@@ -2,15 +2,35 @@
2
2
  // registers these virtual modules process-wide, which lets the precompiled TUI
3
3
  // use the host's single Solid/OpenTUI runtime even when this package is loaded
4
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
+
5
13
  let mod;
6
14
  try {
7
- await import("opentui:runtime-module:" + encodeURIComponent("@opentui/solid"));
8
- mod = await import("../tui-compiled/index.tsx");
9
- } catch {
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
+ }
10
21
  // Older hosts and bare Bun do not provide the virtual registry. Falling back
11
22
  // to the raw TSX entry keeps development checkouts and OpenTUI 0.3.x hosts
12
23
  // working where the Solid transform still applies to this source path.
13
24
  mod = await import("./index.tsx");
14
25
  }
15
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
+
16
36
  export default mod.default;