@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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Persistent WebSocket to the server plugin's RPC server, replacing the old
3
+ * 500ms HTTP notification poll.
4
+ *
5
+ * Why this exists: the TUI plugin and the server plugin run in separate Bun
6
+ * runners in the same process, so they bridge over a localhost socket. The old
7
+ * bridge polled `pending-notifications` over HTTP every 500ms — and each poll
8
+ * opened a NEW loopback TCP connection (Bun's fetch isn't pooled to our server),
9
+ * which was the entire source of idle TUI CPU (#200). A single long-lived WS
10
+ * carries server→TUI pushes with zero per-event connection cost, and the server
11
+ * pushes notifications the instant they're queued (no polling latency).
12
+ *
13
+ * Session scope: the socket carries the TUI's active session in its `hello` so
14
+ * the server delivers only that session's (plus global) notifications and its
15
+ * `isTuiConnected(session)` routing stays correct. The active session is tracked
16
+ * with a cheap watcher that only reads `api.route.current` (a property access,
17
+ * no IPC) and re-scopes the socket ONLY when the session actually changes — so
18
+ * unlike the old poll it does no network work at idle.
19
+ */
20
+
21
+ import { getRpcClient, getRpcGeneration } from "./context-db";
22
+
23
+ export interface SocketNotification {
24
+ id: number;
25
+ type: string;
26
+ payload: Record<string, unknown>;
27
+ sessionId?: string;
28
+ }
29
+
30
+ interface NotificationSocketOptions {
31
+ /** Current active session id (re-read cheaply to follow session switches). */
32
+ getSessionId: () => string | null;
33
+ /** Handle one delivered notification. Returns true if it was consumed (so its
34
+ * id can advance the ack cursor). Async because dialog handlers await. */
35
+ onNotification: (notification: SocketNotification) => boolean | Promise<boolean>;
36
+ }
37
+
38
+ const RECONNECT_BASE_MS = 500;
39
+ const RECONNECT_MAX_MS = 10_000;
40
+ /** Cheap session-watch interval. Reads a property only; no network. The CPU bug
41
+ * was the per-tick fetch, not the timer — this tick does zero IPC at idle. */
42
+ const SESSION_WATCH_MS = 1_000;
43
+
44
+ let socket: WebSocket | null = null;
45
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
46
+ let sessionWatchTimer: ReturnType<typeof setInterval> | undefined;
47
+ let reconnectAttempt = 0;
48
+ let closed = false;
49
+ let helloedSession: string | null = null;
50
+ let opts: NotificationSocketOptions | null = null;
51
+ /** Generation of the rpc client at connect time; a dispose/reinit bumps it and
52
+ * invalidates an in-flight socket so its late callbacks are ignored. */
53
+ let connectGeneration = 0;
54
+ /** Highest handled notification id (sent in hello so a reconnect re-delivers the
55
+ * unhandled backlog; sent in ack so the server prunes the queue). */
56
+ let lastHandledId = 0;
57
+
58
+ /** Open the persistent notification socket. Idempotent: a second call while open
59
+ * is a no-op. Reconnects on its own after any drop. */
60
+ export function startNotificationSocket(options: NotificationSocketOptions): void {
61
+ opts = options;
62
+ closed = false;
63
+ connectGeneration = getRpcGeneration();
64
+ connect();
65
+ if (!sessionWatchTimer) {
66
+ sessionWatchTimer = setInterval(watchSession, SESSION_WATCH_MS);
67
+ }
68
+ }
69
+
70
+ /** Close the socket and stop reconnecting. Call on TUI dispose. */
71
+ export function stopNotificationSocket(): void {
72
+ closed = true;
73
+ if (reconnectTimer) {
74
+ clearTimeout(reconnectTimer);
75
+ reconnectTimer = undefined;
76
+ }
77
+ if (sessionWatchTimer) {
78
+ clearInterval(sessionWatchTimer);
79
+ sessionWatchTimer = undefined;
80
+ }
81
+ try {
82
+ socket?.close();
83
+ } catch {
84
+ // best-effort
85
+ }
86
+ socket = null;
87
+ helloedSession = null;
88
+ reconnectAttempt = 0;
89
+ }
90
+
91
+ function scheduleReconnect(): void {
92
+ if (closed) return;
93
+ if (reconnectTimer) return;
94
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS);
95
+ reconnectAttempt += 1;
96
+ reconnectTimer = setTimeout(() => {
97
+ reconnectTimer = undefined;
98
+ connect();
99
+ }, delay);
100
+ }
101
+
102
+ async function connect(): Promise<void> {
103
+ if (closed) return;
104
+ if (socket) return; // already connected/connecting
105
+
106
+ const client = getRpcClient();
107
+ if (!client) {
108
+ scheduleReconnect();
109
+ return;
110
+ }
111
+ const endpoint = await client.resolveEndpoint();
112
+ // The generation may have bumped (dispose/reinit) while resolving — abandon.
113
+ if (closed || getRpcGeneration() !== connectGeneration) return;
114
+ if (!endpoint) {
115
+ scheduleReconnect();
116
+ return;
117
+ }
118
+
119
+ let ws: WebSocket;
120
+ try {
121
+ ws = new WebSocket(`ws://127.0.0.1:${endpoint.port}/ws`);
122
+ } catch {
123
+ scheduleReconnect();
124
+ return;
125
+ }
126
+ socket = ws;
127
+
128
+ ws.addEventListener("open", () => {
129
+ if (socket !== ws) return;
130
+ reconnectAttempt = 0;
131
+ sendHello(ws, endpoint.token);
132
+ });
133
+
134
+ ws.addEventListener("message", (event) => {
135
+ if (socket !== ws) return;
136
+ void handleSocketMessage(ws, String((event as MessageEvent).data));
137
+ });
138
+
139
+ const onDown = () => {
140
+ if (socket === ws) {
141
+ socket = null;
142
+ helloedSession = null;
143
+ }
144
+ scheduleReconnect();
145
+ };
146
+ ws.addEventListener("close", onDown);
147
+ ws.addEventListener("error", onDown);
148
+ }
149
+
150
+ function sendHello(ws: WebSocket, token: string | null): void {
151
+ const sessionId = opts?.getSessionId() ?? undefined;
152
+ helloedSession = sessionId ?? null;
153
+ ws.send(
154
+ JSON.stringify({
155
+ type: "hello",
156
+ token: token ?? "",
157
+ sessionId,
158
+ lastReceivedId: lastHandledId,
159
+ }),
160
+ );
161
+ }
162
+
163
+ async function handleSocketMessage(ws: WebSocket, raw: string): Promise<void> {
164
+ let msg: { type?: string; notification?: SocketNotification; error?: string };
165
+ try {
166
+ msg = JSON.parse(raw);
167
+ } catch {
168
+ return;
169
+ }
170
+
171
+ if (msg.type === "notification" && msg.notification) {
172
+ const notification = msg.notification;
173
+ // Client-side session filter mirrors the old poller's per-message re-check:
174
+ // a session-scoped notification is only acted on while the TUI is actually
175
+ // viewing that session (the active session can change between queueing and
176
+ // delivery). Global (session-less) notifications always apply.
177
+ const active = opts?.getSessionId() ?? null;
178
+ if (notification.sessionId && active && notification.sessionId !== active) {
179
+ // Not for the session we're viewing — do NOT ack it (a TUI on the right
180
+ // session, or a later switch back, should still get it). Just skip.
181
+ return;
182
+ }
183
+ let consumed = false;
184
+ try {
185
+ consumed = await Promise.resolve(opts?.onNotification(notification) ?? false);
186
+ } catch {
187
+ consumed = false;
188
+ }
189
+ // A dispose/reinit during an awaited dialog handler invalidates this socket.
190
+ if (socket !== ws || getRpcGeneration() !== connectGeneration) return;
191
+ if (consumed && notification.id > lastHandledId) {
192
+ lastHandledId = notification.id;
193
+ // Ack so the server prunes the queue during this long-lived connection.
194
+ // (The id also rides the next reconnect hello via lastReceivedId, so a
195
+ // dropped ack just re-delivers an already-consumed item, which the
196
+ // handlers are idempotent against.)
197
+ try {
198
+ ws.send(JSON.stringify({ type: "ack", lastReceivedId: lastHandledId }));
199
+ } catch {
200
+ // best-effort; reconnect hello re-syncs via lastReceivedId
201
+ }
202
+ }
203
+ return;
204
+ }
205
+
206
+ if (msg.type === "error") {
207
+ // Server rejected us (bad token, etc.). Close and let backoff retry after
208
+ // rediscovering the port/token (the server may have been replaced).
209
+ try {
210
+ ws.close();
211
+ } catch {
212
+ // best-effort
213
+ }
214
+ }
215
+ }
216
+
217
+ /** Cheap session-change watcher: re-scope the socket only when the active session
218
+ * actually changes. Reads a property; no network at idle. */
219
+ function watchSession(): void {
220
+ if (closed || !socket || socket.readyState !== WebSocket.OPEN) return;
221
+ const current = opts?.getSessionId() ?? null;
222
+ if (current === helloedSession) return;
223
+ // Re-hello with the new session; the server replaces this socket's sink scope.
224
+ const client = getRpcClient();
225
+ void client?.resolveEndpoint().then((endpoint) => {
226
+ if (!socket || socket.readyState !== WebSocket.OPEN) return;
227
+ sendHello(socket, endpoint?.token ?? null);
228
+ });
229
+ }
package/src/tui/index.tsx CHANGED
@@ -6,7 +6,8 @@ import { createMemo } from "solid-js"
6
6
  import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
