@cortexkit/opencode-magic-context 0.32.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
- package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts +4 -1
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts +7 -4
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts +8 -16
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
- package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
- package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
- package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
- package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
- package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -5
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
- package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
- package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +1 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts +15 -1
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +24 -2
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/shadow-sender.d.ts +65 -27
- package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +27 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4128 -1207
- package/dist/plugin/rpc-handlers.d.ts +3 -0
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +5 -0
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +3 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +11 -6
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +2 -2
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/tui-config.d.ts +5 -3
- package/dist/shared/tui-config.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +9 -2
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +4 -5
- package/dist/tui/data/notification-socket.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts +9 -2
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
- package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/shared/models-dev-cache.test.ts +5 -0
- package/src/shared/models-dev-cache.ts +44 -6
- package/src/shared/opencode-compaction-detector.test.ts +11 -2
- package/src/shared/rpc-client.test.ts +213 -9
- package/src/shared/rpc-client.ts +48 -39
- package/src/shared/rpc-notifications.test.ts +26 -5
- package/src/shared/rpc-notifications.ts +44 -35
- package/src/shared/rpc-server.ts +69 -33
- package/src/shared/sqlite.ts +22 -3
- package/src/shared/tui-config.test.ts +4 -12
- package/src/shared/tui-config.ts +6 -6
- package/src/tui/data/context-db.test.ts +113 -0
- package/src/tui/data/context-db.ts +26 -19
- package/src/tui/data/notification-socket.test.ts +277 -11
- package/src/tui/data/notification-socket.ts +257 -86
- package/src/tui/index.tsx +33 -25
- package/src/tui/slots/sidebar-content.tsx +62 -24
- package/src/tui-compiled/data/context-db.ts +26 -19
- package/src/tui-compiled/data/notification-socket.ts +257 -86
- package/src/tui-compiled/index.tsx +41 -24
- package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
|
@@ -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 {
|
|
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
|
-
|
|
55
|
-
|
|
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(
|
|
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(
|
|
83
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
});
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -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<{
|
|
105
|
+
async resolveEndpoint(): Promise<{
|
|
106
|
+
port: number;
|
|
107
|
+
token: string | null;
|
|
108
|
+
instanceId: string | null;
|
|
109
|
+
} | null> {
|
|
105
110
|
try {
|
|
106
|
-
|
|
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
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
170
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
52
|
-
*
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
101
|
-
|
|
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
|
|
137
|
-
*
|
|
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
|
|
204
|
-
* sink
|
|
205
|
-
* callers with no session context
|
|
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 ===
|
|
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
|
}
|