@cortexkit/opencode-magic-context 0.36.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/{conflict-warning-hook-dw9fs934.js → conflict-warning-hook-mt0ttc4x.js} +2 -2
  3. package/dist/features/magic-context/context-authority.d.ts +12 -0
  4. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  5. package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
  6. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  7. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
  8. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
  10. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
  12. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
  14. package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
  16. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
  18. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
  21. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
  23. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  24. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  25. package/dist/features/magic-context/mural/compress-cues.d.ts +2 -0
  26. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  27. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  28. package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
  29. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
  30. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +1 -0
  31. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-clone.d.ts +6 -0
  33. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-db.d.ts +1 -1
  35. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  36. package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
  37. package/dist/features/magic-context/storage-tags.d.ts +2 -0
  38. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  39. package/dist/features/magic-context/storage.d.ts +1 -1
  40. package/dist/features/magic-context/storage.d.ts.map +1 -1
  41. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  42. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/child-session-spawn.d.ts +1 -0
  44. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
  48. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
  50. package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/inject-compartments.d.ts +8 -0
  53. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
  55. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
  57. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/module-transport.d.ts +20 -3
  59. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/read-session-formatting.d.ts +1 -0
  61. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
  63. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  64. package/dist/hooks/magic-context/strip-content.d.ts +6 -1
  65. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
  68. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  70. package/dist/{index-3nfgbsp4.js → index-1skzp4dc.js} +347 -44
  71. package/dist/{index-z8v31d85.js → index-3zqcdwqr.js} +137 -10
  72. package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
  73. package/dist/{index-37rt2bka.js → index-zp6x6s6a.js} +1 -1
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +820 -548
  76. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  77. package/dist/plugin/rust-tool-backends.d.ts +4 -0
  78. package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
  79. package/dist/{recomp-orchestrator-7wbbvf87.js → recomp-orchestrator-a0g9b52q.js} +3 -3
  80. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  81. package/dist/shared/jsonc-edit.d.ts +18 -0
  82. package/dist/shared/jsonc-edit.d.ts.map +1 -0
  83. package/dist/shared/jsonc-parser.d.ts +11 -1
  84. package/dist/shared/jsonc-parser.d.ts.map +1 -1
  85. package/dist/shared/prompt-surface.d.ts +5 -3
  86. package/dist/shared/prompt-surface.d.ts.map +1 -1
  87. package/dist/shared/rpc-utils.d.ts +16 -0
  88. package/dist/shared/rpc-utils.d.ts.map +1 -1
  89. package/dist/shared/tui-preferences.d.ts +9 -0
  90. package/dist/shared/tui-preferences.d.ts.map +1 -1
  91. package/dist/{storage-meta-session-kh5egr11.js → storage-meta-session-df9tstzr.js} +1 -1
  92. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  93. package/package.json +2 -1
  94. package/src/config/index.test.ts +23 -0
  95. package/src/config/index.ts +41 -9
  96. package/src/config/schema/magic-context.test.ts +2 -1
  97. package/src/config/schema/magic-context.ts +3 -3
  98. package/src/shared/conflict-fixer.test.ts +174 -0
  99. package/src/shared/conflict-fixer.ts +66 -70
  100. package/src/shared/jsonc-edit.ts +295 -0
  101. package/src/shared/jsonc-parser.test.ts +33 -0
  102. package/src/shared/jsonc-parser.ts +54 -2
  103. package/src/shared/prompt-surface.test.ts +53 -2
  104. package/src/shared/prompt-surface.ts +20 -9
  105. package/src/shared/rpc-utils.test.ts +36 -0
  106. package/src/shared/rpc-utils.ts +71 -0
  107. package/src/shared/tui-preferences.test.ts +52 -2
  108. package/src/shared/tui-preferences.ts +42 -17
@@ -1,7 +1,13 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
2
4
 
3
5
  import { resolveCacheTtl } from "../hooks/magic-context/event-resolvers";
4
- import { modelKeyLookupOrder, resolvePromptSurface } from "./prompt-surface";
6
+ import {
7
+ modelKeyLookupOrder,
8
+ promptSurfaceConfigIdentity,
9
+ resolvePromptSurface,
10
+ } from "./prompt-surface";
5
11
 
