@cortexkit/opencode-magic-context 0.18.0 → 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 (94) 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.map +1 -1
  9. package/dist/features/magic-context/memory/embedding-identity.d.ts +11 -0
  10. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -0
  11. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  12. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  14. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  15. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  16. package/dist/features/magic-context/storage-meta-persisted.d.ts +56 -0
  17. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  18. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  19. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  20. package/dist/features/magic-context/storage.d.ts +1 -1
  21. package/dist/features/magic-context/storage.d.ts.map +1 -1
  22. package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
  23. package/dist/hooks/magic-context/cache-busting-signals.d.ts +10 -0
  24. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -0
  25. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  26. package/dist/hooks/magic-context/compaction-marker-manager.d.ts +50 -0
  27. package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
  28. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  29. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +1 -1
  30. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  32. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/compartment-runner-types.d.ts +16 -7
  34. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/compartment-runner.d.ts +7 -2
  36. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/historian-state-file.d.ts +25 -11
  39. package/dist/hooks/magic-context/historian-state-file.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/hook-handlers.d.ts +11 -4
  41. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/inject-compartments.d.ts +2 -1
  44. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/live-session-state.d.ts +3 -1
  46. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +10 -4
  48. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +15 -1
  50. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/transform.d.ts +2 -0
  52. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  53. package/dist/index.js +1177 -547
  54. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  55. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  56. package/dist/plugin/sidebar-snapshot-cache.d.ts.map +1 -1
  57. package/dist/shared/conflict-detector.d.ts +49 -0
  58. package/dist/shared/conflict-detector.d.ts.map +1 -1
  59. package/dist/shared/conflict-fixer.d.ts +1 -1
  60. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  61. package/dist/shared/data-path.d.ts +84 -0
  62. package/dist/shared/data-path.d.ts.map +1 -1
  63. package/dist/shared/logger.d.ts +6 -0
  64. package/dist/shared/logger.d.ts.map +1 -1
  65. package/dist/shared/rpc-client.d.ts +2 -1
  66. package/dist/shared/rpc-client.d.ts.map +1 -1
  67. package/dist/shared/rpc-notifications.d.ts +3 -2
  68. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  69. package/dist/shared/rpc-server.d.ts +3 -0
  70. package/dist/shared/rpc-server.d.ts.map +1 -1
  71. package/dist/shared/rpc-types.d.ts +1 -0
  72. package/dist/shared/rpc-types.d.ts.map +1 -1
  73. package/dist/shared/rpc-utils.d.ts +13 -2
  74. package/dist/shared/rpc-utils.d.ts.map +1 -1
  75. package/dist/shared/stable-json.d.ts +21 -0
  76. package/dist/shared/stable-json.d.ts.map +1 -0
  77. package/dist/tui/data/context-db.d.ts.map +1 -1
  78. package/package.json +1 -1
  79. package/src/shared/conflict-detector.ts +4 -4
  80. package/src/shared/conflict-fixer.test.ts +124 -0
  81. package/src/shared/conflict-fixer.ts +34 -28
  82. package/src/shared/data-path.test.ts +38 -0
  83. package/src/shared/data-path.ts +99 -0
  84. package/src/shared/logger.ts +29 -3
  85. package/src/shared/rpc-client.test.ts +161 -0
  86. package/src/shared/rpc-client.ts +82 -22
  87. package/src/shared/rpc-notifications.test.ts +20 -0
  88. package/src/shared/rpc-notifications.ts +9 -6
  89. package/src/shared/rpc-server.ts +42 -4
  90. package/src/shared/rpc-types.ts +1 -0
  91. package/src/shared/rpc-utils.ts +59 -3
  92. package/src/shared/stable-json.test.ts +87 -0
  93. package/src/shared/stable-json.ts +37 -0
  94. package/src/tui/data/context-db.ts +20 -1
@@ -1,8 +1,13 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
-
4
- import type { ConflictResult } from "./conflict-detector";
5
- import { readJsoncFile } from "./jsonc-parser";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse, stringify } from "comment-json";
4
+
5
+ import {
6
+ type ConflictResult,
7
+ DCP_PACKAGE_NAMES,
8
+ extractPluginName,
9
+ matchesPackageName,
10
+ } from "./conflict-detector";
6
11
  import { getOpenCodeConfigPaths } from "./opencode-config-dir";
7
12
 
8
13
  type JsonObject = Record<string, unknown>;
@@ -20,10 +25,6 @@ const OMO_CONFIG_NAMES = [
20
25
  "oh-my-opencode.json",
21
26
  ] as const;
22
27
 
23
- function ensureParentDir(filePath: string): void {
24
- mkdirSync(dirname(filePath), { recursive: true });
25
- }
26
-
27
28
  function isRecord(value: unknown): value is JsonObject {
28
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
29
30
  }
