@cortexkit/opencode-magic-context 0.32.0 → 0.32.2

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 (191) hide show
  1. package/README.md +3 -3
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +3 -0
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +82 -2
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/config/transform-mode.d.ts +15 -0
  8. package/dist/config/transform-mode.d.ts.map +1 -0
  9. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
  11. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  13. package/dist/features/magic-context/git-commits/indexer.d.ts.map +1 -1
  14. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  15. package/dist/features/magic-context/memory/embedding-provider.d.ts +6 -0
  16. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  17. package/dist/features/magic-context/memory/embedding-synapse.d.ts +98 -0
  18. package/dist/features/magic-context/memory/embedding-synapse.d.ts.map +1 -0
  19. package/dist/features/magic-context/memory/embedding.d.ts +1 -1
  20. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  21. package/dist/features/magic-context/message-index-async.d.ts +4 -1
  22. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  23. package/dist/features/magic-context/message-index.d.ts +7 -4
  24. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  25. package/dist/features/magic-context/migrations.d.ts +21 -16
  26. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  27. package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
  28. package/dist/features/magic-context/project-embedding-registry.d.ts +27 -0
  29. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  30. package/dist/features/magic-context/search-measurement.d.ts +14 -0
  31. package/dist/features/magic-context/search-measurement.d.ts.map +1 -0
  32. package/dist/features/magic-context/search.d.ts +11 -1
  33. package/dist/features/magic-context/search.d.ts.map +1 -1
  34. package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
  35. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  36. package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
  37. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  38. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
  39. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  40. package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
  41. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  42. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
  43. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
  45. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
  47. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  48. package/dist/features/magic-context/storage-clone.d.ts +4 -0
  49. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  50. package/dist/features/magic-context/storage-db.d.ts +7 -1
  51. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  52. package/dist/features/magic-context/storage-embedding-measurements.d.ts +60 -0
  53. package/dist/features/magic-context/storage-embedding-measurements.d.ts.map +1 -0
  54. package/dist/features/magic-context/storage-meta-persisted.d.ts +10 -5
  55. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  56. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
  58. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  59. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage.d.ts +1 -0
  61. package/dist/features/magic-context/storage.d.ts.map +1 -1
  62. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
  63. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  64. package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
  65. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  66. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/auto-search-runner.d.ts +7 -1
  68. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/channel2-delivery.d.ts +2 -0
  70. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/command-handler.d.ts +5 -0
  72. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +4 -0
  74. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
  76. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
  77. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
  80. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/emergency-fail-closed.d.ts +7 -0
  82. package/dist/hooks/magic-context/emergency-fail-closed.d.ts.map +1 -0
  83. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
  85. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
  86. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
  87. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  89. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/hook-handlers.d.ts +6 -0
  91. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/hook.d.ts +19 -3
  93. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/index.d.ts +1 -1
  95. package/dist/hooks/magic-context/index.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -0
  97. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/lkg-replay.d.ts +51 -0
  99. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -0
  100. package/dist/hooks/magic-context/lkg-slot.d.ts +25 -0
  101. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -0
  102. package/dist/hooks/magic-context/module-state-sync.d.ts +169 -0
  103. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -0
  104. package/dist/hooks/magic-context/module-wire.d.ts +73 -0
  105. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -0
  106. package/dist/hooks/magic-context/pass-outcome.d.ts +15 -0
  107. package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -0
  108. package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
  109. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  110. package/dist/hooks/magic-context/read-session-raw.d.ts +30 -2
  111. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/rust-mode-transform.d.ts +64 -0
  114. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -0
  115. package/dist/hooks/magic-context/shadow-sender.d.ts +84 -49
  116. package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/tag-messages.d.ts +2 -0
  118. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  119. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +56 -0
  120. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  121. package/dist/hooks/magic-context/transform.d.ts +9 -0
  122. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  123. package/dist/index.d.ts.map +1 -1
  124. package/dist/index.js +17355 -10510
  125. package/dist/plugin/boot-quiet.d.ts +12 -0
  126. package/dist/plugin/boot-quiet.d.ts.map +1 -0
  127. package/dist/plugin/dream-timer.d.ts +6 -2
  128. package/dist/plugin/dream-timer.d.ts.map +1 -1
  129. package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
  130. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  131. package/dist/plugin/embedding-routing.d.ts +32 -0
  132. package/dist/plugin/embedding-routing.d.ts.map +1 -0
  133. package/dist/plugin/hooks/create-session-hooks.d.ts +54 -2
  134. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  135. package/dist/plugin/messages-transform.d.ts +4 -3
  136. package/dist/plugin/messages-transform.d.ts.map +1 -1
  137. package/dist/plugin/rpc-handlers.d.ts +19 -2
  138. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  139. package/dist/plugin/rust-tool-backends.d.ts +10 -0
  140. package/dist/plugin/rust-tool-backends.d.ts.map +1 -0
  141. package/dist/plugin/tool-registry.d.ts +2 -0
  142. package/dist/plugin/tool-registry.d.ts.map +1 -1
  143. package/dist/shared/models-dev-cache.d.ts +5 -0
  144. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  145. package/dist/shared/rpc-client.d.ts +3 -1
  146. package/dist/shared/rpc-client.d.ts.map +1 -1
  147. package/dist/shared/rpc-notifications.d.ts +11 -6
  148. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  149. package/dist/shared/rpc-server.d.ts +2 -2
  150. package/dist/shared/rpc-server.d.ts.map +1 -1
  151. package/dist/shared/rpc-types.d.ts +3 -0
  152. package/dist/shared/rpc-types.d.ts.map +1 -1
  153. package/dist/shared/sqlite.d.ts.map +1 -1
  154. package/dist/shared/tui-config.d.ts +5 -3
  155. package/dist/shared/tui-config.d.ts.map +1 -1
  156. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  157. package/dist/tools/ctx-memory/types.d.ts +2 -0
  158. package/dist/tools/ctx-memory/types.d.ts.map +1 -1
  159. package/dist/tools/ctx-reduce/tools.d.ts +2 -0
  160. package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
  161. package/dist/tui/data/context-db.d.ts +9 -2
  162. package/dist/tui/data/context-db.d.ts.map +1 -1
  163. package/dist/tui/data/notification-socket.d.ts +4 -5
  164. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  165. package/dist/tui-compiled/data/context-db.d.ts +9 -2
  166. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  167. package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
  168. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
  169. package/package.json +2 -1
  170. package/src/shared/models-dev-cache.test.ts +5 -0
  171. package/src/shared/models-dev-cache.ts +44 -6
  172. package/src/shared/opencode-compaction-detector.test.ts +11 -2
  173. package/src/shared/rpc-client.test.ts +213 -9
  174. package/src/shared/rpc-client.ts +48 -39
  175. package/src/shared/rpc-notifications.test.ts +26 -5
  176. package/src/shared/rpc-notifications.ts +44 -35
  177. package/src/shared/rpc-server.ts +69 -33
  178. package/src/shared/rpc-types.ts +3 -0
  179. package/src/shared/sqlite.ts +22 -3
  180. package/src/shared/tui-config.test.ts +4 -12
  181. package/src/shared/tui-config.ts +6 -6
  182. package/src/tui/data/context-db.test.ts +113 -0
  183. package/src/tui/data/context-db.ts +26 -19
  184. package/src/tui/data/notification-socket.test.ts +277 -11
  185. package/src/tui/data/notification-socket.ts +257 -86
  186. package/src/tui/index.tsx +42 -25
  187. package/src/tui/slots/sidebar-content.tsx +62 -24
  188. package/src/tui-compiled/data/context-db.ts +26 -19
  189. package/src/tui-compiled/data/notification-socket.ts +257 -86
  190. package/src/tui-compiled/index.tsx +83 -30
  191. package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