6
12
  describe("prompt-surface resolution", () => {
7
13
  it("keeps cache_ttl and prompt-surface routing on the same lookup walk", () => {
@@ -11,6 +17,7 @@ describe("prompt-surface resolution", () => {
11
17
  "google/*": "light" as const,
12
18
  "CaseSensitive/model": "light" as const,
13
19
  "progressive/base": "light" as const,
20
+ "bare-model": "light" as const,
14
21
  };
15
22
  const cacheTtl = {
16
23
  default: "full",
@@ -26,6 +33,7 @@ describe("prompt-surface resolution", () => {
26
33
  ["casesensitive/model", "full"],
27
34
  ["CaseSensitive/model", "light"],
28
35
  ["progressive/base-extra", "light"],
36
+ ["provider/bare-model", "light"],
29
37
  ["unknown/model", "full"],
30
38
  [undefined, "full"],
31
39
  ] as const;
@@ -39,11 +47,50 @@ describe("prompt-surface resolution", () => {
39
47
  }
40
48
  });
41
49
 
42
- it("applies exact, wildcard, then default precedence", () => {
50
+ it("derives a stable config identity independent of object key order", () => {
51
+ const left = promptSurfaceConfigIdentity({
52
+ default: "full",
53
+ models: { "provider/b": "light", "provider/a": "full" },
54
+ tool_descriptions: { ctx_search: "Search", ctx_note: "Note" },
55
+ });
56
+ const right = promptSurfaceConfigIdentity({
57
+ tool_descriptions: { ctx_note: "Note", ctx_search: "Search" },
58
+ models: { "provider/a": "full", "provider/b": "light" },
59
+ default: "full",
60
+ });
61
+ expect(left).toBe(right);
62
+ expect(
63
+ promptSurfaceConfigIdentity({ default: "light", models: { "provider/a": "full" } }),
64
+ ).not.toBe(right);
65
+ });
66
+
67
+ it("matches the Rust cache_ttl resolver over shared routing vectors", () => {
68
+ const vectors = JSON.parse(
69
+ readFileSync(
70
+ resolve(
71
+ import.meta.dir,
72
+ "../../../../crates/mc-module/testdata/cache-ttl-routing-vectors.json",
73
+ ),
74
+ "utf8",
75
+ ),
76
+ ) as {
77
+ default: string;
78
+ models: Record<string, string>;
79
+ cases: Array<{ name: string; modelKey: string; expected: string }>;
80
+ };
81
+ const config = { default: vectors.default, ...vectors.models };
82
+
83
+ for (const vector of vectors.cases) {
84
+ expect(resolveCacheTtl(config, vector.modelKey), vector.name).toBe(vector.expected);
85
+ }
86
+ });
87
+
88
+ it("applies exact, bare, wildcard, then default precedence", () => {
43
89
  const config = {
44
90
  default: "full" as const,
45
91
  models: {
46
92
  "provider/model": "light" as const,
93
+ bare: "light" as const,
47
94
  "provider/*": "full" as const,
48
95
  },
49
96
  };
@@ -52,6 +99,10 @@ describe("prompt-surface resolution", () => {
52
99
  preset: "light",
53
100
  source: "exact",
54
101
  });
102
+ expect(resolvePromptSurface(config, "provider/bare")).toEqual({
103
+ preset: "light",
104
+ source: "bare",
105
+ });
55
106
  expect(resolvePromptSurface(config, "provider/other")).toEqual({
56
107
  preset: "full",
57
108
  source: "wildcard",
@@ -13,14 +13,29 @@ export interface PromptSurfaceConfig {
13
13
  tool_descriptions?: Readonly<Record<string, string>>;
14
14
  }
15
15
 
16
- export type PromptSurfaceResolutionSource = "exact" | "wildcard" | "default";
16
+ /** Stable wire identity for the config fields that can alter a served prompt surface. */
17
+ export function promptSurfaceConfigIdentity(config: PromptSurfaceConfig | undefined): string {
18
+ return JSON.stringify({
19
+ default: config?.default ?? "full",
20
+ models: Object.entries(config?.models ?? {}).sort(([left], [right]) =>
21
+ left < right ? -1 : left > right ? 1 : 0,
22
+ ),
23
+ guidanceOverridePath: config?.guidance_override_path ?? null,
24
+ toolDescriptions: Object.entries(config?.tool_descriptions ?? {}).sort(([left], [right]) =>
25
+ left < right ? -1 : left > right ? 1 : 0,
26
+ ),
27
+ });
28
+ }
29
+
30
+ export type PromptSurfaceResolutionSource = "exact" | "bare" | "wildcard" | "default";
17
31
 
18
- /** Validate the literal provider/model and provider/* key grammar. */
32
+ /** Validate bare model, provider/model, and provider/* routing keys. */
19
33
  export function isValidPromptSurfaceModelKey(key: string): boolean {
20
34
  if (key.length === 0 || key.trim() !== key) return false;
21
35
 
22
36
  const slash = key.indexOf("/");
23
- if (slash <= 0 || slash === key.length - 1) return false;
37
+ if (slash < 0) return !key.includes("*");
38
+ if (slash === 0 || slash === key.length - 1) return false;
24
39
 
25
40
  const provider = key.slice(0, slash);
26
41
  const modelID = key.slice(slash + 1);
@@ -42,7 +57,7 @@ export function isValidPromptSurfaceModelKey(key: string): boolean {
42
57
  );
43
58
  }
44
59
 
45
- export type ModelKeyLookupSource = PromptSurfaceResolutionSource | "bare";
60
+ export type ModelKeyLookupSource = Exclude<PromptSurfaceResolutionSource, "default">;
46
61
 
47
62
  export interface ModelKeyCandidate {
48
63
  key: string;
@@ -117,11 +132,7 @@ export function resolvePromptSurface(
117
132
  const fallback = config?.default ?? "full";
118
133
  const match = resolveModelConfigValue(config?.models, modelKey);
119
134
 
120
- // Bare model keys are accepted by cache_ttl for compatibility, but the
121
- // prompt_surface schema deliberately accepts only provider/model keys and
122
- // provider wildcards. Ignore a bare entry if an unvalidated object reaches
123
- // this API so prompt routing keeps its documented grammar.
124
- if (match && match.source !== "bare") {
135
+ if (match) {
125
136
  return { preset: match.value, source: match.source };
126
137
  }
127
138
 
@@ -6,6 +6,8 @@ import type { readFileSync } from "node:fs";
6
6
  import {
7
7
  __resetRpcIdentityTestHooks,
8
8
  __setRpcIdentityTestHooks,
9
+ discoverLivePiProcessIds,
10
+ inspectLivePiProcesses,
9
11
  isPidIdentityPlausible,
10
12
  type RpcPortFileRecord,
11
13
  } from "./rpc-utils";
@@ -44,6 +46,40 @@ afterEach(() => {
44
46
  __resetRpcIdentityTestHooks();
45
47
  });
46
48
 
49
+ describe("discoverLivePiProcessIds", () => {
50
+ test("finds Pi-family harness commands while excluding the current process", () => {
51
+ __setRpcIdentityTestHooks({
52
+ processListExecFileSync: (() =>
53
+ [
54
+ ` ${process.pid} /usr/local/bin/pi`,
55
+ " 41001 /usr/local/bin/pi --model test",
56
+ " 41002 node /opt/node_modules/@mariozechner/pi-coding-agent/dist/cli.js",
57
+ " 41003 bun /opt/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js",
58
+ " 41004 /Applications/OpenCode.app/Contents/MacOS/opencode",
59
+ " 41005 node /workspace/pi-plugin/src/index.ts",
60
+ " 41006 npm install @earendil-works/pi-coding-agent",
61
+ " 41007 /usr/local/bin/omp --model test",
62
+ ].join("\n")) as typeof execFileSync,
63
+ });
64
+
65
+ expect(discoverLivePiProcessIds()).toEqual([41001, 41002, 41003, 41007]);
66
+ });
67
+
68
+ test("reports uncertainty instead of treating an unavailable process list as empty", () => {
69
+ __setRpcIdentityTestHooks({
70
+ processListExecFileSync: (() => {
71
+ throw new Error("ps unavailable");
72
+ }) as typeof execFileSync,
73
+ });
74
+
75
+ expect(inspectLivePiProcesses()).toEqual({
76
+ state: "unreadable",
77
+ processIds: [],
78
+ error: "ps unavailable",
79
+ });
80
+ });
81
+ });
82
+
47
83
  describe("isPidIdentityPlausible", () => {
48
84
  test("rejects a reused Linux PID when proc start time is substantially newer", () => {
49
85
  const readPaths: string[] = [];
@@ -67,6 +67,8 @@ const OPEN_CODE_COMMAND_MARKERS = ["opencode", "node", "bun", "electron"];
67
67
 
68
68
  let rpcIdentityReadFileSync: typeof readFileSync = readFileSync;
69
69
  let rpcIdentityExecFileSync: typeof execFileSync = execFileSync;
70
+ let rpcProcessListExecFileSync: typeof execFileSync = execFileSync;
71
+ let rpcProcessListTestOverride = false;
70
72
  let rpcIdentityPlatform: NodeJS.Platform = process.platform;
71
73
  let rpcIdentityNowMs: () => number = () => Date.now();
72
74
 
@@ -179,11 +181,14 @@ export function isPidIdentityPlausible(record: RpcPortFileRecord): boolean {
179
181
  export function __setRpcIdentityTestHooks(hooks: {
180
182
  readFileSync?: typeof readFileSync;
181
183
  execFileSync?: typeof execFileSync;
184
+ processListExecFileSync?: typeof execFileSync;
182
185
  platform?: NodeJS.Platform;
183
186
  nowMs?: () => number;
184
187
  }): void {
185
188
  rpcIdentityReadFileSync = hooks.readFileSync ?? readFileSync;
186
189
  rpcIdentityExecFileSync = hooks.execFileSync ?? execFileSync;
190
+ rpcProcessListExecFileSync = hooks.processListExecFileSync ?? execFileSync;
191
+ rpcProcessListTestOverride = hooks.processListExecFileSync !== undefined;
187
192
  rpcIdentityPlatform = hooks.platform ?? process.platform;
188
193
  rpcIdentityNowMs = hooks.nowMs ?? (() => Date.now());
189
194
  }
@@ -191,10 +196,76 @@ export function __setRpcIdentityTestHooks(hooks: {
191
196
  export function __resetRpcIdentityTestHooks(): void {
192
197
  rpcIdentityReadFileSync = readFileSync;
193
198
  rpcIdentityExecFileSync = execFileSync;
199
+ rpcProcessListExecFileSync = execFileSync;
200
+ rpcProcessListTestOverride = false;
194
201
  rpcIdentityPlatform = process.platform;
195
202
  rpcIdentityNowMs = () => Date.now();
196
203
  }
197
204
 
205
+ function commandLooksLikePi(command: string): boolean {
206
+ const normalized = command.trim().toLowerCase().replaceAll("\\", "/");
207
+ const tokens = normalized.split(/\s+/).filter(Boolean);
208
+ const executableName = (token: string | undefined): string =>
209
+ (token ?? "").split("/").at(-1) ?? "";
210
+ const first = executableName(tokens[0]).replace(/\.exe$/, "");
211
+ if (["pi", "pi.cmd", "omp", "oh-my-pi"].includes(first)) return true;
212
+ if (["node", "bun", "deno"].includes(first)) {
213
+ const script = executableName(tokens[1]);
214
+ return (
215
+ ["pi", "pi.js", "pi.mjs", "pi.cjs"].includes(script) ||
216
+ normalized.includes("pi-coding-agent")
217
+ );
218
+ }
219
+ return false;
220
+ }
221
+
222
+ /** Result of checking whether Pi/OMP processes may currently hold the shared database. */
223
+ export interface PiProcessDiscovery {
224
+ state: "known" | "unreadable";
225
+ processIds: number[];
226
+ error?: string;
227
+ }
228
+
229
+ /**
230
+ * Inspect Pi/OMP processes without converting a failed process-list probe into
231
+ * false evidence that no harness is running. Destructive maintenance callers
232
+ * use the unreadable state to fail closed; ordinary migration guards retain
233
+ * their historical best-effort process list through discoverLivePiProcessIds().
234
+ */
235
+ export function inspectLivePiProcesses(): PiProcessDiscovery {
236
+ if (process.env.NODE_ENV === "test" && !rpcProcessListTestOverride) {
237
+ return { state: "known", processIds: [] };
238
+ }
239
+ try {
240
+ const output = String(
241
+ rpcProcessListExecFileSync("ps", ["-axo", "pid=,command="], {
242
+ encoding: "utf8",
243
+ timeout: PS_PROBE_TIMEOUT_MS,
244
+ }),
245
+ );
246
+ const pids = new Set<number>();
247
+ for (const line of output.split(/\r?\n/)) {
248
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
249
+ if (!match) continue;
250
+ const pid = Number(match[1]);
251
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
252
+ if (commandLooksLikePi(match[2])) pids.add(pid);
253
+ }
254
+ return { state: "known", processIds: [...pids].sort((left, right) => left - right) };
255
+ } catch (error) {
256
+ return {
257
+ state: "unreadable",
258
+ processIds: [],
259
+ error: error instanceof Error ? error.message : String(error),
260
+ };
261
+ }
262
+ }
263
+
264
+ /** Enumerate live Pi/OMP harness processes before deciding whether migration can proceed. */
265
+ export function discoverLivePiProcessIds(): number[] {
266
+ return inspectLivePiProcesses().processIds;
267
+ }
268
+
198
269
  export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileRecord | null {
199
270
  const trimmed = content.trim();
200
271
  if (!trimmed) return null;
@@ -1,9 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { basename, join } from "node:path";
5
5
  import { parse } from "comment-json";
6
6
  import {
7
+ __resetTuiPreferencesWatchTestHooks,
8
+ __setTuiPreferencesWatchTestHooks,
7
9
  computeEffectiveOrder,
8
10
  DEFAULT_PREFS,
9
11
  DEFAULT_SLOT_ORDER,
@@ -13,6 +15,7 @@ import {
13
15
  readTuiPreferencesFile,
14
16
  resolveMagicContextPrefs,
15
17
  TUI_PREFS_FILE_ENV,
18
+ watchTuiPreferences,
16
19
  } from "./tui-preferences";
17
20
 
18
21
  let dir: string;
@@ -28,6 +31,7 @@ beforeEach(async () => {
28
31
  });
29
32
 
30
33
  afterEach(async () => {
34
+ __resetTuiPreferencesWatchTestHooks();
31
35
  for (const key of ENV_KEYS) {
32
36
  if (savedEnv[key] === undefined) delete process.env[key];
33
37
  else process.env[key] = savedEnv[key];
@@ -79,6 +83,52 @@ describe("readTuiPreferencesFile (tolerant)", () => {
79
83
  });
80
84
  });
81
85
 
86
+ describe("watchTuiPreferences", () => {
87
+ test("notifies when the initial read resolves after an atomic replacement", async () => {
88
+ const previous = `{"magic-context":{"sections":{"memory":true}}}\n`;
89
+ const next = `{"magic-context":{"sections":{"memory":false}}}\n`;
90
+ await writeFile(file, previous, "utf8");
91
+
92
+ let resolveInitialRead!: (text: string) => void;
93
+ const pendingInitialRead = new Promise<string>((resolve) => {
94
+ resolveInitialRead = resolve;
95
+ });
96
+ let emitWatchEvent!: (event: string, filename: string | null) => void;
97
+ let readCount = 0;
98
+ __setTuiPreferencesWatchTestHooks({
99
+ readFile: () => {
100
+ readCount += 1;
101
+ return readCount === 1 ? pendingInitialRead : Promise.resolve(next);
102
+ },
103
+ watch: (_directory, listener) => {
104
+ emitWatchEvent = listener;
105
+ return { close() {} };
106
+ },
107
+ });
108
+
109
+ const observed = new Promise<{ memory: boolean }>((resolve) => {
110
+ const stop = watchTuiPreferences(() => {
111
+ void readTuiPreferencesFile().then((root) => {
112
+ stop();
113
+ resolve({ memory: resolveMagicContextPrefs(root).sections.memory });
114
+ });
115
+ });
116
+ });
117
+
118
+ const replacement = `${file}.external.tmp`;
119
+ await writeFile(replacement, next, "utf8");
120
+ await rename(replacement, file);
121
+ emitWatchEvent("rename", basename(file));
122
+ resolveInitialRead(next);
123
+
124
+ const result = await Promise.race([
125
+ observed,
126
+ new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 300)),
127
+ ]);
128
+ expect(result).toEqual({ memory: false });
129
+ });
130
+ });
131
+
82
132
  describe("resolveMagicContextPrefs (per-key validation)", () => {
83
133
  test("missing key → full defaults clone", () => {
84
134
  expect(resolveMagicContextPrefs({})).toEqual(DEFAULT_PREFS);
@@ -250,6 +250,28 @@ export function queueTuiPreferenceUpdate(
250
250
 
251
251
  const WATCH_DEBOUNCE_MS = 150;
252
252
 
253
+ type WatchReadFile = (file: string) => Promise<string>;
254
+ type WatchDirectory = (
255
+ directory: string,
256
+ listener: (event: string, filename: string | null) => void,
257
+ ) => { close(): void };
258
+
259
+ let watchReadFile: WatchReadFile = (file) => readFile(file, "utf8");
260
+ let watchDirectory: WatchDirectory = (directory, listener) => watch(directory, listener);
261
+
262
+ export function __setTuiPreferencesWatchTestHooks(hooks: {
263
+ readFile?: WatchReadFile;
264
+ watch?: WatchDirectory;
265
+ }): void {
266
+ watchReadFile = hooks.readFile ?? ((file) => readFile(file, "utf8"));
267
+ watchDirectory = hooks.watch ?? ((directory, listener) => watch(directory, listener));
268
+ }
269
+
270
+ export function __resetTuiPreferencesWatchTestHooks(): void {
271
+ watchReadFile = (file) => readFile(file, "utf8");
272
+ watchDirectory = (directory, listener) => watch(directory, listener);
273
+ }
274
+
253
275
  // Watches the DIRECTORY, not the file: editors and our own atomic writes replace
254
276
  // the file via rename, which kills file-level watchers.
255
277
  //
@@ -266,16 +288,22 @@ export function watchTuiPreferences(onChange: () => void): () => void {
266
288
  const name = basename(file);
267
289
  let timer: ReturnType<typeof setTimeout> | null = null;
268
290
  let lastSeen: string | null = null;
269
- // Seed asynchronously; a real change before the seed resolves still wins
270
- // because the debounce re-reads fresh and compares against `lastSeen` (null
271
- // → does not match → fires).
272
- void readFile(file, "utf8")
273
- .then((text) => {
274
- if (lastSeen === null) lastSeen = text;
275
- })
276
- .catch(() => {});
277
291
  try {
278
- const watcher = watch(dirname(file), (_event, filename) => {
292
+ lastSeen = readFileSync(file, "utf8");
293
+ } catch {
294
+ // A missing or temporarily unreadable baseline is retried after registration.
295
+ }
296
+ const reconcile = (): void => {
297
+ void watchReadFile(file)
298
+ .catch(() => null)
299
+ .then((text) => {
300
+ if (text === null || text === lastSeen) return;
301
+ lastSeen = text;
302
+ onChange();
303
+ });
304
+ };
305
+ try {
306
+ const watcher = watchDirectory(dirname(file), (_event, filename) => {
279
307
  const isOurs =
280
308
  filename === name ||
281
309
  (filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"));
@@ -283,16 +311,13 @@ export function watchTuiPreferences(onChange: () => void): () => void {
283
311
  if (timer) clearTimeout(timer);
284
312
  timer = setTimeout(() => {
285
313
  timer = null;
286
- void readFile(file, "utf8")
287
- .catch(() => null)
288
- .then((text) => {
289
- if (text === null) return;
290
- if (text === lastSeen) return;
291
- lastSeen = text;
292
- onChange();
293
- });
314
+ reconcile();
294
315
  }, WATCH_DEBOUNCE_MS);
295
316
  });
317
+ // Reconcile after registration: a change between the baseline read and
318
+ // watcher installation is observed here, while later changes schedule
319
+ // the same reconciliation path through the watcher callback above.
320
+ reconcile();
296
321
  return () => {
297
322
  if (timer) clearTimeout(timer);
298
323
  watcher.close();