@cortexkit/opencode-magic-context 0.32.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +3 -3
  2. package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
  3. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
  4. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  5. package/dist/features/magic-context/message-index-async.d.ts +4 -1
  6. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  7. package/dist/features/magic-context/message-index.d.ts +7 -4
  8. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  9. package/dist/features/magic-context/migrations.d.ts +8 -16
  10. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  11. package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
  12. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  13. package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
  14. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  15. package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
  16. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  17. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
  18. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  19. package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
  20. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  21. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
  22. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  23. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
  24. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  25. package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
  26. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  27. package/dist/features/magic-context/storage-db.d.ts +1 -1
  28. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  29. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -5
  30. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  31. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
  33. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  35. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
  36. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  37. package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
  38. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  39. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
  41. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
  42. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
  45. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
  47. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
  48. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
  49. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  50. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  51. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -0
  53. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/read-session-chunk.d.ts +15 -1
  55. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/read-session-raw.d.ts +24 -2
  57. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/shadow-sender.d.ts +65 -27
  59. package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +27 -0
  61. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +4128 -1207
  65. package/dist/plugin/rpc-handlers.d.ts +3 -0
  66. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  67. package/dist/shared/models-dev-cache.d.ts +5 -0
  68. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  69. package/dist/shared/rpc-client.d.ts +3 -1
  70. package/dist/shared/rpc-client.d.ts.map +1 -1
  71. package/dist/shared/rpc-notifications.d.ts +11 -6
  72. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  73. package/dist/shared/rpc-server.d.ts +2 -2
  74. package/dist/shared/rpc-server.d.ts.map +1 -1
  75. package/dist/shared/sqlite.d.ts.map +1 -1
  76. package/dist/shared/tui-config.d.ts +5 -3
  77. package/dist/shared/tui-config.d.ts.map +1 -1
  78. package/dist/tui/data/context-db.d.ts +9 -2
  79. package/dist/tui/data/context-db.d.ts.map +1 -1
  80. package/dist/tui/data/notification-socket.d.ts +4 -5
  81. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  82. package/dist/tui-compiled/data/context-db.d.ts +9 -2
  83. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  84. package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
  85. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
  86. package/package.json +2 -1
  87. package/src/shared/models-dev-cache.test.ts +5 -0
  88. package/src/shared/models-dev-cache.ts +44 -6
  89. package/src/shared/opencode-compaction-detector.test.ts +11 -2
  90. package/src/shared/rpc-client.test.ts +213 -9
  91. package/src/shared/rpc-client.ts +48 -39
  92. package/src/shared/rpc-notifications.test.ts +26 -5
  93. package/src/shared/rpc-notifications.ts +44 -35
  94. package/src/shared/rpc-server.ts +69 -33
  95. package/src/shared/sqlite.ts +22 -3
  96. package/src/shared/tui-config.test.ts +4 -12
  97. package/src/shared/tui-config.ts +6 -6
  98. package/src/tui/data/context-db.test.ts +113 -0
  99. package/src/tui/data/context-db.ts +26 -19
  100. package/src/tui/data/notification-socket.test.ts +277 -11
  101. package/src/tui/data/notification-socket.ts +257 -86
  102. package/src/tui/index.tsx +33 -25
  103. package/src/tui/slots/sidebar-content.tsx +62 -24
  104. package/src/tui-compiled/data/context-db.ts +26 -19
  105. package/src/tui-compiled/data/notification-socket.ts +257 -86
  106. package/src/tui-compiled/index.tsx +41 -24
  107. package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
@@ -4,7 +4,12 @@ import { createServer } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { MagicContextRpcClient } from "./rpc-client";
7
- import { drainNotifications, isTuiConnected, pushNotification } from "./rpc-notifications";
7
+ import {
8
+ __resetNotificationStateForTests,
9
+ drainNotifications,
10
+ isTuiConnected,
11
+ pushNotification,
12
+ } from "./rpc-notifications";
8
13
  import { MagicContextRpcServer } from "./rpc-server";
9
14
  import { parseRpcPortFile, type RpcPortFileRecord, rpcPortDir, rpcPortFilePath } from "./rpc-utils";
10
15
 
