@wolfx/opencode-magic-context 0.30.1 → 0.30.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
- package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
- package/dist/index.js +368 -117
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +1 -1
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +8 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +28 -10
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +22 -3
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +4 -14
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +39 -0
- package/dist/tui/data/notification-socket.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/shared/announcement.ts +2 -2
- package/src/shared/data-path.test.ts +28 -0
- package/src/shared/data-path.ts +5 -0
- package/src/shared/rpc-client.ts +14 -0
- package/src/shared/rpc-notifications.test.ts +68 -11
- package/src/shared/rpc-notifications.ts +75 -36
- package/src/shared/rpc-server.ts +249 -150
- package/src/tui/data/context-db.ts +10 -64
- package/src/tui/data/notification-socket.ts +229 -0
- package/src/tui/index.tsx +68 -118
package/src/shared/rpc-server.ts
CHANGED
|
@@ -9,13 +9,36 @@ import {
|
|
|
9
9
|
unlinkSync,
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from "node:fs";
|
|
12
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
13
12
|
import { dirname } from "node:path";
|
|
13
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
14
14
|
import { log } from "./logger";
|
|
15
|
+
import {
|
|
16
|
+
drainNotifications,
|
|
17
|
+
type NotificationSink,
|
|
18
|
+
registerNotificationSink,
|
|
19
|
+
} from "./rpc-notifications";
|
|
15
20
|
import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc-utils";
|
|
16
21
|
|
|
17
22
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
18
23
|
|
|
24
|
+
/** Max body for an HTTP /rpc call. Matches the previous node:http guard. */
|
|
25
|
+
const MAX_BODY_BYTES = 1_048_576;
|
|
26
|
+
/** A WS client that doesn't authenticate within this window is closed. */
|
|
27
|
+
const WS_AUTH_TIMEOUT_MS = 5_000;
|
|
28
|
+
/** WS close code for an auth failure (private; client treats every close as
|
|
29
|
+
* expected and reconnects after rediscovery, so the exact code is advisory). */
|
|
30
|
+
const WS_CLOSE_UNAUTHORIZED = 4401;
|
|
31
|
+
|
|
32
|
+
/** Per-socket state carried on `ServerWebSocket.data`. */
|
|
33
|
+
interface WsData {
|
|
34
|
+
authed: boolean;
|
|
35
|
+
sessionId?: string;
|
|
36
|
+
/** Removes this socket's sink from the notification registry. */
|
|
37
|
+
unregister?: () => void;
|
|
38
|
+
/** Fires if the client never sends a valid hello. */
|
|
39
|
+
authTimer?: ReturnType<typeof setTimeout>;
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
/**
|
|
20
43
|
* Constant-time bearer-token comparison. `timingSafeEqual` throws on
|
|
21
44
|
* length-mismatched buffers, so guard on length first (the length itself is not
|
|
@@ -29,17 +52,32 @@ function tokensMatch(presented: string, expected: string): boolean {
|
|
|
29
52
|
return timingSafeEqual(a, b);
|
|
30
53
|
}
|
|
31
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Plugin-private localhost RPC server for TUI ↔ server-plugin communication.
|
|
57
|
+
*
|
|
58
|
+
* Runs on Bun (the OpenCode server runner is a Bun Worker), so it uses
|
|
59
|
+
* `Bun.serve` to host BOTH:
|
|
60
|
+
* - HTTP request/reply routes (`/health`, `/rpc/<method>`) — the TUI's snapshot
|
|
61
|
+
* and dialog-result calls, which are event-driven, not idle; and
|
|
62
|
+
* - a WebSocket endpoint (`/ws`) — a single persistent connection per TUI over
|
|
63
|
+
* which the server PUSHES notifications (dialog/toast actions). This replaces
|
|
64
|
+
* the old 500ms HTTP poll, whose new-connection-per-tick cost was the source
|
|
65
|
+
* of idle TUI CPU (#200). Pi never imports this module, so `Bun.serve` is safe.
|
|
66
|
+
*/
|
|
32
67
|
export class MagicContextRpcServer {
|
|
33
|
-
private server: Server | null = null;
|
|
68
|
+
private server: Server<WsData> | null = null;
|
|
34
69
|
private port = 0;
|
|
35
70
|
private handlers = new Map<string, RpcHandler>();
|
|
36
71
|
private portFilePath: string;
|
|
37
72
|
private portDir: string;
|
|
38
73
|
private startedAt = Date.now();
|
|
74
|
+
/** Every authenticated WS socket, so dispose can close them all. */
|
|
75
|
+
private sockets = new Set<ServerWebSocket<WsData>>();
|
|
39
76
|
// Unguessable per-process bearer token, published in the (user-private) port
|
|
40
|
-
// file and required on every non-health RPC call. Defends
|
|
41
|
-
// endpoints (recomp/upgrade/dismiss)
|
|
42
|
-
// browser-origin script that merely
|
|
77
|
+
// file and required on every non-health RPC call AND in the WS hello. Defends
|
|
78
|
+
// side-effecting endpoints (recomp/upgrade/dismiss) and the push channel
|
|
79
|
+
// against any local process or browser-origin script that merely
|
|
80
|
+
// discovers/guesses the port.
|
|
43
81
|
private readonly token = randomBytes(32).toString("hex");
|
|
44
82
|
|
|
45
83
|
constructor(storageDir: string, directory: string) {
|
|
@@ -54,84 +92,110 @@ export class MagicContextRpcServer {
|
|
|
54
92
|
|
|
55
93
|
/** Start the server on a random port, write port to disk. */
|
|
56
94
|
async start(): Promise<number> {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// gates side-effecting RPC endpoints (recomp/upgrade/
|
|
83
|
-
// dismiss). Under the default umask 0o022 a plain write
|
|
84
|
-
// lands at 0o644 in a 0o755 dir — world-readable, so any
|
|
85
|
-
// local UID could read the token and drive those endpoints,
|
|
86
|
-
// defeating the auth defense. Restrict both: dir 0o700,
|
|
87
|
-
// file 0o600. renameSync preserves the tmp file's mode, so
|
|
88
|
-
// the 0o600 on the write covers the final file.
|
|
89
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
90
|
-
// mkdirSync's mode only applies on CREATION — a dir left by an
|
|
91
|
-
// older build (or default 0o755 umask) keeps its loose perms, so
|
|
92
|
-
// chmod it defensively so the bearer token isn't world-readable.
|
|
93
|
-
try {
|
|
94
|
-
chmodSync(dir, 0o700);
|
|
95
|
-
} catch {
|
|
96
|
-
// best-effort
|
|
97
|
-
}
|
|
98
|
-
const tmpPath = `${this.portFilePath}.tmp`;
|
|
99
|
-
// A stale tmp from a crashed write could exist with loose perms;
|
|
100
|
-
// writeFileSync's mode only applies on create, so remove it first.
|
|
101
|
-
try {
|
|
102
|
-
rmSync(tmpPath, { force: true });
|
|
103
|
-
} catch {
|
|
104
|
-
// best-effort
|
|
105
|
-
}
|
|
106
|
-
writeFileSync(
|
|
107
|
-
tmpPath,
|
|
108
|
-
JSON.stringify({
|
|
109
|
-
port: this.port,
|
|
110
|
-
pid: process.pid,
|
|
111
|
-
started_at: this.startedAt,
|
|
112
|
-
token: this.token,
|
|
113
|
-
}),
|
|
114
|
-
{ encoding: "utf-8", mode: 0o600 },
|
|
115
|
-
);
|
|
116
|
-
renameSync(tmpPath, this.portFilePath);
|
|
117
|
-
// renameSync preserves the tmp's mode, but chmod the final path
|
|
118
|
-
// defensively in case the token file pre-existed with loose perms.
|
|
119
|
-
try {
|
|
120
|
-
chmodSync(this.portFilePath, 0o600);
|
|
121
|
-
} catch {
|
|
122
|
-
// best-effort
|
|
123
|
-
}
|
|
124
|
-
log(`[rpc] server listening on 127.0.0.1:${this.port}`);
|
|
125
|
-
} catch (err) {
|
|
126
|
-
log(`[rpc] failed to write port file: ${err}`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
resolve(this.port);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// Don't keep the process alive just for the RPC server
|
|
133
|
-
server.unref();
|
|
95
|
+
const self = this;
|
|
96
|
+
const server = Bun.serve<WsData>({
|
|
97
|
+
port: 0,
|
|
98
|
+
hostname: "127.0.0.1",
|
|
99
|
+
fetch(req, srv) {
|
|
100
|
+
return self.handleFetch(req, srv);
|
|
101
|
+
},
|
|
102
|
+
websocket: {
|
|
103
|
+
open(ws) {
|
|
104
|
+
// Close the socket if it doesn't authenticate promptly. A
|
|
105
|
+
// never-authenticated socket holds no sink and is harmless,
|
|
106
|
+
// but we don't want to keep raw connections open forever.
|
|
107
|
+
ws.data.authTimer = setTimeout(() => {
|
|
108
|
+
if (!ws.data.authed) ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
|
|
109
|
+
}, WS_AUTH_TIMEOUT_MS);
|
|
110
|
+
},
|
|
111
|
+
message(ws, raw) {
|
|
112
|
+
self.handleWsMessage(ws, raw);
|
|
113
|
+
},
|
|
114
|
+
close(ws) {
|
|
115
|
+
if (ws.data.authTimer) clearTimeout(ws.data.authTimer);
|
|
116
|
+
ws.data.unregister?.();
|
|
117
|
+
self.sockets.delete(ws);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
134
120
|
});
|
|
121
|
+
|
|
122
|
+
this.server = server;
|
|
123
|
+
this.port = server.port ?? 0;
|
|
124
|
+
|
|
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
|
|
127
|
+
// recent instead of cross-wiring via one shared project file.
|
|
128
|
+
try {
|
|
129
|
+
this.warnIfOtherLiveInstance();
|
|
130
|
+
const dir = dirname(this.portFilePath);
|
|
131
|
+
// The port file holds the per-process bearer token that gates
|
|
132
|
+
// side-effecting RPC endpoints and the push channel. Under the default
|
|
133
|
+
// umask 0o022 a plain write lands at 0o644 in a 0o755 dir —
|
|
134
|
+
// world-readable, so any local UID could read the token. Restrict both:
|
|
135
|
+
// dir 0o700, file 0o600.
|
|
136
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
137
|
+
try {
|
|
138
|
+
chmodSync(dir, 0o700);
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort
|
|
141
|
+
}
|
|
142
|
+
const tmpPath = `${this.portFilePath}.tmp`;
|
|
143
|
+
// A stale tmp from a crashed write could exist with loose perms;
|
|
144
|
+
// writeFileSync's mode only applies on create, so remove it first.
|
|
145
|
+
try {
|
|
146
|
+
rmSync(tmpPath, { force: true });
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort
|
|
149
|
+
}
|
|
150
|
+
// Synchronous write so the renameSync below sees a fully-written file
|
|
151
|
+
// (a 0o600 mode keeps the bearer token out of world-readable reach;
|
|
152
|
+
// renameSync preserves the tmp's mode for the final path).
|
|
153
|
+
writeFileSync(
|
|
154
|
+
tmpPath,
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
port: this.port,
|
|
157
|
+
pid: process.pid,
|
|
158
|
+
started_at: this.startedAt,
|
|
159
|
+
token: this.token,
|
|
160
|
+
}),
|
|
161
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
162
|
+
);
|
|
163
|
+
renameSync(tmpPath, this.portFilePath);
|
|
164
|
+
try {
|
|
165
|
+
chmodSync(this.portFilePath, 0o600);
|
|
166
|
+
} catch {
|
|
167
|
+
// best-effort
|
|
168
|
+
}
|
|
169
|
+
log(`[rpc] server listening on 127.0.0.1:${this.port}`);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
log(`[rpc] failed to write port file: ${err}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return this.port;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Stop the server: close every socket, stop accepting, remove port file. */
|
|
178
|
+
stop(): void {
|
|
179
|
+
for (const ws of this.sockets) {
|
|
180
|
+
try {
|
|
181
|
+
if (ws.data.authTimer) clearTimeout(ws.data.authTimer);
|
|
182
|
+
ws.data.unregister?.();
|
|
183
|
+
ws.close();
|
|
184
|
+
} catch {
|
|
185
|
+
// best-effort
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
this.sockets.clear();
|
|
189
|
+
if (this.server) {
|
|
190
|
+
// `stop(true)` closes active connections too, not just the listener.
|
|
191
|
+
this.server.stop(true);
|
|
192
|
+
this.server = null;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(this.portFilePath);
|
|
196
|
+
} catch {
|
|
197
|
+
// Intentional: port file may already be gone
|
|
198
|
+
}
|
|
135
199
|
}
|
|
136
200
|
|
|
137
201
|
private warnIfOtherLiveInstance(): void {
|
|
@@ -150,94 +214,129 @@ export class MagicContextRpcServer {
|
|
|
150
214
|
}
|
|
151
215
|
}
|
|
152
216
|
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.server = null;
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
unlinkSync(this.portFilePath);
|
|
161
|
-
} catch {
|
|
162
|
-
// Intentional: port file may already be gone
|
|
163
|
-
}
|
|
164
|
-
}
|
|
217
|
+
/** HTTP route handler (Bun fetch). Returns a Response, or undefined when the
|
|
218
|
+
* request was upgraded to a WebSocket. */
|
|
219
|
+
private async handleFetch(req: Request, srv: Server<WsData>): Promise<Response | undefined> {
|
|
220
|
+
const url = new URL(req.url);
|
|
165
221
|
|
|
166
|
-
|
|
167
|
-
|
|
222
|
+
// WebSocket upgrade — the persistent push channel.
|
|
223
|
+
if (url.pathname === "/ws") {
|
|
224
|
+
const ok = srv.upgrade(req, { data: { authed: false } });
|
|
225
|
+
if (ok) return undefined;
|
|
226
|
+
return new Response("upgrade failed", { status: 400 });
|
|
227
|
+
}
|
|
168
228
|
|
|
169
229
|
// No wildcard CORS: the only legitimate client is the in-process TUI
|
|
170
|
-
// client,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (req.method === "GET" && url === "/health") {
|
|
175
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
176
|
-
res.end(JSON.stringify({ ok: true, pid: process.pid }));
|
|
177
|
-
return;
|
|
230
|
+
// client, not a browser origin.
|
|
231
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
232
|
+
return json({ ok: true, pid: process.pid });
|
|
178
233
|
}
|
|
179
234
|
|
|
180
|
-
if (req.method !== "POST" || !url.startsWith("/rpc/")) {
|
|
181
|
-
|
|
182
|
-
res.end("Not Found");
|
|
183
|
-
return;
|
|
235
|
+
if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
|
|
236
|
+
return new Response("Not Found", { status: 404 });
|
|
184
237
|
}
|
|
185
238
|
|
|
186
239
|
// Require the per-process bearer token on every side-effecting call.
|
|
187
|
-
|
|
188
|
-
// discover the port; a process that only guessed the port cannot.
|
|
189
|
-
// Constant-time compare so a local attacker can't byte-probe the token
|
|
190
|
-
// via response-timing (length-guard first, since timingSafeEqual throws
|
|
191
|
-
// on length mismatch).
|
|
192
|
-
const auth = req.headers.authorization;
|
|
240
|
+
const auth = req.headers.get("authorization");
|
|
193
241
|
const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
194
242
|
if (!tokensMatch(presented, this.token)) {
|
|
195
|
-
|
|
196
|
-
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
197
|
-
req.resume();
|
|
198
|
-
return;
|
|
243
|
+
return json({ error: "Unauthorized" }, 401);
|
|
199
244
|
}
|
|
200
245
|
|
|
201
|
-
const method = url.slice(5); // strip "/rpc/"
|
|
246
|
+
const method = url.pathname.slice(5); // strip "/rpc/"
|
|
202
247
|
const handler = this.handlers.get(method);
|
|
203
248
|
if (!handler) {
|
|
204
|
-
|
|
205
|
-
res.end(JSON.stringify({ error: `Unknown method: ${method}` }));
|
|
206
|
-
return;
|
|
249
|
+
return json({ error: `Unknown method: ${method}` }, 404);
|
|
207
250
|
}
|
|
208
251
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
req.destroy();
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
req.on("end", () => {
|
|
220
|
-
let params: Record<string, unknown> = {};
|
|
252
|
+
const bodyText = await req.text();
|
|
253
|
+
if (bodyText.length > MAX_BODY_BYTES) {
|
|
254
|
+
return new Response("Request too large", { status: 413 });
|
|
255
|
+
}
|
|
256
|
+
let params: Record<string, unknown> = {};
|
|
257
|
+
if (bodyText.length > 0) {
|
|
221
258
|
try {
|
|
222
|
-
|
|
223
|
-
params = JSON.parse(body);
|
|
224
|
-
}
|
|
259
|
+
params = JSON.parse(bodyText);
|
|
225
260
|
} catch {
|
|
226
|
-
|
|
227
|
-
|
|
261
|
+
return json({ error: "Invalid JSON" }, 400);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const result = await handler(params);
|
|
267
|
+
return json(result);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log(`[rpc] handler error: ${method} => ${err}`);
|
|
270
|
+
return json({ error: String(err) }, 500);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** WS message handler: hello (auth + sink registration + backlog drain) and
|
|
275
|
+
* ack (cursor advance → queue prune). All other messages are ignored. */
|
|
276
|
+
private handleWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
|
|
277
|
+
let msg: { type?: string; token?: string; sessionId?: string; lastReceivedId?: number };
|
|
278
|
+
try {
|
|
279
|
+
msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
|
|
280
|
+
} catch {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (msg.type === "hello") {
|
|
285
|
+
if (!tokensMatch(typeof msg.token === "string" ? msg.token : "", this.token)) {
|
|
286
|
+
ws.send(JSON.stringify({ type: "error", error: "unauthorized" }));
|
|
287
|
+
ws.close(WS_CLOSE_UNAUTHORIZED, "bad token");
|
|
228
288
|
return;
|
|
229
289
|
}
|
|
290
|
+
if (ws.data.authTimer) {
|
|
291
|
+
clearTimeout(ws.data.authTimer);
|
|
292
|
+
ws.data.authTimer = undefined;
|
|
293
|
+
}
|
|
294
|
+
ws.data.authed = true;
|
|
295
|
+
ws.data.sessionId =
|
|
296
|
+
typeof msg.sessionId === "string" && msg.sessionId.length > 0
|
|
297
|
+
? msg.sessionId
|
|
298
|
+
: undefined;
|
|
230
299
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
300
|
+
// Register a live sink so future pushes reach this socket immediately.
|
|
301
|
+
const sink: NotificationSink = {
|
|
302
|
+
sessionId: ws.data.sessionId,
|
|
303
|
+
send: (notification) => {
|
|
304
|
+
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
ws.data.unregister = registerNotificationSink(sink);
|
|
308
|
+
this.sockets.add(ws);
|
|
309
|
+
|
|
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.
|
|
313
|
+
const lastReceivedId = Number(msg.lastReceivedId ?? 0);
|
|
314
|
+
const backlog = drainNotifications(
|
|
315
|
+
Number.isFinite(lastReceivedId) ? lastReceivedId : 0,
|
|
316
|
+
ws.data.sessionId,
|
|
317
|
+
);
|
|
318
|
+
for (const notification of backlog) {
|
|
319
|
+
ws.send(JSON.stringify({ type: "notification", notification }));
|
|
320
|
+
}
|
|
321
|
+
ws.send(JSON.stringify({ type: "hello-ack" }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
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.
|
|
328
|
+
const lastReceivedId = Number(msg.lastReceivedId ?? 0);
|
|
329
|
+
if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
|
|
330
|
+
drainNotifications(lastReceivedId, ws.data.sessionId);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
242
333
|
}
|
|
243
334
|
}
|
|
335
|
+
|
|
336
|
+
/** JSON Response helper. */
|
|
337
|
+
function json(body: unknown, status = 200): Response {
|
|
338
|
+
return new Response(JSON.stringify(body), {
|
|
339
|
+
status,
|
|
340
|
+
headers: { "Content-Type": "application/json" },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
@@ -5,18 +5,12 @@
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { MagicContextRpcClient } from "../../shared/rpc-client";
|
|
8
|
-
import type {
|
|
9
|
-
EmbedDetail,
|
|
10
|
-
RpcNotificationMessage,
|
|
11
|
-
SidebarSnapshot,
|
|
12
|
-
StatusDetail,
|
|
13
|
-
} from "../../shared/rpc-types";
|
|
8
|
+
import type { EmbedDetail, SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
|
|
14
9
|
|
|
15
10
|
export type { EmbedDetail, SidebarSnapshot, StatusDetail };
|
|
16
11
|
|
|
17
12
|
let rpcClient: MagicContextRpcClient | null = null;
|
|
18
13
|
let rpcGeneration = 0;
|
|
19
|
-
const lastReceivedNotificationIdBySession = new Map<string, number>();
|
|
20
14
|
|
|
21
15
|
function getStorageDir(): string {
|
|
22
16
|
// Plugin v0.16+ uses the shared cortexkit/magic-context path so OpenCode
|
|
@@ -31,9 +25,9 @@ function getStorageDir(): string {
|
|
|
31
25
|
export function initRpcClient(directory: string): void {
|
|
32
26
|
const storageDir = getStorageDir();
|
|
33
27
|
// Bump the generation before replacing the client so late notification
|
|
34
|
-
// responses from a disposed client
|
|
28
|
+
// responses from a disposed client are ignored (the WS socket observes the
|
|
29
|
+
// new generation and abandons its in-flight connect).
|
|
35
30
|
rpcGeneration += 1;
|
|
36
|
-
lastReceivedNotificationIdBySession.clear();
|
|
37
31
|
rpcClient = new MagicContextRpcClient(storageDir, directory);
|
|
38
32
|
}
|
|
39
33
|
|
|
@@ -41,14 +35,19 @@ export function getRpcGeneration(): number {
|
|
|
41
35
|
return rpcGeneration;
|
|
42
36
|
}
|
|
43
37
|
|
|
38
|
+
/** The live RPC client (for the WS notification socket's endpoint discovery).
|
|
39
|
+
* Null before init / after close. */
|
|
40
|
+
export function getRpcClient(): MagicContextRpcClient | null {
|
|
41
|
+
return rpcClient;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
44
|
/** Clean up the RPC client. */
|
|
45
45
|
export function closeRpc(): void {
|
|
46
46
|
// Closing invalidates any already-issued RPC calls; their callbacks must
|
|
47
|
-
// observe the new generation and
|
|
47
|
+
// observe the new generation and abandon (the WS socket checks it too).
|
|
48
48
|
rpcGeneration += 1;
|
|
49
49
|
rpcClient?.reset();
|
|
50
50
|
rpcClient = null;
|
|
51
|
-
lastReceivedNotificationIdBySession.clear();
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
const EMPTY_SNAPSHOT: SidebarSnapshot = {
|
|
@@ -311,13 +310,6 @@ export async function loadToastDurationMs(): Promise<number> {
|
|
|
311
310
|
}
|
|
312
311
|
}
|
|
313
312
|
|
|
314
|
-
export interface TuiMessage {
|
|
315
|
-
id: number;
|
|
316
|
-
type: string;
|
|
317
|
-
payload: Record<string, unknown>;
|
|
318
|
-
sessionId?: string;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
313
|
/**
|
|
322
314
|
* Fetch the current startup announcement from the server, if any.
|
|
323
315
|
* Returns `{show: false}` when there's nothing to announce or when the
|
|
@@ -360,49 +352,3 @@ export async function markAnnounced(): Promise<boolean> {
|
|
|
360
352
|
return false;
|
|
361
353
|
}
|
|
362
354
|
}
|
|
363
|
-
|
|
364
|
-
/** Poll for pending server→TUI notifications via RPC. */
|
|
365
|
-
export async function consumeTuiMessages(sessionId: string): Promise<TuiMessage[]> {
|
|
366
|
-
if (!rpcClient) return [];
|
|
367
|
-
try {
|
|
368
|
-
const result = await rpcClient.call<{ messages: RpcNotificationMessage[] }>(
|
|
369
|
-
"pending-notifications",
|
|
370
|
-
// Pass the TUI's active session so the server only drains
|
|
371
|
-
// notifications scoped to it (or global ones). Without this, a
|
|
372
|
-
// notification for another session served by the same process (e.g.
|
|
373
|
-
// OpenCode Desktop on the same project) could surface here. The
|
|
374
|
-
// cursor is per-session and is advanced by the poller only after it
|
|
375
|
-
// has delivered the returned batch.
|
|
376
|
-
{
|
|
377
|
-
lastReceivedId: lastReceivedNotificationIdBySession.get(sessionId) ?? 0,
|
|
378
|
-
sessionId,
|
|
379
|
-
},
|
|
380
|
-
);
|
|
381
|
-
return (result.messages ?? []).map((m) => ({
|
|
382
|
-
id: m.id,
|
|
383
|
-
type: m.type,
|
|
384
|
-
payload: m.payload,
|
|
385
|
-
sessionId: m.sessionId,
|
|
386
|
-
}));
|
|
387
|
-
} catch {
|
|
388
|
-
return [];
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Advance the delivered-message cursor for one active TUI session.
|
|
394
|
-
* Callers must pass only the contiguous handled prefix of the drained batch;
|
|
395
|
-
* this helper remains empty-safe and monotonic for that prefix.
|
|
396
|
-
*/
|
|
397
|
-
export function markTuiMessagesHandled(sessionId: string, messages: TuiMessage[]): void {
|
|
398
|
-
const previous = lastReceivedNotificationIdBySession.get(sessionId) ?? 0;
|
|
399
|
-
let next = previous;
|
|
400
|
-
for (const message of messages) {
|
|
401
|
-
if (message.id > next) {
|
|
402
|
-
next = message.id;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
if (next > previous) {
|
|
406
|
-
lastReceivedNotificationIdBySession.set(sessionId, next);
|
|
407
|
-
}
|
|
408
|
-
}
|