@cortexkit/opencode-magic-context 0.17.2 → 0.19.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 (130) hide show
  1. package/README.md +1 -1
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/features/magic-context/compaction-marker.d.ts +17 -0
  4. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  5. package/dist/features/magic-context/compartment-storage.d.ts +11 -0
  6. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/runner.d.ts +15 -0
  9. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  10. package/dist/features/magic-context/memory/embedding-identity.d.ts +11 -0
  11. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
  12. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  14. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  15. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  16. package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
  17. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  18. package/dist/features/magic-context/storage-meta-persisted.d.ts +70 -0
  19. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  20. package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
  21. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  22. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  23. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  24. package/dist/features/magic-context/storage.d.ts +1 -1
  25. package/dist/features/magic-context/storage.d.ts.map +1 -1
  26. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  27. package/dist/features/magic-context/types.d.ts +1 -0
  28. package/dist/features/magic-context/types.d.ts.map +1 -1
  29. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  30. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/cache-busting-signals.d.ts +10 -0
  32. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
  33. package/dist/hooks/magic-context/command-handler.d.ts +2 -0
  34. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +50 -0
  36. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +1 -0
  38. package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +7 -0
  40. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -1
  42. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  44. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/compartment-runner-types.d.ts +18 -7
  46. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
  48. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  50. package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
  51. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
  53. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/inject-compartments.d.ts +2 -1
  56. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
  58. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/todo-view.d.ts +102 -0
  61. package/dist/hooks/magic-context/todo-view.d.ts.map +1 -0
  62. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +11 -4
  63. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  64. package/dist/hooks/magic-context/transform-message-helpers.d.ts +22 -0
  65. package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +15 -1
  67. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  68. package/dist/hooks/magic-context/transform.d.ts +4 -0
  69. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  70. package/dist/index.js +1789 -711
  71. package/dist/plugin/dream-timer.d.ts.map +1 -1
  72. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  73. package/dist/plugin/rpc-handlers.d.ts +2 -1
  74. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  75. package/dist/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
  76. package/dist/shared/conflict-detector.d.ts +49 -0
  77. package/dist/shared/conflict-detector.d.ts.map +1 -1
  78. package/dist/shared/conflict-fixer.d.ts +1 -1
  79. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  80. package/dist/shared/data-path.d.ts +84 -0
  81. package/dist/shared/data-path.d.ts.map +1 -1
  82. package/dist/shared/index.d.ts +1 -0
  83. package/dist/shared/index.d.ts.map +1 -1
  84. package/dist/shared/logger.d.ts +6 -0
  85. package/dist/shared/logger.d.ts.map +1 -1
  86. package/dist/shared/model-suggestion-retry.d.ts +37 -0
  87. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  88. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  89. package/dist/shared/resolve-fallbacks.d.ts +32 -0
  90. package/dist/shared/resolve-fallbacks.d.ts.map +1 -0
  91. package/dist/shared/rpc-client.d.ts +2 -1
  92. package/dist/shared/rpc-client.d.ts.map +1 -1
  93. package/dist/shared/rpc-notifications.d.ts +3 -2
  94. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  95. package/dist/shared/rpc-server.d.ts +3 -0
  96. package/dist/shared/rpc-server.d.ts.map +1 -1
  97. package/dist/shared/rpc-types.d.ts +1 -0
  98. package/dist/shared/rpc-types.d.ts.map +1 -1
  99. package/dist/shared/rpc-utils.d.ts +13 -2
  100. package/dist/shared/rpc-utils.d.ts.map +1 -1
  101. package/dist/shared/stable-json.d.ts +21 -0
  102. package/dist/shared/stable-json.d.ts.map +1 -0
  103. package/dist/shared/tag-transcript.d.ts.map +1 -1
  104. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  105. package/dist/tui/data/context-db.d.ts.map +1 -1
  106. package/package.json +1 -1
  107. package/src/shared/conflict-detector.ts +4 -4
  108. package/src/shared/conflict-fixer.test.ts +124 -0
  109. package/src/shared/conflict-fixer.ts +34 -28
  110. package/src/shared/data-path.test.ts +38 -0
  111. package/src/shared/data-path.ts +99 -0
  112. package/src/shared/index.ts +1 -0
  113. package/src/shared/logger.ts +29 -3
  114. package/src/shared/model-suggestion-retry.test.ts +251 -0
  115. package/src/shared/model-suggestion-retry.ts +194 -6
  116. package/src/shared/models-dev-cache.ts +7 -7
  117. package/src/shared/resolve-fallbacks.test.ts +136 -0
  118. package/src/shared/resolve-fallbacks.ts +76 -0
  119. package/src/shared/rpc-client.test.ts +161 -0
  120. package/src/shared/rpc-client.ts +82 -22
  121. package/src/shared/rpc-notifications.test.ts +20 -0
  122. package/src/shared/rpc-notifications.ts +9 -6
  123. package/src/shared/rpc-server.ts +42 -4
  124. package/src/shared/rpc-types.ts +1 -0
  125. package/src/shared/rpc-utils.ts +59 -3
  126. package/src/shared/stable-json.test.ts +87 -0
  127. package/src/shared/stable-json.ts +37 -0
  128. package/src/shared/tag-transcript.ts +3 -2
  129. package/src/tui/data/context-db.ts +20 -1
  130. package/src/tui/index.tsx +114 -18