@@ -20,6 +25,7 @@ afterEach(async () => {
20
25
  for (const server of servers.splice(0)) {
21
26
  await server.close();
22
27
  }
28
+ __resetNotificationStateForTests();
23
29
  for (const dir of tempDirs.splice(0)) {
24
30
  try {
25
31
  rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
@@ -51,10 +57,23 @@ function writePortFileForPid(
51
57
  port: number,
52
58
  pid: number,
53
59
  startedAt: number,
54
- ): void {
55
- const portFile = rpcPortFilePath(storageDir, directory, pid);
60
+ instanceId?: string,
61
+ token?: string,
62
+ ): string {
63
+ const portFile = rpcPortFilePath(storageDir, directory, pid, instanceId);
56
64
  mkdirSync(dirname(portFile), { recursive: true });
57
- writeFileSync(portFile, JSON.stringify({ port, pid, started_at: startedAt }), "utf-8");
65
+ writeFileSync(
66
+ portFile,
67
+ JSON.stringify({
68
+ port,
69
+ pid,
70
+ started_at: startedAt,
71
+ instance_id: instanceId,
72
+ token,
73
+ }),
74
+ "utf-8",
75
+ );
76
+ return portFile;
58
77
  }
59
78
 
60
79
  function readNewestPortRecord(storageDir: string, directory: string): RpcPortFileRecord | null {
@@ -79,8 +98,16 @@ async function waitFor(condition: () => boolean, label: string, timeoutMs = 2_00
79
98
  throw new Error(`Timed out waiting for ${label}`);
80
99
  }
81
100
 
82
- async function openSocket(port: number, token: string): Promise<WebSocket> {
83
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`);
101
+ async function openSocket(
102
+ port: number,
103
+ token: string,
104
+ legacyQueryAuth = false,
105
+ ): Promise<WebSocket> {
106
+ const ws = legacyQueryAuth
107
+ ? new WebSocket(`ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`)
108
+ : new WebSocket(`ws://127.0.0.1:${port}/ws`, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ });
84
111
  await new Promise<void>((resolve, reject) => {
85
112
  const timeout = setTimeout(() => reject(new Error("socket open timed out")), 2_000);
86
113
  ws.addEventListener(
@@ -129,11 +156,14 @@ function waitForJsonMessage<T extends { type?: string }>(
129
156
  });
130
157
  }
131
158
 
132
- async function startRpcServer(handler: (method: string) => Response | object): Promise<TestServer> {
159
+ async function startRpcServer(
160
+ handler: (method: string) => Response | object,
161
+ health: { pid: number; instanceId?: string } = { pid: process.pid },
162
+ ): Promise<TestServer> {
133
163
  const server = createServer(async (req, res) => {
134
164
  if (req.method === "GET" && req.url === "/health") {
135
165
  res.writeHead(200, { "Content-Type": "application/json" });
136
- res.end(JSON.stringify({ ok: true }));
166
+ res.end(JSON.stringify({ ok: true, pid: health.pid, instance_id: health.instanceId }));
137
167
  return;
138
168
  }
139
169
 
@@ -238,6 +268,31 @@ describe("MagicContextRpcClient", () => {
238
268
  }
239
269
  });
240
270
 
271
+ test("accepts a frozen v0.32 websocket upgrade with query-token auth", async () => {
272
+ const storageDir = makeTempDir();
273
+ const directory = "/repo-ws-v032";
274
+ const server = new MagicContextRpcServer(storageDir, directory);
275
+ const port = await server.start();
276
+ const record = readNewestPortRecord(storageDir, directory);
277
+ expect(typeof record?.token).toBe("string");
278
+
279
+ const ws = await openSocket(port, record?.token ?? "", true);
280
+ try {
281
+ const helloAck = waitForJsonMessage(ws, (message) => message.type === "hello-ack");
282
+ ws.send(
283
+ JSON.stringify({
284
+ type: "hello",
285
+ token: record?.token,
286
+ sessionId: "ses_v032",
287
+ }),
288
+ );
289
+ expect((await helloAck).type).toBe("hello-ack");
290
+ } finally {
291
+ ws.close();
292
+ server.stop();
293
+ }
294
+ });
295
+
241
296
  test("websocket upgrade rejects missing bearer token before a socket is created", async () => {
242
297
  const storageDir = makeTempDir();
243
298
  const directory = "/repo-ws-auth";
@@ -300,6 +355,52 @@ describe("MagicContextRpcClient", () => {
300
355
  }
301
356
  });
302
357
 
358
+ test("accepts legacy cursor acknowledgements during protocol skew", async () => {
359
+ __resetNotificationStateForTests();
360
+ const storageDir = makeTempDir();
361
+ const directory = "/repo-legacy-ack";
362
+ const server = new MagicContextRpcServer(storageDir, directory);
363
+ const port = await server.start();
364
+ const record = readNewestPortRecord(storageDir, directory);
365
+ expect(typeof record?.token).toBe("string");
366
+
367
+ pushNotification("legacy-one", { ok: true }, "ses_legacy");
368
+ pushNotification("legacy-two", { ok: true }, "ses_legacy");
369
+ const queued = drainNotifications(0, "ses_legacy", { sessionOnly: true });
370
+ expect(queued).toHaveLength(2);
371
+
372
+ const ws = await openSocket(port, record?.token ?? "");
373
+ try {
374
+ const helloAck = waitForJsonMessage<{
375
+ type?: string;
376
+ instanceId?: string;
377
+ }>(ws, (message) => message.type === "hello-ack");
378
+ ws.send(
379
+ JSON.stringify({
380
+ type: "hello",
381
+ token: record?.token,
382
+ sessionId: "ses_legacy",
383
+ }),
384
+ );
385
+ expect((await helloAck).instanceId).toBe(record?.instance_id);
386
+
387
+ ws.send(
388
+ JSON.stringify({
389
+ type: "ack",
390
+ cursor: queued[0].id,
391
+ sessionId: "ses_legacy",
392
+ }),
393
+ );
394
+ await waitFor(() => {
395
+ const pending = drainNotifications(0, "ses_legacy", { sessionOnly: true });
396
+ return pending.length === 1 && pending[0].id === queued[1].id;
397
+ }, "legacy cursor acknowledgement pruning");
398
+ } finally {
399
+ ws.close();
400
+ server.stop();
401
+ }
402
+ });
403
+
303
404
  test("same-process servers keep distinct port files during overlap", async () => {
304
405
  const storageDir = makeTempDir();
305
406
  const directory = "/repo-port-collision";
@@ -319,7 +420,9 @@ describe("MagicContextRpcClient", () => {
319
420
  expect(remaining?.port).toBe(secondPort);
320
421
 
321
422
  const client = new MagicContextRpcClient(storageDir, directory);
322
- expect((await client.resolveEndpoint())?.port).toBe(secondPort);
423
+ const endpoint = await client.resolveEndpoint();
424
+ expect(endpoint?.port).toBe(secondPort);
425
+ expect(endpoint?.instanceId).toBe(remaining?.instance_id);
323
426
  } finally {
324
427
  first.stop();
325
428
  second.stop();
@@ -368,4 +471,105 @@ describe("MagicContextRpcClient", () => {
368
471
  const client = new MagicContextRpcClient(storageDir, directory);
369
472
  expect(await client.call<{ value: string }>("value")).toEqual({ value: "live" });
370
473
  });
474
+
475
+ test("discovers a frozen v0.32 health response without an instance id", async () => {
476
+ const storageDir = makeTempDir();
477
+ const directory = "/repo-v032-health";
478
+ const legacy = await startRpcServer(() => ({ value: "legacy" }), {
479
+ pid: process.pid,
480
+ });
481
+ writePortFileForPid(
482
+ storageDir,
483
+ directory,
484
+ legacy.port,
485
+ process.pid,
486
+ Date.now(),
487
+ "new-client-record",
488
+ );
489
+
490
+ const client = new MagicContextRpcClient(storageDir, directory);
491
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "legacy" });
492
+ });
493
+
494
+ test("prefers this process and validates every discovery candidate identity", async () => {
495
+ const storageDir = makeTempDir();
496
+ const directory = "/repo-affinity";
497
+ const foreign = await startRpcServer(() => ({ value: "foreign" }), {
498
+ pid: process.ppid,
499
+ instanceId: "foreign",
500
+ });
501
+ const unrelated = await startRpcServer(() => ({ value: "unrelated" }), {
502
+ pid: process.pid,
503
+ instanceId: "different-service",
504
+ });
505
+ const local = await startRpcServer(() => ({ value: "local" }), {
506
+ pid: process.pid,
507
+ instanceId: "local-healthy",
508
+ });
509
+
510
+ writePortFileForPid(
511
+ storageDir,
512
+ directory,
513
+ foreign.port,
514
+ process.ppid,
515
+ Date.now() + 20_000,
516
+ "foreign",
517
+ );
518
+ writePortFileForPid(
519
+ storageDir,
520
+ directory,
521
+ unrelated.port,
522
+ process.pid,
523
+ Date.now() + 10_000,
524
+ "local-stale",
525
+ );
526
+ writePortFileForPid(
527
+ storageDir,
528
+ directory,
529
+ local.port,
530
+ process.pid,
531
+ Date.now(),
532
+ "local-healthy",
533
+ );
534
+
535
+ const client = new MagicContextRpcClient(storageDir, directory);
536
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "local" });
537
+ });
538
+
539
+ test("resets discovery after a 401 response", async () => {
540
+ const storageDir = makeTempDir();
541
+ const directory = "/repo-reauth";
542
+ let unauthorizedRecord = "";
543
+ const unauthorized = await startRpcServer(
544
+ () => {
545
+ rmSync(unauthorizedRecord, { force: true });
546
+ return new Response("stale token", { status: 401 });
547
+ },
548
+ { pid: process.pid, instanceId: "unauthorized" },
549
+ );
550
+ const healthy = await startRpcServer(() => ({ value: "healthy" }), {
551
+ pid: process.pid,
552
+ instanceId: "healthy",
553
+ });
554
+ unauthorizedRecord = writePortFileForPid(
555
+ storageDir,
556
+ directory,
557
+ unauthorized.port,
558
+ process.pid,
559
+ Date.now() + 10_000,
560
+ "unauthorized",
561
+ "stale",
562
+ );
563
+ writePortFileForPid(
564
+ storageDir,
565
+ directory,
566
+ healthy.port,
567
+ process.pid,
568
+ Date.now(),
569
+ "healthy",
570
+ );
571
+
572
+ const client = new MagicContextRpcClient(storageDir, directory);
573
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "healthy" });
574
+ });
371
575
  });
@@ -18,6 +18,7 @@ type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
18
18
  export class MagicContextRpcClient {
19
19
  private port: number | null = null;
20
20
  private token: string | null = null;
21
+ private instanceId: string | null = null;
21
22
  private portDir: string;
22
23
  private legacyPortFilePath: string;
23
24
  private healthChecked = false;
@@ -62,7 +63,7 @@ export class MagicContextRpcClient {
62
63
  if (!response.ok) {
63
64
  const text = await response.text();
64
65
  const error = new Error(`RPC ${method} failed (${response.status}): ${text}`);
65
- if (response.status >= 500) {
66
+ if (response.status === 401 || response.status >= 500) {
66
67
  lastError = error;
67
68
  this.reset();
68
69
  continue;
@@ -101,52 +102,47 @@ export class MagicContextRpcClient {
101
102
  * channel). Reuses the same health-checked port-file discovery as `call`,
102
103
  * so the WS client and the HTTP client always agree on which server instance
103
104
  * (and token) to use. Returns null when no live server is found. */
104
- async resolveEndpoint(): Promise<{ port: number; token: string | null } | null> {
105
+ async resolveEndpoint(): Promise<{
106
+ port: number;
107
+ token: string | null;
108
+ instanceId: string | null;
109
+ } | null> {
105
110
  try {
106
- const port = await this.resolvePort();
111
+ // The socket owns reconnect backoff, so endpoint discovery performs one
112
+ // filesystem/health pass instead of nesting the HTTP client's retries.
113
+ const port = await this.resolvePort(1);
107
114
  if (port === null) return null;
108
- return { port, token: this.token };
115
+ return { port, token: this.token, instanceId: this.instanceId };
109
116
  } catch {
110
117
  return null;
111
118
  }
112
119
  }
113
120
 
114
- private async resolvePort(): Promise<number | null> {
121
+ private async resolvePort(maxAttempts = MAX_RETRIES): Promise<number | null> {
115
122
  if (this.port && this.healthChecked) {
116
123
  return this.port;
117
124
  }
118
125
 
119
- if (this.port) {
120
- const alive = await this.healthCheck(this.port);
121
- if (alive) {
126
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
127
+ for (const record of this.readPortFiles()) {
128
+ if (!(await this.healthCheck(record))) continue;
129
+ this.port = record.port;
130
+ this.token = record.token ?? null;
131
+ this.instanceId = record.instance_id ?? null;
122
132
  this.healthChecked = true;
123
- return this.port;
124
- }
125
- this.port = null;
126
- this.healthChecked = false;
127
- }
128
-
129
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
130
- const record = this.readPortFile();
131
- if (record) {
132
- const alive = await this.healthCheck(record.port);
133
- if (alive) {
134
- this.port = record.port;
135
- this.token = record.token ?? null;
136
- this.healthChecked = true;
137
- return record.port;
138
- }
133
+ return record.port;
139
134
  }
140
135
 
141
- if (attempt < MAX_RETRIES - 1) {
142
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
136
+ this.reset();
137
+ if (attempt < maxAttempts - 1) {
138
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
143
139
  }
144
140
  }
145
141
 
146
142
  return null;
147
143
  }
148
144
 
149
- private readPortFile(): RpcPortFileRecord | null {
145
+ private readPortFiles(): RpcPortFileRecord[] {
150
146
  const records: RpcPortFileRecord[] = [];
151
147
 
152
148
  try {
@@ -160,26 +156,38 @@ export class MagicContextRpcClient {
160
156
  // Directory may not exist yet. Fall back to the legacy file below.
161
157
  }
162
158
 
163
- if (records.length > 0) {
164
- records.sort((a, b) => b.started_at - a.started_at);
165
- return records[0];
166
- }
167
-
168
159
  try {
169
- const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
170
- if (record?.pid && !isPidAlive(record.pid)) return null;
171
- return record;
160
+ const legacy = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
161
+ if (legacy && (!legacy.pid || isPidAlive(legacy.pid))) records.push(legacy);
172
162
  } catch {
173
- return null;
163
+ // Legacy discovery is optional.
174
164
  }
165
+
166
+ // A TUI and its server plugin normally share a process. Prefer that exact
167
+ // process before considering another live OpenCode instance for the project.
168
+ records.sort((a, b) => {
169
+ const aLocal = a.pid === process.pid ? 1 : 0;
170
+ const bLocal = b.pid === process.pid ? 1 : 0;
171
+ return bLocal - aLocal || b.started_at - a.started_at;
172
+ });
173
+ return records;
175
174
  }
176
175
 
177
- private async healthCheck(port: number): Promise<boolean> {
176
+ private async healthCheck(record: RpcPortFileRecord): Promise<boolean> {
178
177
  try {
179
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/health`, {
178
+ const response = await this.fetchWithTimeout(`http://127.0.0.1:${record.port}/health`, {
180
179
  method: "GET",
181
180
  });
182
- return response.ok;
181
+ if (!response.ok) return false;
182
+ const body = (await response.json()) as { pid?: unknown; instance_id?: unknown };
183
+ if (body.pid !== record.pid) return false;
184
+ // v0.32 health responses predate instance ids. A missing id is the
185
+ // one-release discovery bridge; an id that is present must still match.
186
+ return (
187
+ body.instance_id === undefined ||
188
+ record.instance_id === undefined ||
189
+ body.instance_id === record.instance_id
190
+ );
183
191
  } catch {
184
192
  return false;
185
193
  }
@@ -198,6 +206,7 @@ export class MagicContextRpcClient {
198
206
  reset(): void {
199
207
  this.port = null;
200
208
  this.token = null;
209
+ this.instanceId = null;
201
210
  this.healthChecked = false;
202
211
  }
203
212
  }
@@ -70,10 +70,25 @@ describe("rpc notifications", () => {
70
70
  expect(isTuiConnected()).toBe(false);
71
71
  });
72
72
 
73
- test("a session-less sink counts as connected for any session query", () => {
73
+ test("a modern session-less sink is global-only", () => {
74
+ const received: string[] = [];
75
+ const unregister = registerNotificationSink({
76
+ sessionId: undefined,
77
+ protocol: 2,
78
+ send: (notification) => received.push(notification.type),
79
+ });
80
+ expect(isTuiConnected("ses_whatever")).toBe(false);
81
+ expect(isTuiConnected()).toBe(true);
82
+
83
+ pushNotification("scoped", { ok: true }, "ses_whatever");
84
+ pushNotification("global", { ok: true });
85
+ expect(received).toEqual(["global"]);
86
+ unregister();
87
+ });
88
+
89
+ test("a legacy session-less sink retains broad compatibility", () => {
74
90
  const unregister = registerNotificationSink({ sessionId: undefined, send: () => {} });
75
91
  expect(isTuiConnected("ses_whatever")).toBe(true);
76
- expect(isTuiConnected()).toBe(true);
77
92
  unregister();
78
93
  });
79
94
 
@@ -117,14 +132,20 @@ describe("rpc notifications", () => {
117
132
 
118
133
  test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
119
134
  drainNotifications(Number.MAX_SAFE_INTEGER);
120
- // One quiet session with a single pending dialog.
121
135
  pushNotification("quiet-dialog", { action: "show-upgrade-dialog" }, "ses_quiet");
122
- // A noisy session floods well past the 100 cap.
123
136
  for (let i = 0; i < 200; i += 1) {
124
137
  pushNotification("noise", { i }, "ses_noisy");
125
138
  }
126
- // The quiet session's newest item must survive the eviction.
127
139
  const quietPoll = drainNotifications(0, "ses_quiet");
128
140
  expect(quietPoll.some((m) => m.type === "quiet-dialog")).toBe(true);
141
+ expect(drainNotifications(0)).toHaveLength(100);
142
+ });
143
+
144
+ test("queue cap remains global across more than one hundred sessions", () => {
145
+ drainNotifications(Number.MAX_SAFE_INTEGER);
146
+ for (let i = 0; i < 250; i += 1) {
147
+ pushNotification("one-per-session", { i }, `ses_${i}`);
148
+ }
149
+ expect(drainNotifications(0)).toHaveLength(100);
129
150
  });
130
151
  });
@@ -26,6 +26,8 @@ let nextNotificationId = 1;
26
26
  export interface NotificationSink {
27
27
  /** The TUI's active session at connect time (its hello scope). */
28
28
  sessionId?: string;
29
+ /** Protocol 2 clients use strict session scoping; absent means legacy behavior. */
30
+ protocol?: number;
29
31
  /** Deliver one notification over this sink's live socket. */
30
32
  send: (notification: RpcNotification) => void;
31
33
  }
@@ -48,23 +50,19 @@ export function registerNotificationSink(sink: NotificationSink): () => void {
48
50
  };
49
51
  }
50
52
 
51
- /** Whether a given notification may be delivered to a given sink. A global
52
- * notification (no sessionId) reaches every sink; a session-scoped one reaches
53
- * only sinks for that session (or session-less sinks). Mirrors the drain filter
54
- * from the sink's perspective. */
53
+ /** Whether a notification may be delivered to a sink. Protocol 2 makes a
54
+ * session-less socket global-only; legacy sockets retain broad compatibility. */
55
55
  function notificationMatchesSink(notification: RpcNotification, sink: NotificationSink): boolean {
56
- return (
57
- notification.sessionId === undefined ||
58
- sink.sessionId === undefined ||
59
- notification.sessionId === sink.sessionId
60
- );
56
+ if (notification.sessionId === undefined) return true;
57
+ if (sink.sessionId !== undefined) return notification.sessionId === sink.sessionId;
58
+ return sink.protocol !== 2;
61
59
  }
62
60
 
63
61
  /** Push a notification to the TUI. Fans out to any live WS sink immediately and
64
62
  * also enqueues it so a TUI that is momentarily disconnected (reconnecting, or
65
63
  * not yet connected) still receives it on its next hello via the backlog drain.
66
64
  * At-least-once: a live push that the socket drops is re-delivered from the
67
- * queue on reconnect (pruned only when the client acks via `lastReceivedId`). */
65
+ * queue on reconnect (pruned only when the client acknowledges it). */
68
66
  export function pushNotification(
69
67
  type: string,
70
68
  payload: Record<string, unknown>,
@@ -83,30 +81,40 @@ export function pushNotification(
83
81
  // unregister it, and the queue backlog re-delivers on reconnect.
84
82
  }
85
83
  }
86
- // Cap queue size to prevent unbounded growth if a TUI is not draining.
87
- // Session-FAIR eviction: a naive `slice(-50)` drops the globally-oldest
88
- // items, so a noisy session could evict ANOTHER session's single unseen
89
- // notification. Instead, always retain each session's newest item, then
90
- // fill the rest of the budget with the newest overall — no session can
91
- // starve another's pending dialog out of the window.
84
+ // Keep a strict global bound. Reserve the newest item for at most 25 recently
85
+ // active scopes, then evict the oldest unreserved item. This protects a quiet
86
+ // session from one noisy peer without allowing one item per session to grow
87
+ // the queue without limit.
92
88
  if (queue.length > 100) {
93
- const newestPerSession = new Map<string | undefined, number>();
94
- for (const n of queue) {
95
- const prev = newestPerSession.get(n.sessionId);
96
- if (prev === undefined || n.id > prev) {
97
- newestPerSession.set(n.sessionId, n.id);
98
- }
89
+ const reservedIds = new Set<number>();
90
+ const reservedScopes = new Set<string>();
91
+ for (let i = queue.length - 1; i >= 0 && reservedScopes.size < 25; i -= 1) {
92
+ const candidate = queue[i];
93
+ const scope = candidate.sessionId ?? "\0global";
94
+ if (reservedScopes.has(scope)) continue;
95
+ reservedScopes.add(scope);
96
+ reservedIds.add(candidate.id);
99
97
  }
100
- const mustKeep = new Set(newestPerSession.values());
101
- const byNewest = [...queue].sort((a, b) => b.id - a.id);
102
- const kept: RpcNotification[] = [];
103
- for (const n of byNewest) {
104
- if (kept.length < 50 || mustKeep.has(n.id)) kept.push(n);
105
- }
106
- queue = kept.sort((a, b) => a.id - b.id);
98
+ const evictionIndex = queue.findIndex((candidate) => !reservedIds.has(candidate.id));
99
+ queue.splice(evictionIndex >= 0 ? evictionIndex : 0, 1);
107
100
  }
108
101
  }
109
102
 
103
+ export function acknowledgeNotifications(ids: readonly number[]): void {
104
+ const acknowledged = new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0));
105
+ if (acknowledged.size === 0) return;
106
+ // Exact removal preserves an earlier notification when a later handler finishes
107
+ // first or the earlier handler declines to consume its notification.
108
+ queue = queue.filter((notification) => !acknowledged.has(notification.id));
109
+ }
110
+
111
+ /** Reset process-local state to simulate a fresh server module in protocol tests. */
112
+ export function __resetNotificationStateForTests(): void {
113
+ queue = [];
114
+ nextNotificationId = 1;
115
+ sinks.clear();
116
+ }
117
+
110
118
  export interface DrainNotificationsOptions {
111
119
  /**
112
120
  * Cursor for global notifications when a session-scoped client sends separate
@@ -133,8 +141,8 @@ function cursor(value: number | undefined): number {
133
141
  * behavior.
134
142
  *
135
143
  * Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
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. */
144
+ * notification stays queued until an exact acknowledgement or a legacy cursor
145
+ * removes it, so a dropped WS socket re-delivers unhandled backlog on reconnect. */
138
146
  export function drainNotifications(
139
147
  lastReceivedId = 0,
140
148
  sessionId?: string,
@@ -200,14 +208,15 @@ export function drainNotifications(
200
208
  * Pass `sessionId` (preferred) to ask whether a TUI is connected FOR THAT
201
209
  * SESSION — this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
202
210
  * reminder) must use to decide dialog-vs-message, so a TUI on a different
203
- * session in the same process does not misroute their delivery. A session-less
204
- * sink (legacy/global) counts for any session query. Omit `sessionId` only for
205
- * callers with no session context; they get "any sink connected". */
211
+ * session in the same process does not misroute their delivery. A modern
212
+ * session-less sink is global-only; a legacy sink retains broad compatibility.
213
+ * Omit `sessionId` only for callers with no session context. */
206
214
  export function isTuiConnected(sessionId?: string): boolean {
207
215
  if (sinks.size === 0) return false;
208
216
  if (sessionId === undefined) return true;
209
217
  for (const sink of sinks) {
210
- if (sink.sessionId === undefined || sink.sessionId === sessionId) return true;
218
+ if (sink.sessionId === sessionId) return true;
219
+ if (sink.sessionId === undefined && sink.protocol !== 2) return true;
211
220
  }
212
221
  return false;
213
222
  }