7
7
  import { createSidebarContentSlot, kickRecompProgressRefresh } from "./slots/sidebar-content"
8
8
  import packageJson from "../../package.json"
9
- import { closeRpc, consumeTuiMessages, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadEmbedDetail, loadStatusDetail, loadToastDurationMs, markAnnounced, markTuiMessagesHandled, requestRecomp, requestUpgrade, type EmbedDetail, type TuiMessage, type StatusDetail } from "./data/context-db"
9
+ import { closeRpc, dismissUpgradeReminder, getAnnouncement, getCompartmentCount, getRpcGeneration, initRpcClient, loadEmbedDetail, loadStatusDetail, loadToastDurationMs, markAnnounced, requestRecomp, requestUpgrade, type EmbedDetail, type StatusDetail } from "./data/context-db"
10
+ import { startNotificationSocket, stopNotificationSocket, type SocketNotification } from "./data/notification-socket"
10
11
  import { formatThresholdPercent } from "../shared/format-threshold"
11
12
  import { detectConflicts } from "../shared/conflict-detector"
12
13
  import { fixConflicts } from "../shared/conflict-fixer"
@@ -869,128 +870,77 @@ const tui: TuiPlugin = async (api, _options, meta) => {
869
870
  // only when keymap is missing.
870
871
  registerCommandPaletteEntries(api)
871
872
 
872
- // Poll for server→TUI messages: toasts and dialog requests.
873
- // The poller owns cursor advancement so notifications are acked only after
874
- // they are accepted for the still-active session and delivered to the UI.
875
- let pollInFlight = false
876
- const messagePoller = setInterval(() => {
877
- // Scope the drain to the TUI's active session so notifications tagged for
878
- // a different session (served by the same RPC process) are not consumed
879
- // here. Do not poll on non-session routes: a session-scoped action fetched
880
- // while sessionless could otherwise be acked without being shown.
881
- // Avoid overlapping read-only drains: the server re-delivers until acked,
882
- // so a second in-flight poll can fetch and dispatch the same batch twice.
883
- if (pollInFlight) return
884
-
873
+ // Receive server→TUI notifications (toasts + dialog requests) over a single
874
+ // persistent WebSocket, pushed the instant the server queues them. This
875
+ // replaces the old 500ms HTTP poll whose new-connection-per-tick cost was the
876
+ // source of idle TUI CPU (#200). The socket carries the active session in its
877
+ // hello so the server scopes delivery; here we re-check the active session per
878
+ // notification (it can change between queue and delivery) before acting.
879
+ const handleNotification = async (n: SocketNotification): Promise<boolean> => {
885
880
  const requestedSessionId = getSessionId(api)
886
- if (!requestedSessionId) return
881
+ const generation = getRpcGeneration()
882
+ // A session-scoped notification only applies while we're viewing that
883
+ // session; global (session-less) ones always apply. Returning false leaves
884
+ // it unacked so a TUI on the right session (or a later switch back) still
885
+ // gets it.
886
+ if (n.sessionId && requestedSessionId && n.sessionId !== requestedSessionId) {
887
+ return false
888
+ }
889
+ if (n.type === "toast") {
890
+ const p = n.payload
891
+ showToast(api, {
892
+ message: String(p.message ?? ""),
893
+ variant: (p.variant as "info" | "warning" | "error" | "success") ?? "info",
894
+ durationOverrideMs:
895
+ typeof p.duration === "number" && Number.isFinite(p.duration)
896
+ ? p.duration
897
+ : undefined,
898
+ })
899
+ return true
900
+ }
901
+ if (n.type !== "action") return false
902
+ const action = n.payload?.action
903
+ const stillActive = () =>
904
+ getRpcGeneration() === generation && getSessionId(api) === requestedSessionId
905
+ if (action === "show-status-dialog") {
906
+ return stillActive() && (await showStatusDialog(api, requestedSessionId))
907
+ }
908
+ if (action === "show-recomp-dialog") {
909
+ return stillActive() && (await showRecompDialog(api, requestedSessionId))
910
+ }
911
+ if (action === "show-upgrade-dialog") {
912
+ const resume =
913
+ n.payload?.resume === true
914
+ ? {
915
+ stagedCount: Number(n.payload?.stagedCount ?? 0),
916
+ stagedThrough: Number(n.payload?.stagedThrough ?? 0),
917
+ }
918
+ : undefined
919
+ return stillActive() && showUpgradeDialog(api, resume, requestedSessionId)
920
+ }
921
+ if (action === "show-embed-dialog") {
922
+ return stillActive() && (await showEmbedDialog(api, requestedSessionId))
923
+ }
924
+ if (action === "show-flush-dialog") {
925
+ const flushMsg = String(n.payload?.message ?? "Flushed.")
926
+ return stillActive() && showResultDialog(api, "Flush", flushMsg)
927
+ }
928
+ if (action === "show-result-dialog") {
929
+ const title = String(n.payload?.title ?? "Magic Context")
930
+ const body = String(n.payload?.message ?? "")
931
+ return stillActive() && showResultDialog(api, title, body)
932
+ }
933
+ return false
934
+ }
887
935
 
888
- pollInFlight = true
889
- const pollGeneration = getRpcGeneration()
890
- void consumeTuiMessages(requestedSessionId).then(async (messages) => {
891
- if (unifiedToastDurationMs === DEFAULT_TOAST_DURATION_MS) {
892
- void refreshToastDurationMs()
893
- }
894
- // The dialog handlers read the current session when they run. If the
895
- // user switched routes while the RPC was in flight, drop this whole
896
- // batch without advancing the cursor; the next poll for the new
897
- // session will fetch the right notifications.
898
- // Ignore late responses from an older RPC client generation; close/init
899
- // clears cursors and stale callbacks must not recreate them.
900
- if (getRpcGeneration() !== pollGeneration) return
901
-
902
- if (getSessionId(api) !== requestedSessionId) return
903
-
904
- const orderedMessages = [...messages].sort((a, b) => a.id - b.id)
905
- const handledMessageIds = new Set<number>()
906
- for (const msg of orderedMessages) {
907
- // A dialog helper earlier in this batch may have awaited; re-check
908
- // the route before EACH message so a later action/toast in the same
909
- // batch can't paint into a session the user switched to mid-batch
910
- // (the pre-batch + pre-ack guards alone don't cover mid-batch awaits).
911
- if (getRpcGeneration() !== pollGeneration) return
912
- if (getSessionId(api) !== requestedSessionId) return
913
- // Drop any action/dialog whose sessionId doesn't match this TUI's
914
- // active session (session-less/global notifications still apply).
915
- if (
916
- msg.type === "action" &&
917
- msg.sessionId &&
918
- msg.sessionId !== requestedSessionId
919
- ) {
920
- continue
921
- }
922
- if (msg.type === "toast") {
923
- const p = msg.payload
924
- showToast(api, {
925
- message: String(p.message ?? ""),
926
- variant: (p.variant as "info" | "warning" | "error" | "success") ?? "info",
927
- durationOverrideMs:
928
- typeof p.duration === "number" && Number.isFinite(p.duration)
929
- ? p.duration
930
- : undefined,
931
- })
932
- handledMessageIds.add(msg.id)
933
- } else if (msg.type === "action") {
934
- const action = msg.payload?.action
935
- if (action === "show-status-dialog") {
936
- if (await showStatusDialog(api, requestedSessionId)) {
937
- handledMessageIds.add(msg.id)
938
- }
939
- } else if (action === "show-recomp-dialog") {
940
- if (await showRecompDialog(api, requestedSessionId)) {
941
- handledMessageIds.add(msg.id)
942
- }
943
- } else if (action === "show-upgrade-dialog") {
944
- const resume =
945
- msg.payload?.resume === true
946
- ? {
947
- stagedCount: Number(msg.payload?.stagedCount ?? 0),
948
- stagedThrough: Number(msg.payload?.stagedThrough ?? 0),
949
- }
950
- : undefined
951
- if (showUpgradeDialog(api, resume, requestedSessionId)) {
952
- handledMessageIds.add(msg.id)
953
- }
954
- } else if (action === "show-embed-dialog") {
955
- if (await showEmbedDialog(api, requestedSessionId)) {
956
- handledMessageIds.add(msg.id)
957
- }
958
- } else if (action === "show-flush-dialog") {
959
- const flushMsg = String(msg.payload?.message ?? "Flushed.")
960
- if (showResultDialog(api, "Flush", flushMsg)) {
961
- handledMessageIds.add(msg.id)
962
- }
963
- } else if (action === "show-result-dialog") {
964
- const title = String(msg.payload?.title ?? "Magic Context")
965
- const body = String(msg.payload?.message ?? "")
966
- if (showResultDialog(api, title, body)) {
967
- handledMessageIds.add(msg.id)
968
- }
969
- }
970
- }
971
- }
972
- const handledPrefixMessages: TuiMessage[] = []
973
- for (const msg of orderedMessages) {
974
- if (!handledMessageIds.has(msg.id)) break
975
- handledPrefixMessages.push(msg)
976
- }
977
- // A dialog helper may have awaited more RPC work; re-check before
978
- // acking so a dispose/reinit or route switch during that await cannot
979
- // advance a stale cursor.
980
- if (getRpcGeneration() !== pollGeneration) return
981
- if (getSessionId(api) !== requestedSessionId) return
982
-
983
- markTuiMessagesHandled(requestedSessionId, handledPrefixMessages)
984
- }).catch(() => {
985
- // Intentional: message polling should never crash the TUI
986
- }).finally(() => {
987
- pollInFlight = false
988
- })
989
- }, 500)
936
+ startNotificationSocket({
937
+ getSessionId: () => getSessionId(api),
938
+ onNotification: handleNotification,
939
+ })
990
940
 
991
941
  // Clean up on dispose
992
942
  api.lifecycle.onDispose(() => {
993
- clearInterval(messagePoller)
943
+ stopNotificationSocket()
994
944
  closeRpc()
995
945
  })
996
946