@@ -0,0 +1,76 @@
1
+ import { getAgentFallbackModels } from "./model-requirements";
2
+
3
+ /**
4
+ * Resolve the final fallback model list to attempt for an OpenCode subagent
5
+ * call.
6
+ *
7
+ * Policy (decided 2026-05-10):
8
+ * - If user configured explicit `fallback_models` in their magic-context.jsonc
9
+ * for this agent: use ONLY those. Respects user intent, no surprise
10
+ * providers.
11
+ * - If user did NOT configure any: fall back to the plugin's builtin
12
+ * provider-agnostic chain (`AGENT_MODEL_REQUIREMENTS`).
13
+ *
14
+ * The returned list does NOT include the primary model — it's the ordered
15
+ * list of *alternates* to try after the primary fails. Each entry is
16
+ * "provider/modelID" form.
17
+ *
18
+ * Duplicates and empty strings are filtered. Entries that don't match the
19
+ * "provider/modelID" shape (must contain a "/" with non-empty parts) are
20
+ * also dropped — defensive guard against malformed user config.
21
+ */
22
+ export function resolveFallbackChain(
23
+ agentName: string,
24
+ userFallbacks: readonly string[] | string | undefined,
25
+ ): string[] {
26
+ const userList = normalizeUserFallbacks(userFallbacks);
27
+
28
+ if (userList.length > 0) {
29
+ return dedupe(userList.filter(isValidModelSpec));
30
+ }
31
+
32
+ const builtin = getAgentFallbackModels(agentName);
33
+ if (!builtin || builtin.length === 0) return [];
34
+ return dedupe(builtin.filter(isValidModelSpec));
35
+ }
36
+
37
+ function normalizeUserFallbacks(userFallbacks: readonly string[] | string | undefined): string[] {
38
+ if (!userFallbacks) return [];
39
+ if (typeof userFallbacks === "string") {
40
+ const trimmed = userFallbacks.trim();
41
+ return trimmed ? [trimmed] : [];
42
+ }
43
+ return userFallbacks.map((s) => s.trim()).filter((s) => s.length > 0);
44
+ }
45
+
46
+ function isValidModelSpec(spec: string): boolean {
47
+ const slash = spec.indexOf("/");
48
+ return slash > 0 && slash < spec.length - 1;
49
+ }
50
+
51
+ function dedupe(list: string[]): string[] {
52
+ const seen = new Set<string>();
53
+ const out: string[] = [];
54
+ for (const item of list) {
55
+ if (seen.has(item)) continue;
56
+ seen.add(item);
57
+ out.push(item);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Parse a "provider/modelID" string into the OpenCode `model` object shape.
64
+ * Returns null on invalid input.
65
+ *
66
+ * Note: only splits on the FIRST "/" — modelID can legitimately contain slashes
67
+ * (e.g. `lemonade/GLM-4.7-Flash-GGUF/main`).
68
+ */
69
+ export function parseProviderModel(spec: string): { providerID: string; modelID: string } | null {
70
+ const slash = spec.indexOf("/");
71
+ if (slash < 1 || slash >= spec.length - 1) return null;
72
+ return {
73
+ providerID: spec.slice(0, slash).trim(),
74
+ modelID: spec.slice(slash + 1).trim(),
75
+ };
76
+ }
@@ -0,0 +1,161 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { MagicContextRpcClient } from "./rpc-client";
7
+ import { rpcPortFilePath } from "./rpc-utils";
8
+
9
+ interface TestServer {
10
+ port: number;
11
+ close: () => Promise<void>;
12
+ }
13
+
14
+ const tempDirs: string[] = [];
15
+ let servers: TestServer[] = [];
16
+
17
+ afterEach(async () => {
18
+ for (const server of servers.splice(0)) {
19
+ await server.close();
20
+ }
21
+ for (const dir of tempDirs.splice(0)) {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ function makeTempDir(): string {
27
+ const dir = mkdtempSync(join(tmpdir(), "mc-rpc-client-"));
28
+ tempDirs.push(dir);
29
+ return dir;
30
+ }
31
+
32
+ function writePortFile(storageDir: string, directory: string, port: number): void {
33
+ const portFile = rpcPortFilePath(storageDir, directory);
34
+ mkdirSync(dirname(portFile), { recursive: true });
35
+ writeFileSync(
36
+ portFile,
37
+ JSON.stringify({ port, pid: process.pid, started_at: Date.now() }),
38
+ "utf-8",
39
+ );
40
+ }
41
+
42
+ function writePortFileForPid(
43
+ storageDir: string,
44
+ directory: string,
45
+ port: number,
46
+ pid: number,
47
+ startedAt: number,
48
+ ): void {
49
+ const portFile = rpcPortFilePath(storageDir, directory, pid);
50
+ mkdirSync(dirname(portFile), { recursive: true });
51
+ writeFileSync(portFile, JSON.stringify({ port, pid, started_at: startedAt }), "utf-8");
52
+ }
53
+
54
+ async function startRpcServer(handler: (method: string) => Response | object): Promise<TestServer> {
55
+ const server = createServer(async (req, res) => {
56
+ if (req.method === "GET" && req.url === "/health") {
57
+ res.writeHead(200, { "Content-Type": "application/json" });
58
+ res.end(JSON.stringify({ ok: true }));
59
+ return;
60
+ }
61
+
62
+ if (req.method === "POST" && req.url?.startsWith("/rpc/")) {
63
+ const method = req.url.slice("/rpc/".length);
64
+ const result = handler(method);
65
+ if (result instanceof Response) {
66
+ res.writeHead(result.status, { "Content-Type": "application/json" });
67
+ res.end(await result.text());
68
+ return;
69
+ }
70
+ res.writeHead(200, { "Content-Type": "application/json" });
71
+ res.end(JSON.stringify(result));
72
+ return;
73
+ }
74
+
75
+ res.writeHead(404);
76
+ res.end("Not Found");
77
+ });
78
+
79
+ await new Promise<void>((resolve, reject) => {
80
+ server.once("error", reject);
81
+ server.listen(0, "127.0.0.1", () => resolve());
82
+ });
83
+ const addr = server.address();
84
+ if (!addr || typeof addr === "string") throw new Error("failed to bind test server");
85
+
86
+ const testServer = {
87
+ port: addr.port,
88
+ close: () =>
89
+ new Promise<void>((resolve, reject) => {
90
+ server.close((err) => (err ? reject(err) : resolve()));
91
+ }),
92
+ };
93
+ servers.push(testServer);
94
+ return testServer;
95
+ }
96
+
97
+ async function closeServer(server: TestServer): Promise<void> {
98
+ servers = servers.filter((s) => s !== server);
99
+ await server.close();
100
+ }
101
+
102
+ describe("MagicContextRpcClient", () => {
103
+ test("re-reads the port file after the cached server restarts on a new port", async () => {
104
+ const storageDir = makeTempDir();
105
+ const directory = "/repo";
106
+ const client = new MagicContextRpcClient(storageDir, directory);
107
+
108
+ const first = await startRpcServer(() => ({ value: "first" }));
109
+ writePortFile(storageDir, directory, first.port);
110
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "first" });
111
+
112
+ await closeServer(first);
113
+ const second = await startRpcServer(() => ({ value: "second" }));
114
+ writePortFile(storageDir, directory, second.port);
115
+
116
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "second" });
117
+ });
118
+
119
+ test("gives up when the port file points at a dead server", async () => {
120
+ const storageDir = makeTempDir();
121
+ const directory = "/repo";
122
+ const dead = await startRpcServer(() => ({ ok: true }));
123
+ const port = dead.port;
124
+ await closeServer(dead);
125
+ writePortFile(storageDir, directory, port);
126
+
127
+ const client = new MagicContextRpcClient(storageDir, directory);
128
+ await expect(client.call("value")).rejects.toThrow(
129
+ "Magic Context RPC server not available",
130
+ );
131
+ }, 20_000);
132
+
133
+ test("re-resolves and retries transient 5xx responses", async () => {
134
+ const storageDir = makeTempDir();
135
+ const directory = "/repo";
136
+ let calls = 0;
137
+ const server = await startRpcServer(() => {
138
+ calls++;
139
+ if (calls === 1) {
140
+ return new Response(JSON.stringify({ error: "warming up" }), { status: 503 });
141
+ }
142
+ return { value: "ok" };
143
+ });
144
+ writePortFile(storageDir, directory, server.port);
145
+
146
+ const client = new MagicContextRpcClient(storageDir, directory);
147
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "ok" });
148
+ expect(calls).toBe(2);
149
+ });
150
+
151
+ test("ignores newer stale pid files and discovers the latest live instance", async () => {
152
+ const storageDir = makeTempDir();
153
+ const directory = "/repo";
154
+ const live = await startRpcServer(() => ({ value: "live" }));
155
+ writePortFileForPid(storageDir, directory, 65535, 999_999_999, Date.now() + 10_000);
156
+ writePortFileForPid(storageDir, directory, live.port, process.pid, Date.now());
157
+
158
+ const client = new MagicContextRpcClient(storageDir, directory);
159
+ expect(await client.call<{ value: string }>("value")).toEqual({ value: "live" });
160
+ });
161
+ });
@@ -1,17 +1,29 @@
1
- import { readFileSync } from "node:fs";
2
- import { rpcPortFilePath } from "./rpc-utils";
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ isPidAlive,
5
+ legacyRpcPortFilePath,
6
+ parseRpcPortFile,
7
+ type RpcPortFileRecord,
8
+ rpcPortDir,
9
+ } from "./rpc-utils";
3
10
 
