@cortexkit/opencode-magic-context 0.31.5 → 0.32.1

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 (144) hide show
  1. package/README.md +3 -3
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +2 -0
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +9 -0
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/cron.d.ts +2 -2
  8. package/dist/features/magic-context/dreamer/cron.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  10. package/dist/features/magic-context/message-index-async.d.ts +6 -2
  11. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  12. package/dist/features/magic-context/message-index.d.ts +7 -4
  13. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  14. package/dist/features/magic-context/migrations.d.ts +8 -16
  15. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  16. package/dist/features/magic-context/plugin-messages.d.ts.map +1 -1
  17. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  18. package/dist/features/magic-context/session-project-backfill.d.ts +6 -3
  19. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  20. package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
  21. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  22. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -1
  23. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  24. package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
  25. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  26. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +8 -1
  27. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  28. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +10 -0
  29. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  30. package/dist/features/magic-context/smart-notes/storage.d.ts +16 -1
  31. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-clone.d.ts +34 -0
  33. package/dist/features/magic-context/storage-clone.d.ts.map +1 -0
  34. package/dist/features/magic-context/storage-db.d.ts +1 -1
  35. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  36. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -5
  37. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  38. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage-schema-helpers.d.ts +3 -4
  40. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  41. package/dist/features/magic-context/storage-tags.d.ts +11 -0
  42. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  43. package/dist/features/magic-context/storage.d.ts +2 -1
  44. package/dist/features/magic-context/storage.d.ts.map +1 -1
  45. package/dist/features/magic-context/tagger.d.ts +10 -0
  46. package/dist/features/magic-context/tagger.d.ts.map +1 -1
  47. package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -0
  48. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  49. package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
  50. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  51. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/apply-operations.d.ts +24 -0
  53. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +12 -0
  55. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -0
  56. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/compartment-trigger.d.ts +7 -1
  58. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/decay-render.d.ts +2 -0
  60. package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/emergency-drop.d.ts +1 -0
  62. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +29 -0
  64. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -0
  65. package/dist/hooks/magic-context/heuristic-cleanup.d.ts +1 -0
  66. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/historian-prompt.generated.d.ts +1 -1
  68. package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/hook.d.ts +3 -0
  70. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/inject-compartments.d.ts +8 -9
  72. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  73. package/dist/hooks/magic-context/m0-token-breakdown.d.ts +5 -4
  74. package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/read-session-chunk.d.ts +17 -1
  76. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/read-session-db.d.ts +2 -2
  78. package/dist/hooks/magic-context/read-session-raw.d.ts +32 -2
  79. package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
  80. package/dist/hooks/magic-context/shadow-sender.d.ts +300 -0
  81. package/dist/hooks/magic-context/shadow-sender.d.ts.map +1 -0
  82. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/tag-messages.d.ts +8 -0
  84. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  85. package/dist/hooks/magic-context/temporal-awareness.d.ts +3 -3
  86. package/dist/hooks/magic-context/transform-operations.d.ts +1 -1
  87. package/dist/hooks/magic-context/transform-operations.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +32 -0
  89. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/transform.d.ts +4 -1
  91. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +5418 -972
  94. package/dist/plugin/hooks/create-session-hooks.d.ts +48 -175
  95. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  96. package/dist/plugin/rpc-handlers.d.ts +3 -0
  97. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  98. package/dist/shared/models-dev-cache.d.ts +5 -0
  99. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  100. package/dist/shared/rpc-client.d.ts +3 -1
  101. package/dist/shared/rpc-client.d.ts.map +1 -1
  102. package/dist/shared/rpc-notifications.d.ts +11 -6
  103. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  104. package/dist/shared/rpc-server.d.ts +2 -2
  105. package/dist/shared/rpc-server.d.ts.map +1 -1
  106. package/dist/shared/sqlite.d.ts.map +1 -1
  107. package/dist/shared/tag-transcript.d.ts +18 -0
  108. package/dist/shared/tag-transcript.d.ts.map +1 -1
  109. package/dist/shared/tui-config.d.ts +5 -3
  110. package/dist/shared/tui-config.d.ts.map +1 -1
  111. package/dist/tools/ctx-expand/constants.d.ts +1 -1
  112. package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
  113. package/dist/tui/data/context-db.d.ts +9 -2
  114. package/dist/tui/data/context-db.d.ts.map +1 -1
  115. package/dist/tui/data/notification-socket.d.ts +4 -5
  116. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  117. package/dist/tui-compiled/data/context-db.d.ts +9 -2
  118. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  119. package/dist/tui-compiled/data/notification-socket.d.ts +4 -5
  120. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -1
  121. package/package.json +2 -1
  122. package/src/shared/models-dev-cache.test.ts +5 -0
  123. package/src/shared/models-dev-cache.ts +44 -6
  124. package/src/shared/opencode-compaction-detector.test.ts +11 -2
  125. package/src/shared/rpc-client.test.ts +213 -9
  126. package/src/shared/rpc-client.ts +48 -39
  127. package/src/shared/rpc-notifications.test.ts +26 -5
  128. package/src/shared/rpc-notifications.ts +44 -35
  129. package/src/shared/rpc-server.ts +77 -33
  130. package/src/shared/sqlite.ts +22 -3
  131. package/src/shared/tag-transcript.test.ts +253 -7
  132. package/src/shared/tag-transcript.ts +422 -114
  133. package/src/shared/tui-config.test.ts +4 -12
  134. package/src/shared/tui-config.ts +6 -6
  135. package/src/tui/data/context-db.test.ts +113 -0
  136. package/src/tui/data/context-db.ts +26 -19
  137. package/src/tui/data/notification-socket.test.ts +277 -11
  138. package/src/tui/data/notification-socket.ts +257 -86
  139. package/src/tui/index.tsx +33 -25
  140. package/src/tui/slots/sidebar-content.tsx +62 -24
  141. package/src/tui-compiled/data/context-db.ts +26 -19
  142. package/src/tui-compiled/data/notification-socket.ts +257 -86
  143. package/src/tui-compiled/index.tsx +41 -24
  144. package/src/tui-compiled/slots/sidebar-content.tsx +45 -23
