@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.
Files changed (30) hide show
  1. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  2. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  3. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  4. package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
  5. package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
  6. package/dist/index.js +368 -117
  7. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  8. package/dist/shared/announcement.d.ts +1 -1
  9. package/dist/shared/data-path.d.ts.map +1 -1
  10. package/dist/shared/rpc-client.d.ts +8 -0
  11. package/dist/shared/rpc-client.d.ts.map +1 -1
  12. package/dist/shared/rpc-notifications.d.ts +28 -10
  13. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  14. package/dist/shared/rpc-server.d.ts +22 -3
  15. package/dist/shared/rpc-server.d.ts.map +1 -1
  16. package/dist/tui/data/context-db.d.ts +4 -14
  17. package/dist/tui/data/context-db.d.ts.map +1 -1
  18. package/dist/tui/data/notification-socket.d.ts +39 -0
  19. package/dist/tui/data/notification-socket.d.ts.map +1 -0
  20. package/package.json +2 -2
  21. package/src/shared/announcement.ts +2 -2
  22. package/src/shared/data-path.test.ts +28 -0
  23. package/src/shared/data-path.ts +5 -0
  24. package/src/shared/rpc-client.ts +14 -0
  25. package/src/shared/rpc-notifications.test.ts +68 -11
  26. package/src/shared/rpc-notifications.ts +75 -36
  27. package/src/shared/rpc-server.ts +249 -150
  28. package/src/tui/data/context-db.ts +10 -64
  29. package/src/tui/data/notification-socket.ts +229 -0
  30. package/src/tui/index.tsx +68 -118
@@ -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 side-effecting
41
- // endpoints (recomp/upgrade/dismiss) against any local process or
42
- // browser-origin script that merely discovers/guesses the port.
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
- return new Promise((resolve, reject) => {
58
- const server = createServer((req, res) => this.dispatch(req, res));
59
-
60
- server.on("error", (err) => {
61
- log(`[rpc] server error: ${err.message}`);
62
- reject(err);
63
- });
64
-
65
- server.listen(0, "127.0.0.1", () => {
66
- const addr = server.address();
67
- if (!addr || typeof addr === "string") {
68
- reject(new Error("Failed to get server address"));
69
- return;
70
- }
71
- this.port = addr.port;
72
- this.server = server;
73
-
74
- // Write a per-process port file atomically. Multi-instance
75
- // OpenCode is supported: TUI discovery scans all live pid files
76
- // and picks the most recent instead of cross-wiring via one
77
- // shared project file.
78
- try {
79
- this.warnIfOtherLiveInstance();
80
- const dir = dirname(this.portFilePath);
81
- // The port file holds the per-process bearer token that
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
- /** Stop the server and clean up port file. */
154
- stop(): void {
155
- if (this.server) {
156
- this.server.close();
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
- private dispatch(req: IncomingMessage, res: ServerResponse): void {
167
- const url = req.url ?? "";
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, which is not a browser origin. Omitting
171
- // Access-Control-Allow-Origin makes browsers refuse to read responses,
172
- // closing the CSRF-style read path a malicious local page could use.
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
- res.writeHead(404);
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
- // The legitimate TUI client reads it from the same port file it used to
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
- res.writeHead(401, { "Content-Type": "application/json" });
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
- res.writeHead(404, { "Content-Type": "application/json" });
205
- res.end(JSON.stringify({ error: `Unknown method: ${method}` }));
206
- return;
249
+ return json({ error: `Unknown method: ${method}` }, 404);
207
250
  }
208
251
 
209
- let body = "";
210
- req.on("data", (chunk: Buffer) => {
211
- body += chunk.toString();
212
- if (body.length > 1_048_576) {
213
- res.writeHead(413);
214
- res.end("Request too large");
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
- if (body.length > 0) {
223
- params = JSON.parse(body);
224
- }
259
+ params = JSON.parse(bodyText);
225
260
  } catch {
226
- res.writeHead(400, { "Content-Type": "application/json" });
227
- res.end(JSON.stringify({ error: "Invalid JSON" }));
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
- handler(params)
232
- .then((result) => {
233
- res.writeHead(200, { "Content-Type": "application/json" });
234
- res.end(JSON.stringify(result));
235
- })
236
- .catch((err) => {
237
- log(`[rpc] handler error: ${method} => ${err}`);
238
- res.writeHead(500, { "Content-Type": "application/json" });
239
- res.end(JSON.stringify({ error: String(err) }));
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 cannot repopulate cleared cursors.
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 avoid advancing stale notification cursors.
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
- }