4
11
  const MAX_RETRIES = 10;
5
12
  const RETRY_DELAY_MS = 500;
6
13
  const REQUEST_TIMEOUT_MS = 5000;
14
+ const MAX_RERESOLVE_ATTEMPTS = 3;
15
+ const NON_RETRYABLE_RPC_ERROR = Symbol("nonRetryableRpcError");
16
+ type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
7
17
 
8
18
  export class MagicContextRpcClient {
9
19
  private port: number | null = null;
10
- private portFilePath: string;
20
+ private portDir: string;
21
+ private legacyPortFilePath: string;
11
22
  private healthChecked = false;
12
23
 
13
24
  constructor(storageDir: string, directory: string) {
14
- this.portFilePath = rpcPortFilePath(storageDir, directory);
25
+ this.portDir = rpcPortDir(storageDir, directory);
26
+ this.legacyPortFilePath = legacyRpcPortFilePath(storageDir, directory);
15
27
  }
16
28
 
17
29
  /** Call an RPC method. Retries port resolution if the server isn't ready yet. */
@@ -19,23 +31,52 @@ export class MagicContextRpcClient {
19
31
  method: string,
20
32
  params: Record<string, unknown> = {},
21
33
  ): Promise<T> {
22
- const port = await this.resolvePort();
23
- if (!port) {
24
- throw new Error("Magic Context RPC server not available");
25
- }
34
+ let lastError: unknown = null;
26
35
 
27
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/rpc/${method}`, {
28
- method: "POST",
29
- headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify(params),
31
- });
36
+ for (let attempt = 0; attempt < MAX_RERESOLVE_ATTEMPTS; attempt++) {
37
+ const port = await this.resolvePort();
38
+ if (!port) {
39
+ lastError = new Error("Magic Context RPC server not available");
40
+ this.reset();
41
+ continue;
42
+ }
43
+
44
+ try {
45
+ const response = await this.fetchWithTimeout(
46
+ `http://127.0.0.1:${port}/rpc/${method}`,
47
+ {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify(params),
51
+ },
52
+ );
53
+
54
+ if (!response.ok) {
55
+ const text = await response.text();
56
+ const error = new Error(`RPC ${method} failed (${response.status}): ${text}`);
57
+ if (response.status >= 500) {
58
+ lastError = error;
59
+ this.reset();
60
+ continue;
61
+ }
62
+ (error as NonRetryableRpcError)[NON_RETRYABLE_RPC_ERROR] = true;
63
+ throw error;
64
+ }
32
65
 
33
- if (!response.ok) {
34
- const text = await response.text();
35
- throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
66
+ return (await response.json()) as T;
67
+ } catch (err) {
68
+ if (isNonRetryableRpcError(err)) {
69
+ throw err;
70
+ }
71
+ lastError = err;
72
+ this.reset();
73
+ }
36
74
  }
