@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.
- package/dist/config/index.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-dw9fs934.js → conflict-warning-hook-mt0ttc4x.js} +2 -2
- package/dist/features/magic-context/context-authority.d.ts +12 -0
- package/dist/features/magic-context/context-authority.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
- package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
- package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
- package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/mural/compress-cues.d.ts +2 -0
- package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
- package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
- package/dist/features/magic-context/storage-clone.d.ts +6 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +2 -0
- package/dist/features/magic-context/storage-tags.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/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/child-session-spawn.d.ts +1 -0
- package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
- package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
- package/dist/hooks/magic-context/embed-session-state.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 +8 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
- package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-state-sync.d.ts +1 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts +20 -3
- package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-formatting.d.ts +1 -0
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts +3 -0
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +6 -1
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/{index-3nfgbsp4.js → index-1skzp4dc.js} +347 -44
- package/dist/{index-z8v31d85.js → index-3zqcdwqr.js} +137 -10
- package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
- package/dist/{index-37rt2bka.js → index-zp6x6s6a.js} +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +820 -548
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rust-tool-backends.d.ts +4 -0
- package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-7wbbvf87.js → recomp-orchestrator-a0g9b52q.js} +3 -3
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/jsonc-edit.d.ts +18 -0
- package/dist/shared/jsonc-edit.d.ts.map +1 -0
- package/dist/shared/jsonc-parser.d.ts +11 -1
- package/dist/shared/jsonc-parser.d.ts.map +1 -1
- package/dist/shared/prompt-surface.d.ts +5 -3
- package/dist/shared/prompt-surface.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +16 -0
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/tui-preferences.d.ts +9 -0
- package/dist/shared/tui-preferences.d.ts.map +1 -1
- package/dist/{storage-meta-session-kh5egr11.js → storage-meta-session-df9tstzr.js} +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +41 -9
- package/src/config/schema/magic-context.test.ts +2 -1
- package/src/config/schema/magic-context.ts +3 -3
- package/src/shared/conflict-fixer.test.ts +174 -0
- package/src/shared/conflict-fixer.ts +66 -70
- package/src/shared/jsonc-edit.ts +295 -0
- package/src/shared/jsonc-parser.test.ts +33 -0
- package/src/shared/jsonc-parser.ts +54 -2
- package/src/shared/prompt-surface.test.ts +53 -2
- package/src/shared/prompt-surface.ts +20 -9
- package/src/shared/rpc-utils.test.ts +36 -0
- package/src/shared/rpc-utils.ts +71 -0
- package/src/shared/tui-preferences.test.ts +52 -2
- 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 {
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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[] = [];
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|