@@ -6,6 +6,7 @@ import {
6
6
  clearModelsDevCache,
7
7
  getModelsDevCacheState,
8
8
  getSdkContextLimit,
9
+ getSdkInputLimit,
9
10
  refreshModelLimitsAfterAuthOnce,
10
11
  refreshModelLimitsFromApi,
11
12
  resetAuthRewarmLatchForTest,
@@ -62,7 +63,9 @@ describe("models-dev-cache (SDK-only)", () => {
62
63
  );
63
64
 
64
65
  expect(getSdkContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
66
+ expect(getSdkInputLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
65
67
  expect(getSdkContextLimit("github-copilot", "legacy-only-context")).toBe(100000);
68
+ expect(getSdkInputLimit("github-copilot", "legacy-only-context")).toBeUndefined();
66
69
  expect(getSdkContextLimit("unknown", "unknown")).toBeUndefined();
67
70
  });
68
71
 
@@ -78,6 +81,7 @@ describe("models-dev-cache (SDK-only)", () => {
78
81
  ]),
79
82
  );
80
83
  expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
84
+ expect(getSdkInputLimit("openai", "gpt-5.5")).toBe(272000);
81
85
  });
82
86
 
83
87
  test("derived experimental.modes inherit the effective (input) limit", async () => {
@@ -172,6 +176,7 @@ describe("models-dev-cache (SDK-only)", () => {
172
176
  makeClient([{ id: "openai", models: { "gpt-5.5": { limit: { input: 272000 } } } }]),
173
177
  );
174
178
  expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
179
+ expect(getSdkInputLimit("openai", "gpt-5.5")).toBe(272000);
175
180
 
176
181
  // Simulate a restart: in-memory cache gone, but the persisted file
177
182
  // remains under XDG_DATA_HOME. The next lookup seeds from disk.
@@ -56,6 +56,8 @@ export function isSaneLimit(limit: number | undefined): limit is number {
56
56
 
57
57
  interface CachedModelMetadata {
58
58
  limit?: number;
59
+ /** Provider-enforced prompt cap. Undefined when only a combined context window is known. */
60
+ inputLimit?: number;
59
61
  }
60
62
 
61
63
  /**
@@ -90,10 +92,20 @@ function loadPersistedApiCacheOnce(): void {
90
92
  persistSeedLoaded = true;
91
93
  try {
92
94
  const raw = readFileSync(persistFilePath(), "utf-8");
93
- const obj = JSON.parse(raw) as Record<string, number>;
95
+ const obj = JSON.parse(raw) as Record<
96
+ string,
97
+ number | { limit?: number; inputLimit?: number }
98
+ >;
94
99
  const map = new Map<string, CachedModelMetadata>();
95
- for (const [key, limit] of Object.entries(obj)) {
96
- if (isSaneLimit(limit)) map.set(key, { limit });
100
+ for (const [key, persisted] of Object.entries(obj)) {
101
+ const limit = typeof persisted === "number" ? persisted : persisted.limit;
102
+ const inputLimit = typeof persisted === "number" ? undefined : persisted.inputLimit;
103
+ if (isSaneLimit(limit)) {
104
+ map.set(key, {
105
+ limit,
106
+ inputLimit: isSaneLimit(inputLimit) ? inputLimit : undefined,
107
+ });
108
+ }
97
109
  }
98
110
  if (map.size > 0) {
99
111
  apiCache = map;
@@ -112,9 +124,14 @@ function loadPersistedApiCacheOnce(): void {
112
124
  * never sees a torn file (the exact failure mode we're eliminating). */
113
125
  function persistApiCache(): void {
114
126
  if (!apiCache) return;
115
- const obj: Record<string, number> = {};
127
+ const obj: Record<string, CachedModelMetadata> = {};
116
128
  for (const [key, value] of apiCache) {
117
- if (isSaneLimit(value.limit)) obj[key] = value.limit;
129
+ if (isSaneLimit(value.limit)) {
130
+ obj[key] = {
131
+ limit: value.limit,
132
+ inputLimit: isSaneLimit(value.inputLimit) ? value.inputLimit : undefined,
133
+ };
134
+ }
118
135
  }
119
136
  try {
120
137
  const dir = getMagicContextStorageDir();
@@ -157,6 +174,7 @@ function setCachedModelMetadata(
157
174
  | undefined,
158
175
  ): void {
159
176
  const limit = resolveLimit(model?.limit);
177
+ const inputLimit = model?.limit?.input;
160
178
 
161
179
  // Only cache plausible limits. A value outside [20k, 3M] is garbage (torn
162
180
  // read / unconfigured default) and must never enter the cache or get
@@ -165,7 +183,10 @@ function setCachedModelMetadata(
165
183
  return;
166
184
  }
167
185
 
168
- const value: CachedModelMetadata = { limit };
186
+ const value: CachedModelMetadata = {
187
+ limit,
188
+ inputLimit: isSaneLimit(inputLimit) ? inputLimit : undefined,
189
+ };
169
190
  cache.set(key, value);
170
191
 
171
192
  // OpenCode creates derived model IDs from experimental.modes
@@ -332,6 +353,23 @@ export function getSdkContextLimit(providerID: string, modelID: string): number
332
353
  return isSaneLimit(fromApi) ? fromApi : undefined;
333
354
  }
334
355
 
356
+ /**
357
+ * Return only a provider-declared input cap. A combined context window is useful
358
+ * for scheduling but is not safe as a fail-closed prompt boundary.
359
+ */
360
+ export function getSdkInputLimit(providerID: string, modelID: string): number | undefined {
361
+ loadPersistedApiCacheOnce();
362
+ if (!apiCache) return undefined;
363
+ const direct = apiCache.get(`${providerID}/${modelID}`)?.inputLimit;
364
+ if (isSaneLimit(direct)) return direct;
365
+ const colon = modelID.indexOf(":");
366
+ if (colon > 0) {
367
+ const tagless = apiCache.get(`${providerID}/${modelID.slice(0, colon)}`)?.inputLimit;
368
+ if (isSaneLimit(tagless)) return tagless;
369
+ }
370
+ return undefined;
371
+ }
372
+
335
373
  /**
336
374
  * Look up a model's limit in the cache, with an ollama-style tag-suffix
337
375
  * fallback. ollama invokes cloud models with a tag at runtime
@@ -6,24 +6,33 @@ import * as configDir from "./opencode-config-dir";
6
6
 
7
7
  describe("opencode-compaction-detector", () => {
8
8
  let tmpDir: string;
9
+ let previousDisableAutocompact: string | undefined;
10
+ let restoreConfigPaths: () => void;
9
11
 
10
12
  beforeEach(() => {
11
13
  tmpDir = join("/tmp", `compaction-detector-test-${Date.now()}`);
12
14
  mkdirSync(join(tmpDir, ".opencode"), { recursive: true });
15
+ previousDisableAutocompact = process.env.OPENCODE_DISABLE_AUTOCOMPACT;
13
16
  delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
14
- spyOn(configDir, "getOpenCodeConfigPaths").mockReturnValue({
17
+ const configPathsSpy = spyOn(configDir, "getOpenCodeConfigPaths").mockReturnValue({
15
18
  configJson: join(tmpDir, "user-config", "opencode.json"),
16
19
  configJsonc: join(tmpDir, "user-config", "opencode.jsonc"),
17
20
  } as ReturnType<typeof configDir.getOpenCodeConfigPaths>);
21
+ restoreConfigPaths = () => configPathsSpy.mockRestore();
18
22
  });
19
23
 
20
24
  afterEach(() => {
25
+ restoreConfigPaths();
21
26
  try {
22
27
  rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
23
28
  } catch {
24
29
  /* Ignore EBUSY on Windows */
25
30
  }
26
- delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
31
+ if (previousDisableAutocompact === undefined) {
32
+ delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
33
+ } else {
34
+ process.env.OPENCODE_DISABLE_AUTOCOMPACT = previousDisableAutocompact;
35
+ }
27
36
  });
28
37
 
29
38
  describe("#given no config exists", () => {
@@ -4,7 +4,12 @@ import { createServer } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { MagicContextRpcClient } from "./rpc-client";
7
- import { drainNotifications, isTuiConnected, pushNotification } from "./rpc-notifications";
7
+ import {
8
+ __resetNotificationStateForTests,
9
+ drainNotifications,
10
+ isTuiConnected,
11
+ pushNotification,
12
+ } from "./rpc-notifications";
8
13
  import { MagicContextRpcServer } from "./rpc-server";
9
14
  import { parseRpcPortFile, type RpcPortFileRecord, rpcPortDir, rpcPortFilePath } from "./rpc-utils";
10
15
 
@@ -20,6 +25,7 @@ afterEach(async () => {
20
25
  for (const server of servers.splice(0)) {
21
26
  await server.close();
22
27
  }
28
+ __resetNotificationStateForTests();
23
29
  for (const dir of tempDirs.splice(0)) {
24
30
  try {
25
31
  rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
@@ -51,10 +57,23 @@ function writePortFileForPid(
51
57
  port: number,
52
58
  pid: number,
53
59
  startedAt: number,
54
- ): void {
55
- const portFile = rpcPortFilePath(storageDir, directory, pid);
60
+ instanceId?: string,
61
+ token?: string,
62
+ ): string {
63
+ const portFile = rpcPortFilePath(storageDir, directory, pid, instanceId);
56
64
  mkdirSync(dirname(portFile), { recursive: true });
57
- writeFileSync(portFile, JSON.stringify({ port, pid, started_at: startedAt }), "utf-8");
65
+ writeFileSync(
66
+ portFile,
67
+ JSON.stringify({
68
+ port,
69
+ pid,
70
+ started_at: startedAt,
71
+ instance_id: instanceId,
72
+ token,
73
+ }),
74
+ "utf-8",
75
+ );
76
+ return portFile;
58
77
  }
59
78
 
60
79
  function readNewestPortRecord(storageDir: string, directory: string): RpcPortFileRecord | null {
@@ -79,8 +98,16 @@ async function waitFor(condition: () => boolean, label: string, timeoutMs = 2_00
79
98
  throw new Error(`Timed out waiting for ${label}`);
80
99
  }
81
100
 
82
- async function openSocket(port: number, token: string): Promise<WebSocket> {
83
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`);
101
+ async function openSocket(
102
+ port: number,
103
+ token: string,
104
+ legacyQueryAuth = false,
105
+ ): Promise<WebSocket> {
106
+ const ws = legacyQueryAuth
107
+ ? new WebSocket(`ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}`)
108
+ : new WebSocket(`ws://127.0.0.1:${port}/ws`, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ });
84
111
  await new Promise<void>((resolve, reject) => {
85
112
  const timeout = setTimeout(() => reject(new Error("socket open timed out")), 2_000);
86
113
  ws.addEventListener(
@@ -129,11 +156,14 @@ function waitForJsonMessage<T extends { type?: string }>(
129
156
  });
130
157
  }
131
158
 
132
- async function startRpcServer(handler: (method: string) => Response | object): Promise<TestServer> {
159
+ async function startRpcServer(
160
+ handler: (method: string) => Response | object,
161
+ health: { pid: number; instanceId?: string } = { pid: process.pid },
162
+ ): Promise<TestServer> {
133
163
  const server = createServer(async (req, res) => {
134
164
  if (req.method === "GET" && req.url === "/health") {
135
165
  res.writeHead(200, { "Content-Type": "application/json" });
136
- res.end(JSON.stringify({ ok: true }));
166
+ res.end(JSON.stringify({ ok: true, pid: health.pid, instance_id: health.instanceId }));
137
167
  return;
138
168
  }
139
169
 
@@ -238,6 +268,31 @@ describe("MagicContextRpcClient", () => {
238
268
  }
239
269
  });
240
270
 
271
+ test("accepts a frozen v0.32 websocket upgrade with query-token auth", async () => {
272
+ const storageDir = makeTempDir();
273
+ const directory = "/repo-ws-v032";
274
+ const server = new MagicContextRpcServer(storageDir, directory);
275
+ const port = await server.start();
276
+ const record = readNewestPortRecord(storageDir, directory);
277
+ expect(typeof record?.token).toBe("string");
278
+
279
+ const ws = await openSocket(port, record?.token ?? "", true);
280
+ try {
281
+ const helloAck = waitForJsonMessage(ws, (message) => message.type === "hello-ack");
282
+ ws.send(
283
+ JSON.stringify({
284
+ type: "hello",
285
+ token: record?.token,
286
+ sessionId: "ses_v032",
287
+ }),
288
+ );
289
+ expect((await helloAck).type).toBe("hello-ack");
290
+ } finally {
291
+ ws.close();
292
+ server.stop();
293
+ }
294
+ });
295
+
241
296
  test("websocket upgrade rejects missing bearer token before a socket is created", async () => {
242
297
  const storageDir = makeTempDir();
243
298
  const directory = "/repo-ws-auth";
@@ -300,6 +355,52 @@ describe("MagicContextRpcClient", () => {
300
355
  }
301
356
  });
302
357
 
358
+ test("accepts legacy cursor acknowledgements during protocol skew", async () => {
359
+ __resetNotificationStateForTests();
360
+ const storageDir = makeTempDir();
361
+ const directory = "/repo-legacy-ack";
362
+ const server = new MagicContextRpcServer(storageDir, directory);
363
+ const port = await server.start();
364
+ const record = readNewestPortRecord(storageDir, directory);
365
+ expect(typeof record?.token).toBe("string");
366
+
367
+ pushNotification("legacy-one", { ok: true }, "ses_legacy");
368
+ pushNotification("legacy-two", { ok: true }, "ses_legacy");
369
+ const queued = drainNotifications(0, "ses_legacy", { sessionOnly: true });
370
+ expect(queued).toHaveLength(2);
371
+
372
+ const ws = await openSocket(port, record?.token ?? "");
373
+ try {
374
+ const helloAck = waitForJsonMessage<{
375
+ type?: string;
376
+ instanceId?: string;
377
+ }>(ws, (message) => message.type === "hello-ack");
378
+ ws.send(
379
+ JSON.stringify({
380
+ type: "hello",
381
+ token: record?.token,
382
+ sessionId: "ses_legacy",
383
+ }),
384
+ );
385
+ expect((await helloAck).instanceId).toBe(record?.instance_id);
386
+
387
+ ws.send(
388
+ JSON.stringify({
389
+ type: "ack",
390
+ cursor: queued[0].id,
391
+ sessionId: "ses_legacy",
392
+ }),
393
+ );
394
+ await waitFor(() => {
395
+ const pending = drainNotifications(0, "ses_legacy", { sessionOnly: true });
396
+ return pending.length === 1 && pending[0].id === queued[1].id;
397
+ }, "legacy cursor acknowledgement pruning");
398
+ } finally {
399
+ ws.close();
400
+ server.stop();
401
+ }
402
+ });
403
+
303
404
  test("same-process servers keep distinct port files during overlap", async () => {
304
405
  const storageDir = makeTempDir();
305
406
  const directory = "/repo-port-collision";
@@ -319,7 +420,9 @@ describe("MagicContextRpcClient", () => {
319
420
  expect(remaining?.port).toBe(secondPort);
320
421
 
321
422
  const client = new MagicContextRpcClient(storageDir, directory);
322
- expect((await client.resolveEndpoint())?.port).toBe(secondPort);
423
+ const endpoint = await client.resolveEndpoint();
424
+ expect(endpoint?.port).toBe(secondPort);
425
+ expect(endpoint?.instanceId).toBe(remaining?.instance_id);
323
426
  } finally {
324
427
  first.stop();
325
428
  second.stop();
@@ -368,4 +471,105 @@ describe("MagicContextRpcClient", () => {
368
471
  const client = new MagicContextRpcClient(storageDir, directory);
369
472
  expect(await client.call<{ value: string }>("value")).toEqual({ value: "live" });
370
473
  });
474
+
475
+ test("discovers a frozen v0.32 health response without an instance id", async () => {
476
+ const storageDir = makeTempDir();
477
+ const directory = "/repo-v032-health";
478
+ const legacy = await startRpcServer(() => ({ value: "legacy" }), {
479
+ pid: process.pid,
480
+ });
481
+ writePortFileForPid(
482
+ storageDir,
483
+ directory,
484
+ legacy.port,
485
+ process.pid,
486
+ Date.now(),
487
+ "new-client-record",
488
+ );
489
+
490
+ const client = new MagicContextRpcClient(storageDir, directory);
491
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "legacy" });
492
+ });
493
+
494
+ test("prefers this process and validates every discovery candidate identity", async () => {
495
+ const storageDir = makeTempDir();
496
+ const directory = "/repo-affinity";
497
+ const foreign = await startRpcServer(() => ({ value: "foreign" }), {
498
+ pid: process.ppid,
499
+ instanceId: "foreign",
500
+ });
501
+ const unrelated = await startRpcServer(() => ({ value: "unrelated" }), {
502
+ pid: process.pid,
503
+ instanceId: "different-service",
504
+ });
505
+ const local = await startRpcServer(() => ({ value: "local" }), {
506
+ pid: process.pid,
507
+ instanceId: "local-healthy",
508
+ });
509
+
510
+ writePortFileForPid(
511
+ storageDir,
512
+ directory,
513
+ foreign.port,
514
+ process.ppid,
515
+ Date.now() + 20_000,
516
+ "foreign",
517
+ );
518
+ writePortFileForPid(
519
+ storageDir,
520
+ directory,
521
+ unrelated.port,
522
+ process.pid,
523
+ Date.now() + 10_000,
524
+ "local-stale",
525
+ );
526
+ writePortFileForPid(
527
+ storageDir,
528
+ directory,
529
+ local.port,
530
+ process.pid,
531
+ Date.now(),
532
+ "local-healthy",
533
+ );
534
+
535
+ const client = new MagicContextRpcClient(storageDir, directory);
536
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "local" });
537
+ });
538
+
539
+ test("resets discovery after a 401 response", async () => {
540
+ const storageDir = makeTempDir();
541
+ const directory = "/repo-reauth";
542
+ let unauthorizedRecord = "";
543
+ const unauthorized = await startRpcServer(
544
+ () => {
545
+ rmSync(unauthorizedRecord, { force: true });
546
+ return new Response("stale token", { status: 401 });
547
+ },
548
+ { pid: process.pid, instanceId: "unauthorized" },
549
+ );
550
+ const healthy = await startRpcServer(() => ({ value: "healthy" }), {
551
+ pid: process.pid,
552
+ instanceId: "healthy",
553
+ });
554
+ unauthorizedRecord = writePortFileForPid(
555
+ storageDir,
556
+ directory,
557
+ unauthorized.port,
558
+ process.pid,
559
+ Date.now() + 10_000,
560
+ "unauthorized",
561
+ "stale",
562
+ );
563
+ writePortFileForPid(
564
+ storageDir,
565
+ directory,
566
+ healthy.port,
567
+ process.pid,
568
+ Date.now(),
569
+ "healthy",
570
+ );
571
+
572
+ const client = new MagicContextRpcClient(storageDir, directory);
573
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "healthy" });
574
+ });
371
575
  });
@@ -18,6 +18,7 @@ type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
18
18
  export class MagicContextRpcClient {
19
19
  private port: number | null = null;
20
20
  private token: string | null = null;
21
+ private instanceId: string | null = null;
21
22
  private portDir: string;
22
23
  private legacyPortFilePath: string;
23
24
  private healthChecked = false;
@@ -62,7 +63,7 @@ export class MagicContextRpcClient {
62
63
  if (!response.ok) {
63
64
  const text = await response.text();
64
65
  const error = new Error(`RPC ${method} failed (${response.status}): ${text}`);
65
- if (response.status >= 500) {
66
+ if (response.status === 401 || response.status >= 500) {
66
67
  lastError = error;
67
68
  this.reset();
68
69
  continue;
@@ -101,52 +102,47 @@ export class MagicContextRpcClient {
101
102
  * channel). Reuses the same health-checked port-file discovery as `call`,
102
103
  * so the WS client and the HTTP client always agree on which server instance
103
104
  * (and token) to use. Returns null when no live server is found. */
104
- async resolveEndpoint(): Promise<{ port: number; token: string | null } | null> {
105
+ async resolveEndpoint(): Promise<{
106
+ port: number;
107
+ token: string | null;
108
+ instanceId: string | null;
109
+ } | null> {
105
110
  try {
106
- const port = await this.resolvePort();
111
+ // The socket owns reconnect backoff, so endpoint discovery performs one
112
+ // filesystem/health pass instead of nesting the HTTP client's retries.
113
+ const port = await this.resolvePort(1);
107
114
  if (port === null) return null;
108
- return { port, token: this.token };
115
+ return { port, token: this.token, instanceId: this.instanceId };
109
116
  } catch {
110
117
  return null;
111
118
  }
112
119
  }
113
120
 
114
- private async resolvePort(): Promise<number | null> {
121
+ private async resolvePort(maxAttempts = MAX_RETRIES): Promise<number | null> {
115
122
  if (this.port && this.healthChecked) {
116
123
  return this.port;
117
124
  }
118
125
 
119
- if (this.port) {
120
- const alive = await this.healthCheck(this.port);
121
- if (alive) {
126
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
127
+ for (const record of this.readPortFiles()) {
128
+ if (!(await this.healthCheck(record))) continue;
129
+ this.port = record.port;
130
+ this.token = record.token ?? null;
131
+ this.instanceId = record.instance_id ?? null;
122
132
  this.healthChecked = true;
123
- return this.port;
124
- }
125
- this.port = null;
126
- this.healthChecked = false;
127
- }
128
-
129
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
130
- const record = this.readPortFile();
131
- if (record) {
132
- const alive = await this.healthCheck(record.port);
133
- if (alive) {
134
- this.port = record.port;
135
- this.token = record.token ?? null;
136
- this.healthChecked = true;
137
- return record.port;
138
- }
133
+ return record.port;
139
134
  }
140
135
 
141
- if (attempt < MAX_RETRIES - 1) {
142
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
136
+ this.reset();
137
+ if (attempt < maxAttempts - 1) {
138
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
143
139
  }
144
140
  }
145
141
 
146
142
  return null;
147
143
  }
148
144
 
149
- private readPortFile(): RpcPortFileRecord | null {
145
+ private readPortFiles(): RpcPortFileRecord[] {
150
146
  const records: RpcPortFileRecord[] = [];
151
147
 
152
148
  try {
@@ -160,26 +156,38 @@ export class MagicContextRpcClient {
160
156
  // Directory may not exist yet. Fall back to the legacy file below.
161
157
  }
162
158
 
163
- if (records.length > 0) {
164
- records.sort((a, b) => b.started_at - a.started_at);
165
- return records[0];
166
- }
167
-
168
159
  try {
169
- const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
170
- if (record?.pid && !isPidAlive(record.pid)) return null;
171
- return record;
160
+ const legacy = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
161
+ if (legacy && (!legacy.pid || isPidAlive(legacy.pid))) records.push(legacy);
172
162
  } catch {
173
- return null;
163
+ // Legacy discovery is optional.
174
164
  }
165
+
166
+ // A TUI and its server plugin normally share a process. Prefer that exact
167
+ // process before considering another live OpenCode instance for the project.
168
+ records.sort((a, b) => {
169
+ const aLocal = a.pid === process.pid ? 1 : 0;
170
+ const bLocal = b.pid === process.pid ? 1 : 0;
171
+ return bLocal - aLocal || b.started_at - a.started_at;
172
+ });
173
+ return records;
175
174
  }
176
175
 
177
- private async healthCheck(port: number): Promise<boolean> {
176
+ private async healthCheck(record: RpcPortFileRecord): Promise<boolean> {
178
177
  try {
179
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/health`, {
178
+ const response = await this.fetchWithTimeout(`http://127.0.0.1:${record.port}/health`, {
180
179
  method: "GET",
181
180
  });
182
- return response.ok;
181
+ if (!response.ok) return false;
182
+ const body = (await response.json()) as { pid?: unknown; instance_id?: unknown };
183
+ if (body.pid !== record.pid) return false;
184
+ // v0.32 health responses predate instance ids. A missing id is the
185
+ // one-release discovery bridge; an id that is present must still match.
186
+ return (
187
+ body.instance_id === undefined ||
188
+ record.instance_id === undefined ||
189
+ body.instance_id === record.instance_id
190
+ );
183
191
  } catch {
184
192
  return false;
185
193
  }
@@ -198,6 +206,7 @@ export class MagicContextRpcClient {
198
206
  reset(): void {
199
207
  this.port = null;
200
208
  this.token = null;
209
+ this.instanceId = null;
201
210
  this.healthChecked = false;
202
211
  }
203
212
  }
@@ -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
  });