@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.
- package/README.md +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/features/magic-context/compaction-marker.d.ts +17 -0
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts +11 -0
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts +15 -0
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-identity.d.ts +11 -0
- package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +70 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +1 -0
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +1 -0
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/hooks/magic-context/cache-busting-signals.d.ts +10 -0
- package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
- package/dist/hooks/magic-context/command-handler.d.ts +2 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts +50 -0
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +1 -0
- package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts +7 -0
- package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +18 -7
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
- package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +2 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
- package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/todo-view.d.ts +102 -0
- package/dist/hooks/magic-context/todo-view.d.ts.map +1 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +11 -4
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-message-helpers.d.ts +22 -0
- package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +15 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +4 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.js +1789 -711
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts +2 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
- package/dist/shared/conflict-detector.d.ts +49 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/dist/shared/conflict-fixer.d.ts +1 -1
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/data-path.d.ts +84 -0
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/logger.d.ts +6 -0
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/model-suggestion-retry.d.ts +37 -0
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/resolve-fallbacks.d.ts +32 -0
- package/dist/shared/resolve-fallbacks.d.ts.map +1 -0
- package/dist/shared/rpc-client.d.ts +2 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +3 -2
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +3 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +1 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +13 -2
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/stable-json.d.ts +21 -0
- package/dist/shared/stable-json.d.ts.map +1 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/conflict-detector.ts +4 -4
- package/src/shared/conflict-fixer.test.ts +124 -0
- package/src/shared/conflict-fixer.ts +34 -28
- package/src/shared/data-path.test.ts +38 -0
- package/src/shared/data-path.ts +99 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/logger.ts +29 -3
- package/src/shared/model-suggestion-retry.test.ts +251 -0
- package/src/shared/model-suggestion-retry.ts +194 -6
- package/src/shared/models-dev-cache.ts +7 -7
- package/src/shared/resolve-fallbacks.test.ts +136 -0
- package/src/shared/resolve-fallbacks.ts +76 -0
- package/src/shared/rpc-client.test.ts +161 -0
- package/src/shared/rpc-client.ts +82 -22
- package/src/shared/rpc-notifications.test.ts +20 -0
- package/src/shared/rpc-notifications.ts +9 -6
- package/src/shared/rpc-server.ts +42 -4
- package/src/shared/rpc-types.ts +1 -0
- package/src/shared/rpc-utils.ts +59 -3
- package/src/shared/stable-json.test.ts +87 -0
- package/src/shared/stable-json.ts +37 -0
- package/src/shared/tag-transcript.ts +3 -2
- package/src/tui/data/context-db.ts +20 -1
- 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
|
+
});
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
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
|
|
20
|
+
private portDir: string;
|
|
21
|
+
private legacyPortFilePath: string;
|
|
11
22
|
private healthChecked = false;
|
|
12
23
|
|
|
13
24
|
constructor(storageDir: string, directory: string) {
|
|
14
|
-
this.
|
|
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
|
-
|
|
23
|
-
if (!port) {
|
|
24
|
-
throw new Error("Magic Context RPC server not available");
|
|
25
|
-
}
|
|
34
|
+
let lastError: unknown = null;
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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) {
|
package/src/shared/rpc-types.ts
CHANGED
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
export function
|
|
15
|
-
return join(storageDir, "rpc", projectHash(directory)
|
|
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
|
}
|