@@ -70,10 +70,25 @@ describe("rpc notifications", () => {
70
70
  expect(isTuiConnected()).toBe(false);
71
71
  });
72
72
 
73
- test("a session-less sink counts as connected for any session query", () => {
73
+ test("a modern session-less sink is global-only", () => {
74
+ const received: string[] = [];
75
+ const unregister = registerNotificationSink({
76
+ sessionId: undefined,
77
+ protocol: 2,
78
+ send: (notification) => received.push(notification.type),
79
+ });
80
+ expect(isTuiConnected("ses_whatever")).toBe(false);
81
+ expect(isTuiConnected()).toBe(true);
82
+
83
+ pushNotification("scoped", { ok: true }, "ses_whatever");
84
+ pushNotification("global", { ok: true });
85
+ expect(received).toEqual(["global"]);
86
+ unregister();
87
+ });
88
+
89
+ test("a legacy session-less sink retains broad compatibility", () => {
74
90
  const unregister = registerNotificationSink({ sessionId: undefined, send: () => {} });
75
91
  expect(isTuiConnected("ses_whatever")).toBe(true);
76
- expect(isTuiConnected()).toBe(true);
77
92
  unregister();
78
93
  });
79
94
 
@@ -117,14 +132,20 @@ describe("rpc notifications", () => {
117
132
 
118
133
  test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
119
134
  drainNotifications(Number.MAX_SAFE_INTEGER);
120
- // One quiet session with a single pending dialog.
121
135
  pushNotification("quiet-dialog", { action: "show-upgrade-dialog" }, "ses_quiet");
122
- // A noisy session floods well past the 100 cap.
123
136
  for (let i = 0; i < 200; i += 1) {
124
137
  pushNotification("noise", { i }, "ses_noisy");
125
138
  }
126
- // The quiet session's newest item must survive the eviction.
127
139
  const quietPoll = drainNotifications(0, "ses_quiet");
128
140
  expect(quietPoll.some((m) => m.type === "quiet-dialog")).toBe(true);
141
+ expect(drainNotifications(0)).toHaveLength(100);
142
+ });
143
+
144
+ test("queue cap remains global across more than one hundred sessions", () => {
145
+ drainNotifications(Number.MAX_SAFE_INTEGER);
146
+ for (let i = 0; i < 250; i += 1) {
147
+ pushNotification("one-per-session", { i }, `ses_${i}`);
148
+ }
149
+ expect(drainNotifications(0)).toHaveLength(100);
129
150
  });
130
151
  });