37
75
 
38
- return (await response.json()) as T;
76
+ if (lastError instanceof Error) {
77
+ throw lastError;
78
+ }
79
+ throw new Error("Magic Context RPC server not available");
39
80
  }
40
81
 
41
82
  /** Check if the RPC server is reachable. */
@@ -83,13 +124,28 @@ export class MagicContextRpcClient {
83
124
  }
84
125
 
85
126
  private readPortFile(): number | null {
127
+ const records: RpcPortFileRecord[] = [];
128
+
86
129
  try {
87
- const content = readFileSync(this.portFilePath, "utf-8").trim();
88
- const port = Number.parseInt(content, 10);
89
- if (Number.isNaN(port) || port <= 0 || port > 65535) {
90
- return null;
130
+ for (const entry of readdirSync(this.portDir)) {
131
+ if (!entry.startsWith("port-") || !entry.endsWith(".json")) continue;
132
+ const record = parseRpcPortFile(readFileSync(join(this.portDir, entry), "utf-8"));
133
+ if (!record || !isPidAlive(record.pid)) continue;
134
+ records.push(record);
91
135
  }
92
- return port;
136
+ } catch {
137
+ // Directory may not exist yet. Fall back to the legacy file below.
138
+ }
139
+
140
+ if (records.length > 0) {
141
+ records.sort((a, b) => b.started_at - a.started_at);
142
+ return records[0].port;
143
+ }
144
+
145
+ try {
146
+ const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
147
+ if (record?.pid && !isPidAlive(record.pid)) return null;
148
+ return record?.port ?? null;
93
149
  } catch {
94
150
  return null;
95
151
  }
@@ -121,3 +177,7 @@ export class MagicContextRpcClient {
121
177
  this.healthChecked = false;
122
178
  }
123
179
  }
180
+
181
+ function isNonRetryableRpcError(err: unknown): err is NonRetryableRpcError {
182
+ return typeof err === "object" && err !== null && NON_RETRYABLE_RPC_ERROR in err;
183
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { drainNotifications, pushNotification } from "./rpc-notifications";
3
+
4
+ describe("rpc notifications", () => {
5
+ test("keeps messages queued until the client acks their id", () => {
6
+ const initial = drainNotifications(Number.MAX_SAFE_INTEGER);
7
+ expect(initial).toEqual([]);
8
+
9
+ pushNotification("one", { ok: true }, "ses_1");
10
+ const firstPoll = drainNotifications();
11
+ expect(firstPoll).toHaveLength(1);
12
+ expect(firstPoll[0].type).toBe("one");
13
+
14
+ const retryPoll = drainNotifications();
15
+ expect(retryPoll.map((m) => m.id)).toEqual(firstPoll.map((m) => m.id));
16
+
17
+ const lastReceivedId = Math.max(...firstPoll.map((m) => m.id));
18
+ expect(drainNotifications(lastReceivedId)).toEqual([]);
19
+ });
20
+ });
@@ -8,12 +8,14 @@
8
8
  */
9
9
 
10
10
  export interface RpcNotification {
11
+ id: number;
11
12
  type: string;
12
13
  payload: Record<string, unknown>;
13
14
  sessionId?: string;
14
15
  }
15
16
 
16
17
  let queue: RpcNotification[] = [];
18
+ let nextNotificationId = 1;
17
19
  // Timestamp of last drain — used to detect if TUI is actively polling.
18
20
  // The TUI polls every 500ms; we consider it connected if it polled within
19
21
  // the last 3 seconds (6× the poll interval, tolerates transient delays).
@@ -26,20 +28,21 @@ export function pushNotification(
26
28
  payload: Record<string, unknown>,
27
29
  sessionId?: string,
28
30
  ): void {
29
- queue.push({ type, payload, sessionId });
31
+ queue.push({ id: nextNotificationId++, type, payload, sessionId });
30
32
  // Cap queue size to prevent unbounded growth if TUI is not polling
31
33
  if (queue.length > 100) {
32
34
  queue = queue.slice(-50);
33
35
  }
34
36
  }
35
37
 
36
- /** Drain and return all pending notifications atomically.
38
+ /** Return pending notifications after acking the client's last received id.
37
39
  * Updates lastDrainAt so isTuiConnected() reflects recent activity. */
38
- export function drainNotifications(): RpcNotification[] {
40
+ export function drainNotifications(lastReceivedId = 0): RpcNotification[] {
39
41
  lastDrainAt = Date.now();
40
- const result = queue;
41
- queue = [];
42
- return result;
42
+ if (lastReceivedId > 0) {
43
+ queue = queue.filter((notification) => notification.id > lastReceivedId);
44
+ }
45
+ return [...queue];
43
46
  }
44
47
 
45
48
  /** Whether a TUI client is actively polling for notifications.
@@ -1,8 +1,15 @@
1
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ writeFileSync,
8
+ } from "node:fs";
2
9
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
10
  import { dirname } from "node:path";
4
11
  import { log } from "./logger";
5
- import { rpcPortFilePath } from "./rpc-utils";
12
+ import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc-utils";
6
13
 
7
14
  type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
8
15
 
@@ -11,9 +18,12 @@ export class MagicContextRpcServer {
11
18
  private port = 0;
12
19
  private handlers = new Map<string, RpcHandler>();
13
20
  private portFilePath: string;
21
+ private portDir: string;
22
+ private startedAt = Date.now();
14
23
 
15
24
  constructor(storageDir: string, directory: string) {
16
25
  this.portFilePath = rpcPortFilePath(storageDir, directory);
26
+ this.portDir = rpcPortDir(storageDir, directory);
17
27
  }
18
28
 
19
29
  /** Register an RPC method handler. */
@@ -40,12 +50,24 @@ export class MagicContextRpcServer {
40
50
  this.port = addr.port;
41
51
  this.server = server;
42
52
 
43
- // Write port file atomically
53
+ // Write a per-process port file atomically. Multi-instance
54
+ // OpenCode is supported: TUI discovery scans all live pid files
55
+ // and picks the most recent instead of cross-wiring via one
56
+ // shared project file.
44
57
  try {
58
+ this.warnIfOtherLiveInstance();
45
59
  const dir = dirname(this.portFilePath);
46
60
  mkdirSync(dir, { recursive: true });
47
61
  const tmpPath = `${this.portFilePath}.tmp`;
48
- writeFileSync(tmpPath, String(this.port), "utf-8");
62
+ writeFileSync(
63
+ tmpPath,
64
+ JSON.stringify({
65
+ port: this.port,
66
+ pid: process.pid,
67
+ started_at: this.startedAt,
68
+ }),
69
+ "utf-8",
70
+ );
49
71
  renameSync(tmpPath, this.portFilePath);
50
72
  log(`[rpc] server listening on 127.0.0.1:${this.port}`);
51
73
  } catch (err) {
@@ -60,6 +82,22 @@ export class MagicContextRpcServer {
60
82
  });
61
83
  }
62
84
 
85
+ private warnIfOtherLiveInstance(): void {
86
+ try {
87
+ for (const entry of readdirSync(this.portDir)) {
88
+ if (!entry.startsWith("port-") || !entry.endsWith(".json")) continue;
89
+ const record = parseRpcPortFile(readFileSync(`${this.portDir}/${entry}`, "utf-8"));
90
+ if (!record || record.pid === process.pid || !isPidAlive(record.pid)) continue;
91
+ log(
92
+ `[rpc] another Magic Context RPC server is active for this project (pid ${record.pid}, port ${record.port}); starting separate instance on a new port`,
93
+ );
94
+ return;
95
+ }
96
+ } catch {
97
+ // No discovery directory yet, or unreadable stale file. Not fatal.
98
+ }
99
+ }
100
+
63
101
  /** Stop the server and clean up port file. */
64
102
  stop(): void {
65
103
  if (this.server) {
@@ -84,6 +84,7 @@ export interface StatusDetail extends SidebarSnapshot {
84
84
  }
85
85
 
86
86
  export interface RpcNotificationMessage {
87
+ id: number;
87
88
  type: string;
88
89
  payload: Record<string, unknown>;
89
90
  sessionId?: string;
@@ -1,6 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
 
4
+ export interface RpcPortFileRecord {
5
+ port: number;
6
+ pid: number;
7
+ started_at: number;
8
+ }
9
+
4
10
  /**
5
11
  * Stable hash for a project directory — scopes RPC port files per-project
6
12
  * so multiple OpenCode instances don't collide.
@@ -10,7 +16,57 @@ export function projectHash(directory: string): string {
10
16
  return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
11
17
  }
12
18
 
13
- /** Per-project RPC port file path. */
14
- export function rpcPortFilePath(storageDir: string, directory: string): string {
15
- return join(storageDir, "rpc", projectHash(directory), "port");
19
+ /** Directory containing per-process RPC discovery files for a project. */
20
+ export function rpcPortDir(storageDir: string, directory: string): string {
21
+ return join(storageDir, "rpc", projectHash(directory));
22
+ }
23
+
24
+ /** Per-process RPC port file path. */
25
+ export function rpcPortFilePath(storageDir: string, directory: string, pid = process.pid): string {
26
+ return join(rpcPortDir(storageDir, directory), `port-${pid}.json`);
27
+ }
28
+
29
+ /** Legacy single-port file used by v0.18.0 and earlier. */
30
+ export function legacyRpcPortFilePath(storageDir: string, directory: string): string {
31
+ return join(rpcPortDir(storageDir, directory), "port");
32
+ }
33
+
34
+ export function isPidAlive(pid: number): boolean {
35
+ if (!Number.isInteger(pid) || pid <= 0) return false;
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch (err) {
40
+ return (err as NodeJS.ErrnoException).code === "EPERM";
41
+ }
42
+ }
43
+
44
+ export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileRecord | null {
45
+ const trimmed = content.trim();
46
+ if (!trimmed) return null;
47
+
48
+ if (trimmed.startsWith("{")) {
49
+ try {
50
+ const parsed = JSON.parse(trimmed) as Partial<RpcPortFileRecord>;
51
+ const port = Number(parsed.port);
52
+ const pid = Number(parsed.pid);
53
+ const startedAt = Number(parsed.started_at);
54
+ if (!isValidPort(port) || !Number.isInteger(pid) || pid <= 0) return null;
55
+ return {
56
+ port,
57
+ pid,
58
+ started_at: Number.isFinite(startedAt) ? startedAt : 0,
59
+ };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ const port = Number.parseInt(trimmed, 10);
66
+ if (!isValidPort(port)) return null;
67
+ return { port, pid: fallbackPid, started_at: 0 };
68
+ }
69
+
70
+ function isValidPort(port: number): boolean {
71
+ return Number.isInteger(port) && port > 0 && port <= 65535;
16
72
  }