@@ -36,18 +37,19 @@ function asStringArray(value: unknown): string[] {
36
37
 
37
38
  function readConfig(filePath: string): JsonObject | null {
38
39
  if (!existsSync(filePath)) {
39
- return {};
40
+ return null;
40
41
  }
41
42
 
42
- return readJsoncFile<JsonObject>(filePath);
43
+ try {
44
+ const parsed = parse(readFileSync(filePath, "utf-8"));
45
+ return isRecord(parsed) ? parsed : null;
46
+ } catch {
47
+ return null;
48
+ }
43
49
  }
44
50
 
45
- // Intentional: conflict-fixer uses JSON.stringify (not comment-json) because this module
46
- // is imported by the TUI process which loads raw source and cannot resolve npm dependencies.
47
- // Comment preservation for config writes is handled by the CLI paths (doctor.ts, setup.ts).
48
51
  function writeConfig(filePath: string, config: JsonObject): void {
49
- ensureParentDir(filePath);
50
- writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`);
52
+ writeFileSync(filePath, `${stringify(config, null, 2)}\n`);
51
53
  }
52
54
 
53
55
  function resolveUserOpenCodeConfigPath(): string {
@@ -60,7 +62,9 @@ function collectOpenCodeConfigPaths(directory: string): string[] {
60
62
  const paths = new Set<string>();
61
63
  const userConfig = resolveUserOpenCodeConfigPath();
62
64
 
63
- paths.add(userConfig);
65
+ if (existsSync(userConfig)) {
66
+ paths.add(userConfig);
67
+ }
64
68
 
65
69
  for (const filePath of [
66
70
  join(directory, ".opencode", "opencode.jsonc"),
@@ -93,13 +97,17 @@ function collectOmoConfigPaths(directory: string): string[] {
93
97
  }
94
98
  }
95
99
 
96
- if (paths.size === 0) {
97
- paths.add(join(configDir, "oh-my-openagent.json"));
98
- }
99
-
100
100
  return [...paths];
101
101
  }
102
102
 
103
+ function filterDcpPluginEntries(entries: unknown[]): { plugins: unknown[]; removed: boolean } {
104
+ const plugins = entries.filter((entry) => {
105
+ const name = extractPluginName(entry);
106
+ return name ? !matchesPackageName(name, DCP_PACKAGE_NAMES) : true;
107
+ });
108
+ return { plugins, removed: plugins.length !== entries.length };
109
+ }
110
+
103
111
  export function fixConflicts(directory: string, conflicts: ConflictResult["conflicts"]): string[] {
104
112
  const actions: string[] = [];
105
113
  let updatedCompaction = false;
@@ -116,7 +124,7 @@ export function fixConflicts(directory: string, conflicts: ConflictResult["confl
116
124
  let changed = false;
117
125
 
118
126
  if (conflicts.compactionAuto || conflicts.compactionPrune) {
119
- const compaction = isRecord(config.compaction) ? { ...config.compaction } : {};
127
+ const compaction = isRecord(config.compaction) ? config.compaction : {};
120
128
 
121
129
  if (compaction.auto !== false) {
122
130
  compaction.auto = false;
@@ -134,13 +142,11 @@ export function fixConflicts(directory: string, conflicts: ConflictResult["confl
134
142
  }
135
143
 
136
144
  if (conflicts.dcpPlugin) {
137
- const plugins = asStringArray(config.plugin);
138
- const filteredPlugins = plugins.filter(
139
- (plugin) => !plugin.includes("opencode-dcp"),
140
- );
145
+ const plugins = Array.isArray(config.plugin) ? config.plugin : [];
146
+ const filtered = filterDcpPluginEntries(plugins);
141
147
 
142
- if (filteredPlugins.length !== plugins.length) {
143
- config.plugin = filteredPlugins;
148
+ if (filtered.removed) {
149
+ config.plugin = filtered.plugins;
144
150
  changed = true;
145
151
  removedDcpPlugin = true;
146
152
  }
@@ -8,6 +8,8 @@ import {
8
8
  getMagicContextStorageDir,
9
9
  getOpenCodeCacheDir,
10
10
  getOpenCodeStorageDir,
11
+ getProjectMagicContextDir,
12
+ getProjectMagicContextHistorianDir,
11
13
  } from "./data-path";
12
14
 
13
15
  const savedEnv = {
@@ -118,4 +120,40 @@ describe("data-path", () => {
118
120
  expect(legacy).toContain("opencode");
119
121
  expect(shared).toContain("cortexkit");
120
122
  });
123
+
124
+ test("getProjectMagicContextDir composes <project>/.opencode/magic-context", () => {
125
+ // Project-local artifacts (historian state file, failure dumps) live
126
+ // inside the project so OpenCode's external_directory permission system
127
+ // treats them as project-internal. Without this, historian's Read tool
128
+ // would trigger a permission prompt on every run when artifacts lived
129
+ // under os.tmpdir().
130
+ expect(getProjectMagicContextDir("/Users/me/Work/proj")).toBe(
131
+ path.join("/Users/me/Work/proj", ".opencode", "magic-context"),
132
+ );
133
+ });
134
+
135
+ test("getProjectMagicContextHistorianDir appends historian/", () => {
136
+ expect(getProjectMagicContextHistorianDir("/Users/me/Work/proj")).toBe(
137
+ path.join("/Users/me/Work/proj", ".opencode", "magic-context", "historian"),
138
+ );
139
+ });
140
+
141
+ test("getProjectMagicContextDir is unaffected by XDG_DATA_HOME", () => {
142
+ // Project-local paths anchor to the project directory the caller
143
+ // passes in, NOT to any user-config env var. Setting XDG_DATA_HOME
144
+ // (which changes the shared storage dir) must not change the
145
+ // project-local historian dir.
146
+ process.env.XDG_DATA_HOME = "/tmp/custom-data";
147
+ expect(getProjectMagicContextDir("/some/project")).toBe(
148
+ path.join("/some/project", ".opencode", "magic-context"),
149
+ );
150
+ });
151
+
152
+ test("getProjectMagicContextDir handles trailing slashes via path.join", () => {
153
+ // path.join normalizes redundant separators so callers don't need to
154
+ // worry about how the project directory was constructed.
155
+ expect(getProjectMagicContextDir("/some/project/")).toBe(
156
+ path.join("/some/project/", ".opencode", "magic-context"),
157
+ );
158
+ });
121
159
  });
@@ -1,10 +1,109 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
+ import { getHarness, type HarnessId } from "./harness";
3
4
 
4
5
  export function getDataDir(): string {
5
6
  return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
6
7
  }
7
8
 
9
+ /**
10
+ * Per-harness scratch directory under the OS temp dir.
11
+ *
12
+ * Layout:
13
+ * - OpenCode: `${os.tmpdir()}/opencode/magic-context/`
14
+ * - Pi: `${os.tmpdir()}/pi/magic-context/`
15
+ *
16
+ * Why a per-harness subtree of `os.tmpdir()`:
17
+ * 1. OpenCode Desktop runs as an Electron app with a permission sandbox.
18
+ * Writing to arbitrary tmp paths can trigger user-visible permission
19
+ * prompts; the `${tmpdir}/opencode/` subtree is allow-listed by
20
+ * OpenCode, so anything we put under it never asks for permission.
21
+ * 2. Splitting OpenCode from Pi keeps their logs and historian dump
22
+ * directories cleanly separated. `doctor --issue` for each harness
23
+ * reports diagnostics from the matching subtree, so an OpenCode
24
+ * issue report never includes Pi log noise (and vice versa).
25
+ * 3. Pi has no permission sandbox, so the path choice is purely
26
+ * cosmetic for Pi — it just keeps the layout symmetric.
27
+ *
28
+ * Pass an explicit `harness` only when the caller already knows the
29
+ * harness without relying on the global `setHarness()` state (e.g. the
30
+ * CLI's doctor commands, which target a specific harness regardless of
31
+ * which plugin is loaded). Production runtime callers should omit it so
32
+ * the helper picks up the boot-time harness automatically.
33
+ */
34
+ export function getMagicContextTempDir(harness: HarnessId = getHarness()): string {
35
+ return path.join(os.tmpdir(), harness, "magic-context");
36
+ }
37
+
38
+ /**
39
+ * Standard log file path the plugin writes to. Pi and OpenCode write to
40
+ * SEPARATE logs under their respective harness subtrees so a single
41
+ * machine running both harnesses doesn't interleave session traces.
42
+ *
43
+ * The plugin's buffered logger calls this on every flush rather than
44
+ * caching, so `setHarness("pi")` taking effect after module load is
45
+ * reflected in the next flush.
46
+ */
47
+ export function getMagicContextLogPath(harness: HarnessId = getHarness()): string {
48
+ return path.join(getMagicContextTempDir(harness), "magic-context.log");
49
+ }
50
+
51
+ /**
52
+ * Directory used for both historian validation-failure dumps and the
53
+ * existing-state offload XMLs that large historian/recomp passes write
54
+ * before invoking the model. Per-harness so dumps from different
55
+ * harnesses don't collide on filename and so `doctor --issue` for each
56
+ * harness reports only its own historian artifacts.
57
+ */
58
+ export function getMagicContextHistorianDir(harness: HarnessId = getHarness()): string {
59
+ return path.join(getMagicContextTempDir(harness), "historian");
60
+ }
61
+
62
+ /**
63
+ * Project-local magic-context artifact directory.
64
+ *
65
+ * Layout: `<project-directory>/.opencode/magic-context/`
66
+ *
67
+ * Used for artifacts that the historian/recomp pipeline writes during a run
68
+ * and that the model is asked to read via its native Read tool. OpenCode's
69
+ * `external_directory` permission system asks the user before reading any
70
+ * file outside the project directory or its worktree, which interrupts every
71
+ * historian run when artifacts live under `os.tmpdir()`. Writing under the
72
+ * project's own `.opencode/` subtree falls inside the project boundary and
73
+ * never triggers a permission prompt.
74
+ *
75
+ * The `.opencode/` parent dir is OpenCode's own per-project convention (used
76
+ * for project-local config, plans, dumps, plugin installs). Anchoring
77
+ * magic-context artifacts under `.opencode/magic-context/` keeps them
78
+ * co-located with related OpenCode metadata and makes them easy for users to
79
+ * locate when debugging.
80
+ *
81
+ * Logger does NOT use this — log files stay in the per-harness tmp subtree
82
+ * because they are written by the plugin process itself (no model-side Read
83
+ * tool call, no permission prompt) and span sessions/projects.
84
+ */
85
+ export function getProjectMagicContextDir(directory: string): string {
86
+ return path.join(directory, ".opencode", "magic-context");
87
+ }
88
+
89
+ /**
90
+ * Project-local historian artifact directory.
91
+ *
92
+ * Layout: `<project-directory>/.opencode/magic-context/historian/`
93
+ *
94
+ * Used for:
95
+ * - existing-state offload XMLs that long historian/recomp passes write
96
+ * before invoking the model (the model reads the file via Read tool)
97
+ * - validation-failure dump XMLs preserved for debugging
98
+ *
99
+ * Callers must `mkdirSync(dir, { recursive: true })` before writing — the
100
+ * `.opencode/` parent may not exist on a fresh project, and write failures
101
+ * here must degrade gracefully (e.g. historian falls back to inline state).
102
+ */
103
+ export function getProjectMagicContextHistorianDir(directory: string): string {
104
+ return path.join(getProjectMagicContextDir(directory), "historian");
105
+ }
106
+
8
107
  export function getOpenCodeStorageDir(): string {
9
108
  return path.join(getDataDir(), "opencode", "storage");
10
109
  }
@@ -1,8 +1,7 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
3
+ import { getMagicContextLogPath } from "./data-path";
4
4
 
5
- const logFile = path.join(os.tmpdir(), "magic-context.log");
6
5
  const isTestEnv = process.env.NODE_ENV === "test";
7
6
 
8
7
  let buffer: string[] = [];
@@ -10,6 +9,25 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
10
9
  const FLUSH_INTERVAL_MS = 500;
11
10
  const BUFFER_SIZE_LIMIT = 50;
12
11
 
12
+ // Cache the last log directory we mkdir'd successfully so we only retry the
13
+ // filesystem call when the resolved path actually changes. The path is
14
+ // re-evaluated on every flush because `setHarness("pi")` runs after module
15
+ // load on Pi; we MUST NOT freeze it at import time, or Pi's first flush
16
+ // could land in the OpenCode subtree.
17
+ let lastEnsuredDir: string | null = null;
18
+
19
+ function ensureDir(filePath: string): void {
20
+ const dir = path.dirname(filePath);
21
+ if (dir === lastEnsuredDir) return;
22
+ try {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ lastEnsuredDir = dir;
25
+ } catch {
26
+ // Intentional: logging must never throw. If mkdir fails we still
27
+ // try the append; failure there is also swallowed.
28
+ }
29
+ }
30
+
13
31
  function flush(): void {
14
32
  if (flushTimer) {
15
33
  clearTimeout(flushTimer);
@@ -19,6 +37,8 @@ function flush(): void {
19
37
  const data = buffer.join("");
20
38
  buffer = [];
21
39
  try {
40
+ const logFile = getMagicContextLogPath();
41
+ ensureDir(logFile);
22
42
  fs.appendFileSync(logFile, data);
23
43
  } catch {
24
44
  // Intentional: logging must never throw
@@ -58,8 +78,14 @@ export function sessionLog(sessionId: string, message: string, data?: unknown):
58
78
  log(`[magic-context][${sessionId}] ${message}`, data);
59
79
  }
60
80
 
81
+ /**
82
+ * Resolve the current log file path. The path is harness-aware (see
83
+ * {@link getMagicContextLogPath}) and re-evaluated on every call, so callers
84
+ * who format diagnostic output with this value always see the path the next
85
+ * flush will actually use.
86
+ */
61
87
  export function getLogFilePath(): string {
62
- return logFile;
88
+ return getMagicContextLogPath();
63
89
  }
64
90
 
65
91
  // Flush remaining buffer on process exit
@@ -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
+ });