@@ -26,6 +26,8 @@ let nextNotificationId = 1;
26
26
  export interface NotificationSink {
27
27
  /** The TUI's active session at connect time (its hello scope). */
28
28
  sessionId?: string;
29
+ /** Protocol 2 clients use strict session scoping; absent means legacy behavior. */
30
+ protocol?: number;
29
31
  /** Deliver one notification over this sink's live socket. */
30
32
  send: (notification: RpcNotification) => void;
31
33
  }
@@ -48,23 +50,19 @@ export function registerNotificationSink(sink: NotificationSink): () => void {
48
50
  };
49
51
  }
50
52
 
51
- /** Whether a given notification may be delivered to a given sink. A global
52
- * notification (no sessionId) reaches every sink; a session-scoped one reaches
53
- * only sinks for that session (or session-less sinks). Mirrors the drain filter
54
- * from the sink's perspective. */
53
+ /** Whether a notification may be delivered to a sink. Protocol 2 makes a
54
+ * session-less socket global-only; legacy sockets retain broad compatibility. */
55
55
  function notificationMatchesSink(notification: RpcNotification, sink: NotificationSink): boolean {
56
- return (
57
- notification.sessionId === undefined ||
58
- sink.sessionId === undefined ||
59
- notification.sessionId === sink.sessionId
60
- );
56
+ if (notification.sessionId === undefined) return true;
57
+ if (sink.sessionId !== undefined) return notification.sessionId === sink.sessionId;
58
+ return sink.protocol !== 2;
61
59
  }
62
60
 
63
61
  /** Push a notification to the TUI. Fans out to any live WS sink immediately and
64
62
  * also enqueues it so a TUI that is momentarily disconnected (reconnecting, or
65
63
  * not yet connected) still receives it on its next hello via the backlog drain.
66
64
  * At-least-once: a live push that the socket drops is re-delivered from the
67
- * queue on reconnect (pruned only when the client acks via `lastReceivedId`). */
65
+ * queue on reconnect (pruned only when the client acknowledges it). */
68
66
  export function pushNotification(
69
67
  type: string,
70
68
  payload: Record<string, unknown>,
@@ -83,30 +81,40 @@ export function pushNotification(
83
81
  // unregister it, and the queue backlog re-delivers on reconnect.
84
82
  }
85
83
  }
86
- // Cap queue size to prevent unbounded growth if a TUI is not draining.
87
- // Session-FAIR eviction: a naive `slice(-50)` drops the globally-oldest
88
- // items, so a noisy session could evict ANOTHER session's single unseen
89
- // notification. Instead, always retain each session's newest item, then
90
- // fill the rest of the budget with the newest overall — no session can
91
- // starve another's pending dialog out of the window.
84
+ // Keep a strict global bound. Reserve the newest item for at most 25 recently
85
+ // active scopes, then evict the oldest unreserved item. This protects a quiet
86
+ // session from one noisy peer without allowing one item per session to grow
87
+ // the queue without limit.
92
88
  if (queue.length > 100) {
93
- const newestPerSession = new Map<string | undefined, number>();
94
- for (const n of queue) {
95
- const prev = newestPerSession.get(n.sessionId);
96
- if (prev === undefined || n.id > prev) {
97
- newestPerSession.set(n.sessionId, n.id);
98
- }
89
+ const reservedIds = new Set<number>();
90
+ const reservedScopes = new Set<string>();
91
+ for (let i = queue.length - 1; i >= 0 && reservedScopes.size < 25; i -= 1) {
92
+ const candidate = queue[i];
93
+ const scope = candidate.sessionId ?? "\0global";
94
+ if (reservedScopes.has(scope)) continue;
95
+ reservedScopes.add(scope);
96
+ reservedIds.add(candidate.id);
99
97
  }
100
- const mustKeep = new Set(newestPerSession.values());
101
- const byNewest = [...queue].sort((a, b) => b.id - a.id);
102
- const kept: RpcNotification[] = [];
103
- for (const n of byNewest) {
104
- if (kept.length < 50 || mustKeep.has(n.id)) kept.push(n);
105
- }
106
- queue = kept.sort((a, b) => a.id - b.id);
98
+ const evictionIndex = queue.findIndex((candidate) => !reservedIds.has(candidate.id));
99
+ queue.splice(evictionIndex >= 0 ? evictionIndex : 0, 1);
107
100
  }
108
101
  }
