@cortexkit/opencode-magic-context 0.21.7 → 0.22.0

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 (214) hide show
  1. package/README.md +116 -325
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/agents/permissions.d.ts +29 -14
  4. package/dist/agents/permissions.d.ts.map +1 -1
  5. package/dist/config/index.d.ts.map +1 -1
  6. package/dist/config/migrate-experimental.d.ts +29 -0
  7. package/dist/config/migrate-experimental.d.ts.map +1 -0
  8. package/dist/config/schema/magic-context.d.ts +80 -104
  9. package/dist/config/schema/magic-context.d.ts.map +1 -1
  10. package/dist/features/builtin-commands/commands.d.ts.map +1 -1
  11. package/dist/features/magic-context/compartment-embedding.d.ts +34 -0
  12. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -0
  13. package/dist/features/magic-context/compartment-events.d.ts +50 -0
  14. package/dist/features/magic-context/compartment-events.d.ts.map +1 -0
  15. package/dist/features/magic-context/compartment-storage.d.ts +22 -0
  16. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/queue.d.ts +13 -2
  19. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/runner.d.ts +11 -0
  21. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
  23. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  24. package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
  25. package/dist/features/magic-context/key-files/identify-key-files.d.ts +1 -1
  26. package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
  27. package/dist/features/magic-context/key-files/project-key-files.d.ts.map +1 -1
  28. package/dist/features/magic-context/key-files/read-stats.d.ts +1 -1
  29. package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
  30. package/dist/features/magic-context/memory/constants.d.ts +4 -0
  31. package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
  32. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  33. package/dist/features/magic-context/memory/index.d.ts +1 -1
  34. package/dist/features/magic-context/memory/index.d.ts.map +1 -1
  35. package/dist/features/magic-context/memory/memory-migration.d.ts +133 -0
  36. package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -0
  37. package/dist/features/magic-context/memory/project-identity.d.ts +38 -7
  38. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  39. package/dist/features/magic-context/memory/storage-memory-fts.d.ts.map +1 -1
  40. package/dist/features/magic-context/memory/storage-memory.d.ts +15 -1
  41. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  42. package/dist/features/magic-context/memory/types.d.ts +3 -1
  43. package/dist/features/magic-context/memory/types.d.ts.map +1 -1
  44. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  45. package/dist/features/magic-context/migrations.d.ts +7 -0
  46. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  47. package/dist/features/magic-context/project-docs-hash.d.ts +6 -0
  48. package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -0
  49. package/dist/features/magic-context/project-identity.d.ts +2 -0
  50. package/dist/features/magic-context/project-identity.d.ts.map +1 -0
  51. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  52. package/dist/features/magic-context/storage-db.d.ts +51 -7
  53. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  54. package/dist/features/magic-context/storage-historian-runs.d.ts +73 -0
  55. package/dist/features/magic-context/storage-historian-runs.d.ts.map +1 -0
  56. package/dist/features/magic-context/storage-identity-rekey-map.d.ts +11 -0
  57. package/dist/features/magic-context/storage-identity-rekey-map.d.ts.map +1 -0
  58. package/dist/features/magic-context/storage-m0-mutation-log.d.ts +22 -0
  59. package/dist/features/magic-context/storage-m0-mutation-log.d.ts.map +1 -0
  60. package/dist/features/magic-context/storage-memory-mutation-log.d.ts +25 -0
  61. package/dist/features/magic-context/storage-memory-mutation-log.d.ts.map +1 -0
  62. package/dist/features/magic-context/storage-meta-persisted.d.ts +5 -0
  63. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  64. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  65. package/dist/features/magic-context/storage-meta-shared.d.ts +44 -0
  66. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  67. package/dist/features/magic-context/storage-meta.d.ts +2 -1
  68. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  69. package/dist/features/magic-context/storage-project-state.d.ts +19 -0
  70. package/dist/features/magic-context/storage-project-state.d.ts.map +1 -0
  71. package/dist/features/magic-context/storage-subagent-invocations.d.ts +61 -0
  72. package/dist/features/magic-context/storage-subagent-invocations.d.ts.map +1 -0
  73. package/dist/features/magic-context/storage-tags.d.ts +21 -1
  74. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  75. package/dist/features/magic-context/storage-v22-backfill-failures.d.ts +24 -0
  76. package/dist/features/magic-context/storage-v22-backfill-failures.d.ts.map +1 -0
  77. package/dist/features/magic-context/storage.d.ts +13 -3
  78. package/dist/features/magic-context/storage.d.ts.map +1 -1
  79. package/dist/features/magic-context/subagent-token-capture.d.ts +33 -0
  80. package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -0
  81. package/dist/features/magic-context/tagger.d.ts +15 -1
  82. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  83. package/dist/features/magic-context/types.d.ts +21 -0
  84. package/dist/features/magic-context/types.d.ts.map +1 -1
  85. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  86. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  87. package/dist/features/magic-context/v22-deferred-backfill.d.ts +46 -0
  88. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -0
  89. package/dist/features/magic-context/work-metrics.d.ts +79 -0
  90. package/dist/features/magic-context/work-metrics.d.ts.map +1 -0
  91. package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
  92. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/cache-busting-signals.d.ts +9 -0
  94. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/command-handler.d.ts +13 -1
  96. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  97. package/dist/hooks/magic-context/compartment-parser.d.ts +25 -0
  98. package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/compartment-prompt.d.ts +27 -16
  100. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -0
  102. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  104. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts +6 -2
  105. package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts +9 -1
  108. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/compartment-runner-types.d.ts +68 -4
  110. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/decay-curve.d.ts +78 -0
  114. package/dist/hooks/magic-context/decay-curve.d.ts.map +1 -0
  115. package/dist/hooks/magic-context/decay-render.d.ts +67 -0
  116. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -0
  117. package/dist/hooks/magic-context/event-handler.d.ts +1 -1
  118. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  119. package/dist/hooks/magic-context/event-resolvers.d.ts +17 -0
  120. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  121. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +2 -0
  123. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -0
  124. package/dist/hooks/magic-context/hook-handlers.d.ts +3 -0
  125. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  126. package/dist/hooks/magic-context/hook.d.ts +9 -21
  127. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  128. package/dist/hooks/magic-context/inject-compartments.d.ts +126 -0
  129. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  130. package/dist/hooks/magic-context/key-files-block.d.ts.map +1 -1
  131. package/dist/hooks/magic-context/live-session-state.d.ts +9 -0
  132. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  133. package/dist/hooks/magic-context/m0-token-breakdown.d.ts +35 -0
  134. package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -0
  135. package/dist/hooks/magic-context/read-session-chunk.d.ts +9 -0
  136. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  137. package/dist/hooks/magic-context/read-session-db.d.ts +7 -0
  138. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  139. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +104 -0
  140. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -0
  141. package/dist/hooks/magic-context/reference-retrieval.d.ts +61 -0
  142. package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -0
  143. package/dist/hooks/magic-context/reference-seeds.generated.d.ts +8 -0
  144. package/dist/hooks/magic-context/reference-seeds.generated.d.ts.map +1 -0
  145. package/dist/hooks/magic-context/send-session-notification.d.ts +1 -1
  146. package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
  147. package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -6
  148. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  149. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  150. package/dist/hooks/magic-context/tokenizer-calibration.d.ts +6 -0
  151. package/dist/hooks/magic-context/tokenizer-calibration.d.ts.map +1 -1
  152. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +0 -7
  153. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  154. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +18 -0
  155. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  156. package/dist/hooks/magic-context/transform.d.ts +9 -7
  157. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  158. package/dist/hooks/magic-context/upgrade-reminder.d.ts +73 -0
  159. package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -0
  160. package/dist/index.d.ts.map +1 -1
  161. package/dist/index.js +22111 -16352
  162. package/dist/plugin/conflict-warning-hook.d.ts +13 -0
  163. package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
  164. package/dist/plugin/dream-timer.d.ts.map +1 -1
  165. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  166. package/dist/plugin/messages-transform.d.ts.map +1 -1
  167. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  168. package/dist/plugin/tool-registry.d.ts.map +1 -1
  169. package/dist/shared/announcement.d.ts +1 -1
  170. package/dist/shared/announcement.d.ts.map +1 -1
  171. package/dist/shared/rpc-client.d.ts +1 -0
  172. package/dist/shared/rpc-client.d.ts.map +1 -1
  173. package/dist/shared/rpc-notifications.d.ts +27 -5
  174. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  175. package/dist/shared/rpc-server.d.ts +1 -0
  176. package/dist/shared/rpc-server.d.ts.map +1 -1
  177. package/dist/shared/rpc-types.d.ts +32 -2
  178. package/dist/shared/rpc-types.d.ts.map +1 -1
  179. package/dist/shared/rpc-utils.d.ts +9 -0
  180. package/dist/shared/rpc-utils.d.ts.map +1 -1
  181. package/dist/shared/sqlite-helpers.d.ts +7 -7
  182. package/dist/shared/sqlite.d.ts +23 -14
  183. package/dist/shared/sqlite.d.ts.map +1 -1
  184. package/dist/shared/subagent-runner.d.ts +5 -0
  185. package/dist/shared/subagent-runner.d.ts.map +1 -1
  186. package/dist/shared/tag-transcript.d.ts +10 -1
  187. package/dist/shared/tag-transcript.d.ts.map +1 -1
  188. package/dist/tools/ctx-expand/tools.d.ts +5 -1
  189. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  190. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  191. package/dist/tui/data/context-db.d.ts +16 -1
  192. package/dist/tui/data/context-db.d.ts.map +1 -1
  193. package/package.json +2 -4
  194. package/src/shared/announcement.ts +6 -7
  195. package/src/shared/rpc-client.test.ts +49 -2
  196. package/src/shared/rpc-client.ts +19 -9
  197. package/src/shared/rpc-notifications.test.ts +54 -1
  198. package/src/shared/rpc-notifications.ts +82 -13
  199. package/src/shared/rpc-server.ts +33 -4
  200. package/src/shared/rpc-types.ts +32 -2
  201. package/src/shared/rpc-utils.ts +10 -0
  202. package/src/shared/sqlite-helpers.ts +9 -9
  203. package/src/shared/sqlite.ts +99 -80
  204. package/src/shared/subagent-runner.ts +14 -0
  205. package/src/shared/tag-transcript.test.ts +280 -0
  206. package/src/shared/tag-transcript.ts +162 -33
  207. package/src/tui/data/context-db.ts +77 -11
  208. package/src/tui/index.tsx +240 -57
  209. package/src/tui/slots/sidebar-content.tsx +415 -101
  210. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +0 -87
  211. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +0 -1
  212. package/dist/shared/native-binding.d.ts +0 -87
  213. package/dist/shared/native-binding.d.ts.map +0 -1
  214. package/src/shared/native-binding.ts +0 -311