109
102
 
103
+ export function acknowledgeNotifications(ids: readonly number[]): void {
104
+ const acknowledged = new Set(ids.filter((id) => Number.isSafeInteger(id) && id > 0));
105
+ if (acknowledged.size === 0) return;
106
+ // Exact removal preserves an earlier notification when a later handler finishes
107
+ // first or the earlier handler declines to consume its notification.
108
+ queue = queue.filter((notification) => !acknowledged.has(notification.id));
109
+ }
110
+
111
+ /** Reset process-local state to simulate a fresh server module in protocol tests. */
112
+ export function __resetNotificationStateForTests(): void {
113
+ queue = [];
114
+ nextNotificationId = 1;
115
+ sinks.clear();
116
+ }
117
+
110
118
  export interface DrainNotificationsOptions {
111
119
  /**
112
120
  * Cursor for global notifications when a session-scoped client sends separate
@@ -133,8 +141,8 @@ function cursor(value: number | undefined): number {
133
141
  * behavior.
134
142
  *
135
143
  * Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
136
- * notification stays queued until a later call acks it via the matching scope's
137
- * cursor, so a dropped WS socket re-delivers unhandled backlog on reconnect. */
144
+ * notification stays queued until an exact acknowledgement or a legacy cursor
145
+ * removes it, so a dropped WS socket re-delivers unhandled backlog on reconnect. */
138
146
  export function drainNotifications(
139
147
  lastReceivedId = 0,
140
148
  sessionId?: string,
@@ -200,14 +208,15 @@ export function drainNotifications(
200
208
  * Pass `sessionId` (preferred) to ask whether a TUI is connected FOR THAT
201
209
  * SESSION — this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
202
210
  * reminder) must use to decide dialog-vs-message, so a TUI on a different
203
- * session in the same process does not misroute their delivery. A session-less
204
- * sink (legacy/global) counts for any session query. Omit `sessionId` only for
205
- * callers with no session context; they get "any sink connected". */
211
+ * session in the same process does not misroute their delivery. A modern
212
+ * session-less sink is global-only; a legacy sink retains broad compatibility.
213
+ * Omit `sessionId` only for callers with no session context. */
206
214
  export function isTuiConnected(sessionId?: string): boolean {
207
215
  if (sinks.size === 0) return false;
208
216
  if (sessionId === undefined) return true;
209
217
  for (const sink of sinks) {
210
- if (sink.sessionId === undefined || sink.sessionId === sessionId) return true;
218
+ if (sink.sessionId === sessionId) return true;
219
+ if (sink.sessionId === undefined && sink.protocol !== 2) return true;
211
220
  }
212
221
  return false;
213
222
  }
@@ -13,6 +13,7 @@ import { dirname } from "node:path";
13
13
  import type { Server, ServerWebSocket } from "bun";
14
14
  import { log } from "./logger";
15
15
  import {
16
+ acknowledgeNotifications,
16
17
  drainNotifications,
17
18
  type NotificationSink,
18
19
  registerNotificationSink,
@@ -57,8 +58,12 @@ function bearerToken(req: Request): string {
57
58
  return typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
58
59
  }
59
60
 
60
- function websocketToken(req: Request, url: URL): string {
61
- return bearerToken(req) || url.searchParams.get("token") || "";
61
+ function websocketToken(req: Request): string {
62
+ const headerToken = bearerToken(req);
63
+ if (headerToken) return headerToken;
64
+ // v0.32 TUI clients put the token in the upgrade URL. Keep this fallback only
65
+ // for the v0.32→v0.32.1 skew window; remove it when v0.32 clients are unsupported.
66
+ return new URL(req.url).searchParams.get("token") ?? "";
62
67
  }
63
68
 
64
69
  /**
@@ -242,7 +247,7 @@ export class MagicContextRpcServer {
242
247
  // WebSocket upgrade — the persistent push channel. Authenticate before
243
248
  // `srv.upgrade` so an unauthorized request never becomes a live socket.
244
249
  if (url.pathname === "/ws") {
245
- if (!tokensMatch(websocketToken(req, url), this.token)) {
250
+ if (!tokensMatch(websocketToken(req), this.token)) {
246
251
  return new Response("Unauthorized", { status: 401 });
247
252
  }
248
253
  const ok = srv.upgrade(req, { data: { authed: false } });
@@ -253,7 +258,7 @@ export class MagicContextRpcServer {
253
258
  // No wildcard CORS: the only legitimate client is the in-process TUI
254
259
  // client, not a browser origin.
255
260
  if (req.method === "GET" && url.pathname === "/health") {
256
- return json({ ok: true, pid: process.pid });
261
+ return json({ ok: true, pid: process.pid, instance_id: this.instanceId });
257
262
  }
258
263
 
259
264
  if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
@@ -293,8 +298,8 @@ export class MagicContextRpcServer {
293
298
  }
294
299
  }
295
300
 
296
- /** WS message handler: hello (auth + sink registration + backlog drain) and
297
- * ack (cursor advance → queue prune). All other messages are ignored. */
301
+ /** WS message handler: hello (auth + sink registration + backlog delivery) and
302
+ * ack (exact removal or legacy cursor pruning). All other messages are ignored. */
298
303
  private handleWsMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
299
304
  let msg: {
300
305
  type?: string;
@@ -303,6 +308,10 @@ export class MagicContextRpcServer {
303
308
  lastReceivedId?: number;
304
309
  globalLastReceivedId?: number;
305
310
  ackScope?: string;
311
+ protocol?: number;
312
+ instanceId?: string;
313
+ ids?: unknown;
314
+ cursor?: number;
306
315
  };
307
316
  try {
308
317
  msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
@@ -336,6 +345,7 @@ export class MagicContextRpcServer {
336
345
  // Register a live sink so future pushes reach this socket immediately.
337
346
  const sink: NotificationSink = {
338
347
  sessionId: ws.data.sessionId,
348
+ protocol: msg.protocol,
339
349
  send: (notification) => {
340
350
  ws.send(JSON.stringify({ type: "notification", notification }));
341
351
  },
@@ -343,40 +353,66 @@ export class MagicContextRpcServer {
343
353
  ws.data.unregister = registerNotificationSink(sink);
344
354
  this.sockets.add(ws);
345
355
 
346
- // Deliver any backlog the client hasn't seen (at-least-once). Modern
347
- // clients send separate session and global cursors; keeping those
348
- // watermarks independent prevents a high id in one session from pruning
349
- // lower unseen notifications in another session.
350
- const lastReceivedId = Number(msg.lastReceivedId ?? 0);
351
- const sessionCursor = Number.isFinite(lastReceivedId) ? lastReceivedId : 0;
352
- const hasGlobalCursor = typeof msg.globalLastReceivedId === "number";
353
- const globalLastReceivedId = hasGlobalCursor
354
- ? Number.isFinite(msg.globalLastReceivedId)
355
- ? msg.globalLastReceivedId
356
- : 0
357
- : 0;
358
- const backlog =
359
- ws.data.sessionId === undefined && hasGlobalCursor
360
- ? drainNotifications(globalLastReceivedId, undefined, { globalOnly: true })
361
- : drainNotifications(
362
- sessionCursor,
363
- ws.data.sessionId,
364
- hasGlobalCursor
365
- ? { globalLastReceivedId: globalLastReceivedId }
366
- : undefined,
367
- );
356
+ const usesExactAcknowledgements = msg.protocol === 2;
357
+ // The epoch arrives before backlog frames so the client can discard
358
+ // cursors and deduplication entries from a replaced server first.
359
+ ws.send(
360
+ JSON.stringify({
361
+ type: "hello-ack",
362
+ protocol: 2,
363
+ instanceId: this.instanceId,
364
+ }),
365
+ );
366
+
367
+ let backlog: ReturnType<typeof drainNotifications>;
368
+ if (usesExactAcknowledgements) {
369
+ // Protocol 2 never treats a high handled id as proof that lower ids
370
+ // were consumed. Exact acknowledgements are the only destructive
371
+ // operation, so declined or interrupted dialogs survive reconnects.
372
+ backlog =
373
+ ws.data.sessionId === undefined
374
+ ? drainNotifications(0, undefined, { globalOnly: true })
375
+ : drainNotifications(0, ws.data.sessionId, {
376
+ globalLastReceivedId: 0,
377
+ });
378
+ } else {
379
+ // Legacy clients use independent session and global watermarks.
380
+ const lastReceivedId = Number(msg.lastReceivedId ?? 0);
381
+ const sessionCursor = Number.isFinite(lastReceivedId) ? lastReceivedId : 0;
382
+ const hasGlobalCursor = typeof msg.globalLastReceivedId === "number";
383
+ const globalLastReceivedId = hasGlobalCursor
384
+ ? Number.isFinite(msg.globalLastReceivedId)
385
+ ? msg.globalLastReceivedId
386
+ : 0
387
+ : 0;
388
+ backlog =
389
+ ws.data.sessionId === undefined && hasGlobalCursor
390
+ ? drainNotifications(globalLastReceivedId, undefined, { globalOnly: true })
391
+ : drainNotifications(
392
+ sessionCursor,
393
+ ws.data.sessionId,
394
+ hasGlobalCursor
395
+ ? { globalLastReceivedId: globalLastReceivedId }
396
+ : undefined,
397
+ );
398
+ }
368
399
  for (const notification of backlog) {
369
400
  ws.send(JSON.stringify({ type: "notification", notification }));
370
401
  }
371
- ws.send(JSON.stringify({ type: "hello-ack" }));
372
402
  return;
373
403
  }
374
404
 
375
405
  if (msg.type === "ack") {
376
- // Advance only the cursor scope the client handled. Acking session A
377
- // must not prune session B, and acking a global notification must not
378
- // become a session watermark. Cheap, event-driven queue cleanup.
379
- const lastReceivedId = Number(msg.lastReceivedId ?? 0);
406
+ if (Array.isArray(msg.ids)) {
407
+ acknowledgeNotifications(
408
+ msg.ids.filter((id): id is number => typeof id === "number"),
409
+ );
410
+ return;
411
+ }
412
+
413
+ // Keep watermark acknowledgements during the one-release skew window.
414
+ // Scope isolation remains mandatory for these legacy messages.
415
+ const lastReceivedId = Number(msg.cursor ?? msg.lastReceivedId ?? 0);
380
416
  if (Number.isFinite(lastReceivedId) && lastReceivedId > 0) {
381
417
  if (msg.ackScope === "global") {
382
418
  drainNotifications(lastReceivedId, undefined, { globalOnly: true });
@@ -67,6 +67,9 @@ export interface SidebarSnapshot {
67
67
  * scheduler and transform paths.
68
68
  */
69
69
  executeThreshold: number;
70
+ /** Rust module cache boundary state, when the session uses Rust authority mode. */
71
+ boundaryPresent?: boolean;
72
+ coverageOrdinal?: number | null;
70
73
  newWorkTokens?: number | null;
71
74
  totalInputTokens?: number | null;
72
75
  /**
@@ -137,11 +137,15 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
137
137
  transaction<F extends (...args: any[]) => any>(fn: F): F {
138
138
  // biome-ignore lint/suspicious/noExplicitAny: faithful pass-through of this/args to fn.
139
139
  const self = this as any;
140
- const wrapped = function (this: unknown, ...args: unknown[]): unknown {
140
+ const execute = (
141
+ mode: "" | "DEFERRED" | "IMMEDIATE" | "EXCLUSIVE",
142
+ receiver: unknown,
143
+ args: unknown[],
144
+ ) => {
141
145
  const nested = self.isTransaction === true;
142
- self.exec(nested ? `SAVEPOINT ${SAVEPOINT}` : "BEGIN");
146
+ self.exec(nested ? `SAVEPOINT ${SAVEPOINT}` : `BEGIN${mode ? ` ${mode}` : ""}`);
143
147
  try {
144
- const result = fn.apply(this, args);
148
+ const result = fn.apply(receiver, args);
145
149
  self.exec(nested ? `RELEASE ${SAVEPOINT}` : "COMMIT");
146
150
  return result;
147
151
  } catch (error) {
@@ -157,6 +161,21 @@ function buildNodeSqliteDatabaseClass(DatabaseSync: any): typeof BetterSqlite3 {
157
161
  throw error;
158
162
  }
159
163
  };
164
+ const wrapped = function (this: unknown, ...args: unknown[]): unknown {
165
+ return execute("", this, args);
166
+ };
167
+ wrapped.default = function (this: unknown, ...args: unknown[]): unknown {
168
+ return execute("", this, args);
169
+ };
170
+ wrapped.deferred = function (this: unknown, ...args: unknown[]): unknown {
171
+ return execute("DEFERRED", this, args);
172
+ };
173
+ wrapped.immediate = function (this: unknown, ...args: unknown[]): unknown {
174
+ return execute("IMMEDIATE", this, args);
175
+ };
176
+ wrapped.exclusive = function (this: unknown, ...args: unknown[]): unknown {
177
+ return execute("EXCLUSIVE", this, args);
178
+ };
160
179
  return wrapped as unknown as F;
161
180
  }
162
181
  }
@@ -4,11 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  const roots: string[] = [];
7
- const prevConfigDir = process.env.OPENCODE_CONFIG_DIR;
8
-
9
7
  afterEach(() => {
10
- if (prevConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR;
11
- else process.env.OPENCODE_CONFIG_DIR = prevConfigDir;
12
8
  for (const root of roots.splice(0)) {
13
9
  rmSync(root, { recursive: true, force: true });
14
10
  }
@@ -18,7 +14,6 @@ describe("ensureTuiPluginEntry", () => {
18
14
  it("preserves tuple dev-path plugin entry and does not add @latest", async () => {
19
15
  const root = mkdtempSync(join(tmpdir(), "mc-tui-"));
20
16
  roots.push(root);
21
- process.env.OPENCODE_CONFIG_DIR = root;
22
17
  const devPath = "/Work/magic-context/packages/plugin";
23
18
  const tuiPath = join(root, "tui.json");
24
19
  writeFileSync(
@@ -27,7 +22,7 @@ describe("ensureTuiPluginEntry", () => {
27
22
  );
28
23
 
29
24
  const { ensureTuiPluginEntry } = await import("./tui-config");
30
- const changed = ensureTuiPluginEntry();
25
+ const changed = ensureTuiPluginEntry({ configDir: root });
31
26
  expect(changed).toBe(false);
32
27
  const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
33
28
  expect(parsed.plugin).toHaveLength(2);
@@ -40,7 +35,6 @@ describe("ensureTuiPluginEntry", () => {
40
35
  it("upgrades bare npm name to @latest while preserving tuple options", async () => {
41
36
  const root = mkdtempSync(join(tmpdir(), "mc-tui-npm-"));
42
37
  roots.push(root);
43
- process.env.OPENCODE_CONFIG_DIR = root;
44
38
  const tuiPath = join(root, "tui.json");
45
39
  writeFileSync(
46
40
  tuiPath,
@@ -54,7 +48,7 @@ describe("ensureTuiPluginEntry", () => {
54
48
  );
55
49
 
56
50
  const { ensureTuiPluginEntry } = await import("./tui-config");
57
- expect(ensureTuiPluginEntry()).toBe(true);
51
+ expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
58
52
  const parsed = JSON.parse(readFileSync(tuiPath, "utf-8")) as { plugin: unknown[] };
59
53
  const entry = parsed.plugin[0] as unknown[];
60
54
  expect(entry[0]).toBe("@cortexkit/opencode-magic-context@latest");
@@ -64,10 +58,9 @@ describe("ensureTuiPluginEntry", () => {
64
58
  it("creates tui.jsonc (not tui.json) on a fresh install", async () => {
65
59
  const root = mkdtempSync(join(tmpdir(), "mc-tui-fresh-"));
66
60
  roots.push(root);
67
- process.env.OPENCODE_CONFIG_DIR = root;
68
61
 
69
62
  const { ensureTuiPluginEntry } = await import("./tui-config");
70
- expect(ensureTuiPluginEntry()).toBe(true);
63
+ expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
71
64
 
72
65
  // The new file must be tui.jsonc so a tui.json stub never ends up
73
66
  // sitting next to a tui.jsonc the user writes later (#176).
@@ -82,7 +75,6 @@ describe("ensureTuiPluginEntry", () => {
82
75
  it("writes into the existing tui.jsonc when both files exist", async () => {
83
76
  const root = mkdtempSync(join(tmpdir(), "mc-tui-both-"));
84
77
  roots.push(root);
85
- process.env.OPENCODE_CONFIG_DIR = root;
86
78
  // A real user config in tui.jsonc plus a leftover empty tui.json.
87
79
  writeFileSync(
88
80
  join(root, "tui.jsonc"),
@@ -91,7 +83,7 @@ describe("ensureTuiPluginEntry", () => {
91
83
  writeFileSync(join(root, "tui.json"), "{}\n");
92
84
 
93
85
  const { ensureTuiPluginEntry } = await import("./tui-config");
94
- expect(ensureTuiPluginEntry()).toBe(true);
86
+ expect(ensureTuiPluginEntry({ configDir: root })).toBe(true);
95
87
 
96
88
  // The plugin entry must land in tui.jsonc (higher precedence), and the
97
89
  // user's keybinds must survive; tui.json must be left untouched.
@@ -62,8 +62,8 @@ function writeTuiConfigAtomic(configPath: string, config: Record<string, unknown
62
62
  renameSync(tmpPath, configPath);
63
63
  }
64
64
 
65
- function resolveTuiConfigPath(): string {
66
- const configDir = getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
65
+ function resolveTuiConfigPath(configDirOverride?: string): string {
66
+ const configDir = configDirOverride ?? getOpenCodeConfigPaths({ binary: "opencode" }).configDir;
67
67
  const jsoncPath = join(configDir, "tui.jsonc");
68
68
  const jsonPath = join(configDir, "tui.json");
69
69
 
@@ -80,12 +80,12 @@ function resolveTuiConfigPath(): string {
80
80
  }
81
81
 
82
82
  /**
83
- * Ensure tui.json has the magic-context TUI plugin entry.
84
- * Creates tui.json if it doesn't exist. Silently skips if already present.
83
+ * Ensure the selected TUI config has the magic-context plugin entry.
84
+ * Creates tui.jsonc if neither TUI config exists. Silently skips if already present.
85
85
  */
86
- export function ensureTuiPluginEntry(): boolean {
86
+ export function ensureTuiPluginEntry(options: { configDir?: string } = {}): boolean {
87
87
  try {
88
- const configPath = resolveTuiConfigPath();
88
+ const configPath = resolveTuiConfigPath(options.configDir);
89
89
 
90
90
  let config: Record<string, unknown> = {};
91
91
  if (existsSync(configPath)) {
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MagicContextRpcServer } from "../../shared/rpc-server";
6
+ import type { SidebarSnapshot } from "../../shared/rpc-types";
7
+ import { closeRpc, getCompartmentCount, initRpcClient, loadSidebarSnapshot } from "./context-db";
8
+
9
+ const originalXdgDataHome = process.env.XDG_DATA_HOME;
10
+ const tempDirs: string[] = [];
11
+ const servers: MagicContextRpcServer[] = [];
12
+
13
+ afterEach(() => {
14
+ closeRpc();
15
+ for (const server of servers.splice(0)) server.stop();
16
+ for (const dir of tempDirs.splice(0)) {
17
+ rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
18
+ }
19
+ if (originalXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
20
+ else process.env.XDG_DATA_HOME = originalXdgDataHome;
21
+ });
22
+
23
+ function makeDataHome(): string {
24
+ const dir = mkdtempSync(join(tmpdir(), "mc-context-db-"));
25
+ tempDirs.push(dir);
26
+ process.env.XDG_DATA_HOME = dir;
27
+ return dir;
28
+ }
29
+
30
+ function snapshot(sessionId: string, inputTokens: number): SidebarSnapshot {
31
+ return {
32
+ sessionId,
33
+ usagePercentage: inputTokens > 0 ? 25 : 0,
34
+ inputTokens,
35
+ contextLimit: 1000,
36
+ systemPromptTokens: 0,
37
+ compartmentCount: inputTokens > 0 ? 2 : 0,
38
+ memoryCount: 0,
39
+ memoryBlockCount: 0,
40
+ pendingOpsCount: 0,
41
+ historianRunning: false,
42
+ compartmentInProgress: false,
43
+ sessionNoteCount: 0,
44
+ readySmartNoteCount: 0,
45
+ cacheTtl: "5m",
46
+ lastDreamerRunAt: null,
47
+ projectIdentity: null,
48
+ compartmentTokens: 0,
49
+ factTokens: 0,
50
+ memoryTokens: 0,
51
+ docsTokens: 0,
52
+ profileTokens: 0,
53
+ conversationTokens: inputTokens,
54
+ toolCallTokens: 0,
55
+ toolDefinitionTokens: 0,
56
+ executeThreshold: 65,
57
+ newWorkTokens: null,
58
+ totalInputTokens: inputTokens,
59
+ };
60
+ }
61
+
62
+ async function startServer(
63
+ dataHome: string,
64
+ directory: string,
65
+ sidebar: () => Record<string, unknown>,
66
+ ): Promise<MagicContextRpcServer> {
67
+ const server = new MagicContextRpcServer(
68
+ join(dataHome, "cortexkit", "magic-context"),
69
+ directory,
70
+ );
71
+ server.handle("sidebar-snapshot", async () => sidebar());
72
+ await server.start();
73
+ servers.push(server);
74
+ return server;
75
+ }
76
+
77
+ describe("TUI context RPC data", () => {
78
+ test("uses an unexpired sticky snapshot only for failed responses", async () => {
79
+ const dataHome = makeDataHome();
80
+ const directory = "/repo-sticky";
81
+ const sessionId = "ses_sticky";
82
+ let response: Record<string, unknown> = snapshot(sessionId, 250) as unknown as Record<
83
+ string,
84
+ unknown
85
+ >;
86
+ await startServer(dataHome, directory, () => response);
87
+ initRpcClient(directory);
88
+
89
+ expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250);
90
+ response = { error: "database busy" };
91
+ expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(250);
92
+
93
+ response = snapshot(sessionId, 0) as unknown as Record<string, unknown>;
94
+ expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(0);
95
+ response = { error: "database busy again" };
96
+ expect((await loadSidebarSnapshot(sessionId, directory)).inputTokens).toBe(0);
97
+ });
98
+
99
+ test("distinguishes a real zero compartment count from an RPC failure", async () => {
100
+ const dataHome = makeDataHome();
101
+ const directory = "/repo-count";
102
+ const server = await startServer(dataHome, directory, () => ({}));
103
+ server.handle("compartment-count", async () => ({ count: 0 }));
104
+ initRpcClient(directory);
105
+
106
+ expect(await getCompartmentCount("ses_zero")).toEqual({ ok: true, count: 0 });
107
+ server.handle("compartment-count", async () => {
108
+ throw new Error("database unavailable");
109
+ });
110
+ const failed = await getCompartmentCount("ses_zero");
111
+ expect(failed.ok).toBe(false);
112
+ });
113
+ });