@@ -17,6 +17,7 @@ type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
17
17
 
18
18
  export class MagicContextRpcClient {
19
19
  private port: number | null = null;
20
+ private token: string | null = null;
20
21
  private portDir: string;
21
22
  private legacyPortFilePath: string;
22
23
  private healthChecked = false;
@@ -46,7 +47,14 @@ export class MagicContextRpcClient {
46
47
  `http://127.0.0.1:${port}/rpc/${method}`,
47
48
  {
48
49
  method: "POST",
49
- headers: { "Content-Type": "application/json" },
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ // The server requires this per-process token on all
53
+ // non-health calls; read from the same port file used
54
+ // for discovery. Older servers wrote no token — send
55
+ // nothing then (they also require nothing).
56
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
57
+ },
50
58
  body: JSON.stringify(params),
51
59
  },
52
60
  );
@@ -105,13 +113,14 @@ export class MagicContextRpcClient {
105
113
  }
106
114
 
107
115
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
108
- const port = this.readPortFile();
109
- if (port) {
110
- const alive = await this.healthCheck(port);
116
+ const record = this.readPortFile();
117
+ if (record) {
118
+ const alive = await this.healthCheck(record.port);
111
119
  if (alive) {
112
- this.port = port;
120
+ this.port = record.port;
121
+ this.token = record.token ?? null;
113
122
  this.healthChecked = true;
114
- return port;
123
+ return record.port;
115
124
  }
116
125
  }
117
126
 
@@ -123,7 +132,7 @@ export class MagicContextRpcClient {
123
132
  return null;
124
133
  }
125
134
 
126
- private readPortFile(): number | null {
135
+ private readPortFile(): RpcPortFileRecord | null {
127
136
  const records: RpcPortFileRecord[] = [];
128
137
 
129
138
  try {
@@ -139,13 +148,13 @@ export class MagicContextRpcClient {
139
148
 
140
149
  if (records.length > 0) {
141
150
  records.sort((a, b) => b.started_at - a.started_at);
142
- return records[0].port;
151
+ return records[0];
143
152
  }
144
153
 
145
154
  try {
146
155
  const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
147
156
  if (record?.pid && !isPidAlive(record.pid)) return null;
148
- return record?.port ?? null;
157
+ return record;
149
158
  } catch {
150
159
  return null;
151
160
  }
@@ -174,6 +183,7 @@ export class MagicContextRpcClient {
174
183
 
175
184
  reset(): void {
176
185
  this.port = null;
186
+ this.token = null;
177
187
  this.healthChecked = false;
178
188
  }
179
189
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { drainNotifications, pushNotification } from "./rpc-notifications";
2
+ import { drainNotifications, isTuiConnected, pushNotification } from "./rpc-notifications";
3
3
 
4
4
  describe("rpc notifications", () => {
5
5
  test("keeps messages queued until the client acks their id", () => {
@@ -17,4 +17,57 @@ describe("rpc notifications", () => {
17
17
  const lastReceivedId = Math.max(...firstPoll.map((m) => m.id));
18
18
  expect(drainNotifications(lastReceivedId)).toEqual([]);
19
19
  });
20
+
21
+ test("scopes drain to the requesting session; other sessions' items survive", () => {
22
+ // drain everything left from prior tests
23
+ drainNotifications(Number.MAX_SAFE_INTEGER);
24
+
25
+ pushNotification("for-a", { action: "show-upgrade-dialog" }, "ses_A");
26
+ pushNotification("for-b", { action: "show-upgrade-dialog" }, "ses_B");
27
+ pushNotification("global", { action: "show-status-dialog" });
28
+
29
+ // Session A sees only its own item + the global one, never ses_B's.
30
+ const aPoll = drainNotifications(0, "ses_A");
31
+ expect(aPoll.map((m) => m.type).sort()).toEqual(["for-a", "global"]);
32
+
33
+ // Acking session A must NOT prune session B's still-unseen notification.
34
+ const ackId = Math.max(...aPoll.map((m) => m.id));
35
+ drainNotifications(ackId, "ses_A");
36
+ const bPoll = drainNotifications(0, "ses_B");
37
+ expect(bPoll.map((m) => m.type)).toContain("for-b");
38
+ });
39
+
40
+ test("session-less drain (legacy client) still receives all items", () => {
41
+ drainNotifications(Number.MAX_SAFE_INTEGER);
42
+ pushNotification("x", { ok: true }, "ses_1");
43
+ pushNotification("y", { ok: true }, "ses_2");
44
+ const poll = drainNotifications(0);
45
+ expect(poll.map((m) => m.type).sort()).toEqual(["x", "y"]);
46
+ });
47
+
48
+ test("isTuiConnected is per-session: a TUI on session A does not mark session B connected", () => {
49
+ // A TUI draining for tuiA must not make tuiB's producers think a TUI is
50
+ // polling for tuiB (which would route tuiB's /ctx-status, upgrade
51
+ // reminder, etc. to the dialog path and lose them in the unrelated TUI).
52
+ // Use ids no other test drains so the per-session window is unambiguous.
53
+ drainNotifications(0, "ses_tuiA_only");
54
+ expect(isTuiConnected("ses_tuiA_only")).toBe(true);
55
+ expect(isTuiConnected("ses_tuiB_never_drained")).toBe(false);
56
+ // The session-less (global) query still reports recent activity for the
57
+ // legacy callers that have no session context.
58
+ expect(isTuiConnected()).toBe(true);
59
+ });
60
+
61
+ test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
62
+ drainNotifications(Number.MAX_SAFE_INTEGER);
63
+ // One quiet session with a single pending dialog.
64
+ pushNotification("quiet-dialog", { action: "show-upgrade-dialog" }, "ses_quiet");
65
+ // A noisy session floods well past the 100 cap.
66
+ for (let i = 0; i < 200; i += 1) {
67
+ pushNotification("noise", { i }, "ses_noisy");
68
+ }
69
+ // The quiet session's newest item must survive the eviction.
70
+ const quietPoll = drainNotifications(0, "ses_quiet");
71
+ expect(quietPoll.some((m) => m.type === "quiet-dialog")).toBe(true);
72
+ });
20
73
  });
@@ -16,10 +16,22 @@ export interface RpcNotification {
16
16
 
17
17
  let queue: RpcNotification[] = [];
18
18
  let nextNotificationId = 1;
19
- // Timestamp of last drain — used to detect if TUI is actively polling.
19
+ // Timestamp of last drain — used to detect if a TUI is actively polling.
20
20
  // The TUI polls every 500ms; we consider it connected if it polled within
21
21
  // the last 3 seconds (6× the poll interval, tolerates transient delays).
22
- let lastDrainAt = 0;
22
+ //
23
+ // PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on
24
+ // session A plus an OpenCode Desktop opened on session B for the same project,
25
+ // whose newer RPC server this TUI's port discovery then selects). The TUI
26
+ // poller drains with ITS active session id, so a session is "TUI-connected"
27
+ // only if a TUI recently drained FOR THAT session. A process-global timestamp
28
+ // would make session B's producers (`/ctx-status`, upgrade reminder) take the
29
+ // TUI-dialog path because session A's TUI is polling — queuing a B-scoped
30
+ // dialog action that A's poller correctly refuses to show, so B's notice is
31
+ // lost (it also suppressed B's non-TUI fallback). Tracking drains per session
32
+ // routes each producer to the right delivery path.
33
+ const lastDrainAtBySession = new Map<string, number>();
34
+ let lastDrainAtAny = 0;
23
35
  const TUI_CONNECTED_WINDOW_MS = 3_000;
24
36
 
25
37
  /** Push a notification for TUI to pick up via polling. */
@@ -29,25 +41,82 @@ export function pushNotification(
29
41
  sessionId?: string,
30
42
  ): void {
31
43
  queue.push({ id: nextNotificationId++, type, payload, sessionId });
32
- // Cap queue size to prevent unbounded growth if TUI is not polling
44
+ // Cap queue size to prevent unbounded growth if a TUI is not draining.
45
+ // Session-FAIR eviction: a naive `slice(-50)` drops the globally-oldest
46
+ // items, so a noisy session could evict ANOTHER session's single unseen
47
+ // notification. Instead, always retain each session's newest item, then
48
+ // fill the rest of the budget with the newest overall — no session can
49
+ // starve another's pending dialog out of the window.
33
50
  if (queue.length > 100) {
34
- queue = queue.slice(-50);
51
+ const newestPerSession = new Map<string | undefined, number>();
52
+ for (const n of queue) {
53
+ const prev = newestPerSession.get(n.sessionId);
54
+ if (prev === undefined || n.id > prev) {
55
+ newestPerSession.set(n.sessionId, n.id);
56
+ }
57
+ }
58
+ const mustKeep = new Set(newestPerSession.values());
59
+ const byNewest = [...queue].sort((a, b) => b.id - a.id);
60
+ const kept: RpcNotification[] = [];
61
+ for (const n of byNewest) {
62
+ if (kept.length < 50 || mustKeep.has(n.id)) kept.push(n);
63
+ }
64
+ queue = kept.sort((a, b) => a.id - b.id);
35
65
  }
36
66
  }
37
67
 
38
68
  /** Return pending notifications after acking the client's last received id.
39
- * Updates lastDrainAt so isTuiConnected() reflects recent activity. */
40
- export function drainNotifications(lastReceivedId = 0): RpcNotification[] {
41
- lastDrainAt = Date.now();
69
+ * Updates lastDrainAt so isTuiConnected() reflects recent activity.
70
+ *
71
+ * Session scoping: when `sessionId` is provided, only notifications tagged for
72
+ * that session (or session-less/global ones) are returned and pruned — a
73
+ * notification tagged for a DIFFERENT session is never handed to this client
74
+ * and is never pruned by this client's ack. This matters because the in-memory
75
+ * queue is per-process but a TUI can end up draining a process that also serves
76
+ * OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
77
+ * newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
78
+ * so a Desktop-session upgrade-dialog action would otherwise surface in an
79
+ * unrelated TUI session. Each client also tracks its own `lastReceivedId`, so a
80
+ * global watermark prune would let session A's ack drop session B's still-unseen
81
+ * notification — scoping the prune to the acking session prevents that too.
82
+ *
83
+ * Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
84
+ * notification stays queued until a later call acks it via a higher
85
+ * `lastReceivedId`, so a lost poll response re-delivers on the next poll. */
86
+ export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
87
+ const now = Date.now();
88
+ lastDrainAtAny = now;
89
+ if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now);
90
+ const matchesClient = (notification: RpcNotification): boolean =>
91
+ sessionId === undefined ||
92
+ notification.sessionId === undefined ||
93
+ notification.sessionId === sessionId;
42
94
  if (lastReceivedId > 0) {
43
- queue = queue.filter((notification) => notification.id > lastReceivedId);
95
+ // Prune only notifications THIS client both owns (session-matched) and has
96
+ // acked (id <= lastReceivedId). Other sessions' notifications survive.
97
+ queue = queue.filter(
98
+ (notification) => !(notification.id <= lastReceivedId && matchesClient(notification)),
99
+ );
44
100
  }
45
- return [...queue];
101
+ return queue.filter(
102
+ (notification) => notification.id > lastReceivedId && matchesClient(notification),
103
+ );
46
104
  }
47
105
 
48
106
  /** Whether a TUI client is actively polling for notifications.
49
- * Returns true only if the TUI has drained within the last 3 seconds.
50
- * This prevents stale-connected state after TUI closes or disconnects. */
51
- export function isTuiConnected(): boolean {
52
- return lastDrainAt > 0 && Date.now() - lastDrainAt < TUI_CONNECTED_WINDOW_MS;
107
+ * Returns true only if a TUI has drained within the last 3 seconds.
108
+ *
109
+ * Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT
110
+ * SESSION this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
111
+ * reminder) must use to decide dialog-vs-message, so a TUI on a different
112
+ * session in the same process does not misroute their delivery. Omit it only
113
+ * for legacy/global callers that genuinely have no session context; they fall
114
+ * back to "any session recently drained" (the pre-per-session behavior). */
115
+ export function isTuiConnected(sessionId?: string): boolean {
116
+ const now = Date.now();
117
+ if (sessionId !== undefined) {
118
+ const at = lastDrainAtBySession.get(sessionId) ?? 0;
119
+ return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
120
+ }
121
+ return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS;
53
122
  }
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import {
2
3
  mkdirSync,
3
4
  readdirSync,
@@ -20,6 +21,11 @@ export class MagicContextRpcServer {
20
21
  private portFilePath: string;
21
22
  private portDir: string;
22
23
  private startedAt = Date.now();
24
+ // Unguessable per-process bearer token, published in the (user-private) port
25
+ // file and required on every non-health RPC call. Defends side-effecting
26
+ // endpoints (recomp/upgrade/dismiss) against any local process or
27
+ // browser-origin script that merely discovers/guesses the port.
28
+ private readonly token = randomBytes(32).toString("hex");
23
29
 
24
30
  constructor(storageDir: string, directory: string) {
25
31
  this.portFilePath = rpcPortFilePath(storageDir, directory);
@@ -57,7 +63,15 @@ export class MagicContextRpcServer {
57
63
  try {
58
64
  this.warnIfOtherLiveInstance();
59
65
  const dir = dirname(this.portFilePath);
60
- mkdirSync(dir, { recursive: true });
66
+ // The port file holds the per-process bearer token that
67
+ // gates side-effecting RPC endpoints (recomp/upgrade/
68
+ // dismiss). Under the default umask 0o022 a plain write
69
+ // lands at 0o644 in a 0o755 dir — world-readable, so any
70
+ // local UID could read the token and drive those endpoints,
71
+ // defeating the auth defense. Restrict both: dir 0o700,
72
+ // file 0o600. renameSync preserves the tmp file's mode, so
73
+ // the 0o600 on the write covers the final file.
74
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
61
75
  const tmpPath = `${this.portFilePath}.tmp`;
62
76
  writeFileSync(
63
77
  tmpPath,
@@ -65,8 +79,9 @@ export class MagicContextRpcServer {
65
79
  port: this.port,
66
80
  pid: process.pid,
67
81
  started_at: this.startedAt,
82
+ token: this.token,
68
83
  }),
69
- "utf-8",
84
+ { encoding: "utf-8", mode: 0o600 },
70
85
  );
71
86
  renameSync(tmpPath, this.portFilePath);
72
87
  log(`[rpc] server listening on 127.0.0.1:${this.port}`);
@@ -114,8 +129,10 @@ export class MagicContextRpcServer {
114
129
  private dispatch(req: IncomingMessage, res: ServerResponse): void {
115
130
  const url = req.url ?? "";
116
131
 
117
- // CORS headers for same-origin fetch
118
- res.setHeader("Access-Control-Allow-Origin", "*");
132
+ // No wildcard CORS: the only legitimate client is the in-process TUI
133
+ // client, which is not a browser origin. Omitting
134
+ // Access-Control-Allow-Origin makes browsers refuse to read responses,
135
+ // closing the CSRF-style read path a malicious local page could use.
119
136
 
120
137
  if (req.method === "GET" && url === "/health") {
121
138
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -129,6 +146,18 @@ export class MagicContextRpcServer {
129
146
  return;
130
147
  }
131
148
 
149
+ // Require the per-process bearer token on every side-effecting call.
150
+ // The legitimate TUI client reads it from the same port file it used to
151
+ // discover the port; a process that only guessed the port cannot.
152
+ const auth = req.headers.authorization;
153
+ const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
154
+ if (presented !== this.token) {
155
+ res.writeHead(401, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify({ error: "Unauthorized" }));
157
+ req.resume();
158
+ return;
159
+ }
160
+
132
161
  const method = url.slice(5); // strip "/rpc/"
133
162
  const handler = this.handlers.get(method);
134
163
  if (!handler) {
@@ -24,10 +24,23 @@ export interface SidebarSnapshot {
24
24
  compartmentTokens: number;
25
25
  factTokens: number;
26
26
  memoryTokens: number;
27
+ /**
28
+ * Token estimate of the injected <project-docs> block (root ARCHITECTURE.md
29
+ * + STRUCTURE.md) that lives in m[0] in v2. Part of the message stream, not
30
+ * conversation. Display layer shows this as "Docs".
31
+ */
32
+ docsTokens: number;
33
+ /**
34
+ * Token estimate of the injected <user-profile> block (promoted user
35
+ * memories) that lives in m[0] in v2. Part of the message stream, not
36
+ * conversation. Display layer shows this as "Profile".
37
+ */
38
+ profileTokens: number;
27
39
  /**
28
40
  * Token estimate of real user/assistant discussion (text + reasoning +
29
- * image parts) inside messages, excluding injected <session-history>
30
- * blocks. Display layer shows this as "Conversation".
41
+ * image parts) inside messages, excluding injected <session-history>,
42
+ * <project-docs>, and <user-profile> blocks. Display layer shows this as
43
+ * "Conversation".
31
44
  */
32
45
  conversationTokens: number;
33
46
  /**
@@ -55,6 +68,23 @@ export interface SidebarSnapshot {
55
68
  * scheduler and transform paths.
56
69
  */
57
70
  executeThreshold: number;
71
+ newWorkTokens?: number | null;
72
+ totalInputTokens?: number | null;
73
+ /**
74
+ * Live recomp / session-upgrade progress for this session, or null when no
75
+ * recomp is running (and no recent terminal state is being shown). Drives the
76
+ * sidebar "Recomp"/"Upgrade" progress bar and the /ctx-status dialog. Mirrors
77
+ * the runtime `RecompProgress` shape from compartment-runner-types.ts.
78
+ */
79
+ recompProgress?: {
80
+ phase: "recomp" | "migration" | "done" | "failed";
81
+ processedMessages: number;
82
+ totalMessages: number;
83
+ passCount: number;
84
+ compartmentsCreated: number;
85
+ message?: string;
86
+ note?: string;
87
+ } | null;
58
88
  }
59
89
 
60
90
  export interface StatusDetail extends SidebarSnapshot {
@@ -5,6 +5,15 @@ export interface RpcPortFileRecord {
5
5
  port: number;
6
6
  pid: number;
7
7
  started_at: number;
8
+ /**
9
+ * Per-process bearer token. The server requires it on all non-health RPC
10
+ * calls so a random local process or browser-origin script that merely
11
+ * discovers/guesses the port cannot drive side-effecting endpoints
12
+ * (recomp/upgrade/dismiss). Optional in the type for forward/backward
13
+ * compatibility with port files written by older builds (treated as "no
14
+ * auth required" only when the server itself didn't set one).
15
+ */
16
+ token?: string;
8
17
  }
9
18
 
10
19
  /**
@@ -56,6 +65,7 @@ export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileR
56
65
  port,
57
66
  pid,
58
67
  started_at: Number.isFinite(startedAt) ? startedAt : 0,
68
+ token: typeof parsed.token === "string" ? parsed.token : undefined,
59
69
  };
60
70
  } catch {
61
71
  return null;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Cross-runtime helpers that smooth over the small bun:sqlite ↔ better-sqlite3
3
- * API differences without leaking either library into call sites.
2
+ * Cross-runtime helpers that smooth over the small bun:sqlite ↔ node:sqlite
3
+ * API differences without leaking either backend into call sites.
4
4
  */
5
5
 
6
6
  import type { Database } from "./sqlite";
@@ -8,17 +8,17 @@ import type { Database } from "./sqlite";
8
8
  /**
9
9
  * Close a database, ignoring errors.
10
10
  *
11
- * bun:sqlite supports `db.close(throwOnError = false)`. better-sqlite3 has
12
- * only `db.close()` and throws on already-closed databases. This helper
13
- * mirrors the bun "swallow errors" semantics for both runtimes — useful in
14
- * test teardown and `finally` blocks where the caller doesn't care whether
15
- * the close succeeded.
11
+ * bun:sqlite supports `db.close(throwOnError = false)`. node:sqlite has only
12
+ * `db.close()` and throws ("database is not open") on an already-closed
13
+ * handle. This helper mirrors the bun "swallow errors" semantics for both
14
+ * runtimes — useful in test teardown and `finally` blocks where the caller
15
+ * doesn't care whether the close succeeded.
16
16
  */
17
17
  export function closeQuietly(db: Database | null | undefined): void {
18
18
  if (!db) return;
19
19
  // Just attempt close and swallow errors. bun:sqlite has no `open` property,
20
- // and better-sqlite3 throws TypeError on already-closed databases — both
21
- // are handled by the bare try/catch.
20
+ // and node:sqlite throws on an already-closed handle — both are handled by
21
+ // the bare try/catch.
22
22
  try {
23
23
  db.close();
24
24
  } catch {